update:1.新增AI生成组织功能,扩展优化组织字段(所在地 代表颜色 格言/口号)

2.适配移动端项目管理-剧情分析UI页面
This commit is contained in:
xiamuceer
2025-11-05 16:22:14 +08:00
parent ff58548a79
commit 397ca30bcb
17 changed files with 1155 additions and 185 deletions
-3
View File
@@ -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
+82 -23
View File
@@ -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
+94 -11
View File
@@ -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)
+208 -2
View File
@@ -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}
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)}")
+21 -2
View File
@@ -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:
+13 -9
View File
@@ -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}")
+6 -2
View File
@@ -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):
+38
View File
@@ -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,
+106 -1
View File
@@ -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
)
# 创建全局提示词服务实例
@@ -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<AnnotatedTextProps> = ({
onAnnotationClick,
activeAnnotationId,
scrollToAnnotation,
style,
}) => {
const annotationRefs = useRef<Record<string, HTMLSpanElement | null>>({});
@@ -243,6 +245,7 @@ const AnnotatedText: React.FC<AnnotatedTextProps> = ({
fontSize: 16,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
...style,
}}
>
{segments.map((segment, index) => renderAnnotatedSegment(segment, index))}
+50 -30
View File
@@ -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<ChapterAnalysisResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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: <TrophyOutlined />,
children: (
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card title="整体评分" style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={6}>
<div style={{ height: isMobile ? 'calc(80vh - 180px)' : 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card title="整体评分" style={{ marginBottom: 16 }} size={isMobile ? 'small' : 'default'}>
<Row gutter={isMobile ? 8 : 16}>
<Col span={isMobile ? 12 : 6}>
<Statistic
title="整体质量"
value={analysis_data.overall_quality_score || 0}
@@ -205,21 +217,21 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
valueStyle={{ color: '#3f8600' }}
/>
</Col>
<Col span={6}>
<Col span={isMobile ? 12 : 6}>
<Statistic
title="节奏把控"
value={analysis_data.pacing_score || 0}
suffix="/ 10"
/>
</Col>
<Col span={6}>
<Col span={isMobile ? 12 : 6}>
<Statistic
title="吸引力"
value={analysis_data.engagement_score || 0}
suffix="/ 10"
/>
</Col>
<Col span={6}>
<Col span={isMobile ? 12 : 6}>
<Statistic
title="连贯性"
value={analysis_data.coherence_score || 0}
@@ -230,15 +242,15 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
</Card>
{analysis_data.analysis_report && (
<Card title="分析摘要" style={{ marginBottom: 16 }}>
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit' }}>
<Card title="分析摘要" style={{ marginBottom: 16 }} size={isMobile ? 'small' : 'default'}>
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit', fontSize: isMobile ? 13 : 14 }}>
{analysis_data.analysis_report}
</pre>
</Card>
)}
{analysis_data.suggestions && analysis_data.suggestions.length > 0 && (
<Card title={<><BulbOutlined /> </>}>
<Card title={<><BulbOutlined /> </>} size={isMobile ? 'small' : 'default'}>
<List
dataSource={analysis_data.suggestions}
renderItem={(item, index) => (
@@ -257,8 +269,8 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
label: `钩子 (${analysis_data.hooks?.length || 0})`,
icon: <ThunderboltOutlined />,
children: (
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card>
<div style={{ height: isMobile ? 'calc(80vh - 180px)' : 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card size={isMobile ? 'small' : 'default'}>
{analysis_data.hooks && analysis_data.hooks.length > 0 ? (
<List
dataSource={analysis_data.hooks}
@@ -289,8 +301,8 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
label: `伏笔 (${analysis_data.foreshadows?.length || 0})`,
icon: <FireOutlined />,
children: (
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card>
<div style={{ height: isMobile ? 'calc(80vh - 180px)' : 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card size={isMobile ? 'small' : 'default'}>
{analysis_data.foreshadows && analysis_data.foreshadows.length > 0 ? (
<List
dataSource={analysis_data.foreshadows}
@@ -326,18 +338,18 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
label: '情感曲线',
icon: <HeartOutlined />,
children: (
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card>
<div style={{ height: isMobile ? 'calc(80vh - 180px)' : 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card size={isMobile ? 'small' : 'default'}>
{analysis_data.emotional_tone ? (
<div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={12}>
<Row gutter={isMobile ? 8 : 16} style={{ marginBottom: isMobile ? 16 : 24 }}>
<Col span={isMobile ? 24 : 12}>
<Statistic
title="主导情绪"
value={analysis_data.emotional_tone}
/>
</Col>
<Col span={12}>
<Col span={isMobile ? 24 : 12}>
<Statistic
title="情感强度"
value={(analysis_data.emotional_intensity * 10).toFixed(1)}
@@ -372,8 +384,8 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
label: `角色 (${analysis_data.character_states?.length || 0})`,
icon: <TeamOutlined />,
children: (
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card>
<div style={{ height: isMobile ? 'calc(80vh - 180px)' : 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card size={isMobile ? 'small' : 'default'}>
{analysis_data.character_states && analysis_data.character_states.length > 0 ? (
<List
dataSource={analysis_data.character_states}
@@ -414,8 +426,8 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
label: `记忆 (${memories?.length || 0})`,
icon: <FireOutlined />,
children: (
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card>
<div style={{ height: isMobile ? 'calc(80vh - 180px)' : 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card size={isMobile ? 'small' : 'default'}>
{memories && memories.length > 0 ? (
<List
dataSource={memories}
@@ -462,20 +474,25 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
title="章节分析"
open={visible}
onCancel={onClose}
width="90%"
centered
width={isMobile ? '100%' : '90%'}
centered={!isMobile}
style={{
maxWidth: '1400px',
paddingBottom: 0
maxWidth: isMobile ? '100%' : '1400px',
paddingBottom: 0,
top: isMobile ? 0 : undefined,
margin: isMobile ? 0 : undefined,
maxHeight: isMobile ? '100vh' : undefined
}}
styles={{
body: {
padding: '24px',
paddingBottom: 0
padding: isMobile ? '12px' : '24px',
paddingBottom: 0,
maxHeight: isMobile ? 'calc(100vh - 110px)' : undefined,
overflowY: isMobile ? 'auto' : undefined
}
}}
footer={[
<Button key="close" onClick={onClose}>
<Button key="close" onClick={onClose} size={isMobile ? 'small' : 'middle'}>
</Button>,
!task && !loading && (
@@ -485,6 +502,7 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
icon={<ReloadOutlined />}
onClick={triggerAnalysis}
loading={loading}
size={isMobile ? 'small' : 'middle'}
>
</Button>
@@ -497,6 +515,7 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
onClick={triggerAnalysis}
loading={loading}
danger
size={isMobile ? 'small' : 'middle'}
>
</Button>
@@ -508,6 +527,7 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
icon={<ReloadOutlined />}
onClick={triggerAnalysis}
loading={loading}
size={isMobile ? 'small' : 'middle'}
>
</Button>
+36
View File
@@ -122,6 +122,42 @@ export const CharacterCard: React.FC<CharacterCardProps> = ({ character, onEdit,
<Tag color="cyan">{character.organization_type}</Tag>
</div>
)}
{character.power_level !== undefined && character.power_level !== null && (
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center' }}>
<Text type="secondary" style={{ flexShrink: 0 }}></Text>
<Tag color={character.power_level >= 70 ? 'red' : character.power_level >= 50 ? 'orange' : 'default'}>
{character.power_level}
</Tag>
</div>
)}
{character.location && (
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
<Text type="secondary" style={{ flexShrink: 0 }}></Text>
<Text
style={{ flex: 1, minWidth: 0 }}
ellipsis={{ tooltip: character.location }}
>
{character.location}
</Text>
</div>
)}
{character.color && (
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
<Text type="secondary" style={{ flexShrink: 0 }}></Text>
<Text style={{ flex: 1, minWidth: 0 }}>{character.color}</Text>
</div>
)}
{character.motto && (
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
<Text type="secondary" style={{ flexShrink: 0 }}></Text>
<Text
style={{ flex: 1, minWidth: 0 }}
ellipsis={{ tooltip: character.motto }}
>
{character.motto}
</Text>
</div>
)}
{character.organization_purpose && (
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
<Text type="secondary" style={{ flexShrink: 0 }}></Text>
+257 -93
View File
@@ -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<string | undefined>();
const [sidebarVisible, setSidebarVisible] = useState(false);
const [chapterListVisible, setChapterListVisible] = useState(false);
const [scrollToContentAnnotation, setScrollToContentAnnotation] = useState<string | undefined>();
const [scrollToSidebarAnnotation, setScrollToSidebarAnnotation] = useState<string | undefined>();
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 (
<div style={{ display: 'flex', height: '100%', gap: 16 }}>
{/* 左侧章节列表 */}
<Card
title="章节列表"
style={{ width: 280, height: '100%', overflow: 'hidden' }}
bodyStyle={{ padding: 0, height: 'calc(100% - 57px)', overflow: 'auto' }}
>
{chapters.length === 0 ? (
<Empty description="暂无章节" style={{ marginTop: 60 }} />
) : (
<List
dataSource={chapters}
renderItem={(chapter) => (
<List.Item
key={chapter.id}
onClick={() => 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',
}}
>
<List.Item.Meta
title={
<span style={{ fontSize: 14, fontWeight: selectedChapter?.id === chapter.id ? 600 : 400 }}>
{chapter.chapter_number}: {chapter.title}
</span>
}
description={
<Space size={4}>
<Tag color={chapter.content && chapter.content.trim() !== '' ? 'success' : 'default'}>
{chapter.word_count || 0}
</Tag>
</Space>
}
/>
</List.Item>
)}
/>
)}
</Card>
<div style={{
display: 'flex',
height: '100%',
gap: isMobile ? 0 : 16,
flexDirection: isMobile ? 'column' : 'row'
}}>
{/* 左侧章节列表 - 桌面端 */}
{!isMobile && (
<Card
title="章节列表"
style={{ width: 280, height: '100%', overflow: 'hidden' }}
bodyStyle={{ padding: 0, height: 'calc(100% - 57px)', overflow: 'auto' }}
>
{chapters.length === 0 ? (
<Empty description="暂无章节" style={{ marginTop: 60 }} />
) : (
<List
dataSource={chapters}
renderItem={(chapter) => (
<List.Item
key={chapter.id}
onClick={() => 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',
}}
>
<List.Item.Meta
title={
<span style={{ fontSize: 14, fontWeight: selectedChapter?.id === chapter.id ? 600 : 400 }}>
{chapter.chapter_number}: {chapter.title}
</span>
}
description={
<Space size={4}>
<Tag color={chapter.content && chapter.content.trim() !== '' ? 'success' : 'default'}>
{chapter.word_count || 0}
</Tag>
</Space>
}
/>
</List.Item>
)}
/>
)}
</Card>
)}
{/* 移动端章节列表抽屉 */}
{isMobile && (
<Drawer
title="章节列表"
placement="left"
onClose={() => setChapterListVisible(false)}
open={chapterListVisible}
width="85%"
styles={{ body: { padding: 0 } }}
>
{chapters.length === 0 ? (
<Empty description="暂无章节" style={{ marginTop: 60 }} />
) : (
<List
dataSource={chapters}
renderItem={(chapter) => (
<List.Item
key={chapter.id}
onClick={() => 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',
}}
>
<List.Item.Meta
title={
<span style={{ fontSize: 14, fontWeight: selectedChapter?.id === chapter.id ? 600 : 400 }}>
{chapter.chapter_number}: {chapter.title}
</span>
}
description={
<Space size={4}>
<Tag color={chapter.content && chapter.content.trim() !== '' ? 'success' : 'default'}>
{chapter.word_count || 0}
</Tag>
</Space>
}
/>
</List.Item>
)}
/>
)}
</Drawer>
)}
{/* 右侧内容区域 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
@@ -225,54 +295,138 @@ const ChapterAnalysis: React.FC = () => {
) : (
<>
{/* 工具栏 */}
<Card size="small" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<Button
icon={<LeftOutlined />}
onClick={handlePreviousChapter}
disabled={!navigation?.previous}
title={navigation?.previous ? `上一章: ${navigation.previous.title}` : '已是第一章'}
>
</Button>
<span style={{ fontSize: 16, fontWeight: 600 }}>
{selectedChapter.chapter_number}: {selectedChapter.title}
</span>
<Button
icon={<RightOutlined />}
onClick={handleNextChapter}
disabled={!navigation?.next}
title={navigation?.next ? `下一章: ${navigation.next.title}` : '已是最后一章'}
>
</Button>
</Space>
<Card size="small" style={{ marginBottom: isMobile ? 8 : 16 }}>
{isMobile ? (
// 移动端布局:两行显示
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* 第一行:标题和翻页按钮 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 8
}}>
<Button
icon={<LeftOutlined />}
onClick={handlePreviousChapter}
disabled={!navigation?.previous}
title={navigation?.previous ? `上一章: ${navigation.previous.title}` : '已是第一章'}
size="small"
/>
<span style={{
fontSize: 14,
fontWeight: 600,
flex: 1,
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
padding: '0 8px'
}}>
{selectedChapter.chapter_number}: {selectedChapter.title}
</span>
<Button
icon={<RightOutlined />}
onClick={handleNextChapter}
disabled={!navigation?.next}
title={navigation?.next ? `下一章: ${navigation.next.title}` : '已是最后一章'}
size="small"
/>
</div>
<Space>
{hasAnnotations && (
<>
<Switch
checked={showAnnotations}
onChange={setShowAnnotations}
checkedChildren={<EyeOutlined />}
unCheckedChildren={<EyeInvisibleOutlined />}
/>
<span style={{ fontSize: 13, color: '#666' }}></span>
<Button
icon={<MenuOutlined />}
onClick={() => setSidebarVisible(true)}
style={{ display: window.innerWidth < 768 ? 'inline-block' : 'none' }}
>
</Button>
</>
)}
</Space>
</div>
{/* 第二行:章节、开关、分析按钮 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 8
}}>
<Button
icon={<UnorderedListOutlined />}
onClick={() => setChapterListVisible(true)}
size="small"
>
</Button>
{hasAnnotations && (
<>
<Switch
checked={showAnnotations}
onChange={setShowAnnotations}
checkedChildren={<EyeOutlined />}
unCheckedChildren={<EyeInvisibleOutlined />}
size="small"
style={{
flexShrink: 0,
height: 16,
minHeight: 16,
lineHeight: '16px'
}}
/>
<Button
icon={<MenuOutlined />}
onClick={() => setSidebarVisible(true)}
size="small"
>
</Button>
</>
)}
</div>
</div>
) : (
// 桌面端布局:保持原样
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<Space>
<Button
icon={<LeftOutlined />}
onClick={handlePreviousChapter}
disabled={!navigation?.previous}
title={navigation?.previous ? `上一章: ${navigation.previous.title}` : '已是第一章'}
>
</Button>
<span style={{ fontSize: 16, fontWeight: 600 }}>
{selectedChapter.chapter_number}: {selectedChapter.title}
</span>
<Button
icon={<RightOutlined />}
onClick={handleNextChapter}
disabled={!navigation?.next}
title={navigation?.next ? `下一章: ${navigation.next.title}` : '已是最后一章'}
>
</Button>
</Space>
<Space>
{hasAnnotations && (
<>
<Switch
checked={showAnnotations}
onChange={setShowAnnotations}
checkedChildren={<EyeOutlined />}
unCheckedChildren={<EyeInvisibleOutlined />}
/>
<span style={{ fontSize: 13, color: '#666' }}></span>
</>
)}
</Space>
</div>
)}
{hasAnnotations && annotationsData && (
<div style={{ marginTop: 12, fontSize: 12, color: '#999' }}>
<div style={{
marginTop: 12,
fontSize: isMobile ? 11 : 12,
color: '#999',
lineHeight: 1.5
}}>
{annotationsData.summary.total_annotations}
{annotationsData.summary.hooks > 0 && ` 🎣${annotationsData.summary.hooks}个钩子`}
{annotationsData.summary.foreshadows > 0 &&
@@ -286,10 +440,16 @@ const ChapterAnalysis: React.FC = () => {
</Card>
{/* 内容区域 */}
<div style={{ flex: 1, display: 'flex', gap: 16, overflow: 'hidden' }}>
<div style={{
flex: 1,
display: 'flex',
gap: isMobile ? 0 : 16,
overflow: 'hidden'
}}>
{/* 章节内容 */}
<Card
style={{ flex: 1, overflow: 'auto' }}
bodyStyle={{ padding: isMobile ? '12px' : '24px' }}
loading={contentLoading}
>
{!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,
}}
/>
) : (
<div
style={{
lineHeight: 2,
fontSize: 16,
lineHeight: isMobile ? 1.8 : 2,
fontSize: isMobile ? 14 : 16,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
@@ -329,7 +493,7 @@ const ChapterAnalysis: React.FC = () => {
</Card>
{/* 右侧记忆侧边栏(桌面端) */}
{hasAnnotations && annotationsData && window.innerWidth >= 768 && (
{hasAnnotations && annotationsData && !isMobile && (
<Card
style={{ width: 400, overflow: 'auto' }}
bodyStyle={{ padding: 0 }}
@@ -351,7 +515,7 @@ const ChapterAnalysis: React.FC = () => {
placement="right"
onClose={() => setSidebarVisible(false)}
open={sidebarVisible}
width="80%"
width={isMobile ? '90%' : '80%'}
>
<MemorySidebar
annotations={annotationsData.annotations}
+17
View File
@@ -425,6 +425,23 @@ export default function Characters() {
>
<TextArea rows={2} placeholder="描述组织的宗旨和目标..." />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="所在地" name="location">
<Input placeholder="组织的主要活动区域或总部位置" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="代表颜色" name="color">
<Input placeholder="如:深红色、金色、黑色等" />
</Form.Item>
</Col>
</Row>
<Form.Item label="格言/口号" name="motto">
<Input placeholder="组织的宗旨、格言或口号" />
</Form.Item>
</>
)}
+191 -6
View File
@@ -1,10 +1,12 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, InputNumber, Input, Descriptions } from 'antd';
import { PlusOutlined, TeamOutlined, UserOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { PlusOutlined, TeamOutlined, UserOutlined, EditOutlined, DeleteOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import axios from 'axios';
const { TextArea } = Input;
interface Organization {
id: string;
character_id: string;
@@ -15,6 +17,7 @@ interface Organization {
power_level: number;
location?: string;
motto?: string;
color?: string;
}
interface OrganizationMember {
@@ -44,7 +47,11 @@ export default function Organizations() {
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(false);
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
const [isEditOrgModalOpen, setIsEditOrgModalOpen] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [form] = Form.useForm();
const [editOrgForm] = Form.useForm();
const [generateForm] = Form.useForm();
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
useEffect(() => {
@@ -144,6 +151,68 @@ export default function Organizations() {
});
};
const handleGenerateOrganization = async (values: {
name?: string;
organization_type?: string;
background?: string;
requirements?: string;
}) => {
try {
setIsGenerating(true);
await axios.post('/api/organizations/generate', {
project_id: projectId,
name: values.name,
organization_type: values.organization_type,
background: values.background,
requirements: values.requirements,
});
message.success('AI生成组织成功');
Modal.destroyAll();
generateForm.resetFields();
loadOrganizations();
} catch (error: any) {
message.error(error.response?.data?.detail || 'AI生成失败');
} finally {
setIsGenerating(false);
}
};
const showGenerateModal = () => {
Modal.confirm({
title: 'AI生成组织',
width: 600,
centered: !isMobile,
content: (
<Form form={generateForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
label="组织名称"
name="name"
>
<Input placeholder="如:天剑门、黑龙会(可选,AI会自动生成)" />
</Form.Item>
<Form.Item
label="组织类型"
name="organization_type"
>
<Input placeholder="如:门派、帮派、公司、学院(可选,AI会根据世界观生成)" />
</Form.Item>
<Form.Item label="背景设定" name="background">
<TextArea rows={3} placeholder="简要描述组织的背景和环境..." />
</Form.Item>
<Form.Item label="其他要求" name="requirements">
<TextArea rows={2} placeholder="其他特殊要求..." />
</Form.Item>
</Form>
),
okText: '生成',
cancelText: '取消',
onOk: async () => {
const values = await generateForm.validateFields();
await handleGenerateOrganization(values);
},
});
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
active: 'green',
@@ -263,6 +332,17 @@ export default function Organizations() {
{!isMobile && <Tag color="blue">{currentProject?.title}</Tag>}
</Space>
}
extra={
<Button
type="dashed"
icon={<ThunderboltOutlined />}
onClick={showGenerateModal}
loading={isGenerating}
size={isMobile ? 'small' : 'middle'}
>
AI生成组织
</Button>
}
>
<div style={{
display: isMobile ? 'flex' : 'grid',
@@ -308,19 +388,53 @@ export default function Organizations() {
<div style={{ minHeight: isMobile ? 'auto' : undefined }}>
{selectedOrg ? (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Card title="组织详情" size="small">
<Card
title="组织详情"
size="small"
extra={
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => {
editOrgForm.setFieldsValue({
power_level: selectedOrg.power_level,
location: selectedOrg.location,
motto: selectedOrg.motto,
color: selectedOrg.color
});
setIsEditOrgModalOpen(true);
}}
>
</Button>
}
>
<Descriptions column={isMobile ? 1 : 2} size="small">
<Descriptions.Item label="组织名称">{selectedOrg.name}</Descriptions.Item>
<Descriptions.Item label="类型">{selectedOrg.type}</Descriptions.Item>
<Descriptions.Item label="成员数量">{selectedOrg.member_count}</Descriptions.Item>
<Descriptions.Item label="势力等级">{selectedOrg.power_level}</Descriptions.Item>
<Descriptions.Item label="势力等级">
<Tag color={selectedOrg.power_level >= 70 ? 'red' : selectedOrg.power_level >= 50 ? 'orange' : 'default'}>
{selectedOrg.power_level}
</Tag>
</Descriptions.Item>
{selectedOrg.location && (
<Descriptions.Item label="所在地">{selectedOrg.location}</Descriptions.Item>
<Descriptions.Item label="所在地" span={isMobile ? 1 : 2}>
{selectedOrg.location}
</Descriptions.Item>
)}
{selectedOrg.color && (
<Descriptions.Item label="代表颜色">
{selectedOrg.color}
</Descriptions.Item>
)}
{selectedOrg.motto && (
<Descriptions.Item label="宗旨" span={2}>{selectedOrg.motto}</Descriptions.Item>
<Descriptions.Item label="格言/口号" span={2}>
{selectedOrg.motto}
</Descriptions.Item>
)}
<Descriptions.Item label="目标/宗旨" span={2}>
<Descriptions.Item label="组织目的" span={2}>
{selectedOrg.purpose}
</Descriptions.Item>
</Descriptions>
@@ -445,6 +559,77 @@ export default function Organizations() {
</Form.Item>
</Form>
</Modal>
{/* 编辑组织模态框 */}
<Modal
title="编辑组织信息"
open={isEditOrgModalOpen}
onCancel={() => {
setIsEditOrgModalOpen(false);
editOrgForm.resetFields();
}}
footer={null}
centered={!isMobile}
width={isMobile ? '100%' : 500}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
styles={isMobile ? { body: { maxHeight: 'calc(100vh - 110px)', overflowY: 'auto' } } : undefined}
>
<Form
form={editOrgForm}
layout="vertical"
onFinish={async (values) => {
if (!selectedOrg) return;
try {
await axios.put(`/api/organizations/${selectedOrg.id}`, values);
message.success('组织信息更新成功');
setIsEditOrgModalOpen(false);
loadOrganizations();
} catch (error) {
message.error('更新失败');
console.error(error);
}
}}
>
<Form.Item
name="power_level"
label="势力等级"
rules={[{ required: true, message: '请输入势力等级' }]}
tooltip="0-100的数值,表示组织的影响力"
>
<InputNumber min={0} max={100} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="location"
label="所在地"
>
<Input placeholder="组织的主要活动区域或总部位置" />
</Form.Item>
<Form.Item
name="motto"
label="格言/口号"
>
<Input placeholder="组织的宗旨、格言或口号" />
</Form.Item>
<Form.Item
name="color"
label="代表颜色"
>
<Input placeholder="如:深红色、金色、黑色等" />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setIsEditOrgModalOpen(false)}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
}
+23 -3
View File
@@ -974,7 +974,6 @@ export default function ProjectWizardNew() {
styles={{
body: {
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(80vh - 110px)',
overflowY: 'auto'
}
}}
>
@@ -1051,12 +1050,29 @@ export default function ProjectWizardNew() {
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="主要成员" name="organization_members">
<Input placeholder="如:张三、李四、王五" />
<Form.Item label="势力等级" name="power_level">
<InputNumber min={0} max={100} placeholder="0-100" style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="所在地" name="location">
<Input placeholder="组织所在地" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="代表颜色" name="color">
<Input placeholder="如:深红色、金色" />
</Form.Item>
</Col>
</Row>
<Form.Item label="格言/口号" name="motto">
<Input placeholder="组织的格言或口号" />
</Form.Item>
<Form.Item
label="组织目的"
name="organization_purpose"
@@ -1064,6 +1080,10 @@ export default function ProjectWizardNew() {
>
<TextArea rows={2} placeholder="描述组织的宗旨和目标..." />
</Form.Item>
<Form.Item label="主要成员" name="organization_members">
<Input placeholder="如:张三、李四、王五" />
</Form.Item>
</>
)}
+10
View File
@@ -172,6 +172,11 @@ export interface Character {
organization_members?: string;
traits?: string;
avatar_url?: string;
// 组织扩展字段(从Organization表关联)
power_level?: number;
location?: string;
motto?: string;
color?: string;
created_at: string;
updated_at: string;
}
@@ -190,6 +195,11 @@ export interface CharacterUpdate {
organization_purpose?: string;
organization_members?: string;
traits?: string;
// 组织扩展字段
power_level?: number;
location?: string;
motto?: string;
color?: string;
}
// 章节类型定义