1.优化AI请求替换OpenAI SDK调用,使用httpx和自定义头请求,避免触发部分公益站的cloudflare

2.修复deepseek模型调用问题,舍弃思考过程AI响应内容,只获取结果内容
3.新增会话过期机制,更新后添加到.env中
4.支持用户在生成章节内容时设置字数
This commit is contained in:
xiamuceer
2025-11-03 15:28:51 +08:00
parent e02e61ed6b
commit 1cde345ed9
21 changed files with 1118 additions and 251 deletions
+1
View File
@@ -106,3 +106,4 @@ data/
docs/ docs/
data_old/ data_old/
backend/migrate_all_databases.py backend/migrate_all_databases.py
test_api.py
+7 -50
View File
@@ -38,7 +38,7 @@
- [✔] **自定义写作风格** - 支持自定义AI写作风格和语言风格 - [✔] **自定义写作风格** - 支持自定义AI写作风格和语言风格
- [ ] **支持数据导入导出** - 支持项目数据的导入和导出功能 - [ ] **支持数据导入导出** - 支持项目数据的导入和导出功能
- [ ] **添加prompt调整界面** - 提供可视化的prompt模板编辑和调整界面 - [ ] **添加prompt调整界面** - 提供可视化的prompt模板编辑和调整界面
- [ ] **开放章节内容字数限制** - 支持用户在生成章节内容时设置字数 @wyf007 - [✔] **开放章节内容字数限制** - 支持用户在生成章节内容时设置字数 @wyf007
- [ ] **设定追溯与矛盾检测** - 对大纲、世界观、角色档案中的设定支持悬停查看注释,显示相关章节来源和佐证原文;自动检测新章节与已有设定的矛盾(吃书),标记为"矛盾"设定并提供解决建议,当新设定解决矛盾后自动更新注释说明 @lulujiang - [ ] **设定追溯与矛盾检测** - 对大纲、世界观、角色档案中的设定支持悬停查看注释,显示相关章节来源和佐证原文;自动检测新章节与已有设定的矛盾(吃书),标记为"矛盾"设定并提供解决建议,当新设定解决矛盾后自动更新注释说明 @lulujiang
- [ ] **思维链与章节关系图谱** - 为每章建立思维链,总结与上文的逻辑关系、明暗线发展;可选的章节关系满图功能,自动识别和标注伏笔埋设与揭晓、角色出场与呼应等内在联系,帮助提升小说结构的紧密性和连贯性 @lulujiang - [ ] **思维链与章节关系图谱** - 为每章建立思维链,总结与上文的逻辑关系、明暗线发展;可选的章节关系满图功能,自动识别和标注伏笔埋设与揭晓、角色出场与呼应等内在联系,帮助提升小说结构的紧密性和连贯性 @lulujiang
@@ -209,40 +209,6 @@ networks:
访问地址:`http://your-server-ip:8800` 访问地址:`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` 配置后记得更新 `.env` 中的 `LINUXDO_REDIRECT_URI``FRONTEND_URL`
@@ -282,9 +248,6 @@ services:
OPENAI_API_KEY=your_openai_key_here OPENAI_API_KEY=your_openai_key_here
OPENAI_BASE_URL=https://api.openai.com/v1 OPENAI_BASE_URL=https://api.openai.com/v1
# Google Gemini 配置(推荐,免费额度大)
# GEMINI_API_KEY=your_gemini_key_here
# Anthropic 配置 # Anthropic 配置
# ANTHROPIC_API_KEY=your_anthropic_key_here # ANTHROPIC_API_KEY=your_anthropic_key_here
# ANTHROPIC_BASE_URL=https://api.anthropic.com # 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_API_KEY=your_newapi_key_here
# OPENAI_BASE_URL=https://api.new-api.com/v1 # 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 提供商和模型 # 默认 AI 提供商和模型
DEFAULT_AI_PROVIDER=openai DEFAULT_AI_PROVIDER=openai
DEFAULT_MODEL=gpt-4o-mini DEFAULT_MODEL=gpt-4o-mini
@@ -331,6 +282,12 @@ LOCAL_AUTH_USERNAME=admin
LOCAL_AUTH_PASSWORD=your_secure_password_here LOCAL_AUTH_PASSWORD=your_secure_password_here
LOCAL_AUTH_DISPLAY_NAME=管理员 LOCAL_AUTH_DISPLAY_NAME=管理员
# 会话配置
# 会话过期时间(分钟),默认120分钟(2小时)
SESSION_EXPIRE_MINUTES=120
# 会话刷新阈值(分钟),剩余时间少于此值时可刷新,默认30分钟
SESSION_REFRESH_THRESHOLD_MINUTES=30
# ===== CORS 配置(生产环境)===== # ===== CORS 配置(生产环境)=====
# CORS_ORIGINS=https://your-domain.com,https://www.your-domain.com # CORS_ORIGINS=https://your-domain.com,https://www.your-domain.com
``` ```
+6
View File
@@ -43,6 +43,12 @@ LOCAL_AUTH_PASSWORD=your_secure_password_here
# 本地用户显示名称 # 本地用户显示名称
LOCAL_AUTH_DISPLAY_NAME=管理员 LOCAL_AUTH_DISPLAY_NAME=管理员
# 会话配置
# 会话过期时间(分钟),默认120分钟(2小时)
SESSION_EXPIRE_MINUTES=120
# 会话刷新阈值(分钟),剩余时间少于此值时可刷新,默认30分钟
SESSION_REFRESH_THRESHOLD_MINUTES=30
# CORS配置(生产环境) # CORS配置(生产环境)
# 允许的跨域来源,多个用逗号分隔 # 允许的跨域来源,多个用逗号分隔
# CORS_ORIGINS=https://your-domain.com,https://www.your-domain.com # CORS_ORIGINS=https://your-domain.com,https://www.your-domain.com
+115 -5
View File
@@ -6,12 +6,20 @@ from fastapi.responses import RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
import hashlib import hashlib
from datetime import datetime, timedelta, timezone
from app.services.oauth_service import LinuxDOOAuthService from app.services.oauth_service import LinuxDOOAuthService
from app.user_manager import user_manager from app.user_manager import user_manager
from app.database import init_db from app.database import init_db
from app.logger import get_logger from app.logger import get_logger
from app.config import settings 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__) logger = get_logger(__name__)
router = APIRouter(prefix="/auth", tags=["认证"]) router = APIRouter(prefix="/auth", tags=["认证"])
@@ -84,15 +92,31 @@ async def local_login(request: LocalLoginRequest, response: Response):
except Exception as e: except Exception as e:
logger.error(f"本地用户 {user.user_id} 数据库初始化失败: {e}") logger.error(f"本地用户 {user.user_id} 数据库初始化失败: {e}")
# 设置 Cookie7天有效) # 设置 Cookie2小时有效)
max_age = settings.SESSION_EXPIRE_MINUTES * 60
response.set_cookie( response.set_cookie(
key="user_id", key="user_id",
value=user.user_id, value=user.user_id,
max_age=7 * 24 * 60 * 60, # 7天 max_age=max_age,
httponly=True, httponly=True,
samesite="lax" 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( return LocalLoginResponse(
success=True, success=True,
message="登录成功", message="登录成功",
@@ -180,15 +204,31 @@ async def _handle_callback(
logger.info(f"OAuth回调成功,重定向到前端: {redirect_url}") logger.info(f"OAuth回调成功,重定向到前端: {redirect_url}")
redirect_response = RedirectResponse(url=redirect_url) redirect_response = RedirectResponse(url=redirect_url)
# 设置 httponly Cookie7天有效) # 设置 httponly Cookie2小时有效)
max_age = settings.SESSION_EXPIRE_MINUTES * 60
redirect_response.set_cookie( redirect_response.set_cookie(
key="user_id", key="user_id",
value=user.user_id, value=user.user_id,
max_age=7 * 24 * 60 * 60, # 7天 max_age=max_age,
httponly=True, httponly=True,
samesite="lax" 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 return redirect_response
@@ -214,10 +254,80 @@ async def callback_alias(
return await _handle_callback(code, state, error, response) 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") @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("user_id")
response.delete_cookie("session_expire_at")
return {"message": "退出登录成功"} return {"message": "退出登录成功"}
+6 -2
View File
@@ -261,11 +261,13 @@ async def generate_chapter_content_stream(
请求体参数: 请求体参数:
- style_id: 可选,指定使用的写作风格ID。不提供则不使用任何风格 - style_id: 可选,指定使用的写作风格ID。不提供则不使用任何风格
- target_word_count: 可选,目标字数,默认3000字,范围500-10000字
注意:此函数不使用依赖注入的db,而是在生成器内部创建独立的数据库会话 注意:此函数不使用依赖注入的db,而是在生成器内部创建独立的数据库会话
以避免流式响应期间的连接泄漏问题 以避免流式响应期间的连接泄漏问题
""" """
style_id = generate_request.style_id style_id = generate_request.style_id
target_word_count = generate_request.target_word_count or 3000
# 预先验证章节存在性(使用临时会话) # 预先验证章节存在性(使用临时会话)
async for temp_db in get_db(request): async for temp_db in get_db(request):
try: try:
@@ -415,7 +417,8 @@ async def generate_chapter_content_stream(
chapter_number=current_chapter.chapter_number, chapter_number=current_chapter.chapter_number,
chapter_title=current_chapter.title, chapter_title=current_chapter.title,
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲', 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: else:
prompt = prompt_service.get_chapter_generation_prompt( prompt = prompt_service.get_chapter_generation_prompt(
@@ -432,7 +435,8 @@ async def generate_chapter_content_stream(
chapter_number=current_chapter.chapter_number, chapter_number=current_chapter.chapter_number,
chapter_title=current_chapter.title, chapter_title=current_chapter.title,
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲', 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}") logger.info(f"开始AI流式创作章节 {chapter_id}")
+165
View File
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from typing import Dict, Any, List from typing import Dict, Any, List
from pathlib import Path from pathlib import Path
from pydantic import BaseModel
import httpx import httpx
from app.database import get_db from app.database import get_db
@@ -297,3 +298,167 @@ async def get_available_models(
status_code=500, status_code=500,
detail=f"获取模型列表失败: {str(e)}" detail=f"获取模型列表失败: {str(e)}"
) )
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
}
+27 -40
View File
@@ -260,6 +260,7 @@ async def characters_generator(
# 重试逻辑 # 重试逻辑
retry_count = 0 retry_count = 0
batch_success = False batch_success = False
batch_error_message = ""
while retry_count < MAX_RETRIES and not batch_success: while retry_count < MAX_RETRIES and not batch_success:
try: try:
@@ -326,37 +327,24 @@ async def characters_generator(
if not isinstance(characters_data, list): if not isinstance(characters_data, list):
characters_data = [characters_data] characters_data = [characters_data]
# 验证生成数量是否精确 # 严格验证生成数量是否精确匹配
if len(characters_data) != current_batch_size: 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:
if retry_count < MAX_RETRIES - 1: retry_count += 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}")
yield await SSEResponse.send_progress( yield await SSEResponse.send_progress(
f"⚠️ AI生成过多,截取前{current_batch_size}个角色", f"⚠️ {error_msg},准备重试...",
batch_progress, batch_progress,
"warning" "warning"
) )
characters_data = characters_data[:current_batch_size] continue
else:
# 最后一次重试仍失败,直接返回错误
yield await SSEResponse.send_error(error_msg)
return
all_characters.extend(characters_data) all_characters.extend(characters_data)
batch_success = True batch_success = True
@@ -364,6 +352,7 @@ async def characters_generator(
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"批次{batch_idx+1}解析失败(尝试{retry_count+1}/{MAX_RETRIES}): {e}") logger.error(f"批次{batch_idx+1}解析失败(尝试{retry_count+1}/{MAX_RETRIES}): {e}")
batch_error_message = f"JSON解析失败: {str(e)}"
retry_count += 1 retry_count += 1
if retry_count < MAX_RETRIES: if retry_count < MAX_RETRIES:
yield await SSEResponse.send_progress( yield await SSEResponse.send_progress(
@@ -371,14 +360,9 @@ async def characters_generator(
batch_progress, batch_progress,
"warning" "warning"
) )
else:
yield await SSEResponse.send_progress(
f"批次{batch_idx+1}多次重试失败,跳过",
batch_progress,
"warning"
)
except Exception as e: except Exception as e:
logger.error(f"批次{batch_idx+1}生成异常(尝试{retry_count+1}/{MAX_RETRIES}): {e}") logger.error(f"批次{batch_idx+1}生成异常(尝试{retry_count+1}/{MAX_RETRIES}): {e}")
batch_error_message = f"生成异常: {str(e)}"
retry_count += 1 retry_count += 1
if retry_count < MAX_RETRIES: if retry_count < MAX_RETRIES:
yield await SSEResponse.send_progress( yield await SSEResponse.send_progress(
@@ -386,16 +370,15 @@ async def characters_generator(
batch_progress, batch_progress,
"warning" "warning"
) )
else:
yield await SSEResponse.send_progress(
f"批次{batch_idx+1}多次重试失败,跳过",
batch_progress,
"warning"
)
if not all_characters: # 检查批次是否成功
yield await SSEResponse.send_error("所有批次都生成失败,请重试") if not batch_success:
return 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) yield await SSEResponse.send_progress("验证角色数据...", 82)
@@ -665,6 +648,10 @@ async def characters_generator(
logger.info(f" - 创建角色关系:{relationships_created}") logger.info(f" - 创建角色关系:{relationships_created}")
logger.info(f" - 创建组织成员:{members_created}") logger.info(f" - 创建组织成员:{members_created}")
# 更新项目的角色数量
project.character_count = len(created_characters)
logger.info(f"✅ 更新项目角色数量: {project.character_count}")
await db.commit() await db.commit()
db_committed = True db_committed = True
+4
View File
@@ -78,6 +78,10 @@ class Settings(BaseSettings):
LOCAL_AUTH_PASSWORD: Optional[str] = None # 本地登录密码 LOCAL_AUTH_PASSWORD: Optional[str] = None # 本地登录密码
LOCAL_AUTH_DISPLAY_NAME: str = "本地用户" # 本地用户显示名称 LOCAL_AUTH_DISPLAY_NAME: str = "本地用户" # 本地用户显示名称
# 会话配置
SESSION_EXPIRE_MINUTES: int = 120 # 会话过期时间(分钟),默认2小时
SESSION_REFRESH_THRESHOLD_MINUTES: int = 30 # 会话刷新阈值(分钟),剩余时间少于此值时可刷新
class Config: class Config:
env_file = ".env" env_file = ".env"
case_sensitive = False case_sensitive = False
+6
View File
@@ -60,3 +60,9 @@ class ChapterListResponse(BaseModel):
class ChapterGenerateRequest(BaseModel): class ChapterGenerateRequest(BaseModel):
"""AI生成章节内容的请求模型""" """AI生成章节内容的请求模型"""
style_id: Optional[int] = Field(None, description="写作风格ID,不提供则不使用任何风格") style_id: Optional[int] = Field(None, description="写作风格ID,不提供则不使用任何风格")
target_word_count: Optional[int] = Field(
3000,
description="目标字数,默认3000字",
ge=500, # 最小500字
le=10000 # 最大10000字
)
+165 -96
View File
@@ -41,58 +41,7 @@ class AIService:
# 初始化OpenAI客户端 # 初始化OpenAI客户端
openai_key = api_key if api_provider == "openai" else app_settings.openai_api_key openai_key = api_key if api_provider == "openai" else app_settings.openai_api_key
if openai_key: if openai_key:
# 创建自定义的httpx客户端来避免proxies参数问题
try: 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( limits = httpx.Limits(
max_keepalive_connections=50, max_keepalive_connections=50,
max_connections=100, max_connections=100,
@@ -100,13 +49,56 @@ class AIService:
) )
http_client = httpx.AsyncClient( http_client = httpx.AsyncClient(
timeout=httpx.Timeout( timeout=httpx.Timeout(connect=60.0, read=180.0, write=60.0, pool=60.0),
connect=10.0, limits=limits,
read=180.0, headers={
write=10.0, "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"
pool=10.0 }
), )
limits=limits
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 = { client_kwargs = {
@@ -114,15 +106,12 @@ class AIService:
"http_client": http_client "http_client": http_client
} }
# 优先使用用户提供的base_url,否则使用全局配置
base_url = api_base_url if api_provider == "anthropic" else app_settings.anthropic_base_url base_url = api_base_url if api_provider == "anthropic" else app_settings.anthropic_base_url
if base_url: if base_url:
client_kwargs["base_url"] = base_url client_kwargs["base_url"] = base_url
self.anthropic_client = AsyncAnthropic(**client_kwargs) self.anthropic_client = AsyncAnthropic(**client_kwargs)
logger.info("✅ Anthropic客户端初始化成功") logger.info("✅ Anthropic客户端初始化成功")
logger.info(" - 超时设置:连接10s,读取180s")
logger.info(" - 连接池:50个保活连接,最大100个并发")
except Exception as e: except Exception as e:
logger.error(f"Anthropic客户端初始化失败: {e}") logger.error(f"Anthropic客户端初始化失败: {e}")
self.anthropic_client = None self.anthropic_client = None
@@ -219,7 +208,7 @@ class AIService:
system_prompt: Optional[str] system_prompt: Optional[str]
) -> str: ) -> str:
"""使用OpenAI生成文本""" """使用OpenAI生成文本"""
if not self.openai_client: if not self.openai_http_client:
raise ValueError("OpenAI客户端未初始化,请检查API key配置") raise ValueError("OpenAI客户端未初始化,请检查API key配置")
messages = [] messages = []
@@ -228,39 +217,76 @@ class AIService:
messages.append({"role": "user", "content": prompt}) messages.append({"role": "user", "content": prompt})
try: try:
logger.info(f"🔵 开始调用OpenAI API") logger.info(f"🔵 开始调用OpenAI API(直接HTTP请求)")
logger.info(f" - 模型: {model}") logger.info(f" - 模型: {model}")
logger.info(f" - 温度: {temperature}") logger.info(f" - 温度: {temperature}")
logger.info(f" - 最大tokens: {max_tokens}") logger.info(f" - 最大tokens: {max_tokens}")
logger.info(f" - Prompt长度: {len(prompt)} 字符") logger.info(f" - Prompt长度: {len(prompt)} 字符")
logger.info(f" - 消息数量: {len(messages)}") logger.info(f" - 消息数量: {len(messages)}")
response = await self.openai_client.chat.completions.create( url = f"{self.openai_base_url}/chat/completions"
model=model, headers = {
messages=messages, "Authorization": f"Bearer {self.openai_api_key}",
temperature=temperature, "Content-Type": "application/json"
max_tokens=max_tokens }
) 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"✅ OpenAI API调用成功")
logger.info(f" - 响应ID: {response.id if hasattr(response, 'id') else 'N/A'}") logger.info(f" - 响应ID: {data.get('id', 'N/A')}")
logger.info(f" - 选项数量: {len(response.choices)}") logger.info(f" - 选项数量: {len(data.get('choices', []))}")
if not response.choices: if not data.get('choices'):
logger.error("❌ OpenAI返回的choices为空") logger.error("❌ OpenAI返回的choices为空")
return "" raise ValueError("API返回的响应格式错误:choices字段为空")
content = response.choices[0].message.content choice = data['choices'][0]
logger.info(f" - 返回内容长度: {len(content) if content else 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: if content:
logger.info(f" - 返回内容长度: {len(content)} 字符")
logger.info(f" - 完成原因: {finish_reason}")
logger.info(f" - 返回内容预览(前200字符): {content[:200]}") logger.info(f" - 返回内容预览(前200字符): {content[:200]}")
return content return content
else: else:
logger.error("OpenAI返回了空内容") logger.error("❌ AI返回了空内容")
logger.error(f" - 完整响应: {response}") logger.error(f" - 完整响应: {data}")
raise ValueError("AI返回了空内容,请检查API配置或稍后重试") 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: except Exception as e:
logger.error(f"❌ OpenAI API调用失败") logger.error(f"❌ OpenAI API调用失败")
logger.error(f" - 错误类型: {type(e).__name__}") logger.error(f" - 错误类型: {type(e).__name__}")
@@ -277,7 +303,7 @@ class AIService:
system_prompt: Optional[str] system_prompt: Optional[str]
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
"""使用OpenAI流式生成文本""" """使用OpenAI流式生成文本"""
if not self.openai_client: if not self.openai_http_client:
raise ValueError("OpenAI客户端未初始化,请检查API key配置") raise ValueError("OpenAI客户端未初始化,请检查API key配置")
messages = [] messages = []
@@ -286,35 +312,78 @@ class AIService:
messages.append({"role": "user", "content": prompt}) messages.append({"role": "user", "content": prompt})
try: try:
logger.info(f"🔵 开始调用OpenAI流式API") logger.info(f"🔵 开始调用OpenAI流式API(直接HTTP请求)")
logger.info(f" - 模型: {model}") logger.info(f" - 模型: {model}")
logger.info(f" - Prompt长度: {len(prompt)} 字符") logger.info(f" - Prompt长度: {len(prompt)} 字符")
logger.info(f" - 最大tokens: {max_tokens}") logger.info(f" - 最大tokens: {max_tokens}")
stream = await self.openai_client.chat.completions.create( url = f"{self.openai_base_url}/chat/completions"
model=model, headers = {
messages=messages, "Authorization": f"Bearer {self.openai_api_key}",
temperature=temperature, "Content-Type": "application/json"
max_tokens=max_tokens, }
stream=True payload = {
) "model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True
}
logger.info(f"✅ OpenAI流式API连接成功,开始接收数据...") 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 chunk_count = 0
async for chunk in stream: has_content = False
if chunk.choices and len(chunk.choices) > 0: finish_reason = None
if chunk.choices[0].delta.content:
chunk_count += 1
yield chunk.choices[0].delta.content
logger.info(f"✅ OpenAI流式生成完成,共接收 {chunk_count} 个chunk") 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: except httpx.TimeoutException as e:
logger.error(f"❌ OpenAI流式API超时") logger.error(f"❌ OpenAI流式API超时")
logger.error(f" - 错误: {str(e)}") logger.error(f" - 错误: {str(e)}")
logger.error(f" - 提示: 请检查网络连接或考虑缩短prompt长度") logger.error(f" - 提示: 请检查网络连接或考虑缩短prompt长度")
raise TimeoutError(f"AI服务超时(180秒),请稍后重试或减少上下文长度") from e 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: except Exception as e:
logger.error(f"❌ OpenAI流式API调用失败: {str(e)}") logger.error(f"❌ OpenAI流式API调用失败: {str(e)}")
logger.error(f" - 错误类型: {type(e).__name__}") logger.error(f" - 错误类型: {type(e).__name__}")
@@ -389,7 +458,7 @@ class AIService:
raise raise
# 创建全局AI服务实例(使用环境变量配置,用于向后兼容) # 创建全局AI服务实例
ai_service = AIService() ai_service = AIService()
+16 -10
View File
@@ -460,7 +460,7 @@ class PromptService:
3. 符合角色性格设定 3. 符合角色性格设定
4. 体现世界观特色 4. 体现世界观特色
5. 使用{narrative_perspective}视角 5. 使用{narrative_perspective}视角
6. 字数不得低于3000 6. 字数要求:不得低于{target_word_count}
7. 语言自然流畅,避免AI痕迹 7. 语言自然流畅,避免AI痕迹
请直接输出章节正文内容,不要包含章节标题和其他说明文字。""" 请直接输出章节正文内容,不要包含章节标题和其他说明文字。"""
@@ -513,7 +513,7 @@ class PromptService:
4. **写作风格** 4. **写作风格**
- 使用{narrative_perspective}视角 - 使用{narrative_perspective}视角
- 字数不得低于3000 - 字数要求:不得低于{target_word_count}
- 语言自然流畅,避免AI痕迹 - 语言自然流畅,避免AI痕迹
- 体现世界观特色 - 体现世界观特色
@@ -741,16 +741,18 @@ class PromptService:
@classmethod @classmethod
def get_chapter_generation_prompt(cls, title: str, theme: str, genre: str, def get_chapter_generation_prompt(cls, title: str, theme: str, genre: str,
narrative_perspective: str, time_period: str, narrative_perspective: str, time_period: str,
location: str, atmosphere: str, rules: str, location: str, atmosphere: str, rules: str,
characters_info: str, outlines_context: str, characters_info: str, outlines_context: str,
chapter_number: int, chapter_title: str, chapter_number: int, chapter_title: str,
chapter_outline: str, style_content: str = "") -> str: chapter_outline: str, style_content: str = "",
target_word_count: int = 3000) -> str:
""" """
获取章节完整创作提示词 获取章节完整创作提示词
Args: Args:
style_content: 写作风格要求内容,如果提供则会追加到提示词中 style_content: 写作风格要求内容,如果提供则会追加到提示词中
target_word_count: 目标字数,默认3000字
""" """
base_prompt = cls.format_prompt( base_prompt = cls.format_prompt(
cls.CHAPTER_GENERATION, cls.CHAPTER_GENERATION,
@@ -766,7 +768,8 @@ class PromptService:
outlines_context=outlines_context, outlines_context=outlines_context,
chapter_number=chapter_number, chapter_number=chapter_number,
chapter_title=chapter_title, 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, characters_info: str, outlines_context: str,
previous_content: str, chapter_number: int, previous_content: str, chapter_number: int,
chapter_title: str, chapter_outline: str, chapter_title: str, chapter_outline: str,
style_content: str = "") -> str: style_content: str = "",
target_word_count: int = 3000) -> str:
""" """
获取章节完整创作提示词(带前置章节上下文) 获取章节完整创作提示词(带前置章节上下文)
Args: Args:
style_content: 写作风格要求内容,如果提供则会追加到提示词中 style_content: 写作风格要求内容,如果提供则会追加到提示词中
target_word_count: 目标字数,默认3000字
""" """
base_prompt = cls.format_prompt( base_prompt = cls.format_prompt(
cls.CHAPTER_GENERATION_WITH_CONTEXT, cls.CHAPTER_GENERATION_WITH_CONTEXT,
@@ -804,7 +809,8 @@ class PromptService:
previous_content=previous_content, previous_content=previous_content,
chapter_number=chapter_number, chapter_number=chapter_number,
chapter_title=chapter_title, chapter_title=chapter_title,
chapter_outline=chapter_outline chapter_outline=chapter_outline,
target_word_count=target_word_count
) )
# 如果有风格要求,应用到提示词中 # 如果有风格要求,应用到提示词中
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { Spin } from 'antd'; import { Spin } from 'antd';
import { authApi } from '../services/api'; import { authApi } from '../services/api';
import { sessionManager } from '../utils/sessionManager';
interface ProtectedRouteProps { interface ProtectedRouteProps {
children: ReactNode; children: ReactNode;
@@ -17,11 +18,19 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
try { try {
await authApi.getCurrentUser(); await authApi.getCurrentUser();
setIsAuthenticated(true); setIsAuthenticated(true);
// 启动会话管理器
sessionManager.start();
} catch { } catch {
setIsAuthenticated(false); setIsAuthenticated(false);
// 停止会话管理器
sessionManager.stop();
} }
}; };
checkAuth(); checkAuth();
return () => {
// 组件卸载时不停止会话管理器,让它在整个应用生命周期内运行
};
}, []); }, []);
if (isAuthenticated === null) { if (isAuthenticated === null) {
+2 -2
View File
@@ -292,7 +292,7 @@ export default function UserMenu() {
padding: 0, padding: 0,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
height: 'calc(100vh - 200px)', height: 'calc(100vh - 380px)',
} }
}} }}
> >
@@ -314,7 +314,7 @@ export default function UserMenu() {
rowKey="user_id" rowKey="user_id"
loading={loading} loading={loading}
pagination={false} pagination={false}
scroll={{ x: 800, y: 'calc(100vh - 340px)' }} scroll={{ x: 800, y: 'calc(100vh - 520px)' }}
sticky sticky
/> />
</div> </div>
+26 -3
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'; 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 { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined } from '@ant-design/icons';
import { useStore } from '../store'; import { useStore } from '../store';
import { useChapterSync } from '../store/hooks'; import { useChapterSync } from '../store/hooks';
@@ -22,6 +22,7 @@ export default function Chapters() {
const contentTextAreaRef = useRef<any>(null); const contentTextAreaRef = useRef<any>(null);
const [writingStyles, setWritingStyles] = useState<WritingStyle[]>([]); const [writingStyles, setWritingStyles] = useState<WritingStyle[]>([]);
const [selectedStyleId, setSelectedStyleId] = useState<number | undefined>(); const [selectedStyleId, setSelectedStyleId] = useState<number | undefined>();
const [targetWordCount, setTargetWordCount] = useState<number>(3000);
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
@@ -167,7 +168,7 @@ export default function Chapters() {
textArea.scrollTop = textArea.scrollHeight; textArea.scrollTop = textArea.scrollHeight;
} }
} }
}, selectedStyleId); }, selectedStyleId, targetWordCount);
message.success('AI创作成功'); message.success('AI创作成功');
} catch (error) { } catch (error) {
@@ -201,6 +202,7 @@ export default function Chapters() {
{selectedStyle && ( {selectedStyle && (
<li><strong>{selectedStyle.name}</strong></li> <li><strong>{selectedStyle.name}</strong></li>
)} )}
<li><strong>{targetWordCount}</strong></li>
</ul> </ul>
{previousChapters.length > 0 && ( {previousChapters.length > 0 && (
@@ -519,7 +521,7 @@ export default function Chapters() {
} : undefined} } : undefined}
styles={{ styles={{
body: { body: {
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(85vh - 110px)', maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(100vh - 110px)',
overflowY: 'auto', overflowY: 'auto',
padding: isMobile ? '16px 12px' : '8px' padding: isMobile ? '16px 12px' : '8px'
} }
@@ -592,6 +594,27 @@ export default function Chapters() {
)} )}
</Form.Item> </Form.Item>
<Form.Item
label="目标字数"
tooltip="AI生成章节时的目标字数,实际生成字数可能略有偏差"
>
<InputNumber
min={500}
max={10000}
step={100}
value={targetWordCount}
onChange={(value) => setTargetWordCount(value || 3000)}
size="large"
disabled={isGenerating}
style={{ width: '100%' }}
formatter={(value) => `${value}`}
parser={(value) => value?.replace(' 字', '') as any}
/>
<div style={{ color: '#666', fontSize: 12, marginTop: 4 }}>
500-100003000
</div>
</Form.Item>
<Form.Item label="章节内容" name="content"> <Form.Item label="章节内容" name="content">
<TextArea <TextArea
ref={contentTextAreaRef} ref={contentTextAreaRef}
+79 -4
View File
@@ -25,6 +25,7 @@ export default function ProjectWizardNew() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [characterForm] = Form.useForm(); const [characterForm] = Form.useForm();
const [worldForm] = Form.useForm(); const [worldForm] = Form.useForm();
const [generateForm] = Form.useForm();
const [current, setCurrent] = useState(0); const [current, setCurrent] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isResumingWizard, setIsResumingWizard] = useState(false); const [isResumingWizard, setIsResumingWizard] = useState(false);
@@ -814,11 +815,85 @@ export default function ProjectWizardNew() {
return ( return (
<Card> <Card>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Title level={isMobile ? 5 : 4} style={{ margin: 0, fontSize: isMobile ? 16 : undefined }}> <div style={{
(: {safeCharacters.length}: {requiredCharacterCount}) display: 'flex',
</Title> justifyContent: 'space-between',
alignItems: isMobile ? 'flex-start' : 'center',
marginBottom: 12,
flexDirection: isMobile ? 'column' : 'row',
gap: isMobile ? 12 : 0
}}>
<Title level={isMobile ? 5 : 4} style={{ margin: 0, fontSize: isMobile ? 16 : undefined }}>
(: {safeCharacters.length}: {requiredCharacterCount})
</Title>
<Button
type="dashed"
icon={<TeamOutlined />}
onClick={() => {
Modal.confirm({
title: 'AI生成角色',
width: 600,
centered: true,
content: (
<Form form={generateForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
label="角色名称"
name="name"
>
<Input placeholder="如:张三、李四(可选,AI会自动生成)" />
</Form.Item>
<Form.Item
label="角色定位"
name="role_type"
rules={[{ required: true, message: '请选择角色定位' }]}
>
<Select placeholder="选择角色定位">
<Select.Option value="protagonist"></Select.Option>
<Select.Option value="supporting"></Select.Option>
<Select.Option value="antagonist"></Select.Option>
</Select>
</Form.Item>
<Form.Item label="背景设定" name="background">
<TextArea rows={3} placeholder="简要描述角色背景和故事环境..." />
</Form.Item>
</Form>
),
okText: '生成',
cancelText: '取消',
onOk: async () => {
try {
const values = await generateForm.validateFields();
setLoading(true);
// 调用单个角色生成API
const newCharacter = await characterApi.generateCharacter({
project_id: projectId,
name: values.name,
role_type: values.role_type,
background: values.background,
});
// 添加到列表
setCharacters([...safeCharacters, newCharacter]);
message.success('AI生成角色成功');
generateForm.resetFields();
} catch (error) {
const apiError = error as ApiError;
message.error('AI生成失败:' + (apiError.response?.data?.detail || apiError.message || '未知错误'));
} finally {
setLoading(false);
}
}
});
}}
disabled={loading}
size={isMobile ? 'middle' : 'middle'}
>
AI生成角色
</Button>
</div>
<Paragraph type="secondary" style={{ margin: '8px 0 0 0', fontSize: isMobile ? 12 : 14 }}> <Paragraph type="secondary" style={{ margin: '8px 0 0 0', fontSize: isMobile ? 12 : 14 }}>
AI生成 AI生成使"AI生成角色"
</Paragraph> </Paragraph>
</div> </div>
+203 -25
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Card, Form, Input, Button, Select, Slider, InputNumber, message, Space, Typography, Spin, Modal, Tooltip, Alert, Grid } from 'antd'; import { Card, Form, Input, Button, Select, Slider, InputNumber, message, Space, Typography, Spin, Modal, Tooltip, Alert, Grid } from 'antd';
import { SettingOutlined, SaveOutlined, DeleteOutlined, ReloadOutlined, ArrowLeftOutlined, InfoCircleOutlined} from '@ant-design/icons'; import { SettingOutlined, SaveOutlined, DeleteOutlined, ReloadOutlined, ArrowLeftOutlined, InfoCircleOutlined, CheckCircleOutlined, CloseCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { settingsApi } from '../services/api'; import { settingsApi } from '../services/api';
import type { SettingsUpdate } from '../types'; import type { SettingsUpdate } from '../types';
@@ -21,6 +21,17 @@ export default function SettingsPage() {
const [modelOptions, setModelOptions] = useState<Array<{ value: string; label: string; description: string }>>([]); const [modelOptions, setModelOptions] = useState<Array<{ value: string; label: string; description: string }>>([]);
const [fetchingModels, setFetchingModels] = useState(false); const [fetchingModels, setFetchingModels] = useState(false);
const [modelsFetched, setModelsFetched] = useState(false); const [modelsFetched, setModelsFetched] = useState(false);
const [testingApi, setTestingApi] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
response_time_ms?: number;
response_preview?: string;
error?: string;
error_type?: string;
suggestions?: string[];
} | null>(null);
const [showTestResult, setShowTestResult] = useState(false);
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
@@ -178,6 +189,52 @@ export default function SettingsPage() {
} }
}; };
const handleTestConnection = async () => {
const apiKey = form.getFieldValue('api_key');
const apiBaseUrl = form.getFieldValue('api_base_url');
const provider = form.getFieldValue('api_provider');
const modelName = form.getFieldValue('model_name');
if (!apiKey || !apiBaseUrl || !provider || !modelName) {
message.warning('请先填写完整的配置信息');
return;
}
setTestingApi(true);
setTestResult(null);
try {
const result = await settingsApi.testApiConnection({
api_key: apiKey,
api_base_url: apiBaseUrl,
provider: provider,
model_name: modelName
});
setTestResult(result);
setShowTestResult(true);
if (result.success) {
message.success(`测试成功!响应时间: ${result.response_time_ms}ms`);
} else {
message.error('API 测试失败,请查看详细信息');
}
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || '测试请求失败';
message.error(errorMsg);
setTestResult({
success: false,
message: '测试请求失败',
error: errorMsg,
error_type: 'RequestError',
suggestions: ['请检查网络连接', '请确认后端服务是否正常运行']
});
setShowTestResult(true);
} finally {
setTestingApi(false);
}
};
return ( return (
<div style={{ <div style={{
minHeight: '100vh', minHeight: '100vh',
@@ -491,6 +548,94 @@ export default function SettingsPage() {
/> />
</Form.Item> </Form.Item>
{/* 测试结果展示 */}
{showTestResult && testResult && (
<Alert
message={
<Space>
{testResult.success ? (
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: isMobile ? '16px' : '18px' }} />
) : (
<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: isMobile ? '16px' : '18px' }} />
)}
<span style={{ fontSize: isMobile ? '14px' : '16px', fontWeight: 500 }}>
{testResult.message}
</span>
</Space>
}
description={
<div style={{ marginTop: 8 }}>
{testResult.success ? (
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{testResult.response_time_ms && (
<div style={{ fontSize: isMobile ? '12px' : '14px' }}>
: <strong>{testResult.response_time_ms} ms</strong>
</div>
)}
{testResult.response_preview && (
<div style={{
fontSize: isMobile ? '12px' : '13px',
padding: '8px 12px',
background: '#f6ffed',
borderRadius: '4px',
border: '1px solid #b7eb8f',
marginTop: '8px'
}}>
<div style={{ marginBottom: '4px', fontWeight: 500 }}>AI :</div>
<div style={{ color: '#595959' }}>{testResult.response_preview}</div>
</div>
)}
<div style={{ color: '#52c41a', fontSize: isMobile ? '12px' : '13px', marginTop: '4px' }}>
API 使
</div>
</Space>
) : (
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{testResult.error && (
<div style={{
fontSize: isMobile ? '12px' : '13px',
padding: '8px 12px',
background: '#fff2e8',
borderRadius: '4px',
border: '1px solid #ffbb96',
color: '#d4380d'
}}>
<strong>:</strong> {testResult.error}
</div>
)}
{testResult.error_type && (
<div style={{ fontSize: isMobile ? '11px' : '12px', color: '#8c8c8c' }}>
: {testResult.error_type}
</div>
)}
{testResult.suggestions && testResult.suggestions.length > 0 && (
<div style={{ marginTop: '8px' }}>
<div style={{ fontSize: isMobile ? '12px' : '13px', fontWeight: 500, marginBottom: '4px' }}>
💡 :
</div>
<ul style={{
margin: 0,
paddingLeft: isMobile ? '16px' : '20px',
fontSize: isMobile ? '12px' : '13px',
color: '#595959'
}}>
{testResult.suggestions.map((suggestion, index) => (
<li key={index} style={{ marginBottom: '4px' }}>{suggestion}</li>
))}
</ul>
</div>
)}
</Space>
)}
</div>
}
type={testResult.success ? 'success' : 'error'}
closable
onClose={() => setShowTestResult(false)}
style={{ marginBottom: isMobile ? 16 : 24 }}
/>
)}
{/* 操作按钮 */} {/* 操作按钮 */}
<Form.Item style={{ marginBottom: 0, marginTop: isMobile ? 24 : 32 }}> <Form.Item style={{ marginBottom: 0, marginTop: isMobile ? 24 : 32 }}>
{isMobile ? ( {isMobile ? (
@@ -535,9 +680,58 @@ export default function SettingsPage() {
</Space> </Space>
</Space> </Space>
) : ( ) : (
// 桌面端:原有的水平布局 // 桌面端:删除在左边,测试、重置和保存在右边
<Space size="middle" style={{ width: '100%', justifyContent: 'space-between' }}> <div style={{
<Space> display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '16px',
flexWrap: 'wrap'
}}>
{/* 左侧:删除按钮 */}
{hasSettings ? (
<Button
danger
size="large"
icon={<DeleteOutlined />}
onClick={handleDelete}
loading={loading}
style={{
minWidth: '100px'
}}
>
</Button>
) : (
<div /> // 占位符,保持右侧按钮位置
)}
{/* 右侧:测试、重置和保存按钮组 */}
<Space size="middle">
<Button
size="large"
icon={<ThunderboltOutlined />}
onClick={handleTestConnection}
loading={testingApi}
style={{
borderColor: '#52c41a',
color: '#52c41a',
fontWeight: 500,
minWidth: '100px'
}}
>
{testingApi ? '测试中...' : '测试'}
</Button>
<Button
size="large"
icon={<ReloadOutlined />}
onClick={handleReset}
style={{
minWidth: '100px'
}}
>
</Button>
<Button <Button
type="primary" type="primary"
size="large" size="large"
@@ -546,31 +740,15 @@ export default function SettingsPage() {
loading={loading} loading={loading}
style={{ style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none' border: 'none',
minWidth: '120px',
fontWeight: 500
}} }}
> >
</Button>
<Button
size="large"
icon={<ReloadOutlined />}
onClick={handleReset}
>
</Button> </Button>
</Space> </Space>
{hasSettings && ( </div>
<Button
danger
size="large"
icon={<DeleteOutlined />}
onClick={handleDelete}
loading={loading}
>
</Button>
)}
</Space>
)} )}
</Form.Item> </Form.Item>
</Form> </Form>
+6 -6
View File
@@ -328,9 +328,9 @@ export default function WritingStyles() {
createForm.resetFields(); createForm.resetFields();
}} }}
footer={null} footer={null}
centered={!isMobile} centered
width={isMobile ? '100%' : 600} width={isMobile ? 'calc(100vw - 32px)' : 600}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined} style={isMobile ? { maxWidth: 'calc(100vw - 32px)', margin: '0 16px' } : undefined}
> >
<Form <Form
form={createForm} form={createForm}
@@ -387,9 +387,9 @@ export default function WritingStyles() {
setEditingStyle(null); setEditingStyle(null);
}} }}
footer={null} footer={null}
centered={!isMobile} centered
width={isMobile ? '100%' : 600} width={isMobile ? 'calc(100vw - 32px)' : 600}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined} style={isMobile ? { maxWidth: 'calc(100vw - 32px)', margin: '0 16px' } : undefined}
> >
<Form form={editForm} layout="vertical" onFinish={handleUpdate} style={{ marginTop: 16 }}> <Form form={editForm} layout="vertical" onFinish={handleUpdate} style={{ marginTop: 16 }}>
<Form.Item <Form.Item
+16
View File
@@ -115,6 +115,8 @@ export const authApi = {
getCurrentUser: () => api.get<unknown, User>('/auth/user'), getCurrentUser: () => api.get<unknown, User>('/auth/user'),
refreshSession: () => api.post<unknown, { message: string; expire_at: number; remaining_minutes: number }>('/auth/refresh'),
logout: () => api.post('/auth/logout'), logout: () => api.post('/auth/logout'),
}; };
@@ -144,6 +146,20 @@ export const settingsApi = {
getAvailableModels: (params: { api_key: string; api_base_url: string; provider: string }) => getAvailableModels: (params: { api_key: string; api_base_url: string; provider: string }) =>
api.get<unknown, { provider: string; models: Array<{ value: string; label: string; description: string }>; count?: number }>('/settings/models', { params }), api.get<unknown, { provider: string; models: Array<{ value: string; label: string; description: string }>; count?: number }>('/settings/models', { params }),
testApiConnection: (params: { api_key: string; api_base_url: string; provider: string; model_name: string }) =>
api.post<unknown, {
success: boolean;
message: string;
response_time_ms?: number;
provider?: string;
model?: string;
response_preview?: string;
details?: Record<string, boolean>;
error?: string;
error_type?: string;
suggestions?: string[];
}>('/settings/test', params),
}; };
export const projectApi = { export const projectApi = {
+6 -2
View File
@@ -302,7 +302,8 @@ export function useChapterSync() {
const generateChapterContentStream = useCallback(async ( const generateChapterContentStream = useCallback(async (
chapterId: string, chapterId: string,
onProgress?: (content: string) => void, onProgress?: (content: string) => void,
styleId?: number styleId?: number,
targetWordCount?: number
) => { ) => {
try { try {
// 使用fetch处理流式响应 // 使用fetch处理流式响应
@@ -311,7 +312,10 @@ export function useChapterSync() {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(styleId ? { style_id: styleId } : {}), body: JSON.stringify({
style_id: styleId,
target_word_count: targetWordCount
}),
}); });
if (!response.ok) { if (!response.ok) {
+6
View File
@@ -224,6 +224,12 @@ export interface ChapterUpdate {
status?: 'draft' | 'writing' | 'completed'; status?: 'draft' | 'writing' | 'completed';
} }
// 章节生成请求类型
export interface ChapterGenerateRequest {
style_id?: number;
target_word_count?: number;
}
// 章节生成检查响应 // 章节生成检查响应
export interface ChapterCanGenerateResponse { export interface ChapterCanGenerateResponse {
can_generate: boolean; can_generate: boolean;
+241
View File
@@ -0,0 +1,241 @@
import { authApi } from '../services/api';
import { message } from 'antd';
/**
* 会话管理工具
* 负责监控会话状态、自动刷新和过期处理
*/
class SessionManager {
private checkInterval: number | null = null;
private activityTimeout: number | null = null;
private lastActivityTime: number = Date.now();
// 配置参数
private readonly CHECK_INTERVAL = 60 * 1000; // 每分钟检查一次
private readonly REFRESH_THRESHOLD = 30 * 60 * 1000; // 剩余30分钟时刷新
private readonly ACTIVITY_TIMEOUT = 30 * 60 * 1000; // 30分钟无活动则不自动刷新
private readonly WARNING_THRESHOLD = 5 * 60 * 1000; // 剩余5分钟时警告
private warningShown = false;
/**
* 启动会话监控
*/
start() {
// 先检查是否有有效的会话
const expireAt = this.getSessionExpireTime();
if (!expireAt) {
return;
}
const now = Date.now();
const remaining = expireAt - now;
const remainingMinutes = Math.floor(remaining / 60000);
// 如果会话已过期,不启动监控
if (remaining <= 0) {
return;
}
console.log(`✅ [会话] 启动监控,剩余 ${remainingMinutes} 分钟`);
// 立即检查一次
this.checkSession();
// 定期检查会话状态
this.checkInterval = setInterval(() => {
this.checkSession();
}, this.CHECK_INTERVAL);
// 监听用户活动
this.setupActivityListeners();
}
/**
* 停止会话监控
*/
stop() {
console.log('[SessionManager] 停止会话监控');
if (this.checkInterval) {
clearInterval(this.checkInterval);
this.checkInterval = null;
}
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
this.activityTimeout = null;
}
this.removeActivityListeners();
this.warningShown = false;
}
/**
* 检查会话状态
*/
private async checkSession() {
try {
const expireAt = this.getSessionExpireTime();
if (!expireAt) {
this.stop();
return;
}
const now = Date.now();
const remaining = expireAt - now;
const remainingMinutes = Math.floor(remaining / 60000);
// 会话已过期
if (remaining <= 0) {
console.log('⏰ [会话] 已过期,退出登录');
this.handleSessionExpired();
return;
}
// 显示即将过期警告
if (remaining <= this.WARNING_THRESHOLD && !this.warningShown) {
this.warningShown = true;
message.warning({
content: `您的登录状态将在 ${remainingMinutes} 分钟后过期,请注意保存数据`,
duration: 10,
});
}
// 需要刷新会话
if (remaining <= this.REFRESH_THRESHOLD) {
const timeSinceLastActivity = now - this.lastActivityTime;
// 检查用户是否活跃(30分钟内有活动)
if (timeSinceLastActivity < this.ACTIVITY_TIMEOUT) {
await this.refreshSession();
}
}
} catch (error) {
// 静默处理错误
}
}
/**
* 刷新会话
*/
private async refreshSession() {
try {
const result = await authApi.refreshSession();
this.warningShown = false; // 重置警告状态
console.log(`🔄 [会话] 自动续期成功,延长 ${result.remaining_minutes} 分钟`);
message.success({
content: '登录状态已自动延长',
duration: 2,
});
} catch {
// 刷新失败可能是会话已过期
this.handleSessionExpired();
}
}
/**
* 处理会话过期
*/
private async handleSessionExpired() {
this.stop();
const currentPath = window.location.pathname;
// 如果已经在登录页或回调页,不显示错误提示
if (currentPath === '/login' || currentPath === '/auth/callback') {
return;
}
// 调用登出接口清除服务器端的 Cookie
try {
await authApi.logout();
} catch {
// 即使登出失败也继续跳转
}
message.error({
content: '登录已过期,请重新登录',
duration: 3,
});
// 延迟跳转,让用户看到提示
setTimeout(() => {
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
}, 1000);
}
/**
* 获取会话过期时间(毫秒时间戳)
*/
private getSessionExpireTime(): number | null {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'session_expire_at') {
const timestamp = parseInt(value, 10);
return timestamp * 1000; // 转换为毫秒
}
}
return null;
}
/**
* 设置用户活动监听器
*/
private setupActivityListeners() {
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
document.addEventListener(event, this.handleUserActivity, { passive: true });
});
}
/**
* 移除用户活动监听器
*/
private removeActivityListeners() {
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
document.removeEventListener(event, this.handleUserActivity);
});
}
/**
* 处理用户活动
*/
private handleUserActivity = () => {
this.lastActivityTime = Date.now();
// 重置活动超时
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
}
this.activityTimeout = setTimeout(() => {
// 用户已超过30分钟无活动
}, this.ACTIVITY_TIMEOUT);
};
/**
* 手动刷新会话(供外部调用)
*/
async manualRefresh(): Promise<boolean> {
try {
await this.refreshSession();
return true;
} catch (error) {
return false;
}
}
}
// 导出单例
export const sessionManager = new SessionManager();