Merge pull request '恢复画布 Agent 默认处理并修正生图终态与预览闪白' (#49) from codex/agent-feedback-recovery-20261105 into main
This commit is contained in:
commit
8bf8e349ca
47
app/(chat)/api/chat/_lib/image-generation-failure.ts
Normal file
47
app/(chat)/api/chat/_lib/image-generation-failure.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { saveMessages } from "@/lib/db/queries";
|
||||
import type { ImageGenerationStatusStreamPayload } from "@/lib/canvas/image-generation-status";
|
||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
||||
import { safeWriteStreamChunk, type StreamWriter, writeAssistantTextChunk, writeStructuredDataChunk } from "./stream";
|
||||
|
||||
export async function reportImageGenerationFailure(params: {
|
||||
writer: StreamWriter;
|
||||
chatId: string;
|
||||
userMessageId: string;
|
||||
messageId: string;
|
||||
error: unknown;
|
||||
label: string;
|
||||
}) {
|
||||
const failure = getRequestFailureDetails(params.error, "图片生成失败");
|
||||
const status: ImageGenerationStatusStreamPayload = {
|
||||
phase: "failed",
|
||||
userMessageId: params.userMessageId,
|
||||
requestId: params.messageId,
|
||||
failure,
|
||||
};
|
||||
const text = formatRequestFailure(failure, params.messageId);
|
||||
const parts = [
|
||||
{ type: "text", text },
|
||||
{ type: "data-imageGenerationStatus", data: status },
|
||||
];
|
||||
writeStructuredDataChunk(params.writer, "data-imageGenerationStatus", status, `${params.label}:status`);
|
||||
safeWriteStreamChunk(params.writer, {
|
||||
type: "data-appendMessage",
|
||||
data: JSON.stringify({ id: params.messageId, role: "assistant", parts }),
|
||||
}, params.label);
|
||||
writeAssistantTextChunk(params.writer, "<!-- IMAGE_GENERATION_END -->", `${params.label}:end`);
|
||||
// History carries the same terminal state when the browser loses the stream.
|
||||
await saveMessages({ messages: [{
|
||||
id: params.messageId,
|
||||
chatId: params.chatId,
|
||||
role: "assistant",
|
||||
parts,
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: null,
|
||||
}] });
|
||||
return text;
|
||||
}
|
||||
@ -208,6 +208,7 @@ export function writeImageGenerationStatusChunk(
|
||||
writer: StreamWriter,
|
||||
params: {
|
||||
phase: "queued" | "processing" | "finalizing" | "failed" | "completed";
|
||||
userMessageId?: string;
|
||||
providerType?: string | null;
|
||||
modelName?: string | null;
|
||||
imageCount?: number | null;
|
||||
@ -223,6 +224,7 @@ export function writeImageGenerationStatusChunk(
|
||||
"data-imageGenerationStatus",
|
||||
{
|
||||
phase: params.phase,
|
||||
userMessageId: params.userMessageId,
|
||||
providerType: normalizeImageLoadingProviderType(params.providerType),
|
||||
modelName: String(params.modelName || "").trim() || undefined,
|
||||
imageCount:
|
||||
|
||||
@ -426,6 +426,8 @@ import {
|
||||
type VideoModeType,
|
||||
} from "./_lib/video-models";
|
||||
|
||||
import { reportImageGenerationFailure } from "./_lib/image-generation-failure";
|
||||
|
||||
const chatApiLogger = createLogger("chat.route");
|
||||
const createUIMessageStream: typeof createAiUIMessageStream = (options) =>
|
||||
createAiUIMessageStream({ ...options, execute: (writer) => trackRuntimeWork(() => options.execute(writer)) });
|
||||
@ -446,16 +448,6 @@ function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: stri
|
||||
throw new Error(`${modelLabel} 未返回可用图片:${reason.slice(0, 160)}`);
|
||||
}
|
||||
|
||||
function getPublicImageGenerationErrorText(error: unknown, requestId: string) {
|
||||
const message = error instanceof Error ? error.message : String(error || "");
|
||||
const publicMessage = getUserFacingImageGenerationErrorMessage(error);
|
||||
if (!message || (publicMessage === message && INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message))) {
|
||||
return `图片生成失败,请稍后重试。若连续失败,请联系管理员。\n\n请求编号:${requestId}`;
|
||||
}
|
||||
return `${publicMessage}\n\n请求编号:${requestId}`;
|
||||
}
|
||||
|
||||
|
||||
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
|
||||
1,
|
||||
Math.min(8, Number(process.env.AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT || 6) || 6),
|
||||
@ -5688,6 +5680,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
modelName: resolvedModelRef,
|
||||
imageCount: replacementReferences.length,
|
||||
@ -6030,6 +6023,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase:
|
||||
queueStatus.status === "running"
|
||||
? "processing"
|
||||
@ -6185,6 +6179,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: successCount > 0 ? "completed" : "failed",
|
||||
modelName: resolvedModelDisplayName,
|
||||
imageCount: successCount,
|
||||
@ -6417,6 +6412,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
modelName:
|
||||
selectedAgentPreset.defaultModel?.displayName ||
|
||||
@ -6968,6 +6964,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase:
|
||||
queueStatus.status === "running"
|
||||
? "processing"
|
||||
@ -7293,6 +7290,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase:
|
||||
queueStatus.status === "running"
|
||||
? "processing"
|
||||
@ -7521,6 +7519,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase:
|
||||
queueStatus.status === "running"
|
||||
? "processing"
|
||||
@ -7839,6 +7838,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: successCount > 0 ? "completed" : "failed",
|
||||
modelName:
|
||||
selectedAgentPreset.defaultModel?.displayName ||
|
||||
@ -8048,6 +8048,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
providerType: requestedModelProviderType,
|
||||
modelName: finalModelId,
|
||||
@ -8086,6 +8087,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
...directImageStatusPayload,
|
||||
},
|
||||
@ -8178,6 +8180,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: requestedModelProviderType,
|
||||
modelName: directImageResult.model,
|
||||
@ -8198,42 +8201,16 @@ export async function POST(request: Request) {
|
||||
);
|
||||
} catch (directToolError) {
|
||||
chatApiLogger.error("[Chat API] Direct agent image generation failed:", directToolError);
|
||||
const errorMessage = getDirectAgentToolErrorMessage(
|
||||
"generate_image",
|
||||
directToolError,
|
||||
);
|
||||
await saveAssistantTextOnlyMessage({
|
||||
chatId: id,
|
||||
messageId: assistantMessageId,
|
||||
text: errorMessage,
|
||||
const errorMessage = await reportImageGenerationFailure({
|
||||
writer, chatId: id, userMessageId: message.id,
|
||||
messageId: assistantMessageId, error: directToolError,
|
||||
label: "agent:generate_image:error",
|
||||
});
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
errorMessage,
|
||||
"agent:generate_image:error",
|
||||
assistantMessageId,
|
||||
);
|
||||
if (contextPayload?.source === "canvas") {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
phase: "failed",
|
||||
providerType: requestedModelProviderType,
|
||||
modelName: finalModelId,
|
||||
},
|
||||
"agent:generate_image:status-failed",
|
||||
);
|
||||
}
|
||||
writeAgentProgressChunk(
|
||||
writer,
|
||||
buildDirectAgentImageGenerateFailedProgress(errorMessage),
|
||||
"agent:generate_image:progress-failed",
|
||||
);
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
"<!-- IMAGE_GENERATION_END -->",
|
||||
"agent:generate_image:end-error",
|
||||
);
|
||||
} finally {
|
||||
stopDirectImageKeepalive();
|
||||
if (stopDirectImageStatus) {
|
||||
@ -9195,6 +9172,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
providerType: (languageModel as any).provider,
|
||||
modelName: currentModelConfig?.displayName || actualModelId,
|
||||
@ -9282,6 +9260,7 @@ export async function POST(request: Request) {
|
||||
chatApiLogger.warn("[Chat API] Record Gemini fallback health failure failed:", healthError);
|
||||
});
|
||||
chatApiLogger.error(`[Chat API] ❌ Fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
||||
}
|
||||
}
|
||||
|
||||
@ -9356,6 +9335,7 @@ export async function POST(request: Request) {
|
||||
chatApiLogger.warn("[Chat API] Record Gemini cross-provider health failure failed:", healthError);
|
||||
});
|
||||
chatApiLogger.error(`[Chat API] ❌ Gemini cross-provider fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9423,6 +9403,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: (languageModel as any).provider,
|
||||
modelName: usedModelDisplayName,
|
||||
@ -9487,6 +9468,14 @@ export async function POST(request: Request) {
|
||||
}
|
||||
} catch (error) {
|
||||
chatApiLogger.error("[Chat API] Gemini generation error:", error);
|
||||
let finalError = error;
|
||||
if (!shouldFailoverAiModelError(error)) {
|
||||
await reportImageGenerationFailure({
|
||||
writer, chatId: id, userMessageId: message.id,
|
||||
messageId: generateUUID(), error, label: "gemini:error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
chatApiLogger.info("[Chat API] Attempting failover to Volcano Vision...");
|
||||
|
||||
// 故障转移:尝试使用火山引擎模型
|
||||
@ -9564,6 +9553,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: "volcano-vision",
|
||||
modelName: volcanoModel.model.displayName || volcanoModel.model.modelId,
|
||||
@ -9629,47 +9619,14 @@ export async function POST(request: Request) {
|
||||
chatApiLogger.info("[Chat API] No Volcano Vision model available for failover");
|
||||
} catch (failoverError) {
|
||||
chatApiLogger.error("[Chat API] Failover to Volcano Vision also failed:", failoverError);
|
||||
finalError = failoverError;
|
||||
}
|
||||
|
||||
// 如果故障转移也失败,返回错误消息
|
||||
const errorMessageId = generateUUID();
|
||||
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
errorText,
|
||||
"gemini:error",
|
||||
errorMessageId,
|
||||
);
|
||||
|
||||
// 🆕 图片生成结束标记(失败)
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
"<!-- IMAGE_GENERATION_END -->",
|
||||
"gemini:image-end-failed",
|
||||
);
|
||||
|
||||
// 尝试保存错误消息
|
||||
try {
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
id: errorMessageId,
|
||||
chatId: id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: errorText }],
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (saveError) {
|
||||
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
|
||||
}
|
||||
await reportImageGenerationFailure({
|
||||
writer, chatId: id, userMessageId: message.id,
|
||||
messageId: generateUUID(), error: finalError, label: "gemini:error",
|
||||
});
|
||||
} finally {
|
||||
stopKeepalive?.();
|
||||
}
|
||||
@ -9867,6 +9824,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "queued",
|
||||
providerType: "fal-image",
|
||||
modelName: modelDisplayName,
|
||||
@ -9973,6 +9931,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
providerType: (languageModel as any).provider,
|
||||
modelName: alibabaModelConfig?.displayName || actualModelId,
|
||||
@ -10070,6 +10029,7 @@ export async function POST(request: Request) {
|
||||
chatApiLogger.warn("[Chat API] Record image fallback health failure failed:", healthError);
|
||||
});
|
||||
chatApiLogger.error(`[Chat API] ❌ Alibaba fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10204,6 +10164,7 @@ export async function POST(request: Request) {
|
||||
chatApiLogger.warn("[Chat API] Record image cross-provider health failure failed:", healthError);
|
||||
});
|
||||
chatApiLogger.error(`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError);
|
||||
if (!shouldFailoverAiModelError(crossError)) throw crossError;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10271,6 +10232,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: (languageModel as any).provider,
|
||||
modelName: usedModelDisplayName,
|
||||
@ -10335,43 +10297,10 @@ export async function POST(request: Request) {
|
||||
chatApiLogger.error("[Chat API] Alibaba generation error:", error);
|
||||
|
||||
// 返回错误消息
|
||||
const errorMessageId = generateUUID();
|
||||
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
errorText,
|
||||
"alibaba:error",
|
||||
errorMessageId,
|
||||
);
|
||||
|
||||
// 🆕 图片生成结束标记(失败)
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
"<!-- IMAGE_GENERATION_END -->",
|
||||
"alibaba:image-end-failed",
|
||||
);
|
||||
|
||||
try {
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
id: errorMessageId,
|
||||
chatId: id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: errorText }],
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (saveError) {
|
||||
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
|
||||
}
|
||||
await reportImageGenerationFailure({
|
||||
writer, chatId: id, userMessageId: message.id,
|
||||
messageId: generateUUID(), error, label: "alibaba:error",
|
||||
});
|
||||
} finally {
|
||||
stopKeepalive?.();
|
||||
}
|
||||
@ -10500,6 +10429,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
providerType: "volcano-vision",
|
||||
modelName: volcanoModelConfig?.displayName || actualModelId,
|
||||
@ -10545,6 +10475,7 @@ export async function POST(request: Request) {
|
||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||
}
|
||||
chatApiLogger.error(`[Chat API] ❌ Volcano fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
||||
}
|
||||
}
|
||||
if (!fallbackSuccess) {
|
||||
@ -10711,6 +10642,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: "volcano-vision",
|
||||
modelName: usedModelDisplayName,
|
||||
@ -10775,44 +10707,10 @@ export async function POST(request: Request) {
|
||||
}
|
||||
} catch (error) {
|
||||
chatApiLogger.error("[Chat API] Volcano Vision generation error:", error);
|
||||
const errorMessageId = generateUUID();
|
||||
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
errorText,
|
||||
"volcano:error",
|
||||
errorMessageId,
|
||||
);
|
||||
|
||||
// 🆕 图片生成结束标记(失败)
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
"<!-- IMAGE_GENERATION_END -->",
|
||||
"volcano:image-end-failed",
|
||||
);
|
||||
|
||||
// 尝试保存错误消息
|
||||
try {
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
id: errorMessageId,
|
||||
chatId: id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: errorText }],
|
||||
attachments: [], // 必须提供 attachments 字段
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (saveError) {
|
||||
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
|
||||
}
|
||||
await reportImageGenerationFailure({
|
||||
writer, chatId: id, userMessageId: message.id,
|
||||
messageId: generateUUID(), error, label: "volcano:error",
|
||||
});
|
||||
} finally {
|
||||
stopKeepalive?.();
|
||||
}
|
||||
@ -11385,6 +11283,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
dataStream as unknown as StreamWriter,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
providerType: currentModelConfig?.providerId ? resolvedProviderType || providerType : providerType,
|
||||
modelName: currentModelConfig?.displayName || actualModelId,
|
||||
@ -11476,6 +11375,7 @@ export async function POST(request: Request) {
|
||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||
}
|
||||
chatApiLogger.error(`[Chat API] ❌ OpenAI-compatible fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
||||
}
|
||||
}
|
||||
|
||||
@ -11647,6 +11547,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
dataStream as unknown as StreamWriter,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: resolvedProviderType || providerType,
|
||||
modelName: usedModelDisplayName,
|
||||
@ -11706,54 +11607,10 @@ export async function POST(request: Request) {
|
||||
} catch (error) {
|
||||
chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error);
|
||||
|
||||
const errorMessageId = generateUUID();
|
||||
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||
|
||||
dataStream.write({
|
||||
type: "data-appendMessage",
|
||||
data: JSON.stringify({
|
||||
id: errorMessageId,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: errorText }],
|
||||
}),
|
||||
});
|
||||
dataStream.write({
|
||||
type: "data-appendMessage",
|
||||
data: JSON.stringify({
|
||||
id: generateUUID(),
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "<!-- IMAGE_GENERATION_END -->" }],
|
||||
}),
|
||||
});
|
||||
if (contextPayload?.source === "canvas") {
|
||||
writeImageGenerationStatusChunk(
|
||||
dataStream as unknown as StreamWriter,
|
||||
{
|
||||
phase: "failed",
|
||||
providerType: resolvedProviderType || providerType,
|
||||
modelName: currentModelConfig?.displayName || actualModelId,
|
||||
imageCount: effectiveImageParams?.imageCount || 1,
|
||||
},
|
||||
"openai-compat:image-status-failed",
|
||||
);
|
||||
}
|
||||
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
id: errorMessageId,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: errorText }],
|
||||
createdAt: new Date(),
|
||||
attachments: [],
|
||||
chatId: id,
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: null,
|
||||
},
|
||||
],
|
||||
await reportImageGenerationFailure({
|
||||
writer: dataStream as unknown as StreamWriter,
|
||||
chatId: id, userMessageId: message.id,
|
||||
messageId: generateUUID(), error, label: "openai-compat:error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -67,6 +67,7 @@ import { toast } from "sonner";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
||||
import { findImageGenerationFailure } from "@/lib/canvas/image-generation-status";
|
||||
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
||||
import {
|
||||
fetchUploadWithRetry,
|
||||
@ -3340,6 +3341,7 @@ export function CanvasChatPanel({
|
||||
const activeChatIdRef = useRef<string>(requestedChatSessionId || chatId);
|
||||
const activeMessagesRef = useRef<ChatMessage[]>([]);
|
||||
const inFlightChatIdRef = useRef<string | null>(null);
|
||||
const imageGenerationUserMessageIdsRef = useRef<Record<string, string>>({});
|
||||
const inFlightRawChatIdRef = useRef<string | null>(null);
|
||||
const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]);
|
||||
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
|
||||
@ -3642,17 +3644,20 @@ export function CanvasChatPanel({
|
||||
|
||||
const setStreamImageGenerationStateForChat = useCallback(
|
||||
(chatSessionId: string, next: ImageGenerationStatusStreamPayload | null) => {
|
||||
const userMessageId = imageGenerationUserMessageIdsRef.current[chatSessionId];
|
||||
if (next?.userMessageId && userMessageId && next.userMessageId !== userMessageId) return;
|
||||
setStreamImageGenerationStateByChatId((prev) => ({
|
||||
...prev,
|
||||
[chatSessionId]: next,
|
||||
[chatSessionId]: next ? { ...next, userMessageId: next.userMessageId || userMessageId } : null,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string) => {
|
||||
const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string, preserveFailure = false) => {
|
||||
setStreamImageGenerationStateByChatId((prev) => {
|
||||
if (!(chatSessionId in prev)) return prev;
|
||||
if (preserveFailure && prev[chatSessionId]?.phase === "failed") return prev;
|
||||
const next = { ...prev };
|
||||
delete next[chatSessionId];
|
||||
return next;
|
||||
@ -6089,7 +6094,7 @@ export function CanvasChatPanel({
|
||||
const activeComposerMode = unifiedImageAgent && (rawComposerMode === "image" || rawComposerMode === "video")
|
||||
? "agent" : rawComposerMode;
|
||||
const isImageAgentComposer = unifiedImageAgent && activeComposerMode === "agent";
|
||||
const [imageAgentEnabled, setImageAgentEnabled] = useState(false);
|
||||
const [imageAgentEnabled, setImageAgentEnabled] = useState(true);
|
||||
const imageAgentEnabledRef = useRef(imageAgentEnabled);
|
||||
imageAgentEnabledRef.current = imageAgentEnabled;
|
||||
const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video";
|
||||
@ -8018,6 +8023,8 @@ export function CanvasChatPanel({
|
||||
api: "/api/chat",
|
||||
fetch: fetchWithErrorHandlers,
|
||||
async prepareSendMessagesRequest(request) {
|
||||
const userMessageId = request.messages.findLast((item) => item.role === "user")?.id;
|
||||
if (userMessageId) imageGenerationUserMessageIdsRef.current[request.id] = userMessageId;
|
||||
// 从 canvas-{uuid} 格式中提取纯 UUID,后端 schema 要求纯 UUID
|
||||
const rawId = request.id.startsWith('canvas-')
|
||||
? request.id.replace('canvas-', '')
|
||||
@ -8130,20 +8137,27 @@ export function CanvasChatPanel({
|
||||
const payload: ImageGenerationStatusStreamPayload =
|
||||
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const userMessageId = imageGenerationUserMessageIdsRef.current[eventChatId];
|
||||
if (payload.userMessageId && userMessageId && payload.userMessageId !== userMessageId) return;
|
||||
if (payload.phase === "completed") {
|
||||
setStreamImageGenerationStateForChat(eventChatId, {
|
||||
phase: "finalizing",
|
||||
userMessageId: payload.userMessageId,
|
||||
providerType: payload.providerType,
|
||||
modelName: getAgentSafeModelName(payload.modelName),
|
||||
imageCount: Number(payload.imageCount || 0) || undefined,
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
if (payload.userMessageId && imageGenerationUserMessageIdsRef.current[eventChatId] !== payload.userMessageId) return;
|
||||
clearStreamImageGenerationStateForChat(eventChatId);
|
||||
}, 30_000);
|
||||
return;
|
||||
}
|
||||
setStreamImageGenerationStateForChat(eventChatId, {
|
||||
phase: payload.phase || "processing",
|
||||
userMessageId: payload.userMessageId,
|
||||
requestId: payload.requestId,
|
||||
failure: payload.failure,
|
||||
providerType: payload.providerType,
|
||||
modelName: getAgentSafeModelName(payload.modelName),
|
||||
imageCount: Number(payload.imageCount || 0) || undefined,
|
||||
@ -8411,6 +8425,11 @@ export function CanvasChatPanel({
|
||||
)
|
||||
: false;
|
||||
const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : [];
|
||||
const failureStatus = findImageGenerationFailure(
|
||||
[{ role: messageData.role, parts: normalizedParts }],
|
||||
imageGenerationUserMessageIdsRef.current[eventChatId],
|
||||
);
|
||||
if (failureStatus) setStreamImageGenerationStateForChat(eventChatId, failureStatus);
|
||||
const normalizedAttachments = Array.isArray(messageData.attachments)
|
||||
? messageData.attachments
|
||||
: [];
|
||||
@ -8479,7 +8498,7 @@ export function CanvasChatPanel({
|
||||
(hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) &&
|
||||
!shouldDeferClearForActiveMedia
|
||||
) {
|
||||
clearStreamImageGenerationStateForChat(eventChatId);
|
||||
clearStreamImageGenerationStateForChat(eventChatId, true);
|
||||
}
|
||||
|
||||
if (!isEventForActiveChat) {
|
||||
@ -9202,7 +9221,7 @@ export function CanvasChatPanel({
|
||||
|
||||
useEffect(() => {
|
||||
const streamState = streamImageGenerationStateByChatId[activeChatId];
|
||||
if (!rawChatId || !streamState) return;
|
||||
if (!rawChatId || !streamState || (streamState.phase === "failed" && streamState.failure?.outcome !== "unknown")) return;
|
||||
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
@ -9226,6 +9245,13 @@ export function CanvasChatPanel({
|
||||
const streamState = streamImageGenerationStateByChatId[activeChatId];
|
||||
if (!streamState || messages.length === 0) return;
|
||||
|
||||
const failure = findImageGenerationFailure(messages, streamState.userMessageId || "");
|
||||
if (failure && streamState.phase !== "failed") {
|
||||
setStreamImageGenerationStateForChat(activeChatId, failure);
|
||||
return;
|
||||
}
|
||||
if (streamState.phase === "failed" && streamState.failure?.outcome !== "unknown") return;
|
||||
|
||||
const lastUserIndex = [...messages]
|
||||
.map((message) => message.role)
|
||||
.lastIndexOf("user");
|
||||
@ -9242,8 +9268,7 @@ export function CanvasChatPanel({
|
||||
.join("\n");
|
||||
return (
|
||||
extractAssistantImageUrls(textContent).length > 0 ||
|
||||
extractVideoUrls(textContent).length > 0 ||
|
||||
/失败|超时|重试|error|failed/i.test(textContent)
|
||||
extractVideoUrls(textContent).length > 0
|
||||
);
|
||||
});
|
||||
|
||||
@ -9264,6 +9289,7 @@ export function CanvasChatPanel({
|
||||
messages,
|
||||
streamImageGenerationStateByChatId,
|
||||
clearStreamImageGenerationStateForChat,
|
||||
setStreamImageGenerationStateForChat,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -13328,6 +13354,7 @@ export function CanvasChatPanel({
|
||||
});
|
||||
inFlightChatIdRef.current = activeChatId;
|
||||
inFlightRawChatIdRef.current = rawChatId;
|
||||
imageGenerationUserMessageIdsRef.current[activeChatId] = userMessageId;
|
||||
clearStreamImageGenerationStateForChat(activeChatId);
|
||||
clearAgentProgressStateForChat(activeChatId);
|
||||
if (shouldShowOptimisticImageGeneration) {
|
||||
@ -15097,7 +15124,7 @@ export function CanvasChatPanel({
|
||||
!hasGenerationEnd &&
|
||||
Boolean(currentModel || activeComposerMode === "agent"));
|
||||
const hasActiveTypewriter = Object.keys(activeTypingSections).length > 0;
|
||||
const shouldRenderImageLoading = shouldShowImageLoading && !hasActiveTypewriter;
|
||||
const shouldRenderImageLoading = shouldShowImageLoading && streamImageGenerationState?.phase !== "failed" && !hasActiveTypewriter;
|
||||
const loadingImageCount = streamImageGenerationState?.imageCount || imageParams.imageCount || 1;
|
||||
const loadingProviderType =
|
||||
streamImageGenerationState?.providerType ||
|
||||
@ -15160,9 +15187,9 @@ export function CanvasChatPanel({
|
||||
phase === "queued"
|
||||
? "等待开始生成"
|
||||
: phase === "finalizing"
|
||||
? "正在润饰细节"
|
||||
? "正在保存生成结果"
|
||||
: phase === "failed"
|
||||
? "生成失败"
|
||||
? streamState.failure?.message || "生成失败"
|
||||
: "正在创建图片";
|
||||
const status = phase === "failed" ? "failed" : "generating";
|
||||
const currentNodes = useCanvasStore.getState().nodes;
|
||||
@ -15218,14 +15245,10 @@ export function CanvasChatPanel({
|
||||
canvasGenerationPlaceholder: true,
|
||||
chatId: activeChatId,
|
||||
detailProgressLabel: label,
|
||||
generationProgress:
|
||||
phase === "queued"
|
||||
? 12
|
||||
: phase === "finalizing"
|
||||
? 96
|
||||
: phase === "failed"
|
||||
? 100
|
||||
: undefined,
|
||||
generationProgress: undefined,
|
||||
generationFailure: streamState.failure,
|
||||
generationRequestId: streamState.requestId,
|
||||
generationUserMessageId: streamState.userMessageId,
|
||||
modelName: streamState.modelName || currentModel?.displayName || undefined,
|
||||
providerType: streamState.providerType || currentModel?.providerType || undefined,
|
||||
placementMode: "anchored-generation",
|
||||
@ -15319,6 +15342,7 @@ export function CanvasChatPanel({
|
||||
|
||||
if (
|
||||
message.role === "assistant" &&
|
||||
!rawParts.some((part: any) => part.type === "data-imageGenerationStatus" && part.data?.failure) &&
|
||||
isInternalCanvasImageGenerationFailureText(cleanedParts)
|
||||
) {
|
||||
return acc;
|
||||
|
||||
@ -152,16 +152,7 @@ export function shouldRetryCanvasViewOperationAfterRefresh(
|
||||
);
|
||||
}
|
||||
|
||||
export type ImageGenerationStatusStreamPayload = {
|
||||
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
|
||||
providerType?: string;
|
||||
modelName?: string;
|
||||
imageCount?: number;
|
||||
queuePosition?: number;
|
||||
sameModelRunningCount?: number;
|
||||
concurrencyLimit?: number;
|
||||
estimatedWaitMs?: number;
|
||||
};
|
||||
export type { ImageGenerationStatusStreamPayload } from "@/lib/canvas/image-generation-status";
|
||||
|
||||
export type AgentTargetLockRefPreview = {
|
||||
url: string;
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { CanvasPreviewImage } from "./image-node/canvas-preview-image";
|
||||
import { canvasImagePreviewWidth } from "@/lib/canvas/image-preview-width";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||
|
||||
import {
|
||||
@ -2502,13 +2504,13 @@ export function ImageNode({
|
||||
const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05;
|
||||
const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640;
|
||||
const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600;
|
||||
return Math.max(
|
||||
return canvasImagePreviewWidth(Math.max(
|
||||
minWidth,
|
||||
Math.min(
|
||||
maxWidth,
|
||||
Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier)
|
||||
)
|
||||
);
|
||||
), maxWidth);
|
||||
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
|
||||
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
|
||||
const displayImageSrc = useMemo(
|
||||
@ -8399,12 +8401,13 @@ export function ImageNode({
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
|
||||
<img
|
||||
<CanvasPreviewImage
|
||||
sourceKey={data.src}
|
||||
alt={displayTitle}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
||||
@ -8428,12 +8431,13 @@ export function ImageNode({
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
|
||||
<img
|
||||
<CanvasPreviewImage
|
||||
sourceKey={compareSource}
|
||||
alt={`${displayTitle}-原图`}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(compareSource);
|
||||
@ -8478,7 +8482,8 @@ export function ImageNode({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
<CanvasPreviewImage
|
||||
sourceKey={data.src}
|
||||
alt={displayTitle}
|
||||
className={cn(
|
||||
"h-full w-full bg-slate-50 object-contain dark:bg-zinc-900",
|
||||
@ -8490,7 +8495,7 @@ export function ImageNode({
|
||||
)}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
onClick={
|
||||
isThisNodePicking && !boxCutoutMode
|
||||
? handlePickerClick
|
||||
@ -9420,6 +9425,8 @@ export function ImageNode({
|
||||
) : (
|
||||
<ImageNodeEmptyState
|
||||
detailProgressLabel={detailProgressLabel}
|
||||
failure={data.metadata?.generationFailure}
|
||||
requestId={data.metadata?.generationRequestId}
|
||||
fileInputRef={fileInputRef}
|
||||
generationProgress={generationProgress}
|
||||
onImageUpload={handleImageUpload}
|
||||
|
||||
32
components/canvas/nodes/image-node/canvas-preview-image.tsx
Normal file
32
components/canvas/nodes/image-node/canvas-preview-image.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, type ImgHTMLAttributes, useState } from "react";
|
||||
|
||||
type Props = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & { src: string; sourceKey: string };
|
||||
|
||||
export const CanvasPreviewImage = forwardRef<HTMLImageElement, Props>(function CanvasPreviewImage(
|
||||
{ src, sourceKey, onLoad, style, ...props }, ref,
|
||||
) {
|
||||
const [displayed, setDisplayed] = useState({ sourceKey, src });
|
||||
const visibleSrc = displayed.sourceKey === sourceKey ? displayed.src : src;
|
||||
const sources = visibleSrc === src ? [src] : [visibleSrc, src];
|
||||
return <>{sources.map(candidate => {
|
||||
const visible = candidate === visibleSrc;
|
||||
return <img
|
||||
{...props}
|
||||
key={candidate}
|
||||
ref={visible ? ref : undefined}
|
||||
src={candidate}
|
||||
alt={visible ? props.alt : ""}
|
||||
aria-hidden={visible ? props["aria-hidden"] : true}
|
||||
style={visible ? style : { ...style, position: "absolute", inset: 0, opacity: 0, pointerEvents: "none" }}
|
||||
onLoad={event => {
|
||||
const image = event.currentTarget;
|
||||
onLoad?.(event);
|
||||
void image.decode().then(() => {
|
||||
if (image.isConnected && candidate === src) setDisplayed({ sourceKey, src: candidate });
|
||||
}).catch(() => undefined);
|
||||
}}
|
||||
/>;
|
||||
})}</>;
|
||||
});
|
||||
@ -3,11 +3,14 @@
|
||||
import { AlertCircle, ImagePlus, Loader2 } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
|
||||
import type { RequestFailureDetails } from "@/lib/request-error";
|
||||
|
||||
interface ImageNodeEmptyStateProps {
|
||||
detailProgressLabel?: string;
|
||||
fileInputRef: React.Ref<HTMLInputElement>;
|
||||
generationProgress?: number | null;
|
||||
failure?: RequestFailureDetails;
|
||||
requestId?: string;
|
||||
onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
status?: string;
|
||||
@ -52,6 +55,8 @@ export function ImageNodeEmptyState({
|
||||
detailProgressLabel,
|
||||
fileInputRef,
|
||||
generationProgress,
|
||||
failure,
|
||||
requestId,
|
||||
onImageUpload,
|
||||
onSelectFile,
|
||||
status,
|
||||
@ -89,7 +94,7 @@ export function ImageNodeEmptyState({
|
||||
if (status === "failed") {
|
||||
const failedTitle = detailProgressLabel || "生成失败";
|
||||
return (
|
||||
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#fff7f8] px-6 text-center dark:bg-red-950/40">
|
||||
<div role="alert" className="flex h-full w-full flex-col items-center justify-center overflow-auto bg-[#fff7f8] px-6 text-center dark:bg-red-950/40">
|
||||
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)]">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
</span>
|
||||
@ -97,8 +102,9 @@ export function ImageNodeEmptyState({
|
||||
{failedTitle}
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-[#b77480]">
|
||||
{failedTitle.includes("上传") ? "请重新选择文件" : "请重新上传或再试一次"}
|
||||
{failure?.recommendation || (failedTitle.includes("上传") ? "请重新选择文件" : "请查看对话中的错误原因和处理建议")}
|
||||
</span>
|
||||
{requestId ? <span className="mt-2 select-text break-all text-xs text-[#b77480]">请求编号:{requestId}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { BloubAvatar } from "@/components/bloub-avatar";
|
||||
import type { StateId } from "@/lib/bloub-official/states";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -26,193 +25,17 @@ interface GenerationDotPlaceholderProps {
|
||||
const PHASE_TITLES: Record<GenerationPhase, string> = {
|
||||
idle: "正在创建图片",
|
||||
queued: "等待开始生成",
|
||||
processing: "生成初稿中",
|
||||
finalizing: "正在润饰细节",
|
||||
processing: "等待模型返回图片",
|
||||
finalizing: "正在保存生成结果",
|
||||
failed: "生成失败",
|
||||
completed: "生成完成",
|
||||
};
|
||||
|
||||
const GENERATION_STATUS_TITLES = [
|
||||
"正在理解画面关系",
|
||||
"正在读取参考图片",
|
||||
"正在分析主体轮廓",
|
||||
"正在识别关键元素",
|
||||
"正在整理构图线索",
|
||||
"正在确认画面比例",
|
||||
"正在匹配视觉风格",
|
||||
"正在提取材质细节",
|
||||
"正在梳理光影方向",
|
||||
"正在构建初始画面",
|
||||
"正在生成第一版构图",
|
||||
"正在安排主体位置",
|
||||
"正在校准画面重心",
|
||||
"正在补全背景层次",
|
||||
"正在保持主体一致",
|
||||
"正在优化边缘结构",
|
||||
"正在调整透视关系",
|
||||
"正在细化空间层次",
|
||||
"正在增强画面清晰度",
|
||||
"正在统一色彩倾向",
|
||||
"正在检查参考一致性",
|
||||
"正在处理局部细节",
|
||||
"正在补充纹理信息",
|
||||
"正在修正不自然区域",
|
||||
"正在平衡明暗对比",
|
||||
"正在优化产品质感",
|
||||
"正在调整阴影落点",
|
||||
"正在整理背景干扰",
|
||||
"正在增强真实感",
|
||||
"正在重绘细小结构",
|
||||
"正在合成高清结果",
|
||||
"正在检查主体完整性",
|
||||
"正在优化画面边缘",
|
||||
"正在处理反光细节",
|
||||
"正在提升材质表现",
|
||||
"正在微调构图节奏",
|
||||
"正在清理多余噪点",
|
||||
"正在强化视觉焦点",
|
||||
"正在校正颜色偏差",
|
||||
"正在保留参考特征",
|
||||
"正在生成高分辨率版本",
|
||||
"正在压实画面层次",
|
||||
"正在修饰局部瑕疵",
|
||||
"正在检查文字与图形",
|
||||
"正在稳定画面结构",
|
||||
"正在优化细节密度",
|
||||
"正在完善背景过渡",
|
||||
"正在增强主体质感",
|
||||
"正在统一整体光感",
|
||||
"正在检查比例关系",
|
||||
"正在修正边缘锯齿",
|
||||
"正在提升清晰观感",
|
||||
"正在处理最后细节",
|
||||
"正在准备预览结果",
|
||||
"正在同步生成进度",
|
||||
"正在确认输出尺寸",
|
||||
"正在整理结果文件",
|
||||
"正在校验图片可用性",
|
||||
"正在生成最终预览",
|
||||
"正在完成高清渲染",
|
||||
"正在保存生成结果",
|
||||
"正在写入画布节点",
|
||||
"正在同步到对话框",
|
||||
"正在检查结果完整性",
|
||||
"正在收尾生成任务",
|
||||
"正在等待服务商返回",
|
||||
"正在保持连接状态",
|
||||
"正在确认生成结果",
|
||||
"正在拉取图片地址",
|
||||
"正在准备展示图片",
|
||||
"正在完成最后处理",
|
||||
"正在刷新画布内容",
|
||||
"正在整理图片信息",
|
||||
"正在完成本次生成",
|
||||
"正在即将展示结果",
|
||||
];
|
||||
|
||||
const LONG_WAIT_TITLE_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
const LONG_WAIT_STATUS_TITLES = [
|
||||
"服务繁忙,排队处理中",
|
||||
"高峰期生成较慢,请耐心等候",
|
||||
"仍在保持连接,等待模型返回",
|
||||
"结果返回后会自动展示",
|
||||
];
|
||||
const STATUS_TITLE_DURATIONS_MS = GENERATION_STATUS_TITLES.map((_, index) => {
|
||||
const rhythm = [3600, 5200, 4100, 6100, 3300, 4700, 5700, 3900, 6800, 4400];
|
||||
return rhythm[index % rhythm.length];
|
||||
});
|
||||
const TYPEWRITER_START_DELAY_MS = 120;
|
||||
const TYPEWRITER_BASE_STEP_MS = 46;
|
||||
|
||||
function clampProgress(value: number) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function estimateProgress(phase: GenerationPhase, elapsedMs: number) {
|
||||
if (phase === "completed" || phase === "failed") return 100;
|
||||
if (phase === "finalizing") return 96;
|
||||
if (phase === "queued") {
|
||||
return clampProgress(6 + Math.min(12, elapsedMs / 3500));
|
||||
}
|
||||
if (phase === "processing") {
|
||||
const eased = 1 - Math.exp(-elapsedMs / 85_000);
|
||||
return clampProgress(18 + eased * 78);
|
||||
}
|
||||
return clampProgress(Math.min(8, elapsedMs / 1000));
|
||||
}
|
||||
|
||||
function getStatusTitleState(elapsedMs: number, initialTitle?: string) {
|
||||
const titles = initialTitle
|
||||
? [
|
||||
initialTitle,
|
||||
...GENERATION_STATUS_TITLES.filter((item) => item !== initialTitle),
|
||||
]
|
||||
: GENERATION_STATUS_TITLES;
|
||||
const durations = initialTitle
|
||||
? [4200, ...STATUS_TITLE_DURATIONS_MS]
|
||||
: STATUS_TITLE_DURATIONS_MS;
|
||||
|
||||
let cursor = Math.max(0, elapsedMs);
|
||||
for (let index = 0; index < titles.length; index += 1) {
|
||||
const duration = durations[index] || 4600;
|
||||
if (cursor < duration) {
|
||||
return {
|
||||
elapsedInTitle: cursor,
|
||||
index,
|
||||
title:
|
||||
titles[index] ||
|
||||
titles[titles.length - 1] ||
|
||||
PHASE_TITLES.processing,
|
||||
};
|
||||
}
|
||||
cursor -= duration;
|
||||
}
|
||||
|
||||
return {
|
||||
elapsedInTitle: durations[durations.length - 1] || 4600,
|
||||
index: titles.length - 1,
|
||||
title: titles[titles.length - 1] || PHASE_TITLES.processing,
|
||||
};
|
||||
}
|
||||
|
||||
function getLongWaitTitleState(elapsedMs: number, initialTitle?: string) {
|
||||
const titles = initialTitle
|
||||
? [
|
||||
initialTitle,
|
||||
...LONG_WAIT_STATUS_TITLES.filter((item) => item !== initialTitle),
|
||||
]
|
||||
: LONG_WAIT_STATUS_TITLES;
|
||||
const elapsedAfterThreshold = Math.max(
|
||||
0,
|
||||
elapsedMs - LONG_WAIT_TITLE_THRESHOLD_MS
|
||||
);
|
||||
const duration = 7600;
|
||||
const index = Math.floor(elapsedAfterThreshold / duration) % titles.length;
|
||||
|
||||
return {
|
||||
elapsedInTitle: elapsedAfterThreshold % duration,
|
||||
index,
|
||||
title: titles[index] || titles[0] || PHASE_TITLES.processing,
|
||||
};
|
||||
}
|
||||
|
||||
function getTypewriterText(text: string, elapsedInTitle: number, index: number) {
|
||||
const chars = Array.from(text);
|
||||
if (chars.length === 0) return "";
|
||||
const step = TYPEWRITER_BASE_STEP_MS + (index % 5) * 8;
|
||||
const visibleCount = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
chars.length,
|
||||
Math.floor(
|
||||
Math.max(0, elapsedInTitle - TYPEWRITER_START_DELAY_MS) / step
|
||||
) + 1
|
||||
)
|
||||
);
|
||||
return chars.slice(0, visibleCount).join("");
|
||||
}
|
||||
|
||||
function getBloubState(phase: GenerationPhase): StateId {
|
||||
if (phase === "failed") return "alert";
|
||||
if (phase === "completed") return "wink";
|
||||
@ -228,39 +51,10 @@ export function GenerationDotPlaceholder({
|
||||
showProgress = true,
|
||||
title,
|
||||
}: GenerationDotPlaceholderProps) {
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const startedAt = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
setElapsedMs(Date.now() - startedAt);
|
||||
}, 90);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [phase]);
|
||||
|
||||
const resolvedProgress = clampProgress(
|
||||
typeof progress === "number" ? progress : estimateProgress(phase, elapsedMs)
|
||||
);
|
||||
const isLongWaitTitle =
|
||||
(phase === "processing" || phase === "finalizing") &&
|
||||
elapsedMs >= LONG_WAIT_TITLE_THRESHOLD_MS;
|
||||
const statusTitleState = isLongWaitTitle
|
||||
? getLongWaitTitleState(elapsedMs, title)
|
||||
: getStatusTitleState(elapsedMs, title);
|
||||
const rotatingTitle = getTypewriterText(
|
||||
statusTitleState.title,
|
||||
statusTitleState.elapsedInTitle,
|
||||
statusTitleState.index
|
||||
);
|
||||
const resolvedTitle =
|
||||
phase === "processing" || phase === "finalizing"
|
||||
? rotatingTitle
|
||||
: title || PHASE_TITLES[phase];
|
||||
const resolvedProgress = typeof progress === "number" && Number.isFinite(progress)
|
||||
? clampProgress(progress) : null;
|
||||
const resolvedTitle = title || PHASE_TITLES[phase];
|
||||
const playSequence = phase === "processing" || phase === "finalizing";
|
||||
const progressStyle = useMemo(
|
||||
() => ({ width: `${resolvedProgress}%` }),
|
||||
[resolvedProgress]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -281,10 +75,6 @@ export function GenerationDotPlaceholder({
|
||||
)}
|
||||
>
|
||||
{resolvedTitle}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-1 inline-block h-[1em] w-[0.08em] translate-y-[0.15em] animate-[generationCursorBlink_1s_steps(1,end)_infinite] bg-current"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center py-3">
|
||||
@ -297,13 +87,13 @@ export function GenerationDotPlaceholder({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showProgress ? (
|
||||
{showProgress && phase !== "completed" ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-[12px] font-medium text-[#7d838b] dark:text-zinc-400">
|
||||
<span>{phase === "failed" ? "已停止" : "生成进度"}</span>
|
||||
<span>{resolvedProgress}%</span>
|
||||
<span>{phase === "failed" ? "已停止" : resolvedProgress === null ? "等待结果,返回后自动展示" : "生成进度"}</span>
|
||||
{resolvedProgress !== null ? <span>{resolvedProgress}%</span> : null}
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-white/80 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)] dark:bg-white/10 dark:shadow-none">
|
||||
{resolvedProgress !== null ? <div role="progressbar" aria-valuenow={resolvedProgress} aria-valuemin={0} aria-valuemax={100} className="h-1.5 overflow-hidden rounded-full bg-white/80 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)] dark:bg-white/10 dark:shadow-none">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width] duration-700 ease-out",
|
||||
@ -311,25 +101,12 @@ export function GenerationDotPlaceholder({
|
||||
? "bg-[#c7ccd3] dark:bg-zinc-600"
|
||||
: "bg-[#0a0a0c] dark:bg-[#3ecf8e]"
|
||||
)}
|
||||
style={progressStyle}
|
||||
style={{ width: `${resolvedProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes generationCursorBlink {
|
||||
0%,
|
||||
48% {
|
||||
opacity: 1;
|
||||
}
|
||||
49%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -41,45 +41,11 @@ const PHASE_HINT: Record<NonNullable<ImageGenerationLoadingProps["phase"]>, stri
|
||||
idle: "任务已创建,正在初始化生成参数。",
|
||||
queued: "任务已进入队列,请稍候。",
|
||||
processing: "模型正在生成图片,请稍候。",
|
||||
finalizing: "图片已生成完成,正在同步到对话。",
|
||||
failed: "生成失败,请重试。",
|
||||
finalizing: "图片已返回,正在保存并同步到对话。",
|
||||
failed: "本次处理已停止,请查看对话中的原因和建议。",
|
||||
};
|
||||
|
||||
const LONG_PROCESSING_HINTS = [
|
||||
"正在读取参考图与文字需求。",
|
||||
"正在分析主体、场景和构图关系。",
|
||||
"正在提取画面中的关键视觉特征。",
|
||||
"正在确认图片比例和输出尺寸。",
|
||||
"正在匹配当前模型的最佳生成参数。",
|
||||
"正在把参考图信息提交给模型。",
|
||||
"正在等待模型完成第一阶段推理。",
|
||||
"正在生成初始画面结构。",
|
||||
"正在保持主体特征和参考一致。",
|
||||
"正在补全画面中的空间层次。",
|
||||
"正在调整光影方向和明暗关系。",
|
||||
"正在优化主体边缘与背景融合。",
|
||||
"正在细化材质、纹理和局部细节。",
|
||||
"正在减少画面噪点和不自然区域。",
|
||||
"正在提升清晰度与整体质感。",
|
||||
"正在检查生成结果的完整性。",
|
||||
"正在处理高分辨率输出。",
|
||||
"正在等待服务商返回最终图片。",
|
||||
"正在保存图片并同步到对话。",
|
||||
"正在准备把结果显示到画布。",
|
||||
];
|
||||
const LONG_WAIT_SECONDS = 5 * 60;
|
||||
const LONG_WAIT_HINT =
|
||||
"服务繁忙,模型可能正在排队或自动尝试备用通道,请耐心等候,结果返回后会自动同步到画布。";
|
||||
|
||||
function getLongProcessingHint(phase: NonNullable<ImageGenerationLoadingProps["phase"]>, elapsedSeconds: number) {
|
||||
if (phase !== "processing" && phase !== "finalizing") return PHASE_HINT[phase];
|
||||
if (elapsedSeconds >= LONG_WAIT_SECONDS) return LONG_WAIT_HINT;
|
||||
const index = Math.min(
|
||||
LONG_PROCESSING_HINTS.length - 1,
|
||||
Math.floor(Math.max(0, elapsedSeconds) / 15)
|
||||
);
|
||||
return LONG_PROCESSING_HINTS[index];
|
||||
}
|
||||
|
||||
function formatImageQueueWaitLabel(estimatedWaitMs?: number) {
|
||||
const numeric = Number(estimatedWaitMs || 0);
|
||||
@ -138,16 +104,8 @@ export const ImageGenerationLoading = memo(
|
||||
const isLongWait =
|
||||
resolvedElapsedSeconds >= LONG_WAIT_SECONDS &&
|
||||
(phase === "processing" || phase === "finalizing");
|
||||
const estimateRangeLabel = isLongWait
|
||||
? "单次最长约10分钟"
|
||||
: providerType === "grsai-gpt-image"
|
||||
? "2~5分钟"
|
||||
: "60~120秒";
|
||||
const elapsedLabel =
|
||||
resolvedElapsedSeconds > 0
|
||||
? `已等待 ${resolvedElapsedSeconds}s`
|
||||
: `预计 ${estimateRangeLabel}`;
|
||||
const phaseHint = getLongProcessingHint(phase, resolvedElapsedSeconds);
|
||||
const elapsedLabel = resolvedElapsedSeconds > 0 ? `已等待 ${resolvedElapsedSeconds}s` : "";
|
||||
const phaseHint = PHASE_HINT[phase];
|
||||
const queueLine =
|
||||
phase === "queued" && Number.isFinite(queuePosition)
|
||||
? Math.max(0, Number(queuePosition || 0)) > 0
|
||||
@ -189,12 +147,12 @@ export const ImageGenerationLoading = memo(
|
||||
}
|
||||
compact={count > 1}
|
||||
phase={phase}
|
||||
title={isLongWait ? "服务繁忙,排队处理中" : undefined}
|
||||
title={isLongWait && phase === "processing" ? "模型尚未返回结果,请勿重复提交" : undefined}
|
||||
/>
|
||||
|
||||
<div className="space-y-0.5 text-[#9aa3b2]">
|
||||
<div className="text-[13px] font-medium">
|
||||
使用 {resolvedModelName} 生成图片,预计 {estimateRangeLabel}
|
||||
使用 {resolvedModelName} 生成图片
|
||||
</div>
|
||||
{queueLine ? <div className="text-[13px]">{queueLine}</div> : null}
|
||||
{queueDetailLine ? <div className="text-[13px]">{queueDetailLine}</div> : null}
|
||||
|
||||
@ -3,6 +3,7 @@ import { agentModelFault, type AgentModelFault } from "./agent-model-failover";
|
||||
export function agentImageFailurePolicy(error: unknown): { retry: boolean; pause: AgentModelFault | null } {
|
||||
const value = error as { message?: string; providerOutcomeUnknown?: boolean; status?: number; statusCode?: number } | null;
|
||||
const message = String(value?.message || "").toLowerCase();
|
||||
if (value?.providerOutcomeUnknown || ["AbortError", "TimeoutError"].includes(String((error as { name?: string })?.name || ""))) return { retry: false, pause: null };
|
||||
// A submitted image job may still succeed. Retain its task and reconcile it;
|
||||
// never submit another paid job just because a poll/connection timed out.
|
||||
const unknown = value?.providerOutcomeUnknown || /request_outcome_unknown|may still be running|outcome.unknown|timed out|timeout|超时|fetch failed|econnreset|socket hang up/.test(message);
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
isMediaInputFailure,
|
||||
isNonProviderRequestStatus,
|
||||
} from "@/lib/ai/runtime/request-failure-classification";
|
||||
import { isGatewayResponseTimeout, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
||||
import { getRequestFailureDetails, isGatewayResponseTimeout, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
||||
|
||||
export type AiModelHealthEventType =
|
||||
| "success"
|
||||
@ -344,7 +344,12 @@ export function classifyAiModelHealthFailure(error: unknown): {
|
||||
}
|
||||
|
||||
export function shouldFailoverAiModelError(error: unknown) {
|
||||
if (error instanceof ProviderTaskUncertainError || isGatewayResponseTimeout(error)) return false;
|
||||
if (
|
||||
error instanceof ProviderTaskUncertainError ||
|
||||
isGatewayResponseTimeout(error) ||
|
||||
getRequestFailureDetails(error).outcome === "unknown" ||
|
||||
["AbortError", "TimeoutError"].includes(String((error as { name?: string })?.name || ""))
|
||||
) return false;
|
||||
const classification = classifyAiModelHealthFailure(error);
|
||||
const normalized = getErrorMessage(error).toLowerCase();
|
||||
const imageProviderTaskFailed =
|
||||
|
||||
@ -44,7 +44,7 @@ import {
|
||||
} from "@/lib/ai/runtime/model-health";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
|
||||
import { isProviderBalanceError } from "@/lib/request-error";
|
||||
import { getRequestFailureDetails, isProviderBalanceError } from "@/lib/request-error";
|
||||
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
|
||||
|
||||
export interface CanvasLocalEditCompositeRegion {
|
||||
@ -5663,7 +5663,11 @@ export async function generateCanvasImage(
|
||||
}
|
||||
|
||||
// An accepted or uncertain request must not start another paid model/variant.
|
||||
if (caughtError instanceof ProviderTaskUncertainError) throw caughtError;
|
||||
if (caughtError && (
|
||||
caughtError instanceof ProviderTaskUncertainError ||
|
||||
getRequestFailureDetails(caughtError).outcome === "unknown" ||
|
||||
["AbortError", "TimeoutError"].includes(String((caughtError as { name?: string })?.name || ""))
|
||||
)) throw caughtError;
|
||||
|
||||
const retryVariants = Array.isArray(request.retryVariants)
|
||||
? request.retryVariants
|
||||
|
||||
34
lib/canvas/image-generation-status.ts
Normal file
34
lib/canvas/image-generation-status.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { RequestFailureDetails } from "@/lib/request-error";
|
||||
|
||||
export type ImageGenerationStatusStreamPayload = {
|
||||
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
|
||||
userMessageId?: string;
|
||||
requestId?: string;
|
||||
failure?: RequestFailureDetails;
|
||||
providerType?: string;
|
||||
modelName?: string;
|
||||
imageCount?: number;
|
||||
queuePosition?: number;
|
||||
sameModelRunningCount?: number;
|
||||
concurrencyLimit?: number;
|
||||
estimatedWaitMs?: number;
|
||||
};
|
||||
|
||||
export function findImageGenerationFailure(
|
||||
messages: { role: string; parts?: unknown[] }[],
|
||||
userMessageId: string,
|
||||
): ImageGenerationStatusStreamPayload | null {
|
||||
if (!userMessageId) return null;
|
||||
for (const message of [...messages].reverse()) {
|
||||
if (message.role !== "assistant") continue;
|
||||
for (const part of message.parts || []) {
|
||||
const candidate = part as { type?: string; data?: ImageGenerationStatusStreamPayload };
|
||||
if (candidate.type !== "data-imageGenerationStatus") continue;
|
||||
const status = candidate.data;
|
||||
if (status?.phase === "failed" && status.failure && status.userMessageId === userMessageId) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
5
lib/canvas/image-preview-width.ts
Normal file
5
lib/canvas/image-preview-width.ts
Normal file
@ -0,0 +1,5 @@
|
||||
const PREVIEW_WIDTHS = [640, 960, 1280, 1600, 2048, 2600, 3200, 4096];
|
||||
|
||||
export function canvasImagePreviewWidth(width: number, maxWidth: number) {
|
||||
return Math.min(maxWidth, PREVIEW_WIDTHS.find(value => value >= width) || 4096);
|
||||
}
|
||||
@ -293,7 +293,7 @@ export function getRequestFailureDetails(
|
||||
if (/AOS结果下载超时/.test(text)) {
|
||||
return detail("RMBG_RESULT_DOWNLOAD_TIMEOUT", "抠图已完成,但结果图片下载超时。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
|
||||
}
|
||||
if ((isRecord(error) && error.providerOutcomeUnknown === true) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running/i.test(text)) {
|
||||
if ((isRecord(error) && (error.providerOutcomeUnknown === true || ["AbortError", "TimeoutError"].includes(String(error.name)))) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running|operation was aborted/i.test(text)) {
|
||||
return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
|
||||
}
|
||||
if (isProviderBalanceError(error)) {
|
||||
|
||||
@ -21,6 +21,7 @@ export default defineConfig({
|
||||
/routes\/video-canvas-media-import\.test\.ts/,
|
||||
/routes\/video-canvas-toolbar-jobs\.test\.ts/,
|
||||
/routes\/image-provider-error-classification\.test\.ts/,
|
||||
/routes\/image-generation-status\.test\.ts/,
|
||||
/routes\/canvas-agent-runtime-image-failover\.test\.ts/,
|
||||
/routes\/video-canvas-audio-remote\.test\.ts/,
|
||||
/routes\/video-canvas-groups-upscale\.test\.ts/,
|
||||
|
||||
119
tests/e2e/canvas-generation-feedback.test.ts
Normal file
119
tests/e2e/canvas-generation-feedback.test.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
test.setTimeout(30_000);
|
||||
const browserErrors = new WeakMap<Page, string[]>();
|
||||
test.beforeEach(({ page }) => {
|
||||
const errors: string[] = [];
|
||||
browserErrors.set(page, errors);
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
});
|
||||
test.afterEach(({ page }) => expect(browserErrors.get(page)).toEqual([]));
|
||||
|
||||
const projectId = "24cce650-7cfb-48bb-aa65-b04f490ac978";
|
||||
const imageOrigin = "https://scqnycdn.jiansun.vip";
|
||||
const failure = {
|
||||
phase: "failed", requestId: "failed-request", userMessageId: "",
|
||||
failure: { code: "SOURCE_IMAGE_UNAVAILABLE", outcome: "failed", message: "参考图片无法读取。", recommendation: "请重新上传原图后再提交。" },
|
||||
};
|
||||
|
||||
async function setup(page: Page, lostStream = false) {
|
||||
const submissions: { path: string; body: any }[] = [];
|
||||
let messages: any[] = [];
|
||||
const canvasData = {
|
||||
nodes: [{ id: "preview-image", type: "image", position: { x: 220, y: 160 }, size: { width: 640, height: 420 },
|
||||
data: { status: "completed", src: `${imageOrigin}/uploads/feedback-preview.png`, title: "参考图片" } }],
|
||||
edges: [], viewport: { x: 0, y: 0, zoom: 1 },
|
||||
};
|
||||
await page.context().addInitScript(() => localStorage.setItem("theme", "light"));
|
||||
await page.context().route("**/api/**", async route => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
let data: unknown = {};
|
||||
if (["/api/chat", "/api/canvas/agent-chat"].includes(path) && request.method() === "POST") {
|
||||
const body = request.postDataJSON();
|
||||
submissions.push({ path, body });
|
||||
const terminal = { ...failure, userMessageId: body.message.id };
|
||||
const text = `${terminal.failure.message}${terminal.failure.recommendation}\n\n请求编号:${terminal.requestId}`;
|
||||
const assistant = { id: "failed-assistant", role: "assistant", createdAt: new Date(Date.now() + 100).toISOString(),
|
||||
parts: [{ type: "text", text }, { type: "data-imageGenerationStatus", data: terminal }] };
|
||||
messages = [{ ...body.message, createdAt: new Date().toISOString() }, assistant];
|
||||
const chunks: any[] = [{ type: "start", messageId: "stream-assistant" },
|
||||
{ type: "data-imageGenerationStatus", data: JSON.stringify({ phase: "processing", userMessageId: body.message.id }) }];
|
||||
if (!lostStream) chunks.push(
|
||||
{ type: "data-imageGenerationStatus", data: JSON.stringify(terminal) },
|
||||
{ type: "data-appendMessage", data: JSON.stringify(assistant) },
|
||||
{ type: "data-appendMessage", data: JSON.stringify({ id: "end", role: "assistant", parts: [{ type: "text", text: "<!-- IMAGE_GENERATION_END -->" }] }) },
|
||||
);
|
||||
chunks.push({ type: "finish" });
|
||||
await route.fulfill({ headers: { "content-type": "text/event-stream", "x-vercel-ai-ui-message-stream": "v1" },
|
||||
body: `${chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n` });
|
||||
return;
|
||||
}
|
||||
if (path.includes("validate-session")) data = { valid: true, success: true };
|
||||
else if (path === "/api/auth/session") data = { user: { id: "feedback-user", name: "测试", type: "admin" }, expires: "2099-01-01" };
|
||||
else if (path === "/api/site-settings") data = { canvasEnabled: true, agentEnabled: true, membershipEnabled: false };
|
||||
else if (path === "/api/vote") data = [];
|
||||
else if (path === "/api/models") data = { models: [{ id: "image-test", modelId: "gpt-image-2", providerType: "openai", name: "GPT Image", type: "image", enabled: true, supportsImageGen: true, imageGenConfig: { creditCostPerImage: 15 } }] };
|
||||
else if (path === `/api/canvas/${projectId}` && request.method() === "GET") data = { success: true, data: { id: projectId, title: "反馈验收", canvasData } };
|
||||
else if (path.includes("messages")) data = { messages };
|
||||
else if (path.includes("sessions")) data = { sessions: [] };
|
||||
else if (path.startsWith("/api/canvas/")) data = { success: true, runs: [] };
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(data) });
|
||||
});
|
||||
await page.context().route(`${imageOrigin}/**`, async route => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
await route.fulfill({ contentType: "image/svg+xml", body: '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="420"><rect width="640" height="420" fill="#43b9bd"/></svg>' });
|
||||
});
|
||||
await page.goto(`/canvas/${projectId}`, { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("button", { name: "关闭 Agent,直接使用生图模型", exact: true })).toBeVisible();
|
||||
return submissions;
|
||||
}
|
||||
|
||||
test("image workspaces start with Agent enabled and send requests to its planning runtime", async ({ page }) => {
|
||||
const submissions = await setup(page);
|
||||
await page.getByLabel("描述你的设计需求", { exact: true }).fill("请先分析参考图片,再规划处理步骤。");
|
||||
await page.getByLabel("描述你的设计需求", { exact: true }).press("Enter");
|
||||
await expect.poll(() => submissions.length).toBe(1);
|
||||
expect(submissions[0].path).toBe("/api/canvas/agent-chat");
|
||||
expect(submissions[0].body.composerMode).toBe("agent");
|
||||
});
|
||||
|
||||
for (const lostStream of [false, true]) {
|
||||
test(`image failure ends waiting and shows its cause${lostStream ? " after recovering history" : " from the stream"}`, async ({ page }) => {
|
||||
const submissions = await setup(page, lostStream);
|
||||
await page.getByRole("button", { name: "关闭 Agent,直接使用生图模型", exact: true }).click();
|
||||
await page.getByLabel("描述你的设计需求", { exact: true }).fill("将参考图改成白色背景。");
|
||||
await page.getByLabel("描述你的设计需求", { exact: true }).press("Enter");
|
||||
const failedNode = page.locator('[data-canvas-node-id] [role="alert"]');
|
||||
await expect(failedNode).toContainText("参考图片无法读取。");
|
||||
await expect(failedNode).toContainText("请重新上传原图后再提交。");
|
||||
await expect(failedNode).toContainText("请求编号:failed-request");
|
||||
await expect(page.getByTestId("message-assistant-image-loading")).toHaveCount(0);
|
||||
expect(submissions).toHaveLength(1);
|
||||
expect(submissions[0].path).toBe("/api/chat");
|
||||
await page.screenshot({ path: test.info().outputPath("terminal-failure.png") });
|
||||
});
|
||||
}
|
||||
|
||||
test("zoom and hover keep a loaded canvas image visible while a new preview loads", async ({ page }) => {
|
||||
await setup(page);
|
||||
const node = page.locator('[data-canvas-node-id="preview-image"]');
|
||||
await expect.poll(() => node.locator("img").first().evaluate((image: HTMLImageElement) => image.naturalWidth)).toBeGreaterThan(0);
|
||||
await page.evaluate(() => {
|
||||
(window as any).blankPreviewFrames = 0;
|
||||
const inspect = () => {
|
||||
const node = document.querySelector('[data-canvas-node-id="preview-image"]');
|
||||
if (node && !Array.from(node.querySelectorAll("img")).some(image => Number(getComputedStyle(image).opacity) > 0 && image.naturalWidth > 0)) (window as any).blankPreviewFrames++;
|
||||
requestAnimationFrame(inspect);
|
||||
};
|
||||
requestAnimationFrame(inspect);
|
||||
});
|
||||
await page.mouse.move(500, 350);
|
||||
for (let i = 0; i < 18; i++) {
|
||||
await page.mouse.wheel(0, i < 9 ? -100 : 100);
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
await page.mouse.move(100, 100);
|
||||
await page.waitForTimeout(500);
|
||||
expect(await page.evaluate(() => (window as any).blankPreviewFrames)).toBe(0);
|
||||
});
|
||||
@ -71,6 +71,8 @@ test("a submitted task with an unknown outcome is not submitted again through a
|
||||
await expect(h.generate(h.request)).rejects.toThrow();
|
||||
expect(h.calls.map(c => c.id)).toEqual(["primary"]); expect(h.paused.size).toBe(0);
|
||||
expect(agentImageFailurePolicy(Object.assign(new Error("timeout"), { providerOutcomeUnknown: true })).retry).toBe(false);
|
||||
expect(agentImageFailurePolicy(Object.assign(new Error("任务查询失败"), { providerOutcomeUnknown: true })).retry).toBe(false);
|
||||
expect(agentImageFailurePolicy(Object.assign(new Error("This operation was aborted"), { name: "AbortError" })).retry).toBe(false);
|
||||
expect(agentImageFailurePolicy(new Error("VIP98K task failed: timeout")).retry).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
39
tests/routes/image-generation-status.test.ts
Normal file
39
tests/routes/image-generation-status.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { findImageGenerationFailure } from "@/lib/canvas/image-generation-status";
|
||||
import { getRequestFailureDetails } from "@/lib/request-error";
|
||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||
|
||||
test("a terminal image error reaches the stream and persists for disconnected clients", async () => {
|
||||
const chunks: any[] = [];
|
||||
const saved: any[] = [];
|
||||
const load = serverLoader({ "@/lib/db/queries": { saveMessages: async ({ messages }: any) => saved.push(...messages) } });
|
||||
const { reportImageGenerationFailure } = load("app/(chat)/api/chat/_lib/image-generation-failure.ts");
|
||||
await reportImageGenerationFailure({
|
||||
writer: { write: (chunk: any) => chunks.push(chunk) },
|
||||
chatId: "chat", userMessageId: "original-user", messageId: "request-id",
|
||||
error: new Error("get image url failed"), label: "test",
|
||||
});
|
||||
const streamed = JSON.parse(chunks.find(chunk => chunk.type === "data-imageGenerationStatus").data);
|
||||
const persisted = findImageGenerationFailure(saved, "original-user");
|
||||
expect(persisted).toEqual(streamed);
|
||||
expect(persisted).toMatchObject({ phase: "failed", requestId: "request-id", failure: { code: "SOURCE_IMAGE_UNAVAILABLE", outcome: "failed" } });
|
||||
const text = saved[0].parts[0].text;
|
||||
expect(text).toContain("重新上传原图");
|
||||
expect(text).toContain("请求编号:request-id");
|
||||
expect(JSON.parse(chunks.at(-1).data).parts[0].text).toContain("IMAGE_GENERATION_END");
|
||||
|
||||
await reportImageGenerationFailure({
|
||||
writer: { write: () => { throw new Error("browser disconnected"); } },
|
||||
chatId: "chat", userMessageId: "next-user", messageId: "next-request",
|
||||
error: new Error("get image url failed"), label: "test",
|
||||
});
|
||||
expect(findImageGenerationFailure(saved, "next-user")?.requestId).toBe("next-request");
|
||||
});
|
||||
|
||||
test("late failures remain associated with their original user request", () => {
|
||||
const status = { phase: "failed" as const, userMessageId: "first", failure: getRequestFailureDetails(new Error("HTTP 524")) };
|
||||
const messages = [{ role: "user", parts: [] }, { role: "assistant", parts: [{ type: "data-imageGenerationStatus", data: status }] }];
|
||||
expect(findImageGenerationFailure(messages, "first")).toEqual(status);
|
||||
expect(findImageGenerationFailure(messages, "second")).toBeNull();
|
||||
expect(findImageGenerationFailure([{ role: "assistant", parts: [{ type: "text", text: "请分析失败原因" }] }], "second")).toBeNull();
|
||||
});
|
||||
@ -69,6 +69,20 @@ test("HTTP 524 does not start another model while the original result is unconfi
|
||||
expect(health.shouldFailoverAiModelError(new Error("Service unavailable (HTTP 503)"))).toBe(true);
|
||||
});
|
||||
|
||||
test("aborts and unconfirmed fallback results cannot start another paid generation", () => {
|
||||
const { health } = healthHarness();
|
||||
for (const error of [
|
||||
Object.assign(new Error("This operation was aborted"), { name: "AbortError" }),
|
||||
Object.assign(new Error("request timed out"), { name: "TimeoutError" }),
|
||||
Object.assign(new Error("任务查询失败"), { providerOutcomeUnknown: true }),
|
||||
new Error("图片上传到存储桶失败"),
|
||||
]) {
|
||||
expect(getRequestFailureDetails(error).outcome).toBe("unknown");
|
||||
expect(health.shouldFailoverAiModelError(error)).toBe(false);
|
||||
}
|
||||
expect(health.shouldFailoverAiModelError(new Error("Service unavailable (HTTP 503)"))).toBe(true);
|
||||
});
|
||||
|
||||
test("the provider submits once when an HTTP 524 request cannot be located", async () => {
|
||||
let submissions = 0;
|
||||
const provider = serverLoader({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user