update:1.更新灵感模式
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
+2
-1
@@ -135,7 +135,7 @@ from app.api import (
|
|||||||
projects, outlines, characters, chapters,
|
projects, outlines, characters, chapters,
|
||||||
wizard_stream, relationships, organizations,
|
wizard_stream, relationships, organizations,
|
||||||
auth, users, settings, writing_styles, memories,
|
auth, users, settings, writing_styles, memories,
|
||||||
mcp_plugins, admin
|
mcp_plugins, admin, inspiration
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(auth.router, prefix="/api")
|
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(projects.router, prefix="/api")
|
||||||
app.include_router(wizard_stream.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(outlines.router, prefix="/api")
|
||||||
app.include_router(characters.router, prefix="/api")
|
app.include_router(characters.router, prefix="/api")
|
||||||
app.include_router(chapters.router, prefix="/api")
|
app.include_router(chapters.router, prefix="/api")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigProvider } from 'antd';
|
|||||||
import zhCN from 'antd/locale/zh_CN';
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
import ProjectList from './pages/ProjectList';
|
import ProjectList from './pages/ProjectList';
|
||||||
import ProjectWizardNew from './pages/ProjectWizardNew';
|
import ProjectWizardNew from './pages/ProjectWizardNew';
|
||||||
|
import Inspiration from './pages/Inspiration';
|
||||||
import ProjectDetail from './pages/ProjectDetail';
|
import ProjectDetail from './pages/ProjectDetail';
|
||||||
import WorldSetting from './pages/WorldSetting';
|
import WorldSetting from './pages/WorldSetting';
|
||||||
import Outline from './pages/Outline';
|
import Outline from './pages/Outline';
|
||||||
@@ -36,7 +37,9 @@ function App() {
|
|||||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
|
|
||||||
<Route path="/" element={<ProtectedRoute><ProjectList /></ProtectedRoute>} />
|
<Route path="/" element={<ProtectedRoute><ProjectList /></ProtectedRoute>} />
|
||||||
|
<Route path="/projects" element={<ProtectedRoute><ProjectList /></ProtectedRoute>} />
|
||||||
<Route path="/wizard" element={<ProtectedRoute><ProjectWizardNew /></ProtectedRoute>} />
|
<Route path="/wizard" element={<ProtectedRoute><ProjectWizardNew /></ProtectedRoute>} />
|
||||||
|
<Route path="/inspiration" element={<ProtectedRoute><Inspiration /></ProtectedRoute>} />
|
||||||
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||||
<Route path="/mcp-plugins" element={<ProtectedRoute><MCPPlugins /></ProtectedRoute>} />
|
<Route path="/mcp-plugins" element={<ProtectedRoute><MCPPlugins /></ProtectedRoute>} />
|
||||||
<Route path="/user-management" element={<ProtectedRoute><UserManagement /></ProtectedRoute>} />
|
<Route path="/user-management" element={<ProtectedRoute><UserManagement /></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -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<Step>('idea');
|
||||||
|
const [messages, setMessages] = useState<Message[]>([
|
||||||
|
{
|
||||||
|
type: 'ai',
|
||||||
|
content: '你好!我是你的AI创作助手。让我们一起创作一部精彩的小说吧!\n\n请告诉我,你想写一本什么样的小说?',
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 收集的数据
|
||||||
|
const [wizardData, setWizardData] = useState<Partial<WizardData>>({});
|
||||||
|
|
||||||
|
// 项目生成状态
|
||||||
|
const [projectId, setProjectId] = useState<string>('');
|
||||||
|
const [projectTitle, setProjectTitle] = useState<string>('');
|
||||||
|
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<HTMLDivElement>(null);
|
||||||
|
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 记录上次失败的请求参数,用于重试
|
||||||
|
const [lastFailedRequest, setLastFailedRequest] = useState<{
|
||||||
|
step: 'title' | 'description' | 'theme' | 'genre';
|
||||||
|
context: Partial<WizardData>;
|
||||||
|
} | 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<WizardData>) => {
|
||||||
|
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: <CheckCircleOutlined />, color: '#52c41a' };
|
||||||
|
if (step === 'processing') return { icon: <LoadingOutlined />, color: '#1890ff' };
|
||||||
|
if (step === 'error') return { icon: '✗', color: '#ff4d4f' };
|
||||||
|
return { icon: '○', color: '#d9d9d9' };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
|
||||||
|
<Title level={3} style={{ marginBottom: 32, color: '#fff' }}>
|
||||||
|
正在为《{projectTitle}》生成内容
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Card style={{ marginBottom: 24 }}>
|
||||||
|
<Progress
|
||||||
|
percent={progress}
|
||||||
|
status={progress === 100 ? 'success' : 'active'}
|
||||||
|
strokeColor={{
|
||||||
|
'0%': '#667eea',
|
||||||
|
'100%': '#764ba2',
|
||||||
|
}}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Paragraph style={{ fontSize: 16, marginBottom: 32, color: '#666' }}>
|
||||||
|
{progressMessage}
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<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 }) => {
|
||||||
|
const status = getStepStatus(step);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '12px 20px',
|
||||||
|
background: step === 'processing' ? '#f0f5ff' : '#fafafa',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${step === 'processing' ? '#d6e4ff' : '#f0f0f0'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: step === 'processing' ? 600 : 400 }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<span style={{ fontSize: 20, color: status.color }}>
|
||||||
|
{status.icon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Paragraph type="secondary" style={{ color: '#fff', opacity: 0.9 }}>
|
||||||
|
请耐心等待,AI正在为您精心创作...
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染完成页面
|
||||||
|
const renderComplete = () => (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
|
||||||
|
<Card>
|
||||||
|
<div style={{ fontSize: 72, color: '#52c41a', marginBottom: 24 }}>
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
<Title level={2} style={{ color: '#52c41a', marginBottom: 16 }}>
|
||||||
|
项目创建完成!
|
||||||
|
</Title>
|
||||||
|
<Paragraph style={{ fontSize: 16, marginTop: 24, marginBottom: 48 }}>
|
||||||
|
《{projectTitle}》已成功创建,包含完整的世界观、角色和开局大纲
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Space size={16}>
|
||||||
|
<Button size="large" onClick={() => navigate('/')}>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
icon={<RocketOutlined />}
|
||||||
|
onClick={() => navigate(`/project/${projectId}`)}
|
||||||
|
>
|
||||||
|
进入项目
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 渲染对话界面
|
||||||
|
const renderChat = () => (
|
||||||
|
<>
|
||||||
|
{/* 对话区域 */}
|
||||||
|
<Card
|
||||||
|
ref={chatContainerRef}
|
||||||
|
style={{
|
||||||
|
minHeight: 500,
|
||||||
|
maxHeight: 600,
|
||||||
|
overflowY: 'auto',
|
||||||
|
marginBottom: 16,
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.15)',
|
||||||
|
scrollBehavior: 'smooth'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
|
{messages.map((msg, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: msg.type === 'ai' ? 'flex-start' : 'flex-end',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
animation: 'fadeInUp 0.5s ease-out',
|
||||||
|
animationFillMode: 'both',
|
||||||
|
animationDelay: `${index * 0.1}s`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
maxWidth: '80%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 12,
|
||||||
|
background: msg.type === 'ai' ? '#f0f0f0' : '#1890ff',
|
||||||
|
color: msg.type === 'ai' ? '#000' : '#fff',
|
||||||
|
boxShadow: msg.type === 'ai'
|
||||||
|
? '0 2px 8px rgba(0,0,0,0.08)'
|
||||||
|
: '0 2px 8px rgba(24,144,255,0.3)',
|
||||||
|
}}>
|
||||||
|
<Paragraph
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
color: msg.type === 'ai' ? '#000' : '#fff',
|
||||||
|
whiteSpace: 'pre-wrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg.content}
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
{/* 选项卡片 */}
|
||||||
|
{msg.options && msg.options.length > 0 && (
|
||||||
|
<Space
|
||||||
|
direction="vertical"
|
||||||
|
style={{ width: '100%', marginTop: 12 }}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{msg.options.map((option, optIndex) => (
|
||||||
|
<Card
|
||||||
|
key={optIndex}
|
||||||
|
hoverable
|
||||||
|
size="small"
|
||||||
|
onClick={() => 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}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 多选确认按钮 */}
|
||||||
|
{msg.isMultiSelect && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
onClick={handleConfirmGenres}
|
||||||
|
disabled={selectedOptions.length === 0}
|
||||||
|
>
|
||||||
|
确认选择 ({selectedOptions.length})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: 20,
|
||||||
|
animation: 'fadeIn 0.3s ease-in'
|
||||||
|
}}>
|
||||||
|
<Spin tip="AI思考中..." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 滚动锚点 */}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 输入区域 */}
|
||||||
|
<Card style={{ boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}>
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<TextArea
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
currentStep === 'idea'
|
||||||
|
? '例如:我想写一本关于时间旅行的科幻小说...'
|
||||||
|
: '输入自定义内容,或点击上方选项卡片...'
|
||||||
|
}
|
||||||
|
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||||
|
onPressEnter={(e) => {
|
||||||
|
if (!e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
loading={loading}
|
||||||
|
style={{ height: 'auto' }}
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12, marginTop: 8, display: 'block' }}>
|
||||||
|
💡 提示:按 Enter 发送,Shift+Enter 换行
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
padding: '24px'
|
||||||
|
}}>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes floatIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px) scale(0.95);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: translateY(-5px) scale(1.02);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
<div style={{ maxWidth: 800, margin: '0 auto' }}>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={handleBack}
|
||||||
|
type="text"
|
||||||
|
style={{ color: '#fff' }}
|
||||||
|
>
|
||||||
|
返回项目列表
|
||||||
|
</Button>
|
||||||
|
<Title level={2} style={{ color: '#fff', textAlign: 'center', marginTop: 16 }}>
|
||||||
|
✨ 灵感模式
|
||||||
|
</Title>
|
||||||
|
<Text style={{ color: '#fff', display: 'block', textAlign: 'center' }}>
|
||||||
|
通过对话快速创建你的小说项目
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 根据当前步骤渲染不同内容 */}
|
||||||
|
{(currentStep === 'idea' || currentStep === 'title' || currentStep === 'description' ||
|
||||||
|
currentStep === 'theme' || currentStep === 'genre' || currentStep === 'perspective' ||
|
||||||
|
currentStep === 'confirm') && renderChat()}
|
||||||
|
{currentStep === 'generating' && renderGenerating()}
|
||||||
|
{currentStep === 'complete' && renderComplete()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Inspiration;
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge, Alert, Upload, Checkbox, Divider, Switch, Dropdown } from 'antd';
|
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge, Alert, Upload, Checkbox, Divider, Switch, Dropdown } from 'antd';
|
||||||
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined, UploadOutlined, DownloadOutlined, ApiOutlined, MoreOutlined } from '@ant-design/icons';
|
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined, UploadOutlined, DownloadOutlined, ApiOutlined, MoreOutlined, BulbOutlined } from '@ant-design/icons';
|
||||||
import { projectApi } from '../services/api';
|
import { projectApi } from '../services/api';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { useProjectSync } from '../store/hooks';
|
import { useProjectSync } from '../store/hooks';
|
||||||
@@ -310,6 +310,22 @@ export default function ProjectList() {
|
|||||||
// 移动端:按钮分两行显示
|
// 移动端:按钮分两行显示
|
||||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
<Space size={8} style={{ width: '100%' }}>
|
<Space size={8} style={{ width: '100%' }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="middle"
|
||||||
|
icon={<BulbOutlined />}
|
||||||
|
onClick={() => navigate('/inspiration')}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'linear-gradient(135deg, #ffd700 0%, #ff8c00 100%)',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: '0 2px 8px rgba(255, 215, 0, 0.4)',
|
||||||
|
color: '#fff'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
灵感模式
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="middle"
|
size="middle"
|
||||||
@@ -393,6 +409,21 @@ export default function ProjectList() {
|
|||||||
) : (
|
) : (
|
||||||
// PC端:优化后的布局 - 主要按钮 + 下拉菜单
|
// PC端:优化后的布局 - 主要按钮 + 下拉菜单
|
||||||
<Space size={12} style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<Space size={12} style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
icon={<BulbOutlined />}
|
||||||
|
onClick={() => navigate('/inspiration')}
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'linear-gradient(135deg, #ffd700 0%, #ff8c00 100%)',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: '0 2px 8px rgba(255, 215, 0, 0.4)',
|
||||||
|
color: '#fff'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
灵感模式
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="large"
|
||||||
@@ -599,6 +630,20 @@ export default function ProjectList() {
|
|||||||
<Text style={{ fontSize: 16, color: '#8c8c8c' }}>
|
<Text style={{ fontSize: 16, color: '#8c8c8c' }}>
|
||||||
还没有项目,开始创建你的第一个小说项目吧!
|
还没有项目,开始创建你的第一个小说项目吧!
|
||||||
</Text>
|
</Text>
|
||||||
|
<Space size={12}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
icon={<BulbOutlined />}
|
||||||
|
onClick={() => navigate('/inspiration')}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #ffd700 0%, #ff8c00 100%)',
|
||||||
|
border: 'none',
|
||||||
|
color: '#fff'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
灵感模式
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="large"
|
||||||
@@ -608,6 +653,7 @@ export default function ProjectList() {
|
|||||||
向导创建
|
向导创建
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
style={{ padding: '80px 0' }}
|
style={{ padding: '80px 0' }}
|
||||||
/>
|
/>
|
||||||
@@ -1024,6 +1070,7 @@ export default function ProjectList() {
|
|||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -406,6 +406,38 @@ export const polishApi = {
|
|||||||
polishBatch: (texts: string[]) =>
|
polishBatch: (texts: string[]) =>
|
||||||
api.post<unknown, { polished_texts: string[] }>('/polish/batch', { texts }),
|
api.post<unknown, { polished_texts: string[] }>('/polish/batch', { texts }),
|
||||||
};
|
};
|
||||||
|
export const inspirationApi = {
|
||||||
|
// 生成选项建议
|
||||||
|
generateOptions: (data: {
|
||||||
|
step: 'title' | 'description' | 'theme' | 'genre';
|
||||||
|
context: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
theme?: string;
|
||||||
|
};
|
||||||
|
}) =>
|
||||||
|
api.post<unknown, {
|
||||||
|
prompt?: string;
|
||||||
|
options: string[];
|
||||||
|
error?: string;
|
||||||
|
}>('/inspiration/generate-options', data),
|
||||||
|
|
||||||
|
// 智能补全缺失信息
|
||||||
|
quickGenerate: (data: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
theme?: string;
|
||||||
|
genre?: string | string[];
|
||||||
|
}) =>
|
||||||
|
api.post<unknown, {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
theme: string;
|
||||||
|
genre: string[];
|
||||||
|
narrative_perspective: string;
|
||||||
|
}>('/inspiration/quick-generate', data),
|
||||||
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user