update:1.支持手动创建角色 组织

This commit is contained in:
xiamuceer
2025-11-14 10:24:53 +08:00
parent 4431855a14
commit 1c35b82e61
8 changed files with 968 additions and 113 deletions
+288
View File
@@ -3,13 +3,16 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
import json import json
from typing import AsyncGenerator
from app.database import get_db from app.database import get_db
from app.utils.sse_response import SSEResponse, create_sse_response
from app.models.character import Character from app.models.character import Character
from app.models.project import Project from app.models.project import Project
from app.models.generation_history import GenerationHistory from app.models.generation_history import GenerationHistory
from app.models.relationship import CharacterRelationship, Organization, OrganizationMember, RelationshipType from app.models.relationship import CharacterRelationship, Organization, OrganizationMember, RelationshipType
from app.schemas.character import ( from app.schemas.character import (
CharacterCreate,
CharacterUpdate, CharacterUpdate,
CharacterResponse, CharacterResponse,
CharacterListResponse, CharacterListResponse,
@@ -276,6 +279,79 @@ async def delete_character(
return {"message": "角色删除成功"} return {"message": "角色删除成功"}
@router.post("", response_model=CharacterResponse, summary="手动创建角色")
async def create_character(
character_data: CharacterCreate,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
手动创建角色或组织
- 可以创建普通角色(is_organization=False
- 也可以创建组织(is_organization=True
- 如果创建组织且提供了组织额外字段,会自动创建Organization详情记录
"""
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(character_data.project_id, user_id, db)
try:
# 创建角色
character = Character(
project_id=character_data.project_id,
name=character_data.name,
age=character_data.age,
gender=character_data.gender,
is_organization=character_data.is_organization,
role_type=character_data.role_type or "supporting",
personality=character_data.personality,
background=character_data.background,
appearance=character_data.appearance,
relationships=character_data.relationships,
organization_type=character_data.organization_type,
organization_purpose=character_data.organization_purpose,
organization_members=character_data.organization_members,
traits=character_data.traits,
avatar_url=character_data.avatar_url
)
db.add(character)
await db.flush() # 获取character.id
logger.info(f"✅ 手动创建角色成功:{character.name} (ID: {character.id}, 是否组织: {character.is_organization})")
# 如果是组织,且提供了组织额外字段,自动创建Organization详情记录
if character.is_organization and (
character_data.power_level is not None or
character_data.location or
character_data.motto or
character_data.color
):
organization = Organization(
character_id=character.id,
project_id=character_data.project_id,
member_count=0,
power_level=character_data.power_level or 50,
location=character_data.location,
motto=character_data.motto,
color=character_data.color
)
db.add(organization)
await db.flush()
logger.info(f"✅ 自动创建组织详情:{character.name} (Org ID: {organization.id})")
await db.commit()
await db.refresh(character)
logger.info(f"🎉 成功手动创建角色/组织: {character.name}")
return character
except Exception as e:
logger.error(f"手动创建角色失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"创建角色失败: {str(e)}")
@router.post("/generate", response_model=CharacterResponse, summary="AI生成角色") @router.post("/generate", response_model=CharacterResponse, summary="AI生成角色")
async def generate_character( async def generate_character(
request: CharacterGenerateRequest, request: CharacterGenerateRequest,
@@ -650,3 +726,215 @@ async def generate_character(
except Exception as e: except Exception as e:
logger.error(f"生成角色失败: {str(e)}") logger.error(f"生成角色失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"生成角色失败: {str(e)}") raise HTTPException(status_code=500, detail=f"生成角色失败: {str(e)}")
@router.post("/generate-stream", summary="AI生成角色(流式)")
async def generate_character_stream(
request: CharacterGenerateRequest,
http_request: Request,
db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service)
):
"""
使用AI生成角色卡(支持SSE流式进度显示)
通过Server-Sent Events返回实时进度信息
"""
async def generate() -> AsyncGenerator[str, None]:
try:
# 验证用户权限和项目是否存在
user_id = getattr(http_request.state, 'user_id', None)
project = await verify_project_access(request.project_id, user_id, db)
yield await SSEResponse.send_progress("开始生成角色...", 0)
# 获取已存在的角色列表
yield await SSEResponse.send_progress("获取项目上下文...", 10)
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_chars_info = ""
character_list = []
organization_list = []
if existing_characters:
for c in existing_characters[: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_chars_info += "\n已有角色:\n" + "\n".join(character_list)
if organization_list:
existing_chars_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_chars_info}
"""
user_input = f"""
用户要求:
- 角色名称:{request.name or '请AI生成'}
- 角色定位:{request.role_type or 'supporting'}
- 背景设定:{request.background or '无特殊要求'}
- 其他要求:{request.requirements or ''}
"""
yield await SSEResponse.send_progress("构建AI提示词...", 20)
prompt = prompt_service.get_single_character_prompt(
project_context=project_context,
user_input=user_input
)
yield await SSEResponse.send_progress("调用AI服务生成角色...", 30)
logger.info(f"🎯 开始为项目 {request.project_id} 生成角色(SSE流式)")
try:
result = await user_ai_service.generate_text_with_mcp(
prompt=prompt,
user_id=user_id,
db_session=db,
enable_mcp=True,
max_tool_rounds=2,
tool_choice="auto",
provider=None,
model=None
)
if isinstance(result, dict):
ai_response = result.get('content', '')
else:
ai_response = result
except Exception as ai_error:
logger.error(f"❌ AI服务调用异常:{str(ai_error)}")
yield await SSEResponse.send_error(f"AI服务调用失败:{str(ai_error)}")
return
if not ai_response or not ai_response.strip():
yield await SSEResponse.send_error("AI服务返回空响应")
return
yield await SSEResponse.send_progress("解析AI响应...", 60)
# 清理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()
try:
character_data = json.loads(cleaned_response)
except json.JSONDecodeError as e:
yield await SSEResponse.send_error(f"AI返回的内容无法解析为JSON{str(e)}")
return
yield await SSEResponse.send_progress("创建角色记录...", 75)
# 转换traits
traits_json = json.dumps(character_data.get("traits", []), ensure_ascii=False) if character_data.get("traits") else None
is_organization = character_data.get("is_organization", False)
# 创建角色
character = Character(
project_id=request.project_id,
name=character_data.get("name", request.name or "未命名角色"),
age=str(character_data.get("age", "")),
gender=character_data.get("gender"),
is_organization=is_organization,
role_type=request.role_type or "supporting",
personality=character_data.get("personality", ""),
background=character_data.get("background", ""),
appearance=character_data.get("appearance", ""),
relationships=character_data.get("relationships_text", character_data.get("relationships", "")),
organization_type=character_data.get("organization_type") if is_organization else None,
organization_purpose=character_data.get("organization_purpose") if is_organization else None,
organization_members=json.dumps(character_data.get("organization_members", []), ensure_ascii=False) if is_organization else None,
traits=traits_json
)
db.add(character)
await db.flush()
logger.info(f"✅ 角色创建成功:{character.name} (ID: {character.id})")
# 如果是组织,创建Organization详情
if is_organization:
yield await SSEResponse.send_progress("创建组织详情...", 85)
org_check = await db.execute(
select(Organization).where(Organization.character_id == character.id)
)
existing_org = org_check.scalar_one_or_none()
if not existing_org:
organization = Organization(
character_id=character.id,
project_id=request.project_id,
member_count=0,
power_level=character_data.get("power_level", 50),
location=character_data.get("location"),
motto=character_data.get("motto"),
color=character_data.get("color")
)
db.add(organization)
await db.flush()
yield await SSEResponse.send_progress("保存生成历史...", 95)
# 记录生成历史
history = GenerationHistory(
project_id=request.project_id,
prompt=prompt,
generated_content=json.dumps(result, ensure_ascii=False) if isinstance(result, dict) else ai_response,
model=user_ai_service.default_model
)
db.add(history)
await db.commit()
await db.refresh(character)
logger.info(f"🎉 成功生成角色: {character.name}")
yield await SSEResponse.send_progress("角色生成完成!", 100, "success")
# 发送结果数据
yield await SSEResponse.send_result({
"character": {
"id": character.id,
"name": character.name,
"role_type": character.role_type,
"is_organization": character.is_organization
}
})
yield await SSEResponse.send_done()
except HTTPException as he:
logger.error(f"HTTP异常: {he.detail}")
yield await SSEResponse.send_error(he.detail, he.status_code)
except Exception as e:
logger.error(f"生成角色失败: {str(e)}")
yield await SSEResponse.send_error(f"生成角色失败: {str(e)}")
return create_sse_response(generate())
+195 -2
View File
@@ -2,11 +2,12 @@
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_ from sqlalchemy import select, and_
from typing import List, Optional from typing import List, Optional, AsyncGenerator
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import json import json
from app.database import get_db from app.database import get_db
from app.utils.sse_response import SSEResponse, create_sse_response
from app.models.relationship import Organization, OrganizationMember from app.models.relationship import Organization, OrganizationMember
from app.models.character import Character from app.models.character import Character
from app.models.project import Project from app.models.project import Project
@@ -129,7 +130,7 @@ async def get_organization(
return org return org
@router.post("/", response_model=OrganizationResponse, summary="创建组织") @router.post("", response_model=OrganizationResponse, summary="创建组织")
async def create_organization( async def create_organization(
organization: OrganizationCreate, organization: OrganizationCreate,
request: Request, request: Request,
@@ -616,3 +617,195 @@ async def generate_organization(
except Exception as e: except Exception as e:
logger.error(f"生成组织失败: {str(e)}") logger.error(f"生成组织失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"生成组织失败: {str(e)}") raise HTTPException(status_code=500, detail=f"生成组织失败: {str(e)}")
@router.post("/generate-stream", summary="AI生成组织(流式)")
async def generate_organization_stream(
gen_request: OrganizationGenerateRequest,
http_request: Request,
db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service)
):
"""
使用AI生成组织设定(支持SSE流式进度显示)
通过Server-Sent Events返回实时进度信息
"""
async def generate() -> AsyncGenerator[str, None]:
try:
# 验证用户权限和项目是否存在
user_id = getattr(http_request.state, 'user_id', None)
project = await verify_project_access(gen_request.project_id, user_id, db)
yield await SSEResponse.send_progress("开始生成组织...", 0)
# 获取已存在的角色和组织列表
yield await SSEResponse.send_progress("获取项目上下文...", 10)
existing_chars_result = await db.execute(
select(Character)
.where(Character.project_id == gen_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]:
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"""
用户要求:
- 组织名称:{gen_request.name or '请AI生成'}
- 组织类型:{gen_request.organization_type or '请AI根据世界观决定'}
- 背景设定:{gen_request.background or '无特殊要求'}
- 其他要求:{gen_request.requirements or ''}
"""
yield await SSEResponse.send_progress("构建AI提示词...", 20)
prompt = prompt_service.get_single_organization_prompt(
project_context=project_context,
user_input=user_input
)
yield await SSEResponse.send_progress("调用AI服务生成组织...", 30)
logger.info(f"🎯 开始为项目 {gen_request.project_id} 生成组织(SSE流式)")
try:
ai_response = await user_ai_service.generate_text(prompt=prompt)
ai_content = ai_response.get("content", "") if isinstance(ai_response, dict) else str(ai_response)
except Exception as ai_error:
logger.error(f"❌ AI服务调用异常:{str(ai_error)}")
yield await SSEResponse.send_error(f"AI服务调用失败:{str(ai_error)}")
return
if not ai_content or not ai_content.strip():
yield await SSEResponse.send_error("AI服务返回空响应")
return
yield await SSEResponse.send_progress("解析AI响应...", 60)
# 清理AI响应
cleaned_response = ai_content.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()
try:
organization_data = json.loads(cleaned_response)
except json.JSONDecodeError as e:
yield await SSEResponse.send_error(f"AI返回的内容无法解析为JSON{str(e)}")
return
yield await SSEResponse.send_progress("创建组织记录...", 75)
# 创建角色记录(组织也是角色的一种)
character = Character(
project_id=gen_request.project_id,
name=organization_data.get("name", gen_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})")
yield await SSEResponse.send_progress("创建组织详情...", 85)
# 自动创建Organization详情记录
organization = Organization(
character_id=character.id,
project_id=gen_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})")
yield await SSEResponse.send_progress("保存生成历史...", 95)
# 记录生成历史
history = GenerationHistory(
project_id=gen_request.project_id,
prompt=prompt,
generated_content=ai_content,
model=user_ai_service.default_model
)
db.add(history)
await db.commit()
await db.refresh(character)
logger.info(f"🎉 成功生成组织: {character.name}")
yield await SSEResponse.send_progress("组织生成完成!", 100, "success")
# 发送结果数据
yield await SSEResponse.send_result({
"character": {
"id": character.id,
"name": character.name,
"organization_type": character.organization_type,
"is_organization": character.is_organization
}
})
yield await SSEResponse.send_done()
except HTTPException as he:
logger.error(f"HTTP异常: {he.detail}")
yield await SSEResponse.send_error(he.detail, he.status_code)
except Exception as e:
logger.error(f"生成组织失败: {str(e)}")
yield await SSEResponse.send_error(f"生成组织失败: {str(e)}")
return create_sse_response(generate())
+25
View File
@@ -21,6 +21,31 @@ class CharacterBase(BaseModel):
traits: Optional[str] = Field(None, description="特征标签(JSON)") traits: Optional[str] = Field(None, description="特征标签(JSON)")
class CharacterCreate(BaseModel):
"""手动创建角色的请求模型"""
project_id: str = Field(..., description="项目ID")
name: str = Field(..., description="角色/组织姓名")
age: Optional[str] = Field(None, description="年龄")
gender: Optional[str] = Field(None, description="性别")
is_organization: bool = Field(False, description="是否为组织")
role_type: Optional[str] = Field("supporting", description="角色类型:protagonist/supporting/antagonist")
personality: Optional[str] = Field(None, description="性格特点/组织特性")
background: Optional[str] = Field(None, description="背景故事")
appearance: Optional[str] = Field(None, description="外貌特征")
relationships: Optional[str] = Field(None, description="人际关系(JSON)")
organization_type: Optional[str] = Field(None, description="组织类型")
organization_purpose: Optional[str] = Field(None, description="组织目的")
organization_members: Optional[str] = Field(None, description="组织成员(JSON)")
traits: Optional[str] = Field(None, description="特征标签(JSON)")
avatar_url: Optional[str] = Field(None, description="头像URL")
# 组织额外字段
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 CharacterUpdate(BaseModel): class CharacterUpdate(BaseModel):
"""更新角色的请求模型""" """更新角色的请求模型"""
name: Optional[str] = None name: Optional[str] = None
+28 -2
View File
@@ -7,6 +7,7 @@ import { projectApi, writingStyleApi } from '../services/api';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask } from '../types'; import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask } from '../types';
import { cardStyles } from '../components/CardStyles'; import { cardStyles } from '../components/CardStyles';
import ChapterAnalysis from '../components/ChapterAnalysis'; import ChapterAnalysis from '../components/ChapterAnalysis';
import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
const { TextArea } = Input; const { TextArea } = Input;
@@ -30,6 +31,10 @@ export default function Chapters() {
const [analysisTasksMap, setAnalysisTasksMap] = useState<Record<string, AnalysisTask>>({}); const [analysisTasksMap, setAnalysisTasksMap] = useState<Record<string, AnalysisTask>>({});
const pollingIntervalsRef = useRef<Record<string, number>>({}); const pollingIntervalsRef = useRef<Record<string, number>>({});
// 单章节生成进度状态
const [singleChapterProgress, setSingleChapterProgress] = useState(0);
const [singleChapterProgressMessage, setSingleChapterProgressMessage] = useState('');
// 批量生成相关状态 // 批量生成相关状态
const [batchGenerateVisible, setBatchGenerateVisible] = useState(false); const [batchGenerateVisible, setBatchGenerateVisible] = useState(false);
const [batchGenerating, setBatchGenerating] = useState(false); const [batchGenerating, setBatchGenerating] = useState(false);
@@ -301,8 +306,12 @@ export default function Chapters() {
try { try {
setIsContinuing(true); setIsContinuing(true);
setIsGenerating(true); setIsGenerating(true);
setSingleChapterProgress(0);
setSingleChapterProgressMessage('准备开始生成...');
const result = await generateChapterContentStream(editingId, (content) => { const result = await generateChapterContentStream(
editingId,
(content) => {
editorForm.setFieldsValue({ content }); editorForm.setFieldsValue({ content });
if (contentTextAreaRef.current) { if (contentTextAreaRef.current) {
@@ -311,7 +320,15 @@ export default function Chapters() {
textArea.scrollTop = textArea.scrollHeight; textArea.scrollTop = textArea.scrollHeight;
} }
} }
}, selectedStyleId, targetWordCount); },
selectedStyleId,
targetWordCount,
(progressMsg, progressValue) => {
// 进度回调
setSingleChapterProgress(progressValue);
setSingleChapterProgressMessage(progressMsg);
}
);
message.success('AI创作成功,正在分析章节内容...'); message.success('AI创作成功,正在分析章节内容...');
@@ -338,6 +355,8 @@ export default function Chapters() {
} finally { } finally {
setIsContinuing(false); setIsContinuing(false);
setIsGenerating(false); setIsGenerating(false);
setSingleChapterProgress(0);
setSingleChapterProgressMessage('');
} }
}; };
@@ -1378,6 +1397,13 @@ export default function Chapters() {
</div> </div>
)} )}
</Modal> </Modal>
{/* 单章节生成进度显示 */}
<SSELoadingOverlay
loading={isGenerating}
progress={singleChapterProgress}
message={singleChapterProgressMessage}
/>
</div> </div>
); );
} }
+363 -9
View File
@@ -1,12 +1,14 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Button, Modal, Form, Input, Select, message, Row, Col, Empty, Tabs, Divider, Typography, Space } from 'antd'; import { Button, Modal, Form, Input, Select, message, Row, Col, Empty, Tabs, Divider, Typography, Space, InputNumber } from 'antd';
import { ThunderboltOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons'; import { ThunderboltOutlined, UserOutlined, TeamOutlined, PlusOutlined } from '@ant-design/icons';
import { useStore } from '../store'; import { useStore } from '../store';
import { useCharacterSync } from '../store/hooks'; import { useCharacterSync } from '../store/hooks';
import { characterGridConfig } from '../components/CardStyles'; import { characterGridConfig } from '../components/CardStyles';
import { CharacterCard } from '../components/CharacterCard'; import { CharacterCard } from '../components/CharacterCard';
import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
import type { Character, CharacterUpdate } from '../types'; import type { Character, CharacterUpdate } from '../types';
import { characterApi } from '../services/api'; import { characterApi } from '../services/api';
import { SSEPostClient } from '../utils/sseClient';
const { Title } = Typography; const { Title } = Typography;
@@ -15,16 +17,21 @@ const { TextArea } = Input;
export default function Characters() { export default function Characters() {
const { currentProject, characters } = useStore(); const { currentProject, characters } = useStore();
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [progress, setProgress] = useState(0);
const [progressMessage, setProgressMessage] = useState('');
const [activeTab, setActiveTab] = useState<'all' | 'character' | 'organization'>('all'); const [activeTab, setActiveTab] = useState<'all' | 'character' | 'organization'>('all');
const [generateForm] = Form.useForm(); const [generateForm] = Form.useForm();
const [generateOrgForm] = Form.useForm();
const [createForm] = Form.useForm();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [createType, setCreateType] = useState<'character' | 'organization'>('character');
const [editingCharacter, setEditingCharacter] = useState<Character | null>(null); const [editingCharacter, setEditingCharacter] = useState<Character | null>(null);
const { const {
refreshCharacters, refreshCharacters,
deleteCharacter, deleteCharacter
generateCharacter
} = useCharacterSync(); } = useCharacterSync();
useEffect(() => { useEffect(() => {
@@ -48,18 +55,140 @@ export default function Characters() {
const handleGenerate = async (values: { name?: string; role_type: string; background?: string }) => { const handleGenerate = async (values: { name?: string; role_type: string; background?: string }) => {
try { try {
setIsGenerating(true); setIsGenerating(true);
await generateCharacter({ setProgress(0);
setProgressMessage('准备生成角色...');
const client = new SSEPostClient(
'/api/characters/generate-stream',
{
project_id: currentProject.id, project_id: currentProject.id,
name: values.name, name: values.name,
role_type: values.role_type, role_type: values.role_type,
background: values.background, background: values.background,
}); },
{
onProgress: (msg, prog) => {
setProgress(prog);
setProgressMessage(msg);
},
onResult: (data) => {
console.log('角色生成完成:', data);
},
onError: (error) => {
message.error(`生成失败: ${error}`);
},
onComplete: () => {
setProgress(100);
setProgressMessage('生成完成!');
}
}
);
await client.connect();
message.success('AI生成角色成功'); message.success('AI生成角色成功');
Modal.destroyAll(); Modal.destroyAll();
} catch { await refreshCharacters();
message.error('AI生成失败'); } catch (error: any) {
message.error(error.message || 'AI生成失败');
} finally { } finally {
setTimeout(() => {
setIsGenerating(false); setIsGenerating(false);
setProgress(0);
setProgressMessage('');
}, 500);
}
};
const handleGenerateOrganization = async (values: {
name?: string;
organization_type?: string;
background?: string;
requirements?: string;
}) => {
try {
setIsGenerating(true);
setProgress(0);
setProgressMessage('准备生成组织...');
const client = new SSEPostClient(
'/api/organizations/generate-stream',
{
project_id: currentProject.id,
name: values.name,
organization_type: values.organization_type,
background: values.background,
requirements: values.requirements,
},
{
onProgress: (msg, prog) => {
setProgress(prog);
setProgressMessage(msg);
},
onResult: (data) => {
console.log('组织生成完成:', data);
},
onError: (error) => {
message.error(`生成失败: ${error}`);
},
onComplete: () => {
setProgress(100);
setProgressMessage('生成完成!');
}
}
);
await client.connect();
message.success('AI生成组织成功');
Modal.destroyAll();
await refreshCharacters();
} catch (error: any) {
message.error(error.message || 'AI生成失败');
} finally {
setTimeout(() => {
setIsGenerating(false);
setProgress(0);
setProgressMessage('');
}, 500);
}
};
const handleCreateCharacter = async (values: any) => {
try {
const createData: any = {
project_id: currentProject.id,
name: values.name,
is_organization: createType === 'organization',
};
if (createType === 'character') {
// 角色字段
createData.age = values.age;
createData.gender = values.gender;
createData.role_type = values.role_type || 'supporting';
createData.personality = values.personality;
createData.appearance = values.appearance;
createData.relationships = values.relationships;
createData.background = values.background;
} else {
// 组织字段
createData.organization_type = values.organization_type;
createData.organization_purpose = values.organization_purpose;
createData.organization_members = values.organization_members;
createData.background = values.background;
createData.power_level = values.power_level;
createData.location = values.location;
createData.motto = values.motto;
createData.color = values.color;
createData.role_type = 'supporting'; // 组织默认为配角
}
await characterApi.createCharacter(createData);
message.success(`${createType === 'character' ? '角色' : '组织'}创建成功`);
setIsCreateModalOpen(false);
createForm.resetFields();
await refreshCharacters();
} catch {
message.error('创建失败');
} }
}; };
@@ -126,6 +255,42 @@ export default function Characters() {
}); });
}; };
const showGenerateOrgModal = () => {
Modal.confirm({
title: 'AI生成组织',
width: 600,
centered: true,
content: (
<Form form={generateOrgForm} 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 generateOrgForm.validateFields();
await handleGenerateOrganization(values);
},
});
};
const characterList = characters.filter(c => !c.is_organization); const characterList = characters.filter(c => !c.is_organization);
const organizationList = characters.filter(c => c.is_organization); const organizationList = characters.filter(c => c.is_organization);
@@ -156,15 +321,48 @@ export default function Characters() {
alignItems: isMobile ? 'stretch' : 'center' alignItems: isMobile ? 'stretch' : 'center'
}}> }}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}></h2> <h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}></h2>
<Space wrap>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setCreateType('character');
setIsCreateModalOpen(true);
}}
size={isMobile ? 'small' : 'middle'}
>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setCreateType('organization');
setIsCreateModalOpen(true);
}}
size={isMobile ? 'small' : 'middle'}
>
</Button>
<Button <Button
type="dashed" type="dashed"
icon={<ThunderboltOutlined />} icon={<ThunderboltOutlined />}
onClick={showGenerateModal} onClick={showGenerateModal}
loading={isGenerating} loading={isGenerating}
block={isMobile} size={isMobile ? 'small' : 'middle'}
> >
AI生成角色 AI生成角色
</Button> </Button>
<Button
type="dashed"
icon={<ThunderboltOutlined />}
onClick={showGenerateOrgModal}
loading={isGenerating}
size={isMobile ? 'small' : 'middle'}
>
AI生成组织
</Button>
</Space>
</div> </div>
{characters.length > 0 && ( {characters.length > 0 && (
@@ -465,6 +663,162 @@ export default function Characters() {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
{/* 手动创建角色/组织模态框 */}
<Modal
title={createType === 'character' ? '创建角色' : '创建组织'}
open={isCreateModalOpen}
onCancel={() => {
setIsCreateModalOpen(false);
createForm.resetFields();
}}
footer={null}
centered={!isMobile}
width={isMobile ? '100%' : 600}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
styles={isMobile ? { body: { maxHeight: 'calc(100vh - 110px)', overflowY: 'auto' } } : undefined}
>
<Form form={createForm} layout="vertical" onFinish={handleCreateCharacter}>
<Row gutter={16}>
<Col span={createType === 'organization' ? 24 : 12}>
<Form.Item
label={createType === 'character' ? '角色名称' : '组织名称'}
name="name"
rules={[{ required: true, message: `请输入${createType === 'character' ? '角色' : '组织'}名称` }]}
>
<Input placeholder={`输入${createType === 'character' ? '角色' : '组织'}名称`} />
</Form.Item>
</Col>
{createType === 'character' && (
<Col span={12}>
<Form.Item label="角色定位" name="role_type" initialValue="supporting">
<Select>
<Select.Option value="protagonist"></Select.Option>
<Select.Option value="supporting"></Select.Option>
<Select.Option value="antagonist"></Select.Option>
</Select>
</Form.Item>
</Col>
)}
</Row>
{createType === 'character' ? (
<>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="年龄" name="age">
<Input placeholder="如:25、30岁" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="性别" name="gender">
<Select placeholder="选择性别">
<Select.Option value="男"></Select.Option>
<Select.Option value="女"></Select.Option>
<Select.Option value="其他"></Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item label="性格特点" name="personality">
<TextArea rows={2} placeholder="描述角色的性格特点..." />
</Form.Item>
<Form.Item label="外貌描写" name="appearance">
<TextArea rows={2} placeholder="描述角色的外貌特征..." />
</Form.Item>
<Form.Item label="人际关系" name="relationships">
<TextArea rows={2} placeholder="描述角色与其他角色的关系..." />
</Form.Item>
<Form.Item label="角色背景" name="background">
<TextArea rows={3} placeholder="描述角色的背景故事..." />
</Form.Item>
</>
) : (
<>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="组织类型"
name="organization_type"
rules={[{ required: true, message: '请输入组织类型' }]}
>
<Input placeholder="如:帮派、公司、门派、学院" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="势力等级"
name="power_level"
initialValue={50}
tooltip="0-100的数值,表示组织的影响力"
>
<InputNumber min={0} max={100} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item
label="组织目的"
name="organization_purpose"
rules={[{ required: true, message: '请输入组织目的' }]}
>
<TextArea rows={2} placeholder="描述组织的宗旨和目标..." />
</Form.Item>
<Form.Item label="主要成员" name="organization_members">
<Input 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>
<Form.Item label="组织背景" name="background">
<TextArea rows={3} placeholder="描述组织的背景故事..." />
</Form.Item>
</>
)}
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => {
setIsCreateModalOpen(false);
createForm.resetFields();
}}>
</Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* SSE进度显示 */}
<SSELoadingOverlay
loading={isGenerating}
progress={progress}
message={progressMessage}
/>
</div> </div>
); );
} }
+1 -78
View File
@@ -1,12 +1,10 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, InputNumber, Input, Descriptions } from 'antd'; import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, InputNumber, Input, Descriptions } from 'antd';
import { PlusOutlined, TeamOutlined, UserOutlined, EditOutlined, DeleteOutlined, ThunderboltOutlined } from '@ant-design/icons'; import { PlusOutlined, TeamOutlined, UserOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { useStore } from '../store'; import { useStore } from '../store';
import axios from 'axios'; import axios from 'axios';
const { TextArea } = Input;
interface Organization { interface Organization {
id: string; id: string;
character_id: string; character_id: string;
@@ -48,10 +46,8 @@ export default function Organizations() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false); const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
const [isEditOrgModalOpen, setIsEditOrgModalOpen] = useState(false); const [isEditOrgModalOpen, setIsEditOrgModalOpen] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [editOrgForm] = Form.useForm(); const [editOrgForm] = Form.useForm();
const [generateForm] = Form.useForm();
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768); const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
useEffect(() => { useEffect(() => {
@@ -151,68 +147,6 @@ 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 getStatusColor = (status: string) => {
const colors: Record<string, string> = { const colors: Record<string, string> = {
active: 'green', active: 'green',
@@ -332,17 +266,6 @@ export default function Organizations() {
{!isMobile && <Tag color="blue">{currentProject?.title}</Tag>} {!isMobile && <Tag color="blue">{currentProject?.title}</Tag>}
</Space> </Space>
} }
extra={
<Button
type="dashed"
icon={<ThunderboltOutlined />}
onClick={showGenerateModal}
loading={isGenerating}
size={isMobile ? 'small' : 'middle'}
>
AI生成组织
</Button>
}
> >
<div style={{ <div style={{
display: isMobile ? 'flex' : 'grid', display: isMobile ? 'flex' : 'grid',
+23
View File
@@ -303,6 +303,29 @@ export const characterApi = {
getCharacter: (id: string) => api.get<unknown, Character>(`/characters/${id}`), getCharacter: (id: string) => api.get<unknown, Character>(`/characters/${id}`),
createCharacter: (data: {
project_id: string;
name: string;
age?: string;
gender?: string;
is_organization?: boolean;
role_type?: string;
personality?: string;
background?: string;
appearance?: string;
relationships?: string;
organization_type?: string;
organization_purpose?: string;
organization_members?: string;
traits?: string;
avatar_url?: string;
power_level?: number;
location?: string;
motto?: string;
color?: string;
}) =>
api.post<unknown, Character>('/characters', data),
updateCharacter: (id: string, data: CharacterUpdate) => updateCharacter: (id: string, data: CharacterUpdate) =>
api.put<unknown, Character>(`/characters/${id}`, data), api.put<unknown, Character>(`/characters/${id}`, data),
+25 -2
View File
@@ -303,7 +303,8 @@ export function useChapterSync() {
chapterId: string, chapterId: string,
onProgress?: (content: string) => void, onProgress?: (content: string) => void,
styleId?: number, styleId?: number,
targetWordCount?: number targetWordCount?: number,
onProgressUpdate?: (message: string, progress: number) => void
) => { ) => {
try { try {
// 使用fetch处理流式响应 // 使用fetch处理流式响应
@@ -356,7 +357,20 @@ export function useChapterSync() {
if (dataMatch) { if (dataMatch) {
const message = JSON.parse(dataMatch[1]); const message = JSON.parse(dataMatch[1]);
if (message.type === 'content' && message.content) { if (message.type === 'start') {
// 开始生成
if (onProgressUpdate) {
onProgressUpdate(message.message || '开始生成...', 0);
}
} else if (message.type === 'progress') {
// 进度更新
if (onProgressUpdate) {
onProgressUpdate(
message.message || '生成中...',
message.progress || 0
);
}
} else if (message.type === 'content' && message.content) {
fullContent += message.content; fullContent += message.content;
if (onProgress) { if (onProgress) {
onProgress(fullContent); onProgress(fullContent);
@@ -366,8 +380,17 @@ export function useChapterSync() {
} else if (message.type === 'done') { } else if (message.type === 'done') {
// 生成完成,保存分析任务ID // 生成完成,保存分析任务ID
analysisTaskId = message.analysis_task_id; analysisTaskId = message.analysis_task_id;
if (onProgressUpdate) {
onProgressUpdate('生成完成', 100);
}
// 生成完成,刷新章节数据 // 生成完成,刷新章节数据
await refreshChapters(); await refreshChapters();
} else if (message.type === 'analysis_started') {
// 分析已开始
analysisTaskId = message.task_id;
if (onProgressUpdate) {
onProgressUpdate('章节分析已开始...', 100);
}
} else if (message.type === 'analysis_queued') { } else if (message.type === 'analysis_queued') {
// 分析任务已加入队列 // 分析任务已加入队列
analysisTaskId = message.task_id; analysisTaskId = message.task_id;