fix(canvas): 文本生成 - 修复分镜超时并贯通 Agent 与创作页流式返回

This commit is contained in:
ddCat
2026-08-27 22:39:30 +08:00
parent 482d8bdbc2
commit 7cb6aed27d
18 changed files with 944 additions and 44 deletions

View File

@@ -1665,8 +1665,7 @@ function toolCallToResponseInput(call: ResponseToolCall): ResponseInputMessage {
async function requestOnlineAgentModel(config: AiConfig, messages: ResponseInputMessage[], toolChoice: "auto" | "required", prompt: string, onDelta: (text: string) => void, promptCacheKey?: string) {
if (backendModelRuntimeRequired(config)) {
const result = await runBackendToolGenerationTask({ prompt, config, messages, tools: ONLINE_AGENT_TOOLS, toolChoice });
if (result.content.trim()) onDelta(result.content);
const result = await runBackendToolGenerationTask({ prompt, config, messages, tools: ONLINE_AGENT_TOOLS, toolChoice, onDelta });
return result;
}
return requestToolResponse(config, messages, ONLINE_AGENT_TOOLS, toolChoice, onDelta, { promptCacheKey });

View File

@@ -7,7 +7,6 @@ import {
} from "@/services/api/task-center";
const CINEMATIC_SESSION_POLL_INTERVAL_MS = 2000;
const CINEMATIC_SESSION_MAX_POLLS = 120;
type CinematicSessionWaitOptions = {
signal?: AbortSignal;
@@ -42,14 +41,15 @@ export function isAgentSessionPollingAbort(error: unknown) {
async function waitForCinematicAgentSession(initialDetail: AgentSessionDetail, options: CinematicSessionWaitOptions) {
let detail = initialDetail;
for (let attempt = 0; attempt < CINEMATIC_SESSION_MAX_POLLS; attempt += 1) {
// 后端任务策略负责生成超时和终态收敛;前端只在调用方 Abort 时停止监听,
// 避免用独立的固定轮询次数把仍在服务端执行的分镜误判为失败。
for (;;) {
throwIfAborted(options.signal);
if (detail.session.status === "completed") return detail;
if (detail.session.status === "failed") throw new Error(agentSessionFailureMessage(detail));
await abortableDelay(CINEMATIC_SESSION_POLL_INTERVAL_MS, options.signal);
detail = await queryAgentSession(initialDetail.session.id);
}
throw new Error("后端影视 Agent 会话超时");
}
function abortableDelay(ms: number, signal?: AbortSignal) {

View File

@@ -158,6 +158,7 @@ export function useCanvasStoryboard({
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...generationTaskMetadata(task), status: NODE_STATUS_LOADING } } : node));
const completed = await waitForGenerationTask(task.id, {
initialTask: task,
useTextEvents: true,
onTaskUpdate: (next) => setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...generationTaskMetadata(next), status: NODE_STATUS_LOADING } } : node)),
});
const result = storyboardRowsFromTask(completed);

View File

@@ -622,6 +622,7 @@ export default function CreatePage() {
signal: requestLifecycle.signal,
metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, ...referenceMetadata },
onTaskUpdate: bindTask,
onTextDelta: (text) => updateOriginAssistant((item) => ({ ...item, content: text })),
...retryContext,
}));
if (!result.text?.trim()) throw new Error("后端任务没有返回文本");

View File

@@ -52,6 +52,7 @@ type BackendGenerationTaskOptions = {
signal?: AbortSignal;
metadata?: Record<string, unknown>;
onTaskUpdate?: (task: GenerationTask) => void;
onTextDelta?: (text: string) => void;
localIdempotencyKey?: string;
localResumeOnly?: boolean;
clientOperationId?: string;
@@ -130,6 +131,7 @@ export async function runBackendToolGenerationTask(options: {
tools: ResponseFunctionTool[];
toolChoice: ToolChoice;
signal?: AbortSignal;
onDelta?: (text: string) => void;
}): Promise<ToolResponseResult> {
throwIfAborted(options.signal);
const logicalModelId = logicalModelIDForConfig(options.config);
@@ -149,7 +151,7 @@ export async function runBackendToolGenerationTask(options: {
metadata: { source: "canvas-online-agent" },
},
});
const completed = await waitForGenerationTask(task.id, { signal: options.signal, initialTask: task });
const completed = await waitForGenerationTask(task.id, { signal: options.signal, initialTask: task, onTextDelta: options.onDelta });
const result = parseBackendGenerationResult(completed);
return {
content: result.text || "",
@@ -399,7 +401,7 @@ async function prepareGenerationReferences({
}
async function createAndWaitGenerationTask(options: BackendGenerationTaskOptions, prepared: PreparedGenerationReferences, dependencies: GenerationTaskDependencies) {
const { projectId, mode, prompt, config, signal, metadata, onTaskUpdate } = options;
const { projectId, mode, prompt, config, signal, metadata, onTaskUpdate, onTextDelta } = options;
const videoOperation = generationOperation(options);
const workflow = resolveGenerationWorkflowExecution(config, mode);
const logicalModelId = workflow ? "" : logicalModelIDForConfig(config);
@@ -426,7 +428,7 @@ async function createAndWaitGenerationTask(options: BackendGenerationTaskOptions
},
});
onTaskUpdate?.(task);
const completed = await dependencies.waitTask(task.id, { signal, initialTask: task, onTaskUpdate });
const completed = await dependencies.waitTask(task.id, { signal, initialTask: task, onTaskUpdate, onTextDelta });
return parseBackendGenerationResult(completed);
}

View File

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

View File

@@ -0,0 +1,57 @@
export type TaskTextStreamEvent = {
event: string;
id?: number;
data?: unknown;
};
export type TaskTextStreamParser = {
buffer: string;
};
export function createTaskTextStreamParser(): TaskTextStreamParser {
return { buffer: "" };
}
export function consumeTaskTextStream(
parser: TaskTextStreamParser,
chunk: string,
onEvent: (event: TaskTextStreamEvent) => void,
flush = false,
) {
parser.buffer += chunk;
for (;;) {
const boundary = parser.buffer.match(/\r?\n\r?\n/);
if (!boundary) break;
const index = boundary.index ?? 0;
emitTaskTextStreamBlock(parser.buffer.slice(0, index), onEvent);
parser.buffer = parser.buffer.slice(index + boundary[0].length);
}
if (flush && parser.buffer.trim()) {
emitTaskTextStreamBlock(parser.buffer, onEvent);
parser.buffer = "";
}
}
function emitTaskTextStreamBlock(block: string, onEvent: (event: TaskTextStreamEvent) => void) {
let event = "message";
let id: number | undefined;
const data: string[] = [];
for (const line of block.split(/\r?\n/)) {
if (!line || line.startsWith(":")) continue;
const separator = line.indexOf(":");
const field = separator >= 0 ? line.slice(0, separator) : line;
const value = separator >= 0 ? line.slice(separator + 1).replace(/^ /, "") : "";
if (field === "event") event = value || "message";
else if (field === "id" && /^\d+$/.test(value)) id = Number(value);
else if (field === "data") data.push(value);
}
if (!data.length) return;
const raw = data.join("\n");
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("任务文本流返回了无效数据");
}
onEvent({ event, ...(id !== undefined ? { id } : {}), data: parsed });
}