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:
未来
2026-04-29 08:33:26 +08:00
parent 1f80a58994
commit 2bd8b61e91
20 changed files with 2873 additions and 151 deletions
@@ -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(() => {});
};
}