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:
@@ -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