fix(canvas): 文本生成 - 修复分镜超时并贯通 Agent 与创作页流式返回
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("后端任务没有返回文本");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
57
web/src/services/api/task-text-stream.ts
Normal file
57
web/src/services/api/task-text-stream.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user