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
+34 -8
View File
@@ -7,6 +7,7 @@ import { projectApi, writingStyleApi } from '../services/api';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask } from '../types';
import { cardStyles } from '../components/CardStyles';
import ChapterAnalysis from '../components/ChapterAnalysis';
import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
const { TextArea } = Input;
@@ -30,6 +31,10 @@ export default function Chapters() {
const [analysisTasksMap, setAnalysisTasksMap] = useState<Record<string, AnalysisTask>>({});
const pollingIntervalsRef = useRef<Record<string, number>>({});
// 单章节生成进度状态
const [singleChapterProgress, setSingleChapterProgress] = useState(0);
const [singleChapterProgressMessage, setSingleChapterProgressMessage] = useState('');
// 批量生成相关状态
const [batchGenerateVisible, setBatchGenerateVisible] = useState(false);
const [batchGenerating, setBatchGenerating] = useState(false);
@@ -301,17 +306,29 @@ export default function Chapters() {
try {
setIsContinuing(true);
setIsGenerating(true);
setSingleChapterProgress(0);
setSingleChapterProgressMessage('准备开始生成...');
const result = await generateChapterContentStream(editingId, (content) => {
editorForm.setFieldsValue({ content });
if (contentTextAreaRef.current) {
const textArea = contentTextAreaRef.current.resizableTextArea?.textArea;
if (textArea) {
textArea.scrollTop = textArea.scrollHeight;
const result = await generateChapterContentStream(
editingId,
(content) => {
editorForm.setFieldsValue({ content });
if (contentTextAreaRef.current) {
const textArea = contentTextAreaRef.current.resizableTextArea?.textArea;
if (textArea) {
textArea.scrollTop = textArea.scrollHeight;
}
}
},
selectedStyleId,
targetWordCount,
(progressMsg, progressValue) => {
// 进度回调
setSingleChapterProgress(progressValue);
setSingleChapterProgressMessage(progressMsg);
}
}, selectedStyleId, targetWordCount);
);
message.success('AI创作成功,正在分析章节内容...');
@@ -338,6 +355,8 @@ export default function Chapters() {
} finally {
setIsContinuing(false);
setIsGenerating(false);
setSingleChapterProgress(0);
setSingleChapterProgressMessage('');
}
};
@@ -1378,6 +1397,13 @@ export default function Chapters() {
</div>
)}
</Modal>
{/* 单章节生成进度显示 */}
<SSELoadingOverlay
loading={isGenerating}
progress={singleChapterProgress}
message={singleChapterProgressMessage}
/>
</div>
);
}
+376 -22
View File
@@ -1,12 +1,14 @@
import { useState, useEffect } from 'react';
import { Button, Modal, Form, Input, Select, message, Row, Col, Empty, Tabs, Divider, Typography, Space } from 'antd';
import { ThunderboltOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons';
import { Button, Modal, Form, Input, Select, message, Row, Col, Empty, Tabs, Divider, Typography, Space, InputNumber } from 'antd';
import { ThunderboltOutlined, UserOutlined, TeamOutlined, PlusOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useCharacterSync } from '../store/hooks';
import { characterGridConfig } from '../components/CardStyles';
import { CharacterCard } from '../components/CharacterCard';
import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
import type { Character, CharacterUpdate } from '../types';
import { characterApi } from '../services/api';
import { SSEPostClient } from '../utils/sseClient';
const { Title } = Typography;
@@ -15,16 +17,21 @@ const { TextArea } = Input;
export default function Characters() {
const { currentProject, characters } = useStore();
const [isGenerating, setIsGenerating] = useState(false);
const [progress, setProgress] = useState(0);
const [progressMessage, setProgressMessage] = useState('');
const [activeTab, setActiveTab] = useState<'all' | 'character' | 'organization'>('all');
const [generateForm] = Form.useForm();
const [generateOrgForm] = Form.useForm();
const [createForm] = Form.useForm();
const [editForm] = Form.useForm();
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 {
refreshCharacters,
deleteCharacter,
generateCharacter
deleteCharacter
} = useCharacterSync();
useEffect(() => {
@@ -48,18 +55,140 @@ export default function Characters() {
const handleGenerate = async (values: { name?: string; role_type: string; background?: string }) => {
try {
setIsGenerating(true);
await generateCharacter({
project_id: currentProject.id,
name: values.name,
role_type: values.role_type,
background: values.background,
});
setProgress(0);
setProgressMessage('准备生成角色...');
const client = new SSEPostClient(
'/api/characters/generate-stream',
{
project_id: currentProject.id,
name: values.name,
role_type: values.role_type,
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生成角色成功');
Modal.destroyAll();
} catch {
message.error('AI生成失败');
await refreshCharacters();
} catch (error: any) {
message.error(error.message || 'AI生成失败');
} finally {
setIsGenerating(false);
setTimeout(() => {
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 organizationList = characters.filter(c => c.is_organization);
@@ -156,15 +321,48 @@ export default function Characters() {
alignItems: isMobile ? 'stretch' : 'center'
}}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}></h2>
<Button
type="dashed"
icon={<ThunderboltOutlined />}
onClick={showGenerateModal}
loading={isGenerating}
block={isMobile}
>
AI生成角色
</Button>
<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
type="dashed"
icon={<ThunderboltOutlined />}
onClick={showGenerateModal}
loading={isGenerating}
size={isMobile ? 'small' : 'middle'}
>
AI生成角色
</Button>
<Button
type="dashed"
icon={<ThunderboltOutlined />}
onClick={showGenerateOrgModal}
loading={isGenerating}
size={isMobile ? 'small' : 'middle'}
>
AI生成组织
</Button>
</Space>
</div>
{characters.length > 0 && (
@@ -465,6 +663,162 @@ export default function Characters() {
</Form.Item>
</Form>
</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>
);
}
+1 -78
View File
@@ -1,12 +1,10 @@
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, ThunderboltOutlined } from '@ant-design/icons';
import { PlusOutlined, TeamOutlined, UserOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import axios from 'axios';
const { TextArea } = Input;
interface Organization {
id: string;
character_id: string;
@@ -48,10 +46,8 @@ export default function Organizations() {
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(() => {
@@ -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 colors: Record<string, string> = {
active: 'green',
@@ -332,17 +266,6 @@ 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',
+23
View File
@@ -303,6 +303,29 @@ export const characterApi = {
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) =>
api.put<unknown, Character>(`/characters/${id}`, data),
+25 -2
View File
@@ -303,7 +303,8 @@ export function useChapterSync() {
chapterId: string,
onProgress?: (content: string) => void,
styleId?: number,
targetWordCount?: number
targetWordCount?: number,
onProgressUpdate?: (message: string, progress: number) => void
) => {
try {
// 使用fetch处理流式响应
@@ -356,7 +357,20 @@ export function useChapterSync() {
if (dataMatch) {
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;
if (onProgress) {
onProgress(fullContent);
@@ -366,8 +380,17 @@ export function useChapterSync() {
} else if (message.type === 'done') {
// 生成完成,保存分析任务ID
analysisTaskId = message.analysis_task_id;
if (onProgressUpdate) {
onProgressUpdate('生成完成', 100);
}
// 生成完成,刷新章节数据
await refreshChapters();
} else if (message.type === 'analysis_started') {
// 分析已开始
analysisTaskId = message.task_id;
if (onProgressUpdate) {
onProgressUpdate('章节分析已开始...', 100);
}
} else if (message.type === 'analysis_queued') {
// 分析任务已加入队列
analysisTaskId = message.task_id;