update:1.更新根据分析建议重新生成章节内容
This commit is contained in:
+211
-17
@@ -9,6 +9,7 @@ import hashlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from app.services.oauth_service import LinuxDOOAuthService
|
||||
from app.user_manager import user_manager
|
||||
from app.user_password import password_manager
|
||||
from app.database import init_db
|
||||
from app.logger import get_logger
|
||||
from app.config import settings
|
||||
@@ -49,6 +50,25 @@ class LocalLoginResponse(BaseModel):
|
||||
user: Optional[dict] = None
|
||||
|
||||
|
||||
class SetPasswordRequest(BaseModel):
|
||||
"""设置密码请求"""
|
||||
password: str
|
||||
|
||||
|
||||
class SetPasswordResponse(BaseModel):
|
||||
"""设置密码响应"""
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class PasswordStatusResponse(BaseModel):
|
||||
"""密码状态响应"""
|
||||
has_password: bool
|
||||
has_custom_password: bool
|
||||
username: Optional[str] = None
|
||||
default_password: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_auth_config():
|
||||
"""获取认证配置信息"""
|
||||
@@ -60,30 +80,77 @@ async def get_auth_config():
|
||||
|
||||
@router.post("/local/login", response_model=LocalLoginResponse)
|
||||
async def local_login(request: LocalLoginRequest, response: Response):
|
||||
"""本地账户登录"""
|
||||
"""本地账户登录(支持.env配置的管理员账号和Linux DO授权后绑定的账号)"""
|
||||
# 检查是否启用本地登录
|
||||
if not settings.LOCAL_AUTH_ENABLED:
|
||||
raise HTTPException(status_code=403, detail="本地账户登录未启用")
|
||||
|
||||
# 检查是否配置了本地账户
|
||||
if not settings.LOCAL_AUTH_USERNAME or not settings.LOCAL_AUTH_PASSWORD:
|
||||
raise HTTPException(status_code=500, detail="本地账户未配置")
|
||||
logger.info(f"[本地登录] 尝试登录用户名: {request.username}")
|
||||
|
||||
# 验证用户名和密码
|
||||
if request.username != settings.LOCAL_AUTH_USERNAME or request.password != settings.LOCAL_AUTH_PASSWORD:
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
# 首先尝试查找 Linux DO 授权后绑定的账号
|
||||
all_users = await user_manager.get_all_users()
|
||||
target_user = None
|
||||
|
||||
# 生成本地用户ID(使用用户名的hash)
|
||||
user_id = f"local_{hashlib.md5(request.username.encode()).hexdigest()[:16]}"
|
||||
for user in all_users:
|
||||
# 同时检查 users 表的 username 和 user_passwords 表的 username
|
||||
password_username = await password_manager.get_username(user.user_id)
|
||||
if user.username == request.username or password_username == request.username:
|
||||
target_user = user
|
||||
logger.info(f"[本地登录] 找到 Linux DO 授权用户: {user.user_id}")
|
||||
break
|
||||
|
||||
# 创建或更新本地用户
|
||||
user = await user_manager.create_or_update_from_linuxdo(
|
||||
linuxdo_id=user_id,
|
||||
username=request.username,
|
||||
display_name=settings.LOCAL_AUTH_DISPLAY_NAME,
|
||||
avatar_url=None,
|
||||
trust_level=9 # 本地用户给予高信任级别
|
||||
)
|
||||
# 如果找到了 Linux DO 授权的用户
|
||||
if target_user:
|
||||
# 检查是否有密码
|
||||
if not await password_manager.has_password(target_user.user_id):
|
||||
logger.warning(f"[本地登录] 用户 {target_user.user_id} 没有设置密码")
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
# 验证密码
|
||||
if not await password_manager.verify_password(target_user.user_id, request.password):
|
||||
logger.warning(f"[本地登录] 用户 {target_user.user_id} 密码验证失败")
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
logger.info(f"[本地登录] Linux DO 授权用户 {target_user.user_id} 登录成功")
|
||||
user = target_user
|
||||
else:
|
||||
# 没有找到 Linux DO 用户,尝试 .env 配置的管理员账号
|
||||
logger.info(f"[本地登录] 未找到 Linux DO 用户,检查 .env 管理员账号")
|
||||
|
||||
# 检查是否配置了本地账户
|
||||
if not settings.LOCAL_AUTH_USERNAME or not settings.LOCAL_AUTH_PASSWORD:
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
# 生成本地用户ID(使用用户名的hash)
|
||||
user_id = f"local_{hashlib.md5(request.username.encode()).hexdigest()[:16]}"
|
||||
|
||||
# 检查用户是否存在
|
||||
user = await user_manager.get_user(user_id)
|
||||
|
||||
# 如果用户不存在,使用.env中的默认密码验证
|
||||
if not user:
|
||||
# 验证用户名和密码(使用.env配置)
|
||||
if request.username != settings.LOCAL_AUTH_USERNAME or request.password != settings.LOCAL_AUTH_PASSWORD:
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
# 创建本地用户
|
||||
user = await user_manager.create_or_update_from_linuxdo(
|
||||
linuxdo_id=user_id,
|
||||
username=request.username,
|
||||
display_name=settings.LOCAL_AUTH_DISPLAY_NAME,
|
||||
avatar_url=None,
|
||||
trust_level=9 # 本地用户给予高信任级别
|
||||
)
|
||||
|
||||
# 为新用户设置默认密码到数据库
|
||||
await password_manager.set_password(user.user_id, request.username, request.password)
|
||||
logger.info(f"[本地登录] 管理员用户 {user.user_id} 初始密码已设置到数据库")
|
||||
else:
|
||||
# 用户已存在,使用数据库中的密码验证
|
||||
if not await password_manager.verify_password(user.user_id, request.password):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
logger.info(f"[本地登录] 管理员用户 {user.user_id} 登录成功")
|
||||
|
||||
# 初始化用户数据库
|
||||
try:
|
||||
@@ -189,6 +256,11 @@ async def _handle_callback(
|
||||
trust_level=trust_level
|
||||
)
|
||||
|
||||
# 3.1. 自动绑定密码(如果还没有设置)
|
||||
if not await password_manager.has_password(user.user_id):
|
||||
default_password = await password_manager.set_password(user.user_id, username)
|
||||
logger.info(f"用户 {user.user_id} ({username}) 自动绑定默认密码: {default_password}")
|
||||
|
||||
# 3.5. 初始化用户数据库(如果是新用户)
|
||||
try:
|
||||
await init_db(user.user_id)
|
||||
@@ -338,3 +410,125 @@ async def get_current_user(request: Request):
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
|
||||
return request.state.user.dict()
|
||||
|
||||
|
||||
@router.get("/password/status", response_model=PasswordStatusResponse)
|
||||
async def get_password_status(request: Request):
|
||||
"""获取当前用户的密码状态"""
|
||||
if not hasattr(request.state, "user") or not request.state.user:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
|
||||
user = request.state.user
|
||||
has_password = await password_manager.has_password(user.user_id)
|
||||
has_custom = await password_manager.has_custom_password(user.user_id)
|
||||
username = await password_manager.get_username(user.user_id)
|
||||
|
||||
# 如果使用默认密码,返回默认密码供用户查看
|
||||
default_password = None
|
||||
if has_password and not has_custom:
|
||||
default_password = f"{user.username}@666"
|
||||
|
||||
return PasswordStatusResponse(
|
||||
has_password=has_password,
|
||||
has_custom_password=has_custom,
|
||||
username=username or user.username,
|
||||
default_password=default_password
|
||||
)
|
||||
|
||||
|
||||
@router.post("/password/set", response_model=SetPasswordResponse)
|
||||
async def set_user_password(request: Request, password_req: SetPasswordRequest):
|
||||
"""设置当前用户的密码"""
|
||||
if not hasattr(request.state, "user") or not request.state.user:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
|
||||
user = request.state.user
|
||||
|
||||
# 验证密码强度(至少6个字符)
|
||||
if len(password_req.password) < 6:
|
||||
raise HTTPException(status_code=400, detail="密码长度至少为6个字符")
|
||||
|
||||
# 设置密码
|
||||
await password_manager.set_password(user.user_id, user.username, password_req.password)
|
||||
logger.info(f"用户 {user.user_id} ({user.username}) 设置了自定义密码")
|
||||
|
||||
return SetPasswordResponse(
|
||||
success=True,
|
||||
message="密码设置成功"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bind/login", response_model=LocalLoginResponse)
|
||||
async def bind_account_login(request: LocalLoginRequest, response: Response):
|
||||
"""使用绑定的账号密码登录(LinuxDO授权后绑定的账号)"""
|
||||
# 查找用户
|
||||
all_users = await user_manager.get_all_users()
|
||||
target_user = None
|
||||
|
||||
logger.info(f"[绑定账号登录] 尝试登录用户名: {request.username}")
|
||||
logger.info(f"[绑定账号登录] 当前共有 {len(all_users)} 个用户")
|
||||
|
||||
for user in all_users:
|
||||
# 同时检查 users 表的 username 和 user_passwords 表的 username
|
||||
password_username = await password_manager.get_username(user.user_id)
|
||||
logger.info(f"[绑定账号登录] 检查用户 {user.user_id}: users.username={user.username}, passwords.username={password_username}")
|
||||
|
||||
if user.username == request.username or password_username == request.username:
|
||||
target_user = user
|
||||
logger.info(f"[绑定账号登录] 找到匹配用户: {user.user_id}")
|
||||
break
|
||||
|
||||
if not target_user:
|
||||
logger.warning(f"[绑定账号登录] 用户名 {request.username} 未找到")
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
# 检查是否有密码记录
|
||||
has_pwd = await password_manager.has_password(target_user.user_id)
|
||||
if not has_pwd:
|
||||
logger.warning(f"[绑定账号登录] 用户 {target_user.user_id} 没有设置密码")
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
# 验证密码
|
||||
is_valid = await password_manager.verify_password(target_user.user_id, request.password)
|
||||
logger.info(f"[绑定账号登录] 用户 {target_user.user_id} 密码验证结果: {is_valid}")
|
||||
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
# 初始化用户数据库
|
||||
try:
|
||||
await init_db(target_user.user_id)
|
||||
logger.info(f"绑定账号用户 {target_user.user_id} 数据库初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"绑定账号用户 {target_user.user_id} 数据库初始化失败: {e}")
|
||||
|
||||
# 设置 Cookie(2小时有效)
|
||||
max_age = settings.SESSION_EXPIRE_MINUTES * 60
|
||||
response.set_cookie(
|
||||
key="user_id",
|
||||
value=target_user.user_id,
|
||||
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"✅ [绑定账号登录] 用户 {target_user.user_id} ({request.username}) 登录成功,会话有效期 {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="登录成功",
|
||||
user=target_user.dict()
|
||||
)
|
||||
+296
-10
@@ -1,6 +1,5 @@
|
||||
"""章节管理API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Query, BackgroundTasks
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
import json
|
||||
@@ -19,6 +18,7 @@ from app.models.writing_style import WritingStyle
|
||||
from app.models.analysis_task import AnalysisTask
|
||||
from app.models.memory import PlotAnalysis, StoryMemory
|
||||
from app.models.batch_generation_task import BatchGenerationTask
|
||||
from app.models.regeneration_task import RegenerationTask
|
||||
from app.schemas.chapter import (
|
||||
ChapterCreate,
|
||||
ChapterUpdate,
|
||||
@@ -29,12 +29,19 @@ from app.schemas.chapter import (
|
||||
BatchGenerateResponse,
|
||||
BatchGenerateStatusResponse
|
||||
)
|
||||
from app.schemas.regeneration import (
|
||||
ChapterRegenerateRequest,
|
||||
RegenerationTaskResponse,
|
||||
RegenerationTaskStatus
|
||||
)
|
||||
from app.services.ai_service import AIService
|
||||
from app.services.prompt_service import prompt_service
|
||||
from app.services.plot_analyzer import PlotAnalyzer
|
||||
from app.services.memory_service import memory_service
|
||||
from app.services.chapter_regenerator import ChapterRegenerator
|
||||
from app.logger import get_logger
|
||||
from app.api.settings import get_user_ai_service
|
||||
from app.utils.sse_response import create_sse_response
|
||||
|
||||
router = APIRouter(prefix="/chapters", tags=["章节管理"])
|
||||
logger = get_logger(__name__)
|
||||
@@ -1284,15 +1291,7 @@ async def generate_chapter_content_stream(
|
||||
except:
|
||||
pass
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no"
|
||||
}
|
||||
)
|
||||
return create_sse_response(event_generator())
|
||||
|
||||
|
||||
@router.get("/{chapter_id}/analysis/status", summary="查询章节分析任务状态")
|
||||
@@ -2293,3 +2292,290 @@ async def generate_single_chapter_for_batch(
|
||||
await db_session.refresh(chapter)
|
||||
|
||||
logger.info(f"✅ 单章节生成完成: 第{chapter.chapter_number}章,共 {new_word_count} 字")
|
||||
|
||||
|
||||
|
||||
|
||||
# ==================== 章节重新生成相关API ====================
|
||||
|
||||
@router.post("/{chapter_id}/regenerate-stream", summary="流式重新生成章节内容")
|
||||
async def regenerate_chapter_stream(
|
||||
chapter_id: str,
|
||||
request: Request,
|
||||
regenerate_request: ChapterRegenerateRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user_ai_service: AIService = Depends(get_user_ai_service)
|
||||
):
|
||||
"""
|
||||
根据分析建议或自定义指令重新生成章节内容(流式返回)
|
||||
|
||||
工作流程:
|
||||
1. 验证章节和分析结果
|
||||
2. 创建重新生成任务
|
||||
3. 构建修改指令
|
||||
4. 流式生成新内容
|
||||
5. 保存为版本历史
|
||||
6. 可选自动应用
|
||||
"""
|
||||
user_id = getattr(request.state, 'user_id', None)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
|
||||
# 验证章节存在
|
||||
chapter_result = await db.execute(
|
||||
select(Chapter).where(Chapter.id == chapter_id)
|
||||
)
|
||||
chapter = chapter_result.scalar_one_or_none()
|
||||
|
||||
if not chapter:
|
||||
raise HTTPException(status_code=404, detail="章节不存在")
|
||||
|
||||
if not chapter.content or chapter.content.strip() == "":
|
||||
raise HTTPException(status_code=400, detail="章节内容为空,无法重新生成")
|
||||
|
||||
# 验证用户权限
|
||||
await verify_project_access(chapter.project_id, user_id, db)
|
||||
|
||||
# 获取分析结果(如果使用分析建议)
|
||||
analysis = None
|
||||
if regenerate_request.modification_source in ['analysis_suggestions', 'mixed']:
|
||||
analysis_result = await db.execute(
|
||||
select(PlotAnalysis)
|
||||
.where(PlotAnalysis.chapter_id == chapter_id)
|
||||
.order_by(PlotAnalysis.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
analysis = analysis_result.scalar_one_or_none()
|
||||
|
||||
if not analysis:
|
||||
raise HTTPException(status_code=404, detail="该章节暂无分析结果")
|
||||
|
||||
# 预先获取项目上下文数据
|
||||
async for temp_db in get_db(request):
|
||||
try:
|
||||
# 获取项目信息
|
||||
project_result = await temp_db.execute(
|
||||
select(Project).where(Project.id == chapter.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
|
||||
# 获取角色信息
|
||||
characters_result = await temp_db.execute(
|
||||
select(Character).where(Character.project_id == chapter.project_id)
|
||||
)
|
||||
characters = characters_result.scalars().all()
|
||||
|
||||
# 获取章节大纲
|
||||
outline_result = await temp_db.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == chapter.project_id)
|
||||
.where(Outline.order_index == chapter.chapter_number)
|
||||
)
|
||||
outline = outline_result.scalar_one_or_none()
|
||||
|
||||
# 构建项目上下文
|
||||
project_context = {
|
||||
'project_title': project.title if project else '未知',
|
||||
'genre': project.genre if project else '未设定',
|
||||
'theme': project.theme if project else '未设定',
|
||||
'narrative_perspective': project.narrative_perspective if project else '第三人称',
|
||||
'time_period': project.world_time_period if project else '未设定',
|
||||
'location': project.world_location if project else '未设定',
|
||||
'atmosphere': project.world_atmosphere if project else '未设定',
|
||||
'characters_info': "\n".join([
|
||||
f"- {c.name}({'组织' if c.is_organization else '角色'}, {c.role_type}): {c.personality[:100] if c.personality else ''}"
|
||||
for c in characters
|
||||
]) if characters else '暂无角色信息',
|
||||
'chapter_outline': outline.content if outline else chapter.summary or '暂无大纲',
|
||||
'previous_context': '' # 可以后续扩展添加前置章节上下文
|
||||
}
|
||||
finally:
|
||||
await temp_db.close()
|
||||
break
|
||||
|
||||
async def event_generator():
|
||||
"""流式生成事件生成器"""
|
||||
db_session = None
|
||||
db_committed = False
|
||||
|
||||
try:
|
||||
# 创建独立数据库会话
|
||||
async for db_session in get_db(request):
|
||||
# 发送开始事件
|
||||
yield f"data: {json.dumps({'type': 'start', 'message': '开始重新生成章节...'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 创建重新生成任务
|
||||
regen_task = RegenerationTask(
|
||||
chapter_id=chapter_id,
|
||||
analysis_id=analysis.id if analysis else None,
|
||||
user_id=user_id,
|
||||
project_id=chapter.project_id,
|
||||
modification_instructions="", # 稍后填充
|
||||
original_suggestions=analysis.suggestions if analysis else None,
|
||||
selected_suggestion_indices=regenerate_request.selected_suggestion_indices,
|
||||
custom_instructions=regenerate_request.custom_instructions,
|
||||
style_id=regenerate_request.style_id,
|
||||
target_word_count=regenerate_request.target_word_count,
|
||||
focus_areas=regenerate_request.focus_areas,
|
||||
preserve_elements=regenerate_request.preserve_elements.model_dump() if regenerate_request.preserve_elements else None,
|
||||
status='running',
|
||||
original_content=chapter.content,
|
||||
original_word_count=chapter.word_count or len(chapter.content),
|
||||
version_note=regenerate_request.version_note,
|
||||
started_at=datetime.now()
|
||||
)
|
||||
db_session.add(regen_task)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(regen_task)
|
||||
|
||||
task_id = regen_task.id
|
||||
logger.info(f"📝 创建重新生成任务: {task_id}")
|
||||
|
||||
yield f"data: {json.dumps({'type': 'task_created', 'task_id': task_id}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 初始化重新生成器
|
||||
regenerator = ChapterRegenerator(user_ai_service)
|
||||
|
||||
# 流式生成新内容
|
||||
full_content = ""
|
||||
async for event in regenerator.regenerate_with_feedback(
|
||||
chapter=chapter,
|
||||
analysis=analysis,
|
||||
regenerate_request=regenerate_request,
|
||||
project_context=project_context
|
||||
):
|
||||
# 处理不同类型的事件
|
||||
if event['type'] == 'chunk':
|
||||
# 内容块
|
||||
chunk = event['content']
|
||||
full_content += chunk
|
||||
yield f"data: {json.dumps({'type': 'chunk', 'content': chunk}, ensure_ascii=False)}\n\n"
|
||||
elif event['type'] == 'progress':
|
||||
# 进度更新
|
||||
progress_data = {
|
||||
'type': 'progress',
|
||||
'progress': event.get('progress', 0),
|
||||
'message': event.get('message', ''),
|
||||
'word_count': event.get('word_count', 0)
|
||||
}
|
||||
yield f"data: {json.dumps(progress_data, ensure_ascii=False)}\n\n"
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# 更新任务状态
|
||||
regen_task.status = 'completed'
|
||||
regen_task.regenerated_content = full_content
|
||||
regen_task.regenerated_word_count = len(full_content)
|
||||
regen_task.completed_at = datetime.now()
|
||||
|
||||
# 计算差异统计
|
||||
diff_stats = regenerator.calculate_content_diff(chapter.content, full_content)
|
||||
|
||||
await db_session.commit()
|
||||
db_committed = True
|
||||
|
||||
# 先发送结果数据
|
||||
result_data = {
|
||||
'type': 'result',
|
||||
'data': {
|
||||
'task_id': task_id,
|
||||
'word_count': len(full_content),
|
||||
'version_number': regen_task.version_number,
|
||||
'auto_applied': regenerate_request.auto_apply,
|
||||
'diff_stats': diff_stats
|
||||
}
|
||||
}
|
||||
yield f"data: {json.dumps(result_data, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 再发送完成事件
|
||||
completion_data = {
|
||||
'type': 'done',
|
||||
'message': '重新生成完成'
|
||||
}
|
||||
yield f"data: {json.dumps(completion_data, ensure_ascii=False)}\n\n"
|
||||
|
||||
logger.info(f"✅ 章节重新生成完成: {chapter_id}, 任务: {task_id}")
|
||||
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 重新生成失败: {str(e)}", exc_info=True)
|
||||
|
||||
# 更新任务状态为失败
|
||||
if db_session and not db_committed:
|
||||
try:
|
||||
task_result = await db_session.execute(
|
||||
select(RegenerationTask).where(RegenerationTask.chapter_id == chapter_id)
|
||||
.order_by(RegenerationTask.created_at.desc()).limit(1)
|
||||
)
|
||||
task = task_result.scalar_one_or_none()
|
||||
if task:
|
||||
task.status = 'failed'
|
||||
task.error_message = str(e)[:500]
|
||||
task.completed_at = datetime.now()
|
||||
await db_session.commit()
|
||||
except Exception as update_error:
|
||||
logger.error(f"更新任务失败状态失败: {str(update_error)}")
|
||||
|
||||
yield f"data: {json.dumps({'type': 'error', 'error': str(e)}, ensure_ascii=False)}\n\n"
|
||||
|
||||
finally:
|
||||
if db_session:
|
||||
try:
|
||||
if not db_committed and db_session.in_transaction():
|
||||
await db_session.rollback()
|
||||
await db_session.close()
|
||||
except Exception as close_error:
|
||||
logger.error(f"关闭数据库会话失败: {str(close_error)}")
|
||||
|
||||
return create_sse_response(event_generator())
|
||||
|
||||
|
||||
@router.get("/{chapter_id}/regeneration/tasks", summary="获取章节的重新生成任务列表")
|
||||
async def get_regeneration_tasks(
|
||||
chapter_id: str,
|
||||
request: Request,
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取指定章节的重新生成任务历史"""
|
||||
user_id = getattr(request.state, 'user_id', None)
|
||||
|
||||
# 验证章节存在和权限
|
||||
chapter_result = await db.execute(
|
||||
select(Chapter).where(Chapter.id == chapter_id)
|
||||
)
|
||||
chapter = chapter_result.scalar_one_or_none()
|
||||
if not chapter:
|
||||
raise HTTPException(status_code=404, detail="章节不存在")
|
||||
|
||||
await verify_project_access(chapter.project_id, user_id, db)
|
||||
|
||||
# 获取任务列表
|
||||
result = await db.execute(
|
||||
select(RegenerationTask)
|
||||
.where(RegenerationTask.chapter_id == chapter_id)
|
||||
.order_by(RegenerationTask.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
tasks = result.scalars().all()
|
||||
|
||||
return {
|
||||
"chapter_id": chapter_id,
|
||||
"total": len(tasks),
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": task.id,
|
||||
"status": task.status,
|
||||
"version_number": task.version_number,
|
||||
"version_note": task.version_note,
|
||||
"original_word_count": task.original_word_count,
|
||||
"regenerated_word_count": task.regenerated_word_count,
|
||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||
"completed_at": task.completed_at.isoformat() if task.completed_at else None
|
||||
}
|
||||
for task in tasks
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1135,386 +1135,3 @@ async def generate_outline_stream(
|
||||
"""
|
||||
return create_sse_response(outline_generator(data, db, user_ai_service))
|
||||
|
||||
|
||||
async def update_world_building_generator(
|
||||
project_id: str,
|
||||
data: Dict[str, Any],
|
||||
db: AsyncSession
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""更新世界观流式生成器"""
|
||||
db_committed = False
|
||||
try:
|
||||
yield await SSEResponse.send_progress("开始更新世界观...", 10)
|
||||
|
||||
# 获取项目
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
yield await SSEResponse.send_error("项目不存在", 404)
|
||||
return
|
||||
|
||||
yield await SSEResponse.send_progress("验证数据...", 30)
|
||||
|
||||
# 更新世界观字段
|
||||
if "time_period" in data:
|
||||
project.world_time_period = data["time_period"]
|
||||
if "location" in data:
|
||||
project.world_location = data["location"]
|
||||
if "atmosphere" in data:
|
||||
project.world_atmosphere = data["atmosphere"]
|
||||
if "rules" in data:
|
||||
project.world_rules = data["rules"]
|
||||
|
||||
yield await SSEResponse.send_progress("保存到数据库...", 70)
|
||||
|
||||
await db.commit()
|
||||
db_committed = True
|
||||
await db.refresh(project)
|
||||
|
||||
# 发送结果
|
||||
yield await SSEResponse.send_result({
|
||||
"project_id": project.id,
|
||||
"time_period": project.world_time_period,
|
||||
"location": project.world_location,
|
||||
"atmosphere": project.world_atmosphere,
|
||||
"rules": project.world_rules
|
||||
})
|
||||
|
||||
yield await SSEResponse.send_progress("完成!", 100, "success")
|
||||
yield await SSEResponse.send_done()
|
||||
|
||||
except GeneratorExit:
|
||||
logger.warning("更新世界观生成器被提前关闭")
|
||||
if not db_committed and db.in_transaction():
|
||||
await db.rollback()
|
||||
logger.info("更新世界观事务已回滚(GeneratorExit)")
|
||||
except Exception as e:
|
||||
logger.error(f"更新世界观失败: {str(e)}")
|
||||
if not db_committed and db.in_transaction():
|
||||
await db.rollback()
|
||||
logger.info("更新世界观事务已回滚(异常)")
|
||||
yield await SSEResponse.send_error(f"更新失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/world-building/{project_id}", summary="流式更新世界观")
|
||||
async def update_world_building_stream(
|
||||
project_id: str,
|
||||
data: Dict[str, Any],
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
使用SSE流式更新项目的世界观信息
|
||||
请求体格式:
|
||||
{
|
||||
"time_period": "时间背景",
|
||||
"location": "地理位置",
|
||||
"atmosphere": "氛围基调",
|
||||
"rules": "世界规则"
|
||||
}
|
||||
"""
|
||||
return create_sse_response(update_world_building_generator(project_id, data, db))
|
||||
|
||||
|
||||
async def regenerate_world_building_generator(
|
||||
project_id: str,
|
||||
data: Dict[str, Any],
|
||||
db: AsyncSession,
|
||||
user_ai_service: AIService
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""重新生成世界观流式生成器 - 支持MCP工具增强"""
|
||||
db_committed = False
|
||||
try:
|
||||
yield await SSEResponse.send_progress("开始重新生成世界观...", 10)
|
||||
|
||||
# 获取项目
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
yield await SSEResponse.send_error("项目不存在", 404)
|
||||
return
|
||||
|
||||
provider = data.get("provider")
|
||||
model = data.get("model")
|
||||
enable_mcp = data.get("enable_mcp", True) # 默认启用MCP
|
||||
user_id = data.get("user_id") # 从中间件注入
|
||||
|
||||
# 获取基础提示词
|
||||
yield await SSEResponse.send_progress("准备AI提示词...", 15)
|
||||
base_prompt = prompt_service.get_world_building_prompt(
|
||||
title=project.title,
|
||||
theme=project.theme or "",
|
||||
genre=project.genre or ""
|
||||
)
|
||||
|
||||
# MCP工具增强:收集参考资料
|
||||
reference_materials = ""
|
||||
if enable_mcp and user_id:
|
||||
try:
|
||||
yield await SSEResponse.send_progress("🔍 尝试使用MCP工具收集参考资料...", 18)
|
||||
|
||||
# 直接调用MCP增强的AI,内部会自动检查和加载工具
|
||||
# 构建资料收集提示词
|
||||
planning_prompt = f"""你正在为小说《{project.title}》重新设计世界观。
|
||||
|
||||
【小说信息】
|
||||
- 题材:{project.genre or '未设定'}
|
||||
- 主题:{project.theme or '未设定'}
|
||||
|
||||
【任务】
|
||||
请使用可用工具搜索相关背景资料,帮助构建更真实、更有深度的世界观设定。
|
||||
你可以查询:
|
||||
1. 历史背景(如果是历史题材)
|
||||
2. 地理环境和文化特征
|
||||
3. 相关领域的专业知识
|
||||
4. 类似作品的设定参考
|
||||
|
||||
请根据题材特点,有针对性地查询2-3个关键问题。"""
|
||||
|
||||
# 调用MCP增强的AI(非流式,最多2轮工具调用)
|
||||
planning_result = await user_ai_service.generate_text_with_mcp(
|
||||
prompt=planning_prompt,
|
||||
user_id=user_id,
|
||||
db_session=db,
|
||||
enable_mcp=True,
|
||||
max_tool_rounds=2,
|
||||
tool_choice="auto",
|
||||
provider=None,
|
||||
model=None
|
||||
)
|
||||
|
||||
# 提取参考资料
|
||||
if planning_result.get("tool_calls_made", 0) > 0:
|
||||
yield await SSEResponse.send_progress(
|
||||
f"✅ MCP工具调用成功({planning_result['tool_calls_made']}次)",
|
||||
25
|
||||
)
|
||||
reference_materials = planning_result.get("content", "")
|
||||
else:
|
||||
yield await SSEResponse.send_progress("ℹ️ 未使用MCP工具(无可用工具或不需要)", 25)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"MCP工具调用失败(降级处理): {e}")
|
||||
yield await SSEResponse.send_progress("⚠️ MCP工具暂时不可用,使用基础模式", 25)
|
||||
|
||||
# 构建增强提示词
|
||||
if reference_materials:
|
||||
enhanced_prompt = f"""{base_prompt}
|
||||
|
||||
【参考资料】
|
||||
以下是通过MCP工具收集的真实背景资料,请参考这些信息构建更真实的世界观:
|
||||
|
||||
{reference_materials}
|
||||
|
||||
请结合上述资料,生成符合历史/现实的世界观设定。"""
|
||||
final_prompt = enhanced_prompt
|
||||
yield await SSEResponse.send_progress("💡 已整合参考资料,开始重新生成世界观...", 30)
|
||||
else:
|
||||
final_prompt = base_prompt
|
||||
yield await SSEResponse.send_progress("正在调用AI生成...", 30)
|
||||
|
||||
# 流式生成世界观
|
||||
accumulated_text = ""
|
||||
chunk_count = 0
|
||||
|
||||
async for chunk in user_ai_service.generate_text_stream(
|
||||
prompt=final_prompt,
|
||||
provider=provider,
|
||||
model=model
|
||||
):
|
||||
chunk_count += 1
|
||||
accumulated_text += chunk
|
||||
|
||||
# 发送内容块
|
||||
yield await SSEResponse.send_chunk(chunk)
|
||||
|
||||
# 定期更新进度
|
||||
if chunk_count % 5 == 0:
|
||||
progress = min(30 + (chunk_count // 5), 70)
|
||||
yield await SSEResponse.send_progress(f"生成中... ({len(accumulated_text)}字符)", progress)
|
||||
|
||||
# 每20个块发送心跳
|
||||
if chunk_count % 20 == 0:
|
||||
yield await SSEResponse.send_heartbeat()
|
||||
|
||||
# 解析结果
|
||||
yield await SSEResponse.send_progress("解析AI返回结果...", 80)
|
||||
|
||||
world_data = {}
|
||||
try:
|
||||
cleaned_text = accumulated_text.strip()
|
||||
# 移除markdown代码块标记
|
||||
if cleaned_text.startswith('```json'):
|
||||
cleaned_text = cleaned_text[7:].lstrip('\n\r')
|
||||
elif cleaned_text.startswith('```'):
|
||||
cleaned_text = cleaned_text[3:].lstrip('\n\r')
|
||||
if cleaned_text.endswith('```'):
|
||||
cleaned_text = cleaned_text[:-3].rstrip('\n\r')
|
||||
cleaned_text = cleaned_text.strip()
|
||||
|
||||
world_data = json.loads(cleaned_text)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"AI返回非JSON格式: {e}")
|
||||
logger.info(world_data)
|
||||
world_data = {
|
||||
"time_period": "AI返回格式错误,请重试",
|
||||
"location": "AI返回格式错误,请重试",
|
||||
"atmosphere": "AI返回格式错误,请重试",
|
||||
"rules": "AI返回格式错误,请重试"
|
||||
}
|
||||
|
||||
# 更新项目世界观
|
||||
yield await SSEResponse.send_progress("保存到数据库...", 90)
|
||||
|
||||
project.world_time_period = world_data.get("time_period")
|
||||
project.world_location = world_data.get("location")
|
||||
project.world_atmosphere = world_data.get("atmosphere")
|
||||
project.world_rules = world_data.get("rules")
|
||||
|
||||
await db.commit()
|
||||
db_committed = True
|
||||
await db.refresh(project)
|
||||
|
||||
# 发送结果
|
||||
yield await SSEResponse.send_result({
|
||||
"project_id": project.id,
|
||||
"time_period": project.world_time_period,
|
||||
"location": project.world_location,
|
||||
"atmosphere": project.world_atmosphere,
|
||||
"rules": project.world_rules
|
||||
})
|
||||
|
||||
yield await SSEResponse.send_progress("完成!", 100, "success")
|
||||
yield await SSEResponse.send_done()
|
||||
|
||||
except GeneratorExit:
|
||||
logger.warning("重新生成世界观生成器被提前关闭")
|
||||
if not db_committed and db.in_transaction():
|
||||
await db.rollback()
|
||||
logger.info("重新生成世界观事务已回滚(GeneratorExit)")
|
||||
except Exception as e:
|
||||
logger.error(f"重新生成世界观失败: {str(e)}")
|
||||
if not db_committed and db.in_transaction():
|
||||
await db.rollback()
|
||||
logger.info("重新生成世界观事务已回滚(异常)")
|
||||
yield await SSEResponse.send_error(f"重新生成失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/world-building/{project_id}/regenerate", summary="流式重新生成世界观")
|
||||
async def regenerate_world_building_stream(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
data: Dict[str, Any],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user_ai_service: AIService = Depends(get_user_ai_service)
|
||||
):
|
||||
"""
|
||||
使用SSE流式重新生成项目的世界观
|
||||
请求体格式:
|
||||
{
|
||||
"provider": "AI提供商(可选)",
|
||||
"model": "模型名称(可选)"
|
||||
}
|
||||
"""
|
||||
# 从中间件注入user_id到data中
|
||||
if hasattr(request.state, 'user_id'):
|
||||
data['user_id'] = request.state.user_id
|
||||
|
||||
return create_sse_response(regenerate_world_building_generator(project_id, data, db, user_ai_service))
|
||||
|
||||
|
||||
async def cleanup_wizard_data_generator(
|
||||
project_id: str,
|
||||
db: AsyncSession
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""清理向导数据流式生成器"""
|
||||
db_committed = False
|
||||
try:
|
||||
yield await SSEResponse.send_progress("开始清理向导数据...", 10)
|
||||
|
||||
# 获取项目
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
yield await SSEResponse.send_error("项目不存在", 404)
|
||||
return
|
||||
|
||||
# 删除相关的角色
|
||||
yield await SSEResponse.send_progress("删除角色数据...", 30)
|
||||
characters = await db.execute(
|
||||
select(Character).where(Character.project_id == project_id)
|
||||
)
|
||||
char_count = 0
|
||||
for character in characters.scalars():
|
||||
await db.delete(character)
|
||||
char_count += 1
|
||||
|
||||
# 删除相关的大纲
|
||||
yield await SSEResponse.send_progress("删除大纲数据...", 50)
|
||||
outlines = await db.execute(
|
||||
select(Outline).where(Outline.project_id == project_id)
|
||||
)
|
||||
outline_count = 0
|
||||
for outline in outlines.scalars():
|
||||
await db.delete(outline)
|
||||
outline_count += 1
|
||||
|
||||
# 删除相关的章节
|
||||
yield await SSEResponse.send_progress("删除章节数据...", 70)
|
||||
chapters = await db.execute(
|
||||
select(Chapter).where(Chapter.project_id == project_id)
|
||||
)
|
||||
chapter_count = 0
|
||||
for chapter in chapters.scalars():
|
||||
await db.delete(chapter)
|
||||
chapter_count += 1
|
||||
|
||||
# 删除项目
|
||||
yield await SSEResponse.send_progress("删除项目...", 85)
|
||||
await db.delete(project)
|
||||
|
||||
yield await SSEResponse.send_progress("提交数据库更改...", 95)
|
||||
await db.commit()
|
||||
db_committed = True
|
||||
|
||||
# 发送结果
|
||||
yield await SSEResponse.send_result({
|
||||
"message": "项目及相关数据已清理",
|
||||
"deleted": {
|
||||
"characters": char_count,
|
||||
"outlines": outline_count,
|
||||
"chapters": chapter_count
|
||||
}
|
||||
})
|
||||
|
||||
yield await SSEResponse.send_progress("完成!", 100, "success")
|
||||
yield await SSEResponse.send_done()
|
||||
|
||||
except GeneratorExit:
|
||||
logger.warning("清理向导数据生成器被提前关闭")
|
||||
if not db_committed and db.in_transaction():
|
||||
await db.rollback()
|
||||
logger.info("清理向导数据事务已回滚(GeneratorExit)")
|
||||
except Exception as e:
|
||||
logger.error(f"清理数据失败: {str(e)}")
|
||||
if not db_committed and db.in_transaction():
|
||||
await db.rollback()
|
||||
logger.info("清理向导数据事务已回滚(异常)")
|
||||
yield await SSEResponse.send_error(f"清理失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/cleanup/{project_id}", summary="流式清理向导数据")
|
||||
async def cleanup_wizard_data_stream(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
使用SSE流式清理向导过程中创建的项目及相关数据
|
||||
用于返回上一步时清理已生成的内容
|
||||
"""
|
||||
return create_sse_response(cleanup_wizard_data_generator(project_id, db))
|
||||
@@ -110,6 +110,7 @@ class Settings(BaseSettings):
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
extra = "ignore" # 忽略未定义的环境变量,避免验证错误
|
||||
|
||||
|
||||
# 创建全局配置实例
|
||||
|
||||
@@ -21,7 +21,8 @@ from app.models import (
|
||||
Project, Outline, Character, Chapter, GenerationHistory,
|
||||
Settings, WritingStyle, ProjectDefaultStyle,
|
||||
RelationshipType, CharacterRelationship, Organization, OrganizationMember,
|
||||
StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask
|
||||
StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask,
|
||||
RegenerationTask
|
||||
)
|
||||
|
||||
# 引擎缓存:每个用户一个引擎
|
||||
|
||||
@@ -12,6 +12,8 @@ from app.models.memory import StoryMemory, PlotAnalysis
|
||||
from app.models.writing_style import WritingStyle
|
||||
from app.models.project_default_style import ProjectDefaultStyle
|
||||
from app.models.mcp_plugin import MCPPlugin
|
||||
from app.models.user import User, UserPassword
|
||||
from app.models.regeneration_task import RegenerationTask
|
||||
|
||||
__all__ = [
|
||||
"Project",
|
||||
@@ -30,5 +32,8 @@ __all__ = [
|
||||
"PlotAnalysis",
|
||||
"WritingStyle",
|
||||
"ProjectDefaultStyle",
|
||||
"MCPPlugin"
|
||||
"MCPPlugin",
|
||||
"User",
|
||||
"UserPassword",
|
||||
"RegenerationTask"
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""章节重新生成任务模型"""
|
||||
from sqlalchemy import Column, String, Text, Integer, DateTime, ForeignKey, JSON, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import uuid
|
||||
|
||||
|
||||
class RegenerationTask(Base):
|
||||
"""章节重新生成任务表"""
|
||||
__tablename__ = "regeneration_tasks"
|
||||
|
||||
# 基本信息
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
chapter_id = Column(String(36), ForeignKey('chapters.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
analysis_id = Column(String(36), nullable=True, comment="关联的分析结果ID")
|
||||
user_id = Column(String(50), nullable=False, index=True)
|
||||
project_id = Column(String(36), nullable=False, index=True)
|
||||
|
||||
# 修改指令
|
||||
modification_instructions = Column(Text, nullable=False, comment="综合修改指令")
|
||||
original_suggestions = Column(JSON, comment="来自分析的原始建议列表")
|
||||
selected_suggestion_indices = Column(JSON, comment="用户选择的建议索引")
|
||||
custom_instructions = Column(Text, comment="用户自定义修改意见")
|
||||
|
||||
# 生成参数
|
||||
style_id = Column(Integer, nullable=True, comment="写作风格ID")
|
||||
target_word_count = Column(Integer, default=3000, comment="目标字数")
|
||||
focus_areas = Column(JSON, comment="重点优化方向")
|
||||
preserve_elements = Column(JSON, comment="需要保留的元素配置")
|
||||
|
||||
# 状态跟踪
|
||||
status = Column(String(20), default='pending', comment="pending/running/completed/failed")
|
||||
progress = Column(Integer, default=0, comment="进度 0-100")
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
# 内容版本
|
||||
original_content = Column(Text, comment="原始章节内容快照")
|
||||
original_word_count = Column(Integer, comment="原始字数")
|
||||
regenerated_content = Column(Text, comment="重新生成的内容")
|
||||
regenerated_word_count = Column(Integer, comment="新内容字数")
|
||||
version_number = Column(Integer, default=1, comment="版本号")
|
||||
version_note = Column(String(500), comment="版本说明")
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RegenerationTask(id={self.id[:8]}..., chapter_id={self.chapter_id[:8]}..., status={self.status})>"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
用户数据模型 - 存储用户基本信息
|
||||
"""
|
||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""用户模型 - 存储OAuth和本地用户信息"""
|
||||
__tablename__ = "users"
|
||||
|
||||
user_id = Column(String(100), primary_key=True, index=True, comment="用户ID,格式:linuxdo_{id} 或 local_{id}")
|
||||
username = Column(String(100), nullable=False, index=True, comment="用户名")
|
||||
display_name = Column(String(200), nullable=False, comment="显示名称")
|
||||
avatar_url = Column(String(500), nullable=True, comment="头像URL")
|
||||
trust_level = Column(Integer, default=0, comment="信任等级(仅用于显示)")
|
||||
is_admin = Column(Boolean, default=False, comment="是否为管理员")
|
||||
linuxdo_id = Column(String(100), nullable=False, unique=True, index=True, comment="LinuxDO用户ID或本地用户ID")
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间")
|
||||
last_login = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), comment="最后登录时间")
|
||||
|
||||
def to_dict(self):
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"user_id": self.user_id,
|
||||
"username": self.username,
|
||||
"display_name": self.display_name,
|
||||
"avatar_url": self.avatar_url,
|
||||
"trust_level": self.trust_level,
|
||||
"is_admin": self.is_admin,
|
||||
"linuxdo_id": self.linuxdo_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"last_login": self.last_login.isoformat() if self.last_login else None,
|
||||
}
|
||||
|
||||
|
||||
class UserPassword(Base):
|
||||
"""用户密码模型 - 存储用户密码信息"""
|
||||
__tablename__ = "user_passwords"
|
||||
|
||||
user_id = Column(String(100), primary_key=True, index=True, comment="用户ID")
|
||||
username = Column(String(100), nullable=False, comment="用户名")
|
||||
password_hash = Column(String(64), nullable=False, comment="密码哈希(SHA256)")
|
||||
has_custom_password = Column(Boolean, default=False, comment="是否为自定义密码")
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""章节重新生成相关的Schema定义"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class PreserveElementsConfig(BaseModel):
|
||||
"""保留元素配置"""
|
||||
preserve_structure: bool = Field(False, description="是否保留整体结构")
|
||||
preserve_dialogues: List[str] = Field(default_factory=list, description="需要保留的对话片段关键词")
|
||||
preserve_plot_points: List[str] = Field(default_factory=list, description="需要保留的情节点关键词")
|
||||
preserve_character_traits: bool = Field(True, description="保持角色性格一致")
|
||||
|
||||
|
||||
class ChapterRegenerateRequest(BaseModel):
|
||||
"""章节重新生成请求"""
|
||||
|
||||
# 修改来源
|
||||
modification_source: str = Field("custom", description="修改来源: custom/analysis_suggestions/mixed")
|
||||
|
||||
# 基于分析建议
|
||||
selected_suggestion_indices: Optional[List[int]] = Field(None, description="选中的建议索引列表")
|
||||
|
||||
# 自定义修改指令
|
||||
custom_instructions: Optional[str] = Field(None, description="用户自定义的修改要求")
|
||||
|
||||
# 保留配置
|
||||
preserve_elements: Optional[PreserveElementsConfig] = Field(None, description="保留元素配置")
|
||||
|
||||
# 生成参数
|
||||
style_id: Optional[int] = Field(None, description="写作风格ID")
|
||||
target_word_count: int = Field(3000, description="目标字数", ge=500, le=10000)
|
||||
focus_areas: List[str] = Field(default_factory=list, description="重点优化方向")
|
||||
|
||||
# 版本管理
|
||||
save_as_version: bool = Field(True, description="是否保存为新版本")
|
||||
version_note: Optional[str] = Field(None, description="版本说明", max_length=500)
|
||||
auto_apply: bool = Field(False, description="是否自动应用(替换当前内容)")
|
||||
|
||||
|
||||
class RegenerationTaskResponse(BaseModel):
|
||||
"""重新生成任务响应"""
|
||||
task_id: str
|
||||
chapter_id: str
|
||||
status: str
|
||||
message: str
|
||||
estimated_time_seconds: int = 120
|
||||
|
||||
|
||||
class RegenerationTaskStatus(BaseModel):
|
||||
"""重新生成任务状态"""
|
||||
task_id: str
|
||||
chapter_id: str
|
||||
status: str
|
||||
progress: int
|
||||
error_message: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
# 结果信息
|
||||
original_word_count: Optional[int] = None
|
||||
regenerated_word_count: Optional[int] = None
|
||||
version_number: Optional[int] = None
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""章节重新生成服务"""
|
||||
from typing import Dict, Any, AsyncGenerator, Optional, List
|
||||
from app.services.ai_service import AIService
|
||||
from app.services.prompt_service import prompt_service
|
||||
from app.models.chapter import Chapter
|
||||
from app.models.memory import PlotAnalysis
|
||||
from app.schemas.regeneration import ChapterRegenerateRequest, PreserveElementsConfig
|
||||
from app.logger import get_logger
|
||||
import difflib
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ChapterRegenerator:
|
||||
"""章节重新生成服务"""
|
||||
|
||||
def __init__(self, ai_service: AIService):
|
||||
self.ai_service = ai_service
|
||||
logger.info("✅ ChapterRegenerator初始化成功")
|
||||
|
||||
async def regenerate_with_feedback(
|
||||
self,
|
||||
chapter: Chapter,
|
||||
analysis: Optional[PlotAnalysis],
|
||||
regenerate_request: ChapterRegenerateRequest,
|
||||
project_context: Dict[str, Any]
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""
|
||||
根据反馈重新生成章节(流式)
|
||||
|
||||
Args:
|
||||
chapter: 原始章节对象
|
||||
analysis: 分析结果(可选)
|
||||
regenerate_request: 重新生成请求参数
|
||||
project_context: 项目上下文(项目信息、角色、大纲等)
|
||||
|
||||
Yields:
|
||||
包含类型和数据的字典: {'type': 'progress'/'chunk', 'data': ...}
|
||||
"""
|
||||
try:
|
||||
logger.info(f"🔄 开始重新生成章节: 第{chapter.chapter_number}章")
|
||||
|
||||
# 1. 构建修改指令
|
||||
yield {'type': 'progress', 'progress': 5, 'message': '正在构建修改指令...'}
|
||||
modification_instructions = self._build_modification_instructions(
|
||||
analysis=analysis,
|
||||
regenerate_request=regenerate_request
|
||||
)
|
||||
|
||||
logger.info(f"📝 修改指令构建完成,长度: {len(modification_instructions)}字符")
|
||||
|
||||
# 2. 构建完整提示词
|
||||
yield {'type': 'progress', 'progress': 10, 'message': '正在构建生成提示词...'}
|
||||
full_prompt = self._build_regeneration_prompt(
|
||||
chapter=chapter,
|
||||
modification_instructions=modification_instructions,
|
||||
project_context=project_context,
|
||||
regenerate_request=regenerate_request
|
||||
)
|
||||
|
||||
logger.info(f"🎯 提示词构建完成,开始AI生成")
|
||||
yield {'type': 'progress', 'progress': 15, 'message': '开始AI生成内容...'}
|
||||
|
||||
# 3. 流式生成新内容,同时跟踪进度
|
||||
target_word_count = regenerate_request.target_word_count
|
||||
accumulated_length = 0
|
||||
|
||||
async for chunk in self.ai_service.generate_text_stream(
|
||||
prompt=full_prompt,
|
||||
temperature=0.7
|
||||
):
|
||||
# 发送内容块
|
||||
yield {'type': 'chunk', 'content': chunk}
|
||||
|
||||
# 更新累积字数并计算进度(15%-95%)
|
||||
accumulated_length += len(chunk)
|
||||
# 进度从15%开始,到95%结束,为后处理预留5%
|
||||
generation_progress = min(15 + (accumulated_length / target_word_count) * 80, 95)
|
||||
yield {'type': 'progress', 'progress': int(generation_progress), 'word_count': accumulated_length}
|
||||
|
||||
logger.info(f"✅ 章节重新生成完成,共生成 {accumulated_length} 字")
|
||||
yield {'type': 'progress', 'progress': 100, 'message': '生成完成'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 重新生成失败: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
def _build_modification_instructions(
|
||||
self,
|
||||
analysis: Optional[PlotAnalysis],
|
||||
regenerate_request: ChapterRegenerateRequest
|
||||
) -> str:
|
||||
"""构建修改指令"""
|
||||
|
||||
instructions = []
|
||||
|
||||
# 标题
|
||||
instructions.append("# 章节修改指令\n")
|
||||
|
||||
# 1. 来自分析的建议
|
||||
if (analysis and
|
||||
regenerate_request.selected_suggestion_indices and
|
||||
analysis.suggestions):
|
||||
|
||||
instructions.append("## 📋 需要改进的问题(来自AI分析):\n")
|
||||
for idx in regenerate_request.selected_suggestion_indices:
|
||||
if 0 <= idx < len(analysis.suggestions):
|
||||
suggestion = analysis.suggestions[idx]
|
||||
instructions.append(f"{idx + 1}. {suggestion}")
|
||||
instructions.append("")
|
||||
|
||||
# 2. 用户自定义指令
|
||||
if regenerate_request.custom_instructions:
|
||||
instructions.append("## ✍️ 用户自定义修改要求:\n")
|
||||
instructions.append(regenerate_request.custom_instructions)
|
||||
instructions.append("")
|
||||
|
||||
# 3. 重点优化方向
|
||||
if regenerate_request.focus_areas:
|
||||
instructions.append("## 🎯 重点优化方向:\n")
|
||||
focus_map = {
|
||||
"pacing": "节奏把控 - 调整叙事速度,避免拖沓或过快",
|
||||
"emotion": "情感渲染 - 深化人物情感表达,增强感染力",
|
||||
"description": "场景描写 - 丰富环境细节,增强画面感",
|
||||
"dialogue": "对话质量 - 让对话更自然真实,推动剧情",
|
||||
"conflict": "冲突强度 - 强化矛盾冲突,提升戏剧张力"
|
||||
}
|
||||
|
||||
for area in regenerate_request.focus_areas:
|
||||
if area in focus_map:
|
||||
instructions.append(f"- {focus_map[area]}")
|
||||
instructions.append("")
|
||||
|
||||
# 4. 保留要求
|
||||
if regenerate_request.preserve_elements:
|
||||
preserve = regenerate_request.preserve_elements
|
||||
instructions.append("## 🔒 必须保留的元素:\n")
|
||||
|
||||
if preserve.preserve_structure:
|
||||
instructions.append("- 保持原章节的整体结构和情节框架")
|
||||
|
||||
if preserve.preserve_dialogues:
|
||||
instructions.append("- 必须保留以下关键对话:")
|
||||
for dialogue in preserve.preserve_dialogues:
|
||||
instructions.append(f" * {dialogue}")
|
||||
|
||||
if preserve.preserve_plot_points:
|
||||
instructions.append("- 必须保留以下关键情节点:")
|
||||
for plot in preserve.preserve_plot_points:
|
||||
instructions.append(f" * {plot}")
|
||||
|
||||
if preserve.preserve_character_traits:
|
||||
instructions.append("- 保持所有角色的性格特征和行为模式一致")
|
||||
|
||||
instructions.append("")
|
||||
|
||||
return "\n".join(instructions)
|
||||
|
||||
def _build_regeneration_prompt(
|
||||
self,
|
||||
chapter: Chapter,
|
||||
modification_instructions: str,
|
||||
project_context: Dict[str, Any],
|
||||
regenerate_request: ChapterRegenerateRequest
|
||||
) -> str:
|
||||
"""构建完整的重新生成提示词"""
|
||||
|
||||
prompt_parts = []
|
||||
|
||||
# 系统角色
|
||||
prompt_parts.append("""你是一位经验丰富的专业小说编辑和作家。现在需要根据反馈意见重新创作一个章节。
|
||||
|
||||
你的任务是:
|
||||
1. 仔细理解原章节的内容和意图
|
||||
2. 认真分析所有的修改要求
|
||||
3. 在保持故事连贯性的前提下,创作一个改进后的新版本
|
||||
4. 确保新版本在艺术性和可读性上都有明显提升
|
||||
|
||||
---
|
||||
""")
|
||||
|
||||
# 原始章节信息
|
||||
prompt_parts.append(f"""## 📖 原始章节信息
|
||||
|
||||
**章节**:第{chapter.chapter_number}章
|
||||
**标题**:{chapter.title}
|
||||
**字数**:{chapter.word_count}字
|
||||
|
||||
**原始内容**:
|
||||
{chapter.content}
|
||||
|
||||
---
|
||||
""")
|
||||
|
||||
# 修改指令
|
||||
prompt_parts.append(modification_instructions)
|
||||
prompt_parts.append("\n---\n")
|
||||
|
||||
# 项目背景信息
|
||||
prompt_parts.append(f"""## 🌍 项目背景信息
|
||||
|
||||
**小说标题**:{project_context.get('project_title', '未知')}
|
||||
**题材**:{project_context.get('genre', '未设定')}
|
||||
**主题**:{project_context.get('theme', '未设定')}
|
||||
**叙事视角**:{project_context.get('narrative_perspective', '第三人称')}
|
||||
**世界观设定**:
|
||||
- 时代背景:{project_context.get('time_period', '未设定')}
|
||||
- 地理位置:{project_context.get('location', '未设定')}
|
||||
- 氛围基调:{project_context.get('atmosphere', '未设定')}
|
||||
|
||||
---
|
||||
""")
|
||||
|
||||
# 角色信息
|
||||
if project_context.get('characters_info'):
|
||||
prompt_parts.append(f"""## 👥 角色信息
|
||||
|
||||
{project_context['characters_info']}
|
||||
|
||||
---
|
||||
""")
|
||||
|
||||
# 章节大纲
|
||||
if project_context.get('chapter_outline'):
|
||||
prompt_parts.append(f"""## 📝 本章大纲
|
||||
|
||||
{project_context['chapter_outline']}
|
||||
|
||||
---
|
||||
""")
|
||||
|
||||
# 前置章节上下文
|
||||
if project_context.get('previous_context'):
|
||||
prompt_parts.append(f"""## 📚 前置章节上下文
|
||||
|
||||
{project_context['previous_context']}
|
||||
|
||||
---
|
||||
""")
|
||||
|
||||
# 创作要求
|
||||
prompt_parts.append(f"""## ✨ 创作要求
|
||||
|
||||
1. **解决问题**:针对上述修改指令中提到的所有问题进行改进
|
||||
2. **保持连贯**:确保与前后章节的情节、人物、风格保持一致
|
||||
3. **提升质量**:在节奏、情感、描写等方面明显优于原版
|
||||
4. **保留精华**:保持原章节中优秀的部分和关键情节
|
||||
5. **字数控制**:目标字数约{regenerate_request.target_word_count}字(可适当浮动±20%)
|
||||
|
||||
---
|
||||
|
||||
## 🎬 开始创作
|
||||
|
||||
请现在开始创作改进后的新版本章节内容。
|
||||
|
||||
**重要提示**:
|
||||
- 直接输出章节正文内容,从故事内容开始写
|
||||
- **不要**输出章节标题(如"第X章"、"第X章:XXX"等)
|
||||
- **不要**输出任何额外的说明、注释或元数据
|
||||
- 只需要纯粹的故事正文内容
|
||||
|
||||
现在开始:
|
||||
""")
|
||||
|
||||
return "\n".join(prompt_parts)
|
||||
|
||||
def calculate_content_diff(
|
||||
self,
|
||||
original_content: str,
|
||||
new_content: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
计算两个版本的差异
|
||||
|
||||
Returns:
|
||||
差异统计信息
|
||||
"""
|
||||
# 基本统计
|
||||
diff_stats = {
|
||||
'original_length': len(original_content),
|
||||
'new_length': len(new_content),
|
||||
'length_change': len(new_content) - len(original_content),
|
||||
'length_change_percent': round((len(new_content) - len(original_content)) / len(original_content) * 100, 2) if len(original_content) > 0 else 0
|
||||
}
|
||||
|
||||
# 计算相似度
|
||||
similarity = difflib.SequenceMatcher(None, original_content, new_content).ratio()
|
||||
diff_stats['similarity'] = round(similarity * 100, 2)
|
||||
diff_stats['difference'] = round((1 - similarity) * 100, 2)
|
||||
|
||||
# 段落统计
|
||||
original_paragraphs = [p for p in original_content.split('\n\n') if p.strip()]
|
||||
new_paragraphs = [p for p in new_content.split('\n\n') if p.strip()]
|
||||
diff_stats['original_paragraph_count'] = len(original_paragraphs)
|
||||
diff_stats['new_paragraph_count'] = len(new_paragraphs)
|
||||
|
||||
return diff_stats
|
||||
|
||||
|
||||
# 全局实例
|
||||
_regenerator_instance = None
|
||||
|
||||
def get_chapter_regenerator(ai_service: AIService) -> ChapterRegenerator:
|
||||
"""获取章节重新生成器实例"""
|
||||
global _regenerator_instance
|
||||
if _regenerator_instance is None:
|
||||
_regenerator_instance = ChapterRegenerator(ai_service)
|
||||
return _regenerator_instance
|
||||
+133
-201
@@ -1,106 +1,49 @@
|
||||
"""
|
||||
用户管理模块 - 支持 LinuxDO OAuth2
|
||||
用户管理模块 - 使用数据库存储
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from pydantic import BaseModel
|
||||
from app.config import settings, DATA_DIR
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
"""用户模型"""
|
||||
user_id: str # 格式: linuxdo_{linuxdo_id}
|
||||
"""用户数据传输对象"""
|
||||
user_id: str
|
||||
username: str
|
||||
display_name: str
|
||||
avatar_url: Optional[str] = None
|
||||
trust_level: int = 0 # 仅用于显示
|
||||
is_admin: bool = False # 手动设置的管理员权限
|
||||
linuxdo_id: str # LinuxDO 用户 ID
|
||||
trust_level: int = 0
|
||||
is_admin: bool = False
|
||||
linuxdo_id: str
|
||||
created_at: str
|
||||
last_login: str
|
||||
|
||||
|
||||
class UserManager:
|
||||
"""用户管理器 - 线程安全版本"""
|
||||
|
||||
USERS_FILE = str(DATA_DIR / "users.json")
|
||||
ADMINS_FILE = str(DATA_DIR / "admins.json")
|
||||
"""用户管理器 - 使用数据库存储(PostgreSQL共享库)"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化用户管理器"""
|
||||
# DATA_DIR 已在 config.py 中创建,无需重复创建
|
||||
# 添加文件锁保护并发读写
|
||||
self._users_lock = asyncio.Lock()
|
||||
self._admins_lock = asyncio.Lock()
|
||||
self._ensure_files_exist()
|
||||
pass
|
||||
|
||||
def _ensure_files_exist(self):
|
||||
"""确保必要的文件存在"""
|
||||
if not os.path.exists(self.USERS_FILE):
|
||||
with open(self.USERS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({}, f, ensure_ascii=False, indent=2)
|
||||
async def _get_session(self) -> AsyncSession:
|
||||
"""获取数据库会话 - 使用共享的PostgreSQL引擎"""
|
||||
from app.database import get_engine
|
||||
|
||||
if not os.path.exists(self.ADMINS_FILE):
|
||||
with open(self.ADMINS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({"admins": []}, f, ensure_ascii=False, indent=2)
|
||||
# 使用共享的PostgreSQL引擎(user_id使用特殊标识)
|
||||
engine = await get_engine("_global_users_")
|
||||
|
||||
def _load_users_unsafe(self) -> Dict[str, dict]:
|
||||
"""加载用户数据(不加锁,内部使用)"""
|
||||
try:
|
||||
with open(self.USERS_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"加载用户数据失败: {e}")
|
||||
return {}
|
||||
session_maker = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
def _save_users_unsafe(self, users: Dict[str, dict]):
|
||||
"""保存用户数据(不加锁,内部使用)"""
|
||||
try:
|
||||
with open(self.USERS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(users, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"保存用户数据失败: {e}")
|
||||
|
||||
async def _load_users(self) -> Dict[str, dict]:
|
||||
"""加载用户数据(加锁)"""
|
||||
async with self._users_lock:
|
||||
return self._load_users_unsafe()
|
||||
|
||||
async def _save_users(self, users: Dict[str, dict]):
|
||||
"""保存用户数据(加锁)"""
|
||||
async with self._users_lock:
|
||||
self._save_users_unsafe(users)
|
||||
|
||||
def _load_admin_list_unsafe(self) -> List[str]:
|
||||
"""加载管理员列表(不加锁,内部使用)"""
|
||||
try:
|
||||
with open(self.ADMINS_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data.get("admins", [])
|
||||
except Exception as e:
|
||||
print(f"加载管理员列表失败: {e}")
|
||||
return []
|
||||
|
||||
def _save_admin_list_unsafe(self, admin_list: List[str]):
|
||||
"""保存管理员列表(不加锁,内部使用)"""
|
||||
try:
|
||||
with open(self.ADMINS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({"admins": admin_list}, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"保存管理员列表失败: {e}")
|
||||
|
||||
async def _load_admin_list(self) -> List[str]:
|
||||
"""加载管理员列表(加锁)"""
|
||||
async with self._admins_lock:
|
||||
return self._load_admin_list_unsafe()
|
||||
|
||||
async def _save_admin_list(self, admin_list: List[str]):
|
||||
"""保存管理员列表(加锁)"""
|
||||
async with self._admins_lock:
|
||||
self._save_admin_list_unsafe(admin_list)
|
||||
return session_maker()
|
||||
|
||||
async def create_or_update_from_linuxdo(
|
||||
self,
|
||||
@@ -111,106 +54,97 @@ class UserManager:
|
||||
trust_level: int
|
||||
) -> User:
|
||||
"""
|
||||
从 LinuxDO 用户信息创建或更新用户(线程安全)
|
||||
从 LinuxDO 用户信息创建或更新用户
|
||||
|
||||
Args:
|
||||
linuxdo_id: LinuxDO 用户 ID(本地用户时为 local_xxx 格式)
|
||||
username: 用户名
|
||||
display_name: 显示名称
|
||||
avatar_url: 头像 URL
|
||||
trust_level: 信任等级 (仅用于显示)
|
||||
trust_level: 信任等级
|
||||
|
||||
Returns:
|
||||
用户对象
|
||||
"""
|
||||
# 如果已经是 local_ 开头,直接使用;否则添加 linuxdo_ 前缀
|
||||
from app.models.user import User as UserModel
|
||||
|
||||
# 生成 user_id
|
||||
if linuxdo_id.startswith("local_"):
|
||||
user_id = linuxdo_id
|
||||
else:
|
||||
user_id = f"linuxdo_{linuxdo_id}"
|
||||
|
||||
# 使用锁保护整个读-改-写操作
|
||||
async with self._users_lock:
|
||||
async with self._admins_lock:
|
||||
users = self._load_users_unsafe()
|
||||
admin_list = self._load_admin_list_unsafe()
|
||||
async with await self._get_session() as session:
|
||||
# 查询用户是否存在
|
||||
result = await session.execute(
|
||||
select(UserModel).where(UserModel.user_id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
# 检查是否为初始管理员或本地用户
|
||||
initial_admin_id = settings.INITIAL_ADMIN_LINUXDO_ID
|
||||
is_initial_admin = (initial_admin_id and linuxdo_id == initial_admin_id)
|
||||
is_local_user = user_id.startswith("local_")
|
||||
is_admin = is_initial_admin or is_local_user
|
||||
|
||||
# 检查是否为初始管理员
|
||||
initial_admin_id = settings.INITIAL_ADMIN_LINUXDO_ID
|
||||
is_initial_admin = (initial_admin_id and linuxdo_id == initial_admin_id)
|
||||
if user:
|
||||
# 更新现有用户
|
||||
user.username = username
|
||||
user.display_name = display_name
|
||||
user.avatar_url = avatar_url
|
||||
user.trust_level = trust_level
|
||||
user.last_login = datetime.now()
|
||||
|
||||
# 检查是否为本地用户(所有 local_ 开头的用户默认为管理员)
|
||||
is_local_user = user_id.startswith("local_")
|
||||
# 更新管理员状态
|
||||
if is_admin and not user.is_admin:
|
||||
user.is_admin = True
|
||||
else:
|
||||
# 创建新用户
|
||||
user = UserModel(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
avatar_url=avatar_url,
|
||||
trust_level=trust_level,
|
||||
is_admin=is_admin,
|
||||
linuxdo_id=linuxdo_id,
|
||||
created_at=datetime.now(),
|
||||
last_login=datetime.now()
|
||||
)
|
||||
session.add(user)
|
||||
|
||||
if user_id in users:
|
||||
# 更新现有用户
|
||||
user_data = users[user_id]
|
||||
user_data["username"] = username
|
||||
user_data["display_name"] = display_name
|
||||
user_data["avatar_url"] = avatar_url
|
||||
user_data["trust_level"] = trust_level
|
||||
user_data["last_login"] = now
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
# 如果是初始管理员或本地用户且还不在管理员列表中,添加进去
|
||||
if (is_initial_admin or is_local_user) and user_id not in admin_list:
|
||||
admin_list.append(user_id)
|
||||
self._save_admin_list_unsafe(admin_list)
|
||||
user_data["is_admin"] = True
|
||||
else:
|
||||
# 从管理员列表同步 is_admin 状态
|
||||
user_data["is_admin"] = user_id in admin_list
|
||||
else:
|
||||
# 创建新用户(本地用户默认为管理员)
|
||||
is_admin = is_initial_admin or is_local_user
|
||||
if is_admin and user_id not in admin_list:
|
||||
admin_list.append(user_id)
|
||||
self._save_admin_list_unsafe(admin_list)
|
||||
|
||||
user_data = {
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"display_name": display_name,
|
||||
"avatar_url": avatar_url,
|
||||
"trust_level": trust_level,
|
||||
"is_admin": is_admin,
|
||||
"linuxdo_id": linuxdo_id,
|
||||
"created_at": now,
|
||||
"last_login": now
|
||||
}
|
||||
users[user_id] = user_data
|
||||
|
||||
self._save_users_unsafe(users)
|
||||
return User(**user_data)
|
||||
return User(**user.to_dict())
|
||||
|
||||
async def get_user(self, user_id: str) -> Optional[User]:
|
||||
"""获取用户(线程安全)"""
|
||||
users = await self._load_users()
|
||||
user_data = users.get(user_id)
|
||||
if user_data:
|
||||
# 同步管理员状态
|
||||
admin_list = await self._load_admin_list()
|
||||
user_data["is_admin"] = user_id in admin_list
|
||||
return User(**user_data)
|
||||
return None
|
||||
"""获取用户"""
|
||||
from app.models.user import User as UserModel
|
||||
|
||||
async with await self._get_session() as session:
|
||||
result = await session.execute(
|
||||
select(UserModel).where(UserModel.user_id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
return User(**user.to_dict())
|
||||
return None
|
||||
|
||||
async def get_all_users(self) -> List[User]:
|
||||
"""获取所有用户(线程安全)"""
|
||||
users = await self._load_users()
|
||||
admin_list = await self._load_admin_list()
|
||||
"""获取所有用户"""
|
||||
from app.models.user import User as UserModel
|
||||
|
||||
user_list = []
|
||||
for user_data in users.values():
|
||||
# 同步管理员状态
|
||||
user_data["is_admin"] = user_data["user_id"] in admin_list
|
||||
user_list.append(User(**user_data))
|
||||
async with await self._get_session() as session:
|
||||
result = await session.execute(select(UserModel))
|
||||
users = result.scalars().all()
|
||||
|
||||
return user_list
|
||||
return [User(**user.to_dict()) for user in users]
|
||||
|
||||
async def set_admin(self, user_id: str, is_admin: bool) -> bool:
|
||||
"""
|
||||
设置用户的管理员权限(线程安全)
|
||||
设置用户的管理员权限
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
@@ -219,38 +153,35 @@ class UserManager:
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
# 使用锁保护整个读-改-写操作
|
||||
async with self._users_lock:
|
||||
async with self._admins_lock:
|
||||
users = self._load_users_unsafe()
|
||||
if user_id not in users:
|
||||
from app.models.user import User as UserModel
|
||||
|
||||
async with await self._get_session() as session:
|
||||
result = await session.execute(
|
||||
select(UserModel).where(UserModel.user_id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return False
|
||||
|
||||
if not is_admin:
|
||||
# 撤销管理员权限时,确保至少保留一个管理员
|
||||
admin_result = await session.execute(
|
||||
select(UserModel).where(UserModel.is_admin == True)
|
||||
)
|
||||
admin_count = len(admin_result.scalars().all())
|
||||
|
||||
if admin_count <= 1:
|
||||
return False
|
||||
|
||||
admin_list = self._load_admin_list_unsafe()
|
||||
user.is_admin = is_admin
|
||||
await session.commit()
|
||||
|
||||
if is_admin:
|
||||
# 授予管理员权限
|
||||
if user_id not in admin_list:
|
||||
admin_list.append(user_id)
|
||||
self._save_admin_list_unsafe(admin_list)
|
||||
else:
|
||||
# 撤销管理员权限
|
||||
if user_id in admin_list:
|
||||
# 确保至少保留一个管理员
|
||||
if len(admin_list) <= 1:
|
||||
return False
|
||||
admin_list.remove(user_id)
|
||||
self._save_admin_list_unsafe(admin_list)
|
||||
|
||||
# 更新用户数据中的 is_admin 字段
|
||||
users[user_id]["is_admin"] = is_admin
|
||||
self._save_users_unsafe(users)
|
||||
|
||||
return True
|
||||
return True
|
||||
|
||||
async def delete_user(self, user_id: str) -> bool:
|
||||
"""
|
||||
删除用户(线程安全)
|
||||
删除用户
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
@@ -258,36 +189,37 @@ class UserManager:
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
# 使用锁保护整个读-改-写操作
|
||||
async with self._users_lock:
|
||||
async with self._admins_lock:
|
||||
users = self._load_users_unsafe()
|
||||
if user_id not in users:
|
||||
return False
|
||||
from app.models.user import User as UserModel
|
||||
|
||||
# 不能删除管理员
|
||||
admin_list = self._load_admin_list_unsafe()
|
||||
if user_id in admin_list:
|
||||
return False
|
||||
async with await self._get_session() as session:
|
||||
result = await session.execute(
|
||||
select(UserModel).where(UserModel.user_id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# 删除用户数据
|
||||
del users[user_id]
|
||||
self._save_users_unsafe(users)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
# 删除用户数据库文件(在锁外执行,避免阻塞)
|
||||
db_file = str(DATA_DIR / f"ai_story_user_{user_id}.db")
|
||||
if os.path.exists(db_file):
|
||||
try:
|
||||
os.remove(db_file)
|
||||
except Exception as e:
|
||||
print(f"删除用户数据库文件失败: {e}")
|
||||
# 不能删除管理员
|
||||
if user.is_admin:
|
||||
return False
|
||||
|
||||
return True
|
||||
await session.delete(user)
|
||||
await session.commit()
|
||||
|
||||
return True
|
||||
|
||||
async def is_admin(self, user_id: str) -> bool:
|
||||
"""检查用户是否为管理员(线程安全)"""
|
||||
admin_list = await self._load_admin_list()
|
||||
return user_id in admin_list
|
||||
"""检查用户是否为管理员"""
|
||||
from app.models.user import User as UserModel
|
||||
|
||||
async with await self._get_session() as session:
|
||||
result = await session.execute(
|
||||
select(UserModel).where(UserModel.user_id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
return user.is_admin if user else False
|
||||
|
||||
|
||||
# 全局用户管理器实例
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
用户密码管理模块 - 使用数据库存储
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class UserPasswordManager:
|
||||
"""用户密码管理器 - 使用数据库存储(PostgreSQL共享库)"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化密码管理器"""
|
||||
pass
|
||||
|
||||
async def _get_session(self) -> AsyncSession:
|
||||
"""获取数据库会话 - 使用共享的PostgreSQL引擎"""
|
||||
from app.database import get_engine
|
||||
|
||||
# 使用共享的PostgreSQL引擎(user_id使用特殊标识)
|
||||
engine = await get_engine("_global_users_")
|
||||
|
||||
session_maker = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
return session_maker()
|
||||
|
||||
def _hash_password(self, password: str) -> str:
|
||||
"""密码哈希"""
|
||||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
async def set_password(self, user_id: str, username: str, password: Optional[str] = None) -> str:
|
||||
"""
|
||||
设置用户密码
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
username: 用户名
|
||||
password: 密码,如果为None则使用默认密码(username+@666)
|
||||
|
||||
Returns:
|
||||
实际使用的密码(明文,仅用于首次设置时返回给用户)
|
||||
"""
|
||||
from app.models.user import UserPassword as UserPasswordModel
|
||||
|
||||
# 如果没有提供密码,使用默认密码
|
||||
actual_password = password if password else f"{username}@666"
|
||||
|
||||
async with await self._get_session() as session:
|
||||
# 查询密码记录是否存在
|
||||
result = await session.execute(
|
||||
select(UserPasswordModel).where(UserPasswordModel.user_id == user_id)
|
||||
)
|
||||
pwd_record = result.scalar_one_or_none()
|
||||
|
||||
if pwd_record:
|
||||
# 更新现有密码
|
||||
pwd_record.username = username
|
||||
pwd_record.password_hash = self._hash_password(actual_password)
|
||||
pwd_record.has_custom_password = password is not None
|
||||
pwd_record.updated_at = datetime.now()
|
||||
else:
|
||||
# 创建新密码记录
|
||||
pwd_record = UserPasswordModel(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
password_hash=self._hash_password(actual_password),
|
||||
has_custom_password=password is not None,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
session.add(pwd_record)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return actual_password
|
||||
|
||||
async def verify_password(self, user_id: str, password: str) -> bool:
|
||||
"""
|
||||
验证用户密码
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
password: 待验证的密码
|
||||
|
||||
Returns:
|
||||
是否验证通过
|
||||
"""
|
||||
from app.models.user import UserPassword as UserPasswordModel
|
||||
|
||||
async with await self._get_session() as session:
|
||||
result = await session.execute(
|
||||
select(UserPasswordModel).where(UserPasswordModel.user_id == user_id)
|
||||
)
|
||||
pwd_record = result.scalar_one_or_none()
|
||||
|
||||
if not pwd_record:
|
||||
return False
|
||||
|
||||
password_hash = self._hash_password(password)
|
||||
return pwd_record.password_hash == password_hash
|
||||
|
||||
async def has_password(self, user_id: str) -> bool:
|
||||
"""
|
||||
检查用户是否已设置密码
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
|
||||
Returns:
|
||||
是否已设置密码
|
||||
"""
|
||||
from app.models.user import UserPassword as UserPasswordModel
|
||||
|
||||
async with await self._get_session() as session:
|
||||
result = await session.execute(
|
||||
select(UserPasswordModel).where(UserPasswordModel.user_id == user_id)
|
||||
)
|
||||
pwd_record = result.scalar_one_or_none()
|
||||
|
||||
return pwd_record is not None
|
||||
|
||||
async def has_custom_password(self, user_id: str) -> bool:
|
||||
"""
|
||||
检查用户是否设置了自定义密码(非默认密码)
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
|
||||
Returns:
|
||||
是否使用自定义密码
|
||||
"""
|
||||
from app.models.user import UserPassword as UserPasswordModel
|
||||
|
||||
async with await self._get_session() as session:
|
||||
result = await session.execute(
|
||||
select(UserPasswordModel).where(UserPasswordModel.user_id == user_id)
|
||||
)
|
||||
pwd_record = result.scalar_one_or_none()
|
||||
|
||||
if not pwd_record:
|
||||
return False
|
||||
|
||||
return pwd_record.has_custom_password
|
||||
|
||||
async def get_username(self, user_id: str) -> Optional[str]:
|
||||
"""
|
||||
获取用户名
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
|
||||
Returns:
|
||||
用户名,如果不存在返回None
|
||||
"""
|
||||
from app.models.user import UserPassword as UserPasswordModel
|
||||
|
||||
async with await self._get_session() as session:
|
||||
result = await session.execute(
|
||||
select(UserPasswordModel).where(UserPasswordModel.user_id == user_id)
|
||||
)
|
||||
pwd_record = result.scalar_one_or_none()
|
||||
|
||||
if not pwd_record:
|
||||
return None
|
||||
|
||||
return pwd_record.username
|
||||
|
||||
|
||||
# 全局密码管理器实例
|
||||
password_manager = UserPasswordManager()
|
||||
@@ -158,8 +158,18 @@ def create_sse_response(generator: AsyncGenerator[str, None]) -> StreamingRespon
|
||||
Returns:
|
||||
StreamingResponse对象
|
||||
"""
|
||||
async def wrapper():
|
||||
"""包装生成器以捕获StreamingResponse初始化时的GeneratorExit"""
|
||||
try:
|
||||
async for chunk in generator:
|
||||
yield chunk
|
||||
except GeneratorExit:
|
||||
# StreamingResponse在初始化时会进行类型检查,导致GeneratorExit
|
||||
# 这是正常行为,不需要记录警告
|
||||
pass
|
||||
|
||||
return StreamingResponse(
|
||||
generator,
|
||||
wrapper(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
-- 创建章节重新生成任务表
|
||||
-- 用于支持根据AI分析建议重新生成章节内容的功能
|
||||
|
||||
-- 创建重新生成任务表
|
||||
CREATE TABLE IF NOT EXISTS regeneration_tasks (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
chapter_id VARCHAR(36) NOT NULL,
|
||||
analysis_id VARCHAR(36),
|
||||
user_id VARCHAR(100) NOT NULL,
|
||||
project_id VARCHAR(36) NOT NULL,
|
||||
|
||||
-- 修改指令
|
||||
modification_instructions TEXT NOT NULL,
|
||||
original_suggestions JSON,
|
||||
selected_suggestion_indices JSON,
|
||||
custom_instructions TEXT,
|
||||
|
||||
-- 生成配置
|
||||
style_id INTEGER,
|
||||
target_word_count INTEGER DEFAULT 3000,
|
||||
focus_areas JSON,
|
||||
preserve_elements JSON,
|
||||
|
||||
-- 任务状态
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
progress INTEGER DEFAULT 0,
|
||||
error_message TEXT,
|
||||
|
||||
-- 内容数据
|
||||
original_content TEXT,
|
||||
original_word_count INTEGER,
|
||||
regenerated_content TEXT,
|
||||
regenerated_word_count INTEGER,
|
||||
|
||||
-- 版本信息
|
||||
version_number INTEGER DEFAULT 1,
|
||||
version_note TEXT,
|
||||
|
||||
-- 时间戳
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
|
||||
-- 外键约束
|
||||
CONSTRAINT fk_regeneration_chapter FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_regeneration_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_regeneration_analysis FOREIGN KEY (analysis_id) REFERENCES analysis_tasks(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_regeneration_style FOREIGN KEY (style_id) REFERENCES writing_styles(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- 创建索引以提升查询性能
|
||||
CREATE INDEX IF NOT EXISTS idx_regeneration_tasks_chapter ON regeneration_tasks(chapter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_regeneration_tasks_project ON regeneration_tasks(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_regeneration_tasks_user ON regeneration_tasks(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_regeneration_tasks_status ON regeneration_tasks(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_regeneration_tasks_created ON regeneration_tasks(created_at DESC);
|
||||
|
||||
-- 添加注释
|
||||
COMMENT ON TABLE regeneration_tasks IS '章节重新生成任务表,记录每次根据AI建议重新生成章节的任务';
|
||||
|
||||
COMMENT ON COLUMN regeneration_tasks.modification_instructions IS '合并后的完整修改指令';
|
||||
COMMENT ON COLUMN regeneration_tasks.original_suggestions IS '原始AI分析建议列表';
|
||||
COMMENT ON COLUMN regeneration_tasks.selected_suggestion_indices IS '用户选择的建议索引';
|
||||
COMMENT ON COLUMN regeneration_tasks.preserve_elements IS '需要保留的元素配置(JSON)';
|
||||
COMMENT ON COLUMN regeneration_tasks.focus_areas IS '重点优化方向列表(JSON)';
|
||||
|
||||
-- 修复外键约束(合并自 fix_all_missing_columns.sql)
|
||||
-- 删除可能存在问题的外键约束
|
||||
ALTER TABLE regeneration_tasks
|
||||
DROP CONSTRAINT IF EXISTS fk_regeneration_analysis;
|
||||
|
||||
-- 完成提示
|
||||
SELECT '✅ 重新生成任务表创建完成,外键约束已修复' AS status;
|
||||
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
用户数据迁移脚本 - 从JSON文件迁移到数据库
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from app.user_manager import user_manager
|
||||
from app.user_password import password_manager
|
||||
from app.config import DATA_DIR
|
||||
|
||||
|
||||
async def migrate_users():
|
||||
"""迁移用户数据"""
|
||||
users_file = DATA_DIR / "users.json"
|
||||
|
||||
if not users_file.exists():
|
||||
print("❌ 用户数据文件不存在,跳过迁移")
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(users_file, "r", encoding="utf-8") as f:
|
||||
users_data = json.load(f)
|
||||
|
||||
if not users_data:
|
||||
print("ℹ️ 用户数据为空,跳过迁移")
|
||||
return 0
|
||||
|
||||
migrated_count = 0
|
||||
for user_id, user_info in users_data.items():
|
||||
try:
|
||||
# 迁移用户基本信息
|
||||
await user_manager.create_or_update_from_linuxdo(
|
||||
linuxdo_id=user_info["linuxdo_id"],
|
||||
username=user_info["username"],
|
||||
display_name=user_info["display_name"],
|
||||
avatar_url=user_info.get("avatar_url"),
|
||||
trust_level=user_info.get("trust_level", 0)
|
||||
)
|
||||
|
||||
# 如果用户是管理员,设置管理员权限
|
||||
if user_info.get("is_admin", False):
|
||||
await user_manager.set_admin(user_id, True)
|
||||
|
||||
migrated_count += 1
|
||||
print(f"✅ 迁移用户: {user_info['username']} ({user_id})")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 迁移用户 {user_id} 失败: {e}")
|
||||
|
||||
print(f"\n✅ 用户数据迁移完成: {migrated_count}/{len(users_data)} 个用户")
|
||||
|
||||
# 备份原文件
|
||||
backup_file = DATA_DIR / "users.json.backup"
|
||||
os.rename(users_file, backup_file)
|
||||
print(f"📦 原文件已备份到: {backup_file}")
|
||||
|
||||
return migrated_count
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 迁移用户数据失败: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
async def migrate_passwords():
|
||||
"""迁移密码数据"""
|
||||
passwords_file = DATA_DIR / "user_passwords.json"
|
||||
|
||||
if not passwords_file.exists():
|
||||
print("❌ 密码数据文件不存在,跳过迁移")
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(passwords_file, "r", encoding="utf-8") as f:
|
||||
passwords_data = json.load(f)
|
||||
|
||||
if not passwords_data:
|
||||
print("ℹ️ 密码数据为空,跳过迁移")
|
||||
return 0
|
||||
|
||||
migrated_count = 0
|
||||
for user_id, pwd_info in passwords_data.items():
|
||||
try:
|
||||
# 直接插入密码记录(已经是哈希值)
|
||||
from app.models.user import UserPassword
|
||||
from app.user_password import password_manager as pm
|
||||
|
||||
async with await pm._get_session() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
# 检查是否已存在
|
||||
result = await session.execute(
|
||||
select(UserPassword).where(UserPassword.user_id == user_id)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
print(f"ℹ️ 密码已存在,跳过: {pwd_info['username']} ({user_id})")
|
||||
continue
|
||||
|
||||
# 创建密码记录
|
||||
from datetime import datetime
|
||||
pwd_record = UserPassword(
|
||||
user_id=user_id,
|
||||
username=pwd_info["username"],
|
||||
password_hash=pwd_info["password_hash"],
|
||||
has_custom_password=pwd_info.get("has_custom_password", False),
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
session.add(pwd_record)
|
||||
await session.commit()
|
||||
|
||||
migrated_count += 1
|
||||
print(f"✅ 迁移密码: {pwd_info['username']} ({user_id})")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 迁移密码 {user_id} 失败: {e}")
|
||||
|
||||
print(f"\n✅ 密码数据迁移完成: {migrated_count}/{len(passwords_data)} 个密码")
|
||||
|
||||
# 备份原文件
|
||||
backup_file = DATA_DIR / "user_passwords.json.backup"
|
||||
os.rename(passwords_file, backup_file)
|
||||
print(f"📦 原文件已备份到: {backup_file}")
|
||||
|
||||
return migrated_count
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 迁移密码数据失败: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
async def migrate_admins():
|
||||
"""迁移管理员列表"""
|
||||
admins_file = DATA_DIR / "admins.json"
|
||||
|
||||
if not admins_file.exists():
|
||||
print("❌ 管理员数据文件不存在,跳过迁移")
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(admins_file, "r", encoding="utf-8") as f:
|
||||
admins_data = json.load(f)
|
||||
|
||||
admin_list = admins_data.get("admins", [])
|
||||
|
||||
if not admin_list:
|
||||
print("ℹ️ 管理员列表为空,跳过迁移")
|
||||
return 0
|
||||
|
||||
migrated_count = 0
|
||||
for user_id in admin_list:
|
||||
try:
|
||||
# 设置管理员权限
|
||||
success = await user_manager.set_admin(user_id, True)
|
||||
if success:
|
||||
migrated_count += 1
|
||||
print(f"✅ 设置管理员: {user_id}")
|
||||
else:
|
||||
print(f"⚠️ 用户不存在或已是管理员: {user_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 设置管理员 {user_id} 失败: {e}")
|
||||
|
||||
print(f"\n✅ 管理员数据迁移完成: {migrated_count}/{len(admin_list)} 个管理员")
|
||||
|
||||
# 备份原文件
|
||||
backup_file = DATA_DIR / "admins.json.backup"
|
||||
os.rename(admins_file, backup_file)
|
||||
print(f"📦 原文件已备份到: {backup_file}")
|
||||
|
||||
return migrated_count
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 迁移管理员数据失败: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
async def main():
|
||||
"""主函数"""
|
||||
print("=" * 60)
|
||||
print("用户数据迁移工具 - JSON 到数据库")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# 迁移用户
|
||||
print("📋 步骤 1/3: 迁移用户数据")
|
||||
print("-" * 60)
|
||||
user_count = await migrate_users()
|
||||
print()
|
||||
|
||||
# 迁移密码
|
||||
print("📋 步骤 2/3: 迁移密码数据")
|
||||
print("-" * 60)
|
||||
pwd_count = await migrate_passwords()
|
||||
print()
|
||||
|
||||
# 迁移管理员
|
||||
print("📋 步骤 3/3: 迁移管理员数据")
|
||||
print("-" * 60)
|
||||
admin_count = await migrate_admins()
|
||||
print()
|
||||
|
||||
# 总结
|
||||
print("=" * 60)
|
||||
print("迁移完成")
|
||||
print("=" * 60)
|
||||
print(f"✅ 用户: {user_count}")
|
||||
print(f"✅ 密码: {pwd_count}")
|
||||
print(f"✅ 管理员: {admin_count}")
|
||||
print()
|
||||
print("💡 提示: 原文件已备份为 .backup 后缀")
|
||||
print("💡 如需回滚,请删除数据库文件并恢复 .backup 文件")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
用户数据迁移脚本 - 从JSON文件迁移到PostgreSQL数据库
|
||||
|
||||
使用方法:
|
||||
python migrate_users_to_postgres.py
|
||||
python migrate_users_to_postgres.py --db-url postgresql+asyncpg://user:pass@localhost/dbname
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from app.config import settings, DATA_DIR
|
||||
|
||||
|
||||
async def create_tables(engine):
|
||||
"""创建用户相关表"""
|
||||
from app.database import Base
|
||||
from app.models.user import User, UserPassword
|
||||
|
||||
print("📋 创建数据库表...")
|
||||
async with engine.begin() as conn:
|
||||
# 只创建用户相关的表
|
||||
await conn.run_sync(User.metadata.create_all)
|
||||
await conn.run_sync(UserPassword.metadata.create_all)
|
||||
print("✅ 表创建成功")
|
||||
|
||||
|
||||
async def migrate_users(session):
|
||||
"""迁移用户数据"""
|
||||
from app.models.user import User as UserModel
|
||||
|
||||
users_file = DATA_DIR / "users.json"
|
||||
|
||||
if not users_file.exists():
|
||||
print("ℹ️ 用户数据文件不存在,跳过迁移")
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(users_file, "r", encoding="utf-8") as f:
|
||||
users_data = json.load(f)
|
||||
|
||||
if not users_data:
|
||||
print("ℹ️ 用户数据为空,跳过迁移")
|
||||
return 0
|
||||
|
||||
migrated_count = 0
|
||||
for user_id, user_info in users_data.items():
|
||||
try:
|
||||
# 检查用户是否已存在
|
||||
result = await session.execute(
|
||||
select(UserModel).where(UserModel.user_id == user_id)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
print(f"ℹ️ 用户已存在,跳过: {user_info['username']} ({user_id})")
|
||||
continue
|
||||
|
||||
# 创建用户记录
|
||||
user = UserModel(
|
||||
user_id=user_id,
|
||||
username=user_info["username"],
|
||||
display_name=user_info["display_name"],
|
||||
avatar_url=user_info.get("avatar_url"),
|
||||
trust_level=user_info.get("trust_level", 0),
|
||||
is_admin=user_info.get("is_admin", False),
|
||||
linuxdo_id=user_info["linuxdo_id"],
|
||||
created_at=datetime.fromisoformat(user_info.get("created_at", datetime.now().isoformat())),
|
||||
last_login=datetime.fromisoformat(user_info.get("last_login", datetime.now().isoformat()))
|
||||
)
|
||||
session.add(user)
|
||||
|
||||
migrated_count += 1
|
||||
print(f"✅ 迁移用户: {user_info['username']} ({user_id})")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 迁移用户 {user_id} 失败: {e}")
|
||||
|
||||
await session.commit()
|
||||
print(f"\n✅ 用户数据迁移完成: {migrated_count}/{len(users_data)} 个用户")
|
||||
|
||||
return migrated_count
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 迁移用户数据失败: {e}")
|
||||
await session.rollback()
|
||||
return 0
|
||||
|
||||
|
||||
async def migrate_passwords(session):
|
||||
"""迁移密码数据"""
|
||||
from app.models.user import UserPassword
|
||||
|
||||
passwords_file = DATA_DIR / "user_passwords.json"
|
||||
|
||||
if not passwords_file.exists():
|
||||
print("ℹ️ 密码数据文件不存在,跳过迁移")
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(passwords_file, "r", encoding="utf-8") as f:
|
||||
passwords_data = json.load(f)
|
||||
|
||||
if not passwords_data:
|
||||
print("ℹ️ 密码数据为空,跳过迁移")
|
||||
return 0
|
||||
|
||||
migrated_count = 0
|
||||
for user_id, pwd_info in passwords_data.items():
|
||||
try:
|
||||
# 检查密码是否已存在
|
||||
result = await session.execute(
|
||||
select(UserPassword).where(UserPassword.user_id == user_id)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
print(f"ℹ️ 密码已存在,跳过: {pwd_info['username']} ({user_id})")
|
||||
continue
|
||||
|
||||
# 创建密码记录
|
||||
pwd_record = UserPassword(
|
||||
user_id=user_id,
|
||||
username=pwd_info["username"],
|
||||
password_hash=pwd_info["password_hash"],
|
||||
has_custom_password=pwd_info.get("has_custom_password", False),
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
session.add(pwd_record)
|
||||
|
||||
migrated_count += 1
|
||||
print(f"✅ 迁移密码: {pwd_info['username']} ({user_id})")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 迁移密码 {user_id} 失败: {e}")
|
||||
|
||||
await session.commit()
|
||||
print(f"\n✅ 密码数据迁移完成: {migrated_count}/{len(passwords_data)} 个密码")
|
||||
|
||||
return migrated_count
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 迁移密码数据失败: {e}")
|
||||
await session.rollback()
|
||||
return 0
|
||||
|
||||
|
||||
async def backup_json_files():
|
||||
"""备份原始JSON文件"""
|
||||
files_to_backup = ["users.json", "user_passwords.json", "admins.json"]
|
||||
|
||||
print("\n📦 备份原始文件...")
|
||||
for filename in files_to_backup:
|
||||
source = DATA_DIR / filename
|
||||
if source.exists():
|
||||
backup = DATA_DIR / f"{filename}.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
import shutil
|
||||
shutil.copy2(source, backup)
|
||||
print(f"✅ 备份: {filename} -> {backup.name}")
|
||||
|
||||
|
||||
async def main(db_url=None):
|
||||
"""主函数
|
||||
|
||||
Args:
|
||||
db_url: 可选的数据库URL,如果不提供则使用配置文件中的
|
||||
"""
|
||||
print("=" * 70)
|
||||
print("用户数据迁移工具 - JSON 到 PostgreSQL")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# 确定使用的数据库URL
|
||||
target_db_url = db_url if db_url else settings.database_url
|
||||
|
||||
# 检查数据库配置
|
||||
if "postgresql" not in target_db_url:
|
||||
print("❌ 错误: 未指定 PostgreSQL 数据库")
|
||||
if not db_url:
|
||||
print(f" 当前配置: {settings.database_url}")
|
||||
print(" 请使用 --db-url 参数指定PostgreSQL数据库,或在 .env 中配置 DATABASE_URL")
|
||||
else:
|
||||
print(f" 提供的URL: {target_db_url}")
|
||||
print()
|
||||
print("示例:")
|
||||
print(" python migrate_users_to_postgres.py --db-url postgresql+asyncpg://user:pass@localhost/dbname")
|
||||
return
|
||||
|
||||
# 隐藏密码部分显示
|
||||
display_url = target_db_url
|
||||
if '@' in display_url:
|
||||
parts = display_url.split('@')
|
||||
if ':' in parts[0]:
|
||||
user_part = parts[0].split(':')[0]
|
||||
display_url = f"{user_part}:****@{parts[1]}"
|
||||
|
||||
print(f"📊 目标数据库: {display_url}")
|
||||
print()
|
||||
|
||||
try:
|
||||
# 创建数据库引擎
|
||||
engine = create_async_engine(
|
||||
target_db_url,
|
||||
echo=False,
|
||||
future=True,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
# 创建表
|
||||
await create_tables(engine)
|
||||
print()
|
||||
|
||||
# 创建会话
|
||||
async_session = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
# 迁移用户
|
||||
print("📋 步骤 1/2: 迁移用户数据")
|
||||
print("-" * 70)
|
||||
async with async_session() as session:
|
||||
user_count = await migrate_users(session)
|
||||
print()
|
||||
|
||||
# 迁移密码
|
||||
print("📋 步骤 2/2: 迁移密码数据")
|
||||
print("-" * 70)
|
||||
async with async_session() as session:
|
||||
pwd_count = await migrate_passwords(session)
|
||||
print()
|
||||
|
||||
# 备份原文件
|
||||
await backup_json_files()
|
||||
print()
|
||||
|
||||
# 总结
|
||||
print("=" * 70)
|
||||
print("迁移完成")
|
||||
print("=" * 70)
|
||||
print(f"✅ 用户: {user_count}")
|
||||
print(f"✅ 密码: {pwd_count}")
|
||||
print()
|
||||
print("💡 提示:")
|
||||
print(" - 原文件已备份(带时间戳)")
|
||||
print(" - 可以安全删除 users.json 和 user_passwords.json")
|
||||
print(" - 如需回滚,请从备份文件恢复")
|
||||
print()
|
||||
|
||||
# 关闭引擎
|
||||
await engine.dispose()
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 迁移过程出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 解析命令行参数
|
||||
parser = argparse.ArgumentParser(
|
||||
description="迁移用户数据从JSON到PostgreSQL数据库",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
# 使用 .env 配置的数据库
|
||||
python migrate_users_to_postgres.py
|
||||
|
||||
# 指定数据库URL
|
||||
python migrate_users_to_postgres.py --db-url postgresql+asyncpg://user:pass@localhost/dbname
|
||||
|
||||
# 使用环境变量
|
||||
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/db python migrate_users_to_postgres.py
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--db-url",
|
||||
type=str,
|
||||
help="PostgreSQL数据库连接URL (格式: postgresql+asyncpg://user:password@host:port/database)",
|
||||
default=None
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 运行迁移
|
||||
asyncio.run(main(db_url=args.db_url))
|
||||
@@ -0,0 +1,156 @@
|
||||
# 安装和测试指南
|
||||
|
||||
## 1. 安装前端依赖
|
||||
|
||||
在完成所有代码更改后,需要安装新添加的npm包:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
这将安装以下新依赖:
|
||||
- `react-diff-viewer-continued`: 用于版本对比的diff查看器
|
||||
|
||||
## 2. 重启后端服务
|
||||
|
||||
由于修改了数据模型,需要重启后端服务以加载新的模型定义:
|
||||
|
||||
```bash
|
||||
# 在项目根目录
|
||||
cd backend
|
||||
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## 3. 启动前端开发服务器
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 4. 测试功能流程
|
||||
|
||||
### 4.1 基本流程测试
|
||||
|
||||
1. **打开章节列表**
|
||||
- 进入任意项目
|
||||
- 查看章节列表
|
||||
|
||||
2. **分析章节**
|
||||
- 点击某个章节的"分析"按钮
|
||||
- 等待AI分析完成
|
||||
- 查看分析结果和改进建议
|
||||
|
||||
3. **重新生成章节**
|
||||
- 在分析结果页面,点击"根据建议重新生成"
|
||||
- 选择要应用的建议
|
||||
- 可以添加自定义修改要求
|
||||
- 配置生成参数(字数、保留元素等)
|
||||
- 勾选"保存为版本历史"(不勾选自动应用)
|
||||
- 点击"开始重新生成"
|
||||
- 观察流式生成过程
|
||||
|
||||
4. **查看版本对比**
|
||||
- 生成完成后,点击"查看版本对比"按钮
|
||||
- 进入版本管理器界面
|
||||
|
||||
5. **版本管理操作**
|
||||
- **版本列表**: 查看所有历史版本
|
||||
- **预览版本**: 点击"预览"查看某个版本的完整内容
|
||||
- **对比版本**:
|
||||
- 点击第一个版本的"对比"按钮
|
||||
- 再点击第二个版本的"对比"按钮
|
||||
- 自动切换到"对比"标签页
|
||||
- 查看并排diff对比
|
||||
- **恢复版本**: 点击"恢复"将章节内容还原到该版本
|
||||
- **删除版本**: 删除不需要的历史版本(当前激活版本不能删除)
|
||||
|
||||
### 4.2 测试场景
|
||||
|
||||
#### 场景1:优化情感描写
|
||||
1. 分析一个章节
|
||||
2. 查看建议中关于情感的建议
|
||||
3. 重新生成时选择情感相关建议
|
||||
4. 设置重点优化方向为"情感渲染"
|
||||
5. 生成后对比新旧版本的差异
|
||||
|
||||
#### 场景2:调整节奏
|
||||
1. 选择节奏问题的建议
|
||||
2. 添加自定义指令:"加快前半部分节奏,增强紧张感"
|
||||
3. 设置目标字数适当减少(如从3000减到2500)
|
||||
4. 生成后查看结构变化
|
||||
|
||||
#### 场景3:版本管理
|
||||
1. 对同一章节重新生成多次(使用不同建议)
|
||||
2. 在版本管理器中浏览所有版本
|
||||
3. 对比不同版本的差异
|
||||
4. 选择最满意的版本恢复
|
||||
|
||||
## 5. 验证清单
|
||||
|
||||
- [ ] 依赖安装无错误
|
||||
- [ ] 前后端服务正常启动
|
||||
- [ ] 章节分析功能正常
|
||||
- [ ] 重新生成功能正常
|
||||
- [ ] 流式生成显示正常
|
||||
- [ ] 版本保存成功
|
||||
- [ ] 版本列表显示正确
|
||||
- [ ] 版本预览功能正常
|
||||
- [ ] 版本对比diff显示正确
|
||||
- [ ] 版本恢复功能正常
|
||||
- [ ] 版本删除功能正常
|
||||
- [ ] 移动端适配正常
|
||||
|
||||
## 6. 常见问题
|
||||
|
||||
### Q1: 依赖安装失败
|
||||
```bash
|
||||
# 清除缓存重试
|
||||
npm cache clean --force
|
||||
npm install
|
||||
```
|
||||
|
||||
### Q2: 后端启动报错
|
||||
- 检查是否运行了数据库迁移脚本
|
||||
- 确认模型定义与数据库表结构一致
|
||||
|
||||
### Q3: 版本对比不显示
|
||||
- 检查浏览器控制台是否有JavaScript错误
|
||||
- 确认react-diff-viewer-continued正确安装
|
||||
|
||||
### Q4: 重新生成后看不到新内容
|
||||
- 检查是否勾选了"自动应用"
|
||||
- 查看版本管理器中是否有新版本记录
|
||||
|
||||
## 7. 性能优化建议
|
||||
|
||||
1. **首次加载优化**
|
||||
- react-diff-viewer-continued是较大的依赖
|
||||
- 可以考虑代码分割(lazy loading)
|
||||
|
||||
2. **版本列表优化**
|
||||
- 如果版本过多,考虑分页加载
|
||||
- 添加版本数量限制提示
|
||||
|
||||
3. **diff计算优化**
|
||||
- 对于超长文本,可以限制diff行数
|
||||
- 添加加载提示
|
||||
|
||||
## 8. 下一步优化方向
|
||||
|
||||
1. **AI质量评分对比**
|
||||
- 在版本对比时显示质量分数变化
|
||||
- 自动标注改进/退步的指标
|
||||
|
||||
2. **批量操作**
|
||||
- 支持批量删除历史版本
|
||||
- 支持版本导出/导入
|
||||
|
||||
3. **协作功能**
|
||||
- 版本评论和讨论
|
||||
- 多人协作编辑
|
||||
|
||||
4. **智能推荐**
|
||||
- 基于历史生成结果推荐最佳配置
|
||||
- 学习用户偏好自动调整参数
|
||||
Generated
+337
-23
@@ -14,6 +14,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"react": "^18.3.1",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-diff-viewer-continued": "^3.4.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"zustand": "^5.0.8"
|
||||
@@ -135,7 +136,6 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
@@ -191,7 +191,6 @@
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
|
||||
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.3",
|
||||
@@ -225,7 +224,6 @@
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -235,7 +233,6 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
|
||||
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.27.1",
|
||||
@@ -277,7 +274,6 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -287,7 +283,6 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -321,7 +316,6 @@
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
|
||||
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.4"
|
||||
@@ -378,7 +372,6 @@
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
@@ -393,7 +386,6 @@
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz",
|
||||
"integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
@@ -412,7 +404,6 @@
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
|
||||
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
@@ -422,18 +413,136 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin": {
|
||||
"version": "11.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||
"integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.16.7",
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/hash": "^0.9.2",
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
"@emotion/serialize": "^1.3.3",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"convert-source-map": "^1.5.0",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"find-root": "^1.1.0",
|
||||
"source-map": "^0.5.7",
|
||||
"stylis": "4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin/node_modules/@emotion/hash": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin/node_modules/stylis": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
|
||||
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/cache": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
|
||||
"integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
"@emotion/sheet": "^1.4.0",
|
||||
"@emotion/utils": "^1.4.2",
|
||||
"@emotion/weak-memoize": "^0.4.0",
|
||||
"stylis": "4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/cache/node_modules/stylis": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
|
||||
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/css": {
|
||||
"version": "11.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.13.5.tgz",
|
||||
"integrity": "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/cache": "^11.13.5",
|
||||
"@emotion/serialize": "^1.3.3",
|
||||
"@emotion/sheet": "^1.4.0",
|
||||
"@emotion/utils": "^1.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
|
||||
"integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/memoize": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
|
||||
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/serialize": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
|
||||
"integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/hash": "^0.9.2",
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
"@emotion/unitless": "^0.10.0",
|
||||
"@emotion/utils": "^1.4.2",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/serialize/node_modules/@emotion/hash": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/serialize/node_modules/@emotion/unitless": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
|
||||
"integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/sheet": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
|
||||
"integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/unitless": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
|
||||
"integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/utils": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
|
||||
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/weak-memoize": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
|
||||
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz",
|
||||
@@ -1089,7 +1198,6 @@
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -1111,7 +1219,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -1121,14 +1228,12 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@@ -1726,6 +1831,12 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse-json": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -2211,6 +2322,21 @@
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-macros": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
|
||||
"integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"cosmiconfig": "^7.0.0",
|
||||
"resolve": "^1.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -2303,7 +2429,6 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -2414,6 +2539,31 @@
|
||||
"toggle-selection": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||
"integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/parse-json": "^4.0.0",
|
||||
"import-fresh": "^3.2.1",
|
||||
"parse-json": "^5.0.0",
|
||||
"path-type": "^4.0.0",
|
||||
"yaml": "^1.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/cosmiconfig/node_modules/yaml": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2454,7 +2604,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -2484,6 +2633,15 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
|
||||
"integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -2505,6 +2663,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -2606,7 +2773,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -2879,6 +3045,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-root": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
||||
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
@@ -3147,7 +3319,6 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parent-module": "^1.0.0",
|
||||
@@ -3170,6 +3341,27 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -3233,7 +3425,6 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jsesc": "bin/jsesc"
|
||||
@@ -3249,6 +3440,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-parse-even-better-errors": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -3309,6 +3506,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -3431,7 +3634,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@@ -3530,7 +3732,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"callsites": "^3.0.0"
|
||||
@@ -3539,6 +3740,24 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.0.0",
|
||||
"error-ex": "^1.3.1",
|
||||
"json-parse-even-better-errors": "^2.3.0",
|
||||
"lines-and-columns": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -3559,11 +3778,25 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
@@ -4316,6 +4549,32 @@
|
||||
"react-dom": "^16.8.5 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-diff-viewer-continued": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/react-diff-viewer-continued/-/react-diff-viewer-continued-3.4.0.tgz",
|
||||
"integrity": "sha512-kMZmUyb3Pv5L9vUtCfIGYsdOHs8mUojblGy1U1Sm0D7FhAOEsH9QhnngEIRo5hXWIPNGupNRJls1TJ6Eqx84eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.11.2",
|
||||
"classnames": "^2.3.2",
|
||||
"diff": "^5.1.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-diff-viewer-continued/node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
@@ -4423,11 +4682,30 @@
|
||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.16.1",
|
||||
"path-parse": "^1.0.7",
|
||||
"supports-preserve-symlinks-flag": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"resolve": "bin/resolve"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
@@ -4561,6 +4839,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -4609,6 +4896,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/throttle-debounce": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
|
||||
@@ -4951,6 +5250,21 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"react": "^18.3.1",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-diff-viewer-continued": "^3.4.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"zustand": "^5.0.8"
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ReloadOutlined
|
||||
ReloadOutlined,
|
||||
EditOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { AnalysisTask, ChapterAnalysisResponse } from '../types';
|
||||
import ChapterRegenerationModal from './ChapterRegenerationModal';
|
||||
import ChapterContentComparison from './ChapterContentComparison';
|
||||
|
||||
// 判断是否为移动设备
|
||||
const isMobileDevice = () => window.innerWidth < 768;
|
||||
@@ -29,6 +32,11 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isMobile, setIsMobile] = useState(isMobileDevice());
|
||||
const [regenerationModalVisible, setRegenerationModalVisible] = useState(false);
|
||||
const [comparisonModalVisible, setComparisonModalVisible] = useState(false);
|
||||
const [chapterInfo, setChapterInfo] = useState<{ title: string; chapter_number: number; content: string } | null>(null);
|
||||
const [newGeneratedContent, setNewGeneratedContent] = useState('');
|
||||
const [newContentWordCount, setNewContentWordCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && chapterId) {
|
||||
@@ -54,6 +62,17 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 同时获取章节信息
|
||||
const chapterResponse = await fetch(`/api/chapters/${chapterId}`);
|
||||
if (chapterResponse.ok) {
|
||||
const chapterData = await chapterResponse.json();
|
||||
setChapterInfo({
|
||||
title: chapterData.title,
|
||||
chapter_number: chapterData.chapter_number,
|
||||
content: chapterData.content || ''
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/chapters/${chapterId}/analysis/status`);
|
||||
|
||||
if (response.status === 404) {
|
||||
@@ -199,6 +218,17 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
);
|
||||
};
|
||||
|
||||
// 将分析建议转换为重新生成组件需要的格式
|
||||
const convertSuggestionsForRegeneration = () => {
|
||||
if (!analysis?.analysis?.suggestions) return [];
|
||||
|
||||
return analysis.analysis.suggestions.map((suggestion, index) => ({
|
||||
category: '改进建议',
|
||||
content: suggestion,
|
||||
priority: index < 3 ? 'high' : 'medium'
|
||||
}));
|
||||
};
|
||||
|
||||
const renderAnalysisResult = () => {
|
||||
if (!analysis) return null;
|
||||
|
||||
@@ -215,6 +245,29 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
icon: <TrophyOutlined />,
|
||||
children: (
|
||||
<div style={{ height: isMobile ? 'calc(80vh - 180px)' : 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
{/* 根据建议重新生成按钮 */}
|
||||
{analysis_data.suggestions && analysis_data.suggestions.length > 0 && (
|
||||
<Alert
|
||||
message="发现改进建议"
|
||||
description={
|
||||
<div>
|
||||
<p style={{ marginBottom: 12 }}>AI已分析出 {analysis_data.suggestions.length} 条改进建议,您可以根据这些建议重新生成章节内容。</p>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => setRegenerationModalVisible(true)}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
>
|
||||
根据建议重新生成
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card title="整体评分" style={{ marginBottom: 16 }} size={isMobile ? 'small' : 'default'}>
|
||||
<Row gutter={isMobile ? 8 : 16}>
|
||||
<Col span={isMobile ? 12 : 6}>
|
||||
@@ -560,6 +613,50 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
|
||||
{task && task.status !== 'completed' && renderProgress()}
|
||||
{task && task.status === 'completed' && analysis && renderAnalysisResult()}
|
||||
|
||||
{/* 重新生成Modal */}
|
||||
{chapterInfo && (
|
||||
<ChapterRegenerationModal
|
||||
visible={regenerationModalVisible}
|
||||
onCancel={() => setRegenerationModalVisible(false)}
|
||||
onSuccess={(newContent: string, wordCount: number) => {
|
||||
// 保存新生成的内容
|
||||
setNewGeneratedContent(newContent);
|
||||
setNewContentWordCount(wordCount);
|
||||
// 关闭重新生成对话框
|
||||
setRegenerationModalVisible(false);
|
||||
// 打开对比界面
|
||||
setComparisonModalVisible(true);
|
||||
}}
|
||||
chapterId={chapterId}
|
||||
chapterTitle={chapterInfo.title}
|
||||
chapterNumber={chapterInfo.chapter_number}
|
||||
suggestions={convertSuggestionsForRegeneration()}
|
||||
hasAnalysis={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 内容对比组件 */}
|
||||
{chapterInfo && comparisonModalVisible && (
|
||||
<ChapterContentComparison
|
||||
visible={comparisonModalVisible}
|
||||
onClose={() => setComparisonModalVisible(false)}
|
||||
chapterId={chapterId}
|
||||
chapterTitle={chapterInfo.title}
|
||||
originalContent={chapterInfo.content}
|
||||
newContent={newGeneratedContent}
|
||||
wordCount={newContentWordCount}
|
||||
onApply={() => {
|
||||
// 应用新内容后刷新章节信息
|
||||
fetchAnalysisStatus();
|
||||
}}
|
||||
onDiscard={() => {
|
||||
// 放弃新内容,清空状态
|
||||
setNewGeneratedContent('');
|
||||
setNewContentWordCount(0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Button, Card, Statistic, Row, Col, message } from 'antd';
|
||||
import { CheckOutlined, CloseOutlined, SwapOutlined } from '@ant-design/icons';
|
||||
import ReactDiffViewer from 'react-diff-viewer-continued';
|
||||
|
||||
interface ChapterContentComparisonProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
chapterId: string;
|
||||
chapterTitle: string;
|
||||
originalContent: string;
|
||||
newContent: string;
|
||||
wordCount: number;
|
||||
onApply: () => void;
|
||||
onDiscard: () => void;
|
||||
}
|
||||
|
||||
const ChapterContentComparison: React.FC<ChapterContentComparisonProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
chapterId,
|
||||
chapterTitle,
|
||||
originalContent,
|
||||
newContent,
|
||||
wordCount,
|
||||
onApply,
|
||||
onDiscard
|
||||
}) => {
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'split' | 'unified'>('split');
|
||||
|
||||
const originalWordCount = originalContent.length;
|
||||
const wordCountDiff = wordCount - originalWordCount;
|
||||
const wordCountDiffPercent = ((wordCountDiff / originalWordCount) * 100).toFixed(1);
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplying(true);
|
||||
try {
|
||||
const response = await fetch(`/api/chapters/${chapterId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: newContent
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('应用新内容失败');
|
||||
}
|
||||
|
||||
message.success('新内容已应用!正在触发章节分析...');
|
||||
|
||||
// 触发章节分析
|
||||
try {
|
||||
const analysisResponse = await fetch(`/api/chapters/${chapterId}/analyze`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (analysisResponse.ok) {
|
||||
message.success('章节分析已开始,请稍后查看结果');
|
||||
} else {
|
||||
message.warning('章节分析触发失败,您可以手动触发分析');
|
||||
}
|
||||
} catch (analysisError) {
|
||||
console.error('触发分析失败:', analysisError);
|
||||
message.warning('章节分析触发失败,您可以手动触发分析');
|
||||
}
|
||||
|
||||
onApply();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '应用失败');
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
Modal.confirm({
|
||||
title: '确认放弃',
|
||||
content: '确定要放弃新生成的内容吗?此操作不可恢复。',
|
||||
okText: '确定放弃',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => {
|
||||
onDiscard();
|
||||
onClose();
|
||||
message.info('已放弃新内容');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`内容对比 - ${chapterTitle}`}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width="95%"
|
||||
centered
|
||||
style={{ maxWidth: 1600 }}
|
||||
footer={[
|
||||
<Button
|
||||
key="discard"
|
||||
danger
|
||||
icon={<CloseOutlined />}
|
||||
onClick={handleDiscard}
|
||||
>
|
||||
放弃新内容
|
||||
</Button>,
|
||||
<Button
|
||||
key="toggle"
|
||||
icon={<SwapOutlined />}
|
||||
onClick={() => setViewMode(viewMode === 'split' ? 'unified' : 'split')}
|
||||
>
|
||||
切换视图
|
||||
</Button>,
|
||||
<Button
|
||||
key="apply"
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
loading={applying}
|
||||
onClick={handleApply}
|
||||
>
|
||||
应用新内容
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
{/* 统计信息 */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="原内容字数"
|
||||
value={originalWordCount}
|
||||
suffix="字"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="新内容字数"
|
||||
value={wordCount}
|
||||
suffix="字"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="字数变化"
|
||||
value={wordCountDiff}
|
||||
suffix="字"
|
||||
valueStyle={{ color: wordCountDiff > 0 ? '#3f8600' : '#cf1322' }}
|
||||
prefix={wordCountDiff > 0 ? '+' : ''}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="变化比例"
|
||||
value={wordCountDiffPercent}
|
||||
suffix="%"
|
||||
valueStyle={{ color: Math.abs(parseFloat(wordCountDiffPercent)) < 10 ? '#1890ff' : '#faad14' }}
|
||||
prefix={wordCountDiff > 0 ? '+' : ''}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 内容对比 */}
|
||||
<div style={{
|
||||
maxHeight: 'calc(90vh - 300px)',
|
||||
overflow: 'auto',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 4
|
||||
}}>
|
||||
<ReactDiffViewer
|
||||
oldValue={originalContent}
|
||||
newValue={newContent}
|
||||
splitView={viewMode === 'split'}
|
||||
leftTitle="原内容"
|
||||
rightTitle="新内容"
|
||||
showDiffOnly={false}
|
||||
useDarkTheme={false}
|
||||
styles={{
|
||||
variables: {
|
||||
light: {
|
||||
diffViewerBackground: '#fff',
|
||||
addedBackground: '#e6ffed',
|
||||
addedColor: '#24292e',
|
||||
removedBackground: '#ffeef0',
|
||||
removedColor: '#24292e',
|
||||
wordAddedBackground: '#acf2bd',
|
||||
wordRemovedBackground: '#fdb8c0',
|
||||
addedGutterBackground: '#cdffd8',
|
||||
removedGutterBackground: '#ffdce0',
|
||||
gutterBackground: '#f6f8fa',
|
||||
gutterBackgroundDark: '#f3f4f6',
|
||||
highlightBackground: '#fffbdd',
|
||||
highlightGutterBackground: '#fff5b1',
|
||||
},
|
||||
},
|
||||
line: {
|
||||
padding: '10px 2px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '20px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChapterContentComparison;
|
||||
@@ -0,0 +1,402 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Checkbox,
|
||||
InputNumber,
|
||||
Space,
|
||||
Alert,
|
||||
Divider,
|
||||
Progress,
|
||||
Tag,
|
||||
message,
|
||||
Collapse,
|
||||
Card,
|
||||
Radio
|
||||
} from 'antd';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { ssePost } from '../utils/sseClient';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Panel } = Collapse;
|
||||
|
||||
interface Suggestion {
|
||||
category: string;
|
||||
content: string;
|
||||
priority: string;
|
||||
}
|
||||
|
||||
interface ChapterRegenerationModalProps {
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
onSuccess: (newContent: string, wordCount: number) => void;
|
||||
chapterId: string;
|
||||
chapterTitle: string;
|
||||
chapterNumber: number;
|
||||
suggestions?: Suggestion[];
|
||||
hasAnalysis: boolean;
|
||||
}
|
||||
|
||||
|
||||
const ChapterRegenerationModal: React.FC<ChapterRegenerationModalProps> = ({
|
||||
visible,
|
||||
onCancel,
|
||||
onSuccess,
|
||||
chapterId,
|
||||
chapterTitle,
|
||||
chapterNumber,
|
||||
suggestions = [],
|
||||
hasAnalysis
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [status, setStatus] = useState<'idle' | 'generating' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [wordCount, setWordCount] = useState(0);
|
||||
const [selectedSuggestions, setSelectedSuggestions] = useState<number[]>([]);
|
||||
const [modificationSource, setModificationSource] = useState<'custom' | 'analysis_suggestions' | 'mixed'>('custom');
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// 重置状态
|
||||
setStatus('idle');
|
||||
setProgress(0);
|
||||
setErrorMessage('');
|
||||
setWordCount(0);
|
||||
setSelectedSuggestions([]);
|
||||
|
||||
// 如果有分析建议,默认选择混合模式
|
||||
if (hasAnalysis && suggestions.length > 0) {
|
||||
setModificationSource('mixed');
|
||||
} else {
|
||||
setModificationSource('custom');
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
form.setFieldsValue({
|
||||
modification_source: hasAnalysis && suggestions.length > 0 ? 'mixed' : 'custom',
|
||||
target_word_count: 3000,
|
||||
preserve_structure: false,
|
||||
preserve_character_traits: true,
|
||||
focus_areas: []
|
||||
});
|
||||
}
|
||||
}, [visible, hasAnalysis, suggestions.length, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// 验证至少提供一种修改指令
|
||||
if (values.modification_source === 'custom' && !values.custom_instructions?.trim()) {
|
||||
message.error('请输入自定义修改要求');
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.modification_source === 'analysis_suggestions' && selectedSuggestions.length === 0) {
|
||||
message.error('请选择至少一条分析建议');
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.modification_source === 'mixed' &&
|
||||
selectedSuggestions.length === 0 &&
|
||||
!values.custom_instructions?.trim()) {
|
||||
message.error('请至少选择一条建议或输入自定义要求');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setStatus('generating');
|
||||
setProgress(0);
|
||||
setWordCount(0);
|
||||
|
||||
// 构建请求数据
|
||||
const requestData: any = {
|
||||
modification_source: values.modification_source,
|
||||
custom_instructions: values.custom_instructions,
|
||||
selected_suggestion_indices: selectedSuggestions,
|
||||
preserve_elements: {
|
||||
preserve_structure: values.preserve_structure,
|
||||
preserve_dialogues: values.preserve_dialogues || [],
|
||||
preserve_plot_points: values.preserve_plot_points || [],
|
||||
preserve_character_traits: values.preserve_character_traits
|
||||
},
|
||||
style_id: values.style_id,
|
||||
target_word_count: values.target_word_count,
|
||||
focus_areas: values.focus_areas || []
|
||||
};
|
||||
|
||||
let accumulatedContent = '';
|
||||
let currentWordCount = 0;
|
||||
|
||||
// 使用SSE流式生成
|
||||
await ssePost(
|
||||
`/api/chapters/${chapterId}/regenerate-stream`,
|
||||
requestData,
|
||||
{
|
||||
onProgress: (_msg: string, prog: number, _status: string, wordCount?: number) => {
|
||||
// 后端发送的进度消息
|
||||
setProgress(prog);
|
||||
// 如果后端提供了word_count,使用它;否则使用累积的字数
|
||||
if (wordCount !== undefined) {
|
||||
setWordCount(wordCount);
|
||||
currentWordCount = wordCount;
|
||||
}
|
||||
},
|
||||
onChunk: (content: string) => {
|
||||
// 累积内容块
|
||||
accumulatedContent += content;
|
||||
// 仅作为备用字数统计
|
||||
currentWordCount = accumulatedContent.length;
|
||||
// 不再自己计算进度,完全依赖后端发送的progress消息
|
||||
},
|
||||
onResult: (data: any) => {
|
||||
// 生成完成,确保使用最新的累积内容
|
||||
setProgress(100);
|
||||
setStatus('success');
|
||||
const finalWordCount = data.word_count || currentWordCount;
|
||||
setWordCount(finalWordCount);
|
||||
message.success('重新生成完成!');
|
||||
|
||||
// 直接调用onSuccess打开对比界面,传递最终的累积内容
|
||||
setTimeout(() => {
|
||||
onSuccess(accumulatedContent, finalWordCount);
|
||||
}, 500);
|
||||
},
|
||||
onComplete: () => {
|
||||
// SSE完成
|
||||
},
|
||||
onError: (error: string, code?: number) => {
|
||||
console.error('SSE Error:', error, code);
|
||||
setStatus('error');
|
||||
setErrorMessage(error || '生成失败');
|
||||
message.error('重新生成失败: ' + (error || '未知错误'));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('提交失败:', error);
|
||||
setStatus('error');
|
||||
setErrorMessage(error.message || '提交失败');
|
||||
message.error('操作失败: ' + (error.message || '未知错误'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuggestionSelect = (index: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedSuggestions([...selectedSuggestions, index]);
|
||||
} else {
|
||||
setSelectedSuggestions(selectedSuggestions.filter(i => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (loading) {
|
||||
Modal.confirm({
|
||||
title: '确认取消',
|
||||
content: '生成正在进行中,确定要取消吗?',
|
||||
onOk: () => {
|
||||
setLoading(false);
|
||||
setStatus('idle');
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`重新生成章节 - 第${chapterNumber}章:${chapterTitle}`}
|
||||
open={visible}
|
||||
onCancel={handleCancel}
|
||||
width={800}
|
||||
centered
|
||||
footer={
|
||||
status === 'success' ? null : (
|
||||
[
|
||||
<Button key="cancel" onClick={handleCancel} disabled={loading}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
icon={<ReloadOutlined />}
|
||||
>
|
||||
开始重新生成
|
||||
</Button>
|
||||
]
|
||||
)
|
||||
}
|
||||
>
|
||||
{status === 'generating' && (
|
||||
<Alert
|
||||
message="正在重新生成中..."
|
||||
description={
|
||||
<div>
|
||||
<Progress percent={progress} status="active" />
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
|
||||
已生成 {wordCount} 字
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<Alert
|
||||
message="重新生成成功!"
|
||||
description={`共生成 ${wordCount} 字`}
|
||||
type="success"
|
||||
showIcon
|
||||
icon={<CheckCircleOutlined />}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<Alert
|
||||
message="生成失败"
|
||||
description={errorMessage}
|
||||
type="error"
|
||||
showIcon
|
||||
icon={<CloseCircleOutlined />}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
disabled={loading || status === 'success'}
|
||||
>
|
||||
{/* 修改来源 */}
|
||||
<Form.Item
|
||||
name="modification_source"
|
||||
label="修改来源"
|
||||
rules={[{ required: true, message: '请选择修改来源' }]}
|
||||
>
|
||||
<Radio.Group onChange={(e) => setModificationSource(e.target.value)}>
|
||||
<Radio value="custom">仅自定义修改</Radio>
|
||||
{hasAnalysis && suggestions.length > 0 && (
|
||||
<>
|
||||
<Radio value="analysis_suggestions">仅分析建议</Radio>
|
||||
<Radio value="mixed">混合模式</Radio>
|
||||
</>
|
||||
)}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{/* 分析建议选择 */}
|
||||
{hasAnalysis && suggestions.length > 0 &&
|
||||
(modificationSource === 'analysis_suggestions' || modificationSource === 'mixed') && (
|
||||
<Form.Item label={`选择分析建议 (${selectedSuggestions.length}/${suggestions.length})`}>
|
||||
<Card size="small" style={{ maxHeight: 300, overflow: 'auto' }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<Checkbox
|
||||
key={index}
|
||||
checked={selectedSuggestions.includes(index)}
|
||||
onChange={(e) => handleSuggestionSelect(index, e.target.checked)}
|
||||
>
|
||||
<Space>
|
||||
<Tag color={
|
||||
suggestion.priority === 'high' ? 'red' :
|
||||
suggestion.priority === 'medium' ? 'orange' : 'blue'
|
||||
}>
|
||||
{suggestion.category}
|
||||
</Tag>
|
||||
<span style={{ fontSize: 13 }}>{suggestion.content}</span>
|
||||
</Space>
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* 自定义修改要求 */}
|
||||
{(modificationSource === 'custom' || modificationSource === 'mixed') && (
|
||||
<Form.Item
|
||||
name="custom_instructions"
|
||||
label="自定义修改要求"
|
||||
tooltip="描述你希望如何改进这个章节"
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder="例如:增强情感渲染,让主角的内心戏更加细腻..."
|
||||
showCount
|
||||
maxLength={1000}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* 高级选项 */}
|
||||
<Collapse ghost>
|
||||
<Panel header="高级选项" key="advanced">
|
||||
{/* 重点优化方向 */}
|
||||
<Form.Item
|
||||
name="focus_areas"
|
||||
label="重点优化方向"
|
||||
>
|
||||
<Checkbox.Group>
|
||||
<Space direction="vertical">
|
||||
<Checkbox value="pacing">节奏把控</Checkbox>
|
||||
<Checkbox value="emotion">情感渲染</Checkbox>
|
||||
<Checkbox value="description">场景描写</Checkbox>
|
||||
<Checkbox value="dialogue">对话质量</Checkbox>
|
||||
<Checkbox value="conflict">冲突强度</Checkbox>
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 保留元素 */}
|
||||
<Form.Item label="保留元素">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Form.Item name="preserve_structure" valuePropName="checked" noStyle>
|
||||
<Checkbox>保留整体结构和情节框架</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="preserve_character_traits" valuePropName="checked" noStyle>
|
||||
<Checkbox>保持角色性格一致</Checkbox>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 生成参数 */}
|
||||
<Form.Item
|
||||
name="target_word_count"
|
||||
label="目标字数"
|
||||
tooltip="生成内容的目标字数,实际字数可能有±20%的浮动"
|
||||
>
|
||||
<InputNumber min={500} max={10000} step={500} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</Form>
|
||||
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChapterRegenerationModal;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dropdown, Avatar, Space, Typography, message, Modal, Table, Button, Tag, Popconfirm, Pagination } from 'antd';
|
||||
import { UserOutlined, LogoutOutlined, TeamOutlined, CrownOutlined } from '@ant-design/icons';
|
||||
import { Dropdown, Avatar, Space, Typography, message, Modal, Table, Button, Tag, Popconfirm, Pagination, Form, Input } from 'antd';
|
||||
import { UserOutlined, LogoutOutlined, TeamOutlined, CrownOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { authApi, userApi } from '../services/api';
|
||||
import type { User } from '../types';
|
||||
import type { MenuProps } from 'antd';
|
||||
@@ -10,10 +10,13 @@ const { Text } = Typography;
|
||||
export default function UserMenu() {
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [changePasswordForm] = Form.useForm();
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadCurrentUser();
|
||||
@@ -84,6 +87,21 @@ export default function UserMenu() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (values: { oldPassword: string; newPassword: string }) => {
|
||||
try {
|
||||
setChangingPassword(true);
|
||||
await authApi.setPassword(values.newPassword);
|
||||
message.success('密码修改成功');
|
||||
setShowChangePassword(false);
|
||||
changePasswordForm.resetFields();
|
||||
} catch (error: any) {
|
||||
console.error('修改密码失败:', error);
|
||||
message.error(error.response?.data?.detail || '修改密码失败');
|
||||
} finally {
|
||||
setChangingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'user-info',
|
||||
@@ -110,6 +128,15 @@ export default function UserMenu() {
|
||||
}, {
|
||||
type: 'divider' as const,
|
||||
}] : []),
|
||||
{
|
||||
key: 'change-password',
|
||||
icon: <LockOutlined />,
|
||||
label: '修改密码',
|
||||
onClick: () => setShowChangePassword(true),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
@@ -341,6 +368,77 @@ export default function UserMenu() {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="修改密码"
|
||||
open={showChangePassword}
|
||||
onCancel={() => {
|
||||
setShowChangePassword(false);
|
||||
changePasswordForm.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
width={480}
|
||||
centered
|
||||
>
|
||||
<Form
|
||||
form={changePasswordForm}
|
||||
layout="vertical"
|
||||
onFinish={handleChangePassword}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
label="新密码"
|
||||
name="newPassword"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 6, message: '密码至少6个字符' },
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="请输入新密码(至少6个字符)"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="确认密码"
|
||||
name="confirmPassword"
|
||||
dependencies={['newPassword']}
|
||||
rules={[
|
||||
{ required: true, message: '请确认新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="请再次输入新密码"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => {
|
||||
setShowChangePassword(false);
|
||||
changePasswordForm.resetFields();
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit" loading={changingPassword}>
|
||||
确认修改
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Spin, Result, Button } from 'antd';
|
||||
import { Spin, Result, Button, Modal, Input, message } from 'antd';
|
||||
import { authApi } from '../services/api';
|
||||
import AnnouncementModal from '../components/AnnouncementModal';
|
||||
|
||||
@@ -9,6 +9,11 @@ export default function AuthCallback() {
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [passwordStatus, setPasswordStatus] = useState<any>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [settingPassword, setSettingPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
@@ -17,8 +22,21 @@ export default function AuthCallback() {
|
||||
// 这里只需要验证登录状态
|
||||
await authApi.getCurrentUser();
|
||||
|
||||
// 检查密码状态
|
||||
const pwdStatus = await authApi.getPasswordStatus();
|
||||
setPasswordStatus(pwdStatus);
|
||||
|
||||
setStatus('success');
|
||||
|
||||
// 只有在用户完全没有密码时才显示密码设置提示
|
||||
// 如果已经有密码(无论是默认密码还是自定义密码),都不再提示
|
||||
if (!pwdStatus.has_password) {
|
||||
setTimeout(() => {
|
||||
setShowPasswordModal(true);
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 从 sessionStorage 获取重定向地址
|
||||
const redirect = sessionStorage.getItem('login_redirect') || '/';
|
||||
sessionStorage.removeItem('login_redirect');
|
||||
@@ -105,6 +123,70 @@ export default function AuthCallback() {
|
||||
localStorage.setItem('announcement_do_not_show_until', tomorrow.getTime().toString());
|
||||
};
|
||||
|
||||
const handleSetPassword = async () => {
|
||||
if (!newPassword) {
|
||||
message.error('请输入新密码');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
message.error('密码长度至少为6个字符');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
message.error('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
setSettingPassword(true);
|
||||
try {
|
||||
await authApi.setPassword(newPassword);
|
||||
message.success('密码设置成功');
|
||||
setShowPasswordModal(false);
|
||||
|
||||
// 继续后续流程
|
||||
const redirect = sessionStorage.getItem('login_redirect') || '/';
|
||||
sessionStorage.removeItem('login_redirect');
|
||||
|
||||
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until');
|
||||
const now = new Date().getTime();
|
||||
|
||||
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) {
|
||||
setTimeout(() => {
|
||||
setShowAnnouncement(true);
|
||||
}, 500);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
navigate(redirect);
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('密码设置失败,请重试');
|
||||
} finally {
|
||||
setSettingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipPasswordSetting = () => {
|
||||
setShowPasswordModal(false);
|
||||
|
||||
// 继续后续流程
|
||||
const redirect = sessionStorage.getItem('login_redirect') || '/';
|
||||
sessionStorage.removeItem('login_redirect');
|
||||
|
||||
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until');
|
||||
const now = new Date().getTime();
|
||||
|
||||
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) {
|
||||
setTimeout(() => {
|
||||
setShowAnnouncement(true);
|
||||
}, 500);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
navigate(redirect);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnnouncementModal
|
||||
@@ -112,6 +194,62 @@ export default function AuthCallback() {
|
||||
onClose={handleAnnouncementClose}
|
||||
onDoNotShowToday={handleDoNotShowToday}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="设置账号密码"
|
||||
open={showPasswordModal}
|
||||
centered
|
||||
onOk={handleSetPassword}
|
||||
onCancel={handleSkipPasswordSetting}
|
||||
confirmLoading={settingPassword}
|
||||
okText="设置密码"
|
||||
cancelText="暂不设置"
|
||||
width={500}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<p>您已成功通过 Linux DO 授权登录!</p>
|
||||
<p>系统已为您自动生成默认密码,您可以选择设置自定义密码或继续使用默认密码。</p>
|
||||
{passwordStatus?.default_password && (
|
||||
<div style={{
|
||||
background: '#f0f2f5',
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
marginTop: 12
|
||||
}}>
|
||||
<strong>账号:</strong>{passwordStatus.username}<br/>
|
||||
<strong>默认密码:</strong><code style={{
|
||||
background: '#fff',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 3,
|
||||
color: '#1890ff',
|
||||
fontSize: 14
|
||||
}}>{passwordStatus.default_password}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label>新密码(至少6个字符):</label>
|
||||
<Input.Password
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="请输入新密码"
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>确认密码:</label>
|
||||
<Input.Password
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="请再次输入密码"
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
@@ -122,7 +260,7 @@ export default function AuthCallback() {
|
||||
<Result
|
||||
status="success"
|
||||
title="登录成功"
|
||||
subTitle={showAnnouncement ? "欢迎使用..." : "正在跳转..."}
|
||||
subTitle={showPasswordModal ? "请设置账号密码..." : (showAnnouncement ? "欢迎使用..." : "正在跳转...")}
|
||||
style={{ background: 'white', padding: 40, borderRadius: 8 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -600,6 +600,7 @@ export default function MCPPluginsPage() {
|
||||
<Modal
|
||||
title={editingPlugin ? '编辑插件' : '添加插件'}
|
||||
open={modalVisible}
|
||||
centered
|
||||
onCancel={() => {
|
||||
setModalVisible(false);
|
||||
form.resetFields();
|
||||
|
||||
@@ -73,23 +73,8 @@ export default function ProjectList() {
|
||||
};
|
||||
|
||||
const handleEnterProject = (id: string) => {
|
||||
const project = projects.find(p => p.id === id);
|
||||
if (project) {
|
||||
console.log('项目信息:', {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
wizard_status: project.wizard_status,
|
||||
wizard_step: project.wizard_step
|
||||
});
|
||||
|
||||
if (project.wizard_status === 'incomplete' || !project.wizard_status) {
|
||||
console.log('向导未完成,跳转到向导页面');
|
||||
navigate(`/wizard?projectId=${id}&step=${project.wizard_step || 0}`);
|
||||
} else {
|
||||
console.log('向导已完成,进入项目管理界面');
|
||||
navigate(`/project/${id}`);
|
||||
}
|
||||
}
|
||||
// 简化后直接进入项目,不再检查向导状态
|
||||
navigate(`/project/${id}`);
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
@@ -207,8 +192,8 @@ export default function ProjectList() {
|
||||
setSelectedProjectIds([]);
|
||||
};
|
||||
|
||||
// 获取可导出的项目(过滤掉向导未完成的项目)
|
||||
const exportableProjects = projects.filter(p => p.wizard_status === 'completed');
|
||||
// 获取所有可导出的项目
|
||||
const exportableProjects = projects;
|
||||
|
||||
// 关闭导出对话框
|
||||
const handleCloseExportModal = () => {
|
||||
@@ -631,12 +616,11 @@ export default function ProjectList() {
|
||||
<Row gutter={[16, 16]}>
|
||||
{projects.map((project) => {
|
||||
const progress = getProgress(project.current_words, project.target_words || 0);
|
||||
const isWizardComplete = project.wizard_status === 'completed';
|
||||
|
||||
return (
|
||||
<Col {...gridConfig} key={project.id}>
|
||||
<Badge.Ribbon
|
||||
text={isWizardComplete ? getStatusTag(project.status) : <Tag color="orange" icon={<RocketOutlined />}>创建中</Tag>}
|
||||
text={getStatusTag(project.status)}
|
||||
color="transparent"
|
||||
style={{ top: 12, right: 12 }}
|
||||
>
|
||||
@@ -680,69 +664,50 @@ export default function ProjectList() {
|
||||
{project.description || '暂无描述'}
|
||||
</Paragraph>
|
||||
|
||||
{isWizardComplete ? (
|
||||
<>
|
||||
{project.target_words && project.target_words > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>完成进度</Text>
|
||||
<Text strong style={{ fontSize: 12 }}>{progress}%</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={progress}
|
||||
strokeColor={getProgressColor(progress)}
|
||||
showInfo={false}
|
||||
size={{ height: 8 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px 0',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#1890ff' }}>
|
||||
{(project.current_words / 1000).toFixed(1)}K
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>已写字数</Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px 0',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#52c41a' }}>
|
||||
{project.target_words ? (project.target_words / 1000).toFixed(0) + 'K' : '--'}
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>目标字数</Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '24px 0',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<RocketOutlined style={{ fontSize: 32, color: '#faad14', marginBottom: 12 }} />
|
||||
<div style={{ color: '#faad14', fontWeight: 'bold', marginBottom: 4 }}>
|
||||
项目创建中
|
||||
{project.target_words && project.target_words > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>完成进度</Text>
|
||||
<Text strong style={{ fontSize: 12 }}>{progress}%</Text>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
点击继续创建向导
|
||||
</Text>
|
||||
<Progress
|
||||
percent={progress}
|
||||
strokeColor={getProgressColor(progress)}
|
||||
showInfo={false}
|
||||
size={{ height: 8 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px 0',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#1890ff' }}>
|
||||
{(project.current_words / 1000).toFixed(1)}K
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>已写字数</Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px 0',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#52c41a' }}>
|
||||
{project.target_words ? (project.target_words / 1000).toFixed(0) + 'K' : '--'}
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>目标字数</Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
|
||||
+303
-1178
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,18 @@
|
||||
import { Card, Descriptions, Empty, Typography } from 'antd';
|
||||
import { GlobalOutlined } from '@ant-design/icons';
|
||||
import { Card, Descriptions, Empty, Typography, Button, Modal, Form, Input, message } from 'antd';
|
||||
import { GlobalOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { useState } from 'react';
|
||||
import { useStore } from '../store';
|
||||
import { cardStyles } from '../components/CardStyles';
|
||||
import { projectApi } from '../services/api';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function WorldSetting() {
|
||||
const { currentProject } = useStore();
|
||||
const { currentProject, setCurrentProject } = useStore();
|
||||
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
||||
const [editForm] = Form.useForm();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
if (!currentProject) return null;
|
||||
|
||||
@@ -62,10 +68,28 @@ export default function WorldSetting() {
|
||||
marginBottom: 24,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<GlobalOutlined style={{ fontSize: 24, marginRight: 12, color: '#1890ff' }} />
|
||||
<h2 style={{ margin: 0 }}>世界设定</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<GlobalOutlined style={{ fontSize: 24, marginRight: 12, color: '#1890ff' }} />
|
||||
<h2 style={{ margin: 0 }}>世界设定</h2>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
editForm.setFieldsValue({
|
||||
world_time_period: currentProject.world_time_period || '',
|
||||
world_location: currentProject.world_location || '',
|
||||
world_atmosphere: currentProject.world_atmosphere || '',
|
||||
world_rules: currentProject.world_rules || '',
|
||||
});
|
||||
setIsEditModalVisible(true);
|
||||
}}
|
||||
>
|
||||
编辑世界观
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 可滚动内容区域 */}
|
||||
@@ -182,6 +206,102 @@ export default function WorldSetting() {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 编辑世界观模态框 */}
|
||||
<Modal
|
||||
title="编辑世界观"
|
||||
open={isEditModalVisible}
|
||||
centered
|
||||
onCancel={() => {
|
||||
setIsEditModalVisible(false);
|
||||
editForm.resetFields();
|
||||
}}
|
||||
onOk={async () => {
|
||||
try {
|
||||
const values = await editForm.validateFields();
|
||||
setIsSaving(true);
|
||||
|
||||
const updatedProject = await projectApi.updateProject(currentProject.id, {
|
||||
world_time_period: values.world_time_period,
|
||||
world_location: values.world_location,
|
||||
world_atmosphere: values.world_atmosphere,
|
||||
world_rules: values.world_rules,
|
||||
});
|
||||
|
||||
setCurrentProject(updatedProject);
|
||||
message.success('世界观更新成功');
|
||||
setIsEditModalVisible(false);
|
||||
editForm.resetFields();
|
||||
} catch (error) {
|
||||
console.error('更新世界观失败:', error);
|
||||
message.error('更新失败,请重试');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}}
|
||||
confirmLoading={isSaving}
|
||||
width={800}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Form.Item
|
||||
label="时间设定"
|
||||
name="world_time_period"
|
||||
rules={[{ required: true, message: '请输入时间设定' }]}
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder="描述故事发生的时代背景..."
|
||||
showCount
|
||||
maxLength={1000}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="地点设定"
|
||||
name="world_location"
|
||||
rules={[{ required: true, message: '请输入地点设定' }]}
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder="描述故事发生的地理位置和环境..."
|
||||
showCount
|
||||
maxLength={1000}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="氛围设定"
|
||||
name="world_atmosphere"
|
||||
rules={[{ required: true, message: '请输入氛围设定' }]}
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder="描述故事的整体氛围和基调..."
|
||||
showCount
|
||||
maxLength={1000}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="规则设定"
|
||||
name="world_rules"
|
||||
rules={[{ required: true, message: '请输入规则设定' }]}
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder="描述这个世界的特殊规则和设定..."
|
||||
showCount
|
||||
maxLength={1000}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -123,10 +123,23 @@ export const authApi = {
|
||||
localLogin: (username: string, password: string) =>
|
||||
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/local/login', { username, password }),
|
||||
|
||||
bindAccountLogin: (username: string, password: string) =>
|
||||
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/bind/login', { username, password }),
|
||||
|
||||
getLinuxDOAuthUrl: () => api.get<unknown, AuthUrlResponse>('/auth/linuxdo/url'),
|
||||
|
||||
getCurrentUser: () => api.get<unknown, User>('/auth/user'),
|
||||
|
||||
getPasswordStatus: () => api.get<unknown, {
|
||||
has_password: boolean;
|
||||
has_custom_password: boolean;
|
||||
username: string | null;
|
||||
default_password: string | null;
|
||||
}>('/auth/password/status'),
|
||||
|
||||
setPassword: (password: string) =>
|
||||
api.post<unknown, { success: boolean; message: string }>('/auth/password/set', { password }),
|
||||
|
||||
refreshSession: () => api.post<unknown, { message: string; expire_at: number; remaining_minutes: number }>('/auth/refresh'),
|
||||
|
||||
logout: () => api.post('/auth/logout'),
|
||||
@@ -306,6 +319,23 @@ export const chapterApi = {
|
||||
|
||||
checkCanGenerate: (chapterId: string) =>
|
||||
api.get<unknown, import('../types').ChapterCanGenerateResponse>(`/chapters/${chapterId}/can-generate`),
|
||||
|
||||
// 章节重新生成相关
|
||||
getRegenerationTasks: (chapterId: string, limit?: number) =>
|
||||
api.get<unknown, {
|
||||
chapter_id: string;
|
||||
total: number;
|
||||
tasks: Array<{
|
||||
task_id: string;
|
||||
status: string;
|
||||
version_number: number | null;
|
||||
version_note: string | null;
|
||||
original_word_count: number | null;
|
||||
regenerated_word_count: number | null;
|
||||
created_at: string | null;
|
||||
completed_at: string | null;
|
||||
}>;
|
||||
}>(`/chapters/${chapterId}/regeneration/tasks`, { params: { limit } }),
|
||||
};
|
||||
|
||||
export const writingStyleApi = {
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface SSEMessage {
|
||||
type: 'progress' | 'chunk' | 'result' | 'error' | 'done';
|
||||
message?: string;
|
||||
progress?: number;
|
||||
word_count?: number;
|
||||
status?: 'processing' | 'success' | 'error' | 'warning';
|
||||
content?: string;
|
||||
data?: any;
|
||||
@@ -10,7 +11,7 @@ export interface SSEMessage {
|
||||
}
|
||||
|
||||
export interface SSEClientOptions {
|
||||
onProgress?: (message: string, progress: number, status: string) => void;
|
||||
onProgress?: (message: string, progress: number, status: string, wordCount?: number) => void;
|
||||
onChunk?: (content: string) => void;
|
||||
onResult?: (data: any) => void;
|
||||
onError?: (error: string, code?: number) => void;
|
||||
@@ -61,8 +62,13 @@ export class SSEClient {
|
||||
private handleMessage(message: SSEMessage, resolve: Function, reject: Function) {
|
||||
switch (message.type) {
|
||||
case 'progress':
|
||||
if (this.options.onProgress && message.message && message.progress !== undefined) {
|
||||
this.options.onProgress(message.message, message.progress, message.status || 'processing');
|
||||
if (this.options.onProgress && message.progress !== undefined) {
|
||||
this.options.onProgress(
|
||||
message.message || '',
|
||||
message.progress,
|
||||
message.status || 'processing',
|
||||
message.word_count
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -201,8 +207,13 @@ export class SSEPostClient {
|
||||
private async handleMessage(message: SSEMessage, resolve: Function, reject: Function) {
|
||||
switch (message.type) {
|
||||
case 'progress':
|
||||
if (this.options.onProgress && message.message && message.progress !== undefined) {
|
||||
this.options.onProgress(message.message, message.progress, message.status || 'processing');
|
||||
if (this.options.onProgress && message.progress !== undefined) {
|
||||
this.options.onProgress(
|
||||
message.message || '',
|
||||
message.progress,
|
||||
message.status || 'processing',
|
||||
message.word_count
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user