fix(canvas): 文本生成 - 修复分镜超时并贯通 Agent 与创作页流式返回
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { DREAMINA_SUBMIT_ERROR_MESSAGES, generationErrorMessage } from "@/lib/generation-error";
|
||||
import { apiClient, request, type BackendEnvelope } from "@/services/api/request";
|
||||
import { apiBaseURL, apiClient, request, type BackendEnvelope } from "@/services/api/request";
|
||||
import { consumeTaskTextStream, createTaskTextStreamParser, type TaskTextStreamEvent } from "@/services/api/task-text-stream";
|
||||
import { recordDiagnosticEvent } from "@/services/diagnostics/client-diagnostics";
|
||||
import {
|
||||
deleteLocalDreaminaGenerationTask,
|
||||
@@ -100,6 +101,10 @@ export type TaskTextReplay = {
|
||||
textDraft?: string;
|
||||
finalText?: string;
|
||||
complete: boolean;
|
||||
status: TaskStatus;
|
||||
stage?: string;
|
||||
progress: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type AgentSession = {
|
||||
@@ -491,7 +496,17 @@ function safeTaskLogErrorCode(value: unknown) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function waitForGenerationTask(id: string, options?: { signal?: AbortSignal; intervalMs?: number; timeoutMs?: number; initialTask?: GenerationTask; onTaskUpdate?: (task: GenerationTask) => void }) {
|
||||
export type WaitForGenerationTaskOptions = {
|
||||
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 (isLocalDreaminaTaskId(id)) {
|
||||
try {
|
||||
const task = await waitForLocalGenerationTask(id, { signal: options?.signal });
|
||||
@@ -506,6 +521,7 @@ export async function waitForGenerationTask(id: string, options?: { signal?: Abo
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (options?.onTextDelta || options?.useTextEvents) return waitForGenerationTaskTextEvents(id, options);
|
||||
const startedAt = Date.now();
|
||||
const intervalMs = options?.intervalMs || 2000;
|
||||
let lastTask = options?.initialTask;
|
||||
@@ -544,6 +560,129 @@ export async function waitForGenerationTask(id: string, options?: { signal?: Abo
|
||||
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;
|
||||
let fullText = 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 response = await fetch(`${base}/tasks/${encodeURIComponent(id)}/text-events${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 || "";
|
||||
if (type.includes("storyboard")) return 13 * 60 * 1000;
|
||||
|
||||
Reference in New Issue
Block a user