Files
ttstd-infinite-canvas/web/src/services/api/task-center.ts
xiaomuclaw 3daf9db53a fix(media): 创作结果恢复与等待上限 - 长视频不再误判失败 (#665)
* fix(media): 创作结果恢复 - 结果定位符缺失时回退到任务结果

首页创作生成的图片/视频在本地素材索引尚未同步时(物化结果幂等命中、换标签页、
素材同步延迟)会被收敛成“任务已结束,但生成结果暂时无法读取”:结果定位符只查
本地素材 store,查不到就返回空,于是结果既不挂载也不显示,而服务端其实已经成功。

- generationTaskMaterializedStorageKeys 在本地查不到素材时回退到结果自带的
  providerArtifactRef(resource:<id>),展示地址仍由渲染层按需续签;
- 携带过期签名的历史地址不再直接交给 <img>/<video>,避免首帧 403 空白。

* fix(media): 前端等待上限对齐后端超时 - 长视频不再被前端误判失败

视频任务的前端等待上限写死 32 分钟,而后端视频超时默认 60 分钟(现网配到 120 分钟)、
上游 30 秒档常态 35-45 分钟。结果:任务在后端正常跑并最终成功,前端先报
“任务执行超时,请稍后重试”,消息被标成失败;而失败态消息不会被恢复流程接管
(isRecoverableCreationMessage 只认 pending),用户看到的就是“失败”且视频永远不显示。

- taskWaitTimeoutMs:视频 32 → 125 分钟、分镜 13 → 25 分钟,都不小于后端同类超时;
- isRecoverableCreationMessage:媒体消息的失败态也纳入恢复,任务确实失败时收敛结果不变,
  任务其实成功时把结果补回消息。

---------

Co-authored-by: xiaomuclaw <282836183+xiaomuclaw@users.noreply.github.com>
2026-10-06 09:13:43 +08:00

567 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { generationErrorMessage } from "@/lib/generation-error";
import { http, apiBaseURL, type BackendEnvelope } from "@/services/api/request";
import { consumeTaskTextStream, createTaskTextStreamParser, type TaskTextStreamEvent } from "@/services/api/task-text-stream";
import { recordDiagnosticEvent } from "@/services/diagnostics/client-diagnostics";
export type { BackendEnvelope } from "@/services/api/request";
export type TaskStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled";
export type TaskBillingStatus = "reserved" | "running" | "settled" | "refunded" | "uncertain";
export type ProviderCancelStatus = "requested" | "confirmed" | "uncertain";
export type GenerationTaskResultState = "NOT_AVAILABLE" | "PENDING_MATERIALIZATION" | "MATERIALIZING" | "READY" | "FAILED_RETRYABLE" | "FAILED_PERMANENT";
export type GenerationTaskOutput = {
outputIndex: number;
mediaType: "image" | "video" | "audio";
providerArtifactRef?: string;
materializedAssetId?: string;
materializationErrorCode?: string;
};
export type GenerationTask = {
id: string;
clientOperationId?: string;
retryOf?: string;
attemptGroupId?: string;
projectId?: string;
type: string;
status: TaskStatus;
progress?: number;
stage?: string;
mediaStage?: "download" | "upload" | "local_save" | "register" | "completed" | "checkpoint";
canRecoverMedia?: boolean;
prompt: string;
operation?: string;
provider?: string;
model?: string;
providerRequestId?: string;
providerCancelStatus?: ProviderCancelStatus;
providerCancelError?: string;
providerCancelAttempts?: number;
providerCancelRequestedAt?: string;
providerCancelledAt?: string;
errorCode?: string;
officialStatus?: "pending" | "processing" | "completed" | "failed" | "cancelled";
receiptRecorded?: boolean;
previewUrl?: string;
previewKind?: "image" | "video";
previewPosterUrl?: string;
inputJson?: string;
resultJson?: string;
resultState?: GenerationTaskResultState;
outputs?: GenerationTaskOutput[];
textDraft?: string;
error?: string;
attempts: number;
startedAt?: string;
completedAt?: string;
createdAt: string;
updatedAt: string;
billing?: {
amountMicrocredits: number;
status: TaskBillingStatus;
};
clientContext?: {
conversationId?: string;
messageId?: string;
nodeId?: string;
batchIndex?: number;
batchCount?: number;
domainProjectId?: string;
chapterId?: string;
chapterOperation?: "characters" | "storyboard";
shotId?: string;
workflowStepId?: string;
artifactType?: string;
};
created_at?: string;
updated_at?: string;
};
export type ProviderTaskQueryResult = {
task: GenerationTask;
providerStatus: string;
recovered: boolean;
billingSettled: boolean;
};
export type TaskTextDelta = {
id: string;
taskId: string;
sequence: number;
content: string;
byteCount: number;
createdAt: string;
expiresAt: string;
};
export type TaskTextReplay = {
deltas: TaskTextDelta[];
textDraft?: string;
finalText?: string;
complete: boolean;
status: TaskStatus;
stage?: string;
progress: number;
error?: string;
};
export type TaskLog = {
id: string;
taskId: string;
level: "info" | "warn" | "error";
stage: string;
errorCode?: string;
provenance: "task_state" | "provider_observation" | "background_reconcile" | "manual_refresh" | "backend";
observedAt?: string;
createdAt: string;
};
export type CreateTaskInput = {
projectId?: string;
type?: string;
operation?: string;
prompt: string;
provider?: string;
model?: string;
logicalModelId?: string;
input?: Record<string, unknown>;
};
export function createGenerationTask(input: CreateTaskInput) {
return http.post<GenerationTask>("/tasks", input).then((task) => {
recordDiagnosticEvent({ level: "info", category: "task", message: "任务已创建", taskId: task.id, projectId: task.projectId });
notifyCanvasTaskCreated(task);
// 创建任务时积分已被预占,不能等任务结束后才刷新可用余额。
window.dispatchEvent(new CustomEvent("wallet:updated"));
return task;
});
}
export type GenerationTaskPageRequest = {
limit: number;
projectId?: string;
activeOnly: boolean;
cursor?: string;
};
type GenerationTaskPage<T> = { tasks: T[]; nextCursor?: string };
type GenerationTaskListOptions = { projectId?: string; activeOnly?: boolean };
type GenerationTaskListDependencies = {
listBackend?(limit: number, options?: GenerationTaskListOptions, signal?: AbortSignal): Promise<GenerationTask[]>;
listBackendPage?(request: GenerationTaskPageRequest, signal?: AbortSignal): Promise<GenerationTaskPage<GenerationTask>>;
};
const defaultGenerationTaskListDependencies: GenerationTaskListDependencies = {
listBackendPage: async (page, signal) => ({
tasks: await http.get<GenerationTask[]>("/tasks", {
params: { pageSize: Math.min(page.limit, 100), projectId: page.projectId, activeOnly: page.activeOnly || undefined },
signal,
}),
}),
};
export async function listGenerationTasks(limit = 30, options?: { projectId?: string; activeOnly?: boolean }, dependencies: GenerationTaskListDependencies = defaultGenerationTaskListDependencies, signal?: AbortSignal) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
const boundedLimit = Number.isFinite(limit) ? Math.max(1, Math.min(10_000, Math.trunc(limit))) : 30;
const baseRequest = {
limit: Math.min(100, boundedLimit),
...(options?.projectId ? { projectId: options.projectId } : {}),
activeOnly: options?.activeOnly === true,
} satisfies GenerationTaskPageRequest;
const backendPageReader =
dependencies.listBackendPage ??
(async (page: GenerationTaskPageRequest, pageSignal?: AbortSignal) => ({
tasks:
(await dependencies.listBackend?.(
page.limit,
{
...(page.projectId ? { projectId: page.projectId } : {}),
activeOnly: page.activeOnly,
},
pageSignal,
)) ?? [],
}));
const backendTasks = await collectGenerationTaskPages(backendPageReader, baseRequest, boundedLimit, signal);
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return backendTasks
.filter((task) => !options?.projectId || task.projectId === options.projectId)
.filter((task) => !options?.activeOnly || task.status === "queued" || task.status === "running")
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id))
.slice(0, boundedLimit);
}
async function collectGenerationTaskPages<T>(readPage: (request: GenerationTaskPageRequest, signal?: AbortSignal) => Promise<GenerationTaskPage<T>>, baseRequest: GenerationTaskPageRequest, limit: number, signal?: AbortSignal) {
const items: T[] = [];
const seenCursors = new Set<string>();
let cursor: string | undefined;
do {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
const response = await readPage({ ...baseRequest, ...(cursor ? { cursor } : {}) }, signal);
const page = Array.isArray(response) ? { tasks: response as T[] } : response;
items.push(...page.tasks);
if (!page.nextCursor || seenCursors.has(page.nextCursor)) break;
seenCursors.add(page.nextCursor);
cursor = page.nextCursor;
} while (items.length < limit);
return items.slice(0, limit);
}
export function queryGenerationTask(id: string, options?: { signal?: AbortSignal }) {
return http.get<GenerationTask>(`/tasks/${encodeURIComponent(id)}`, { signal: options?.signal });
}
type GenerationTaskSubscriptionDependencies = {
queryTask(id: string): Promise<GenerationTask>;
waitTask(id: string, options?: { initialTask?: GenerationTask; onTaskUpdate?: (task: GenerationTask) => void }): Promise<GenerationTask>;
};
export function createGenerationTaskSubscriptionService(dependencies: GenerationTaskSubscriptionDependencies) {
type Entry = {
listeners: Set<(task: GenerationTask) => void>;
latest?: GenerationTask;
observation?: Promise<void>;
};
const entries = new Map<string, Entry>();
const publish = (entry: Entry, task: GenerationTask) => {
entry.latest = task;
for (const listener of entry.listeners) listener(task);
};
const observe = (id: string, entry: Entry) => {
if (entry.observation) return;
entry.observation = (async () => {
const initial = await dependencies.queryTask(id);
publish(entry, initial);
if (initial.status === "succeeded" || initial.status === "failed" || initial.status === "cancelled") return;
const terminal = await dependencies.waitTask(id, {
initialTask: initial,
onTaskUpdate: (task) => publish(entry, task),
});
publish(entry, terminal);
})().catch((error) => {
// 观察失败不能永久占住 entry,否则页面重新订阅时也不会再查询任务。
entry.observation = undefined;
console.warn("生成任务观察中断,后续订阅将重新建立连接", { taskId: id, error });
});
};
return {
subscribe(ids: readonly string[], listener: (task: GenerationTask) => void) {
const uniqueIds = [...new Set(ids)];
for (const id of uniqueIds) {
const entry = entries.get(id) ?? { listeners: new Set<(task: GenerationTask) => void>() };
entries.set(id, entry);
entry.listeners.add(listener);
if (entry.latest) listener(entry.latest);
observe(id, entry);
}
return () => {
for (const id of uniqueIds) entries.get(id)?.listeners.delete(listener);
};
},
};
}
const generationTaskSubscriptionService = createGenerationTaskSubscriptionService({
queryTask: (id) => queryGenerationTask(id),
waitTask: (id, options) => waitForGenerationTask(id, options),
});
export function subscribeGenerationTasks(ids: readonly string[], listener: (task: GenerationTask) => void) {
return generationTaskSubscriptionService.subscribe(ids, listener);
}
export function appendTaskTextDelta(id: string, content: string) {
return http.post<TaskTextDelta>(`/tasks/${encodeURIComponent(id)}/text-deltas`, { content });
}
export function completeTextReplayTask(id: string, text: string) {
return http.post<GenerationTask>(`/tasks/${encodeURIComponent(id)}/text-replay-complete`, { text });
}
export function queryTaskTextReplay(id: string, after = 0) {
return http.get<TaskTextReplay>(`/tasks/${encodeURIComponent(id)}/text-deltas`, { params: { after } });
}
export function retryGenerationTask(id: string) {
return http.post<GenerationTask>(`/tasks/${encodeURIComponent(id)}/retry`);
}
export function recoverGenerationTaskMedia(id: string) {
return http.post<GenerationTask>(`/tasks/${encodeURIComponent(id)}/recover-media`);
}
export function cancelGenerationTask(id: string) {
return http.post<GenerationTask>(`/tasks/${encodeURIComponent(id)}/cancel`).then((task) => {
window.dispatchEvent(new CustomEvent("canvas:task-cancelled", { detail: { task } }));
window.dispatchEvent(new CustomEvent("wallet:updated"));
return task;
});
}
export function queryFailedVideoProviderTask(id: string) {
return http.post<ProviderTaskQueryResult>(`/tasks/${encodeURIComponent(id)}/query-provider`);
}
export function refreshGenerationTaskStatus(id: string, options?: { signal?: AbortSignal }) {
return http.get<GenerationTask>(`/tasks/${encodeURIComponent(id)}`, { signal: options?.signal });
}
export function deleteGenerationTask(id: string) {
return http.delete<void>(`/tasks/${encodeURIComponent(id)}`);
}
export async function listTaskLogs(id: string) {
const raw = await http.get<Array<{ level?: unknown; message?: unknown; payload?: unknown; createdAt?: unknown }>>(`/tasks/${encodeURIComponent(id)}/logs`);
return raw.map((log, index) => projectBackendSafeTaskLog(id, log, index));
}
export function formatTaskLog(log: TaskLog) {
return [`stage=${log.stage}`, ...(log.errorCode ? [`error=${log.errorCode}`] : []), `provenance=${log.provenance}`, ...(log.observedAt ? [`observedAt=${log.observedAt}`] : [])].join(" ");
}
export function projectBackendSafeTaskLog(taskId: string, raw: { level?: unknown; message?: unknown; payload?: unknown; createdAt?: unknown }, index: number): TaskLog {
const text = [raw.message, raw.payload].filter((value): value is string => typeof value === "string").join(" ");
const stage = safeTaskLogStage(text) || "backend_event";
const errorCode = safeTaskLogErrorCode(text);
const createdAt = typeof raw.createdAt === "string" && Number.isFinite(Date.parse(raw.createdAt)) ? raw.createdAt : "1970-01-01T00:00:00.000Z";
return {
id: `safe:${taskId}:${index}`,
taskId,
level: raw.level === "error" ? "error" : raw.level === "warn" ? "warn" : "info",
stage,
...(errorCode ? { errorCode } : {}),
provenance: "backend",
createdAt,
};
}
function safeTaskLogStage(value: unknown) {
if (typeof value !== "string") return undefined;
const match = value.match(/(?:^|[^a-z0-9_])(queued|submitting|submitted|generating|submission_unknown|processing|completed|succeeded|failed|cancelled)(?:$|[^a-z0-9_])/i);
return match?.[1]?.toLowerCase();
}
const SAFE_TASK_LOG_ERROR_CODES = new Set(["provider_query_failed", "provider_submission_unknown", "provider_reference_invalid"]);
function safeTaskLogErrorCode(value: unknown) {
if (typeof value !== "string") return undefined;
for (const match of value.matchAll(/(?:model|provider)_[a-z0-9_]{2,80}/gi)) {
const errorCode = match[0].toLowerCase();
if (SAFE_TASK_LOG_ERROR_CODES.has(errorCode)) return errorCode;
}
return undefined;
}
export type WaitForGenerationTaskOptions = {
textEventsSource?: "task" | "agent";
signal?: AbortSignal;
intervalMs?: number;
timeoutMs?: number;
initialTask?: GenerationTask;
onTaskUpdate?: (task: GenerationTask) => void;
onTextDelta?: (text: string) => void;
useTextEvents?: boolean;
};
export async function waitForGenerationTask(id: string, options?: WaitForGenerationTaskOptions) {
if (options?.onTextDelta || options?.useTextEvents) return waitForGenerationTaskTextEvents(id, options);
const startedAt = Date.now();
const intervalMs = options?.intervalMs || 2000;
let lastTask = options?.initialTask;
let lastQueryError: unknown;
let consecutiveFailures = 0;
try {
while (Date.now() - startedAt < (options?.timeoutMs || taskWaitTimeoutMs(lastTask))) {
if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError");
let task: GenerationTask;
try {
task = await queryGenerationTask(id, { signal: options?.signal });
lastTask = task;
lastQueryError = undefined;
consecutiveFailures = 0;
options?.onTaskUpdate?.(task);
} catch (error) {
lastQueryError = error;
consecutiveFailures += 1;
// 连续失败说明查询通道已不可用,继续轮询只会空转到整体超时;
// 保留少量容忍度(约 10 秒)以跳过瞬时抖动后直接报错,便于用户尽早处理。
if (consecutiveFailures >= 5) {
throw error instanceof Error ? error : new Error(String(error));
}
await delay(intervalMs, options?.signal);
continue;
}
if (task.status === "succeeded") {
window.dispatchEvent(new CustomEvent("wallet:updated"));
return task;
}
if (task.status === "failed" || task.status === "cancelled") {
window.dispatchEvent(new CustomEvent("wallet:updated"));
throw new Error(task.error ? generationErrorMessage(task.error) : `任务${task.status === "cancelled" ? "已取消" : "失败"}`);
}
await delay(intervalMs, options?.signal);
}
} catch (error) {
if (options?.signal?.aborted) {
// Abort 只停止当前页面的状态监听,不能把已发起的上游任务改成取消状态。
throw new DOMException("Aborted", "AbortError");
}
throw error;
}
throw new Error(lastQueryError instanceof Error ? `任务状态同步失败:${lastQueryError.message}` : "任务执行超时,请稍后重试");
}
async function waitForGenerationTaskTextEvents(id: string, options: WaitForGenerationTaskOptions) {
const startedAt = Date.now();
const intervalMs = options.intervalMs || 1000;
let lastTask = options.initialTask;
let lastEventId = 0;
// Agent subscriptions replay from cursor 0; an existing draft must not be
// prepended to the same persisted deltas when reopening a conversation.
let fullText = options.textEventsSource === "agent" ? "" : lastTask?.textDraft || "";
let lastStreamError: unknown;
if (!lastTask) {
lastTask = await queryGenerationTask(id, { signal: options.signal });
options.onTaskUpdate?.(lastTask);
}
const timeoutMs = options.timeoutMs || taskWaitTimeoutMs(lastTask);
while (Date.now() - startedAt < timeoutMs) {
if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError");
let terminalReceived = false;
try {
const base = String(apiBaseURL).replace(/\/+$/, "");
const cursor = lastEventId > 0 ? `?after=${encodeURIComponent(String(lastEventId))}` : "";
const path = options.textEventsSource === "agent" ? `/agent/runs/${encodeURIComponent(id)}/events` : `/tasks/${encodeURIComponent(id)}/text-events`;
const response = await fetch(`${base}${path}${cursor}`, {
headers: { Accept: "text/event-stream" },
credentials: "include",
signal: options.signal,
});
if (!response.ok) throw new TaskTextStreamFatalError(await taskTextStreamHTTPError(response));
if (!(response.headers.get("content-type") || "").toLowerCase().includes("text/event-stream")) {
throw new TaskTextStreamFatalError("任务文本流接口未返回事件流,请检查后端地址和反向代理配置");
}
if (!response.body) throw new Error("任务文本流不可用");
const reader = response.body.getReader();
const decoder = new TextDecoder();
const parser = createTaskTextStreamParser();
const onEvent = (event: TaskTextStreamEvent) => {
const payload = asTaskTextStreamRecord(event.data);
if (event.event === "delta") {
const sequence = numberValue(payload.sequence) || event.id || 0;
if (sequence <= lastEventId) return;
const content = typeof payload.content === "string" ? payload.content : "";
lastEventId = sequence;
if (!content) return;
fullText += content;
options.onTextDelta?.(fullText);
if (lastTask) {
lastTask = { ...lastTask, textDraft: fullText };
options.onTaskUpdate?.(lastTask);
}
return;
}
if (event.event === "progress") {
if (!lastTask) return;
lastTask = {
...lastTask,
...(isTaskStatus(payload.status) ? { status: payload.status } : {}),
...(typeof payload.stage === "string" ? { stage: payload.stage } : {}),
...(numberValue(payload.progress) !== undefined ? { progress: numberValue(payload.progress) } : {}),
};
options.onTaskUpdate?.(lastTask);
return;
}
if (event.event === "terminal") {
terminalReceived = true;
if (typeof payload.finalText === "string" && payload.finalText !== fullText) {
fullText = payload.finalText;
options.onTextDelta?.(fullText);
}
return;
}
if (event.event === "error") throw new Error(typeof payload.message === "string" ? payload.message : "任务文本流不可用");
};
for (;;) {
const { done, value } = await reader.read();
if (done) break;
consumeTaskTextStream(parser, decoder.decode(value, { stream: true }), onEvent);
}
consumeTaskTextStream(parser, decoder.decode(), onEvent, true);
if (terminalReceived) {
const completed = await queryGenerationTask(id, { signal: options.signal });
options.onTaskUpdate?.(completed);
window.dispatchEvent(new CustomEvent("wallet:updated"));
if (completed.status === "succeeded") return completed;
if (completed.status === "failed" || completed.status === "cancelled") {
throw new TaskTextStreamFatalError(completed.error ? generationErrorMessage(completed.error) : `任务${completed.status === "cancelled" ? "已取消" : "失败"}`);
}
}
lastStreamError = new Error("任务文本流连接提前结束");
} catch (error) {
if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (error instanceof TaskTextStreamFatalError) throw error;
lastStreamError = error;
}
await delay(intervalMs, options.signal);
}
throw new Error(lastStreamError instanceof Error ? `任务文本流同步失败:${lastStreamError.message}` : "任务执行超时,请稍后重试");
}
function asTaskTextStreamRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function numberValue(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function isTaskStatus(value: unknown): value is TaskStatus {
return value === "queued" || value === "running" || value === "succeeded" || value === "failed" || value === "cancelled";
}
class TaskTextStreamFatalError extends Error {
constructor(message: string) {
super(message);
this.name = "TaskTextStreamFatalError";
}
}
async function taskTextStreamHTTPError(response: Response) {
try {
const payload = await response.json() as { msg?: unknown };
if (typeof payload.msg === "string" && payload.msg.trim()) return payload.msg;
} catch {
// SSE 错误响应可能是空正文或网关 HTML,只返回状态码,避免泄露正文。
}
return `任务文本流请求失败(HTTP ${response.status})`;
}
function taskWaitTimeoutMs(task?: GenerationTask) {
const type = task?.type || "";
// 等待上限必须不小于后端同类任务的执行超时(分镜 20 分钟、视频 120 分钟,见后台
// 资源与策略),否则长任务会在前端被判超时、消息显示失败,而后端其实还在跑并最终成功。
if (type.includes("storyboard")) return 25 * 60 * 1000;
if (type.includes("video")) return 125 * 60 * 1000;
if (type.includes("image")) return 10 * 60 * 1000;
if (type.includes("text") || type.includes("audio")) return 12 * 60 * 1000;
return 10 * 60 * 1000;
}
function delay(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const timer = window.setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
window.clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
},
{ once: true },
);
});
}
function notifyCanvasTaskCreated(task: GenerationTask) {
if (typeof window === "undefined" || !task.projectId) return;
window.dispatchEvent(new CustomEvent("canvas:task-created", { detail: { task } }));
}