Compare commits
No commits in common. "main" and "codex/box-cutout-billing-paths-20261105" have entirely different histories.
main
...
codex/box-
@ -1,47 +0,0 @@
|
|||||||
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,7 +208,6 @@ export function writeImageGenerationStatusChunk(
|
|||||||
writer: StreamWriter,
|
writer: StreamWriter,
|
||||||
params: {
|
params: {
|
||||||
phase: "queued" | "processing" | "finalizing" | "failed" | "completed";
|
phase: "queued" | "processing" | "finalizing" | "failed" | "completed";
|
||||||
userMessageId?: string;
|
|
||||||
providerType?: string | null;
|
providerType?: string | null;
|
||||||
modelName?: string | null;
|
modelName?: string | null;
|
||||||
imageCount?: number | null;
|
imageCount?: number | null;
|
||||||
@ -224,7 +223,6 @@ export function writeImageGenerationStatusChunk(
|
|||||||
"data-imageGenerationStatus",
|
"data-imageGenerationStatus",
|
||||||
{
|
{
|
||||||
phase: params.phase,
|
phase: params.phase,
|
||||||
userMessageId: params.userMessageId,
|
|
||||||
providerType: normalizeImageLoadingProviderType(params.providerType),
|
providerType: normalizeImageLoadingProviderType(params.providerType),
|
||||||
modelName: String(params.modelName || "").trim() || undefined,
|
modelName: String(params.modelName || "").trim() || undefined,
|
||||||
imageCount:
|
imageCount:
|
||||||
|
|||||||
@ -426,8 +426,6 @@ import {
|
|||||||
type VideoModeType,
|
type VideoModeType,
|
||||||
} from "./_lib/video-models";
|
} from "./_lib/video-models";
|
||||||
|
|
||||||
import { reportImageGenerationFailure } from "./_lib/image-generation-failure";
|
|
||||||
|
|
||||||
const chatApiLogger = createLogger("chat.route");
|
const chatApiLogger = createLogger("chat.route");
|
||||||
const createUIMessageStream: typeof createAiUIMessageStream = (options) =>
|
const createUIMessageStream: typeof createAiUIMessageStream = (options) =>
|
||||||
createAiUIMessageStream({ ...options, execute: (writer) => trackRuntimeWork(() => options.execute(writer)) });
|
createAiUIMessageStream({ ...options, execute: (writer) => trackRuntimeWork(() => options.execute(writer)) });
|
||||||
@ -448,6 +446,16 @@ function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: stri
|
|||||||
throw new Error(`${modelLabel} 未返回可用图片:${reason.slice(0, 160)}`);
|
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(
|
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.min(8, Number(process.env.AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT || 6) || 6),
|
Math.min(8, Number(process.env.AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT || 6) || 6),
|
||||||
@ -5680,7 +5688,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
modelName: resolvedModelRef,
|
modelName: resolvedModelRef,
|
||||||
imageCount: replacementReferences.length,
|
imageCount: replacementReferences.length,
|
||||||
@ -6023,7 +6030,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase:
|
phase:
|
||||||
queueStatus.status === "running"
|
queueStatus.status === "running"
|
||||||
? "processing"
|
? "processing"
|
||||||
@ -6179,7 +6185,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: successCount > 0 ? "completed" : "failed",
|
phase: successCount > 0 ? "completed" : "failed",
|
||||||
modelName: resolvedModelDisplayName,
|
modelName: resolvedModelDisplayName,
|
||||||
imageCount: successCount,
|
imageCount: successCount,
|
||||||
@ -6412,7 +6417,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
modelName:
|
modelName:
|
||||||
selectedAgentPreset.defaultModel?.displayName ||
|
selectedAgentPreset.defaultModel?.displayName ||
|
||||||
@ -6964,7 +6968,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase:
|
phase:
|
||||||
queueStatus.status === "running"
|
queueStatus.status === "running"
|
||||||
? "processing"
|
? "processing"
|
||||||
@ -7290,7 +7293,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase:
|
phase:
|
||||||
queueStatus.status === "running"
|
queueStatus.status === "running"
|
||||||
? "processing"
|
? "processing"
|
||||||
@ -7519,7 +7521,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase:
|
phase:
|
||||||
queueStatus.status === "running"
|
queueStatus.status === "running"
|
||||||
? "processing"
|
? "processing"
|
||||||
@ -7838,7 +7839,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: successCount > 0 ? "completed" : "failed",
|
phase: successCount > 0 ? "completed" : "failed",
|
||||||
modelName:
|
modelName:
|
||||||
selectedAgentPreset.defaultModel?.displayName ||
|
selectedAgentPreset.defaultModel?.displayName ||
|
||||||
@ -8048,7 +8048,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
providerType: requestedModelProviderType,
|
providerType: requestedModelProviderType,
|
||||||
modelName: finalModelId,
|
modelName: finalModelId,
|
||||||
@ -8087,7 +8086,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
...directImageStatusPayload,
|
...directImageStatusPayload,
|
||||||
},
|
},
|
||||||
@ -8180,7 +8178,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "completed",
|
phase: "completed",
|
||||||
providerType: requestedModelProviderType,
|
providerType: requestedModelProviderType,
|
||||||
modelName: directImageResult.model,
|
modelName: directImageResult.model,
|
||||||
@ -8201,16 +8198,42 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
} catch (directToolError) {
|
} catch (directToolError) {
|
||||||
chatApiLogger.error("[Chat API] Direct agent image generation failed:", directToolError);
|
chatApiLogger.error("[Chat API] Direct agent image generation failed:", directToolError);
|
||||||
const errorMessage = await reportImageGenerationFailure({
|
const errorMessage = getDirectAgentToolErrorMessage(
|
||||||
writer, chatId: id, userMessageId: message.id,
|
"generate_image",
|
||||||
messageId: assistantMessageId, error: directToolError,
|
directToolError,
|
||||||
label: "agent:generate_image:error",
|
);
|
||||||
|
await saveAssistantTextOnlyMessage({
|
||||||
|
chatId: id,
|
||||||
|
messageId: assistantMessageId,
|
||||||
|
text: errorMessage,
|
||||||
});
|
});
|
||||||
|
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(
|
writeAgentProgressChunk(
|
||||||
writer,
|
writer,
|
||||||
buildDirectAgentImageGenerateFailedProgress(errorMessage),
|
buildDirectAgentImageGenerateFailedProgress(errorMessage),
|
||||||
"agent:generate_image:progress-failed",
|
"agent:generate_image:progress-failed",
|
||||||
);
|
);
|
||||||
|
writeAssistantTextChunk(
|
||||||
|
writer,
|
||||||
|
"<!-- IMAGE_GENERATION_END -->",
|
||||||
|
"agent:generate_image:end-error",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
stopDirectImageKeepalive();
|
stopDirectImageKeepalive();
|
||||||
if (stopDirectImageStatus) {
|
if (stopDirectImageStatus) {
|
||||||
@ -9172,7 +9195,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
providerType: (languageModel as any).provider,
|
providerType: (languageModel as any).provider,
|
||||||
modelName: currentModelConfig?.displayName || actualModelId,
|
modelName: currentModelConfig?.displayName || actualModelId,
|
||||||
@ -9260,7 +9282,6 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.warn("[Chat API] Record Gemini fallback health failure failed:", healthError);
|
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);
|
chatApiLogger.error(`[Chat API] ❌ Fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -9335,7 +9356,6 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.warn("[Chat API] Record Gemini cross-provider health failure failed:", healthError);
|
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);
|
chatApiLogger.error(`[Chat API] ❌ Gemini cross-provider fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -9403,7 +9423,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "completed",
|
phase: "completed",
|
||||||
providerType: (languageModel as any).provider,
|
providerType: (languageModel as any).provider,
|
||||||
modelName: usedModelDisplayName,
|
modelName: usedModelDisplayName,
|
||||||
@ -9468,14 +9487,6 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
chatApiLogger.error("[Chat API] Gemini generation error:", 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...");
|
chatApiLogger.info("[Chat API] Attempting failover to Volcano Vision...");
|
||||||
|
|
||||||
// 故障转移:尝试使用火山引擎模型
|
// 故障转移:尝试使用火山引擎模型
|
||||||
@ -9553,7 +9564,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "completed",
|
phase: "completed",
|
||||||
providerType: "volcano-vision",
|
providerType: "volcano-vision",
|
||||||
modelName: volcanoModel.model.displayName || volcanoModel.model.modelId,
|
modelName: volcanoModel.model.displayName || volcanoModel.model.modelId,
|
||||||
@ -9619,14 +9629,47 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.info("[Chat API] No Volcano Vision model available for failover");
|
chatApiLogger.info("[Chat API] No Volcano Vision model available for failover");
|
||||||
} catch (failoverError) {
|
} catch (failoverError) {
|
||||||
chatApiLogger.error("[Chat API] Failover to Volcano Vision also failed:", failoverError);
|
chatApiLogger.error("[Chat API] Failover to Volcano Vision also failed:", failoverError);
|
||||||
finalError = failoverError;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果故障转移也失败,返回错误消息
|
// 如果故障转移也失败,返回错误消息
|
||||||
await reportImageGenerationFailure({
|
const errorMessageId = generateUUID();
|
||||||
writer, chatId: id, userMessageId: message.id,
|
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||||
messageId: generateUUID(), error: finalError, label: "gemini:error",
|
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);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
stopKeepalive?.();
|
stopKeepalive?.();
|
||||||
}
|
}
|
||||||
@ -9824,7 +9867,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "queued",
|
phase: "queued",
|
||||||
providerType: "fal-image",
|
providerType: "fal-image",
|
||||||
modelName: modelDisplayName,
|
modelName: modelDisplayName,
|
||||||
@ -9931,7 +9973,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
providerType: (languageModel as any).provider,
|
providerType: (languageModel as any).provider,
|
||||||
modelName: alibabaModelConfig?.displayName || actualModelId,
|
modelName: alibabaModelConfig?.displayName || actualModelId,
|
||||||
@ -10029,7 +10070,6 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.warn("[Chat API] Record image fallback health failure failed:", healthError);
|
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);
|
chatApiLogger.error(`[Chat API] ❌ Alibaba fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -10164,7 +10204,6 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.warn("[Chat API] Record image cross-provider health failure failed:", healthError);
|
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);
|
chatApiLogger.error(`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError);
|
||||||
if (!shouldFailoverAiModelError(crossError)) throw crossError;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -10232,7 +10271,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "completed",
|
phase: "completed",
|
||||||
providerType: (languageModel as any).provider,
|
providerType: (languageModel as any).provider,
|
||||||
modelName: usedModelDisplayName,
|
modelName: usedModelDisplayName,
|
||||||
@ -10297,10 +10335,43 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.error("[Chat API] Alibaba generation error:", error);
|
chatApiLogger.error("[Chat API] Alibaba generation error:", error);
|
||||||
|
|
||||||
// 返回错误消息
|
// 返回错误消息
|
||||||
await reportImageGenerationFailure({
|
const errorMessageId = generateUUID();
|
||||||
writer, chatId: id, userMessageId: message.id,
|
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||||
messageId: generateUUID(), error, label: "alibaba:error",
|
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);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
stopKeepalive?.();
|
stopKeepalive?.();
|
||||||
}
|
}
|
||||||
@ -10429,7 +10500,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
providerType: "volcano-vision",
|
providerType: "volcano-vision",
|
||||||
modelName: volcanoModelConfig?.displayName || actualModelId,
|
modelName: volcanoModelConfig?.displayName || actualModelId,
|
||||||
@ -10475,7 +10545,6 @@ export async function POST(request: Request) {
|
|||||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||||
}
|
}
|
||||||
chatApiLogger.error(`[Chat API] ❌ Volcano fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
chatApiLogger.error(`[Chat API] ❌ Volcano fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!fallbackSuccess) {
|
if (!fallbackSuccess) {
|
||||||
@ -10642,7 +10711,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "completed",
|
phase: "completed",
|
||||||
providerType: "volcano-vision",
|
providerType: "volcano-vision",
|
||||||
modelName: usedModelDisplayName,
|
modelName: usedModelDisplayName,
|
||||||
@ -10707,10 +10775,44 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
chatApiLogger.error("[Chat API] Volcano Vision generation error:", error);
|
chatApiLogger.error("[Chat API] Volcano Vision generation error:", error);
|
||||||
await reportImageGenerationFailure({
|
const errorMessageId = generateUUID();
|
||||||
writer, chatId: id, userMessageId: message.id,
|
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||||
messageId: generateUUID(), error, label: "volcano:error",
|
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);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
stopKeepalive?.();
|
stopKeepalive?.();
|
||||||
}
|
}
|
||||||
@ -11283,7 +11385,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
dataStream as unknown as StreamWriter,
|
dataStream as unknown as StreamWriter,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
providerType: currentModelConfig?.providerId ? resolvedProviderType || providerType : providerType,
|
providerType: currentModelConfig?.providerId ? resolvedProviderType || providerType : providerType,
|
||||||
modelName: currentModelConfig?.displayName || actualModelId,
|
modelName: currentModelConfig?.displayName || actualModelId,
|
||||||
@ -11375,7 +11476,6 @@ export async function POST(request: Request) {
|
|||||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||||
}
|
}
|
||||||
chatApiLogger.error(`[Chat API] ❌ OpenAI-compatible fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
chatApiLogger.error(`[Chat API] ❌ OpenAI-compatible fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -11547,7 +11647,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
dataStream as unknown as StreamWriter,
|
dataStream as unknown as StreamWriter,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "completed",
|
phase: "completed",
|
||||||
providerType: resolvedProviderType || providerType,
|
providerType: resolvedProviderType || providerType,
|
||||||
modelName: usedModelDisplayName,
|
modelName: usedModelDisplayName,
|
||||||
@ -11607,10 +11706,54 @@ export async function POST(request: Request) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error);
|
chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error);
|
||||||
|
|
||||||
await reportImageGenerationFailure({
|
const errorMessageId = generateUUID();
|
||||||
writer: dataStream as unknown as StreamWriter,
|
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||||
chatId: id, userMessageId: message.id,
|
|
||||||
messageId: generateUUID(), error, label: "openai-compat:error",
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,7 +67,6 @@ import { toast } from "sonner";
|
|||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
||||||
import { findImageGenerationFailure } from "@/lib/canvas/image-generation-status";
|
|
||||||
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
fetchUploadWithRetry,
|
fetchUploadWithRetry,
|
||||||
@ -3341,7 +3340,6 @@ export function CanvasChatPanel({
|
|||||||
const activeChatIdRef = useRef<string>(requestedChatSessionId || chatId);
|
const activeChatIdRef = useRef<string>(requestedChatSessionId || chatId);
|
||||||
const activeMessagesRef = useRef<ChatMessage[]>([]);
|
const activeMessagesRef = useRef<ChatMessage[]>([]);
|
||||||
const inFlightChatIdRef = useRef<string | null>(null);
|
const inFlightChatIdRef = useRef<string | null>(null);
|
||||||
const imageGenerationUserMessageIdsRef = useRef<Record<string, string>>({});
|
|
||||||
const inFlightRawChatIdRef = useRef<string | null>(null);
|
const inFlightRawChatIdRef = useRef<string | null>(null);
|
||||||
const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]);
|
const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]);
|
||||||
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
|
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
|
||||||
@ -3644,20 +3642,17 @@ export function CanvasChatPanel({
|
|||||||
|
|
||||||
const setStreamImageGenerationStateForChat = useCallback(
|
const setStreamImageGenerationStateForChat = useCallback(
|
||||||
(chatSessionId: string, next: ImageGenerationStatusStreamPayload | null) => {
|
(chatSessionId: string, next: ImageGenerationStatusStreamPayload | null) => {
|
||||||
const userMessageId = imageGenerationUserMessageIdsRef.current[chatSessionId];
|
|
||||||
if (next?.userMessageId && userMessageId && next.userMessageId !== userMessageId) return;
|
|
||||||
setStreamImageGenerationStateByChatId((prev) => ({
|
setStreamImageGenerationStateByChatId((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[chatSessionId]: next ? { ...next, userMessageId: next.userMessageId || userMessageId } : null,
|
[chatSessionId]: next,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string, preserveFailure = false) => {
|
const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string) => {
|
||||||
setStreamImageGenerationStateByChatId((prev) => {
|
setStreamImageGenerationStateByChatId((prev) => {
|
||||||
if (!(chatSessionId in prev)) return prev;
|
if (!(chatSessionId in prev)) return prev;
|
||||||
if (preserveFailure && prev[chatSessionId]?.phase === "failed") return prev;
|
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[chatSessionId];
|
delete next[chatSessionId];
|
||||||
return next;
|
return next;
|
||||||
@ -6094,7 +6089,7 @@ export function CanvasChatPanel({
|
|||||||
const activeComposerMode = unifiedImageAgent && (rawComposerMode === "image" || rawComposerMode === "video")
|
const activeComposerMode = unifiedImageAgent && (rawComposerMode === "image" || rawComposerMode === "video")
|
||||||
? "agent" : rawComposerMode;
|
? "agent" : rawComposerMode;
|
||||||
const isImageAgentComposer = unifiedImageAgent && activeComposerMode === "agent";
|
const isImageAgentComposer = unifiedImageAgent && activeComposerMode === "agent";
|
||||||
const [imageAgentEnabled, setImageAgentEnabled] = useState(true);
|
const [imageAgentEnabled, setImageAgentEnabled] = useState(false);
|
||||||
const imageAgentEnabledRef = useRef(imageAgentEnabled);
|
const imageAgentEnabledRef = useRef(imageAgentEnabled);
|
||||||
imageAgentEnabledRef.current = imageAgentEnabled;
|
imageAgentEnabledRef.current = imageAgentEnabled;
|
||||||
const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video";
|
const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video";
|
||||||
@ -8023,8 +8018,6 @@ export function CanvasChatPanel({
|
|||||||
api: "/api/chat",
|
api: "/api/chat",
|
||||||
fetch: fetchWithErrorHandlers,
|
fetch: fetchWithErrorHandlers,
|
||||||
async prepareSendMessagesRequest(request) {
|
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
|
// 从 canvas-{uuid} 格式中提取纯 UUID,后端 schema 要求纯 UUID
|
||||||
const rawId = request.id.startsWith('canvas-')
|
const rawId = request.id.startsWith('canvas-')
|
||||||
? request.id.replace('canvas-', '')
|
? request.id.replace('canvas-', '')
|
||||||
@ -8137,27 +8130,20 @@ export function CanvasChatPanel({
|
|||||||
const payload: ImageGenerationStatusStreamPayload =
|
const payload: ImageGenerationStatusStreamPayload =
|
||||||
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
|
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
|
||||||
if (!payload || typeof payload !== "object") return;
|
if (!payload || typeof payload !== "object") return;
|
||||||
const userMessageId = imageGenerationUserMessageIdsRef.current[eventChatId];
|
|
||||||
if (payload.userMessageId && userMessageId && payload.userMessageId !== userMessageId) return;
|
|
||||||
if (payload.phase === "completed") {
|
if (payload.phase === "completed") {
|
||||||
setStreamImageGenerationStateForChat(eventChatId, {
|
setStreamImageGenerationStateForChat(eventChatId, {
|
||||||
phase: "finalizing",
|
phase: "finalizing",
|
||||||
userMessageId: payload.userMessageId,
|
|
||||||
providerType: payload.providerType,
|
providerType: payload.providerType,
|
||||||
modelName: getAgentSafeModelName(payload.modelName),
|
modelName: getAgentSafeModelName(payload.modelName),
|
||||||
imageCount: Number(payload.imageCount || 0) || undefined,
|
imageCount: Number(payload.imageCount || 0) || undefined,
|
||||||
});
|
});
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (payload.userMessageId && imageGenerationUserMessageIdsRef.current[eventChatId] !== payload.userMessageId) return;
|
|
||||||
clearStreamImageGenerationStateForChat(eventChatId);
|
clearStreamImageGenerationStateForChat(eventChatId);
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStreamImageGenerationStateForChat(eventChatId, {
|
setStreamImageGenerationStateForChat(eventChatId, {
|
||||||
phase: payload.phase || "processing",
|
phase: payload.phase || "processing",
|
||||||
userMessageId: payload.userMessageId,
|
|
||||||
requestId: payload.requestId,
|
|
||||||
failure: payload.failure,
|
|
||||||
providerType: payload.providerType,
|
providerType: payload.providerType,
|
||||||
modelName: getAgentSafeModelName(payload.modelName),
|
modelName: getAgentSafeModelName(payload.modelName),
|
||||||
imageCount: Number(payload.imageCount || 0) || undefined,
|
imageCount: Number(payload.imageCount || 0) || undefined,
|
||||||
@ -8425,11 +8411,6 @@ export function CanvasChatPanel({
|
|||||||
)
|
)
|
||||||
: false;
|
: false;
|
||||||
const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : [];
|
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)
|
const normalizedAttachments = Array.isArray(messageData.attachments)
|
||||||
? messageData.attachments
|
? messageData.attachments
|
||||||
: [];
|
: [];
|
||||||
@ -8498,7 +8479,7 @@ export function CanvasChatPanel({
|
|||||||
(hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) &&
|
(hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) &&
|
||||||
!shouldDeferClearForActiveMedia
|
!shouldDeferClearForActiveMedia
|
||||||
) {
|
) {
|
||||||
clearStreamImageGenerationStateForChat(eventChatId, true);
|
clearStreamImageGenerationStateForChat(eventChatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isEventForActiveChat) {
|
if (!isEventForActiveChat) {
|
||||||
@ -9221,7 +9202,7 @@ export function CanvasChatPanel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const streamState = streamImageGenerationStateByChatId[activeChatId];
|
const streamState = streamImageGenerationStateByChatId[activeChatId];
|
||||||
if (!rawChatId || !streamState || (streamState.phase === "failed" && streamState.failure?.outcome !== "unknown")) return;
|
if (!rawChatId || !streamState) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
@ -9245,13 +9226,6 @@ export function CanvasChatPanel({
|
|||||||
const streamState = streamImageGenerationStateByChatId[activeChatId];
|
const streamState = streamImageGenerationStateByChatId[activeChatId];
|
||||||
if (!streamState || messages.length === 0) return;
|
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]
|
const lastUserIndex = [...messages]
|
||||||
.map((message) => message.role)
|
.map((message) => message.role)
|
||||||
.lastIndexOf("user");
|
.lastIndexOf("user");
|
||||||
@ -9268,7 +9242,8 @@ export function CanvasChatPanel({
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
return (
|
return (
|
||||||
extractAssistantImageUrls(textContent).length > 0 ||
|
extractAssistantImageUrls(textContent).length > 0 ||
|
||||||
extractVideoUrls(textContent).length > 0
|
extractVideoUrls(textContent).length > 0 ||
|
||||||
|
/失败|超时|重试|error|failed/i.test(textContent)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -9289,7 +9264,6 @@ export function CanvasChatPanel({
|
|||||||
messages,
|
messages,
|
||||||
streamImageGenerationStateByChatId,
|
streamImageGenerationStateByChatId,
|
||||||
clearStreamImageGenerationStateForChat,
|
clearStreamImageGenerationStateForChat,
|
||||||
setStreamImageGenerationStateForChat,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -13354,7 +13328,6 @@ export function CanvasChatPanel({
|
|||||||
});
|
});
|
||||||
inFlightChatIdRef.current = activeChatId;
|
inFlightChatIdRef.current = activeChatId;
|
||||||
inFlightRawChatIdRef.current = rawChatId;
|
inFlightRawChatIdRef.current = rawChatId;
|
||||||
imageGenerationUserMessageIdsRef.current[activeChatId] = userMessageId;
|
|
||||||
clearStreamImageGenerationStateForChat(activeChatId);
|
clearStreamImageGenerationStateForChat(activeChatId);
|
||||||
clearAgentProgressStateForChat(activeChatId);
|
clearAgentProgressStateForChat(activeChatId);
|
||||||
if (shouldShowOptimisticImageGeneration) {
|
if (shouldShowOptimisticImageGeneration) {
|
||||||
@ -15124,7 +15097,7 @@ export function CanvasChatPanel({
|
|||||||
!hasGenerationEnd &&
|
!hasGenerationEnd &&
|
||||||
Boolean(currentModel || activeComposerMode === "agent"));
|
Boolean(currentModel || activeComposerMode === "agent"));
|
||||||
const hasActiveTypewriter = Object.keys(activeTypingSections).length > 0;
|
const hasActiveTypewriter = Object.keys(activeTypingSections).length > 0;
|
||||||
const shouldRenderImageLoading = shouldShowImageLoading && streamImageGenerationState?.phase !== "failed" && !hasActiveTypewriter;
|
const shouldRenderImageLoading = shouldShowImageLoading && !hasActiveTypewriter;
|
||||||
const loadingImageCount = streamImageGenerationState?.imageCount || imageParams.imageCount || 1;
|
const loadingImageCount = streamImageGenerationState?.imageCount || imageParams.imageCount || 1;
|
||||||
const loadingProviderType =
|
const loadingProviderType =
|
||||||
streamImageGenerationState?.providerType ||
|
streamImageGenerationState?.providerType ||
|
||||||
@ -15187,9 +15160,9 @@ export function CanvasChatPanel({
|
|||||||
phase === "queued"
|
phase === "queued"
|
||||||
? "等待开始生成"
|
? "等待开始生成"
|
||||||
: phase === "finalizing"
|
: phase === "finalizing"
|
||||||
? "正在保存生成结果"
|
? "正在润饰细节"
|
||||||
: phase === "failed"
|
: phase === "failed"
|
||||||
? streamState.failure?.message || "生成失败"
|
? "生成失败"
|
||||||
: "正在创建图片";
|
: "正在创建图片";
|
||||||
const status = phase === "failed" ? "failed" : "generating";
|
const status = phase === "failed" ? "failed" : "generating";
|
||||||
const currentNodes = useCanvasStore.getState().nodes;
|
const currentNodes = useCanvasStore.getState().nodes;
|
||||||
@ -15245,10 +15218,14 @@ export function CanvasChatPanel({
|
|||||||
canvasGenerationPlaceholder: true,
|
canvasGenerationPlaceholder: true,
|
||||||
chatId: activeChatId,
|
chatId: activeChatId,
|
||||||
detailProgressLabel: label,
|
detailProgressLabel: label,
|
||||||
generationProgress: undefined,
|
generationProgress:
|
||||||
generationFailure: streamState.failure,
|
phase === "queued"
|
||||||
generationRequestId: streamState.requestId,
|
? 12
|
||||||
generationUserMessageId: streamState.userMessageId,
|
: phase === "finalizing"
|
||||||
|
? 96
|
||||||
|
: phase === "failed"
|
||||||
|
? 100
|
||||||
|
: undefined,
|
||||||
modelName: streamState.modelName || currentModel?.displayName || undefined,
|
modelName: streamState.modelName || currentModel?.displayName || undefined,
|
||||||
providerType: streamState.providerType || currentModel?.providerType || undefined,
|
providerType: streamState.providerType || currentModel?.providerType || undefined,
|
||||||
placementMode: "anchored-generation",
|
placementMode: "anchored-generation",
|
||||||
@ -15342,7 +15319,6 @@ export function CanvasChatPanel({
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
message.role === "assistant" &&
|
message.role === "assistant" &&
|
||||||
!rawParts.some((part: any) => part.type === "data-imageGenerationStatus" && part.data?.failure) &&
|
|
||||||
isInternalCanvasImageGenerationFailureText(cleanedParts)
|
isInternalCanvasImageGenerationFailureText(cleanedParts)
|
||||||
) {
|
) {
|
||||||
return acc;
|
return acc;
|
||||||
|
|||||||
@ -152,7 +152,16 @@ export function shouldRetryCanvasViewOperationAfterRefresh(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { ImageGenerationStatusStreamPayload } from "@/lib/canvas/image-generation-status";
|
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 AgentTargetLockRefPreview = {
|
export type AgentTargetLockRefPreview = {
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
"use client";
|
"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 { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -2504,13 +2502,13 @@ export function ImageNode({
|
|||||||
const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05;
|
const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05;
|
||||||
const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640;
|
const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640;
|
||||||
const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600;
|
const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600;
|
||||||
return canvasImagePreviewWidth(Math.max(
|
return Math.max(
|
||||||
minWidth,
|
minWidth,
|
||||||
Math.min(
|
Math.min(
|
||||||
maxWidth,
|
maxWidth,
|
||||||
Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier)
|
Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier)
|
||||||
)
|
)
|
||||||
), maxWidth);
|
);
|
||||||
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
|
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
|
||||||
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
|
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
|
||||||
const displayImageSrc = useMemo(
|
const displayImageSrc = useMemo(
|
||||||
@ -8401,13 +8399,12 @@ export function ImageNode({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
|
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
|
||||||
<CanvasPreviewImage
|
<img
|
||||||
sourceKey={data.src}
|
|
||||||
alt={displayTitle}
|
alt={displayTitle}
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
loading="eager"
|
loading="lazy"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
||||||
@ -8431,13 +8428,12 @@ export function ImageNode({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
|
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
|
||||||
<CanvasPreviewImage
|
<img
|
||||||
sourceKey={compareSource}
|
|
||||||
alt={`${displayTitle}-原图`}
|
alt={`${displayTitle}-原图`}
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
loading="eager"
|
loading="lazy"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(compareSource);
|
event.currentTarget.src = buildImageFallbackUrl(compareSource);
|
||||||
@ -8482,8 +8478,7 @@ export function ImageNode({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CanvasPreviewImage
|
<img
|
||||||
sourceKey={data.src}
|
|
||||||
alt={displayTitle}
|
alt={displayTitle}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full w-full bg-slate-50 object-contain dark:bg-zinc-900",
|
"h-full w-full bg-slate-50 object-contain dark:bg-zinc-900",
|
||||||
@ -8495,7 +8490,7 @@ export function ImageNode({
|
|||||||
)}
|
)}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
loading="eager"
|
loading="lazy"
|
||||||
onClick={
|
onClick={
|
||||||
isThisNodePicking && !boxCutoutMode
|
isThisNodePicking && !boxCutoutMode
|
||||||
? handlePickerClick
|
? handlePickerClick
|
||||||
@ -9425,8 +9420,6 @@ export function ImageNode({
|
|||||||
) : (
|
) : (
|
||||||
<ImageNodeEmptyState
|
<ImageNodeEmptyState
|
||||||
detailProgressLabel={detailProgressLabel}
|
detailProgressLabel={detailProgressLabel}
|
||||||
failure={data.metadata?.generationFailure}
|
|
||||||
requestId={data.metadata?.generationRequestId}
|
|
||||||
fileInputRef={fileInputRef}
|
fileInputRef={fileInputRef}
|
||||||
generationProgress={generationProgress}
|
generationProgress={generationProgress}
|
||||||
onImageUpload={handleImageUpload}
|
onImageUpload={handleImageUpload}
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
"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,14 +3,11 @@
|
|||||||
import { AlertCircle, ImagePlus, Loader2 } from "lucide-react";
|
import { AlertCircle, ImagePlus, Loader2 } from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
|
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
|
||||||
import type { RequestFailureDetails } from "@/lib/request-error";
|
|
||||||
|
|
||||||
interface ImageNodeEmptyStateProps {
|
interface ImageNodeEmptyStateProps {
|
||||||
detailProgressLabel?: string;
|
detailProgressLabel?: string;
|
||||||
fileInputRef: React.Ref<HTMLInputElement>;
|
fileInputRef: React.Ref<HTMLInputElement>;
|
||||||
generationProgress?: number | null;
|
generationProgress?: number | null;
|
||||||
failure?: RequestFailureDetails;
|
|
||||||
requestId?: string;
|
|
||||||
onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
status?: string;
|
status?: string;
|
||||||
@ -55,8 +52,6 @@ export function ImageNodeEmptyState({
|
|||||||
detailProgressLabel,
|
detailProgressLabel,
|
||||||
fileInputRef,
|
fileInputRef,
|
||||||
generationProgress,
|
generationProgress,
|
||||||
failure,
|
|
||||||
requestId,
|
|
||||||
onImageUpload,
|
onImageUpload,
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
status,
|
status,
|
||||||
@ -94,7 +89,7 @@ export function ImageNodeEmptyState({
|
|||||||
if (status === "failed") {
|
if (status === "failed") {
|
||||||
const failedTitle = detailProgressLabel || "生成失败";
|
const failedTitle = detailProgressLabel || "生成失败";
|
||||||
return (
|
return (
|
||||||
<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">
|
<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">
|
||||||
<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)]">
|
<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" />
|
<AlertCircle className="h-5 w-5" />
|
||||||
</span>
|
</span>
|
||||||
@ -102,9 +97,8 @@ export function ImageNodeEmptyState({
|
|||||||
{failedTitle}
|
{failedTitle}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 text-xs text-[#b77480]">
|
<span className="mt-1 text-xs text-[#b77480]">
|
||||||
{failure?.recommendation || (failedTitle.includes("上传") ? "请重新选择文件" : "请查看对话中的错误原因和处理建议")}
|
{failedTitle.includes("上传") ? "请重新选择文件" : "请重新上传或再试一次"}
|
||||||
</span>
|
</span>
|
||||||
{requestId ? <span className="mt-2 select-text break-all text-xs text-[#b77480]">请求编号:{requestId}</span> : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { BloubAvatar } from "@/components/bloub-avatar";
|
import { BloubAvatar } from "@/components/bloub-avatar";
|
||||||
import type { StateId } from "@/lib/bloub-official/states";
|
import type { StateId } from "@/lib/bloub-official/states";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -25,17 +26,193 @@ interface GenerationDotPlaceholderProps {
|
|||||||
const PHASE_TITLES: Record<GenerationPhase, string> = {
|
const PHASE_TITLES: Record<GenerationPhase, string> = {
|
||||||
idle: "正在创建图片",
|
idle: "正在创建图片",
|
||||||
queued: "等待开始生成",
|
queued: "等待开始生成",
|
||||||
processing: "等待模型返回图片",
|
processing: "生成初稿中",
|
||||||
finalizing: "正在保存生成结果",
|
finalizing: "正在润饰细节",
|
||||||
failed: "生成失败",
|
failed: "生成失败",
|
||||||
completed: "生成完成",
|
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) {
|
function clampProgress(value: number) {
|
||||||
if (!Number.isFinite(value)) return 0;
|
if (!Number.isFinite(value)) return 0;
|
||||||
return Math.max(0, Math.min(100, Math.round(value)));
|
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 {
|
function getBloubState(phase: GenerationPhase): StateId {
|
||||||
if (phase === "failed") return "alert";
|
if (phase === "failed") return "alert";
|
||||||
if (phase === "completed") return "wink";
|
if (phase === "completed") return "wink";
|
||||||
@ -51,10 +228,39 @@ export function GenerationDotPlaceholder({
|
|||||||
showProgress = true,
|
showProgress = true,
|
||||||
title,
|
title,
|
||||||
}: GenerationDotPlaceholderProps) {
|
}: GenerationDotPlaceholderProps) {
|
||||||
const resolvedProgress = typeof progress === "number" && Number.isFinite(progress)
|
const [elapsedMs, setElapsedMs] = useState(0);
|
||||||
? clampProgress(progress) : null;
|
|
||||||
const resolvedTitle = title || PHASE_TITLES[phase];
|
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 playSequence = phase === "processing" || phase === "finalizing";
|
const playSequence = phase === "processing" || phase === "finalizing";
|
||||||
|
const progressStyle = useMemo(
|
||||||
|
() => ({ width: `${resolvedProgress}%` }),
|
||||||
|
[resolvedProgress]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -75,6 +281,10 @@ export function GenerationDotPlaceholder({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{resolvedTitle}
|
{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>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 items-center justify-center py-3">
|
<div className="flex min-h-0 flex-1 items-center justify-center py-3">
|
||||||
@ -87,13 +297,13 @@ export function GenerationDotPlaceholder({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showProgress && phase !== "completed" ? (
|
{showProgress ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between text-[12px] font-medium text-[#7d838b] dark:text-zinc-400">
|
<div className="flex items-center justify-between text-[12px] font-medium text-[#7d838b] dark:text-zinc-400">
|
||||||
<span>{phase === "failed" ? "已停止" : resolvedProgress === null ? "等待结果,返回后自动展示" : "生成进度"}</span>
|
<span>{phase === "failed" ? "已停止" : "生成进度"}</span>
|
||||||
{resolvedProgress !== null ? <span>{resolvedProgress}%</span> : null}
|
<span>{resolvedProgress}%</span>
|
||||||
</div>
|
</div>
|
||||||
{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="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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full rounded-full transition-[width] duration-700 ease-out",
|
"h-full rounded-full transition-[width] duration-700 ease-out",
|
||||||
@ -101,12 +311,25 @@ export function GenerationDotPlaceholder({
|
|||||||
? "bg-[#c7ccd3] dark:bg-zinc-600"
|
? "bg-[#c7ccd3] dark:bg-zinc-600"
|
||||||
: "bg-[#0a0a0c] dark:bg-[#3ecf8e]"
|
: "bg-[#0a0a0c] dark:bg-[#3ecf8e]"
|
||||||
)}
|
)}
|
||||||
style={{ width: `${resolvedProgress}%` }}
|
style={progressStyle}
|
||||||
/>
|
/>
|
||||||
</div> : null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes generationCursorBlink {
|
||||||
|
0%,
|
||||||
|
48% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
49%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,11 +41,45 @@ const PHASE_HINT: Record<NonNullable<ImageGenerationLoadingProps["phase"]>, stri
|
|||||||
idle: "任务已创建,正在初始化生成参数。",
|
idle: "任务已创建,正在初始化生成参数。",
|
||||||
queued: "任务已进入队列,请稍候。",
|
queued: "任务已进入队列,请稍候。",
|
||||||
processing: "模型正在生成图片,请稍候。",
|
processing: "模型正在生成图片,请稍候。",
|
||||||
finalizing: "图片已返回,正在保存并同步到对话。",
|
finalizing: "图片已生成完成,正在同步到对话。",
|
||||||
failed: "本次处理已停止,请查看对话中的原因和建议。",
|
failed: "生成失败,请重试。",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LONG_PROCESSING_HINTS = [
|
||||||
|
"正在读取参考图与文字需求。",
|
||||||
|
"正在分析主体、场景和构图关系。",
|
||||||
|
"正在提取画面中的关键视觉特征。",
|
||||||
|
"正在确认图片比例和输出尺寸。",
|
||||||
|
"正在匹配当前模型的最佳生成参数。",
|
||||||
|
"正在把参考图信息提交给模型。",
|
||||||
|
"正在等待模型完成第一阶段推理。",
|
||||||
|
"正在生成初始画面结构。",
|
||||||
|
"正在保持主体特征和参考一致。",
|
||||||
|
"正在补全画面中的空间层次。",
|
||||||
|
"正在调整光影方向和明暗关系。",
|
||||||
|
"正在优化主体边缘与背景融合。",
|
||||||
|
"正在细化材质、纹理和局部细节。",
|
||||||
|
"正在减少画面噪点和不自然区域。",
|
||||||
|
"正在提升清晰度与整体质感。",
|
||||||
|
"正在检查生成结果的完整性。",
|
||||||
|
"正在处理高分辨率输出。",
|
||||||
|
"正在等待服务商返回最终图片。",
|
||||||
|
"正在保存图片并同步到对话。",
|
||||||
|
"正在准备把结果显示到画布。",
|
||||||
|
];
|
||||||
const LONG_WAIT_SECONDS = 5 * 60;
|
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) {
|
function formatImageQueueWaitLabel(estimatedWaitMs?: number) {
|
||||||
const numeric = Number(estimatedWaitMs || 0);
|
const numeric = Number(estimatedWaitMs || 0);
|
||||||
@ -104,8 +138,16 @@ export const ImageGenerationLoading = memo(
|
|||||||
const isLongWait =
|
const isLongWait =
|
||||||
resolvedElapsedSeconds >= LONG_WAIT_SECONDS &&
|
resolvedElapsedSeconds >= LONG_WAIT_SECONDS &&
|
||||||
(phase === "processing" || phase === "finalizing");
|
(phase === "processing" || phase === "finalizing");
|
||||||
const elapsedLabel = resolvedElapsedSeconds > 0 ? `已等待 ${resolvedElapsedSeconds}s` : "";
|
const estimateRangeLabel = isLongWait
|
||||||
const phaseHint = PHASE_HINT[phase];
|
? "单次最长约10分钟"
|
||||||
|
: providerType === "grsai-gpt-image"
|
||||||
|
? "2~5分钟"
|
||||||
|
: "60~120秒";
|
||||||
|
const elapsedLabel =
|
||||||
|
resolvedElapsedSeconds > 0
|
||||||
|
? `已等待 ${resolvedElapsedSeconds}s`
|
||||||
|
: `预计 ${estimateRangeLabel}`;
|
||||||
|
const phaseHint = getLongProcessingHint(phase, resolvedElapsedSeconds);
|
||||||
const queueLine =
|
const queueLine =
|
||||||
phase === "queued" && Number.isFinite(queuePosition)
|
phase === "queued" && Number.isFinite(queuePosition)
|
||||||
? Math.max(0, Number(queuePosition || 0)) > 0
|
? Math.max(0, Number(queuePosition || 0)) > 0
|
||||||
@ -147,12 +189,12 @@ export const ImageGenerationLoading = memo(
|
|||||||
}
|
}
|
||||||
compact={count > 1}
|
compact={count > 1}
|
||||||
phase={phase}
|
phase={phase}
|
||||||
title={isLongWait && phase === "processing" ? "模型尚未返回结果,请勿重复提交" : undefined}
|
title={isLongWait ? "服务繁忙,排队处理中" : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-0.5 text-[#9aa3b2]">
|
<div className="space-y-0.5 text-[#9aa3b2]">
|
||||||
<div className="text-[13px] font-medium">
|
<div className="text-[13px] font-medium">
|
||||||
使用 {resolvedModelName} 生成图片
|
使用 {resolvedModelName} 生成图片,预计 {estimateRangeLabel}
|
||||||
</div>
|
</div>
|
||||||
{queueLine ? <div className="text-[13px]">{queueLine}</div> : null}
|
{queueLine ? <div className="text-[13px]">{queueLine}</div> : null}
|
||||||
{queueDetailLine ? <div className="text-[13px]">{queueDetailLine}</div> : null}
|
{queueDetailLine ? <div className="text-[13px]">{queueDetailLine}</div> : null}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { agentModelFault, type AgentModelFault } from "./agent-model-failover";
|
|||||||
export function agentImageFailurePolicy(error: unknown): { retry: boolean; pause: AgentModelFault | null } {
|
export function agentImageFailurePolicy(error: unknown): { retry: boolean; pause: AgentModelFault | null } {
|
||||||
const value = error as { message?: string; providerOutcomeUnknown?: boolean; status?: number; statusCode?: number } | null;
|
const value = error as { message?: string; providerOutcomeUnknown?: boolean; status?: number; statusCode?: number } | null;
|
||||||
const message = String(value?.message || "").toLowerCase();
|
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;
|
// 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.
|
// 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);
|
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,
|
isMediaInputFailure,
|
||||||
isNonProviderRequestStatus,
|
isNonProviderRequestStatus,
|
||||||
} from "@/lib/ai/runtime/request-failure-classification";
|
} from "@/lib/ai/runtime/request-failure-classification";
|
||||||
import { getRequestFailureDetails, isGatewayResponseTimeout, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
import { isGatewayResponseTimeout, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
||||||
|
|
||||||
export type AiModelHealthEventType =
|
export type AiModelHealthEventType =
|
||||||
| "success"
|
| "success"
|
||||||
@ -344,12 +344,7 @@ export function classifyAiModelHealthFailure(error: unknown): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldFailoverAiModelError(error: unknown) {
|
export function shouldFailoverAiModelError(error: unknown) {
|
||||||
if (
|
if (error instanceof ProviderTaskUncertainError || isGatewayResponseTimeout(error)) return false;
|
||||||
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 classification = classifyAiModelHealthFailure(error);
|
||||||
const normalized = getErrorMessage(error).toLowerCase();
|
const normalized = getErrorMessage(error).toLowerCase();
|
||||||
const imageProviderTaskFailed =
|
const imageProviderTaskFailed =
|
||||||
|
|||||||
@ -44,7 +44,7 @@ import {
|
|||||||
} from "@/lib/ai/runtime/model-health";
|
} from "@/lib/ai/runtime/model-health";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
|
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
|
||||||
import { getRequestFailureDetails, isProviderBalanceError } from "@/lib/request-error";
|
import { isProviderBalanceError } from "@/lib/request-error";
|
||||||
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
|
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
|
||||||
|
|
||||||
export interface CanvasLocalEditCompositeRegion {
|
export interface CanvasLocalEditCompositeRegion {
|
||||||
@ -5663,11 +5663,7 @@ export async function generateCanvasImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// An accepted or uncertain request must not start another paid model/variant.
|
// An accepted or uncertain request must not start another paid model/variant.
|
||||||
if (caughtError && (
|
if (caughtError instanceof ProviderTaskUncertainError) throw 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)
|
const retryVariants = Array.isArray(request.retryVariants)
|
||||||
? request.retryVariants
|
? request.retryVariants
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
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)) {
|
if (/AOS结果下载超时/.test(text)) {
|
||||||
return detail("RMBG_RESULT_DOWNLOAD_TIMEOUT", "抠图已完成,但结果图片下载超时。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
|
return detail("RMBG_RESULT_DOWNLOAD_TIMEOUT", "抠图已完成,但结果图片下载超时。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
|
||||||
}
|
}
|
||||||
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)) {
|
if ((isRecord(error) && error.providerOutcomeUnknown === true) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running/i.test(text)) {
|
||||||
return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
|
return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
|
||||||
}
|
}
|
||||||
if (isProviderBalanceError(error)) {
|
if (isProviderBalanceError(error)) {
|
||||||
|
|||||||
@ -21,7 +21,6 @@ export default defineConfig({
|
|||||||
/routes\/video-canvas-media-import\.test\.ts/,
|
/routes\/video-canvas-media-import\.test\.ts/,
|
||||||
/routes\/video-canvas-toolbar-jobs\.test\.ts/,
|
/routes\/video-canvas-toolbar-jobs\.test\.ts/,
|
||||||
/routes\/image-provider-error-classification\.test\.ts/,
|
/routes\/image-provider-error-classification\.test\.ts/,
|
||||||
/routes\/image-generation-status\.test\.ts/,
|
|
||||||
/routes\/canvas-agent-runtime-image-failover\.test\.ts/,
|
/routes\/canvas-agent-runtime-image-failover\.test\.ts/,
|
||||||
/routes\/video-canvas-audio-remote\.test\.ts/,
|
/routes\/video-canvas-audio-remote\.test\.ts/,
|
||||||
/routes\/video-canvas-groups-upscale\.test\.ts/,
|
/routes\/video-canvas-groups-upscale\.test\.ts/,
|
||||||
|
|||||||
@ -1,119 +0,0 @@
|
|||||||
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,8 +71,6 @@ test("a submitted task with an unknown outcome is not submitted again through a
|
|||||||
await expect(h.generate(h.request)).rejects.toThrow();
|
await expect(h.generate(h.request)).rejects.toThrow();
|
||||||
expect(h.calls.map(c => c.id)).toEqual(["primary"]); expect(h.paused.size).toBe(0);
|
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("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);
|
expect(agentImageFailurePolicy(new Error("VIP98K task failed: timeout")).retry).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
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,20 +69,6 @@ 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);
|
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 () => {
|
test("the provider submits once when an HTTP 524 request cannot be located", async () => {
|
||||||
let submissions = 0;
|
let submissions = 0;
|
||||||
const provider = serverLoader({
|
const provider = serverLoader({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user