feat: 后台任务系统 + JSON容错解析 + SSE心跳保活 + 多项Bug修复
新功能: - 大纲/章节生成改为服务端后台任务,支持断线续传 - 后台任务队列排队执行,按用户排队(同用户串行不同用户并发) - 章节管理页面添加后台任务列表弹窗和进度面板 - 章节状态添加 pending(待处理)选项 - 集成json5容错解析器 + 上下文感知JSON修复 - SSE流式生成添加心跳保活,防止连接超时 - SSEPostClient添加credentials:include修复network error - 每章最大伏笔数从2调整为5 - 添加大纲读区伏笔的功能 Bug修复: - 修复AI生成JSON中未转义引号/中文标点/多对象属性值未合并 - 修复JSON非法转义字符清洗和中文引号处理 - 修复MCP插件TimeoutError/连接失败上下文清理 - MCP插件后台注册添加重试机制 - 续写模式添加缺失的mcp_references参数 - 修复Alembic迁移链分叉 - 使用torch CPU版本加速Docker构建
This commit is contained in:
Generated
+31
@@ -23,6 +23,9 @@ importers:
|
||||
'@types/canvas-confetti':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0
|
||||
'@types/dagre':
|
||||
specifier: ^0.7.54
|
||||
version: 0.7.54
|
||||
'@xyflow/react':
|
||||
specifier: ^12.10.1
|
||||
version: 12.10.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -35,6 +38,9 @@ importers:
|
||||
canvas-confetti:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
dagre:
|
||||
specifier: ^0.8.5
|
||||
version: 0.8.5
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.19
|
||||
@@ -734,6 +740,9 @@ packages:
|
||||
'@types/d3-zoom@3.0.8':
|
||||
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
|
||||
|
||||
'@types/dagre@0.7.54':
|
||||
resolution: {integrity: sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -996,6 +1005,9 @@ packages:
|
||||
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dagre@0.8.5:
|
||||
resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==}
|
||||
|
||||
dayjs@1.11.19:
|
||||
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
|
||||
|
||||
@@ -1200,6 +1212,9 @@ packages:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
graphlib@2.1.8:
|
||||
resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==}
|
||||
|
||||
has-flag@4.0.0:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1299,6 +1314,9 @@ packages:
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
@@ -2498,6 +2516,8 @@ snapshots:
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-selection': 3.0.11
|
||||
|
||||
'@types/dagre@0.7.54': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
@@ -2861,6 +2881,11 @@ snapshots:
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
|
||||
dagre@0.8.5:
|
||||
dependencies:
|
||||
graphlib: 2.1.8
|
||||
lodash: 4.18.1
|
||||
|
||||
dayjs@1.11.19: {}
|
||||
|
||||
debug@4.4.3:
|
||||
@@ -3082,6 +3107,10 @@ snapshots:
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
graphlib@2.1.8:
|
||||
dependencies:
|
||||
lodash: 4.18.1
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
@@ -3158,6 +3187,8 @@ snapshots:
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash@4.18.1: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
+394
-30
@@ -1,15 +1,15 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, Pagination, theme } from 'antd';
|
||||
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined, FormOutlined, PlusOutlined, ReadOutlined } from '@ant-design/icons';
|
||||
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Progress, Card, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, Pagination, theme } from 'antd';
|
||||
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined, FormOutlined, PlusOutlined, ReadOutlined, ClockCircleOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useChapterSync } from '../store/hooks';
|
||||
import { generateChapterBackground, getProjectTasks, cancelTask, deleteTask, type TaskStatus as BgTaskStatus } from '../services/backgroundTaskService';
|
||||
import { projectApi, writingStyleApi, chapterApi } from '../services/api';
|
||||
import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types';
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||
import ChapterAnalysis from '../components/ChapterAnalysis';
|
||||
import ExpansionPlanEditor from '../components/ExpansionPlanEditor';
|
||||
import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
|
||||
import { SSEProgressModal } from '../components/SSEProgressModal';
|
||||
import ChapterReader from '../components/ChapterReader';
|
||||
import PartialRegenerateToolbar from '../components/PartialRegenerateToolbar';
|
||||
import PartialRegenerateModal from '../components/PartialRegenerateModal';
|
||||
@@ -97,6 +97,112 @@ export default function Chapters() {
|
||||
const [singleChapterProgress, setSingleChapterProgress] = useState(0);
|
||||
const [singleChapterProgressMessage, setSingleChapterProgressMessage] = useState('');
|
||||
|
||||
// 后台生成任务状态
|
||||
const [bgTaskVisible, setBgTaskVisible] = useState(false);
|
||||
const [bgTaskProgress, setBgTaskProgress] = useState(0);
|
||||
const [bgTaskMessage, setBgTaskMessage] = useState('');
|
||||
const [bgTaskRunning, setBgTaskRunning] = useState(false);
|
||||
const bgTaskCancelRef = useRef<(() => void) | null>(null);
|
||||
const [projectBgTasks, setProjectBgTasks] = useState<BgTaskStatus[]>([]);
|
||||
const bgPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// 后台任务列表 Modal 状态
|
||||
const [taskListVisible, setTaskListVisible] = useState(false);
|
||||
const [taskList, setTaskList] = useState<BgTaskStatus[]>([]);
|
||||
const [taskListLoading, setTaskListLoading] = useState(false);
|
||||
|
||||
// 轮询项目后台任务
|
||||
useEffect(() => {
|
||||
if (!currentProject) return;
|
||||
const pollBgTasks = async () => {
|
||||
try {
|
||||
const resp = await getProjectTasks(currentProject.id, 'chapter_generate', 10);
|
||||
const active = resp.items.filter(t => t.status === 'pending' || t.status === 'running');
|
||||
setProjectBgTasks(active);
|
||||
// 如果有活跃任务,继续轮询
|
||||
if (active.length > 0) {
|
||||
bgPollTimerRef.current = setTimeout(pollBgTasks, 3000);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
pollBgTasks();
|
||||
return () => { if (bgPollTimerRef.current) clearTimeout(bgPollTimerRef.current); };
|
||||
}, [currentProject]);
|
||||
|
||||
// 加载并显示后台任务列表
|
||||
const showTaskListModal = async () => {
|
||||
if (!currentProject?.id) return;
|
||||
setTaskListVisible(true);
|
||||
setTaskListLoading(true);
|
||||
try {
|
||||
const result = await getProjectTasks(currentProject.id);
|
||||
setTaskList(result.items || []);
|
||||
} catch (error) {
|
||||
message.error('加载任务列表失败');
|
||||
} finally {
|
||||
setTaskListLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新任务列表
|
||||
const refreshTaskList = async () => {
|
||||
if (!currentProject?.id) return;
|
||||
setTaskListLoading(true);
|
||||
try {
|
||||
const result = await getProjectTasks(currentProject.id);
|
||||
setTaskList(result.items || []);
|
||||
const active = (result.items || []).filter(t => t.status === 'pending' || t.status === 'running');
|
||||
setProjectBgTasks(active);
|
||||
} catch (error) {
|
||||
console.error('刷新任务列表失败:', error);
|
||||
} finally {
|
||||
setTaskListLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取任务状态标签
|
||||
const getTaskStatusTag = (status: BgTaskStatus['status']) => {
|
||||
switch (status) {
|
||||
case 'pending': return <Tag icon={<ClockCircleOutlined />} color="default">等待中</Tag>;
|
||||
case 'running': return <Tag icon={<LoadingOutlined />} color="processing">运行中</Tag>;
|
||||
case 'completed': return <Tag icon={<CheckCircleOutlined />} color="success">已完成</Tag>;
|
||||
case 'failed': return <Tag icon={<CloseCircleOutlined />} color="error">失败</Tag>;
|
||||
case 'cancelled': return <Tag icon={<CloseCircleOutlined />} color="default">已取消</Tag>;
|
||||
default: return <Tag>{status}</Tag>;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取任务类型标签
|
||||
const getTaskTypeLabel = (taskType: string) => {
|
||||
switch (taskType) {
|
||||
case 'chapter_generate': return '章节生成';
|
||||
case 'outline_new': return '大纲生成';
|
||||
case 'outline_continue': return '大纲续写';
|
||||
default: return taskType;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理取消后台任务
|
||||
const handleCancelBgTask = async (taskId: string) => {
|
||||
try {
|
||||
await cancelTask(taskId);
|
||||
message.success('任务已取消');
|
||||
refreshTaskList();
|
||||
} catch (error) {
|
||||
message.error('取消任务失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理删除任务记录
|
||||
const handleDeleteBgTask = async (taskId: string) => {
|
||||
try {
|
||||
await deleteTask(taskId);
|
||||
message.success('任务记录已删除');
|
||||
refreshTaskList();
|
||||
} catch (error) {
|
||||
message.error('删除任务记录失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 批量生成相关状态
|
||||
const [batchGenerateVisible, setBatchGenerateVisible] = useState(false);
|
||||
const [batchGenerating, setBatchGenerating] = useState(false);
|
||||
@@ -523,7 +629,7 @@ export default function Chapters() {
|
||||
if (data.has_active_task && data.task) {
|
||||
const task = data.task;
|
||||
|
||||
// 恢复任务状态
|
||||
// 恢复任务状态(只在顶部进度条显示,不弹出Modal)
|
||||
setBatchTaskId(task.batch_id);
|
||||
setBatchProgress({
|
||||
status: task.status,
|
||||
@@ -532,12 +638,12 @@ export default function Chapters() {
|
||||
current_chapter_number: task.current_chapter_number,
|
||||
});
|
||||
setBatchGenerating(true);
|
||||
setBatchGenerateVisible(true);
|
||||
// 不设置 setBatchGenerateVisible(true),避免弹出Modal遮挡页面
|
||||
|
||||
// 启动轮询
|
||||
startBatchPolling(task.batch_id);
|
||||
|
||||
message.info('检测到未完成的批量生成任务,已自动恢复');
|
||||
message.info('检测到未完成的批量生成任务,已在顶部显示进度');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查批量生成任务失败:', error);
|
||||
@@ -971,9 +1077,62 @@ export default function Chapters() {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// 后台生成章节(关闭浏览器也不影响)
|
||||
const handleBackgroundGenerate = async () => {
|
||||
if (!editingId) return;
|
||||
if (!selectedStyleId) {
|
||||
message.error("请先选择写作风格");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setBgTaskVisible(true);
|
||||
setBgTaskRunning(true);
|
||||
setBgTaskProgress(0);
|
||||
setBgTaskMessage("正在创建后台任务...");
|
||||
|
||||
const cancelFn = await generateChapterBackground(
|
||||
editingId,
|
||||
{
|
||||
style_id: selectedStyleId,
|
||||
target_word_count: targetWordCount,
|
||||
model: selectedModel,
|
||||
narrative_perspective: temporaryNarrativePerspective,
|
||||
},
|
||||
(status) => {
|
||||
setBgTaskProgress(status.progress || 0);
|
||||
setBgTaskMessage(status.status_message || "处理中...");
|
||||
},
|
||||
(_) => {
|
||||
setBgTaskProgress(100);
|
||||
setBgTaskMessage("生成完成!");
|
||||
setBgTaskRunning(false);
|
||||
message.success("后台章节生成完成!");
|
||||
refreshChapters();
|
||||
if (currentProject) {
|
||||
projectApi.getProject(currentProject.id).then(setCurrentProject).catch(console.error);
|
||||
}
|
||||
loadAnalysisTasks();
|
||||
},
|
||||
(error) => {
|
||||
setBgTaskRunning(false);
|
||||
setBgTaskMessage("失败: " + error);
|
||||
message.error("后台生成失败: " + error);
|
||||
}
|
||||
);
|
||||
|
||||
bgTaskCancelRef.current = cancelFn;
|
||||
message.info("已提交后台生成任务,可以关闭此页面");
|
||||
} catch (error) {
|
||||
message.error("创建后台任务失败");
|
||||
setBgTaskRunning(false);
|
||||
}
|
||||
};
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'draft': 'default',
|
||||
'pending': 'warning',
|
||||
'writing': 'processing',
|
||||
'completed': 'success',
|
||||
};
|
||||
@@ -983,6 +1142,7 @@ export default function Chapters() {
|
||||
const getStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
'draft': '草稿',
|
||||
'pending': '待处理',
|
||||
'writing': '创作中',
|
||||
'completed': '已完成',
|
||||
};
|
||||
@@ -1387,6 +1547,7 @@ export default function Chapters() {
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="draft">草稿</Select.Option>
|
||||
<Select.Option value="pending">待处理</Select.Option>
|
||||
<Select.Option value="writing">创作中</Select.Option>
|
||||
<Select.Option value="completed">已完成</Select.Option>
|
||||
</Select>
|
||||
@@ -1931,6 +2092,13 @@ export default function Chapters() {
|
||||
>
|
||||
一键分析{batchAnalyzableChapterCount > 0 ? ` (${batchAnalyzableChapterCount})` : ''}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ClockCircleOutlined />}
|
||||
onClick={showTaskListModal}
|
||||
>
|
||||
后台任务
|
||||
{projectBgTasks.length > 0 && <Badge count={projectBgTasks.length} size="small" style={{ marginLeft: 4 }} />}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<RocketOutlined />}
|
||||
@@ -1955,6 +2123,103 @@ export default function Chapters() {
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 后台生成任务进度 */}
|
||||
{(projectBgTasks.length > 0 || (batchGenerating && batchProgress)) && (
|
||||
<div style={{
|
||||
marginBottom: 16,
|
||||
padding: '12px 16px',
|
||||
background: token.colorInfoBg,
|
||||
borderRadius: token.borderRadius,
|
||||
border: `1px solid ${token.colorInfoBorder}`
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<RocketOutlined style={{ color: token.colorInfo }} spin />
|
||||
<span style={{ fontWeight: 600, color: token.colorInfo }}>
|
||||
后台生成任务
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
||||
关闭浏览器也不影响,完成后自动保存
|
||||
</span>
|
||||
</div>
|
||||
{/* 批量生成进度 */}
|
||||
{batchGenerating && batchProgress && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '8px 0',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`
|
||||
}}>
|
||||
<Tag color="processing" style={{ minWidth: 60, textAlign: 'center' }}>
|
||||
批量生成
|
||||
</Tag>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 12, marginBottom: 4, color: token.colorText }}>
|
||||
{batchProgress.current_chapter_number
|
||||
? `正在生成第 ${batchProgress.current_chapter_number} 章`
|
||||
: '批量生成中...'} ({batchProgress.completed}/{batchProgress.total})
|
||||
</div>
|
||||
<div style={{
|
||||
background: token.colorBgLayout, borderRadius: 4,
|
||||
height: 8, overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
background: token.colorInfo, height: '100%',
|
||||
width: (batchProgress.total > 0 ? Math.round((batchProgress.completed / batchProgress.total) * 100) : 0) + '%',
|
||||
transition: 'width 0.3s'
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: token.colorInfo, minWidth: 40, textAlign: 'right' }}>
|
||||
{batchProgress.total > 0 ? Math.round((batchProgress.completed / batchProgress.total) * 100) : 0}%
|
||||
</span>
|
||||
<Button size="small" danger onClick={() => {
|
||||
modal.confirm({
|
||||
title: '确认取消',
|
||||
content: '确定要取消批量生成吗?已生成的章节将保留。',
|
||||
okText: '确定取消',
|
||||
cancelText: '继续生成',
|
||||
okButtonProps: { danger: true },
|
||||
centered: true,
|
||||
onOk: handleCancelBatchGenerate,
|
||||
});
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* 单章节后台生成进度 */}
|
||||
{projectBgTasks.map(task => (
|
||||
<div key={task.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '6px 0',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`
|
||||
}}>
|
||||
<Tag color={task.status === 'running' ? 'processing' : 'default'}
|
||||
style={{ minWidth: 60, textAlign: 'center' }}>
|
||||
{task.status === 'running' ? '生成中' : '排队中'}
|
||||
</Tag>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
background: token.colorBgLayout, borderRadius: 4,
|
||||
height: 6, overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
background: token.colorInfo, height: '100%',
|
||||
width: (task.progress || 0) + '%',
|
||||
transition: 'width 0.3s'
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: token.colorTextSecondary, minWidth: 40, textAlign: 'right' }}>
|
||||
{task.progress || 0}%
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
||||
{task.status_message || ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{chapters.length === 0 ? (
|
||||
<Empty description="还没有章节,开始创作吧!" />
|
||||
@@ -2435,6 +2700,7 @@ export default function Chapters() {
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select placeholder="选择状态">
|
||||
<Select.Option value="draft">草稿</Select.Option>
|
||||
<Select.Option value="pending">待处理</Select.Option>
|
||||
<Select.Option value="writing">创作中</Select.Option>
|
||||
<Select.Option value="completed">已完成</Select.Option>
|
||||
</Select>
|
||||
@@ -2497,23 +2763,56 @@ export default function Chapters() {
|
||||
const disabledReason = currentChapter ? getGenerateDisabledReason(currentChapter) : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={canGenerate ? <ThunderboltOutlined /> : <LockOutlined />}
|
||||
onClick={() => currentChapter && showGenerateModal(currentChapter)}
|
||||
loading={isContinuing}
|
||||
disabled={!canGenerate}
|
||||
disabled={!canGenerate || bgTaskRunning}
|
||||
danger={!canGenerate}
|
||||
style={{ fontWeight: 'bold' }}
|
||||
title={!canGenerate ? disabledReason : '根据大纲和前置章节内容创作'}
|
||||
title={!canGenerate ? disabledReason : '根据大纲和前置章节内容创作(流式)'}
|
||||
>
|
||||
{isMobile ? 'AI' : 'AI创作'}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<RocketOutlined />}
|
||||
onClick={handleBackgroundGenerate}
|
||||
disabled={!canGenerate || bgTaskRunning || isContinuing}
|
||||
loading={bgTaskRunning}
|
||||
style={{ fontWeight: 'bold' }}
|
||||
title={!canGenerate ? disabledReason : '后台生成:关闭浏览器也不影响,完成后自动保存'}
|
||||
>
|
||||
{isMobile ? '后台' : '后台生成'}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
{/* 后台生成进度 */}
|
||||
{bgTaskVisible && (
|
||||
<Alert
|
||||
message={bgTaskRunning ? '后台生成进行中...' : '后台生成完成'}
|
||||
description={
|
||||
<div>
|
||||
<div style={{ marginBottom: 8 }}>{bgTaskMessage}</div>
|
||||
<div style={{ background: '#f0f0f0', borderRadius: 4, height: 8, overflow: 'hidden' }}>
|
||||
<div style={{ background: '#1890ff', height: '100%', width: bgTaskProgress + '%', transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>{bgTaskProgress}%</div>
|
||||
</div>
|
||||
}
|
||||
type={bgTaskRunning ? 'info' : (bgTaskProgress >= 100 ? 'success' : 'error')}
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
closable={!bgTaskRunning}
|
||||
onClose={() => setBgTaskVisible(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 第一行:写作风格 + 叙事角度 */}
|
||||
<div style={{
|
||||
display: isMobile ? 'block' : 'flex',
|
||||
@@ -2934,29 +3233,94 @@ export default function Chapters() {
|
||||
message={singleChapterProgressMessage}
|
||||
/>
|
||||
|
||||
{/* 批量生成进度显示 - 使用统一的进度组件 */}
|
||||
<SSEProgressModal
|
||||
visible={batchGenerating}
|
||||
progress={batchProgress ? Math.round((batchProgress.completed / batchProgress.total) * 100) : 0}
|
||||
message={
|
||||
batchProgress?.current_chapter_number
|
||||
? `正在生成第 ${batchProgress.current_chapter_number} 章... (${batchProgress.completed}/${batchProgress.total})`
|
||||
: `批量生成进行中... (${batchProgress?.completed || 0}/${batchProgress?.total || 0})`
|
||||
{/* 后台任务列表 Modal */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
<span>后台任务</span>
|
||||
{taskList.filter(t => t.status === 'running' || t.status === 'pending').length > 0 && (
|
||||
<Badge count={taskList.filter(t => t.status === 'running' || t.status === 'pending').length} />
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
title="批量生成章节"
|
||||
onCancel={() => {
|
||||
modal.confirm({
|
||||
title: '确认取消',
|
||||
content: '确定要取消批量生成吗?已生成的章节将保留。',
|
||||
okText: '确定取消',
|
||||
cancelText: '继续生成',
|
||||
okButtonProps: { danger: true },
|
||||
centered: true,
|
||||
onOk: handleCancelBatchGenerate,
|
||||
});
|
||||
}}
|
||||
cancelButtonText="取消任务"
|
||||
/>
|
||||
open={taskListVisible}
|
||||
onCancel={() => setTaskListVisible(false)}
|
||||
width={isMobile ? '95%' : 700}
|
||||
centered
|
||||
footer={
|
||||
<Space>
|
||||
<Button icon={<SyncOutlined />} onClick={refreshTaskList} loading={taskListLoading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={() => setTaskListVisible(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{taskListLoading && taskList.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<LoadingOutlined style={{ fontSize: 24 }} />
|
||||
<div style={{ marginTop: 12, color: token.colorTextSecondary }}>加载中...</div>
|
||||
</div>
|
||||
) : taskList.length === 0 ? (
|
||||
<Empty description="暂无后台任务" />
|
||||
) : (
|
||||
<List
|
||||
dataSource={taskList}
|
||||
renderItem={(task) => (
|
||||
<List.Item
|
||||
key={task.id}
|
||||
actions={[
|
||||
...(task.status === 'running' || task.status === 'pending'
|
||||
? [<Button key="cancel" size="small" danger onClick={() => handleCancelBgTask(task.id)}>取消</Button>]
|
||||
: []
|
||||
),
|
||||
...(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled'
|
||||
? [<Button key="delete" size="small" type="link" danger onClick={() => handleDeleteBgTask(task.id)}>删除</Button>]
|
||||
: []
|
||||
),
|
||||
].filter(Boolean)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space>
|
||||
{getTaskStatusTag(task.status)}
|
||||
<span>{getTaskTypeLabel(task.task_type)}</span>
|
||||
{task.status === 'running' || task.status === 'pending' ? (
|
||||
<Progress percent={task.progress} size="small" style={{ width: 120 }} />
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
||||
{task.status_message || '无状态信息'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: token.colorTextTertiary, marginTop: 4 }}>
|
||||
创建: {task.created_at ? new Date(task.created_at).toLocaleString() : '-'}
|
||||
{task.completed_at && ' | 完成: ' + new Date(task.completed_at).toLocaleString()}
|
||||
</div>
|
||||
{task.error_message && (
|
||||
<div style={{ fontSize: 12, color: token.colorError, marginTop: 4 }}>
|
||||
{'❌ ' + task.error_message}
|
||||
</div>
|
||||
)}
|
||||
{task.task_result && task.status === 'completed' && (
|
||||
<div style={{ fontSize: 12, color: token.colorSuccess, marginTop: 4 }}>
|
||||
{'✅ ' + ((task.task_result as Record<string, unknown>).message as string || '任务完成')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
|
||||
{/* 章节阅读器 */}
|
||||
{readingChapter && (
|
||||
|
||||
+209
-24
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, InputNumber, Tabs, Pagination, theme } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, BranchesOutlined, AppstoreAddOutlined, CheckCircleOutlined, ExclamationCircleOutlined, PlusOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, InputNumber, Tabs, Pagination, theme, Progress, Badge, Tooltip } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, BranchesOutlined, AppstoreAddOutlined, CheckCircleOutlined, ExclamationCircleOutlined, PlusOutlined, FileTextOutlined, ClockCircleOutlined, ReloadOutlined, CloseCircleOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useOutlineSync } from '../store/hooks';
|
||||
import { SSEPostClient } from '../utils/sseClient';
|
||||
import { SSEProgressModal } from '../components/SSEProgressModal';
|
||||
import { generateOutlineBackground, getProjectTasks, cancelTask, deleteTask, type TaskStatus } from '../services/backgroundTaskService';
|
||||
import { outlineApi, chapterApi, projectApi, characterApi } from '../services/api';
|
||||
import type { OutlineExpansionResponse, BatchOutlineExpansionResponse, ChapterPlanItem, ApiError, Character } from '../types';
|
||||
|
||||
@@ -154,6 +155,14 @@ export default function Outline() {
|
||||
const [sseMessage, setSSEMessage] = useState('');
|
||||
const [sseModalVisible, setSSEModalVisible] = useState(false);
|
||||
|
||||
// 后台任务取消函数引用
|
||||
const cancelGenerateRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// 后台任务列表状态
|
||||
const [taskListVisible, setTaskListVisible] = useState(false);
|
||||
const [taskList, setTaskList] = useState<TaskStatus[]>([]);
|
||||
const [taskListLoading, setTaskListLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
@@ -573,33 +582,31 @@ export default function Outline() {
|
||||
console.log('6. 最终请求数据:', JSON.stringify(requestData, null, 2));
|
||||
console.log('=========================');
|
||||
|
||||
// 使用SSE客户端
|
||||
const apiUrl = `/api/outlines/generate-stream`;
|
||||
const client = new SSEPostClient(apiUrl, requestData, {
|
||||
onProgress: (msg: string, progress: number) => {
|
||||
setSSEMessage(msg);
|
||||
setSSEProgress(progress);
|
||||
// 使用后台任务生成(不怕断连,关闭浏览器也继续运行)
|
||||
setSSEMessage('正在创建后台任务...');
|
||||
|
||||
const cancelFn = await generateOutlineBackground(
|
||||
requestData,
|
||||
(status) => {
|
||||
setSSEProgress(status.progress);
|
||||
setSSEMessage(status.status_message || '处理中...');
|
||||
},
|
||||
onResult: (data: unknown) => {
|
||||
console.log('生成完成,结果:', data);
|
||||
(result) => {
|
||||
message.success(result.task_result?.message as string || '大纲生成完成!');
|
||||
setSSEModalVisible(false);
|
||||
setIsGenerating(false);
|
||||
cancelGenerateRef.current = null;
|
||||
refreshOutlines();
|
||||
},
|
||||
onError: (error: string) => {
|
||||
// 现在只处理真正的错误
|
||||
(error) => {
|
||||
message.error(`生成失败: ${error}`);
|
||||
setSSEModalVisible(false);
|
||||
setIsGenerating(false);
|
||||
},
|
||||
onComplete: () => {
|
||||
message.success('大纲生成完成!');
|
||||
setSSEModalVisible(false);
|
||||
setIsGenerating(false);
|
||||
// 刷新大纲列表
|
||||
refreshOutlines();
|
||||
cancelGenerateRef.current = null;
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 开始连接
|
||||
client.connect();
|
||||
cancelGenerateRef.current = cancelFn;
|
||||
|
||||
} catch (error) {
|
||||
console.error('AI生成失败:', error);
|
||||
@@ -1895,8 +1902,168 @@ export default function Outline() {
|
||||
};
|
||||
|
||||
|
||||
// 加载并显示后台任务列表
|
||||
const showTaskListModal = async () => {
|
||||
if (!currentProject?.id) return;
|
||||
setTaskListVisible(true);
|
||||
setTaskListLoading(true);
|
||||
try {
|
||||
const result = await getProjectTasks(currentProject.id);
|
||||
setTaskList(result.items || []);
|
||||
} catch (error) {
|
||||
message.error('加载任务列表失败');
|
||||
} finally {
|
||||
setTaskListLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新任务列表
|
||||
const refreshTaskList = async () => {
|
||||
if (!currentProject?.id) return;
|
||||
setTaskListLoading(true);
|
||||
try {
|
||||
const result = await getProjectTasks(currentProject.id);
|
||||
setTaskList(result.items || []);
|
||||
} catch (error) {
|
||||
console.error('刷新任务列表失败:', error);
|
||||
} finally {
|
||||
setTaskListLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取任务状态标签
|
||||
const getTaskStatusTag = (status: TaskStatus['status']) => {
|
||||
switch (status) {
|
||||
case 'pending': return <Tag icon={<ClockCircleOutlined />} color="default">等待中</Tag>;
|
||||
case 'running': return <Tag icon={<LoadingOutlined />} color="processing">运行中</Tag>;
|
||||
case 'completed': return <Tag icon={<CheckCircleOutlined />} color="success">已完成</Tag>;
|
||||
case 'failed': return <Tag icon={<CloseCircleOutlined />} color="error">失败</Tag>;
|
||||
case 'cancelled': return <Tag icon={<CloseCircleOutlined />} color="default">已取消</Tag>;
|
||||
default: return <Tag>{status}</Tag>;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取任务类型标签
|
||||
const getTaskTypeLabel = (taskType: string) => {
|
||||
switch (taskType) {
|
||||
case 'outline_new': return '大纲生成';
|
||||
case 'outline_continue': return '大纲续写';
|
||||
default: return taskType;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理取消后台任务
|
||||
const handleCancelTask = async (taskId: string) => {
|
||||
try {
|
||||
await cancelTask(taskId);
|
||||
message.success('任务已取消');
|
||||
refreshTaskList();
|
||||
} catch (error) {
|
||||
message.error('取消任务失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理删除任务记录
|
||||
const handleDeleteTask = async (taskId: string) => {
|
||||
try {
|
||||
await deleteTask(taskId);
|
||||
message.success('任务记录已删除');
|
||||
refreshTaskList();
|
||||
} catch (error) {
|
||||
message.error('删除任务记录失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 后台任务列表 Modal */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
<span>后台任务</span>
|
||||
{taskList.filter(t => t.status === 'running' || t.status === 'pending').length > 0 && (
|
||||
<Badge count={taskList.filter(t => t.status === 'running' || t.status === 'pending').length} />
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
open={taskListVisible}
|
||||
onCancel={() => setTaskListVisible(false)}
|
||||
width={isMobile ? '95%' : 700}
|
||||
centered
|
||||
footer={
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={refreshTaskList} loading={taskListLoading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={() => setTaskListVisible(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{taskListLoading && taskList.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<LoadingOutlined style={{ fontSize: 24 }} />
|
||||
<div style={{ marginTop: 12, color: token.colorTextSecondary }}>加载中...</div>
|
||||
</div>
|
||||
) : taskList.length === 0 ? (
|
||||
<Empty description="暂无后台任务" />
|
||||
) : (
|
||||
<List
|
||||
dataSource={taskList}
|
||||
renderItem={(task) => (
|
||||
<List.Item
|
||||
key={task.id}
|
||||
actions={[
|
||||
...(task.status === 'running' || task.status === 'pending'
|
||||
? [<Button key="cancel" size="small" danger onClick={() => handleCancelTask(task.id)}>取消</Button>]
|
||||
: []
|
||||
),
|
||||
...(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled'
|
||||
? [<Button key="delete" size="small" type="link" danger onClick={() => handleDeleteTask(task.id)}>删除</Button>]
|
||||
: []
|
||||
),
|
||||
].filter(Boolean)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space>
|
||||
{getTaskStatusTag(task.status)}
|
||||
<span>{getTaskTypeLabel(task.task_type)}</span>
|
||||
{task.status === 'running' || task.status === 'pending' ? (
|
||||
<Progress percent={task.progress} size="small" style={{ width: 120 }} />
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
||||
{task.status_message || '无状态信息'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: token.colorTextTertiary, marginTop: 4 }}>
|
||||
创建: {task.created_at ? new Date(task.created_at).toLocaleString() : '-'}
|
||||
{task.completed_at && ` | 完成: ${new Date(task.completed_at).toLocaleString()}`}
|
||||
</div>
|
||||
{task.error_message && (
|
||||
<div style={{ fontSize: 12, color: token.colorError, marginTop: 4 }}>
|
||||
❌ {task.error_message}
|
||||
</div>
|
||||
)}
|
||||
{task.task_result && task.status === 'completed' && (
|
||||
<div style={{ fontSize: 12, color: token.colorSuccess, marginTop: 4 }}>
|
||||
✅ {(task.task_result as Record<string, unknown>).message as string || '任务完成'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 批量展开预览 Modal */}
|
||||
<Modal
|
||||
title={
|
||||
@@ -1923,7 +2090,16 @@ export default function Outline() {
|
||||
visible={sseModalVisible}
|
||||
progress={sseProgress}
|
||||
message={sseMessage}
|
||||
title="AI生成中..."
|
||||
title="AI生成中(后台运行,可关闭页面)..."
|
||||
onCancel={() => {
|
||||
if (cancelGenerateRef.current) {
|
||||
cancelGenerateRef.current();
|
||||
cancelGenerateRef.current = null;
|
||||
}
|
||||
setSSEModalVisible(false);
|
||||
setIsGenerating(false);
|
||||
message.info('已取消生成任务');
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
@@ -1977,6 +2153,15 @@ export default function Outline() {
|
||||
>
|
||||
{isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'}
|
||||
</Button>
|
||||
<Tooltip title="查看后台任务进度">
|
||||
<Button
|
||||
icon={<ClockCircleOutlined />}
|
||||
onClick={showTaskListModal}
|
||||
block={isMobile}
|
||||
>
|
||||
{isMobile ? '任务' : '后台任务'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{outlines.length > 0 && currentProject?.outline_mode === 'one-to-many' && (
|
||||
<Button
|
||||
icon={<AppstoreAddOutlined />}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 后台任务服务 - 轮询任务进度,替代SSE
|
||||
*/
|
||||
|
||||
const API_BASE = '/api/tasks';
|
||||
|
||||
export interface TaskStatus {
|
||||
id: string;
|
||||
task_type: string;
|
||||
project_id: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
progress: number; // 0-100
|
||||
status_message: string | null;
|
||||
progress_details: {
|
||||
stage: string;
|
||||
message: string;
|
||||
current_chars?: number;
|
||||
retry_count?: number;
|
||||
} | null;
|
||||
error_message: string | null;
|
||||
task_result: Record<string, unknown> | null;
|
||||
retry_count: number;
|
||||
cancel_requested: boolean;
|
||||
created_at: string | null;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface TaskListResponse {
|
||||
items: TaskStatus[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询任务状态
|
||||
*/
|
||||
export async function getTaskStatus(taskId: string): Promise<TaskStatus> {
|
||||
const response = await fetch(`${API_BASE}/${taskId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`查询任务状态失败: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目的任务列表
|
||||
*/
|
||||
export async function getProjectTasks(
|
||||
projectId: string,
|
||||
taskType?: string,
|
||||
limit: number = 20
|
||||
): Promise<TaskListResponse> {
|
||||
const params = new URLSearchParams({ project_id: projectId, limit: String(limit) });
|
||||
if (taskType) params.set('task_type', taskType);
|
||||
const response = await fetch(`${API_BASE}?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取任务列表失败: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消任务
|
||||
*/
|
||||
export async function cancelTask(taskId: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/${taskId}/cancel`, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`取消任务失败: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除任务记录
|
||||
*/
|
||||
export async function deleteTask(taskId: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/${taskId}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`删除任务失败: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type TaskProgressCallback = (status: TaskStatus) => void;
|
||||
export type TaskCompleteCallback = (result: TaskStatus) => void;
|
||||
export type TaskErrorCallback = (error: string, status: TaskStatus) => void;
|
||||
|
||||
/**
|
||||
* 轮询任务直到完成
|
||||
*
|
||||
* @param taskId 任务ID
|
||||
* @param onProgress 进度回调
|
||||
* @param onComplete 完成回调
|
||||
* @param onError 错误回调
|
||||
* @param intervalMs 轮询间隔(毫秒),默认2000
|
||||
* @returns 取消轮询的函数
|
||||
*/
|
||||
export function pollTaskUntilComplete(
|
||||
taskId: string,
|
||||
onProgress: TaskProgressCallback,
|
||||
onComplete: TaskCompleteCallback,
|
||||
onError: TaskErrorCallback,
|
||||
intervalMs: number = 2000
|
||||
): () => void {
|
||||
let cancelled = false;
|
||||
let timerId: ReturnType<typeof setTimeout>;
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelled) return;
|
||||
|
||||
try {
|
||||
const status = await getTaskStatus(taskId);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
onProgress(status);
|
||||
|
||||
if (status.status === 'completed') {
|
||||
onComplete(status);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === 'failed') {
|
||||
onError(status.error_message || '任务失败', status);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === 'cancelled') {
|
||||
onError('任务已取消', status);
|
||||
return;
|
||||
}
|
||||
|
||||
// 继续轮询(运行中时加快轮询频率)
|
||||
const nextInterval = status.status === 'running' ? intervalMs : intervalMs * 2;
|
||||
timerId = setTimeout(poll, nextInterval);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
onError(err instanceof Error ? err.message : '查询任务状态失败', {} as TaskStatus);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 立即开始第一次轮询
|
||||
timerId = setTimeout(poll, 0);
|
||||
|
||||
// 返回取消函数
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timerId);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求后台生成大纲并轮询进度
|
||||
*
|
||||
* @param data 请求参数(同原来的generate-stream)
|
||||
* @param onProgress 进度回调
|
||||
* @param onComplete 完成回调
|
||||
* @param onError 错误回调
|
||||
* @returns 取消函数(同时取消轮询和后台任务)
|
||||
*/
|
||||
export async function generateOutlineBackground(
|
||||
data: unknown,
|
||||
onProgress: TaskProgressCallback,
|
||||
onComplete: TaskCompleteCallback,
|
||||
onError: TaskErrorCallback
|
||||
): Promise<() => void> {
|
||||
// 1. 创建后台任务
|
||||
const response = await fetch('/api/outlines/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
onError(err.detail || '创建任务失败', {} as TaskStatus);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const { task_id } = await response.json();
|
||||
|
||||
// 2. 开始轮询
|
||||
let cancelPolling = pollTaskUntilComplete(task_id, onProgress, onComplete, onError);
|
||||
|
||||
// 3. 返回统一的取消函数(取消轮询 + 取消后台任务)
|
||||
return () => {
|
||||
cancelPolling();
|
||||
cancelTask(task_id).catch(() => {});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求后台生成章节内容并轮询进度
|
||||
* 关闭浏览器不影响生成,生成完成后内容自动保存到数据库
|
||||
*/
|
||||
export async function generateChapterBackground(
|
||||
chapterId: string,
|
||||
options: {
|
||||
style_id?: number | null;
|
||||
target_word_count?: number;
|
||||
model?: string | null;
|
||||
narrative_perspective?: string | null;
|
||||
enable_mcp?: boolean;
|
||||
},
|
||||
onProgress: TaskProgressCallback,
|
||||
onComplete: TaskCompleteCallback,
|
||||
onError: TaskErrorCallback
|
||||
): Promise<() => void> {
|
||||
const response = await fetch(`/api/chapters/${chapterId}/generate-background`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
onError(err.detail || '创建章节生成任务失败', {} as TaskStatus);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const { task_id } = await response.json();
|
||||
const cancelPolling = pollTaskUntilComplete(task_id, onProgress, onComplete, onError);
|
||||
|
||||
return () => {
|
||||
cancelPolling();
|
||||
cancelTask(task_id).catch(() => {});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user