From 397ca30bcb5baac737751d7ac4f2b13b752f4dec Mon Sep 17 00:00:00 2001 From: xiamuceer Date: Wed, 5 Nov 2025 16:22:14 +0800 Subject: [PATCH] =?UTF-8?q?update:1.=E6=96=B0=E5=A2=9EAI=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=BB=84=E7=BB=87=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BB=84=E7=BB=87=E5=AD=97=E6=AE=B5=EF=BC=88?= =?UTF-8?q?=E6=89=80=E5=9C=A8=E5=9C=B0=20=E4=BB=A3=E8=A1=A8=E9=A2=9C?= =?UTF-8?q?=E8=89=B2=20=E6=A0=BC=E8=A8=80/=E5=8F=A3=E5=8F=B7=EF=BC=89=202.?= =?UTF-8?q?=E9=80=82=E9=85=8D=E7=A7=BB=E5=8A=A8=E7=AB=AF=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=AE=A1=E7=90=86-=E5=89=A7=E6=83=85=E5=88=86=E6=9E=90UI?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 3 - backend/app/api/chapters.py | 105 ++++-- backend/app/api/characters.py | 105 +++++- backend/app/api/organizations.py | 210 +++++++++++- backend/app/api/projects.py | 23 +- backend/app/api/wizard_stream.py | 22 +- backend/app/schemas/character.py | 8 +- backend/app/services/memory_service.py | 38 +++ backend/app/services/prompt_service.py | 107 +++++- frontend/src/components/AnnotatedText.tsx | 3 + frontend/src/components/ChapterAnalysis.tsx | 80 +++-- frontend/src/components/CharacterCard.tsx | 36 ++ frontend/src/pages/ChapterAnalysis.tsx | 350 ++++++++++++++------ frontend/src/pages/Characters.tsx | 17 + frontend/src/pages/Organizations.tsx | 197 ++++++++++- frontend/src/pages/ProjectWizardNew.tsx | 26 +- frontend/src/types/index.ts | 10 + 17 files changed, 1155 insertions(+), 185 deletions(-) diff --git a/Dockerfile b/Dockerfile index 995c558..a6a114e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,9 +59,6 @@ RUN mkdir -p /app/data /app/logs /app/embedding # 这样可以避免首次运行时联网下载约420MB的模型文件 COPY backend/embedding /app/embedding -# 复制环境变量示例文件 -COPY backend/.env.example ./.env.example - # 暴露端口 EXPOSE 8000 diff --git a/backend/app/api/chapters.py b/backend/app/api/chapters.py index 4b91737..800676b 100644 --- a/backend/app/api/chapters.py +++ b/backend/app/api/chapters.py @@ -553,36 +553,59 @@ async def analyze_chapter_background( ) logger.info(f"✅ 添加{added_count}条记忆到向量库") - # 最终更新任务状态(写操作,需要锁) - async with write_lock: - task.progress = 100 - task.status = 'completed' - task.completed_at = datetime.now() - await db_session.commit() + # 最终更新任务状态(写操作,需要锁)- 增加重试机制 + update_success = False + for retry in range(3): + try: + async with write_lock: + task.progress = 100 + task.status = 'completed' + task.completed_at = datetime.now() + await db_session.commit() + update_success = True + logger.info(f"✅ 章节分析完成: {chapter_id}, 提取{len(memories)}条记忆") + break + except Exception as commit_error: + logger.error(f"❌ 提交任务完成状态失败(重试{retry+1}/3): {str(commit_error)}") + if retry < 2: + await asyncio.sleep(0.1) + else: + logger.error(f"❌ 无法更新任务为completed状态: {task_id}") + # 即使失败也不抛出异常,因为分析本身已经完成 - logger.info(f"✅ 章节分析完成: {chapter_id}, 提取{len(memories)}条记忆") + if not update_success: + logger.warning(f"⚠️ 章节分析完成但状态更新失败: {chapter_id}") except Exception as e: logger.error(f"❌ 后台分析异常: {str(e)}", exc_info=True) # 确保任务状态被更新为failed(写操作,需要锁) if db_session: - try: - async with write_lock: - task_result = await db_session.execute( - select(AnalysisTask).where(AnalysisTask.id == task_id) - ) - task = task_result.scalar_one_or_none() - if task: - task.status = 'failed' - task.error_message = str(e)[:500] - task.completed_at = datetime.now() - task.progress = 0 - await db_session.commit() - logger.info(f"✅ 任务状态已更新为failed: {task_id}") + # 多次重试更新任务状态 + for retry in range(3): + try: + async with write_lock: + # 重新获取任务(可能是旧会话导致的问题) + task_result = await db_session.execute( + select(AnalysisTask).where(AnalysisTask.id == task_id) + ) + task = task_result.scalar_one_or_none() + if task: + task.status = 'failed' + task.error_message = str(e)[:500] + task.completed_at = datetime.now() + task.progress = 0 + await db_session.commit() + logger.info(f"✅ 任务状态已更新为failed: {task_id} (重试{retry+1}次)") + break + else: + logger.error(f"❌ 无法找到任务进行状态更新: {task_id}") + break + except Exception as update_error: + logger.error(f"❌ 更新任务状态失败(重试{retry+1}/3): {str(update_error)}") + if retry < 2: + await asyncio.sleep(0.1) # 短暂等待后重试 else: - logger.error(f"❌ 无法找到任务进行状态更新: {task_id}") - except Exception as update_error: - logger.error(f"❌ 更新任务状态失败: {str(update_error)}") + logger.error(f"❌ 任务状态更新失败,已达到最大重试次数: {task_id}") finally: if db_session: await db_session.close() @@ -956,14 +979,21 @@ async def get_analysis_task_status( """ 查询指定章节的最新分析任务状态 + 自动恢复机制: + - 如果任务状态为running且超过1分钟未更新,自动标记为failed + - 如果任务状态为pending且超过2分钟未启动,自动标记为failed + 返回: - task_id: 任务ID - status: pending/running/completed/failed - progress: 0-100 - error_message: 错误信息(如果失败) + - auto_recovered: 是否被自动恢复 - created_at: 创建时间 - completed_at: 完成时间 """ + from datetime import timedelta + # 获取该章节最新的分析任务 result = await db.execute( select(AnalysisTask) @@ -976,12 +1006,41 @@ async def get_analysis_task_status( if not task: raise HTTPException(status_code=404, detail="未找到分析任务") + auto_recovered = False + current_time = datetime.now() + + # 自动恢复卡住的任务 + if task.status == 'running': + # 如果任务在running状态超过1分钟,标记为失败 + if task.started_at and (current_time - task.started_at) > timedelta(minutes=1): + task.status = 'failed' + task.error_message = '任务超时(超过1分钟未完成,已自动恢复)' + task.completed_at = current_time + task.progress = 0 + auto_recovered = True + await db.commit() + await db.refresh(task) + logger.warning(f"🔄 自动恢复卡住的任务: {task.id}, 章节: {chapter_id}") + + elif task.status == 'pending': + # 如果任务在pending状态超过2分钟仍未开始,标记为失败 + if task.created_at and (current_time - task.created_at) > timedelta(minutes=2): + task.status = 'failed' + task.error_message = '任务启动超时(超过2分钟未启动,已自动恢复)' + task.completed_at = current_time + task.progress = 0 + auto_recovered = True + await db.commit() + await db.refresh(task) + logger.warning(f"🔄 自动恢复未启动的任务: {task.id}, 章节: {chapter_id}") + return { "task_id": task.id, "chapter_id": task.chapter_id, "status": task.status, "progress": task.progress, "error_message": task.error_message, + "auto_recovered": auto_recovered, "created_at": task.created_at.isoformat() if task.created_at else None, "started_at": task.started_at.isoformat() if task.started_at else None, "completed_at": task.completed_at.isoformat() if task.completed_at else None diff --git a/backend/app/api/characters.py b/backend/app/api/characters.py index 13066b5..1893635 100644 --- a/backend/app/api/characters.py +++ b/backend/app/api/characters.py @@ -44,7 +44,50 @@ async def get_characters( ) characters = result.scalars().all() - return CharacterListResponse(total=total, items=characters) + # 为组织类型的角色填充Organization表的额外字段 + enriched_characters = [] + for char in characters: + char_dict = { + "id": char.id, + "project_id": char.project_id, + "name": char.name, + "age": char.age, + "gender": char.gender, + "is_organization": char.is_organization, + "role_type": char.role_type, + "personality": char.personality, + "background": char.background, + "appearance": char.appearance, + "relationships": char.relationships, + "organization_type": char.organization_type, + "organization_purpose": char.organization_purpose, + "organization_members": char.organization_members, + "traits": char.traits, + "avatar_url": char.avatar_url, + "created_at": char.created_at, + "updated_at": char.updated_at, + "power_level": None, + "location": None, + "motto": None, + "color": None + } + + if char.is_organization: + org_result = await db.execute( + select(Organization).where(Organization.character_id == char.id) + ) + org = org_result.scalar_one_or_none() + if org: + char_dict.update({ + "power_level": org.power_level, + "location": org.location, + "motto": org.motto, + "color": org.color + }) + + enriched_characters.append(char_dict) + + return CharacterListResponse(total=total, items=enriched_characters) @router.get("/project/{project_id}", response_model=CharacterListResponse, summary="获取项目的所有角色") @@ -67,7 +110,50 @@ async def get_project_characters( ) characters = result.scalars().all() - return CharacterListResponse(total=total, items=characters) + # 为组织类型的角色填充Organization表的额外字段 + enriched_characters = [] + for char in characters: + char_dict = { + "id": char.id, + "project_id": char.project_id, + "name": char.name, + "age": char.age, + "gender": char.gender, + "is_organization": char.is_organization, + "role_type": char.role_type, + "personality": char.personality, + "background": char.background, + "appearance": char.appearance, + "relationships": char.relationships, + "organization_type": char.organization_type, + "organization_purpose": char.organization_purpose, + "organization_members": char.organization_members, + "traits": char.traits, + "avatar_url": char.avatar_url, + "created_at": char.created_at, + "updated_at": char.updated_at, + "power_level": None, + "location": None, + "motto": None, + "color": None + } + + if char.is_organization: + org_result = await db.execute( + select(Organization).where(Organization.character_id == char.id) + ) + org = org_result.scalar_one_or_none() + if org: + char_dict.update({ + "power_level": org.power_level, + "location": org.location, + "motto": org.motto, + "color": org.color + }) + + enriched_characters.append(char_dict) + + return CharacterListResponse(total=total, items=enriched_characters) @router.get("/{character_id}", response_model=CharacterResponse, summary="获取角色详情") @@ -213,16 +299,12 @@ async def generate_character( logger.info(f" - 角色名:{request.name or 'AI生成'}") logger.info(f" - 角色定位:{request.role_type}") logger.info(f" - 背景设定:{request.background or '无'}") - logger.info(f" - AI提供商:{request.provider or 'default'}") - logger.info(f" - AI模型:{request.model or 'default'}") + logger.info(f" - AI提供商:{user_ai_service.api_provider}") + logger.info(f" - AI模型:{user_ai_service.default_model}") logger.info(f" - Prompt长度:{len(prompt)} 字符") try: - ai_response = await user_ai_service.generate_text( - prompt=prompt, - provider=request.provider, - model=request.model - ) + ai_response = await user_ai_service.generate_text(prompt=prompt) logger.info(f"✅ AI响应接收完成,长度:{len(ai_response) if ai_response else 0} 字符") except Exception as ai_error: logger.error(f"❌ AI服务调用异常:{str(ai_error)}") @@ -317,7 +399,8 @@ async def generate_character( member_count=0, power_level=character_data.get("power_level", 50), location=character_data.get("location"), - motto=character_data.get("motto") + motto=character_data.get("motto"), + color=character_data.get("color") ) db.add(organization) await db.flush() @@ -477,7 +560,7 @@ async def generate_character( project_id=request.project_id, prompt=prompt, generated_content=ai_response, - model=request.model or "default" + model=user_ai_service.default_model ) db.add(history) diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py index 43f9ef7..f7a45cf 100644 --- a/backend/app/api/organizations.py +++ b/backend/app/api/organizations.py @@ -2,11 +2,15 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_ -from typing import List +from typing import List, Optional +from pydantic import BaseModel, Field +import json from app.database import get_db from app.models.relationship import Organization, OrganizationMember from app.models.character import Character +from app.models.project import Project +from app.models.generation_history import GenerationHistory from app.schemas.relationship import ( OrganizationCreate, OrganizationUpdate, @@ -17,12 +21,25 @@ from app.schemas.relationship import ( OrganizationMemberResponse, OrganizationMemberDetailResponse ) +from app.schemas.character import CharacterResponse +from app.services.ai_service import AIService +from app.services.prompt_service import prompt_service from app.logger import get_logger +from app.api.settings import get_user_ai_service router = APIRouter(prefix="/organizations", tags=["组织管理"]) logger = get_logger(__name__) +class OrganizationGenerateRequest(BaseModel): + """AI生成组织的请求模型""" + project_id: str = Field(..., description="项目ID") + name: Optional[str] = Field(None, description="组织名称") + organization_type: Optional[str] = Field(None, description="组织类型") + background: Optional[str] = Field(None, description="组织背景") + requirements: Optional[str] = Field(None, description="特殊要求") + + @router.get("/project/{project_id}", response_model=List[OrganizationDetailResponse], summary="获取项目的所有组织") async def get_project_organizations( project_id: str, @@ -338,4 +355,193 @@ async def remove_organization_member( await db.commit() logger.info(f"移除成员成功:{member_id}") - return {"message": "成员移除成功", "id": member_id} \ No newline at end of file + return {"message": "成员移除成功", "id": member_id} + +@router.post("/generate", response_model=CharacterResponse, summary="AI生成组织") +async def generate_organization( + request: OrganizationGenerateRequest, + db: AsyncSession = Depends(get_db), + user_ai_service: AIService = Depends(get_user_ai_service) +): + """ + 使用AI生成组织设定 + + 根据用户输入的信息,结合项目的世界观、主题等背景, + AI会生成一个完整、详细的组织设定。 + + 生成内容包括:组织名称、类型、特性、背景、目的、势力等级等 + """ + # 验证项目是否存在并获取项目信息 + result = await db.execute( + select(Project).where(Project.id == request.project_id) + ) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + try: + # 获取已存在的角色和组织列表 + existing_chars_result = await db.execute( + select(Character) + .where(Character.project_id == request.project_id) + .order_by(Character.created_at.desc()) + ) + existing_characters = existing_chars_result.scalars().all() + + # 构建现有角色和组织信息摘要 + existing_info = "" + character_list = [] + organization_list = [] + + if existing_characters: + for c in existing_characters[:10]: # 最多显示10个 + if c.is_organization: + organization_list.append(f"- {c.name} [{c.organization_type or '组织'}]") + else: + character_list.append(f"- {c.name}({c.role_type or '未知'})") + + if character_list: + existing_info += "\n已有角色:\n" + "\n".join(character_list) + if organization_list: + existing_info += "\n\n已有组织:\n" + "\n".join(organization_list) + + # 构建项目上下文信息 + project_context = f""" +项目信息: +- 书名:{project.title} +- 主题:{project.theme or '未设定'} +- 类型:{project.genre or '未设定'} +- 时间背景:{project.world_time_period or '未设定'} +- 地理位置:{project.world_location or '未设定'} +- 氛围基调:{project.world_atmosphere or '未设定'} +- 世界规则:{project.world_rules or '未设定'} +{existing_info} +""" + + # 构建用户输入信息 + user_input = f""" +用户要求: +- 组织名称:{request.name or '请AI生成'} +- 组织类型:{request.organization_type or '请AI根据世界观决定'} +- 背景设定:{request.background or '无特殊要求'} +- 其他要求:{request.requirements or '无'} +""" + + # 使用统一的提示词服务 + prompt = prompt_service.get_single_organization_prompt( + project_context=project_context, + user_input=user_input + ) + + # 调用AI生成组织 + logger.info(f"🎯 开始为项目 {request.project_id} 生成组织") + logger.info(f" - 组织名:{request.name or 'AI生成'}") + logger.info(f" - 组织类型:{request.organization_type or 'AI决定'}") + logger.info(f" - 背景设定:{request.background or '无'}") + logger.info(f" - AI提供商:{user_ai_service.api_provider}") + logger.info(f" - AI模型:{user_ai_service.default_model}") + logger.info(f" - Prompt长度:{len(prompt)} 字符") + + try: + ai_response = await user_ai_service.generate_text(prompt=prompt) + logger.info(f"✅ AI响应接收完成,长度:{len(ai_response) if ai_response else 0} 字符") + except Exception as ai_error: + logger.error(f"❌ AI服务调用异常:{str(ai_error)}") + raise HTTPException( + status_code=500, + detail=f"AI服务调用失败:{str(ai_error)}" + ) + + # 检查AI响应 + if not ai_response or not ai_response.strip(): + logger.error("❌ AI返回了空响应") + raise HTTPException( + status_code=500, + detail="AI服务返回空响应。请检查AI配置和网络连接。" + ) + + logger.info(f"📝 开始清理AI响应") + # 清理AI响应 + cleaned_response = ai_response.strip() + if cleaned_response.startswith("```json"): + cleaned_response = cleaned_response[7:] + if cleaned_response.startswith("```"): + cleaned_response = cleaned_response[3:] + if cleaned_response.endswith("```"): + cleaned_response = cleaned_response[:-3] + cleaned_response = cleaned_response.strip() + + logger.info(f" - 清理后长度:{len(cleaned_response)}") + + # 解析AI响应 + logger.info(f"🔍 开始解析JSON") + try: + organization_data = json.loads(cleaned_response) + logger.info(f"✅ JSON解析成功") + logger.info(f" - 解析后的字段:{list(organization_data.keys())}") + except json.JSONDecodeError as e: + logger.error(f"❌ JSON解析失败:{str(e)}") + raise HTTPException( + status_code=500, + detail=f"AI返回的内容无法解析为JSON。错误:{str(e)}" + ) + + # 创建角色记录(组织也是角色的一种) + character = Character( + project_id=request.project_id, + name=organization_data.get("name", request.name or "未命名组织"), + is_organization=True, + role_type="supporting", # 组织通常作为配角 + personality=organization_data.get("personality", ""), + background=organization_data.get("background", ""), + appearance=organization_data.get("appearance", ""), + organization_type=organization_data.get("organization_type"), + organization_purpose=organization_data.get("organization_purpose"), + organization_members=json.dumps( + organization_data.get("organization_members", []), + ensure_ascii=False + ), + traits=json.dumps( + organization_data.get("traits", []), + ensure_ascii=False + ) + ) + db.add(character) + await db.flush() + + logger.info(f"✅ 组织角色创建成功:{character.name} (ID: {character.id})") + + # 自动创建Organization详情记录 + organization = Organization( + character_id=character.id, + project_id=request.project_id, + member_count=0, + power_level=organization_data.get("power_level", 50), + location=organization_data.get("location"), + motto=organization_data.get("motto"), + color=organization_data.get("color") + ) + db.add(organization) + await db.flush() + + logger.info(f"✅ 组织详情创建成功:{character.name} (Org ID: {organization.id})") + + # 记录生成历史 + history = GenerationHistory( + project_id=request.project_id, + prompt=prompt, + generated_content=ai_response, + model=user_ai_service.default_model + ) + db.add(history) + + await db.commit() + await db.refresh(character) + + logger.info(f"🎉 成功为项目 {request.project_id} 生成组织: {character.name}") + + return character + + except Exception as e: + logger.error(f"生成组织失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"生成组织失败: {str(e)}") diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index ffa4e81..51f6269 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -1,5 +1,5 @@ """项目管理API""" -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request from fastapi.responses import Response from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, delete @@ -13,6 +13,7 @@ from app.models.outline import Outline from app.models.chapter import Chapter from app.models.generation_history import GenerationHistory from app.models.relationship import CharacterRelationship, Organization, OrganizationMember +from app.models.memory import StoryMemory, PlotAnalysis from app.schemas.project import ( ProjectCreate, ProjectUpdate, @@ -25,6 +26,7 @@ from app.schemas.import_export import ( ImportResult ) from app.services.import_export_service import ImportExportService +from app.services.memory_service import memory_service from app.logger import get_logger from app.utils.data_consistency import ( run_full_data_consistency_check, @@ -143,6 +145,7 @@ async def update_project( @router.delete("/{project_id}", summary="删除项目") async def delete_project( project_id: str, + request: Request, db: AsyncSession = Depends(get_db) ): try: @@ -158,6 +161,19 @@ async def delete_project( project_title = project.title + # 从认证中间件获取用户ID + user_id = getattr(request.state, 'user_id', None) + + # 删除向量数据库中的记忆 + if user_id: + try: + await memory_service.delete_project_memories(user_id, project_id) + logger.info(f"✅ 向量数据库清理成功") + except Exception as e: + logger.warning(f"⚠️ 向量数据库清理失败(继续删除其他数据): {str(e)}") + else: + logger.warning(f"⚠️ 未找到用户ID,跳过向量数据库清理") + relationships_result = await db.execute( delete(CharacterRelationship).where(CharacterRelationship.project_id == project_id) ) @@ -200,11 +216,14 @@ async def delete_project( ) logger.debug(f"删除角色数: {characters_result.rowcount}") + # 注意:StoryMemory和PlotAnalysis会通过数据库级联删除自动清理 + # 但向量数据库已在上面手动清理 + await db.delete(project) await db.commit() logger.info(f"项目删除成功: {project_title}") - return {"message": "项目及所有关联数据删除成功"} + return {"message": "项目及所有关联数据(包括向量数据库)删除成功"} except HTTPException: raise except Exception as e: diff --git a/backend/app/api/wizard_stream.py b/backend/app/api/wizard_stream.py index f0bb9c8..7c39d46 100644 --- a/backend/app/api/wizard_stream.py +++ b/backend/app/api/wizard_stream.py @@ -452,21 +452,24 @@ async def characters_generator( elif isinstance(char_data.get("relationships"), str): relationships_text = char_data.get("relationships") + # 判断是否为组织 + is_organization = char_data.get("is_organization", False) + character = Character( project_id=project_id, name=char_data.get("name", "未命名角色"), - age=char_data.get("age"), - gender=char_data.get("gender"), - is_organization=char_data.get("is_organization", False), + age=str(char_data.get("age", "")) if not is_organization else None, + gender=char_data.get("gender") if not is_organization else None, + is_organization=is_organization, role_type=char_data.get("role_type", "supporting"), personality=char_data.get("personality", ""), background=char_data.get("background", ""), appearance=char_data.get("appearance", ""), relationships=relationships_text, - organization_type=char_data.get("organization_type"), - organization_purpose=char_data.get("organization_purpose"), - organization_members=json.dumps(char_data.get("organization_members", []), ensure_ascii=False), - traits=json.dumps(char_data.get("traits", []), ensure_ascii=False) + organization_type=char_data.get("organization_type") if is_organization else None, + organization_purpose=char_data.get("organization_purpose") if is_organization else None, + organization_members=json.dumps(char_data.get("organization_members", []), ensure_ascii=False) if is_organization else None, + traits=json.dumps(char_data.get("traits", []), ensure_ascii=False) if char_data.get("traits") else None ) db.add(character) created_characters.append((character, char_data)) @@ -497,9 +500,10 @@ async def characters_generator( character_id=character.id, project_id=project_id, member_count=0, # 初始为0,后续添加成员时会更新 - power_level=char_data.get("power_level", 5), + power_level=char_data.get("power_level", 50), location=char_data.get("location"), - motto=char_data.get("motto") + motto=char_data.get("motto"), + color=char_data.get("color") ) db.add(org) logger.info(f"向导创建组织记录:{character.name}") diff --git a/backend/app/schemas/character.py b/backend/app/schemas/character.py index 864c998..a61ded1 100644 --- a/backend/app/schemas/character.py +++ b/backend/app/schemas/character.py @@ -46,6 +46,12 @@ class CharacterResponse(CharacterBase): created_at: datetime updated_at: datetime + # 组织额外字段(从Organization表关联) + power_level: Optional[int] = Field(None, description="组织势力等级(0-100)") + location: Optional[str] = Field(None, description="组织所在地") + motto: Optional[str] = Field(None, description="组织格言/口号") + color: Optional[str] = Field(None, description="组织代表颜色") + class Config: from_attributes = True @@ -57,8 +63,6 @@ class CharacterGenerateRequest(BaseModel): role_type: Optional[str] = Field(None, description="角色类型") background: Optional[str] = Field(None, description="角色背景") requirements: Optional[str] = Field(None, description="特殊要求") - provider: Optional[str] = Field(None, description="AI提供商") - model: Optional[str] = Field(None, description="AI模型") class CharacterListResponse(BaseModel): diff --git a/backend/app/services/memory_service.py b/backend/app/services/memory_service.py index 71e4186..081e095 100644 --- a/backend/app/services/memory_service.py +++ b/backend/app/services/memory_service.py @@ -659,6 +659,44 @@ class MemoryService: logger.error(f"❌ 删除章节记忆失败: {str(e)}") return False + async def delete_project_memories( + self, + user_id: str, + project_id: str + ) -> bool: + """ + 删除指定项目的所有记忆(包括向量数据库) + + Args: + user_id: 用户ID + project_id: 项目ID + + Returns: + 是否删除成功 + """ + try: + # 生成collection名称 + user_hash = hashlib.sha256(user_id.encode()).hexdigest()[:8] + project_hash = hashlib.sha256(project_id.encode()).hexdigest()[:8] + collection_name = f"u_{user_hash}_p_{project_hash}" + + # 删除整个collection(这会清理所有向量数据) + try: + self.client.delete_collection(name=collection_name) + logger.info(f"🗑️ 已删除项目{project_id[:8]}的向量数据库collection: {collection_name}") + return True + except Exception as e: + # 如果collection不存在,也算成功 + if "does not exist" in str(e).lower(): + logger.info(f"ℹ️ 项目{project_id[:8]}的collection不存在,无需删除") + return True + else: + raise + + except Exception as e: + logger.error(f"❌ 删除项目记忆失败: {str(e)}") + return False + async def update_memory( self, user_id: str, diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py index ecc47d7..1b05a0f 100644 --- a/backend/app/services/prompt_service.py +++ b/backend/app/services/prompt_service.py @@ -169,7 +169,7 @@ class PromptService: - 至少1个主角(protagonist) - 多个配角(supporting) - 可以包含反派(antagonist) -- 可以包含1-2个重要组织 +- 可以包含1-2个**高影响力的重要组织**(势力等级应在70-95之间) 要求: - 角色要符合世界观设定 @@ -222,10 +222,21 @@ class PromptService: "organization_type": "组织类型", "organization_purpose": "组织目的", "organization_members": ["成员1", "成员2"], + "power_level": 85, + "location": "组织所在地或主要活动区域", + "motto": "组织格言、口号或宗旨", + "color": "组织代表颜色(如:深红色、金色、黑色等)", "traits": [] }} ] +**组织生成要求(重要):** +- 组织必须是对故事有重大影响的势力 +- power_level应在70-95之间(高影响力组织) +- 不要生成无关紧要的小组织或普通社团 +- 组织应该是推动剧情发展的关键力量 +- 可以是正派势力、中立势力或反派势力,但一定要有存在感 + **关系类型参考(从中选择或自定义):** - 家族:父亲、母亲、兄弟、姐妹、子女、配偶、恋人 - 社交:师父、徒弟、朋友、同学、同事、邻居、知己 @@ -678,6 +689,91 @@ class PromptService: - 配角要有独特性,不能是工具人 - 所有设定要为故事服务 +再次强调: +1. 只返回纯JSON对象,不要有```json```这样的标记 +2. 文本中不要使用中文引号(""),改用【】或《》 +3. 不要有任何额外的文字说明""" + + # 单个组织生成提示词 + SINGLE_ORGANIZATION_GENERATION = """你是一位专业的组织设定师。请根据以下信息创建一个完整的组织/势力设定。 + +{project_context} + +{user_input} + +请生成一个完整的组织设定,包含以下所有信息: + +1. **基本信息**: + - 组织名称:如果用户未提供,请生成一个符合世界观的名称 + - 组织类型:如帮派、公司、门派、学院、政府机构、宗教组织等 + - 成立时间:具体时间或时间段 + +2. **组织特性**(150-200字): + - 组织的核心理念和行事风格 + - 组织文化和价值观 + - 运作方式和管理模式 + - 特殊传统或规矩 + +3. **组织背景**(200-300字): + - 建立历史和起源 + - 发展历程和重要事件 + - 目前的地位和影响力 + - 如何与项目主题关联 + - 融入用户提供的背景设定 + +4. **外在表现**(100-150字): + - 总部或主要据点位置 + - 标志性建筑或场所 + - 组织标志、徽章、制服等 + - 可辨识的外在特征 + +5. **组织目的/宗旨**: + - 明确的组织目标 + - 长期愿景 + - 行动准则 + +6. **势力等级**: + - 在世界中的影响力(0-100) + - 综合实力评估 + +7. **所在地点**: + - 主要活动区域 + - 势力范围 + +**重要格式要求:** +1. 只返回纯JSON格式,不要包含任何markdown标记、代码块标记或其他说明文字 +2. 不要在JSON字符串值中使用中文引号(""''),改用【】或《》 +3. 文本描述中的专有名词使用【】标记 + +请严格按照以下JSON格式返回: +{{ + "name": "组织名称", + "is_organization": true, + "organization_type": "组织类型", + "personality": "组织特性(150-200字)", + "background": "组织背景(200-300字)", + "appearance": "外在表现(100-150字)", + "organization_purpose": "组织目的和宗旨", + "power_level": 75, + "location": "所在地点", + "motto": "组织格言或口号", + "traits": ["特征1", "特征2", "特征3"], + "color": "组织代表颜色(如:深红色、金色、黑色等)", + "organization_members": ["重要成员1", "重要成员2", "重要成员3"] +}} + +**组织设定要求:** +- 组织要符合项目的世界观和主题 +- 目标和行动要合理,不能过于理想化或脸谱化 +- 要有存在的必要性,能推动故事发展 +- 内部要有层级和结构 +- 与其他势力要有互动关系 + +**说明**: +1. power_level是0-100的整数,表示组织在世界中的影响力 +2. organization_members是组织内重要成员的名字列表(如果已有角色,可以关联) +3. 所有文本描述要详细具体,避免空泛 + 再次强调: 1. 只返回纯JSON对象,不要有```json```这样的标记 2. 文本中不要使用中文引号(""),改用【】或《》 @@ -938,6 +1034,15 @@ class PromptService: project_context=project_context, user_input=user_input ) + + @classmethod + def get_single_organization_prompt(cls, project_context: str, user_input: str) -> str: + """获取单个组织生成提示词""" + return cls.format_prompt( + cls.SINGLE_ORGANIZATION_GENERATION, + project_context=project_context, + user_input=user_input + ) # 创建全局提示词服务实例 diff --git a/frontend/src/components/AnnotatedText.tsx b/frontend/src/components/AnnotatedText.tsx index 52c745d..979e880 100644 --- a/frontend/src/components/AnnotatedText.tsx +++ b/frontend/src/components/AnnotatedText.tsx @@ -32,6 +32,7 @@ interface AnnotatedTextProps { onAnnotationClick?: (annotation: MemoryAnnotation) => void; activeAnnotationId?: string; scrollToAnnotation?: string; + style?: React.CSSProperties; } // 类型颜色映射 @@ -60,6 +61,7 @@ const AnnotatedText: React.FC = ({ onAnnotationClick, activeAnnotationId, scrollToAnnotation, + style, }) => { const annotationRefs = useRef>({}); @@ -243,6 +245,7 @@ const AnnotatedText: React.FC = ({ fontSize: 16, whiteSpace: 'pre-wrap', wordBreak: 'break-word', + ...style, }} > {segments.map((segment, index) => renderAnnotatedSegment(segment, index))} diff --git a/frontend/src/components/ChapterAnalysis.tsx b/frontend/src/components/ChapterAnalysis.tsx index 4346e36..64c59ed 100644 --- a/frontend/src/components/ChapterAnalysis.tsx +++ b/frontend/src/components/ChapterAnalysis.tsx @@ -14,6 +14,9 @@ import { } from '@ant-design/icons'; import type { AnalysisTask, ChapterAnalysisResponse } from '../types'; +// 判断是否为移动设备 +const isMobileDevice = () => window.innerWidth < 768; + interface ChapterAnalysisProps { chapterId: string; visible: boolean; @@ -25,14 +28,23 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter const [analysis, setAnalysis] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [isMobile, setIsMobile] = useState(isMobileDevice()); useEffect(() => { if (visible && chapterId) { fetchAnalysisStatus(); } + // 监听窗口大小变化 + const handleResize = () => { + setIsMobile(isMobileDevice()); + }; + + window.addEventListener('resize', handleResize); + // 清理函数:组件卸载或关闭时清除轮询 return () => { + window.removeEventListener('resize', handleResize); // 清除可能存在的轮询 }; }, [visible, chapterId]); @@ -194,10 +206,10 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter label: '概览', icon: , children: ( -
- - - +
+ + + - + - + - + {analysis_data.analysis_report && ( - -
+                  
+                    
                       {analysis_data.analysis_report}
                     
)} {analysis_data.suggestions && analysis_data.suggestions.length > 0 && ( - 改进建议}> + 改进建议} size={isMobile ? 'small' : 'default'}> ( @@ -257,8 +269,8 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter label: `钩子 (${analysis_data.hooks?.length || 0})`, icon: , children: ( -
- +
+ {analysis_data.hooks && analysis_data.hooks.length > 0 ? ( , children: ( -
- +
+ {analysis_data.foreshadows && analysis_data.foreshadows.length > 0 ? ( , children: ( -
- +
+ {analysis_data.emotional_tone ? (
- - + + - + , children: ( -
- +
+ {analysis_data.character_states && analysis_data.character_states.length > 0 ? ( , children: ( -
- +
+ {memories && memories.length > 0 ? ( + , !task && !loading && ( @@ -485,6 +502,7 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter icon={} onClick={triggerAnalysis} loading={loading} + size={isMobile ? 'small' : 'middle'} > 开始分析 @@ -497,6 +515,7 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter onClick={triggerAnalysis} loading={loading} danger + size={isMobile ? 'small' : 'middle'} > 重新分析 @@ -508,6 +527,7 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter icon={} onClick={triggerAnalysis} loading={loading} + size={isMobile ? 'small' : 'middle'} > 重新分析 diff --git a/frontend/src/components/CharacterCard.tsx b/frontend/src/components/CharacterCard.tsx index 07490ae..815815d 100644 --- a/frontend/src/components/CharacterCard.tsx +++ b/frontend/src/components/CharacterCard.tsx @@ -122,6 +122,42 @@ export const CharacterCard: React.FC = ({ character, onEdit, {character.organization_type}
)} + {character.power_level !== undefined && character.power_level !== null && ( +
+ 势力等级: + = 70 ? 'red' : character.power_level >= 50 ? 'orange' : 'default'}> + {character.power_level} + +
+ )} + {character.location && ( +
+ 所在地: + + {character.location} + +
+ )} + {character.color && ( +
+ 代表颜色: + {character.color} +
+ )} + {character.motto && ( +
+ 格言: + + {character.motto} + +
+ )} {character.organization_purpose && (
目的: diff --git a/frontend/src/pages/ChapterAnalysis.tsx b/frontend/src/pages/ChapterAnalysis.tsx index 6bc845b..615b201 100644 --- a/frontend/src/pages/ChapterAnalysis.tsx +++ b/frontend/src/pages/ChapterAnalysis.tsx @@ -6,6 +6,7 @@ import { MenuOutlined, LeftOutlined, RightOutlined, + UnorderedListOutlined, } from '@ant-design/icons'; import { useParams } from 'react-router-dom'; import api from '../services/api'; @@ -71,8 +72,20 @@ const ChapterAnalysis: React.FC = () => { const [showAnnotations, setShowAnnotations] = useState(true); const [activeAnnotationId, setActiveAnnotationId] = useState(); const [sidebarVisible, setSidebarVisible] = useState(false); + const [chapterListVisible, setChapterListVisible] = useState(false); const [scrollToContentAnnotation, setScrollToContentAnnotation] = useState(); const [scrollToSidebarAnnotation, setScrollToSidebarAnnotation] = useState(); + const [isMobile, setIsMobile] = useState(window.innerWidth < 768); + + // 监听窗口大小变化 + useEffect(() => { + const handleResize = () => { + setIsMobile(window.innerWidth < 768); + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); // 加载章节列表 useEffect(() => { @@ -128,6 +141,9 @@ const ChapterAnalysis: React.FC = () => { const handleChapterSelect = (chapterId: string) => { loadChapterContent(chapterId); + if (isMobile) { + setChapterListVisible(false); + } }; const handlePreviousChapter = () => { @@ -151,7 +167,7 @@ const ChapterAnalysis: React.FC = () => { // 清除滚动状态 setTimeout(() => setScrollToSidebarAnnotation(undefined), 100); - if (window.innerWidth < 768) { + if (isMobile) { setSidebarVisible(true); } } else { @@ -173,48 +189,102 @@ const ChapterAnalysis: React.FC = () => { } return ( -
- {/* 左侧章节列表 */} - - {chapters.length === 0 ? ( - - ) : ( - ( - handleChapterSelect(chapter.id)} - style={{ - cursor: 'pointer', - padding: '12px 16px', - background: selectedChapter?.id === chapter.id ? '#e6f7ff' : 'transparent', - borderLeft: selectedChapter?.id === chapter.id ? '3px solid #1890ff' : '3px solid transparent', - }} - > - - 第{chapter.chapter_number}章: {chapter.title} - - } - description={ - - - {chapter.word_count || 0}字 - - - } - /> - - )} - /> - )} - +
+ {/* 左侧章节列表 - 桌面端 */} + {!isMobile && ( + + {chapters.length === 0 ? ( + + ) : ( + ( + handleChapterSelect(chapter.id)} + style={{ + cursor: 'pointer', + padding: '12px 16px', + background: selectedChapter?.id === chapter.id ? '#e6f7ff' : 'transparent', + borderLeft: selectedChapter?.id === chapter.id ? '3px solid #1890ff' : '3px solid transparent', + }} + > + + 第{chapter.chapter_number}章: {chapter.title} + + } + description={ + + + {chapter.word_count || 0}字 + + + } + /> + + )} + /> + )} + + )} + + {/* 移动端章节列表抽屉 */} + {isMobile && ( + setChapterListVisible(false)} + open={chapterListVisible} + width="85%" + styles={{ body: { padding: 0 } }} + > + {chapters.length === 0 ? ( + + ) : ( + ( + handleChapterSelect(chapter.id)} + style={{ + cursor: 'pointer', + padding: '12px 16px', + background: selectedChapter?.id === chapter.id ? '#e6f7ff' : 'transparent', + borderLeft: selectedChapter?.id === chapter.id ? '3px solid #1890ff' : '3px solid transparent', + }} + > + + 第{chapter.chapter_number}章: {chapter.title} + + } + description={ + + + {chapter.word_count || 0}字 + + + } + /> + + )} + /> + )} + + )} {/* 右侧内容区域 */}
@@ -225,54 +295,138 @@ const ChapterAnalysis: React.FC = () => { ) : ( <> {/* 工具栏 */} - -
- - - - 第{selectedChapter.chapter_number}章: {selectedChapter.title} - - - + + {isMobile ? ( + // 移动端布局:两行显示 +
+ {/* 第一行:标题和翻页按钮 */} +
+
- - {hasAnnotations && ( - <> - } - unCheckedChildren={} - /> - 显示标注 - - - )} - -
+ {/* 第二行:章节、开关、分析按钮 */} +
+ + + {hasAnnotations && ( + <> + } + unCheckedChildren={} + size="small" + style={{ + flexShrink: 0, + height: 16, + minHeight: 16, + lineHeight: '16px' + }} + /> + + + )} +
+
+ ) : ( + // 桌面端布局:保持原样 +
+ + + + 第{selectedChapter.chapter_number}章: {selectedChapter.title} + + + + + + {hasAnnotations && ( + <> + } + unCheckedChildren={} + /> + 显示标注 + + )} + +
+ )} {hasAnnotations && annotationsData && ( -
+
共有 {annotationsData.summary.total_annotations} 个标注: {annotationsData.summary.hooks > 0 && ` 🎣${annotationsData.summary.hooks}个钩子`} {annotationsData.summary.foreshadows > 0 && @@ -286,10 +440,16 @@ const ChapterAnalysis: React.FC = () => { {/* 内容区域 */} -
+
{/* 章节内容 */} {!contentLoading && ( @@ -311,12 +471,16 @@ const ChapterAnalysis: React.FC = () => { onAnnotationClick={(annotation) => handleAnnotationClick(annotation, 'content')} activeAnnotationId={activeAnnotationId} scrollToAnnotation={scrollToContentAnnotation} + style={{ + lineHeight: isMobile ? 1.8 : 2, + fontSize: isMobile ? 14 : 16, + }} /> ) : (
{ {/* 右侧记忆侧边栏(桌面端) */} - {hasAnnotations && annotationsData && window.innerWidth >= 768 && ( + {hasAnnotations && annotationsData && !isMobile && ( { placement="right" onClose={() => setSidebarVisible(false)} open={sidebarVisible} - width="80%" + width={isMobile ? '90%' : '80%'} >