update: 更新1-N模式展开章节/批量展开作为后台任务
This commit is contained in:
@@ -2451,6 +2451,284 @@ async def expand_outline_generator(
|
||||
yield await tracker.error(f"展开失败: {str(e)}")
|
||||
|
||||
|
||||
async def _save_background_task_result(db: AsyncSession, task_id: str, result_data: Dict[str, Any]) -> None:
|
||||
"""保存后台任务结果到 background_tasks.task_result。"""
|
||||
from app.models.background_task import BackgroundTask
|
||||
|
||||
task_result = await db.execute(select(BackgroundTask).where(BackgroundTask.id == task_id))
|
||||
task = task_result.scalar_one_or_none()
|
||||
if task:
|
||||
task.task_result = result_data
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _run_outline_expansion_background(
|
||||
task_id: str,
|
||||
user_id: str,
|
||||
outline_id: str,
|
||||
data: Dict[str, Any]
|
||||
):
|
||||
"""后台执行单个大纲展开并可直接创建章节。"""
|
||||
from app.database import get_engine
|
||||
from app.api.settings import get_user_ai_service_from_db
|
||||
from app.services.background_task_service import TaskProgressTracker
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession as BgAsyncSession
|
||||
|
||||
engine = await get_engine(user_id)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, class_=BgAsyncSession, expire_on_commit=False)
|
||||
|
||||
async with AsyncSessionLocal() as bg_db:
|
||||
tracker = TaskProgressTracker(task_id, user_id, "大纲展开")
|
||||
try:
|
||||
await tracker.start("开始大纲展开任务...")
|
||||
|
||||
target_chapter_count = int(data.get("target_chapter_count", 3))
|
||||
expansion_strategy = data.get("expansion_strategy", "balanced")
|
||||
enable_scene_analysis = data.get("enable_scene_analysis", True)
|
||||
auto_create_chapters = data.get("auto_create_chapters", True)
|
||||
batch_size = int(data.get("batch_size", 5))
|
||||
|
||||
await tracker.loading("加载大纲信息...", 0.3)
|
||||
outline_result = await bg_db.execute(select(Outline).where(Outline.id == outline_id))
|
||||
outline = outline_result.scalar_one_or_none()
|
||||
if not outline:
|
||||
raise ValueError("大纲不存在")
|
||||
|
||||
await tracker.loading("加载项目信息...", 0.7)
|
||||
project_result = await bg_db.execute(select(Project).where(Project.id == outline.project_id))
|
||||
project = project_result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise ValueError("项目不存在")
|
||||
|
||||
if await tracker.check_cancelled():
|
||||
return
|
||||
|
||||
await tracker.preparing(f"准备展开《{outline.title}》为 {target_chapter_count} 章...")
|
||||
|
||||
bg_ai_service = await get_user_ai_service_from_db(user_id, bg_db)
|
||||
expansion_service = PlotExpansionService(bg_ai_service)
|
||||
|
||||
await tracker.generating(
|
||||
current_chars=0,
|
||||
estimated_total=target_chapter_count * 500,
|
||||
message=f"AI分析大纲《{outline.title}》,生成章节规划..."
|
||||
)
|
||||
|
||||
chapter_plans = await expansion_service.analyze_outline_for_chapters(
|
||||
outline=outline,
|
||||
project=project,
|
||||
db=bg_db,
|
||||
target_chapter_count=target_chapter_count,
|
||||
expansion_strategy=expansion_strategy,
|
||||
enable_scene_analysis=enable_scene_analysis,
|
||||
provider=data.get("provider"),
|
||||
model=data.get("model"),
|
||||
batch_size=batch_size,
|
||||
progress_callback=None
|
||||
)
|
||||
|
||||
if await tracker.check_cancelled():
|
||||
return
|
||||
if not chapter_plans:
|
||||
raise ValueError("AI分析失败,未能生成章节规划")
|
||||
|
||||
await tracker.parsing(f"规划生成完成,共 {len(chapter_plans)} 个章节")
|
||||
|
||||
created_chapters = None
|
||||
if auto_create_chapters:
|
||||
await tracker.saving("创建章节记录...", 0.3)
|
||||
created_chapters = await expansion_service.create_chapters_from_plans(
|
||||
outline_id=outline_id,
|
||||
chapter_plans=chapter_plans,
|
||||
project_id=outline.project_id,
|
||||
db=bg_db,
|
||||
start_chapter_number=None
|
||||
)
|
||||
await tracker.saving(f"成功创建 {len(created_chapters)} 个章节记录", 0.8)
|
||||
|
||||
result_data = {
|
||||
"outline_id": outline_id,
|
||||
"outline_title": outline.title,
|
||||
"target_chapter_count": target_chapter_count,
|
||||
"actual_chapter_count": len(chapter_plans),
|
||||
"expansion_strategy": expansion_strategy,
|
||||
"chapter_plans": chapter_plans,
|
||||
"created_chapters": [
|
||||
{
|
||||
"id": ch.id,
|
||||
"chapter_number": ch.chapter_number,
|
||||
"title": ch.title,
|
||||
"summary": ch.summary,
|
||||
"outline_id": ch.outline_id,
|
||||
"sub_index": ch.sub_index,
|
||||
"status": ch.status
|
||||
}
|
||||
for ch in created_chapters
|
||||
] if created_chapters else None
|
||||
}
|
||||
await _save_background_task_result(bg_db, task_id, result_data)
|
||||
await tracker.complete(f"《{outline.title}》展开完成")
|
||||
except Exception as e:
|
||||
logger.error(f"后台大纲展开失败: {str(e)}", exc_info=True)
|
||||
try:
|
||||
if bg_db.in_transaction():
|
||||
await bg_db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
await tracker.error(str(e))
|
||||
|
||||
|
||||
async def _run_batch_outline_expansion_background(
|
||||
task_id: str,
|
||||
user_id: str,
|
||||
data: Dict[str, Any]
|
||||
):
|
||||
"""后台执行批量大纲展开并可直接创建章节。"""
|
||||
from app.database import get_engine
|
||||
from app.api.settings import get_user_ai_service_from_db
|
||||
from app.services.background_task_service import TaskProgressTracker
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession as BgAsyncSession
|
||||
|
||||
engine = await get_engine(user_id)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, class_=BgAsyncSession, expire_on_commit=False)
|
||||
|
||||
async with AsyncSessionLocal() as bg_db:
|
||||
tracker = TaskProgressTracker(task_id, user_id, "批量大纲展开")
|
||||
try:
|
||||
await tracker.start("开始批量大纲展开任务...")
|
||||
|
||||
project_id = data.get("project_id")
|
||||
chapters_per_outline = int(data.get("chapters_per_outline", 3))
|
||||
expansion_strategy = data.get("expansion_strategy", "balanced")
|
||||
auto_create_chapters = data.get("auto_create_chapters", True)
|
||||
outline_ids = data.get("outline_ids")
|
||||
|
||||
await tracker.loading("加载项目信息...", 0.4)
|
||||
project_result = await bg_db.execute(select(Project).where(Project.id == project_id))
|
||||
project = project_result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise ValueError("项目不存在")
|
||||
|
||||
await tracker.loading("获取大纲列表...", 0.8)
|
||||
if outline_ids:
|
||||
outlines_result = await bg_db.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == project_id, Outline.id.in_(outline_ids))
|
||||
.order_by(Outline.order_index)
|
||||
)
|
||||
else:
|
||||
outlines_result = await bg_db.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == project_id)
|
||||
.order_by(Outline.order_index)
|
||||
)
|
||||
outlines = outlines_result.scalars().all()
|
||||
if not outlines:
|
||||
raise ValueError("没有找到要展开的大纲")
|
||||
|
||||
total_outlines = len(outlines)
|
||||
await tracker.preparing(f"共找到 {total_outlines} 个大纲,准备批量展开...")
|
||||
|
||||
bg_ai_service = await get_user_ai_service_from_db(user_id, bg_db)
|
||||
expansion_service = PlotExpansionService(bg_ai_service)
|
||||
|
||||
expansion_results = []
|
||||
skipped_outlines = []
|
||||
total_chapters_created = 0
|
||||
|
||||
for idx, outline in enumerate(outlines):
|
||||
if await tracker.check_cancelled():
|
||||
return
|
||||
|
||||
await tracker.generating(
|
||||
current_chars=idx * chapters_per_outline * 500,
|
||||
estimated_total=total_outlines * chapters_per_outline * 500,
|
||||
message=f"处理第 {idx + 1}/{total_outlines} 个大纲:《{outline.title}》"
|
||||
)
|
||||
|
||||
existing_chapters_result = await bg_db.execute(
|
||||
select(Chapter).where(Chapter.outline_id == outline.id).limit(1)
|
||||
)
|
||||
existing_chapter = existing_chapters_result.scalar_one_or_none()
|
||||
if existing_chapter:
|
||||
skipped_outlines.append({
|
||||
"outline_id": outline.id,
|
||||
"outline_title": outline.title,
|
||||
"reason": "已展开"
|
||||
})
|
||||
await tracker.warning(f"《{outline.title}》已展开过,已跳过")
|
||||
continue
|
||||
|
||||
chapter_plans = await expansion_service.analyze_outline_for_chapters(
|
||||
outline=outline,
|
||||
project=project,
|
||||
db=bg_db,
|
||||
target_chapter_count=chapters_per_outline,
|
||||
expansion_strategy=expansion_strategy,
|
||||
enable_scene_analysis=data.get("enable_scene_analysis", True),
|
||||
provider=data.get("provider"),
|
||||
model=data.get("model")
|
||||
)
|
||||
|
||||
created_chapters = None
|
||||
if auto_create_chapters:
|
||||
created_chapters = await expansion_service.create_chapters_from_plans(
|
||||
outline_id=outline.id,
|
||||
chapter_plans=chapter_plans,
|
||||
project_id=outline.project_id,
|
||||
db=bg_db,
|
||||
start_chapter_number=None
|
||||
)
|
||||
total_chapters_created += len(created_chapters)
|
||||
|
||||
expansion_results.append({
|
||||
"outline_id": outline.id,
|
||||
"outline_title": outline.title,
|
||||
"target_chapter_count": chapters_per_outline,
|
||||
"actual_chapter_count": len(chapter_plans),
|
||||
"expansion_strategy": expansion_strategy,
|
||||
"chapter_plans": chapter_plans,
|
||||
"created_chapters": [
|
||||
{
|
||||
"id": ch.id,
|
||||
"chapter_number": ch.chapter_number,
|
||||
"title": ch.title,
|
||||
"summary": ch.summary,
|
||||
"outline_id": ch.outline_id,
|
||||
"sub_index": ch.sub_index,
|
||||
"status": ch.status
|
||||
}
|
||||
for ch in created_chapters
|
||||
] if created_chapters else None
|
||||
})
|
||||
|
||||
await tracker.generating(
|
||||
current_chars=(idx + 1) * chapters_per_outline * 500,
|
||||
estimated_total=total_outlines * chapters_per_outline * 500,
|
||||
message=f"《{outline.title}》展开完成 ({len(chapter_plans)} 章)"
|
||||
)
|
||||
|
||||
await tracker.parsing("整理批量展开结果...")
|
||||
result_data = {
|
||||
"project_id": project_id,
|
||||
"total_outlines_expanded": len(expansion_results),
|
||||
"total_chapters_created": total_chapters_created,
|
||||
"skipped_count": len(skipped_outlines),
|
||||
"skipped_outlines": skipped_outlines,
|
||||
"expansion_results": expansion_results
|
||||
}
|
||||
await _save_background_task_result(bg_db, task_id, result_data)
|
||||
await tracker.complete(f"批量展开完成,共创建 {total_chapters_created} 个章节")
|
||||
except Exception as e:
|
||||
logger.error(f"后台批量大纲展开失败: {str(e)}", exc_info=True)
|
||||
try:
|
||||
if bg_db.in_transaction():
|
||||
await bg_db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
await tracker.error(str(e))
|
||||
|
||||
|
||||
@router.post("/{outline_id}/create-single-chapter", summary="一对一创建章节(传统模式)")
|
||||
async def create_single_chapter_from_outline(
|
||||
outline_id: str,
|
||||
@@ -2549,6 +2827,48 @@ async def create_single_chapter_from_outline(
|
||||
raise HTTPException(status_code=500, detail=f"创建章节失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/{outline_id}/expand-background", summary="后台展开单个大纲为多章")
|
||||
async def expand_outline_to_chapters_background(
|
||||
outline_id: str,
|
||||
data: Dict[str, Any],
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""创建后台任务展开单个大纲,任务完成后可在右下角后台任务面板查看结果。"""
|
||||
result = await db.execute(select(Outline).where(Outline.id == outline_id))
|
||||
outline = result.scalar_one_or_none()
|
||||
if not outline:
|
||||
raise HTTPException(status_code=404, detail="大纲不存在")
|
||||
|
||||
user_id = getattr(request.state, 'user_id', None)
|
||||
await verify_project_access(outline.project_id, user_id, db)
|
||||
|
||||
from app.services.background_task_service import background_task_service
|
||||
|
||||
task_input = dict(data or {})
|
||||
task_input["outline_id"] = outline_id
|
||||
task_input.setdefault("auto_create_chapters", True)
|
||||
|
||||
task = await background_task_service.create_task(
|
||||
user_id=user_id,
|
||||
project_id=outline.project_id,
|
||||
task_type="outline_expand",
|
||||
task_input=task_input,
|
||||
db=db
|
||||
)
|
||||
|
||||
await background_task_service.spawn_background_task(
|
||||
task.id, user_id, _run_outline_expansion_background, outline_id, task_input
|
||||
)
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"task_type": "outline_expand",
|
||||
"status": "pending",
|
||||
"message": "大纲展开任务已创建,请通过后台任务面板查看进度"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{outline_id}/expand-stream", summary="展开单个大纲为多章(SSE流式)")
|
||||
async def expand_outline_to_chapters_stream(
|
||||
outline_id: str,
|
||||
@@ -2901,6 +3221,41 @@ async def batch_expand_outlines_generator(
|
||||
yield await SSEResponse.send_error(f"批量展开失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/batch-expand-background", summary="后台批量展开大纲为多章")
|
||||
async def batch_expand_outlines_background(
|
||||
data: Dict[str, Any],
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""创建后台任务批量展开大纲,任务完成后可在右下角后台任务面板查看结果。"""
|
||||
user_id = getattr(request.state, 'user_id', None)
|
||||
project = await verify_project_access(data.get("project_id"), user_id, db)
|
||||
|
||||
from app.services.background_task_service import background_task_service
|
||||
|
||||
task_input = dict(data or {})
|
||||
task_input.setdefault("auto_create_chapters", True)
|
||||
|
||||
task = await background_task_service.create_task(
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
task_type="outline_batch_expand",
|
||||
task_input=task_input,
|
||||
db=db
|
||||
)
|
||||
|
||||
await background_task_service.spawn_background_task(
|
||||
task.id, user_id, _run_batch_outline_expansion_background, task_input
|
||||
)
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"task_type": "outline_batch_expand",
|
||||
"status": "pending",
|
||||
"message": "批量大纲展开任务已创建,请通过后台任务面板查看进度"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/batch-expand-stream", summary="批量展开大纲为多章(SSE流式)")
|
||||
async def batch_expand_outlines_stream(
|
||||
data: Dict[str, Any],
|
||||
|
||||
@@ -151,6 +151,8 @@ export const FloatingTaskPanel: React.FC<FloatingTaskPanelProps> = ({
|
||||
return '大纲续写';
|
||||
case 'outline_expand':
|
||||
return '大纲展开';
|
||||
case 'outline_batch_expand':
|
||||
return '批量大纲展开';
|
||||
case 'chapter_generate':
|
||||
return '章节生成';
|
||||
case 'chapter_batch':
|
||||
|
||||
+34
-524
@@ -5,11 +5,9 @@ import { useStore } from '../store';
|
||||
import { eventBus } from '../store/eventBus';
|
||||
import { getProjectTasks, type TaskStatus } from '../services/backgroundTaskService';
|
||||
import { useOutlineSync } from '../store/hooks';
|
||||
import { SSEPostClient } from '../utils/sseClient';
|
||||
import { SSEProgressModal } from '../components/SSEProgressModal';
|
||||
import { generateOutlineBackground } from '../services/backgroundTaskService';
|
||||
import { outlineApi, chapterApi, projectApi, characterApi } from '../services/api';
|
||||
import type { OutlineExpansionResponse, BatchOutlineExpansionResponse, ChapterPlanItem, ApiError, Character } from '../types';
|
||||
import type { ApiError, Character } from '../types';
|
||||
|
||||
// 大纲生成请求数据类型
|
||||
interface OutlineGenerateRequestData {
|
||||
@@ -27,20 +25,6 @@ interface OutlineGenerateRequestData {
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
// 跳过的大纲信息类型
|
||||
interface SkippedOutlineInfo {
|
||||
outline_id: string;
|
||||
outline_title: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// 场景类型
|
||||
interface SceneInfo {
|
||||
location: string;
|
||||
characters: string[];
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
// 角色/组织条目类型(新格式)
|
||||
interface CharacterEntry {
|
||||
name: string;
|
||||
@@ -143,22 +127,6 @@ export default function Outline() {
|
||||
// ✅ 新增:记录场景区域的展开/折叠状态
|
||||
const [scenesExpandStatus, setScenesExpandStatus] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 缓存批量展开的规划数据,避免重复AI调用
|
||||
const [cachedBatchExpansionResponse, setCachedBatchExpansionResponse] = useState<BatchOutlineExpansionResponse | null>(null);
|
||||
|
||||
// 批量展开预览的状态
|
||||
const [batchPreviewVisible, setBatchPreviewVisible] = useState(false);
|
||||
const [batchPreviewData, setBatchPreviewData] = useState<BatchOutlineExpansionResponse | null>(null);
|
||||
const [selectedOutlineIdx, setSelectedOutlineIdx] = useState(0);
|
||||
const [selectedChapterIdx, setSelectedChapterIdx] = useState(0);
|
||||
|
||||
// SSE进度状态
|
||||
const [sseProgress, setSSEProgress] = useState(0);
|
||||
const [sseMessage, setSSEMessage] = useState('');
|
||||
const [sseModalVisible, setSSEModalVisible] = useState(false);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
@@ -923,7 +891,7 @@ export default function Outline() {
|
||||
});
|
||||
};
|
||||
|
||||
// 展开单个大纲为多章 - 使用SSE显示进度
|
||||
// 展开单个大纲为多章 - 提交后台任务并在悬浮任务面板显示进度
|
||||
const handleExpandOutline = async (outlineId: string, outlineTitle: string) => {
|
||||
try {
|
||||
setIsExpanding(true);
|
||||
@@ -1044,60 +1012,39 @@ export default function Outline() {
|
||||
</Form>
|
||||
</div>
|
||||
),
|
||||
okText: '生成规划预览',
|
||||
okText: '提交后台任务',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const values = await expansionForm.validateFields();
|
||||
|
||||
// 关闭配置表单
|
||||
Modal.destroyAll();
|
||||
|
||||
// 显示SSE进度Modal
|
||||
setSSEProgress(0);
|
||||
setSSEMessage('正在准备展开大纲...');
|
||||
setSSEModalVisible(true);
|
||||
setIsExpanding(true);
|
||||
|
||||
// 准备请求数据
|
||||
const requestData = {
|
||||
...values,
|
||||
auto_create_chapters: false, // 第一步:仅生成规划
|
||||
auto_create_chapters: true,
|
||||
enable_scene_analysis: true
|
||||
};
|
||||
|
||||
// 使用SSE客户端调用新的流式端点
|
||||
const apiUrl = `/api/outlines/${outlineId}/expand-stream`;
|
||||
const client = new SSEPostClient(apiUrl, requestData, {
|
||||
onProgress: (msg: string, progress: number) => {
|
||||
setSSEMessage(msg);
|
||||
setSSEProgress(progress);
|
||||
},
|
||||
onResult: (data: OutlineExpansionResponse) => {
|
||||
console.log('展开完成,结果:', data);
|
||||
// 关闭SSE进度Modal
|
||||
setSSEModalVisible(false);
|
||||
// 显示规划预览
|
||||
showExpansionPreview(outlineId, data);
|
||||
},
|
||||
onError: (error: string) => {
|
||||
message.error(`展开失败: ${error}`);
|
||||
setSSEModalVisible(false);
|
||||
setIsExpanding(false);
|
||||
},
|
||||
onComplete: () => {
|
||||
setSSEModalVisible(false);
|
||||
setIsExpanding(false);
|
||||
}
|
||||
const response = await fetch(`/api/outlines/${outlineId}/expand-background`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
// 开始连接
|
||||
client.connect();
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(err.detail || '创建大纲展开任务失败');
|
||||
}
|
||||
|
||||
message.success('大纲展开任务已提交,可在右下角任务面板查看进度');
|
||||
eventBus.emit('background-task-created');
|
||||
setIsExpanding(false);
|
||||
|
||||
} catch (error) {
|
||||
console.error('展开失败:', error);
|
||||
message.error('展开失败');
|
||||
setSSEModalVisible(false);
|
||||
message.error(error instanceof Error ? error.message : '展开失败');
|
||||
setIsExpanding(false);
|
||||
}
|
||||
},
|
||||
@@ -1392,134 +1339,7 @@ export default function Outline() {
|
||||
});
|
||||
};
|
||||
|
||||
// 显示展开规划预览,并提供确认创建章节的选项
|
||||
const showExpansionPreview = (outlineId: string, response: OutlineExpansionResponse) => {
|
||||
// 缓存AI生成的规划数据
|
||||
const cachedPlans = response.chapter_plans;
|
||||
|
||||
modalApi.confirm({
|
||||
title: (
|
||||
<Space>
|
||||
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
|
||||
<span>展开规划预览</span>
|
||||
</Space>
|
||||
),
|
||||
width: 900,
|
||||
centered: true,
|
||||
okText: '确认并创建章节',
|
||||
cancelText: '暂不创建',
|
||||
content: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Tag color="blue">策略: {response.expansion_strategy}</Tag>
|
||||
<Tag color="green">章节数: {response.actual_chapter_count}</Tag>
|
||||
<Tag color="orange">预览模式(未创建章节)</Tag>
|
||||
</div>
|
||||
<Tabs
|
||||
defaultActiveKey="0"
|
||||
type="card"
|
||||
items={response.chapter_plans.map((plan, idx) => ({
|
||||
key: idx.toString(),
|
||||
label: (
|
||||
<Space size="small">
|
||||
<span style={{ fontWeight: 500 }}>{idx + 1}. {plan.title}</span>
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div style={{ maxHeight: '500px', overflowY: 'auto', padding: '8px 0' }}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="基本信息">
|
||||
<Space wrap>
|
||||
<Tag color="blue">{plan.emotional_tone}</Tag>
|
||||
<Tag color="orange">{plan.conflict_type}</Tag>
|
||||
<Tag color="green">约{plan.estimated_words}字</Tag>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="情节概要">
|
||||
{plan.plot_summary}
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="叙事目标">
|
||||
{plan.narrative_goal}
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="关键事件">
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
{plan.key_events.map((event, eventIdx) => (
|
||||
<div key={eventIdx}>• {event}</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="涉及角色">
|
||||
<Space wrap>
|
||||
{plan.character_focus.map((char, charIdx) => (
|
||||
<Tag key={charIdx} color="purple">{char}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{plan.scenes && plan.scenes.length > 0 && (
|
||||
<Card size="small" title="场景">
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
{plan.scenes.map((scene, sceneIdx) => (
|
||||
<Card key={sceneIdx} size="small" style={{ backgroundColor: token.colorFillQuaternary }}>
|
||||
<div><strong>地点:</strong>{scene.location}</div>
|
||||
<div><strong>角色:</strong>{scene.characters.join('、')}</div>
|
||||
<div><strong>目的:</strong>{scene.purpose}</div>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
onOk: async () => {
|
||||
// 第二步:用户确认后,直接使用缓存的规划创建章节(避免重复调用AI)
|
||||
await handleConfirmCreateChapters(outlineId, cachedPlans);
|
||||
},
|
||||
onCancel: () => {
|
||||
message.info('已取消创建章节');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 确认创建章节 - 使用缓存的规划数据,避免重复AI调用
|
||||
const handleConfirmCreateChapters = async (
|
||||
outlineId: string,
|
||||
cachedPlans: ChapterPlanItem[]
|
||||
) => {
|
||||
try {
|
||||
setIsExpanding(true);
|
||||
|
||||
// 使用新的API端点,直接传递缓存的规划数据
|
||||
const response = await outlineApi.createChaptersFromPlans(outlineId, cachedPlans);
|
||||
|
||||
message.success(
|
||||
`成功创建${response.chapters_created}个章节!`,
|
||||
3
|
||||
);
|
||||
|
||||
console.log('✅ 使用缓存的规划创建章节,避免了重复的AI调用');
|
||||
|
||||
// 刷新大纲和章节列表
|
||||
refreshOutlines();
|
||||
|
||||
} catch (error) {
|
||||
console.error('创建章节失败:', error);
|
||||
message.error('创建章节失败');
|
||||
} finally {
|
||||
setIsExpanding(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 批量展开所有大纲 - 使用SSE流式显示进度
|
||||
// 批量展开所有大纲 - 提交后台任务并在悬浮任务面板显示进度
|
||||
const handleBatchExpandOutlines = () => {
|
||||
if (!currentProject?.id || outlines.length === 0) {
|
||||
message.warning('没有可展开的大纲');
|
||||
@@ -1585,360 +1405,50 @@ export default function Outline() {
|
||||
</Form>
|
||||
</div>
|
||||
),
|
||||
okText: '开始展开',
|
||||
okText: '提交后台任务',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { type: 'primary' },
|
||||
onOk: async () => {
|
||||
try {
|
||||
const values = await batchExpansionForm.validateFields();
|
||||
|
||||
// 关闭配置表单
|
||||
Modal.destroyAll();
|
||||
|
||||
// 显示SSE进度Modal
|
||||
setSSEProgress(0);
|
||||
setSSEMessage('正在准备批量展开...');
|
||||
setSSEModalVisible(true);
|
||||
setIsExpanding(true);
|
||||
|
||||
// 准备请求数据
|
||||
const requestData = {
|
||||
project_id: currentProject.id,
|
||||
...values,
|
||||
auto_create_chapters: false // 第一步:仅生成规划
|
||||
auto_create_chapters: true,
|
||||
enable_scene_analysis: true
|
||||
};
|
||||
|
||||
// 使用SSE客户端
|
||||
const apiUrl = `/api/outlines/batch-expand-stream`;
|
||||
const client = new SSEPostClient(apiUrl, requestData, {
|
||||
onProgress: (msg: string, progress: number) => {
|
||||
setSSEMessage(msg);
|
||||
setSSEProgress(progress);
|
||||
},
|
||||
onResult: (data: BatchOutlineExpansionResponse) => {
|
||||
console.log('批量展开完成,结果:', data);
|
||||
// 缓存AI生成的规划数据
|
||||
setCachedBatchExpansionResponse(data);
|
||||
setBatchPreviewData(data);
|
||||
// 关闭SSE进度Modal
|
||||
setSSEModalVisible(false);
|
||||
// 重置选择状态
|
||||
setSelectedOutlineIdx(0);
|
||||
setSelectedChapterIdx(0);
|
||||
// 显示批量预览Modal
|
||||
setBatchPreviewVisible(true);
|
||||
},
|
||||
onError: (error: string) => {
|
||||
message.error(`批量展开失败: ${error}`);
|
||||
setSSEModalVisible(false);
|
||||
setIsExpanding(false);
|
||||
},
|
||||
onComplete: () => {
|
||||
setSSEModalVisible(false);
|
||||
setIsExpanding(false);
|
||||
}
|
||||
const response = await fetch('/api/outlines/batch-expand-background', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
// 开始连接
|
||||
client.connect();
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(err.detail || '创建批量展开任务失败');
|
||||
}
|
||||
|
||||
message.success('批量展开任务已提交,可在右下角任务面板查看进度');
|
||||
eventBus.emit('background-task-created');
|
||||
setIsExpanding(false);
|
||||
|
||||
} catch (error) {
|
||||
console.error('批量展开失败:', error);
|
||||
message.error('批量展开失败');
|
||||
setSSEModalVisible(false);
|
||||
message.error(error instanceof Error ? error.message : '批量展开失败');
|
||||
setIsExpanding(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 渲染批量展开预览 Modal 内容
|
||||
const renderBatchPreviewContent = () => {
|
||||
if (!batchPreviewData) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 顶部统计信息 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Tag color="blue">已处理: {batchPreviewData.total_outlines_expanded} 个大纲</Tag>
|
||||
<Tag color="green">总章节数: {batchPreviewData.expansion_results.reduce((sum: number, r: OutlineExpansionResponse) => sum + r.actual_chapter_count, 0)}</Tag>
|
||||
<Tag color="orange">预览模式(未创建章节)</Tag>
|
||||
{batchPreviewData.skipped_outlines && batchPreviewData.skipped_outlines.length > 0 && (
|
||||
<Tag color="warning">跳过: {batchPreviewData.skipped_outlines.length} 个大纲</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 显示跳过的大纲信息 */}
|
||||
{batchPreviewData.skipped_outlines && batchPreviewData.skipped_outlines.length > 0 && (
|
||||
<div style={{
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
background: token.colorWarningBg,
|
||||
borderRadius: token.borderRadius,
|
||||
border: `1px solid ${token.colorWarningBorder}`
|
||||
}}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8, color: token.colorWarning }}>
|
||||
⚠️ 以下大纲已展开过,已自动跳过:
|
||||
</div>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
{batchPreviewData.skipped_outlines.map((skipped: SkippedOutlineInfo, idx: number) => (
|
||||
<div key={idx} style={{ fontSize: 13, color: token.colorTextSecondary }}>
|
||||
• {skipped.outline_title} <Tag color="default" style={{ fontSize: 11 }}>{skipped.reason}</Tag>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 水平三栏布局 */}
|
||||
<div style={{ display: 'flex', gap: 16, height: 500 }}>
|
||||
{/* 左栏:大纲列表 */}
|
||||
<div style={{
|
||||
width: 280,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
paddingRight: 12,
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8, color: token.colorTextSecondary }}>大纲列表</div>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={batchPreviewData.expansion_results}
|
||||
renderItem={(result: OutlineExpansionResponse, idx: number) => (
|
||||
<List.Item
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
setSelectedOutlineIdx(idx);
|
||||
setSelectedChapterIdx(0);
|
||||
}}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '8px 12px',
|
||||
background: selectedOutlineIdx === idx ? token.colorPrimaryBg : 'transparent',
|
||||
borderRadius: token.borderRadius,
|
||||
marginBottom: 4,
|
||||
border: selectedOutlineIdx === idx ? `1px solid ${token.colorPrimary}` : '1px solid transparent'
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 13, marginBottom: 4 }}>
|
||||
{idx + 1}. {result.outline_title}
|
||||
</div>
|
||||
<Space size={4}>
|
||||
<Tag color="blue" style={{ fontSize: 11, margin: 0 }}>{result.expansion_strategy}</Tag>
|
||||
<Tag color="green" style={{ fontSize: 11, margin: 0 }}>{result.actual_chapter_count} 章</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 中栏:章节列表 */}
|
||||
<div style={{
|
||||
width: 320,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
paddingRight: 12,
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8, color: token.colorTextSecondary }}>
|
||||
章节列表 ({batchPreviewData.expansion_results[selectedOutlineIdx]?.actual_chapter_count || 0} 章)
|
||||
</div>
|
||||
{batchPreviewData.expansion_results[selectedOutlineIdx] && (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans}
|
||||
renderItem={(plan: ChapterPlanItem, idx: number) => (
|
||||
<List.Item
|
||||
key={idx}
|
||||
onClick={() => setSelectedChapterIdx(idx)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '8px 12px',
|
||||
background: selectedChapterIdx === idx ? token.colorPrimaryBg : 'transparent',
|
||||
borderRadius: token.borderRadius,
|
||||
marginBottom: 4,
|
||||
border: selectedChapterIdx === idx ? `1px solid ${token.colorPrimary}` : '1px solid transparent'
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 13, marginBottom: 4 }}>
|
||||
{idx + 1}. {plan.title}
|
||||
</div>
|
||||
<Space size={4} wrap>
|
||||
<Tag color="blue" style={{ fontSize: 11, margin: 0 }}>{plan.emotional_tone}</Tag>
|
||||
<Tag color="orange" style={{ fontSize: 11, margin: 0 }}>{plan.conflict_type}</Tag>
|
||||
<Tag color="green" style={{ fontSize: 11, margin: 0 }}>约{plan.estimated_words}字</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右栏:章节详情 */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', paddingLeft: 12 }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 12, color: token.colorTextSecondary }}>章节详情</div>
|
||||
{batchPreviewData.expansion_results[selectedOutlineIdx]?.chapter_plans[selectedChapterIdx] ? (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="情节概要" bordered={false}>
|
||||
{batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].plot_summary}
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="叙事目标" bordered={false}>
|
||||
{batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].narrative_goal}
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="关键事件" bordered={false}>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
{(batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].key_events as string[]).map((event: string, eventIdx: number) => (
|
||||
<div key={eventIdx}>• {event}</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="涉及角色" bordered={false}>
|
||||
<Space wrap>
|
||||
{(batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].character_focus as string[]).map((char: string, charIdx: number) => (
|
||||
<Tag key={charIdx} color="purple">{char}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].scenes && batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].scenes!.length > 0 && (
|
||||
<Card size="small" title="场景" bordered={false}>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
{batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].scenes!.map((scene: SceneInfo, sceneIdx: number) => (
|
||||
<Card key={sceneIdx} size="small" style={{ backgroundColor: token.colorFillQuaternary }}>
|
||||
<div><strong>地点:</strong>{scene.location}</div>
|
||||
<div><strong>角色:</strong>{scene.characters.join('、')}</div>
|
||||
<div><strong>目的:</strong>{scene.purpose}</div>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
) : (
|
||||
<Empty description="请选择章节查看详情" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 处理批量预览确认
|
||||
const handleBatchPreviewOk = async () => {
|
||||
setBatchPreviewVisible(false);
|
||||
await handleConfirmBatchCreateChapters();
|
||||
};
|
||||
|
||||
// 处理批量预览取消
|
||||
const handleBatchPreviewCancel = () => {
|
||||
setBatchPreviewVisible(false);
|
||||
message.info('已取消创建章节,规划已保存');
|
||||
};
|
||||
|
||||
|
||||
// 确认批量创建章节 - 使用缓存的规划数据
|
||||
const handleConfirmBatchCreateChapters = async () => {
|
||||
try {
|
||||
setIsExpanding(true);
|
||||
|
||||
// 使用缓存的规划数据,避免重复调用AI
|
||||
if (!cachedBatchExpansionResponse) {
|
||||
message.error('规划数据丢失,请重新展开');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ 使用缓存的批量规划数据创建章节,避免重复AI调用');
|
||||
|
||||
// 逐个大纲创建章节
|
||||
let totalCreated = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const result of cachedBatchExpansionResponse.expansion_results) {
|
||||
try {
|
||||
// 使用create-chapters-from-plans接口,直接传递缓存的规划
|
||||
const response = await outlineApi.createChaptersFromPlans(
|
||||
result.outline_id,
|
||||
result.chapter_plans
|
||||
);
|
||||
totalCreated += response.chapters_created;
|
||||
} catch (error: unknown) {
|
||||
const apiError = error as ApiError;
|
||||
const err = error as Error;
|
||||
const errorMsg = apiError.response?.data?.detail || err.message || '未知错误';
|
||||
errors.push(`${result.outline_title}: ${errorMsg}`);
|
||||
console.error(`创建大纲 ${result.outline_title} 的章节失败:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示结果
|
||||
if (errors.length === 0) {
|
||||
message.success(
|
||||
`批量创建完成!共创建 ${totalCreated} 个章节`,
|
||||
3
|
||||
);
|
||||
} else {
|
||||
message.warning(
|
||||
`部分完成:成功创建 ${totalCreated} 个章节,${errors.length} 个失败`,
|
||||
5
|
||||
);
|
||||
console.error('失败详情:', errors);
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
setCachedBatchExpansionResponse(null);
|
||||
|
||||
// 刷新列表
|
||||
refreshOutlines();
|
||||
|
||||
} catch (error) {
|
||||
console.error('批量创建章节失败:', error);
|
||||
message.error('批量创建章节失败');
|
||||
} finally {
|
||||
setIsExpanding(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 批量展开预览 Modal */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
|
||||
<span>批量展开规划预览</span>
|
||||
</Space>
|
||||
}
|
||||
open={batchPreviewVisible}
|
||||
onOk={handleBatchPreviewOk}
|
||||
onCancel={handleBatchPreviewCancel}
|
||||
width={1200}
|
||||
centered
|
||||
okText="确认并批量创建章节"
|
||||
cancelText="暂不创建"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
{renderBatchPreviewContent()}
|
||||
</Modal>
|
||||
|
||||
{contextHolder}
|
||||
{/* SSE进度Modal - 使用统一组件 */}
|
||||
<SSEProgressModal
|
||||
visible={sseModalVisible}
|
||||
progress={sseProgress}
|
||||
message={sseMessage}
|
||||
title="AI生成中(后台运行,可关闭页面)..."
|
||||
onCancel={() => {
|
||||
setSSEModalVisible(false);
|
||||
setIsExpanding(false);
|
||||
message.info('已取消操作');
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* 固定头部 */}
|
||||
|
||||
@@ -13,9 +13,10 @@ export interface TaskStatus {
|
||||
status_message: string | null;
|
||||
progress_details: {
|
||||
stage: string;
|
||||
message: string;
|
||||
message?: string;
|
||||
current_chars?: number;
|
||||
retry_count?: number;
|
||||
queue_size?: number;
|
||||
} | null;
|
||||
error_message: string | null;
|
||||
task_result: Record<string, unknown> | null;
|
||||
|
||||
Reference in New Issue
Block a user