From 1cde345ed9b8aa8ec53a0904d04a683ff7d81e26 Mon Sep 17 00:00:00 2001 From: xiamuceer Date: Mon, 3 Nov 2025 15:28:51 +0800 Subject: [PATCH] =?UTF-8?q?1.=E4=BC=98=E5=8C=96AI=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2OpenAI=20SDK=E8=B0=83=E7=94=A8=EF=BC=8C?= =?UTF-8?q?=E4=BD=BF=E7=94=A8httpx=E5=92=8C=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E5=A4=B4=E8=AF=B7=E6=B1=82=EF=BC=8C=E9=81=BF=E5=85=8D=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E9=83=A8=E5=88=86=E5=85=AC=E7=9B=8A=E7=AB=99=E7=9A=84?= =?UTF-8?q?cloudflare=202.=E4=BF=AE=E5=A4=8Ddeepseek=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E9=97=AE=E9=A2=98=EF=BC=8C=E8=88=8D=E5=BC=83?= =?UTF-8?q?=E6=80=9D=E8=80=83=E8=BF=87=E7=A8=8BAI=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E5=86=85=E5=AE=B9=EF=BC=8C=E5=8F=AA=E8=8E=B7=E5=8F=96=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=86=85=E5=AE=B9=203.=E6=96=B0=E5=A2=9E=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E8=BF=87=E6=9C=9F=E6=9C=BA=E5=88=B6=EF=BC=8C=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=90=8E=E6=B7=BB=E5=8A=A0=E5=88=B0.env=E4=B8=AD=204.?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=94=A8=E6=88=B7=E5=9C=A8=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=AB=A0=E8=8A=82=E5=86=85=E5=AE=B9=E6=97=B6=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=AD=97=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- README.md | 57 +---- backend/.env.example | 6 + backend/app/api/auth.py | 120 +++++++++- backend/app/api/chapters.py | 8 +- backend/app/api/settings.py | 167 ++++++++++++- backend/app/api/wizard_stream.py | 69 +++--- backend/app/config.py | 4 + backend/app/schemas/chapter.py | 8 +- backend/app/services/ai_service.py | 265 +++++++++++++-------- backend/app/services/prompt_service.py | 26 +- frontend/src/components/ProtectedRoute.tsx | 9 + frontend/src/components/UserMenu.tsx | 4 +- frontend/src/pages/Chapters.tsx | 29 ++- frontend/src/pages/ProjectWizardNew.tsx | 83 ++++++- frontend/src/pages/Settings.tsx | 228 ++++++++++++++++-- frontend/src/pages/WritingStyles.tsx | 12 +- frontend/src/services/api.ts | 16 ++ frontend/src/store/hooks.ts | 8 +- frontend/src/types/index.ts | 6 + frontend/src/utils/sessionManager.ts | 241 +++++++++++++++++++ 21 files changed, 1118 insertions(+), 251 deletions(-) create mode 100644 frontend/src/utils/sessionManager.ts diff --git a/.gitignore b/.gitignore index aa7f9ff..a7ce302 100644 --- a/.gitignore +++ b/.gitignore @@ -105,4 +105,5 @@ dmypy.json data/ docs/ data_old/ -backend/migrate_all_databases.py \ No newline at end of file +backend/migrate_all_databases.py +test_api.py \ No newline at end of file diff --git a/README.md b/README.md index cc38fb1..32fa39b 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ - [✔] **自定义写作风格** - 支持自定义AI写作风格和语言风格 - [ ] **支持数据导入导出** - 支持项目数据的导入和导出功能 - [ ] **添加prompt调整界面** - 提供可视化的prompt模板编辑和调整界面 -- [ ] **开放章节内容字数限制** - 支持用户在生成章节内容时设置字数 @wyf007 +- [✔] **开放章节内容字数限制** - 支持用户在生成章节内容时设置字数 @wyf007 - [ ] **设定追溯与矛盾检测** - 对大纲、世界观、角色档案中的设定支持悬停查看注释,显示相关章节来源和佐证原文;自动检测新章节与已有设定的矛盾(吃书),标记为"矛盾"设定并提供解决建议,当新设定解决矛盾后自动更新注释说明 @lulujiang - [ ] **思维链与章节关系图谱** - 为每章建立思维链,总结与上文的逻辑关系、明暗线发展;可选的章节关系满图功能,自动识别和标注伏笔埋设与揭晓、角色出场与呼应等内在联系,帮助提升小说结构的紧密性和连贯性 @lulujiang @@ -209,40 +209,6 @@ networks: 访问地址:`http://your-server-ip:8800` -#### 4. 反向代理配置(Nginx) - -推荐使用 Nginx 配置 HTTPS: - -```nginx -server { - listen 80; - server_name your-domain.com; - return 301 https://$server_name$request_uri; -} - -server { - listen 443 ssl http2; - server_name your-domain.com; - - ssl_certificate /path/to/cert.pem; - ssl_certificate_key /path/to/key.pem; - - location / { - proxy_pass http://localhost:8800; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # 支持 SSE(服务器推送事件) - proxy_buffering off; - proxy_cache off; - proxy_set_header Connection ''; - proxy_http_version 1.1; - chunked_transfer_encoding off; - } -} -``` 配置后记得更新 `.env` 中的 `LINUXDO_REDIRECT_URI` 和 `FRONTEND_URL`。 @@ -282,9 +248,6 @@ services: OPENAI_API_KEY=your_openai_key_here OPENAI_BASE_URL=https://api.openai.com/v1 -# Google Gemini 配置(推荐,免费额度大) -# GEMINI_API_KEY=your_gemini_key_here - # Anthropic 配置 # ANTHROPIC_API_KEY=your_anthropic_key_here # ANTHROPIC_BASE_URL=https://api.anthropic.com @@ -294,18 +257,6 @@ OPENAI_BASE_URL=https://api.openai.com/v1 # OPENAI_API_KEY=your_newapi_key_here # OPENAI_BASE_URL=https://api.new-api.com/v1 -# API2D 中转服务 -# OPENAI_API_KEY=your_api2d_key_here -# OPENAI_BASE_URL=https://api.api2d.com/v1 - -# OpenAI-SB 中转服务 -# OPENAI_API_KEY=your_openai_sb_key_here -# OPENAI_BASE_URL=https://api.openai-sb.com/v1 - -# 其他支持 OpenAI 格式的中转服务 -# OPENAI_API_KEY=your_api_key_here -# OPENAI_BASE_URL=https://your-api-proxy.com/v1 - # 默认 AI 提供商和模型 DEFAULT_AI_PROVIDER=openai DEFAULT_MODEL=gpt-4o-mini @@ -331,6 +282,12 @@ LOCAL_AUTH_USERNAME=admin LOCAL_AUTH_PASSWORD=your_secure_password_here LOCAL_AUTH_DISPLAY_NAME=管理员 +# 会话配置 +# 会话过期时间(分钟),默认120分钟(2小时) +SESSION_EXPIRE_MINUTES=120 +# 会话刷新阈值(分钟),剩余时间少于此值时可刷新,默认30分钟 +SESSION_REFRESH_THRESHOLD_MINUTES=30 + # ===== CORS 配置(生产环境)===== # CORS_ORIGINS=https://your-domain.com,https://www.your-domain.com ``` diff --git a/backend/.env.example b/backend/.env.example index f66079f..ddcefe1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -43,6 +43,12 @@ LOCAL_AUTH_PASSWORD=your_secure_password_here # 本地用户显示名称 LOCAL_AUTH_DISPLAY_NAME=管理员 +# 会话配置 +# 会话过期时间(分钟),默认120分钟(2小时) +SESSION_EXPIRE_MINUTES=120 +# 会话刷新阈值(分钟),剩余时间少于此值时可刷新,默认30分钟 +SESSION_REFRESH_THRESHOLD_MINUTES=30 + # CORS配置(生产环境) # 允许的跨域来源,多个用逗号分隔 # CORS_ORIGINS=https://your-domain.com,https://www.your-domain.com \ No newline at end of file diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 767f936..ebec79f 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -6,12 +6,20 @@ from fastapi.responses import RedirectResponse from pydantic import BaseModel from typing import Optional import hashlib +from datetime import datetime, timedelta, timezone from app.services.oauth_service import LinuxDOOAuthService from app.user_manager import user_manager from app.database import init_db from app.logger import get_logger from app.config import settings +# 中国时区 UTC+8 +CHINA_TZ = timezone(timedelta(hours=8)) + +def get_china_now(): + """获取中国当前时间""" + return datetime.now(CHINA_TZ) + logger = get_logger(__name__) router = APIRouter(prefix="/auth", tags=["认证"]) @@ -84,15 +92,31 @@ async def local_login(request: LocalLoginRequest, response: Response): except Exception as e: logger.error(f"本地用户 {user.user_id} 数据库初始化失败: {e}") - # 设置 Cookie(7天有效) + # 设置 Cookie(2小时有效) + max_age = settings.SESSION_EXPIRE_MINUTES * 60 response.set_cookie( key="user_id", value=user.user_id, - max_age=7 * 24 * 60 * 60, # 7天 + max_age=max_age, httponly=True, samesite="lax" ) + # 设置过期时间戳 Cookie(用于前端判断) + china_now = get_china_now() + expire_time = china_now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES) + expire_at = int(expire_time.timestamp()) + + logger.info(f"✅ [登录] 用户 {user.user_id} 登录成功,会话有效期 {settings.SESSION_EXPIRE_MINUTES} 分钟") + + response.set_cookie( + key="session_expire_at", + value=str(expire_at), + max_age=max_age, + httponly=False, # 前端需要读取 + samesite="lax" + ) + return LocalLoginResponse( success=True, message="登录成功", @@ -180,15 +204,31 @@ async def _handle_callback( logger.info(f"OAuth回调成功,重定向到前端: {redirect_url}") redirect_response = RedirectResponse(url=redirect_url) - # 设置 httponly Cookie(7天有效) + # 设置 httponly Cookie(2小时有效) + max_age = settings.SESSION_EXPIRE_MINUTES * 60 redirect_response.set_cookie( key="user_id", value=user.user_id, - max_age=7 * 24 * 60 * 60, # 7天 + max_age=max_age, httponly=True, samesite="lax" ) + # 设置过期时间戳 Cookie(用于前端判断) + china_now = get_china_now() + expire_time = china_now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES) + expire_at = int(expire_time.timestamp()) + + logger.info(f"✅ [OAuth登录] 用户 {user.user_id} 登录成功,会话有效期 {settings.SESSION_EXPIRE_MINUTES} 分钟") + + redirect_response.set_cookie( + key="session_expire_at", + value=str(expire_at), + max_age=max_age, + httponly=False, # 前端需要读取 + samesite="lax" + ) + return redirect_response @@ -214,10 +254,80 @@ async def callback_alias( return await _handle_callback(code, state, error, response) +@router.post("/refresh") +async def refresh_session(request: Request, response: Response): + """刷新会话 - 延长登录状态""" + # 检查是否已登录 + if not hasattr(request.state, "user") or not request.state.user: + raise HTTPException(status_code=401, detail="未登录,无法刷新会话") + + user = request.state.user + + # 检查当前会话是否即将过期(剩余时间少于阈值) + session_expire_at = request.cookies.get("session_expire_at") + if session_expire_at: + try: + expire_timestamp = int(session_expire_at) + current_timestamp = int(get_china_now().timestamp()) + remaining_minutes = (expire_timestamp - current_timestamp) / 60 + + # 如果剩余时间大于刷新阈值,不需要刷新 + if remaining_minutes > settings.SESSION_REFRESH_THRESHOLD_MINUTES: + logger.info(f"⏱️ [刷新会话] 用户 {user.user_id} 会话仍有效,剩余 {int(remaining_minutes)} 分钟") + return { + "message": "会话仍然有效,无需刷新", + "remaining_minutes": int(remaining_minutes), + "expire_at": expire_timestamp + } + except (ValueError, TypeError): + pass # Cookie 格式错误,继续刷新 + + # 刷新 Cookie + max_age = settings.SESSION_EXPIRE_MINUTES * 60 + response.set_cookie( + key="user_id", + value=user.user_id, + max_age=max_age, + httponly=True, + samesite="lax" + ) + + # 更新过期时间戳 + china_now = get_china_now() + expire_time = china_now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES) + expire_at = int(expire_time.timestamp()) + + logger.info(f"[刷新会话] 用户: {user.user_id}") + logger.info(f"[刷新会话] 中国当前时间: {china_now.strftime('%Y-%m-%d %H:%M:%S')} (UTC+8)") + logger.info(f"[刷新会话] 中国过期时间: {expire_time.strftime('%Y-%m-%d %H:%M:%S')} (UTC+8)") + logger.info(f"[刷新会话] 过期时间戳 (秒): {expire_at}") + logger.info(f"[刷新会话] Cookie max_age (秒): {max_age}") + + response.set_cookie( + key="session_expire_at", + value=str(expire_at), + max_age=max_age, + httponly=False, + samesite="lax" + ) + + logger.info(f"用户 {user.user_id} 刷新会话成功") + return { + "message": "会话刷新成功", + "expire_at": expire_at, + "remaining_minutes": settings.SESSION_EXPIRE_MINUTES + } + + @router.post("/logout") -async def logout(response: Response): +async def logout(request: Request, response: Response): """退出登录""" + user_id = getattr(request.state, 'user_id', None) + if user_id: + logger.info(f"🚪 [退出] 用户 {user_id} 退出登录") + response.delete_cookie("user_id") + response.delete_cookie("session_expire_at") return {"message": "退出登录成功"} diff --git a/backend/app/api/chapters.py b/backend/app/api/chapters.py index fda9bf1..0bb5803 100644 --- a/backend/app/api/chapters.py +++ b/backend/app/api/chapters.py @@ -261,11 +261,13 @@ async def generate_chapter_content_stream( 请求体参数: - style_id: 可选,指定使用的写作风格ID。不提供则不使用任何风格 + - target_word_count: 可选,目标字数,默认3000字,范围500-10000字 注意:此函数不使用依赖注入的db,而是在生成器内部创建独立的数据库会话 以避免流式响应期间的连接泄漏问题 """ style_id = generate_request.style_id + target_word_count = generate_request.target_word_count or 3000 # 预先验证章节存在性(使用临时会话) async for temp_db in get_db(request): try: @@ -415,7 +417,8 @@ async def generate_chapter_content_stream( chapter_number=current_chapter.chapter_number, chapter_title=current_chapter.title, chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲', - style_content=style_content + style_content=style_content, + target_word_count=target_word_count ) else: prompt = prompt_service.get_chapter_generation_prompt( @@ -432,7 +435,8 @@ async def generate_chapter_content_stream( chapter_number=current_chapter.chapter_number, chapter_title=current_chapter.title, chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲', - style_content=style_content + style_content=style_content, + target_word_count=target_word_count ) logger.info(f"开始AI流式创作章节 {chapter_id}") diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 434466a..e5a6d20 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from typing import Dict, Any, List from pathlib import Path +from pydantic import BaseModel import httpx from app.database import get_db @@ -296,4 +297,168 @@ async def get_available_models( raise HTTPException( status_code=500, detail=f"获取模型列表失败: {str(e)}" - ) \ No newline at end of file + ) + + +class ApiTestRequest(BaseModel): + """API 测试请求模型""" + api_key: str + api_base_url: str + provider: str + model_name: str + + +@router.post("/test") +async def test_api_connection(data: ApiTestRequest): + """ + 测试 API 连接和配置是否正确 + + Args: + data: 包含 API 配置的请求数据 + + Returns: + 测试结果包含状态、响应时间和详细信息 + """ + api_key = data.api_key + api_base_url = data.api_base_url + provider = data.provider + model_name = data.model_name + import time + + try: + start_time = time.time() + + # 创建临时 AI 服务实例 + test_service = AIService( + api_provider=provider, + api_key=api_key, + api_base_url=api_base_url, + default_model=model_name, + default_temperature=0.7, + default_max_tokens=100 + ) + + # 发送简单的测试请求 + test_prompt = "请用一句话回复:测试成功" + + logger.info(f"🧪 开始测试 API 连接") + logger.info(f" - 提供商: {provider}") + logger.info(f" - 模型: {model_name}") + logger.info(f" - Base URL: {api_base_url}") + + response = await test_service.generate_text( + prompt=test_prompt, + provider=provider, + model=model_name, + temperature=0.7, + max_tokens=8000 + ) + + end_time = time.time() + response_time = round((end_time - start_time) * 1000, 2) # 转换为毫秒 + + logger.info(f"✅ API 测试成功") + logger.info(f" - 响应时间: {response_time}ms") + logger.info(f" - 响应内容: {response[:100] if response else 'N/A'}") + + return { + "success": True, + "message": "API 连接测试成功", + "response_time_ms": response_time, + "provider": provider, + "model": model_name, + "response_preview": response[:100] if response and len(response) > 100 else response, + "details": { + "api_available": True, + "model_accessible": True, + "response_valid": bool(response) + } + } + + except ValueError as e: + # 配置错误 + error_msg = str(e) + logger.error(f"❌ API 配置错误: {error_msg}") + return { + "success": False, + "message": "API 配置错误", + "error": error_msg, + "error_type": "ConfigurationError", + "suggestions": [ + "请检查 API Key 是否正确", + "请确认 API Base URL 格式正确", + "请验证所选提供商是否匹配" + ] + } + + except TimeoutError as e: + # 超时错误 + error_msg = str(e) + logger.error(f"❌ API 请求超时: {error_msg}") + return { + "success": False, + "message": "API 请求超时", + "error": error_msg, + "error_type": "TimeoutError", + "suggestions": [ + "请检查网络连接", + "请确认 API Base URL 是否可访问", + "如果使用代理,请检查代理设置" + ] + } + + except Exception as e: + # 其他错误 + error_msg = str(e) + error_type = type(e).__name__ + + logger.error(f"❌ API 测试失败: {error_msg}") + logger.error(f" - 错误类型: {error_type}") + + # 分析错误原因并提供建议 + suggestions = [] + if "blocked" in error_msg.lower(): + suggestions = [ + "请求被 API 提供商阻止", + "可能原因:API Key 被限制或地区限制", + "建议:检查 API Key 状态和账户余额", + "建议:尝试更换 API Base URL 或使用代理" + ] + elif "unauthorized" in error_msg.lower() or "401" in error_msg: + suggestions = [ + "API Key 认证失败", + "建议:检查 API Key 是否正确", + "建议:确认 API Key 是否过期" + ] + elif "not found" in error_msg.lower() or "404" in error_msg: + suggestions = [ + "API 端点不存在或模型不可用", + "建议:检查 API Base URL 是否正确", + "建议:确认模型名称是否正确" + ] + elif "rate limit" in error_msg.lower() or "429" in error_msg: + suggestions = [ + "API 请求频率超限", + "建议:稍后重试", + "建议:升级 API 套餐" + ] + elif "insufficient" in error_msg.lower() or "quota" in error_msg.lower(): + suggestions = [ + "API 配额不足", + "建议:检查账户余额", + "建议:充值或升级套餐" + ] + else: + suggestions = [ + "请检查所有配置参数是否正确", + "请确认网络连接正常", + "请查看详细错误信息" + ] + + return { + "success": False, + "message": "API 测试失败", + "error": error_msg, + "error_type": error_type, + "suggestions": suggestions + } \ No newline at end of file diff --git a/backend/app/api/wizard_stream.py b/backend/app/api/wizard_stream.py index 322f49f..f0bb9c8 100644 --- a/backend/app/api/wizard_stream.py +++ b/backend/app/api/wizard_stream.py @@ -260,6 +260,7 @@ async def characters_generator( # 重试逻辑 retry_count = 0 batch_success = False + batch_error_message = "" while retry_count < MAX_RETRIES and not batch_success: try: @@ -326,37 +327,24 @@ async def characters_generator( if not isinstance(characters_data, list): characters_data = [characters_data] - # 验证生成数量是否精确 + # 严格验证生成数量是否精确匹配 if len(characters_data) != current_batch_size: - logger.warning(f"批次{batch_idx+1}生成数量不匹配: 期望{current_batch_size}, 实际{len(characters_data)}") + error_msg = f"批次{batch_idx+1}生成数量不正确: 期望{current_batch_size}个, 实际{len(characters_data)}个" + logger.error(error_msg) - # 如果数量不足,重试 - if len(characters_data) < current_batch_size: - if retry_count < MAX_RETRIES - 1: - retry_count += 1 - yield await SSEResponse.send_progress( - f"⚠️ 生成数量不足(期望{current_batch_size},实际{len(characters_data)}),准备重试...", - batch_progress, - "warning" - ) - continue - else: - # 最后一次重试仍不足,记录但继续使用 - logger.warning(f"批次{batch_idx+1}多次重试后仍数量不足,使用当前结果") - yield await SSEResponse.send_progress( - f"⚠️ 批次{batch_idx+1}生成{len(characters_data)}个(期望{current_batch_size}),继续处理", - batch_progress, - "warning" - ) - # 如果数量过多,只取需要的数量并发出警告 - else: - logger.warning(f"批次{batch_idx+1}生成过多角色({len(characters_data)}>{current_batch_size}),将只取前{current_batch_size}个") + # 如果还有重试机会,继续重试 + if retry_count < MAX_RETRIES - 1: + retry_count += 1 yield await SSEResponse.send_progress( - f"⚠️ AI生成过多,截取前{current_batch_size}个角色", + f"⚠️ {error_msg},准备重试...", batch_progress, "warning" ) - characters_data = characters_data[:current_batch_size] + continue + else: + # 最后一次重试仍失败,直接返回错误 + yield await SSEResponse.send_error(error_msg) + return all_characters.extend(characters_data) batch_success = True @@ -364,6 +352,7 @@ async def characters_generator( except json.JSONDecodeError as e: logger.error(f"批次{batch_idx+1}解析失败(尝试{retry_count+1}/{MAX_RETRIES}): {e}") + batch_error_message = f"JSON解析失败: {str(e)}" retry_count += 1 if retry_count < MAX_RETRIES: yield await SSEResponse.send_progress( @@ -371,14 +360,9 @@ async def characters_generator( batch_progress, "warning" ) - else: - yield await SSEResponse.send_progress( - f"批次{batch_idx+1}多次重试失败,跳过", - batch_progress, - "warning" - ) except Exception as e: logger.error(f"批次{batch_idx+1}生成异常(尝试{retry_count+1}/{MAX_RETRIES}): {e}") + batch_error_message = f"生成异常: {str(e)}" retry_count += 1 if retry_count < MAX_RETRIES: yield await SSEResponse.send_progress( @@ -386,16 +370,15 @@ async def characters_generator( batch_progress, "warning" ) - else: - yield await SSEResponse.send_progress( - f"批次{batch_idx+1}多次重试失败,跳过", - batch_progress, - "warning" - ) - - if not all_characters: - yield await SSEResponse.send_error("所有批次都生成失败,请重试") - return + + # 检查批次是否成功 + if not batch_success: + error_msg = f"批次{batch_idx+1}在{MAX_RETRIES}次重试后仍然失败" + if batch_error_message: + error_msg += f": {batch_error_message}" + logger.error(error_msg) + yield await SSEResponse.send_error(error_msg) + return # 保存到数据库 - 分阶段处理以保证一致性 yield await SSEResponse.send_progress("验证角色数据...", 82) @@ -665,6 +648,10 @@ async def characters_generator( logger.info(f" - 创建角色关系:{relationships_created} 条") logger.info(f" - 创建组织成员:{members_created} 条") + # 更新项目的角色数量 + project.character_count = len(created_characters) + logger.info(f"✅ 更新项目角色数量: {project.character_count}") + await db.commit() db_committed = True diff --git a/backend/app/config.py b/backend/app/config.py index c15bbf2..2247417 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -78,6 +78,10 @@ class Settings(BaseSettings): LOCAL_AUTH_PASSWORD: Optional[str] = None # 本地登录密码 LOCAL_AUTH_DISPLAY_NAME: str = "本地用户" # 本地用户显示名称 + # 会话配置 + SESSION_EXPIRE_MINUTES: int = 120 # 会话过期时间(分钟),默认2小时 + SESSION_REFRESH_THRESHOLD_MINUTES: int = 30 # 会话刷新阈值(分钟),剩余时间少于此值时可刷新 + class Config: env_file = ".env" case_sensitive = False diff --git a/backend/app/schemas/chapter.py b/backend/app/schemas/chapter.py index 46f88e0..b82c369 100644 --- a/backend/app/schemas/chapter.py +++ b/backend/app/schemas/chapter.py @@ -59,4 +59,10 @@ class ChapterListResponse(BaseModel): class ChapterGenerateRequest(BaseModel): """AI生成章节内容的请求模型""" - style_id: Optional[int] = Field(None, description="写作风格ID,不提供则不使用任何风格") \ No newline at end of file + style_id: Optional[int] = Field(None, description="写作风格ID,不提供则不使用任何风格") + target_word_count: Optional[int] = Field( + 3000, + description="目标字数,默认3000字", + ge=500, # 最小500字 + le=10000 # 最大10000字 + ) \ No newline at end of file diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 5cfe16c..5fe53af 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -41,58 +41,7 @@ class AIService: # 初始化OpenAI客户端 openai_key = api_key if api_provider == "openai" else app_settings.openai_api_key if openai_key: - # 创建自定义的httpx客户端来避免proxies参数问题 try: - # 配置连接池限制,支持高并发 - # max_keepalive_connections: 保持活跃的连接数(提高复用率) - # max_connections: 最大并发连接数(防止资源耗尽) - limits = httpx.Limits( - max_keepalive_connections=50, # 保持50个活跃连接 - max_connections=100, # 最多100个并发连接 - keepalive_expiry=30.0 # 30秒后过期未使用的连接 - ) - - # 使用httpx.AsyncClient并设置超时和连接池 - # connect: 连接超时10秒 - # read: 读取超时180秒(3分钟,适合长文本生成) - # write: 写入超时10秒 - # pool: 连接池超时10秒 - http_client = httpx.AsyncClient( - timeout=httpx.Timeout( - connect=10.0, - read=180.0, - write=10.0, - pool=10.0 - ), - limits=limits - ) - - client_kwargs = { - "api_key": openai_key, - "http_client": http_client - } - - # 优先使用用户提供的base_url,否则使用全局配置 - base_url = api_base_url if api_provider == "openai" else app_settings.openai_base_url - if base_url: - client_kwargs["base_url"] = base_url - - self.openai_client = AsyncOpenAI(**client_kwargs) - logger.info("✅ OpenAI客户端初始化成功") - logger.info(" - 超时设置:连接10s,读取180s") - logger.info(" - 连接池:50个保活连接,最大100个并发") - except Exception as e: - logger.error(f"OpenAI客户端初始化失败: {e}") - self.openai_client = None - else: - self.openai_client = None - logger.warning("OpenAI API key未配置") - - # 初始化Anthropic客户端 - anthropic_key = api_key if api_provider == "anthropic" else app_settings.anthropic_api_key - if anthropic_key: - try: - # 为Anthropic设置相同的超时和连接池配置 limits = httpx.Limits( max_keepalive_connections=50, max_connections=100, @@ -100,13 +49,56 @@ class AIService: ) http_client = httpx.AsyncClient( - timeout=httpx.Timeout( - connect=10.0, - read=180.0, - write=10.0, - pool=10.0 - ), - limits=limits + timeout=httpx.Timeout(connect=60.0, read=180.0, write=60.0, pool=60.0), + limits=limits, + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + } + ) + + client_kwargs = { + "api_key": openai_key, + "http_client": http_client + } + + base_url = api_base_url if api_provider == "openai" else app_settings.openai_base_url + if base_url: + client_kwargs["base_url"] = base_url + + self.openai_client = AsyncOpenAI(**client_kwargs) + self.openai_http_client = http_client + self.openai_api_key = openai_key + self.openai_base_url = base_url + logger.info("✅ OpenAI客户端初始化成功") + except Exception as e: + logger.error(f"OpenAI客户端初始化失败: {e}") + self.openai_client = None + self.openai_http_client = None + self.openai_api_key = None + self.openai_base_url = None + else: + self.openai_client = None + self.openai_http_client = None + self.openai_api_key = None + self.openai_base_url = None + logger.warning("OpenAI API key未配置") + + # 初始化Anthropic客户端 + anthropic_key = api_key if api_provider == "anthropic" else app_settings.anthropic_api_key + if anthropic_key: + try: + limits = httpx.Limits( + max_keepalive_connections=50, + max_connections=100, + keepalive_expiry=30.0 + ) + + http_client = httpx.AsyncClient( + timeout=httpx.Timeout(connect=60.0, read=180.0, write=60.0, pool=60.0), + limits=limits, + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + } ) client_kwargs = { @@ -114,15 +106,12 @@ class AIService: "http_client": http_client } - # 优先使用用户提供的base_url,否则使用全局配置 base_url = api_base_url if api_provider == "anthropic" else app_settings.anthropic_base_url if base_url: client_kwargs["base_url"] = base_url self.anthropic_client = AsyncAnthropic(**client_kwargs) logger.info("✅ Anthropic客户端初始化成功") - logger.info(" - 超时设置:连接10s,读取180s") - logger.info(" - 连接池:50个保活连接,最大100个并发") except Exception as e: logger.error(f"Anthropic客户端初始化失败: {e}") self.anthropic_client = None @@ -219,7 +208,7 @@ class AIService: system_prompt: Optional[str] ) -> str: """使用OpenAI生成文本""" - if not self.openai_client: + if not self.openai_http_client: raise ValueError("OpenAI客户端未初始化,请检查API key配置") messages = [] @@ -228,39 +217,76 @@ class AIService: messages.append({"role": "user", "content": prompt}) try: - logger.info(f"🔵 开始调用OpenAI API") + logger.info(f"🔵 开始调用OpenAI API(直接HTTP请求)") logger.info(f" - 模型: {model}") logger.info(f" - 温度: {temperature}") logger.info(f" - 最大tokens: {max_tokens}") logger.info(f" - Prompt长度: {len(prompt)} 字符") logger.info(f" - 消息数量: {len(messages)}") - response = await self.openai_client.chat.completions.create( - model=model, - messages=messages, - temperature=temperature, - max_tokens=max_tokens - ) + url = f"{self.openai_base_url}/chat/completions" + headers = { + "Authorization": f"Bearer {self.openai_api_key}", + "Content-Type": "application/json" + } + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens + } + + logger.debug(f" - 请求URL: {url}") + logger.debug(f" - 请求头: Authorization=Bearer ***") + + response = await self.openai_http_client.post(url, headers=headers, json=payload) + response.raise_for_status() + + data = response.json() logger.info(f"✅ OpenAI API调用成功") - logger.info(f" - 响应ID: {response.id if hasattr(response, 'id') else 'N/A'}") - logger.info(f" - 选项数量: {len(response.choices)}") + logger.info(f" - 响应ID: {data.get('id', 'N/A')}") + logger.info(f" - 选项数量: {len(data.get('choices', []))}") - if not response.choices: + if not data.get('choices'): logger.error("❌ OpenAI返回的choices为空") - return "" + raise ValueError("API返回的响应格式错误:choices字段为空") - content = response.choices[0].message.content - logger.info(f" - 返回内容长度: {len(content) if content else 0} 字符") + choice = data['choices'][0] + message = choice.get('message', {}) + finish_reason = choice.get('finish_reason') + + # DeepSeek R1特殊处理:只使用content(最终答案),忽略reasoning_content(思考过程) + # reasoning_content是AI的思考过程,不是我们需要的JSON结果 + content = message.get('content', '') + + # 检查是否因达到长度限制而截断 + if finish_reason == 'length': + logger.warning(f"⚠️ 响应因达到max_tokens限制而被截断") + logger.warning(f" - 当前max_tokens: {max_tokens}") + logger.warning(f" - 建议: 增加max_tokens参数(推荐2000+)") if content: + logger.info(f" - 返回内容长度: {len(content)} 字符") + logger.info(f" - 完成原因: {finish_reason}") logger.info(f" - 返回内容预览(前200字符): {content[:200]}") return content else: - logger.error("❌ OpenAI返回了空内容") - logger.error(f" - 完整响应: {response}") - raise ValueError("AI返回了空内容,请检查API配置或稍后重试") + logger.error("❌ AI返回了空内容") + logger.error(f" - 完整响应: {data}") + logger.error(f" - 完成原因: {finish_reason}") + + # 提供更详细的错误信息 + if finish_reason == 'length': + raise ValueError(f"AI响应被截断且无有效内容。请增加max_tokens参数(当前: {max_tokens},建议: 2000+)") + else: + raise ValueError(f"AI返回了空内容(finish_reason: {finish_reason}),请检查API配置或稍后重试") + except httpx.HTTPStatusError as e: + logger.error(f"❌ OpenAI API调用失败 (HTTP {e.response.status_code})") + logger.error(f" - 错误信息: {e.response.text}") + logger.error(f" - 模型: {model}") + raise Exception(f"API返回错误 ({e.response.status_code}): {e.response.text}") except Exception as e: logger.error(f"❌ OpenAI API调用失败") logger.error(f" - 错误类型: {type(e).__name__}") @@ -277,7 +303,7 @@ class AIService: system_prompt: Optional[str] ) -> AsyncGenerator[str, None]: """使用OpenAI流式生成文本""" - if not self.openai_client: + if not self.openai_http_client: raise ValueError("OpenAI客户端未初始化,请检查API key配置") messages = [] @@ -286,35 +312,78 @@ class AIService: messages.append({"role": "user", "content": prompt}) try: - logger.info(f"🔵 开始调用OpenAI流式API") + logger.info(f"🔵 开始调用OpenAI流式API(直接HTTP请求)") logger.info(f" - 模型: {model}") logger.info(f" - Prompt长度: {len(prompt)} 字符") logger.info(f" - 最大tokens: {max_tokens}") - stream = await self.openai_client.chat.completions.create( - model=model, - messages=messages, - temperature=temperature, - max_tokens=max_tokens, - stream=True - ) + url = f"{self.openai_base_url}/chat/completions" + headers = { + "Authorization": f"Bearer {self.openai_api_key}", + "Content-Type": "application/json" + } + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + "stream": True + } - logger.info(f"✅ OpenAI流式API连接成功,开始接收数据...") - - chunk_count = 0 - async for chunk in stream: - if chunk.choices and len(chunk.choices) > 0: - if chunk.choices[0].delta.content: - chunk_count += 1 - yield chunk.choices[0].delta.content - - logger.info(f"✅ OpenAI流式生成完成,共接收 {chunk_count} 个chunk") + async with self.openai_http_client.stream('POST', url, headers=headers, json=payload) as response: + response.raise_for_status() + logger.info(f"✅ OpenAI流式API连接成功,开始接收数据...") + + chunk_count = 0 + has_content = False + finish_reason = None + + async for line in response.aiter_lines(): + if line.startswith('data: '): + data_str = line[6:] + if data_str.strip() == '[DONE]': + break + + try: + import json + data = json.loads(data_str) + if 'choices' in data and len(data['choices']) > 0: + choice = data['choices'][0] + delta = choice.get('delta', {}) + finish_reason = choice.get('finish_reason') or finish_reason + + # DeepSeek R1特殊处理:只收集content(最终答案),忽略reasoning_content(思考过程) + # reasoning_content是AI的思考过程,不是我们需要的JSON结果 + content = delta.get('content', '') + + if content: + chunk_count += 1 + has_content = True + yield content + except json.JSONDecodeError: + continue + + # 检查是否因长度限制截断 + if finish_reason == 'length': + logger.warning(f"⚠️ 流式响应因达到max_tokens限制而被截断") + logger.warning(f" - 当前max_tokens: {max_tokens}") + logger.warning(f" - 建议: 增加max_tokens参数(推荐2000+)") + + if not has_content: + logger.warning(f"⚠️ 流式响应未返回任何内容") + logger.warning(f" - 完成原因: {finish_reason}") + + logger.info(f"✅ OpenAI流式生成完成,共接收 {chunk_count} 个chunk,完成原因: {finish_reason}") except httpx.TimeoutException as e: logger.error(f"❌ OpenAI流式API超时") logger.error(f" - 错误: {str(e)}") logger.error(f" - 提示: 请检查网络连接或考虑缩短prompt长度") raise TimeoutError(f"AI服务超时(180秒),请稍后重试或减少上下文长度") from e + except httpx.HTTPStatusError as e: + logger.error(f"❌ OpenAI流式API调用失败 (HTTP {e.response.status_code})") + logger.error(f" - 错误信息: {await e.response.aread()}") + raise except Exception as e: logger.error(f"❌ OpenAI流式API调用失败: {str(e)}") logger.error(f" - 错误类型: {type(e).__name__}") @@ -389,7 +458,7 @@ class AIService: raise -# 创建全局AI服务实例(使用环境变量配置,用于向后兼容) +# 创建全局AI服务实例 ai_service = AIService() diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py index d883d3a..be1b4a9 100644 --- a/backend/app/services/prompt_service.py +++ b/backend/app/services/prompt_service.py @@ -460,7 +460,7 @@ class PromptService: 3. 符合角色性格设定 4. 体现世界观特色 5. 使用{narrative_perspective}视角 -6. 字数不得低于3000字 +6. 字数要求:不得低于{target_word_count}字 7. 语言自然流畅,避免AI痕迹 请直接输出章节正文内容,不要包含章节标题和其他说明文字。""" @@ -513,7 +513,7 @@ class PromptService: 4. **写作风格**: - 使用{narrative_perspective}视角 -- 字数不得低于3000字 +- 字数要求:不得低于{target_word_count}字 - 语言自然流畅,避免AI痕迹 - 体现世界观特色 @@ -741,16 +741,18 @@ class PromptService: @classmethod def get_chapter_generation_prompt(cls, title: str, theme: str, genre: str, - narrative_perspective: str, time_period: str, - location: str, atmosphere: str, rules: str, - characters_info: str, outlines_context: str, - chapter_number: int, chapter_title: str, - chapter_outline: str, style_content: str = "") -> str: + narrative_perspective: str, time_period: str, + location: str, atmosphere: str, rules: str, + characters_info: str, outlines_context: str, + chapter_number: int, chapter_title: str, + chapter_outline: str, style_content: str = "", + target_word_count: int = 3000) -> str: """ 获取章节完整创作提示词 Args: style_content: 写作风格要求内容,如果提供则会追加到提示词中 + target_word_count: 目标字数,默认3000字 """ base_prompt = cls.format_prompt( cls.CHAPTER_GENERATION, @@ -766,7 +768,8 @@ class PromptService: outlines_context=outlines_context, chapter_number=chapter_number, chapter_title=chapter_title, - chapter_outline=chapter_outline + chapter_outline=chapter_outline, + target_word_count=target_word_count ) # 如果有风格要求,应用到提示词中 @@ -782,12 +785,14 @@ class PromptService: characters_info: str, outlines_context: str, previous_content: str, chapter_number: int, chapter_title: str, chapter_outline: str, - style_content: str = "") -> str: + style_content: str = "", + target_word_count: int = 3000) -> str: """ 获取章节完整创作提示词(带前置章节上下文) Args: style_content: 写作风格要求内容,如果提供则会追加到提示词中 + target_word_count: 目标字数,默认3000字 """ base_prompt = cls.format_prompt( cls.CHAPTER_GENERATION_WITH_CONTEXT, @@ -804,7 +809,8 @@ class PromptService: previous_content=previous_content, chapter_number=chapter_number, chapter_title=chapter_title, - chapter_outline=chapter_outline + chapter_outline=chapter_outline, + target_word_count=target_word_count ) # 如果有风格要求,应用到提示词中 diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index 407c1da..acff158 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { Navigate, useLocation } from 'react-router-dom'; import { Spin } from 'antd'; import { authApi } from '../services/api'; +import { sessionManager } from '../utils/sessionManager'; interface ProtectedRouteProps { children: ReactNode; @@ -17,11 +18,19 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { try { await authApi.getCurrentUser(); setIsAuthenticated(true); + // 启动会话管理器 + sessionManager.start(); } catch { setIsAuthenticated(false); + // 停止会话管理器 + sessionManager.stop(); } }; checkAuth(); + + return () => { + // 组件卸载时不停止会话管理器,让它在整个应用生命周期内运行 + }; }, []); if (isAuthenticated === null) { diff --git a/frontend/src/components/UserMenu.tsx b/frontend/src/components/UserMenu.tsx index 63f1b6e..757dace 100644 --- a/frontend/src/components/UserMenu.tsx +++ b/frontend/src/components/UserMenu.tsx @@ -292,7 +292,7 @@ export default function UserMenu() { padding: 0, display: 'flex', flexDirection: 'column', - height: 'calc(100vh - 200px)', + height: 'calc(100vh - 380px)', } }} > @@ -314,7 +314,7 @@ export default function UserMenu() { rowKey="user_id" loading={loading} pagination={false} - scroll={{ x: 800, y: 'calc(100vh - 340px)' }} + scroll={{ x: 800, y: 'calc(100vh - 520px)' }} sticky /> diff --git a/frontend/src/pages/Chapters.tsx b/frontend/src/pages/Chapters.tsx index c8418be..5dbb574 100644 --- a/frontend/src/pages/Chapters.tsx +++ b/frontend/src/pages/Chapters.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip } from 'antd'; +import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber } from 'antd'; import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { useChapterSync } from '../store/hooks'; @@ -22,6 +22,7 @@ export default function Chapters() { const contentTextAreaRef = useRef(null); const [writingStyles, setWritingStyles] = useState([]); const [selectedStyleId, setSelectedStyleId] = useState(); + const [targetWordCount, setTargetWordCount] = useState(3000); useEffect(() => { const handleResize = () => { @@ -167,7 +168,7 @@ export default function Chapters() { textArea.scrollTop = textArea.scrollHeight; } } - }, selectedStyleId); + }, selectedStyleId, targetWordCount); message.success('AI创作成功'); } catch (error) { @@ -201,6 +202,7 @@ export default function Chapters() { {selectedStyle && (
  • 写作风格:{selectedStyle.name}
  • )} +
  • 目标字数:{targetWordCount}字
  • {previousChapters.length > 0 && ( @@ -519,7 +521,7 @@ export default function Chapters() { } : undefined} styles={{ body: { - maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(85vh - 110px)', + maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(100vh - 110px)', overflowY: 'auto', padding: isMobile ? '16px 12px' : '8px' } @@ -592,6 +594,27 @@ export default function Chapters() { )} + + setTargetWordCount(value || 3000)} + size="large" + disabled={isGenerating} + style={{ width: '100%' }} + formatter={(value) => `${value} 字`} + parser={(value) => value?.replace(' 字', '') as any} + /> +
    + 建议范围:500-10000字,默认3000字 +
    +
    +