Compare commits
No commits in common. "main" and "codex/production-48-pm2-definition" have entirely different histories.
main
...
codex/prod
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,7 @@ import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-c
|
|||||||
import type { ChatMessage } from "@/lib/types";
|
import type { ChatMessage } from "@/lib/types";
|
||||||
import { convertMessagesWithImages } from "./image-media";
|
import { convertMessagesWithImages } from "./image-media";
|
||||||
import { isProviderBalanceError } from "@/lib/request-error";
|
import { isProviderBalanceError } from "@/lib/request-error";
|
||||||
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
|
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
|
||||||
|
|
||||||
const chatApiLogger = createLogger("chat.imageFailover");
|
const chatApiLogger = createLogger("chat.imageFailover");
|
||||||
|
|
||||||
@ -326,7 +326,7 @@ export function buildImageFallbackCandidates(
|
|||||||
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
|
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (isImageFallbackModelCoolingDown(item.model.id) || isImageModelBalanceBlocked(item.model)) {
|
if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(item.model)) {
|
||||||
const cooldown = imageFailoverModelCooldown.get(item.model.id);
|
const cooldown = imageFailoverModelCooldown.get(item.model.id);
|
||||||
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
||||||
return false;
|
return false;
|
||||||
@ -488,7 +488,7 @@ export function getImageCrossProviderCandidates(
|
|||||||
}
|
}
|
||||||
if (m.model.id === options.currentModelId) return false;
|
if (m.model.id === options.currentModelId) return false;
|
||||||
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
|
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
|
||||||
if (isImageFallbackModelCoolingDown(m.model.id) || isImageModelBalanceBlocked(m.model)) {
|
if (isImageFallbackModelCoolingDown(m.model.id) || isImageFallbackModelBalanceCoolingDown(m.model)) {
|
||||||
const cooldown = imageFailoverModelCooldown.get(m.model.id);
|
const cooldown = imageFailoverModelCooldown.get(m.model.id);
|
||||||
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import { isExplicitDestructiveIntent } from "@/lib/ai/canvas-agent-runtime/capability-access";
|
import { isExplicitDestructiveIntent } from "@/lib/ai/canvas-agent-runtime/capability-access";
|
||||||
import { inferVideoTaskRequirements } from "@/lib/ai/canvas-agent-runtime/video-protocol";
|
import { inferVideoTaskRequirements } from "@/lib/ai/canvas-agent-runtime/video-protocol";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
|
||||||
import { geolocation } from "@vercel/functions";
|
import { geolocation } from "@vercel/functions";
|
||||||
import {
|
import {
|
||||||
convertToModelMessages,
|
convertToModelMessages,
|
||||||
createUIMessageStream as createAiUIMessageStream,
|
createUIMessageStream,
|
||||||
generateText,
|
generateText,
|
||||||
smoothStream,
|
smoothStream,
|
||||||
stepCountIs,
|
stepCountIs,
|
||||||
@ -337,7 +336,6 @@ import {
|
|||||||
} from "./_lib/image-media";
|
} from "./_lib/image-media";
|
||||||
import {
|
import {
|
||||||
buildImageFallbackCandidates,
|
buildImageFallbackCandidates,
|
||||||
countChatMessageImageInputs,
|
|
||||||
generateImageWithFailoverCandidate,
|
generateImageWithFailoverCandidate,
|
||||||
getImageCrossProviderCandidates,
|
getImageCrossProviderCandidates,
|
||||||
IMAGE_FAILOVER_PROVIDER_ALLOWLIST,
|
IMAGE_FAILOVER_PROVIDER_ALLOWLIST,
|
||||||
@ -346,8 +344,6 @@ import {
|
|||||||
setImageFallbackModelCooldown,
|
setImageFallbackModelCooldown,
|
||||||
shouldCooldownImageFallbackModel,
|
shouldCooldownImageFallbackModel,
|
||||||
} from "./_lib/image-failover";
|
} from "./_lib/image-failover";
|
||||||
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
|
|
||||||
import { isProviderBalanceError } from "@/lib/request-error";
|
|
||||||
import {
|
import {
|
||||||
buildAgentSceneWorkflowPrompt,
|
buildAgentSceneWorkflowPrompt,
|
||||||
buildAgentSlotPrompt,
|
buildAgentSlotPrompt,
|
||||||
@ -426,11 +422,7 @@ 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) =>
|
|
||||||
createAiUIMessageStream({ ...options, execute: (writer) => trackRuntimeWork(() => options.execute(writer)) });
|
|
||||||
|
|
||||||
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
|
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
|
||||||
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
|
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
|
||||||
@ -448,6 +440,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) {
|
||||||
|
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 "图片生成失败,请稍后重试";
|
||||||
|
}
|
||||||
|
return publicMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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 +5682,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 +6024,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 +6179,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 +6411,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 +6962,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 +7287,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 +7515,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 +7833,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 +8042,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 +8080,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
...directImageStatusPayload,
|
...directImageStatusPayload,
|
||||||
},
|
},
|
||||||
@ -8180,7 +8172,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 +8192,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) {
|
||||||
@ -8752,19 +8769,6 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^[0-9a-f-]{36}$/i.test(finalModelId)) {
|
|
||||||
const requested = await getAiModelById({ id: finalModelId });
|
|
||||||
if (requested?.supportsImageGen && isImageModelBalanceBlocked(requested)) {
|
|
||||||
const replacement = resolveConfiguredImageBalanceFallback(requested, await getEnabledAiModelsWithProvider(), {
|
|
||||||
imageSize: effectiveImageGenerationParams.imageSize,
|
|
||||||
aspectRatio: effectiveImageGenerationParams.aspectRatio,
|
|
||||||
referenceImageCount: countChatMessageImageInputs(uiMessagesForModel.filter(item => item.role === "user").slice(-1)),
|
|
||||||
});
|
|
||||||
if (!replacement) return new ChatSDKError("bad_request:api", "当前生图线路余额不足,已暂停新请求;请切换可用模型,已有任务和图片会保留。").toResponse();
|
|
||||||
finalModelId = replacement.model.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否是 UUID 格式(数据库中的模型 ID)
|
// 检查是否是 UUID 格式(数据库中的模型 ID)
|
||||||
const isUUID =
|
const isUUID =
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||||
@ -9172,7 +9176,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 +9263,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 +9337,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 +9404,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 +9468,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 +9545,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 +9610,46 @@ 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,
|
writeAssistantTextChunk(
|
||||||
messageId: generateUUID(), error: finalError, label: "gemini:error",
|
writer,
|
||||||
|
"图片生成失败,请稍后重试",
|
||||||
|
"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: "图片生成失败,请稍后重试" }],
|
||||||
|
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 +9847,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 +9953,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,
|
||||||
@ -9981,10 +10002,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
let fallbackSuccess = false;
|
let fallbackSuccess = false;
|
||||||
const triedFallbackModelIds = new Set<string>();
|
const triedFallbackModelIds = new Set<string>();
|
||||||
const balanceBlockedProviders = new Set<string>();
|
|
||||||
if (isProviderBalanceError(primaryError) && currentModelEntry?.provider.id) balanceBlockedProviders.add(currentModelEntry.provider.id);
|
|
||||||
for (const candidate of fallbackCandidates) {
|
for (const candidate of fallbackCandidates) {
|
||||||
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
|
|
||||||
try {
|
try {
|
||||||
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||||
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
|
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||||
@ -10007,7 +10025,6 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.info(`[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!`);
|
chatApiLogger.info(`[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!`);
|
||||||
break;
|
break;
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
if (isProviderBalanceError(fallbackError)) balanceBlockedProviders.add(candidate.provider.id);
|
|
||||||
const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError);
|
const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError);
|
||||||
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
|
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
|
||||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||||
@ -10029,7 +10046,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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -10057,10 +10073,9 @@ export async function POST(request: Request) {
|
|||||||
currentModelKey: actualModelId,
|
currentModelKey: actualModelId,
|
||||||
currentProviderId: alibabaModelConfig?.providerId,
|
currentProviderId: alibabaModelConfig?.providerId,
|
||||||
providerPriority,
|
providerPriority,
|
||||||
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")) && !balanceBlockedProviders.has(candidate.provider.id));
|
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")));
|
||||||
|
|
||||||
for (const candidate of crossProviderCandidates) {
|
for (const candidate of crossProviderCandidates) {
|
||||||
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
|
|
||||||
try {
|
try {
|
||||||
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||||
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
|
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||||
@ -10142,7 +10157,6 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.info(`[Chat API] ✅ Cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`);
|
chatApiLogger.info(`[Chat API] ✅ Cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||||
break;
|
break;
|
||||||
} catch (crossError) {
|
} catch (crossError) {
|
||||||
if (isProviderBalanceError(crossError)) balanceBlockedProviders.add(candidate.provider.id);
|
|
||||||
const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
|
const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
|
||||||
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
|
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
|
||||||
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
|
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
|
||||||
@ -10164,7 +10178,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 +10245,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 +10309,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);
|
||||||
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 +10474,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 +10519,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 +10685,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 +10749,43 @@ 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,
|
writeAssistantTextChunk(
|
||||||
messageId: generateUUID(), error, label: "volcano:error",
|
writer,
|
||||||
|
"图片生成失败,请稍后重试",
|
||||||
|
"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: "图片生成失败,请稍后重试" }],
|
||||||
|
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 +11358,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 +11449,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 +11620,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 +11679,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);
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,8 +8,6 @@ import { ArrowLeft, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsR
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { formatBeijingTime } from "@/lib/utils";
|
import { formatBeijingTime } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
type FeaturedImage = {
|
type FeaturedImage = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -76,11 +74,18 @@ export default function FeaturedPage() {
|
|||||||
|
|
||||||
const handleDownload = async (url: string, prompt: string) => {
|
const handleDownload = async (url: string, prompt: string) => {
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url, name: "featured-image" });
|
const response = await fetch(url);
|
||||||
toast.success("原图已开始下载");
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = `featured-${Date.now()}.png`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Download failed:", error);
|
console.error("Download failed:", error);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@ -10,10 +9,9 @@ import { Input } from "@/components/ui/input";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
import { formatBeijingTime } from "@/lib/utils";
|
import { formatBeijingTime } from "@/lib/utils";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
type GalleryImage = {
|
type GalleryImage = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -34,7 +32,7 @@ type Pagination = {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildGalleryDownloadFilename(url: string, alt: string, contentType: string) {
|
function buildGalleryDownloadFilename(url: string, alt: string) {
|
||||||
const rawUrl = String(url || "").trim();
|
const rawUrl = String(url || "").trim();
|
||||||
const rawAlt = String(alt || "").trim();
|
const rawAlt = String(alt || "").trim();
|
||||||
const safeBase =
|
const safeBase =
|
||||||
@ -60,7 +58,7 @@ function buildGalleryDownloadFilename(url: string, alt: string, contentType: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${safeBase}.${getImageDownloadExtension(contentType) || extension}`;
|
return `${safeBase}-${Date.now()}.${extension}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GalleryPage() {
|
export default function GalleryPage() {
|
||||||
@ -105,7 +103,7 @@ export default function GalleryPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async (url: string, alt: string) => {
|
const handleDownload = async (url: string, alt: string) => {
|
||||||
const originalUrl = String(url || "").trim();
|
const originalUrl = inferOriginalImageUrl(String(url || "").trim());
|
||||||
if (!originalUrl) {
|
if (!originalUrl) {
|
||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
@ -114,17 +112,32 @@ export default function GalleryPage() {
|
|||||||
try {
|
try {
|
||||||
toast.info("正在准备下载...");
|
toast.info("正在准备下载...");
|
||||||
|
|
||||||
// Validate the supplied URL before any legacy thumbnail inference can
|
const response = await fetch(
|
||||||
// remove signed storage transformation parameters.
|
buildImageProxyUrl(originalUrl, { delivery: "proxy" })
|
||||||
buildImageProxyUrl(originalUrl, { delivery: "original" });
|
);
|
||||||
const parsed = new URL(originalUrl, window.location.origin);
|
if (!response.ok) {
|
||||||
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
|
throw new Error(`download failed: ${response.status}`);
|
||||||
|
}
|
||||||
await downloadOriginalImage({ url: source, name: buildGalleryDownloadFilename(originalUrl, alt, "") });
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = buildGalleryDownloadFilename(originalUrl, alt);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
|
||||||
toast.success("图片已开始下载");
|
toast.success("图片已开始下载");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Download failed:", error);
|
console.error("Download failed:", error);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
const fallbackLink = document.createElement("a");
|
||||||
|
fallbackLink.href = originalUrl;
|
||||||
|
fallbackLink.target = "_blank";
|
||||||
|
fallbackLink.rel = "noopener noreferrer";
|
||||||
|
document.body.appendChild(fallbackLink);
|
||||||
|
fallbackLink.click();
|
||||||
|
document.body.removeChild(fallbackLink);
|
||||||
|
toast.error("下载失败,已为你打开原图,可直接另存为");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -304,12 +317,7 @@ export default function GalleryPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex flex-wrap justify-center gap-6 px-4">
|
<div className="flex flex-wrap justify-center gap-6 px-4">
|
||||||
{images.map((img) => isRetiredStorageUrl(img.url) ? (
|
{images.map((img) => (
|
||||||
<div key={img.id} className="flex h-[270px] w-[200px] shrink-0 flex-col justify-center rounded-lg border bg-muted p-4 text-center text-sm text-muted-foreground">
|
|
||||||
<p>{RETIRED_STORAGE_MESSAGE}</p>
|
|
||||||
<p className="mt-3 line-clamp-3 text-xs">{img.alt}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
<div
|
||||||
key={img.id}
|
key={img.id}
|
||||||
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
|
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
|
||||||
@ -318,7 +326,7 @@ export default function GalleryPage() {
|
|||||||
>
|
>
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<Image
|
<Image
|
||||||
src={isRetiredStorageUrl(img.thumbnailUrl) ? img.url : img.thumbnailUrl || img.url}
|
src={img.thumbnailUrl || img.url}
|
||||||
alt={img.alt}
|
alt={img.alt}
|
||||||
width={200}
|
width={200}
|
||||||
height={270}
|
height={270}
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
import { ChatSDKError } from "@/lib/errors";
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { aiModelHealthEvent } from "@/lib/db/schema";
|
|
||||||
import { and, desc, eq, sql } from "drizzle-orm";
|
|
||||||
import {
|
import {
|
||||||
|
listAiModelHealthEvents,
|
||||||
restoreAiModelHealth,
|
restoreAiModelHealth,
|
||||||
} from "@/lib/ai/runtime/model-health";
|
} from "@/lib/ai/runtime/model-health";
|
||||||
|
|
||||||
@ -17,30 +15,14 @@ export async function GET(request: Request) {
|
|||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const modelId = searchParams.get("modelId");
|
const modelId = searchParams.get("modelId") || undefined;
|
||||||
const providerId = searchParams.get("providerId");
|
const limit = Number(searchParams.get("limit") || 80);
|
||||||
const rawLimit = Number(searchParams.get("limit") || 20);
|
const events = await listAiModelHealthEvents({
|
||||||
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(200, Math.floor(rawLimit))) : 20;
|
modelId,
|
||||||
const rawPage = Number(searchParams.get("page") || 1);
|
limit: Number.isFinite(limit) ? limit : 80,
|
||||||
const page = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1;
|
});
|
||||||
const filters = [];
|
|
||||||
if (modelId) filters.push(eq(aiModelHealthEvent.modelId, modelId));
|
return Response.json({ events });
|
||||||
if (providerId) filters.push(eq(aiModelHealthEvent.providerId, providerId));
|
|
||||||
if (searchParams.get("eventType")) filters.push(eq(aiModelHealthEvent.eventType, searchParams.get("eventType")!));
|
|
||||||
const start = searchParams.get("startDate");
|
|
||||||
const end = searchParams.get("endDate");
|
|
||||||
if (start && /^\d{4}-\d{2}-\d{2}$/.test(start)) filters.push(sql`${aiModelHealthEvent.createdAt}>=${start}::date`);
|
|
||||||
if (end && /^\d{4}-\d{2}-\d{2}$/.test(end)) filters.push(sql`${aiModelHealthEvent.createdAt}<${end}::date+interval '1 day'`);
|
|
||||||
const events = await db.select({
|
|
||||||
id: aiModelHealthEvent.id, modelId: aiModelHealthEvent.modelId, modelDisplayName: aiModelHealthEvent.modelDisplayName,
|
|
||||||
modelRuntimeId: aiModelHealthEvent.modelRuntimeId, providerName: aiModelHealthEvent.providerName,
|
|
||||||
providerId: aiModelHealthEvent.providerId, eventType: aiModelHealthEvent.eventType,
|
|
||||||
errorMessage: aiModelHealthEvent.errorMessage, elapsedMs: aiModelHealthEvent.elapsedMs,
|
|
||||||
consecutiveFailures: aiModelHealthEvent.consecutiveFailures, autoDisabled: aiModelHealthEvent.autoDisabled,
|
|
||||||
counted: aiModelHealthEvent.counted, createdAt: aiModelHealthEvent.createdAt,
|
|
||||||
}).from(aiModelHealthEvent).where(and(...filters)).orderBy(desc(aiModelHealthEvent.createdAt), desc(aiModelHealthEvent.id))
|
|
||||||
.limit(limit + 1).offset((page - 1) * limit);
|
|
||||||
return Response.json({ events: events.slice(0, limit), hasMore: events.length > limit, page });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ChatSDKError) {
|
if (error instanceof ChatSDKError) {
|
||||||
return error.toResponse();
|
return error.toResponse();
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { aiModel, aiProvider } from "@/lib/db/schema";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
import {
|
import {
|
||||||
|
getAllAiModels,
|
||||||
getAiModelById,
|
getAiModelById,
|
||||||
|
getAiModelsByProviderId,
|
||||||
createAiModel,
|
createAiModel,
|
||||||
updateAiModel,
|
updateAiModel,
|
||||||
deleteAiModel,
|
deleteAiModel,
|
||||||
@ -155,14 +154,8 @@ export async function GET(request: Request) {
|
|||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
const providerId = searchParams.get("providerId");
|
const providerId = searchParams.get("providerId");
|
||||||
|
|
||||||
if (searchParams.get("view") === "options") {
|
|
||||||
const models = await db.select({ id: aiModel.id, modelId: aiModel.modelId, displayName: aiModel.displayName,
|
|
||||||
providerName: aiProvider.name }).from(aiModel).leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id));
|
|
||||||
return Response.json({ models: sortModelsForDisplay(models) });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
const model = await db.query.aiModel.findFirst({ where: eq(aiModel.id, id) });
|
const model = await getAiModelById({ id });
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return new ChatSDKError("bad_request:api", "Model not found").toResponse();
|
return new ChatSDKError("bad_request:api", "Model not found").toResponse();
|
||||||
}
|
}
|
||||||
@ -170,19 +163,12 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (providerId) {
|
if (providerId) {
|
||||||
const models = await db.select().from(aiModel).where(eq(aiModel.providerId, providerId));
|
const models = await getAiModelsByProviderId({ providerId });
|
||||||
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = await db.select().from(aiModel);
|
const models = await getAllAiModels();
|
||||||
const normalized = models.map(normalizeModel);
|
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
||||||
const output = searchParams.get("view") === "summary" ? normalized.map(model => {
|
|
||||||
const { capabilities, defaultParams, imageGenConfig, description, ...summary } = model;
|
|
||||||
const fields = ["kind", "mediaType", "modelType", "outputType", "supportedModes"];
|
|
||||||
return { ...summary, healthLastError: summary.healthLastError?.slice(0, 1200),
|
|
||||||
capabilities: Object.fromEntries(fields.filter(key => capabilities?.[key] !== undefined).map(key => [key, capabilities[key]])) };
|
|
||||||
}) : normalized;
|
|
||||||
return Response.json({ models: sortModelsForDisplay(output) });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ChatSDKError) {
|
if (error instanceof ChatSDKError) {
|
||||||
return error.toResponse();
|
return error.toResponse();
|
||||||
|
|||||||
@ -1,31 +1,237 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage, user } from "@/lib/db/schema";
|
import { chat, generatedImage, message, user } from "@/lib/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, desc, sql, and, gte, lte, like, or, isNotNull } from "drizzle-orm";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
|
||||||
import { ChatSDKError } from "@/lib/errors";
|
function extractVideoUrlFromParts(parts: unknown): string | null {
|
||||||
import { mediaRecordsQuery, parseMediaQuery } from "@/lib/admin/media-records";
|
const text = Array.isArray(parts)
|
||||||
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
? parts
|
||||||
|
.map((part) => (part && typeof part === "object" && "text" in part ? String((part as any).text || "") : ""))
|
||||||
|
.join("\n")
|
||||||
|
: typeof parts === "string"
|
||||||
|
? parts
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
const htmlMatch = text.match(/<video[^>]+src="([^"]+)"/i);
|
||||||
|
if (htmlMatch?.[1]) return htmlMatch[1];
|
||||||
|
|
||||||
|
const markdownMatch = text.match(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/i);
|
||||||
|
if (markdownMatch?.[1]) return markdownMatch[1];
|
||||||
|
|
||||||
|
const plainMatch = text.match(/(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/i);
|
||||||
|
return plainMatch?.[1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await requireAdmin();
|
const session = await auth();
|
||||||
const operator = await db.query.user.findFirst({ where: eq(user.id, session.user.id) });
|
|
||||||
if (operator?.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
if (!session?.user?.id) {
|
||||||
const query = parseMediaQuery(request.nextUrl.searchParams);
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (query.startDate && query.endDate && query.startDate > query.endDate) {
|
|
||||||
return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 });
|
|
||||||
}
|
}
|
||||||
const [result] = await db.execute(mediaRecordsQuery(query));
|
|
||||||
const images = await resolveArchivedImageUrls(result.records);
|
// 检查是否为管理员
|
||||||
const total = Number(result.total);
|
const currentUser = await db.query.user.findFirst({
|
||||||
return NextResponse.json({ images, pagination: { page: query.page, pageSize: query.pageSize, total,
|
where: eq(user.id, session.user.id),
|
||||||
totalPages: Math.ceil(total / query.pageSize), totalImages: Number(result.totalImages), totalVideos: Number(result.totalVideos) } });
|
});
|
||||||
|
|
||||||
|
if (currentUser?.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取查询参数
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const page = Number.parseInt(searchParams.get("page") || "1");
|
||||||
|
const pageSize = Number.parseInt(searchParams.get("pageSize") || "200");
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const search = searchParams.get("search") || "";
|
||||||
|
const startDate = searchParams.get("startDate");
|
||||||
|
const endDate = searchParams.get("endDate");
|
||||||
|
|
||||||
|
// 构建查询条件
|
||||||
|
const imageConditions = [];
|
||||||
|
const videoConditions = [
|
||||||
|
eq(message.role, "assistant"),
|
||||||
|
or(
|
||||||
|
isNotNull(message.temporaryVideoUrl),
|
||||||
|
isNotNull(message.permanentVideoUrl),
|
||||||
|
sql`${message.parts}::text ILIKE '%<video%'`,
|
||||||
|
sql`${message.parts}::text ILIKE '%.mp4%'`
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (search) {
|
||||||
|
// 搜索用户名或邮箱
|
||||||
|
const matchingUsers = await db
|
||||||
|
.select({ id: user.id })
|
||||||
|
.from(user)
|
||||||
|
.where(or(
|
||||||
|
like(user.name, `%${search}%`),
|
||||||
|
like(user.email, `%${search}%`)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (matchingUsers.length > 0) {
|
||||||
|
const matchedUserIds = matchingUsers.map((u) => u.id);
|
||||||
|
imageConditions.push(
|
||||||
|
sql`${generatedImage.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
|
||||||
|
);
|
||||||
|
videoConditions.push(
|
||||||
|
sql`${chat.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 没有匹配的用户,返回空结果
|
||||||
|
return NextResponse.json({
|
||||||
|
images: [],
|
||||||
|
pagination: { page, pageSize, total: 0, totalPages: 0 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
const start = new Date(startDate);
|
||||||
|
imageConditions.push(gte(generatedImage.createdAt, start));
|
||||||
|
videoConditions.push(gte(message.createdAt, start));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
const end = new Date(endDate);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
imageConditions.push(lte(generatedImage.createdAt, end));
|
||||||
|
videoConditions.push(lte(message.createdAt, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageWhereClause = imageConditions.length > 0 ? and(...imageConditions) : undefined;
|
||||||
|
const videoWhereClause = videoConditions.length > 0 ? and(...videoConditions) : undefined;
|
||||||
|
|
||||||
|
// 查询图片总数
|
||||||
|
const imageCountResult = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(generatedImage)
|
||||||
|
.where(imageWhereClause);
|
||||||
|
|
||||||
|
// 查询视频总数
|
||||||
|
const videoCountResult = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(message)
|
||||||
|
.innerJoin(chat, eq(message.chatId, chat.id))
|
||||||
|
.where(videoWhereClause);
|
||||||
|
|
||||||
|
const totalImages = imageCountResult[0]?.count || 0;
|
||||||
|
const totalVideos = videoCountResult[0]?.count || 0;
|
||||||
|
const total = totalImages + totalVideos;
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
const fetchLimit = Math.max(page * pageSize, pageSize);
|
||||||
|
|
||||||
|
// 查询图片列表(包含用户信息)
|
||||||
|
const imageRows = await db
|
||||||
|
.select({
|
||||||
|
id: generatedImage.id,
|
||||||
|
userId: generatedImage.userId,
|
||||||
|
userName: user.name,
|
||||||
|
userEmail: user.email,
|
||||||
|
userDepartment: user.department,
|
||||||
|
thumbnailUrl: generatedImage.thumbnailUrl,
|
||||||
|
originalUrl: generatedImage.originalUrl,
|
||||||
|
imageSize: generatedImage.imageSize,
|
||||||
|
prompt: generatedImage.prompt,
|
||||||
|
modelId: generatedImage.modelId,
|
||||||
|
isFeatured: generatedImage.isFeatured,
|
||||||
|
featuredCategory: generatedImage.featuredCategory,
|
||||||
|
featuredAt: generatedImage.featuredAt,
|
||||||
|
createdAt: generatedImage.createdAt,
|
||||||
|
chatId: generatedImage.chatId,
|
||||||
|
recordType: sql<"image">`'image'`,
|
||||||
|
videoUrl: sql<string | null>`null`,
|
||||||
|
})
|
||||||
|
.from(generatedImage)
|
||||||
|
.leftJoin(user, eq(generatedImage.userId, user.id))
|
||||||
|
.where(imageWhereClause)
|
||||||
|
.orderBy(desc(generatedImage.createdAt))
|
||||||
|
.limit(fetchLimit);
|
||||||
|
|
||||||
|
// 查询视频列表(来自消息表)
|
||||||
|
const videoRows = await db
|
||||||
|
.select({
|
||||||
|
id: message.id,
|
||||||
|
userId: chat.userId,
|
||||||
|
userName: user.name,
|
||||||
|
userEmail: user.email,
|
||||||
|
userDepartment: user.department,
|
||||||
|
temporaryVideoUrl: message.temporaryVideoUrl,
|
||||||
|
permanentVideoUrl: message.permanentVideoUrl,
|
||||||
|
parts: message.parts,
|
||||||
|
videoMetadata: message.videoMetadata,
|
||||||
|
createdAt: message.createdAt,
|
||||||
|
chatId: message.chatId,
|
||||||
|
})
|
||||||
|
.from(message)
|
||||||
|
.innerJoin(chat, eq(message.chatId, chat.id))
|
||||||
|
.leftJoin(user, eq(chat.userId, user.id))
|
||||||
|
.where(videoWhereClause)
|
||||||
|
.orderBy(desc(message.createdAt))
|
||||||
|
.limit(fetchLimit);
|
||||||
|
|
||||||
|
const normalizedVideos = videoRows
|
||||||
|
.map((item) => {
|
||||||
|
const metadata = (item.videoMetadata as Record<string, any> | null) || {};
|
||||||
|
const videoUrl =
|
||||||
|
item.permanentVideoUrl ||
|
||||||
|
item.temporaryVideoUrl ||
|
||||||
|
extractVideoUrlFromParts(item.parts);
|
||||||
|
|
||||||
|
if (!videoUrl) return null;
|
||||||
|
|
||||||
|
const resolution =
|
||||||
|
typeof metadata.resolution === "string" && metadata.resolution.trim()
|
||||||
|
? metadata.resolution.trim()
|
||||||
|
: null;
|
||||||
|
const prompt =
|
||||||
|
typeof metadata.prompt === "string" && metadata.prompt.trim()
|
||||||
|
? metadata.prompt.trim()
|
||||||
|
: null;
|
||||||
|
const modelId =
|
||||||
|
typeof metadata.model === "string" && metadata.model.trim()
|
||||||
|
? metadata.model.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `video-${item.id}`,
|
||||||
|
sourceMessageId: item.id,
|
||||||
|
userId: item.userId,
|
||||||
|
userName: item.userName,
|
||||||
|
userEmail: item.userEmail,
|
||||||
|
userDepartment: item.userDepartment,
|
||||||
|
thumbnailUrl: videoUrl,
|
||||||
|
originalUrl: videoUrl,
|
||||||
|
videoUrl,
|
||||||
|
imageSize: resolution,
|
||||||
|
prompt,
|
||||||
|
modelId,
|
||||||
|
isFeatured: false,
|
||||||
|
featuredCategory: null,
|
||||||
|
featuredAt: null,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
chatId: item.chatId,
|
||||||
|
recordType: "video" as const,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is NonNullable<typeof item> => Boolean(item));
|
||||||
|
|
||||||
|
const merged = [...imageRows, ...normalizedVideos].sort(
|
||||||
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
);
|
||||||
|
const images = merged.slice(offset, offset + pageSize);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
images,
|
||||||
|
pagination: { page, pageSize, total, totalPages, totalImages, totalVideos }
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ChatSDKError) return error.toResponse();
|
console.error("[Admin Images API] Error:", error);
|
||||||
console.error("[Admin Images API] GET failed", error);
|
return NextResponse.json({ error: "Failed to fetch images" }, { status: 500 });
|
||||||
return NextResponse.json({ error: "记录加载失败,请重试" }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
|
||||||
import { ChatSDKError } from "@/lib/errors";
|
|
||||||
import { parseMediaQuery } from "@/lib/admin/media-records";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
await requireAdmin();
|
|
||||||
const q = parseMediaQuery(request.nextUrl.searchParams);
|
|
||||||
const status = request.nextUrl.searchParams.get("status") || "all";
|
|
||||||
const filters = [sql`true`];
|
|
||||||
if (q.search)
|
|
||||||
filters.push(sql`(u.name ilike ${`%${q.search}%`} or u.email ilike ${`%${q.search}%`})`);
|
|
||||||
if (["pending", "running", "failed", "succeeded"].includes(status))
|
|
||||||
filters.push(sql`t.status=${status}`);
|
|
||||||
if (q.startDate) filters.push(sql`t."createdAt">=${q.startDate}::date - interval '8 hours'`);
|
|
||||||
if (q.endDate) filters.push(sql`t."createdAt"<${q.endDate}::date+interval '16 hours'`);
|
|
||||||
const [result] = await db.execute(sql`with filtered as not materialized (
|
|
||||||
select t.id,u.name as "userName",t.status,t."createdAt"+interval '8 hours' as "createdAt",t.error,coalesce(t.request->>'modelId',t.request->>'model') as model,
|
|
||||||
extract(epoch from (t."completedAt"-t."startedAt"))*1000 as "elapsedMs"
|
|
||||||
from "CanvasImageTask" t left join "User" u on u.id=t."userId" where ${sql.join(filters, sql` and `)}
|
|
||||||
) select (select count(*)::int from filtered) as total,
|
|
||||||
coalesce((select jsonb_agg(p) from (select * from filtered order by "createdAt" desc,id desc limit ${q.pageSize} offset ${(q.page - 1) * q.pageSize}) p),'[]'::jsonb) as tasks`);
|
|
||||||
return NextResponse.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ChatSDKError) return error.toResponse();
|
|
||||||
return NextResponse.json({ error: "任务记录加载失败,请重试" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -106,22 +106,6 @@ export async function POST(request: Request) {
|
|||||||
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
|
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
|
||||||
: catalogPresets;
|
: catalogPresets;
|
||||||
|
|
||||||
if (body?.dryRun === true) {
|
|
||||||
const catalogIds = new Set(catalogPresets.map(preset => preset.modelId));
|
|
||||||
const fields = ["displayName", "description", "supportsVision", "supportsImageGen", "isHidden", "sortOrder", "capabilities", "defaultParams", "imageGenConfig"] as const;
|
|
||||||
const added = presets.filter(preset => !existingModelIds.has(preset.modelId)).map(preset => ({ modelId: preset.modelId, displayName: preset.displayName }));
|
|
||||||
const updated = presets.flatMap(preset => {
|
|
||||||
const existing = existingModels.find(model => model.modelId === preset.modelId);
|
|
||||||
if (!existing) return [];
|
|
||||||
const changedFields: string[] = fields.filter(field => preset[field] !== undefined && JSON.stringify(existing[field]) !== JSON.stringify(preset[field]));
|
|
||||||
if (existing.avatarUrl) changedFields.push("avatarUrl");
|
|
||||||
if (requestedModelIds !== null && !existing.isEnabled) changedFields.push("isEnabled");
|
|
||||||
return changedFields.length ? [{ modelId: preset.modelId, displayName: existing.displayName, changedFields }] : [];
|
|
||||||
});
|
|
||||||
const disabled = requestedModelIds === null ? existingModels.filter(model => !catalogIds.has(model.modelId) && (model.isEnabled || !model.isHidden)).map(model => ({ modelId: model.modelId, displayName: model.displayName })) : [];
|
|
||||||
return NextResponse.json({ preview: { added, updated, disabled }, total: catalogPresets.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
const addedModels: AiModel[] = [];
|
const addedModels: AiModel[] = [];
|
||||||
const updatedModels: AiModel[] = [];
|
const updatedModels: AiModel[] = [];
|
||||||
const disabledModels: AiModel[] = [];
|
const disabledModels: AiModel[] = [];
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { canvasProject } from "@/lib/db/schema";
|
import { canvasProject } from "@/lib/db/schema";
|
||||||
@ -57,20 +56,14 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const requestedId = body.id === undefined ? undefined : z.string().uuid().safeParse(body.id);
|
|
||||||
if (requestedId && !requestedId.success) {
|
|
||||||
return NextResponse.json({ success: false, error: "项目编号无效" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const id = requestedId?.data;
|
|
||||||
const title = String(body.title || "").trim() || "未命名生图项目";
|
const title = String(body.title || "").trim() || "未命名生图项目";
|
||||||
const description = String(body.description || "").trim() || null;
|
const description = String(body.description || "").trim() || null;
|
||||||
const thumbnail = String(body.thumbnail || "").trim() || null;
|
const thumbnail = String(body.thumbnail || "").trim() || null;
|
||||||
const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState);
|
const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState);
|
||||||
|
|
||||||
const [insertedProject] = await db
|
const [project] = await db
|
||||||
.insert(canvasProject)
|
.insert(canvasProject)
|
||||||
.values({
|
.values({
|
||||||
id,
|
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@ -85,22 +78,8 @@ export async function POST(request: NextRequest) {
|
|||||||
agentProjectState: projectState,
|
agentProjectState: projectState,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
})
|
||||||
.onConflictDoNothing({ target: canvasProject.id })
|
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const project = insertedProject || (id ? (await db
|
|
||||||
.select()
|
|
||||||
.from(canvasProject)
|
|
||||||
.where(and(
|
|
||||||
eq(canvasProject.id, id),
|
|
||||||
eq(canvasProject.userId, session.user.id),
|
|
||||||
eq(canvasProject.workspaceType, "agent")
|
|
||||||
))
|
|
||||||
.limit(1))[0] : undefined);
|
|
||||||
if (!project) {
|
|
||||||
return NextResponse.json({ success: false, error: "项目编号不可用,请重试" }, { status: 409 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
|
return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AgentProjects API] 创建项目失败:", error);
|
console.error("[AgentProjects API] 创建项目失败:", error);
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export function GET() {
|
|
||||||
return NextResponse.json({ version: process.env.NEXT_PUBLIC_APP_RELEASE || "development" }, {
|
|
||||||
headers: { "Cache-Control": "no-store, max-age=0" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
|
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
|
||||||
|
|
||||||
@ -13,6 +12,7 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
|||||||
"tripo3d.ai",
|
"tripo3d.ai",
|
||||||
"tripo3d.com",
|
"tripo3d.com",
|
||||||
"worldlabs.ai",
|
"worldlabs.ai",
|
||||||
|
"s3.1080p.hk",
|
||||||
"img.yydd.ai",
|
"img.yydd.ai",
|
||||||
"scqny.jiansun.vip",
|
"scqny.jiansun.vip",
|
||||||
"blob.vercel-storage.com",
|
"blob.vercel-storage.com",
|
||||||
@ -25,6 +25,7 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
|||||||
|
|
||||||
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
|
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
|
||||||
"bbyy.ai",
|
"bbyy.ai",
|
||||||
|
"s3.1080p.hk",
|
||||||
"img.yydd.ai",
|
"img.yydd.ai",
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -164,9 +165,6 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
|
|||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const rawUrl = String(searchParams.get("url") || "").trim();
|
const rawUrl = String(searchParams.get("url") || "").trim();
|
||||||
if (isRetiredStorageUrl(rawUrl)) {
|
|
||||||
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
|
||||||
}
|
|
||||||
if (!rawUrl) {
|
if (!rawUrl) {
|
||||||
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@ -182,18 +180,6 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
|
|||||||
return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizeLegacyAliyunStorageUrl(targetUrl.toString())) {
|
|
||||||
try {
|
|
||||||
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
|
|
||||||
targetUrl = new URL(await resolveLegacyAliyunStorageUrl(targetUrl.toString()));
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message.includes("HTTP 410")) {
|
|
||||||
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAllowedModelHost(targetUrl.hostname)) {
|
if (!isAllowedModelHost(targetUrl.hostname)) {
|
||||||
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
|
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,14 +2,12 @@ import { NextResponse } from "next/server";
|
|||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
|
|
||||||
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
|
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
|
||||||
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
|
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
|
||||||
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
|
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
|
||||||
import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime";
|
import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime";
|
||||||
import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store";
|
import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store";
|
||||||
import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations";
|
import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations";
|
||||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const maxDuration = 600;
|
export const maxDuration = 600;
|
||||||
@ -41,8 +39,7 @@ export async function GET(
|
|||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
|
return NextResponse.json({ success: true, task });
|
||||||
return NextResponse.json({ success: true, task: { ...task, failure, error: failure ? formatRequestFailure(failure, task.id) : task.error } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
@ -54,11 +51,9 @@ export async function POST(
|
|||||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let requestId: string | undefined;
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
|
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
|
||||||
requestId = command.requestId;
|
|
||||||
const result = await executeCanvasCapabilityRuntime({
|
const result = await executeCanvasCapabilityRuntime({
|
||||||
command,
|
command,
|
||||||
projectId: id,
|
projectId: id,
|
||||||
@ -72,9 +67,8 @@ export async function POST(
|
|||||||
error instanceof CanvasCapabilityRuntimeError ||
|
error instanceof CanvasCapabilityRuntimeError ||
|
||||||
error instanceof CanvasAgentMutationError
|
error instanceof CanvasAgentMutationError
|
||||||
) {
|
) {
|
||||||
const failure = getRequestFailureDetails(error);
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: formatRequestFailure(failure, requestId), code: error.code, failure, requestId },
|
{ success: false, error: error.message, code: error.code },
|
||||||
{ status: error.status }
|
{ status: error.status }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -88,10 +82,10 @@ export async function POST(
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const failure = getRequestFailureDetails(error, "执行画布能力失败");
|
const message = error instanceof Error ? error.message : "执行画布能力失败";
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: formatRequestFailure(failure, requestId), failure, requestId },
|
{ success: false, error: message },
|
||||||
{ status: error instanceof RmbgServiceError ? error.status : 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -207,11 +207,7 @@ export async function PUT(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
return NextResponse.json({
|
return NextResponse.json({ success: true, data: saved });
|
||||||
success: true,
|
|
||||||
data: saved,
|
|
||||||
previousCanvasRevision: existingProject?.updatedAt.toISOString() ?? null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("更新/创建画布失败:", error);
|
console.error("更新/创建画布失败:", error);
|
||||||
|
|||||||
@ -329,7 +329,6 @@ export async function POST(request: Request) {
|
|||||||
withModel,
|
withModel,
|
||||||
imageSize: snapshot.imagePreferences?.parameters?.imageSize,
|
imageSize: snapshot.imagePreferences?.parameters?.imageSize,
|
||||||
imagePreferences: snapshot.imagePreferences,
|
imagePreferences: snapshot.imagePreferences,
|
||||||
priorCalls: snapshot.calls,
|
|
||||||
preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined),
|
preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined),
|
||||||
operationSelectedNodeIds: selectedNodeIds,
|
operationSelectedNodeIds: selectedNodeIds,
|
||||||
objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join(
|
objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join(
|
||||||
|
|||||||
@ -1,15 +1,100 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout";
|
import {
|
||||||
|
BoxCutoutServiceError,
|
||||||
|
executeBoxCutoutJob,
|
||||||
|
} from "@/lib/ai/box-cutout/service";
|
||||||
|
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
|
||||||
|
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||||
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
|
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
type BoxCutoutRequest = {
|
||||||
|
imageUrl?: string;
|
||||||
|
cropRect?: {
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
paddingRatio?: number;
|
||||||
|
prompt?: string;
|
||||||
|
chatId?: string | null;
|
||||||
|
messageId?: string | null;
|
||||||
|
skipGallerySave?: boolean;
|
||||||
|
sourceNodeId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
|
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
|
||||||
}
|
}
|
||||||
return executeBoxCutoutPOST(request, session.user.id);
|
|
||||||
|
const body = (await request.json()) as BoxCutoutRequest;
|
||||||
|
const rawImageUrl = String(body.imageUrl || "").trim();
|
||||||
|
if (!rawImageUrl) {
|
||||||
|
return NextResponse.json({ success: false, error: "缺少 imageUrl 参数" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
|
||||||
|
const pointsConfig = await getPointsSystemConfig();
|
||||||
|
const pointsCharge = await prepareFeaturePointsCharge({
|
||||||
|
userId: session.user.id,
|
||||||
|
unitCost: getImageToolCreditCost(pointsConfig, "box-cutout"),
|
||||||
|
reason: "image_box_cutout",
|
||||||
|
modelId: "box-cutout",
|
||||||
|
note: "框选抠图消耗积分",
|
||||||
|
pointsConfig,
|
||||||
|
});
|
||||||
|
if (!pointsCharge.ok) {
|
||||||
|
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeBoxCutoutJob({
|
||||||
|
userId: session.user.id,
|
||||||
|
imageUrl: resolvedImageUrl,
|
||||||
|
cropRect: body.cropRect || {},
|
||||||
|
paddingRatio: body.paddingRatio,
|
||||||
|
prompt: body.prompt || "框选抠图",
|
||||||
|
chatId: body.chatId || null,
|
||||||
|
messageId: body.messageId || null,
|
||||||
|
skipGallerySave: body.skipGallerySave === true,
|
||||||
|
sourceNodeId: body.sourceNodeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pointsCharge.chargeable) {
|
||||||
|
try {
|
||||||
|
await pointsCharge.consume();
|
||||||
|
} catch (pointsError) {
|
||||||
|
console.error("[Points] Failed to consume box cutout points:", pointsError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error.detail || error.message,
|
||||||
|
},
|
||||||
|
{ status: error.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[BoxCutout] 处理失败:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "框选抠图失败",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
|
||||||
import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation";
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
|
||||||
import { after, NextRequest, NextResponse } from "next/server";
|
import { after, NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
|
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
|
||||||
@ -16,22 +12,6 @@ import {
|
|||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 120;
|
||||||
const logger = createLogger("canvas.download");
|
|
||||||
|
|
||||||
function failed(failure: DownloadFailure, requestId: string) {
|
|
||||||
logger.warn("request_failed", { requestId, code: failure.code, status: failure.status });
|
|
||||||
return NextResponse.json({ success: false, error: formatDownloadFailure(failure, requestId), failure, requestId }, {
|
|
||||||
status: failure.status,
|
|
||||||
headers: { "Cache-Control": "no-store", ...(failure.status === 429 ? { "Retry-After": "3" } : {}) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function preparing(requestId: string) {
|
|
||||||
return NextResponse.json({ delivery: "preparing", retryAfter: 2, requestId,
|
|
||||||
message: "正在准备原图,完成后将自动开始下载" }, {
|
|
||||||
status: 202, headers: { "Retry-After": "2", "Cache-Control": "no-store" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
|
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
|
||||||
// A proxy candidate for the same original must not repeat the same slow
|
// A proxy candidate for the same original must not repeat the same slow
|
||||||
@ -40,25 +20,22 @@ async function prepareDownload(input: DownloadPreparationInput, signal: AbortSig
|
|||||||
const source = extractImageProxySourceUrl(url) || url;
|
const source = extractImageProxySourceUrl(url) || url;
|
||||||
return inferOriginalImageUrl(source) || source;
|
return inferOriginalImageUrl(source) || source;
|
||||||
}))];
|
}))];
|
||||||
let lastError: unknown;
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
for (let attempt = 0; attempt < 2; attempt++) {
|
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
for (const url of sources) {
|
for (const url of sources) {
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
const result = await fetchCanvasDownloadAsset({
|
const result = await fetchCanvasDownloadAsset({
|
||||||
file: { url, name: "original" }, index: 0,
|
file: { url, name: "original" }, index: 0,
|
||||||
requestOrigin: input.requestOrigin, cookie: input.cookie,
|
requestOrigin: input.requestOrigin, cookie: input.cookie,
|
||||||
includeSequencePrefix: false, timeoutMs: 60_000, retryCount: 1, signal,
|
includeSequencePrefix: false, timeoutMs: 12_000, retryCount: 1, signal,
|
||||||
});
|
});
|
||||||
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
|
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
|
||||||
lastError = result.error;
|
|
||||||
}
|
}
|
||||||
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
|
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
|
||||||
// No AI/model request is involved and a completed source fetch is shared.
|
// No AI/model request is involved and a completed source fetch is shared.
|
||||||
if (attempt < 1 && getDownloadFailure(lastError).retryable) await new Promise(resolve => setTimeout(resolve, 1_000));
|
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 1_000));
|
||||||
else break;
|
|
||||||
}
|
}
|
||||||
throw new Error(String(lastError || "Original image sources unavailable"));
|
throw new Error("Original image sources unavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRequestOrigin(request: NextRequest): string {
|
function getRequestOrigin(request: NextRequest): string {
|
||||||
@ -98,43 +75,27 @@ function resolveDownloadUrls(body: Record<string, unknown>, origin: string): str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const requestId = randomUUID();
|
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return failed(getDownloadFailure("未登录"), requestId);
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
const requestOrigin = getRequestOrigin(request);
|
const requestOrigin = getRequestOrigin(request);
|
||||||
const candidates = resolveDownloadUrls(body, requestOrigin);
|
const urls = resolveDownloadUrls(body, requestOrigin);
|
||||||
if (candidates.length && candidates.every(isRetiredStorageUrl)) {
|
|
||||||
return failed(getDownloadFailure(RETIRED_STORAGE_MESSAGE), requestId);
|
|
||||||
}
|
|
||||||
const urls = candidates.filter(url => !isRetiredStorageUrl(url));
|
|
||||||
const requestedUrls = [...urls];
|
const requestedUrls = [...urls];
|
||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
return failed(getDownloadFailure("图片地址无效"), requestId);
|
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
||||||
if (body.preferDirect === true) {
|
if (body.preferDirect === true) {
|
||||||
const { task, direct, canPrepareDirect } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch((error) => {
|
const { task, direct } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch(() => ({ task: null, direct: null }));
|
||||||
logger.warn("cdn_lookup_failed", error, { requestId });
|
|
||||||
return { task: null, direct: null, canPrepareDirect: false };
|
|
||||||
});
|
|
||||||
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
|
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
|
||||||
if (task && canPrepareDirect && body.preparation === "async") {
|
|
||||||
const asset = (task.metadata as { downloadAsset?: { status?: string; failure?: DownloadFailure; retryAt?: string } } | null)?.downloadAsset;
|
|
||||||
if (asset?.status === "abandoned" || (asset?.status === "failed" && Date.parse(asset.retryAt || "") > Date.now())) {
|
|
||||||
return failed(asset.failure || getDownloadFailure(null), task.id);
|
|
||||||
}
|
|
||||||
scheduleImageDownloadPreparation(task.id);
|
|
||||||
return preparing(task.id);
|
|
||||||
}
|
|
||||||
if (task?.targetUrl) {
|
if (task?.targetUrl) {
|
||||||
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
|
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
|
||||||
scheduleImageDownloadPreparation(task.id);
|
after(() => prepareImageArchiveDownload(task.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cookie = request.headers.get("cookie") || "";
|
const cookie = request.headers.get("cookie") || "";
|
||||||
@ -143,10 +104,13 @@ export async function POST(request: NextRequest) {
|
|||||||
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
|
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
|
||||||
});
|
});
|
||||||
if (result.status === "preparing") {
|
if (result.status === "preparing") {
|
||||||
return preparing(result.requestId);
|
return NextResponse.json({ delivery: "preparing", retryAfter: 3,
|
||||||
|
message: "正在准备原图,完成后将自动下载" }, {
|
||||||
|
status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (result.status === "failed") {
|
if (result.status === "failed") {
|
||||||
return failed(result.failure, result.requestId);
|
return NextResponse.json({ success: false, error: result.error }, { status: 502 });
|
||||||
}
|
}
|
||||||
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
|
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
|
||||||
"Content-Type": result.asset.contentType,
|
"Content-Type": result.asset.contentType,
|
||||||
@ -167,14 +131,17 @@ export async function POST(request: NextRequest) {
|
|||||||
includeSequencePrefix: false,
|
includeSequencePrefix: false,
|
||||||
});
|
});
|
||||||
if (result.ok) break;
|
if (result.ok) break;
|
||||||
failures.push(result.error);
|
failures.push(`${url}: ${result.error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result?.ok) {
|
if (!result?.ok) {
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
logger.warn("all_sources_failed", { requestId, failures });
|
console.warn("[CanvasDownloadImage] All candidates failed:", failures);
|
||||||
}
|
}
|
||||||
return failed(getDownloadFailure(result?.error), requestId);
|
return NextResponse.json(
|
||||||
|
{ success: false, error: result?.error || "图片源文件暂时不可访问" },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NextResponse(result.bytes, {
|
return new NextResponse(result.bytes, {
|
||||||
@ -187,7 +154,7 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("download_failed", error, { requestId });
|
console.error("[CanvasDownloadImage] Failed:", error);
|
||||||
return failed(getDownloadFailure(error), requestId);
|
return NextResponse.json({ success: false, error: "图片下载失败" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,13 +10,11 @@ import JSZip from "jszip";
|
|||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
||||||
import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery";
|
import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery";
|
||||||
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 300;
|
export const maxDuration = 300;
|
||||||
type DownloadFileItem = { url: string; name?: string };
|
type DownloadFileItem = { url: string; name?: string };
|
||||||
const MAX_DOWNLOAD_FILES = 200;
|
const MAX_DOWNLOAD_FILES = 200;
|
||||||
const TASK_TTL_MS = 24 * 60 * 60_000;
|
const TASK_TTL_MS = 60 * 60_000;
|
||||||
const PREPARATION_TTL_MS = 6 * 60 * 60_000;
|
|
||||||
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
|
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
|
||||||
const active = running.__canvasZipRunning ??= new Set<string>();
|
const active = running.__canvasZipRunning ??= new Set<string>();
|
||||||
function sanitizeFolderName(value: string): string {
|
function sanitizeFolderName(value: string): string {
|
||||||
@ -100,12 +98,8 @@ async function processTask(id: string, userId: string, requestOrigin: string, co
|
|||||||
task = await loadZipTask(id, userId);
|
task = await loadZipTask(id, userId);
|
||||||
if (!task || task.status === "completed" || task.status === "failed") return;
|
if (!task || task.status === "completed" || task.status === "failed") return;
|
||||||
await assertZipDiskSpace();
|
await assertZipDiskSpace();
|
||||||
const existingZip = task.zipFile && task.contentDisposition && await stat(task.zipFile.path).catch(() => null);
|
task.status = "running"; task.stage = "正在准备原图"; task.updatedAt = Date.now();
|
||||||
const reuseZip = existingZip && existingZip.size === task.zipFile!.bytes;
|
|
||||||
task.status = "running"; task.stage = reuseZip ? "正在准备加速下载" : "正在准备原图"; task.updatedAt = Date.now();
|
|
||||||
await saveZipTask(task);
|
await saveZipTask(task);
|
||||||
if (!reuseZip) {
|
|
||||||
delete task.zipFile; delete task.contentDisposition; delete task.cdnUrl;
|
|
||||||
// Stage each image on disk, not all PNG buffers in the web process heap.
|
// Stage each image on disk, not all PNG buffers in the web process heap.
|
||||||
// A restart resumes from these files using the current owner's session.
|
// A restart resumes from these files using the current owner's session.
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
@ -143,28 +137,24 @@ async function processTask(id: string, userId: string, requestOrigin: string, co
|
|||||||
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
|
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
|
||||||
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
|
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
|
||||||
task.contentDisposition = buildContentDisposition(task.folderName);
|
task.contentDisposition = buildContentDisposition(task.folderName);
|
||||||
}
|
|
||||||
task.stage = "正在准备下载链接"; await saveZipTask(task);
|
task.stage = "正在准备下载链接"; await saveZipTask(task);
|
||||||
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile!, disposition: task.contentDisposition!,
|
try {
|
||||||
onProgress: async bytes => {
|
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile, disposition: task.contentDisposition });
|
||||||
task!.stage = `正在传送至七牛 ${Math.round(bytes / task!.zipFile!.bytes * 100)}%`;
|
|
||||||
task!.updatedAt = Date.now(); await saveZipTask(task!);
|
|
||||||
} });
|
|
||||||
if (cdn) {
|
if (cdn) {
|
||||||
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
|
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
|
||||||
if (!cdn.url) throw new Error(cdn.error || `ZIP 已打包,但加速下载未就绪,请再次点击下载继续传送原 ZIP。下载编号:${task.id}`);
|
if (cdn.url) task.cdnUrl = cdn.url;
|
||||||
task.cdnUrl = cdn.url;
|
|
||||||
}
|
}
|
||||||
|
} catch { console.warn("[CanvasDownloadZipTask] CDN delivery unavailable; complete local ZIP retained", { taskId: id }); }
|
||||||
task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
|
task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
|
||||||
task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
|
task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
|
||||||
await saveZipTask(task);
|
await saveZipTask(task);
|
||||||
// The complete ZIP is enough for retries; release staged duplicate bytes.
|
// The complete ZIP is enough for retries; release staged duplicate bytes.
|
||||||
await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined)));
|
await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined)));
|
||||||
console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount,
|
console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount,
|
||||||
bytes: task.zipFile!.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
|
bytes: task.zipFile.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (task) {
|
if (task) {
|
||||||
task.status = "failed"; task.stage = task.zipFile ? "ZIP 已打包,加速下载未就绪" : "打包未完成";
|
task.status = "failed"; task.stage = "打包未完成";
|
||||||
task.error = error instanceof Error ? error.message : "打包失败,请稍后重试";
|
task.error = error instanceof Error ? error.message : "打包失败,请稍后重试";
|
||||||
task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined);
|
task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined);
|
||||||
}
|
}
|
||||||
@ -172,7 +162,7 @@ async function processTask(id: string, userId: string, requestOrigin: string, co
|
|||||||
} finally { await release?.().catch(() => undefined); active.delete(id); }
|
} finally { await release?.().catch(() => undefined); active.delete(id); }
|
||||||
}
|
}
|
||||||
function schedule(task: ZipTask, request: NextRequest) {
|
function schedule(task: ZipTask, request: NextRequest) {
|
||||||
const run = () => trackRuntimeWork(() => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || ""));
|
const run = () => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || "");
|
||||||
after(run);
|
after(run);
|
||||||
}
|
}
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@ -197,17 +187,14 @@ export async function POST(request: NextRequest) {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files,
|
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files,
|
||||||
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0,
|
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0,
|
||||||
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + PREPARATION_TTL_MS };
|
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + 6 * TASK_TTL_MS };
|
||||||
await saveZipTask(task);
|
await saveZipTask(task);
|
||||||
} else if ((task.status === "failed" || task.status === "completed" && task.cdnObject && !task.cdnUrl) && body.retry === true) {
|
} else if (task.status === "failed" && body.retry === true) {
|
||||||
task.status = "pending"; task.error = undefined;
|
task.status = "pending"; task.error = undefined;
|
||||||
await assertZipTaskCapacity(session.user.id);
|
await assertZipTaskCapacity(session.user.id);
|
||||||
if (!task.zipFile) {
|
|
||||||
for (const file of task.files) delete file.error;
|
for (const file of task.files) delete file.error;
|
||||||
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
|
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
|
||||||
task.failedCount = 0;
|
task.failedCount = 0; task.updatedAt = Date.now(); task.expiresAt = Date.now() + 6 * TASK_TTL_MS;
|
||||||
}
|
|
||||||
task.updatedAt = Date.now(); task.expiresAt = Date.now() + PREPARATION_TTL_MS;
|
|
||||||
await saveZipTask(task);
|
await saveZipTask(task);
|
||||||
}
|
}
|
||||||
if (task.status === "pending" || task.status === "running") schedule(task, request);
|
if (task.status === "pending" || task.status === "running") schedule(task, request);
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
|
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
|
||||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
|
||||||
|
|
||||||
export const maxDuration = 1200;
|
export const maxDuration = 1200;
|
||||||
|
|
||||||
@ -24,15 +23,13 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
|
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
task: {
|
task: {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
attempts: task.attempts,
|
attempts: task.attempts,
|
||||||
error: failure ? formatRequestFailure(failure, task.id) : null,
|
error: task.error || null,
|
||||||
failure,
|
|
||||||
result: task.result || null,
|
result: task.result || null,
|
||||||
createdAt: task.createdAt,
|
createdAt: task.createdAt,
|
||||||
startedAt: task.startedAt,
|
startedAt: task.startedAt,
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage, user, aiModel } from "@/lib/db/schema";
|
import { generatedImage, user, aiModel } from "@/lib/db/schema";
|
||||||
import { and, eq, desc, sql } from "drizzle-orm";
|
import { eq, desc, sql } from "drizzle-orm";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_URL_PATTERN } from "@/lib/storage/retired-storage";
|
|
||||||
import { cachedPublicJson } from "@/lib/public-api/performance";
|
import { cachedPublicJson } from "@/lib/public-api/performance";
|
||||||
|
|
||||||
const FEATURED_TIMEOUT_MS = 1800;
|
const FEATURED_TIMEOUT_MS = 1800;
|
||||||
@ -58,17 +57,13 @@ export async function GET(request: NextRequest) {
|
|||||||
userName: user.name,
|
userName: user.name,
|
||||||
userEmail: user.email,
|
userEmail: user.email,
|
||||||
userDepartment: user.department,
|
userDepartment: user.department,
|
||||||
modelName: sql<string | null>`(select ${aiModel.displayName} from ${aiModel}
|
modelName: aiModel.displayName,
|
||||||
where ${aiModel.modelId} = ${generatedImage.modelId}
|
|
||||||
order by ${aiModel.isEnabled} desc, ${aiModel.createdAt} desc, ${aiModel.id} asc limit 1)`,
|
|
||||||
totalCount: sql<number>`count(*) over()::int`,
|
totalCount: sql<number>`count(*) over()::int`,
|
||||||
})
|
})
|
||||||
.from(generatedImage)
|
.from(generatedImage)
|
||||||
.leftJoin(user, eq(generatedImage.userId, user.id))
|
.leftJoin(user, eq(generatedImage.userId, user.id))
|
||||||
.where(and(
|
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
|
||||||
eq(generatedImage.isFeatured, true),
|
.where(eq(generatedImage.isFeatured, true))
|
||||||
sql`${generatedImage.originalUrl} !~* ${RETIRED_STORAGE_URL_PATTERN}`,
|
|
||||||
))
|
|
||||||
.orderBy(desc(generatedImage.featuredAt))
|
.orderBy(desc(generatedImage.featuredAt))
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
@ -79,7 +74,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return {
|
return {
|
||||||
images: featuredImages.map((img) => ({
|
images: featuredImages.map((img) => ({
|
||||||
id: img.id,
|
id: img.id,
|
||||||
thumbnailUrl: isRetiredStorageUrl(img.thumbnailUrl) ? img.originalUrl : img.thumbnailUrl,
|
thumbnailUrl: img.thumbnailUrl,
|
||||||
originalUrl: img.originalUrl,
|
originalUrl: img.originalUrl,
|
||||||
imageSize: img.imageSize,
|
imageSize: img.imageSize,
|
||||||
prompt: img.prompt,
|
prompt: img.prompt,
|
||||||
|
|||||||
@ -1,12 +1,7 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { formatDownloadFailure, getDownloadFailure } from "@/lib/canvas/download-error";
|
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
|
||||||
import { buildImageProxyUrl, isAlibabaModelImageUrl } from "@/lib/image-delivery";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
const logger = createLogger("image.download");
|
|
||||||
|
|
||||||
const DEFAULT_QUALITY = 82;
|
const DEFAULT_QUALITY = 82;
|
||||||
const MAX_WIDTH = 4096;
|
const MAX_WIDTH = 4096;
|
||||||
@ -14,7 +9,6 @@ const MIN_WIDTH = 64;
|
|||||||
const MIN_QUALITY = 40;
|
const MIN_QUALITY = 40;
|
||||||
const MAX_QUALITY = 100;
|
const MAX_QUALITY = 100;
|
||||||
const FETCH_TIMEOUT_MS = 15000;
|
const FETCH_TIMEOUT_MS = 15000;
|
||||||
const SMALL_PREVIEW_FETCH_TIMEOUT_MS = 45000;
|
|
||||||
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
||||||
const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256;
|
const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256;
|
||||||
|
|
||||||
@ -133,15 +127,13 @@ function buildImageResponse(
|
|||||||
etag: string;
|
etag: string;
|
||||||
vary?: string;
|
vary?: string;
|
||||||
cacheStatus?: "HIT" | "MISS";
|
cacheStatus?: "HIT" | "MISS";
|
||||||
cacheControl?: string;
|
|
||||||
attachment?: boolean;
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": params.contentType,
|
"Content-Type": params.contentType,
|
||||||
"Content-Length": String(body.length),
|
"Content-Length": String(body.length),
|
||||||
ETag: params.etag,
|
ETag: params.etag,
|
||||||
"Cache-Control": params.cacheControl || "public, max-age=31536000, immutable, stale-while-revalidate=86400",
|
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
|
||||||
};
|
};
|
||||||
if (params.vary) {
|
if (params.vary) {
|
||||||
headers.Vary = params.vary;
|
headers.Vary = params.vary;
|
||||||
@ -149,11 +141,6 @@ function buildImageResponse(
|
|||||||
if (params.cacheStatus) {
|
if (params.cacheStatus) {
|
||||||
headers["X-Image-Proxy-Cache"] = params.cacheStatus;
|
headers["X-Image-Proxy-Cache"] = params.cacheStatus;
|
||||||
}
|
}
|
||||||
if (params.attachment) {
|
|
||||||
headers["Content-Disposition"] = "attachment";
|
|
||||||
headers["X-Content-Type-Options"] = "nosniff";
|
|
||||||
headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
|
|
||||||
}
|
|
||||||
return new NextResponse(body, {
|
return new NextResponse(body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers,
|
headers,
|
||||||
@ -243,7 +230,6 @@ function resolveDirectImageTarget(
|
|||||||
requestedQuality: number | null,
|
requestedQuality: number | null,
|
||||||
requestedFormat: string | null
|
requestedFormat: string | null
|
||||||
): URL | null {
|
): URL | null {
|
||||||
if (isAlibabaModelImageUrl(target.toString())) return null;
|
|
||||||
if (!isBrowserImageRequest(request)) {
|
if (!isBrowserImageRequest(request)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -272,19 +258,7 @@ function redirectToDirectImage(target: URL): NextResponse {
|
|||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
const requestId = crypto.randomUUID();
|
|
||||||
const downloadFailure = (error: unknown) => {
|
|
||||||
const failure = getDownloadFailure(error);
|
|
||||||
logger.warn("original_download_failed", error, { requestId, code: failure.code, status: failure.status });
|
|
||||||
return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId }, {
|
|
||||||
status: failure.status, headers: { "Cache-Control": "no-store" },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const rawUrl = (searchParams.get("url") || "").trim();
|
const rawUrl = (searchParams.get("url") || "").trim();
|
||||||
if (isRetiredStorageUrl(rawUrl)) {
|
|
||||||
if (searchParams.get("original") === "1") return downloadFailure(RETIRED_STORAGE_MESSAGE);
|
|
||||||
return NextResponse.json({ error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
|
||||||
}
|
|
||||||
if (!rawUrl) {
|
if (!rawUrl) {
|
||||||
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
|
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@ -300,24 +274,6 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 });
|
return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizeLegacyAliyunStorageUrl(target.toString())) {
|
|
||||||
try {
|
|
||||||
if (searchParams.get("original") === "1") {
|
|
||||||
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
|
|
||||||
buildImageProxyUrl(target.toString(), { delivery: "original" });
|
|
||||||
}
|
|
||||||
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
|
|
||||||
target = new URL(await resolveLegacyAliyunStorageUrl(target.toString()));
|
|
||||||
} catch (error) {
|
|
||||||
if (searchParams.get("original") === "1") return downloadFailure(error);
|
|
||||||
const failure = getDownloadFailure(error);
|
|
||||||
return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId,
|
|
||||||
code: failure.code === "STORAGE_RETIRED" ? "storage_retired" : failure.code }, {
|
|
||||||
status: failure.status, headers: { "Cache-Control": "no-store" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isBlockedHost(target.hostname)) {
|
if (isBlockedHost(target.hostname)) {
|
||||||
return NextResponse.json({ error: "Blocked target host" }, { status: 403 });
|
return NextResponse.json({ error: "Blocked target host" }, { status: 403 });
|
||||||
}
|
}
|
||||||
@ -329,47 +285,8 @@ export async function GET(request: NextRequest) {
|
|||||||
const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null;
|
const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null;
|
||||||
const requestedFormat = searchParams.get("f");
|
const requestedFormat = searchParams.get("f");
|
||||||
const forceProxy = searchParams.get("proxy") === "1";
|
const forceProxy = searchParams.get("proxy") === "1";
|
||||||
const original = searchParams.get("original") === "1";
|
|
||||||
|
|
||||||
if (original && (requestedWidth || requestedQuality || requestedFormat)) {
|
if (!forceProxy) {
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Original images cannot be transformed" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!original && isAlibabaModelImageUrl(target.toString())) {
|
|
||||||
try {
|
|
||||||
const { getArchivedImageUrlMappings } = await import("@/lib/storage/image-archive-db");
|
|
||||||
const mappings = await getArchivedImageUrlMappings([target.toString()]);
|
|
||||||
const archived = mappings[target.toString()];
|
|
||||||
if (archived) {
|
|
||||||
const preview = buildImageProxyUrl(archived, {
|
|
||||||
width: width || undefined,
|
|
||||||
quality,
|
|
||||||
format: ["webp", "avif", "jpeg", "png"].includes(requestedFormat || "") ? requestedFormat as OutputFormat : undefined,
|
|
||||||
});
|
|
||||||
if (/^https?:\/\//i.test(preview)) return redirectToDirectImage(new URL(preview));
|
|
||||||
target = new URL(archived);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn("preview_archive_lookup_failed", error, { requestId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (original && searchParams.get("download") === "resolve") {
|
|
||||||
try {
|
|
||||||
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
|
|
||||||
buildImageProxyUrl(target.toString(), { delivery: "original" });
|
|
||||||
const { resolveOriginalCdnDownload } = await import("@/lib/storage/original-image-download");
|
|
||||||
const direct = await resolveOriginalCdnDownload(target.toString(), searchParams.get("name") || "", request.signal);
|
|
||||||
return NextResponse.json(direct || { delivery: "proxy" }, { headers: { "Cache-Control": "no-store" } });
|
|
||||||
} catch (error) {
|
|
||||||
return downloadFailure(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!forceProxy && !original) {
|
|
||||||
const directImageTarget = resolveDirectImageTarget(
|
const directImageTarget = resolveDirectImageTarget(
|
||||||
request,
|
request,
|
||||||
target,
|
target,
|
||||||
@ -382,12 +299,10 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = original
|
const cacheKey = `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
|
||||||
? `${target.toString()}|original-v1`
|
|
||||||
: `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
|
|
||||||
const etag = buildEtag(cacheKey);
|
const etag = buildEtag(cacheKey);
|
||||||
const ifNoneMatch = request.headers.get("if-none-match");
|
const ifNoneMatch = request.headers.get("if-none-match");
|
||||||
if (!original && ifNoneMatch && ifNoneMatch === etag) {
|
if (ifNoneMatch && ifNoneMatch === etag) {
|
||||||
return new NextResponse(null, {
|
return new NextResponse(null, {
|
||||||
status: 304,
|
status: 304,
|
||||||
headers: {
|
headers: {
|
||||||
@ -397,7 +312,7 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = original ? null : getCachedImageResponse(cacheKey);
|
const cached = getCachedImageResponse(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return buildImageResponse(cached.body, {
|
return buildImageResponse(cached.body, {
|
||||||
contentType: cached.contentType,
|
contentType: cached.contentType,
|
||||||
@ -407,73 +322,32 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchSignal = AbortSignal.any([
|
|
||||||
request.signal,
|
|
||||||
AbortSignal.timeout(original ? 60_000 : isAlibabaModelImageUrl(target.toString()) || width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
|
|
||||||
]);
|
|
||||||
const upstreamFailure = (error: unknown) => {
|
|
||||||
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
|
|
||||||
if (original) return downloadFailure(error);
|
|
||||||
const timedOut = (error instanceof Error && error.name === "TimeoutError") || fetchSignal.reason?.name === "TimeoutError";
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: timedOut ? "读取源图片超时,请重试" : "源图片传输中断,请重试" },
|
|
||||||
{ status: timedOut ? 504 : 502 },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
let contentType: string;
|
|
||||||
let originalBuffer: Buffer;
|
|
||||||
if (!original && isAlibabaModelImageUrl(target.toString())) {
|
|
||||||
try {
|
|
||||||
const { fetchImageBytes } = await import("@/lib/storage/image-byte-cache");
|
|
||||||
const data = await fetchImageBytes(target.toString(), { timeoutMs: SMALL_PREVIEW_FETCH_TIMEOUT_MS });
|
|
||||||
originalBuffer = data.buffer;
|
|
||||||
contentType = data.contentType;
|
|
||||||
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
|
|
||||||
if (!contentType.toLowerCase().startsWith("image/")) {
|
|
||||||
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return upstreamFailure(error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let upstreamResponse: Response;
|
let upstreamResponse: Response;
|
||||||
try {
|
try {
|
||||||
upstreamResponse = await fetch(target.toString(), {
|
upstreamResponse = await fetch(target.toString(), {
|
||||||
signal: fetchSignal,
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
headers: {
|
headers: {
|
||||||
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return upstreamFailure(error);
|
console.error("[ImageProxy] Upstream fetch failed:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch upstream image" }, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!upstreamResponse.ok) {
|
if (!upstreamResponse.ok) {
|
||||||
if (original) return downloadFailure(`HTTP ${upstreamResponse.status}`);
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Upstream responded with ${upstreamResponse.status}` },
|
{ error: `Upstream responded with ${upstreamResponse.status}` },
|
||||||
{ status: 502 }
|
{ status: 502 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
|
|
||||||
|
const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
|
||||||
if (!contentType.toLowerCase().startsWith("image/")) {
|
if (!contentType.toLowerCase().startsWith("image/")) {
|
||||||
if (original) return downloadFailure("Source is not an image");
|
|
||||||
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||||
} catch (error) {
|
|
||||||
return upstreamFailure(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (original) {
|
|
||||||
return buildImageResponse(originalBuffer, {
|
|
||||||
contentType,
|
|
||||||
etag: `"${crypto.createHash("sha256").update(originalBuffer).digest("hex")}"`,
|
|
||||||
cacheControl: "private, no-store",
|
|
||||||
attachment: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const isSvg = contentType.toLowerCase().includes("image/svg");
|
const isSvg = contentType.toLowerCase().includes("image/svg");
|
||||||
const isGif = contentType.toLowerCase().includes("image/gif");
|
const isGif = contentType.toLowerCase().includes("image/gif");
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { preprocessProductImageWithRmbg } from "@/lib/scene-fusion/preprocess-product";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@ -70,6 +69,7 @@ const SCENE_FUSION_REQUEST_STAGGER_MS = 350;
|
|||||||
const SCENE_FUSION_AGENT_TIMEOUT_MS = 20000;
|
const SCENE_FUSION_AGENT_TIMEOUT_MS = 20000;
|
||||||
const SCENE_FUSION_AGENT_MAX_WORKERS = 3;
|
const SCENE_FUSION_AGENT_MAX_WORKERS = 3;
|
||||||
const SCENE_FUSION_PROCESS_MAX_WORKERS = 4;
|
const SCENE_FUSION_PROCESS_MAX_WORKERS = 4;
|
||||||
|
const SCENE_FUSION_PRE_RMBG_TIMEOUT_MS = 60_000;
|
||||||
const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000;
|
const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000;
|
||||||
const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8;
|
const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8;
|
||||||
const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000;
|
const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000;
|
||||||
@ -236,6 +236,41 @@ function isSceneFusionReviewRejectError(message: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPreRmbgCandidateUrls(request: Request): string[] {
|
||||||
|
const urls: string[] = [];
|
||||||
|
const requestUrl = new URL(request.url);
|
||||||
|
const requestHost = requestUrl.hostname.toLowerCase();
|
||||||
|
const requestProtocol = requestUrl.protocol.replace(":", "");
|
||||||
|
if (!(requestProtocol === "https" && (requestHost === "localhost" || requestHost === "127.0.0.1"))) {
|
||||||
|
urls.push(new URL("/api/rmbg", request.url).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = request.headers.get("host")?.trim();
|
||||||
|
const protoHint = request.headers
|
||||||
|
.get("x-forwarded-proto")
|
||||||
|
?.split(",")[0]
|
||||||
|
?.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const protocol = protoHint === "https" || protoHint === "http" ? protoHint : requestUrl.protocol.replace(":", "");
|
||||||
|
|
||||||
|
if (host) {
|
||||||
|
const normalizedHost = host.split(":")[0]?.toLowerCase() || "";
|
||||||
|
const isLocalHost = normalizedHost === "localhost" || normalizedHost === "127.0.0.1";
|
||||||
|
if (!(protocol === "https" && isLocalHost)) {
|
||||||
|
urls.push(`${protocol}://${host}/api/rmbg`);
|
||||||
|
}
|
||||||
|
if (isLocalHost) {
|
||||||
|
urls.push(`http://${host}/api/rmbg`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPort = requestUrl.port || process.env.PORT || "3125";
|
||||||
|
urls.push(`http://127.0.0.1:${localPort}/api/rmbg`);
|
||||||
|
urls.push(`http://localhost:${localPort}/api/rmbg`);
|
||||||
|
|
||||||
|
return Array.from(new Set(urls));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUrlForCompare(input: string): string {
|
function normalizeUrlForCompare(input: string): string {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(input);
|
const parsed = new URL(input);
|
||||||
@ -953,6 +988,102 @@ async function getSceneFusionConfig() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function preprocessProductImageWithRmbg(
|
||||||
|
request: Request,
|
||||||
|
requestId: string,
|
||||||
|
productImageUrl: string,
|
||||||
|
preRmbgEnabled: boolean
|
||||||
|
): Promise<{
|
||||||
|
productImageUrl: string;
|
||||||
|
usedPreRmbg: boolean;
|
||||||
|
provider?: string;
|
||||||
|
reason?: string;
|
||||||
|
}> {
|
||||||
|
if (!preRmbgEnabled) {
|
||||||
|
return {
|
||||||
|
productImageUrl,
|
||||||
|
usedPreRmbg: false,
|
||||||
|
reason: "disabled_by_config",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = request.headers.get("cookie");
|
||||||
|
if (!cookie) {
|
||||||
|
return {
|
||||||
|
productImageUrl,
|
||||||
|
usedPreRmbg: false,
|
||||||
|
reason: "missing_cookie",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateUrls = buildPreRmbgCandidateUrls(request);
|
||||||
|
let lastReason = "no_candidate_url";
|
||||||
|
|
||||||
|
logger.info("pre_rmbg_started", {
|
||||||
|
requestId,
|
||||||
|
candidateCount: candidateUrls.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const rmbgUrl of candidateUrls) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), SCENE_FUSION_PRE_RMBG_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(rmbgUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
cookie,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
imageUrl: productImageUrl,
|
||||||
|
prompt: "场景裂变预处理-抠图",
|
||||||
|
skipGallerySave: true,
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => null) as Record<string, unknown> | null;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
lastReason = `request_failed_${response.status}`;
|
||||||
|
logger.warn("pre_rmbg_candidate_failed", {
|
||||||
|
requestId,
|
||||||
|
status: response.status,
|
||||||
|
detail: payload?.detail || payload?.error || "unknown",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedUrl = String(payload?.original_url || payload?.thumbnail_url || "").trim();
|
||||||
|
if (!cleanedUrl || cleanedUrl === productImageUrl) {
|
||||||
|
lastReason = cleanedUrl ? "same_as_source" : "empty_result";
|
||||||
|
logger.warn("pre_rmbg_candidate_invalid", {
|
||||||
|
requestId,
|
||||||
|
reason: lastReason,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
productImageUrl: cleanedUrl,
|
||||||
|
usedPreRmbg: true,
|
||||||
|
provider: String(payload?.provider || "unknown"),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
lastReason = error instanceof Error ? error.message : "exception";
|
||||||
|
logger.warn("pre_rmbg_candidate_exception", error, { requestId });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
productImageUrl,
|
||||||
|
usedPreRmbg: false,
|
||||||
|
reason: lastReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建缩略图
|
* 创建缩略图
|
||||||
*/
|
*/
|
||||||
@ -2305,13 +2436,12 @@ export async function POST(request: Request) {
|
|||||||
productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product");
|
productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product");
|
||||||
}
|
}
|
||||||
const shouldForcePreRmbg = resolvedSceneMode === "random-bg";
|
const shouldForcePreRmbg = resolvedSceneMode === "random-bg";
|
||||||
const preprocessedProduct = await preprocessProductImageWithRmbg({
|
const preprocessedProduct = await preprocessProductImageWithRmbg(
|
||||||
request,
|
request,
|
||||||
requestId,
|
requestId,
|
||||||
userId: session.user.id,
|
|
||||||
productImageUrl,
|
productImageUrl,
|
||||||
preRmbgEnabled: shouldForcePreRmbg ? true : config.preRmbgEnabled,
|
shouldForcePreRmbg ? true : config.preRmbgEnabled
|
||||||
});
|
);
|
||||||
const fusionProductImageUrl = preprocessedProduct.productImageUrl;
|
const fusionProductImageUrl = preprocessedProduct.productImageUrl;
|
||||||
logger.info("pre_rmbg_resolved", {
|
logger.info("pre_rmbg_resolved", {
|
||||||
requestId,
|
requestId,
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { resolveSiteBranding } from "@/lib/site-branding";
|
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@ -153,7 +152,7 @@ import {
|
|||||||
} from "@/lib/ai-wash/config";
|
} from "@/lib/ai-wash/config";
|
||||||
import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config";
|
import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config";
|
||||||
import { withTimeout } from "@/lib/public-api/performance";
|
import { withTimeout } from "@/lib/public-api/performance";
|
||||||
import type { AgentCategoryTreeConfig, AgentWorkflowUiConfig } from "@/types/agent";
|
import type { AgentCategoryTreeConfig } from "@/types/agent";
|
||||||
|
|
||||||
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
|
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
|
||||||
const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories";
|
const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories";
|
||||||
@ -553,7 +552,6 @@ function normalizeAgentCategoryTreeConfig(value: unknown): AgentCategoryTreeConf
|
|||||||
type PublicSiteSettingsScope =
|
type PublicSiteSettingsScope =
|
||||||
| "agent-brand-model"
|
| "agent-brand-model"
|
||||||
| "agent-canvas"
|
| "agent-canvas"
|
||||||
| "agent-entry"
|
|
||||||
| "agent-presets"
|
| "agent-presets"
|
||||||
| "auth"
|
| "auth"
|
||||||
| "branding"
|
| "branding"
|
||||||
@ -565,7 +563,6 @@ type PublicSiteSettingsScope =
|
|||||||
const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
|
const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
|
||||||
"agent-brand-model",
|
"agent-brand-model",
|
||||||
"agent-canvas",
|
"agent-canvas",
|
||||||
"agent-entry",
|
|
||||||
"agent-presets",
|
"agent-presets",
|
||||||
"auth",
|
"auth",
|
||||||
"branding",
|
"branding",
|
||||||
@ -609,60 +606,22 @@ const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
|
|||||||
lovartHomeSidebarConfig: normalizeLovartHomeSidebarConfig(undefined),
|
lovartHomeSidebarConfig: normalizeLovartHomeSidebarConfig(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
const AGENT_CANVAS_SETTING_KEYS = [
|
|
||||||
REALTIME_CANVAS_CONFIG_KEY,
|
|
||||||
REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
|
|
||||||
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
|
|
||||||
LAYER_SPLIT_CONFIG_KEY,
|
|
||||||
ANGLE_EDIT_CONFIG_KEY,
|
|
||||||
CANVAS_AGENT_MODE_ENABLED_KEY,
|
|
||||||
...CANVAS_AGENT_FEATURE_SETTING_KEYS,
|
|
||||||
VIDEO_CANVAS_ENABLED_KEY,
|
|
||||||
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
|
|
||||||
ENHANCE_PRO_KREA_SETTING_KEY,
|
|
||||||
AI_WASH_CONFIG_KEY,
|
|
||||||
STYLE_PRO_CONFIG_KEY,
|
|
||||||
AGENT_WORKFLOW_UI_CONFIG_KEY,
|
|
||||||
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
|
|
||||||
];
|
|
||||||
|
|
||||||
async function getAgentCanvasSettingsEtag(scope: string) {
|
|
||||||
const [settings, rows, providers] = await Promise.all([
|
|
||||||
db.select({ id: siteSettings.id, updatedAt: siteSettings.updatedAt })
|
|
||||||
.from(siteSettings).limit(1),
|
|
||||||
db.select({ key: systemSettings.key, updatedAt: systemSettings.updatedAt })
|
|
||||||
.from(systemSettings)
|
|
||||||
.where(inArray(systemSettings.key, AGENT_CANVAS_SETTING_KEYS)),
|
|
||||||
db.select({ id: aiProvider.id, updatedAt: aiProvider.updatedAt, enabled: aiProvider.isEnabled })
|
|
||||||
.from(aiProvider)
|
|
||||||
.where(inArray(aiProvider.type, ["tripo-3d", "worldlabs-3d"])),
|
|
||||||
]);
|
|
||||||
const versions = [
|
|
||||||
...settings.map((row) => `${row.id}:${row.updatedAt.getTime()}`),
|
|
||||||
...rows.map((row) => `${row.key}:${row.updatedAt.getTime()}`),
|
|
||||||
...providers.map((row) => `${row.id}:${row.updatedAt.getTime()}:${row.enabled}`),
|
|
||||||
].sort();
|
|
||||||
return `W/"${scope}/${versions.join(",")}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createScopedSiteSettingsResponse(
|
function createScopedSiteSettingsResponse(
|
||||||
scope: PublicSiteSettingsScope,
|
scope: PublicSiteSettingsScope,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
cacheStatus: "HIT" | "MISS" | "STALE" | "FALLBACK" = "MISS",
|
cacheStatus: "HIT" | "MISS" | "STALE" | "FALLBACK" = "MISS",
|
||||||
cacheKey: string = scope,
|
cacheKey: string = scope,
|
||||||
options: { fallback?: boolean; startedAt?: number; etag?: string } = {}
|
options: { fallback?: boolean; startedAt?: number } = {}
|
||||||
) {
|
) {
|
||||||
if (cacheStatus === "MISS") {
|
if (cacheStatus === "MISS") {
|
||||||
setScopedSiteSettingsCache(
|
setScopedSiteSettingsCache(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
data,
|
data,
|
||||||
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS,
|
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS
|
||||||
options.etag
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = NextResponse.json(data);
|
const response = NextResponse.json(data);
|
||||||
if (options.etag) response.headers.set("ETag", options.etag);
|
|
||||||
// Lightweight public settings change rarely; keep them browser-local briefly
|
// Lightweight public settings change rarely; keep them browser-local briefly
|
||||||
// so page opens do not repeatedly wait on the large full settings endpoint.
|
// so page opens do not repeatedly wait on the large full settings endpoint.
|
||||||
response.headers.set(
|
response.headers.set(
|
||||||
@ -704,12 +663,12 @@ async function readScopedSiteSettings(keys: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
||||||
return resolveSiteBranding({
|
return {
|
||||||
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
||||||
siteLogo: String(settings?.siteLogo || ""),
|
siteLogo: settings?.siteLogo || null,
|
||||||
siteLogoDark: String(settings?.siteLogoDark || ""),
|
siteLogoDark: settings?.siteLogoDark || null,
|
||||||
favicon: String(settings?.favicon || ""),
|
favicon: settings?.favicon || null,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getScopedPublicSiteSettings(
|
async function getScopedPublicSiteSettings(
|
||||||
@ -717,7 +676,6 @@ async function getScopedPublicSiteSettings(
|
|||||||
options: {
|
options: {
|
||||||
brandName?: string;
|
brandName?: string;
|
||||||
cacheKey?: string;
|
cacheKey?: string;
|
||||||
etag?: string;
|
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
if (scope === "auth") {
|
if (scope === "auth") {
|
||||||
@ -779,8 +737,23 @@ async function getScopedPublicSiteSettings(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === "agent-canvas" || scope === "agent-entry") {
|
if (scope === "agent-canvas") {
|
||||||
const { settings, valueByKey } = await readScopedSiteSettings(AGENT_CANVAS_SETTING_KEYS);
|
const { settings, valueByKey } = await readScopedSiteSettings([
|
||||||
|
REALTIME_CANVAS_CONFIG_KEY,
|
||||||
|
REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
|
||||||
|
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
|
||||||
|
LAYER_SPLIT_CONFIG_KEY,
|
||||||
|
ANGLE_EDIT_CONFIG_KEY,
|
||||||
|
CANVAS_AGENT_MODE_ENABLED_KEY,
|
||||||
|
...CANVAS_AGENT_FEATURE_SETTING_KEYS,
|
||||||
|
VIDEO_CANVAS_ENABLED_KEY,
|
||||||
|
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
|
||||||
|
ENHANCE_PRO_KREA_SETTING_KEY,
|
||||||
|
AI_WASH_CONFIG_KEY,
|
||||||
|
STYLE_PRO_CONFIG_KEY,
|
||||||
|
AGENT_WORKFLOW_UI_CONFIG_KEY,
|
||||||
|
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
|
||||||
|
]);
|
||||||
const canvasFeatureAvailability = await readCanvasFeatureAvailability(
|
const canvasFeatureAvailability = await readCanvasFeatureAvailability(
|
||||||
valueByKey
|
valueByKey
|
||||||
);
|
);
|
||||||
@ -788,8 +761,6 @@ async function getScopedPublicSiteSettings(
|
|||||||
const videoCanvasSidebarEnabled = normalizeVideoCanvasSidebarEnabled(
|
const videoCanvasSidebarEnabled = normalizeVideoCanvasSidebarEnabled(
|
||||||
valueByKey.get(VIDEO_CANVAS_SIDEBAR_ENABLED_KEY)
|
valueByKey.get(VIDEO_CANVAS_SIDEBAR_ENABLED_KEY)
|
||||||
);
|
);
|
||||||
const rawUiConfig = valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY) as
|
|
||||||
Partial<AgentWorkflowUiConfig> | undefined;
|
|
||||||
return createScopedSiteSettingsResponse(scope, {
|
return createScopedSiteSettingsResponse(scope, {
|
||||||
...createBrandingSettings(typedSettings),
|
...createBrandingSettings(typedSettings),
|
||||||
agentEnabled: typedSettings?.agentEnabled ?? true,
|
agentEnabled: typedSettings?.agentEnabled ?? true,
|
||||||
@ -825,18 +796,12 @@ async function getScopedPublicSiteSettings(
|
|||||||
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
|
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
|
||||||
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
|
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
|
||||||
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
|
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
|
||||||
scope === "agent-entry"
|
valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY)
|
||||||
? {
|
|
||||||
steps: [],
|
|
||||||
pageFeatureSettings: rawUiConfig?.pageFeatureSettings,
|
|
||||||
businessModeSettings: rawUiConfig?.businessModeSettings,
|
|
||||||
}
|
|
||||||
: rawUiConfig
|
|
||||||
),
|
),
|
||||||
agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig(
|
agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig(
|
||||||
valueByKey.get(AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY)
|
valueByKey.get(AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY)
|
||||||
),
|
),
|
||||||
}, "MISS", options.cacheKey || scope, { etag: options.etag });
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === "branding") {
|
if (scope === "branding") {
|
||||||
@ -1090,23 +1055,13 @@ export async function GET(request: NextRequest) {
|
|||||||
const cacheKey = brandName.trim()
|
const cacheKey = brandName.trim()
|
||||||
? `${scopedScope}:${brandName.trim().toLowerCase()}`
|
? `${scopedScope}:${brandName.trim().toLowerCase()}`
|
||||||
: scopedScope;
|
: scopedScope;
|
||||||
const etag = scopedScope === "agent-canvas" || scopedScope === "agent-entry"
|
|
||||||
? await getAgentCanvasSettingsEtag(scopedScope)
|
|
||||||
: undefined;
|
|
||||||
if (etag && request.headers.get("If-None-Match") === etag) {
|
|
||||||
return new NextResponse(null, {
|
|
||||||
status: 304,
|
|
||||||
headers: { ETag: etag, "Cache-Control": "private, no-cache" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const cached = getScopedSiteSettingsCache(cacheKey);
|
const cached = getScopedSiteSettingsCache(cacheKey);
|
||||||
if (cached && cached.expiresAt > Date.now() && (!etag || cached.etag === etag)) {
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
return createScopedSiteSettingsResponse(
|
return createScopedSiteSettingsResponse(
|
||||||
scopedScope,
|
scopedScope,
|
||||||
cached.data,
|
cached.data,
|
||||||
"HIT",
|
"HIT",
|
||||||
cacheKey,
|
cacheKey
|
||||||
{ etag }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (cached && scopedScope !== "home") {
|
if (cached && scopedScope !== "home") {
|
||||||
@ -1117,7 +1072,6 @@ export async function GET(request: NextRequest) {
|
|||||||
const responsePromise = getScopedPublicSiteSettings(scopedScope, {
|
const responsePromise = getScopedPublicSiteSettings(scopedScope, {
|
||||||
brandName,
|
brandName,
|
||||||
cacheKey,
|
cacheKey,
|
||||||
etag,
|
|
||||||
});
|
});
|
||||||
return scopedScope === "home"
|
return scopedScope === "home"
|
||||||
? await withTimeout(responsePromise, 1600, "site-settings:home")
|
? await withTimeout(responsePromise, 1600, "site-settings:home")
|
||||||
@ -1587,7 +1541,7 @@ export async function GET(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
...resolveSiteBranding(baseSettings),
|
...baseSettings,
|
||||||
lovartPromoText:
|
lovartPromoText:
|
||||||
baseSettings.lovartPromoText ||
|
baseSettings.lovartPromoText ||
|
||||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
||||||
import { readCanvasDraft, writeCanvasDraft, clearCanvasDraft } from "@/lib/client/canvas-draft";
|
|
||||||
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
|
|
||||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
|
||||||
import { CanvasRemoteOperationContext, type CanvasRemoteOperation, type CanvasRemoteOperationResult } from "@/components/canvas/CanvasRemoteOperationContext";
|
|
||||||
import equal from "fast-deep-equal";
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@ -172,7 +167,6 @@ import {
|
|||||||
type VideoCanvasSymbolMarkerId,
|
type VideoCanvasSymbolMarkerId,
|
||||||
} from "@/lib/canvas/video-canvas-markers";
|
} from "@/lib/canvas/video-canvas-markers";
|
||||||
import {
|
import {
|
||||||
isCanvasImageUploadPending,
|
|
||||||
LOCAL_UPLOAD_PENDING_METADATA_KEY,
|
LOCAL_UPLOAD_PENDING_METADATA_KEY,
|
||||||
LOCAL_UPLOAD_PREVIEW_METADATA_KEY,
|
LOCAL_UPLOAD_PREVIEW_METADATA_KEY,
|
||||||
} from "@/lib/canvas/local-upload-state";
|
} from "@/lib/canvas/local-upload-state";
|
||||||
@ -233,7 +227,6 @@ import {
|
|||||||
type WorkflowSceneRecord,
|
type WorkflowSceneRecord,
|
||||||
} from "@/lib/video-workflow/runtime";
|
} from "@/lib/video-workflow/runtime";
|
||||||
import { agentProjectService } from "@/services/agent-projects";
|
import { agentProjectService } from "@/services/agent-projects";
|
||||||
import { fetchCanvasSiteSettings } from "@/lib/client/canvas-site-settings";
|
|
||||||
import {
|
import {
|
||||||
canvasService,
|
canvasService,
|
||||||
imageGenerationService,
|
imageGenerationService,
|
||||||
@ -271,8 +264,30 @@ import type {
|
|||||||
} from "@/types/canvas";
|
} from "@/types/canvas";
|
||||||
|
|
||||||
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
|
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
|
||||||
|
const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000;
|
||||||
const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
|
const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
|
||||||
|
|
||||||
|
const fetchCanvasSiteSettings = async (url: string) => {
|
||||||
|
const separator = url.includes("?") ? "&" : "?";
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
}, CANVAS_SITE_SETTINGS_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const response = await fetch(`${url}${separator}t=${Date.now()}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: controller.signal,
|
||||||
|
}).finally(() => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch canvas site settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
function CanvasLazyPanelLoader({
|
function CanvasLazyPanelLoader({
|
||||||
label,
|
label,
|
||||||
className,
|
className,
|
||||||
@ -1934,12 +1949,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
() => !initialProjectForCurrentRoute
|
() => !initialProjectForCurrentRoute
|
||||||
);
|
);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [canvasLoadError, setCanvasLoadError] = useState("");
|
|
||||||
const [canvasLoadAttempt, setCanvasLoadAttempt] = useState(0);
|
|
||||||
const canvasReadyRef = useRef(Boolean(initialProjectForCurrentRoute));
|
|
||||||
const savedCanvasSnapshotRef = useRef<{ id: string; snapshot: string } | null>(null);
|
|
||||||
const canvasSyncErrorRef = useRef("");
|
|
||||||
const [canvasSyncConflicts, setCanvasSyncConflicts] = useState<string[]>([]);
|
|
||||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false);
|
const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false);
|
||||||
const [editTitle, setEditTitle] = useState("");
|
const [editTitle, setEditTitle] = useState("");
|
||||||
@ -2175,9 +2184,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
[agentProjectId, id]
|
[agentProjectId, id]
|
||||||
);
|
);
|
||||||
const canvasSiteSettingsKey = requestedAgentMode
|
const canvasSiteSettingsKey = requestedAgentMode
|
||||||
? hasActiveAgentProject
|
|
||||||
? "/api/site-settings?scope=agent-canvas"
|
? "/api/site-settings?scope=agent-canvas"
|
||||||
: "/api/site-settings?scope=agent-entry"
|
|
||||||
: "/api/site-settings?scope=canvas";
|
: "/api/site-settings?scope=canvas";
|
||||||
const [hasResolvedCanvasSiteSettings, setHasResolvedCanvasSiteSettings] =
|
const [hasResolvedCanvasSiteSettings, setHasResolvedCanvasSiteSettings] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@ -2213,7 +2220,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
? 0
|
? 0
|
||||||
: CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS,
|
: CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS,
|
||||||
errorRetryCount: 1,
|
errorRetryCount: 1,
|
||||||
dedupingInterval: 30_000,
|
|
||||||
revalidateOnFocus: true,
|
revalidateOnFocus: true,
|
||||||
revalidateOnReconnect: true,
|
revalidateOnReconnect: true,
|
||||||
});
|
});
|
||||||
@ -2330,18 +2336,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
});
|
});
|
||||||
const sanitizedWorkflowSelections = useMemo(
|
const sanitizedWorkflowSelections = useMemo(
|
||||||
() =>
|
() =>
|
||||||
requestedAgentMode && !siteSettingsData?.agentWorkflowUiConfig
|
sanitizeAgentWorkflowSelections({
|
||||||
? workflowSelections
|
|
||||||
: sanitizeAgentWorkflowSelections({
|
|
||||||
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
|
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
|
||||||
selections: workflowSelections,
|
selections: workflowSelections,
|
||||||
}),
|
}),
|
||||||
[
|
[resolvedWorkflowUiConfig, workflowSelections]
|
||||||
requestedAgentMode,
|
|
||||||
siteSettingsData?.agentWorkflowUiConfig,
|
|
||||||
resolvedWorkflowUiConfig,
|
|
||||||
workflowSelections,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
const deferredSanitizedWorkflowSelections = useDeferredValue(
|
const deferredSanitizedWorkflowSelections = useDeferredValue(
|
||||||
sanitizedWorkflowSelections
|
sanitizedWorkflowSelections
|
||||||
@ -3376,32 +3375,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
return () => window.clearTimeout(timeoutId);
|
return () => window.clearTimeout(timeoutId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadPersistedCanvasState = useCallback((input: CanvasState | null | undefined) => {
|
|
||||||
const canvasData = input || { nodes: [], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select" as const };
|
|
||||||
const base = JSON.stringify({ canvasData });
|
|
||||||
savedCanvasSnapshotRef.current = { id, snapshot: base };
|
|
||||||
externalCanvasConflictRef.current = false;
|
|
||||||
setCanvasSyncConflicts([]);
|
|
||||||
const draft = readCanvasDraft(id);
|
|
||||||
if (draft) {
|
|
||||||
const merged = mergeCanvasSnapshots(draft.base, draft.snapshot, base);
|
|
||||||
if (merged.conflicts.length) {
|
|
||||||
externalCanvasConflictRef.current = true;
|
|
||||||
savedCanvasSnapshotRef.current = { id, snapshot: draft.base };
|
|
||||||
setCanvasSyncConflicts(merged.conflicts);
|
|
||||||
canvasSyncErrorRef.current = "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。";
|
|
||||||
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
|
||||||
toast.warning(canvasSyncErrorRef.current);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
|
||||||
if (merged.snapshot !== base) writeCanvasDraft(id, { base, snapshot: merged.snapshot });
|
|
||||||
else clearCanvasDraft(id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
useCanvasStore.getState().loadCanvas(canvasData);
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const hydrateCanvasProject = useCallback(
|
const hydrateCanvasProject = useCallback(
|
||||||
(project: CanvasInitialProject) => {
|
(project: CanvasInitialProject) => {
|
||||||
externalCanvasConflictRef.current = false;
|
externalCanvasConflictRef.current = false;
|
||||||
@ -3411,9 +3384,9 @@ export default function CanvasEditorLegacyPage({
|
|||||||
useCanvasStore
|
useCanvasStore
|
||||||
.getState()
|
.getState()
|
||||||
.setCanvasRevision(projectUpdatedAt, projectUpdatedAt);
|
.setCanvasRevision(projectUpdatedAt, projectUpdatedAt);
|
||||||
loadPersistedCanvasState(project.canvasData);
|
|
||||||
|
|
||||||
if (project.canvasData) {
|
if (project.canvasData) {
|
||||||
|
useCanvasStore.getState().loadCanvas(project.canvasData);
|
||||||
const persistedWorkflow = project.canvasData.workflowState;
|
const persistedWorkflow = project.canvasData.workflowState;
|
||||||
if (!requestedAgentMode && persistedWorkflow) {
|
if (!requestedAgentMode && persistedWorkflow) {
|
||||||
const nextAppearanceRecognitionEnabled =
|
const nextAppearanceRecognitionEnabled =
|
||||||
@ -3516,7 +3489,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
setWorkflowHydrationSource("empty");
|
setWorkflowHydrationSource("empty");
|
||||||
},
|
},
|
||||||
[id, requestedAgentMode, loadPersistedCanvasState]
|
[id, requestedAgentMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@ -3524,7 +3497,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
if (lastLoadedCanvasIdRef.current === id) return;
|
if (lastLoadedCanvasIdRef.current === id) return;
|
||||||
|
|
||||||
hydrateCanvasProject(initialProjectForCurrentRoute);
|
hydrateCanvasProject(initialProjectForCurrentRoute);
|
||||||
canvasReadyRef.current = true;
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
lastLoadedCanvasIdRef.current = id;
|
lastLoadedCanvasIdRef.current = id;
|
||||||
}, [hydrateCanvasProject, id, initialProjectForCurrentRoute]);
|
}, [hydrateCanvasProject, id, initialProjectForCurrentRoute]);
|
||||||
@ -3532,7 +3504,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
// 加载画布数据
|
// 加载画布数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("[Canvas] Loading project:", id);
|
console.log("[Canvas] Loading project:", id);
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const loadProject = async () => {
|
const loadProject = async () => {
|
||||||
if (lastLoadedCanvasIdRef.current === id) {
|
if (lastLoadedCanvasIdRef.current === id) {
|
||||||
@ -3541,8 +3512,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
canvasReadyRef.current = false;
|
|
||||||
setCanvasLoadError("");
|
|
||||||
if (!requestedAgentMode) {
|
if (!requestedAgentMode) {
|
||||||
setWorkflowHydrationSource("pending");
|
setWorkflowHydrationSource("pending");
|
||||||
}
|
}
|
||||||
@ -3575,16 +3544,18 @@ export default function CanvasEditorLegacyPage({
|
|||||||
setLoadedAgentProjectId(null);
|
setLoadedAgentProjectId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
|
||||||
|
useCanvasStore.getState().clearCanvas();
|
||||||
|
|
||||||
const result = await canvasService.getProject(id);
|
const result = await canvasService.getProject(id);
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
useCanvasStore.getState().setProject(id, result.data.title);
|
useCanvasStore.getState().setProject(id, result.data.title);
|
||||||
useCanvasStore
|
useCanvasStore
|
||||||
.getState()
|
.getState()
|
||||||
.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
|
.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
|
||||||
loadPersistedCanvasState(result.data.canvasData);
|
|
||||||
if (result.data.canvasData) {
|
if (result.data.canvasData) {
|
||||||
|
useCanvasStore.getState().loadCanvas(result.data.canvasData);
|
||||||
const persistedWorkflow = result.data.canvasData.workflowState;
|
const persistedWorkflow = result.data.canvasData.workflowState;
|
||||||
if (!requestedAgentMode && persistedWorkflow) {
|
if (!requestedAgentMode && persistedWorkflow) {
|
||||||
const nextAppearanceRecognitionEnabled =
|
const nextAppearanceRecognitionEnabled =
|
||||||
@ -3701,15 +3672,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status !== 404) {
|
// 画布不存在时,创建一个新画布
|
||||||
setCanvasLoadError(result.error || "画布暂时无法读取,请检查网络后重试加载。现有内容未被覆盖。");
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only an explicit missing project can create a new empty canvas.
|
|
||||||
console.log("[Canvas] 画布不存在,创建新画布:", id);
|
console.log("[Canvas] 画布不存在,创建新画布:", id);
|
||||||
useCanvasStore.getState().clearCanvas();
|
|
||||||
useCanvasStore
|
useCanvasStore
|
||||||
.getState()
|
.getState()
|
||||||
.setProject(
|
.setProject(
|
||||||
@ -3736,20 +3700,15 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cancelled) return;
|
|
||||||
canvasReadyRef.current = true;
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
lastLoadedCanvasIdRef.current = id;
|
lastLoadedCanvasIdRef.current = id;
|
||||||
};
|
};
|
||||||
|
|
||||||
loadProject();
|
loadProject();
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [
|
}, [
|
||||||
activeCanvasWorkspaceType,
|
activeCanvasWorkspaceType,
|
||||||
defaultCanvasTitle,
|
defaultCanvasTitle,
|
||||||
id,
|
id,
|
||||||
canvasLoadAttempt,
|
|
||||||
loadPersistedCanvasState,
|
|
||||||
openLoginDialog,
|
openLoginDialog,
|
||||||
requestedAgentMode,
|
requestedAgentMode,
|
||||||
]);
|
]);
|
||||||
@ -3987,18 +3946,20 @@ export default function CanvasEditorLegacyPage({
|
|||||||
loadCanvas(normalized.canvasData);
|
loadCanvas(normalized.canvasData);
|
||||||
setProjectTitle(nextTitle);
|
setProjectTitle(nextTitle);
|
||||||
|
|
||||||
const saveResult = await canvasService.saveCanvasState(
|
const [saveResult, updateResult] = await Promise.all([
|
||||||
|
canvasService.saveCanvasState(
|
||||||
id,
|
id,
|
||||||
normalized.canvasData,
|
normalized.canvasData,
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
);
|
),
|
||||||
const updateResult = await canvasService.updateProject(
|
canvasService.updateProject(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
title: nextTitle,
|
title: nextTitle,
|
||||||
},
|
},
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
);
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!saveResult.success) {
|
if (!saveResult.success) {
|
||||||
throw new Error(saveResult.error || "导入后保存失败");
|
throw new Error(saveResult.error || "导入后保存失败");
|
||||||
@ -4739,7 +4700,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
title: nextTitle,
|
title: nextTitle,
|
||||||
thumbnail: agentProjectThumbnail,
|
thumbnail: agentProjectThumbnail,
|
||||||
projectState: buildPersistedAgentProjectState(),
|
projectState: buildPersistedAgentProjectState(),
|
||||||
}, { creationKey: `save-as:${id}:${agentProjectId}:${nextTitle}` });
|
});
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
if (isUnauthorizedApiError(result.error)) {
|
if (isUnauthorizedApiError(result.error)) {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
@ -4764,7 +4725,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
agentProjectThumbnail,
|
agentProjectThumbnail,
|
||||||
agentProjectTitle,
|
agentProjectTitle,
|
||||||
buildPersistedAgentProjectState,
|
buildPersistedAgentProjectState,
|
||||||
id,
|
|
||||||
openLoginDialog,
|
openLoginDialog,
|
||||||
persistAgentProject,
|
persistAgentProject,
|
||||||
updateCanvasSearchParams,
|
updateCanvasSearchParams,
|
||||||
@ -4829,7 +4789,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const result = await agentProjectService.createProject({
|
const result = await agentProjectService.createProject({
|
||||||
title: buildDefaultAgentProjectTitle(),
|
title: buildDefaultAgentProjectTitle(),
|
||||||
projectState: createEmptyAgentGenerationProjectPersistedState(),
|
projectState: createEmptyAgentGenerationProjectPersistedState(),
|
||||||
}, { creationKey: `blank:${id}:${agentProjectId}` });
|
});
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
if (isUnauthorizedApiError(result.error)) {
|
if (isUnauthorizedApiError(result.error)) {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
@ -4852,7 +4812,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}, [
|
}, [
|
||||||
agentProjectId,
|
agentProjectId,
|
||||||
isAgentProjectSaving,
|
isAgentProjectSaving,
|
||||||
id,
|
|
||||||
openLoginDialog,
|
openLoginDialog,
|
||||||
persistAgentProject,
|
persistAgentProject,
|
||||||
updateCanvasSearchParams,
|
updateCanvasSearchParams,
|
||||||
@ -5121,70 +5080,15 @@ export default function CanvasEditorLegacyPage({
|
|||||||
if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue();
|
if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue();
|
||||||
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const canvasPersistenceScope = useRef(id);
|
const canvasPersistenceScope = useRef(id);
|
||||||
const canvasRemoteOperationActive = useRef(false);
|
|
||||||
const canvasLeaveApprovedRef = useRef(false);
|
|
||||||
const [canvasLeaveUrl, setCanvasLeaveUrl] = useState("");
|
|
||||||
const [isSavingBeforeLeave, setIsSavingBeforeLeave] = useState(false);
|
|
||||||
const [canvasLeaveError, setCanvasLeaveError] = useState("");
|
|
||||||
canvasPersistenceScope.current = id;
|
canvasPersistenceScope.current = id;
|
||||||
const hasUnsavedCanvasChanges = useCallback(() => {
|
const persistCanvasState = useCallback(
|
||||||
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || savedCanvasSnapshotRef.current?.id !== id) return false;
|
(reason: "autosave" | "delete" | "beforeunload") => canvasSaveQueue.current!.enqueue(async () => {
|
||||||
if (useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) return true;
|
if (canvasPersistenceScope.current !== id) return false;
|
||||||
const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData as CanvasState;
|
|
||||||
const current = JSON.parse(JSON.stringify(buildPersistedCanvasData())) as CanvasState;
|
|
||||||
const content = (value: CanvasState) => [value.nodes.map(node => {
|
|
||||||
const { updatedAt: _updatedAt, ...nodeData } = node;
|
|
||||||
return { ...nodeData, connections: node.connections || [] };
|
|
||||||
}), value.edges || [], value.suppressedMediaKeys || [], normalizeStyleProPersistedState(value.styleProState)];
|
|
||||||
return !equal(content(saved), content(current));
|
|
||||||
}, [buildPersistedCanvasData, id]);
|
|
||||||
const requestCanvasLeave = useCallback((url: string) => {
|
|
||||||
if (hasUnsavedCanvasChanges()) {
|
|
||||||
setCanvasLeaveError("");
|
|
||||||
setCanvasLeaveUrl(url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
canvasLeaveApprovedRef.current = true;
|
|
||||||
window.location.assign(url);
|
|
||||||
}, [hasUnsavedCanvasChanges]);
|
|
||||||
const applyCanvasRemoteSnapshot = useCallback((result: CanvasRemoteOperationResult) => {
|
|
||||||
if (result.projectId !== id || canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return false;
|
|
||||||
const remoteSnapshot = JSON.stringify({ canvasData: result.canvasData });
|
|
||||||
const merged = mergeCanvasSnapshots(
|
|
||||||
savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : remoteSnapshot,
|
|
||||||
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
|
||||||
remoteSnapshot,
|
|
||||||
);
|
|
||||||
if (merged.changed) useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
|
||||||
useCanvasStore.getState().setCanvasRevision(result.updatedAt, result.updatedAt);
|
|
||||||
if (merged.conflicts.length) {
|
|
||||||
canvasSyncErrorRef.current = "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。";
|
|
||||||
if (!externalCanvasConflictRef.current) toast.warning(canvasSyncErrorRef.current);
|
|
||||||
externalCanvasConflictRef.current = true;
|
|
||||||
setCanvasSyncConflicts(merged.conflicts);
|
|
||||||
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current?.snapshot || remoteSnapshot, snapshot: merged.snapshot });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
|
|
||||||
externalCanvasConflictRef.current = false;
|
|
||||||
setCanvasSyncConflicts([]);
|
|
||||||
canvasSyncErrorRef.current = "";
|
|
||||||
return true;
|
|
||||||
}, [buildPersistedCanvasData, id]);
|
|
||||||
const persistCanvasStateNow = useCallback(
|
|
||||||
async (reason: "autosave" | "delete" | "beforeunload") => {
|
|
||||||
if (canvasLeaveApprovedRef.current || canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false;
|
|
||||||
if (externalCanvasConflictRef.current) return false;
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
let canvasData = buildPersistedCanvasData();
|
const canvasData = buildPersistedCanvasData();
|
||||||
let snapshot = JSON.stringify({ canvasData });
|
|
||||||
if (savedCanvasSnapshotRef.current?.id === id && equal(JSON.parse(savedCanvasSnapshotRef.current.snapshot), JSON.parse(snapshot))) {
|
|
||||||
clearCanvasDraft(id);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
||||||
let result = await canvasService.saveCanvasState(
|
const result = await canvasService.saveCanvasState(
|
||||||
id,
|
id,
|
||||||
canvasData,
|
canvasData,
|
||||||
activeCanvasWorkspaceType,
|
activeCanvasWorkspaceType,
|
||||||
@ -5194,53 +5098,23 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
if (canvasPersistenceScope.current !== id) return false;
|
if (canvasPersistenceScope.current !== id) return false;
|
||||||
if (result.status === 409 && savedCanvasSnapshotRef.current?.id === id) {
|
|
||||||
const remote = await canvasService.getProject(id);
|
|
||||||
if (canvasPersistenceScope.current !== id) return false;
|
|
||||||
if (remote.success && remote.data?.canvasData) {
|
|
||||||
const remoteSnapshot = JSON.stringify({ canvasData: remote.data.canvasData });
|
|
||||||
const merged = mergeCanvasSnapshots(
|
|
||||||
savedCanvasSnapshotRef.current.snapshot,
|
|
||||||
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
|
||||||
remoteSnapshot,
|
|
||||||
);
|
|
||||||
if (!merged.conflicts.length) {
|
|
||||||
canvasData = JSON.parse(merged.snapshot).canvasData;
|
|
||||||
snapshot = merged.snapshot;
|
|
||||||
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
|
|
||||||
useCanvasStore.getState().loadCanvas(canvasData);
|
|
||||||
const revision = String(remote.data.updatedAt);
|
|
||||||
useCanvasStore.getState().setCanvasRevision(revision, revision);
|
|
||||||
result = await canvasService.saveCanvasState(id, canvasData, activeCanvasWorkspaceType, {
|
|
||||||
baseCanvasRevision: revision, baseCanvasUpdatedAt: revision,
|
|
||||||
});
|
|
||||||
if (canvasPersistenceScope.current !== id) return false;
|
|
||||||
} else {
|
|
||||||
externalCanvasConflictRef.current = true;
|
|
||||||
setCanvasSyncConflicts(merged.conflicts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const retained = writeCanvasDraft(id, {
|
|
||||||
base: savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : JSON.stringify({ canvasData: { nodes: [], edges: [] } }),
|
|
||||||
snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
|
||||||
});
|
|
||||||
canvasSyncErrorRef.current = result.status === 409
|
|
||||||
? "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。"
|
|
||||||
: `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"}。${retained ? "本地草稿已保留,系统会继续自动同步,也可稍后重试。" : "请保留当前页面,检查网络后重试同步,暂勿刷新。"}`;
|
|
||||||
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
||||||
} else {
|
if (result.status === 409) {
|
||||||
canvasSyncErrorRef.current = "";
|
externalCanvasConflictRef.current = true;
|
||||||
savedCanvasSnapshotRef.current = { id, snapshot };
|
console.warn("[Canvas] stale save revision handled without user prompt", {
|
||||||
clearCanvasDraft(id);
|
reason,
|
||||||
externalCanvasConflictRef.current = false;
|
canvasUpdatedAt: result.canvasUpdatedAt,
|
||||||
setCanvasSyncConflicts([]);
|
});
|
||||||
externalCanvasConflictNotifiedRef.current = false;
|
if (!externalCanvasConflictNotifiedRef.current) {
|
||||||
if (result.data?.canvasData && result.data?.updatedAt) {
|
externalCanvasConflictNotifiedRef.current = true;
|
||||||
return applyCanvasRemoteSnapshot({ projectId: id, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt) });
|
toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
|
||||||
}
|
}
|
||||||
if (result.data?.updatedAt) useCanvasStore
|
}
|
||||||
|
} else if (result.data?.updatedAt) {
|
||||||
|
externalCanvasConflictRef.current = false;
|
||||||
|
externalCanvasConflictNotifiedRef.current = false;
|
||||||
|
useCanvasStore
|
||||||
.getState()
|
.getState()
|
||||||
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
||||||
}
|
}
|
||||||
@ -5248,149 +5122,18 @@ export default function CanvasEditorLegacyPage({
|
|||||||
} finally {
|
} finally {
|
||||||
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
[activeCanvasWorkspaceType, applyCanvasRemoteSnapshot, buildPersistedCanvasData, id]
|
[activeCanvasWorkspaceType, buildPersistedCanvasData, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const persistCanvasState = useCallback(
|
const persistCanvasBeforeAgent = useCallback(() => {
|
||||||
(reason: "autosave" | "delete" | "beforeunload") =>
|
|
||||||
canvasSaveQueue.current!.enqueue(() => persistCanvasStateNow(reason)),
|
|
||||||
[persistCanvasStateNow]
|
|
||||||
);
|
|
||||||
|
|
||||||
const finishCanvasLeave = useCallback(async (save: boolean) => {
|
|
||||||
if (!canvasLeaveUrl || isSavingBeforeLeave) return;
|
|
||||||
setCanvasLeaveError("");
|
|
||||||
if (save && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
|
|
||||||
setCanvasLeaveError("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再保存并离开。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (canvasAutosaveTimer.current) {
|
|
||||||
clearTimeout(canvasAutosaveTimer.current);
|
|
||||||
canvasAutosaveTimer.current = null;
|
|
||||||
}
|
|
||||||
if (save) {
|
|
||||||
setIsSavingBeforeLeave(true);
|
|
||||||
try {
|
|
||||||
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current || "画布暂时无法同步,请保留此页并稍后重试。");
|
|
||||||
if (hasUnsavedCanvasChanges() && !(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current);
|
|
||||||
if (hasUnsavedCanvasChanges()) throw new Error("还有改动正在同步,请稍后重试离开。");
|
|
||||||
} catch (error) {
|
|
||||||
setCanvasLeaveError(error instanceof Error ? error.message : "画布暂时无法同步,请保留此页并稍后重试。");
|
|
||||||
setIsSavingBeforeLeave(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
canvasLeaveApprovedRef.current = true;
|
|
||||||
clearCanvasDraft(id);
|
|
||||||
window.location.assign(canvasLeaveUrl);
|
|
||||||
}, [canvasLeaveUrl, hasUnsavedCanvasChanges, id, isSavingBeforeLeave, persistCanvasState]);
|
|
||||||
|
|
||||||
const resolveCanvasSyncConflict = useCallback((choice: "local" | "remote") => {
|
|
||||||
return canvasSaveQueue.current!.enqueue(async () => {
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const remote = await canvasService.getProject(id);
|
|
||||||
if (canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return;
|
|
||||||
if (!remote.success || !remote.data?.canvasData || savedCanvasSnapshotRef.current?.id !== id) {
|
|
||||||
throw new Error(remote.error || "画布暂时无法读取,改动已保留,请稍后重试同步。");
|
|
||||||
}
|
|
||||||
const remoteSnapshot = JSON.stringify({ canvasData: remote.data.canvasData });
|
|
||||||
const merged = mergeCanvasSnapshots(
|
|
||||||
savedCanvasSnapshotRef.current.snapshot,
|
|
||||||
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
|
||||||
remoteSnapshot,
|
|
||||||
choice,
|
|
||||||
);
|
|
||||||
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
|
|
||||||
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
|
||||||
const revision = String(remote.data.updatedAt);
|
|
||||||
useCanvasStore.getState().setCanvasRevision(revision, revision);
|
|
||||||
externalCanvasConflictRef.current = false;
|
|
||||||
canvasSyncErrorRef.current = "";
|
|
||||||
setCanvasSyncConflicts([]);
|
|
||||||
if (!(await persistCanvasStateNow("autosave"))) throw new Error(canvasSyncErrorRef.current);
|
|
||||||
toast.success("画布已合并同步,可以继续操作");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "画布同步失败,改动已保留,请稍后重试。");
|
|
||||||
} finally {
|
|
||||||
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [buildPersistedCanvasData, id, persistCanvasStateNow]);
|
|
||||||
|
|
||||||
const runCanvasRemoteOperation = useCallback(
|
|
||||||
(operation: CanvasRemoteOperation) => {
|
|
||||||
if (canvasAutosaveTimer.current) {
|
|
||||||
clearTimeout(canvasAutosaveTimer.current);
|
|
||||||
canvasAutosaveTimer.current = null;
|
|
||||||
}
|
|
||||||
return canvasSaveQueue.current!.enqueueOperation(
|
|
||||||
async () => {
|
|
||||||
const saved = await persistCanvasStateNow("autosave");
|
|
||||||
if (!saved) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次处理未提交。` : "画布尚未完成加载,本次处理未提交,请等待加载完成后重试。");
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
canvasRemoteOperationActive.current = true;
|
|
||||||
try {
|
|
||||||
const result = await operation();
|
|
||||||
if (result && result.projectId === id && canvasPersistenceScope.current === id) {
|
|
||||||
savedCanvasSnapshotRef.current = { id, snapshot: JSON.stringify({ canvasData: result.canvasData }) };
|
|
||||||
canvasSyncErrorRef.current = "";
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
canvasRemoteOperationActive.current = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[persistCanvasStateNow, id]
|
|
||||||
);
|
|
||||||
|
|
||||||
const persistCanvasBeforeAgent = useCallback(async () => {
|
|
||||||
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || externalCanvasConflictRef.current)
|
|
||||||
throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。");
|
|
||||||
if (savedCanvasSnapshotRef.current?.id === id) {
|
|
||||||
if (!hasUnsavedCanvasChanges()) return true;
|
|
||||||
}
|
|
||||||
// Flush the pending local edit once instead of racing its scheduled autosave.
|
// Flush the pending local edit once instead of racing its scheduled autosave.
|
||||||
if (canvasAutosaveTimer.current) {
|
if (canvasAutosaveTimer.current) {
|
||||||
clearTimeout(canvasAutosaveTimer.current);
|
clearTimeout(canvasAutosaveTimer.current);
|
||||||
canvasAutosaveTimer.current = null;
|
canvasAutosaveTimer.current = null;
|
||||||
}
|
}
|
||||||
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。");
|
return persistCanvasState("autosave");
|
||||||
return true;
|
}, [persistCanvasState]);
|
||||||
}, [persistCanvasState, hasUnsavedCanvasChanges, id]);
|
|
||||||
|
|
||||||
const canvasRemoteOperationContext = useMemo(() => ({
|
|
||||||
run: runCanvasRemoteOperation,
|
|
||||||
applySnapshot: applyCanvasRemoteSnapshot,
|
|
||||||
}), [runCanvasRemoteOperation, applyCanvasRemoteSnapshot]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isVideoMode) return;
|
|
||||||
const beforeRefresh = (event: Event) => {
|
|
||||||
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
|
||||||
if (!isAgentMode && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
|
|
||||||
request.block("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再刷新。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (useCanvasStore.getState().isGenerating) {
|
|
||||||
request.block("当前图片任务仍在处理中,请等待结果返回后再刷新。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
request.save(async () => {
|
|
||||||
if (isAgentMode) {
|
|
||||||
if (agentProjectId && hasLoadedCurrentAgentProject && !(await persistAgentProject("switch"))) throw new Error("生图项目同步失败,请保留此页并重试刷新。");
|
|
||||||
} else if (canvasReadyRef.current && !(await persistCanvasState("autosave"))) {
|
|
||||||
throw new Error(canvasSyncErrorRef.current || "画布尚未完成同步,请保留此页并重试刷新。");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
|
||||||
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
|
||||||
}, [isVideoMode, isAgentMode, agentProjectId, hasLoadedCurrentAgentProject, persistAgentProject, persistCanvasState]);
|
|
||||||
|
|
||||||
// 自动保存逻辑
|
// 自动保存逻辑
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -5411,32 +5154,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
|
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
|
||||||
};
|
};
|
||||||
}, [isAgentMode, isAgentOperationRefreshActive, nodes, edges, isLoading, persistCanvasState]);
|
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoading || isAgentMode) return;
|
|
||||||
const retrySync = () => {
|
|
||||||
if (canvasLeaveApprovedRef.current || externalCanvasConflictRef.current || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || !hasUnsavedCanvasChanges()) return;
|
|
||||||
void persistCanvasState("autosave").catch(error => console.error("[Canvas] background sync failed:", error));
|
|
||||||
};
|
|
||||||
const onVisibilityChange = () => {
|
|
||||||
if (!canvasLeaveApprovedRef.current && document.visibilityState === "hidden" && hasUnsavedCanvasChanges()) {
|
|
||||||
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const retryTimer = window.setInterval(() => {
|
|
||||||
if (document.visibilityState === "visible" && navigator.onLine && canvasSyncErrorRef.current) retrySync();
|
|
||||||
}, 20_000);
|
|
||||||
window.addEventListener("online", retrySync);
|
|
||||||
window.addEventListener("focus", retrySync);
|
|
||||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
||||||
return () => {
|
|
||||||
window.clearInterval(retryTimer);
|
|
||||||
window.removeEventListener("online", retrySync);
|
|
||||||
window.removeEventListener("focus", retrySync);
|
|
||||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
||||||
};
|
|
||||||
}, [buildPersistedCanvasData, hasUnsavedCanvasChanges, id, isAgentMode, isAgentOperationRefreshActive, isLoading, persistCanvasState]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isAgentOperationRefreshActive()) return;
|
if (isLoading || isAgentOperationRefreshActive()) return;
|
||||||
@ -5450,7 +5168,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const refreshIfExternallyUpdated = async () => {
|
const refreshIfExternallyUpdated = async () => {
|
||||||
if (
|
if (
|
||||||
cancelled ||
|
cancelled ||
|
||||||
canvasRemoteOperationActive.current ||
|
|
||||||
document.visibilityState !== "visible" ||
|
document.visibilityState !== "visible" ||
|
||||||
isSaving ||
|
isSaving ||
|
||||||
externalCanvasConflictRef.current ||
|
externalCanvasConflictRef.current ||
|
||||||
@ -5460,7 +5177,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const beforeRead = useCanvasStore.getState();
|
|
||||||
const revisionResponse = await fetch(`/api/canvas/${id}/revision`, {
|
const revisionResponse = await fetch(`/api/canvas/${id}/revision`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
@ -5481,11 +5197,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await canvasService.getProject(id);
|
const result = await canvasService.getProject(id);
|
||||||
const current = useCanvasStore.getState();
|
if (cancelled || !result.success || !result.data) return;
|
||||||
if (
|
|
||||||
cancelled || canvasRemoteOperationActive.current || !result.success || !result.data ||
|
|
||||||
current.projectId !== id || current.nodes !== beforeRead.nodes || current.edges !== beforeRead.edges
|
|
||||||
) return;
|
|
||||||
useCanvasStore.getState().markAgentOperationRefresh();
|
useCanvasStore.getState().markAgentOperationRefresh();
|
||||||
hydrateCanvasProject(result.data);
|
hydrateCanvasProject(result.data);
|
||||||
lastLocalCanvasMutationAtRef.current = Date.now();
|
lastLocalCanvasMutationAtRef.current = Date.now();
|
||||||
@ -5539,25 +5251,14 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
||||||
|
|
||||||
// 浏览器离开确认只保护尚未同步的内容;真正离开时保留草稿并发送一次保存。
|
// 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isAgentMode) return;
|
if (isLoading || isAgentMode) return;
|
||||||
|
|
||||||
const retainDraft = () => {
|
const handleBeforeUnload = () => {
|
||||||
if (canvasLeaveApprovedRef.current || !hasUnsavedCanvasChanges()) return null;
|
|
||||||
const canvasData = buildPersistedCanvasData();
|
|
||||||
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData }) });
|
|
||||||
return canvasData;
|
|
||||||
};
|
|
||||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
||||||
if (!retainDraft()) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.returnValue = "true";
|
|
||||||
};
|
|
||||||
const handlePageHide = () => {
|
|
||||||
try {
|
try {
|
||||||
const canvasData = retainDraft();
|
if (isAgentOperationRefreshActive()) return;
|
||||||
if (!canvasData || isSaving || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || externalCanvasConflictRef.current) return;
|
const canvasData = buildPersistedCanvasData();
|
||||||
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
||||||
void fetch(`/api/canvas/${id}`, {
|
void fetch(`/api/canvas/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@ -5576,20 +5277,18 @@ export default function CanvasEditorLegacyPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
window.addEventListener("pagehide", handlePageHide);
|
window.addEventListener("pagehide", handleBeforeUnload);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
window.removeEventListener("pagehide", handlePageHide);
|
window.removeEventListener("pagehide", handleBeforeUnload);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
activeCanvasWorkspaceType,
|
activeCanvasWorkspaceType,
|
||||||
buildPersistedCanvasData,
|
buildPersistedCanvasData,
|
||||||
hasUnsavedCanvasChanges,
|
|
||||||
id,
|
id,
|
||||||
isAgentOperationRefreshActive,
|
isAgentOperationRefreshActive,
|
||||||
isAgentMode,
|
isAgentMode,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 智能命名与缩略图逻辑
|
// 智能命名与缩略图逻辑
|
||||||
@ -5621,11 +5320,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (autoThumbnailSyncedRef.current !== autoTitleKey) {
|
if (autoThumbnailSyncedRef.current !== autoTitleKey) {
|
||||||
await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
|
await canvasService.updateProject(
|
||||||
id,
|
id,
|
||||||
{ thumbnail: imageSrc },
|
{ thumbnail: imageSrc },
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
));
|
);
|
||||||
autoThumbnailSyncedRef.current = autoTitleKey;
|
autoThumbnailSyncedRef.current = autoTitleKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5641,11 +5340,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.title) {
|
if (data.title) {
|
||||||
setProjectTitle(data.title);
|
setProjectTitle(data.title);
|
||||||
await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
|
await canvasService.updateProject(
|
||||||
id,
|
id,
|
||||||
{ title: data.title },
|
{ title: data.title },
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
));
|
);
|
||||||
toast.success(`${workspaceLabel}已自动命名为:${data.title}`);
|
toast.success(`${workspaceLabel}已自动命名为:${data.title}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -5673,12 +5372,21 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
// 保存画布 (手动触发保留作为备用,但UI上移除)
|
// 保存画布 (手动触发保留作为备用,但UI上移除)
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (await persistCanvasState("autosave")) {
|
setIsSaving(true);
|
||||||
|
const canvasData = buildPersistedCanvasData();
|
||||||
|
const result = await canvasService.saveCanvasState(
|
||||||
|
id,
|
||||||
|
canvasData,
|
||||||
|
activeCanvasWorkspaceType
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
toast.success("保存成功");
|
toast.success("保存成功");
|
||||||
} else {
|
} else {
|
||||||
toast.error(canvasSyncErrorRef.current || "画布同步失败,请检查网络后重试");
|
toast.error(result.error || "保存失败");
|
||||||
}
|
}
|
||||||
}, [persistCanvasState]);
|
setIsSaving(false);
|
||||||
|
}, [activeCanvasWorkspaceType, buildPersistedCanvasData, id]);
|
||||||
|
|
||||||
// 更新标题
|
// 更新标题
|
||||||
const handleUpdateTitle = async () => {
|
const handleUpdateTitle = async () => {
|
||||||
@ -5687,11 +5395,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
|
const result = await canvasService.updateProject(
|
||||||
id,
|
id,
|
||||||
{ title: editTitle.trim() },
|
{ title: editTitle.trim() },
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
));
|
);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setProjectTitle(editTitle.trim());
|
setProjectTitle(editTitle.trim());
|
||||||
} else {
|
} else {
|
||||||
@ -7324,9 +7032,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
const result = existingProject
|
const result = existingProject
|
||||||
? await agentProjectService.updateProject(existingProject.id, payload)
|
? await agentProjectService.updateProject(existingProject.id, payload)
|
||||||
: await agentProjectService.createProject(payload, {
|
: await agentProjectService.createProject(payload);
|
||||||
creationKey: `workflow:${id}:${nodeId}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
if (isUnauthorizedApiError(result.error)) {
|
if (isUnauthorizedApiError(result.error)) {
|
||||||
@ -10472,14 +10178,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (canvasLoadError && !isAgentMode) {
|
|
||||||
return <div role="alert" className="flex h-dvh flex-col items-center justify-center gap-4">
|
|
||||||
<p>{canvasLoadError}</p>
|
|
||||||
<Button onClick={() => setCanvasLoadAttempt(value => value + 1)}>重新加载画布</Button>
|
|
||||||
{loginDialogElement}
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAgentMode) {
|
if (isAgentMode) {
|
||||||
if (!hasActiveAgentProject) {
|
if (!hasActiveAgentProject) {
|
||||||
return (
|
return (
|
||||||
@ -10583,7 +10281,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CanvasRemoteOperationContext.Provider value={canvasRemoteOperationContext}>
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div
|
<div
|
||||||
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
|
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
|
||||||
@ -10599,30 +10296,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CanvasAgentUiListener projectId={id} workspace="canvas" />
|
<CanvasAgentUiListener projectId={id} workspace="canvas" />
|
||||||
<Dialog open={Boolean(canvasLeaveUrl)} onOpenChange={open => { if (!open && !isSavingBeforeLeave) setCanvasLeaveUrl(""); }}>
|
|
||||||
<DialogContent onEscapeKeyDown={event => { if (isSavingBeforeLeave) event.preventDefault(); }} onPointerDownOutside={event => event.preventDefault()}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>离开前保存画布?</DialogTitle>
|
|
||||||
<DialogDescription>平时会自动保存。当前还有改动尚未同步到云端,可以保存后离开,或放弃这些未同步的改动。</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{canvasLeaveError ? <p role="alert" className="text-sm text-destructive">{canvasLeaveError}</p> : null}
|
|
||||||
<DialogFooter className="gap-2">
|
|
||||||
<Button disabled={isSavingBeforeLeave} variant="ghost" onClick={() => setCanvasLeaveUrl("")}>继续编辑</Button>
|
|
||||||
<Button disabled={isSavingBeforeLeave} variant="outline" onClick={() => void finishCanvasLeave(false)}>不保存并离开</Button>
|
|
||||||
<Button disabled={isSavingBeforeLeave} onClick={() => void finishCanvasLeave(true)}>{isSavingBeforeLeave ? "正在保存…" : "保存并离开"}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
{canvasSyncConflicts.length > 0 ? (
|
|
||||||
<div role="alert" className="absolute inset-x-3 top-28 z-[60] mx-auto flex max-w-2xl flex-wrap items-center gap-3 rounded-lg border bg-background p-4 text-sm shadow-md">
|
|
||||||
<div className="min-w-0 flex-1 basis-64">
|
|
||||||
<p className="font-medium">画布同步发生冲突</p>
|
|
||||||
<p className="mt-1 text-muted-foreground">本页和云端的同一内容有不同修改。请选择保留哪边的冲突内容,其余图片和修改会合并保留。</p>
|
|
||||||
</div>
|
|
||||||
<Button disabled={isSaving} onClick={() => void resolveCanvasSyncConflict("local")}>保留本页修改</Button>
|
|
||||||
<Button disabled={isSaving} variant="outline" onClick={() => void resolveCanvasSyncConflict("remote")}>保留云端修改</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
|
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-0"
|
className="absolute inset-0 z-0"
|
||||||
@ -11332,12 +11005,14 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
{isVideoMode ? brandEntry : (
|
{isVideoMode ? brandEntry : (
|
||||||
<CanvasSurfaceHeader
|
<CanvasSurfaceHeader
|
||||||
|
isSaving={isSaving}
|
||||||
onAddImage={handleAddImageNode}
|
onAddImage={handleAddImageNode}
|
||||||
onHome={() => requestCanvasLeave("/")}
|
onHome={goHome}
|
||||||
onOpenFiles={() => {
|
onOpenFiles={() => {
|
||||||
setIsPanelOpen(true);
|
setIsPanelOpen(true);
|
||||||
setPanelMode("fileList");
|
setPanelMode("fileList");
|
||||||
}}
|
}}
|
||||||
|
onSave={() => void handleSave()}
|
||||||
projectTitle={projectTitle}
|
projectTitle={projectTitle}
|
||||||
rightInset={panelOcclusionRight}
|
rightInset={panelOcclusionRight}
|
||||||
/>
|
/>
|
||||||
@ -11442,7 +11117,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
createUrl={createNewCanvasUrl}
|
createUrl={createNewCanvasUrl}
|
||||||
currentId={id}
|
currentId={id}
|
||||||
onClose={() => setPanelMode("chat")}
|
onClose={() => setPanelMode("chat")}
|
||||||
onNavigate={requestCanvasLeave}
|
|
||||||
workspaceLabel={workspaceLabel}
|
workspaceLabel={workspaceLabel}
|
||||||
workspaceType={activeCanvasWorkspaceType}
|
workspaceType={activeCanvasWorkspaceType}
|
||||||
/>
|
/>
|
||||||
@ -12405,6 +12079,5 @@ export default function CanvasEditorLegacyPage({
|
|||||||
{projectLoadingBadge}
|
{projectLoadingBadge}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</CanvasRemoteOperationContext.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,13 +48,8 @@ export default function CanvasStyleProPage() {
|
|||||||
const canvasData = result.data.canvasData;
|
const canvasData = result.data.canvasData;
|
||||||
const latestState = useCanvasStore.getState();
|
const latestState = useCanvasStore.getState();
|
||||||
setProject(result.data.id, result.data.title);
|
setProject(result.data.id, result.data.title);
|
||||||
if (
|
if (latestState.nodes.length === 0 && latestState.edges.length === 0) {
|
||||||
latestState.projectId !== id ||
|
|
||||||
(latestState.nodes.length === 0 && latestState.edges.length === 0)
|
|
||||||
) {
|
|
||||||
loadCanvas(canvasData);
|
loadCanvas(canvasData);
|
||||||
const revision = String(result.data.updatedAt || "");
|
|
||||||
useCanvasStore.getState().setCanvasRevision(revision, revision);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { Toaster } from "sonner";
|
|||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { FaviconUpdater } from "@/components/favicon-updater";
|
import { FaviconUpdater } from "@/components/favicon-updater";
|
||||||
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
|
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
|
||||||
import { AppUpdateNotice } from "@/components/app-update-notice";
|
|
||||||
import { getSiteSettings } from "@/lib/db/queries";
|
import { getSiteSettings } from "@/lib/db/queries";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
@ -216,7 +215,6 @@ export default function RootLayout({
|
|||||||
enableSystem
|
enableSystem
|
||||||
>
|
>
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
<AppUpdateNotice />
|
|
||||||
<SessionProvider>{children}</SessionProvider>
|
<SessionProvider>{children}</SessionProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useEffect, useRef, type ReactNode } from "react";
|
|
||||||
|
|
||||||
export function AdminSurface({ children }: { children: ReactNode }) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
||||||
let context: { revert: () => void } | undefined;
|
|
||||||
let disposed = false;
|
|
||||||
import("gsap").then(({ gsap }) => {
|
|
||||||
if (disposed) return;
|
|
||||||
context = gsap.context(() => {
|
|
||||||
gsap.fromTo(
|
|
||||||
ref.current,
|
|
||||||
{ y: 6, opacity: 0.85 },
|
|
||||||
{ y: 0, opacity: 1, duration: 0.25, ease: "power3.out", clearProps: "transform,opacity" },
|
|
||||||
);
|
|
||||||
}, ref);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
context?.revert();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="space-y-6">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,603 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import {
|
|
||||||
RefreshCw,
|
|
||||||
Search,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
ExternalLink,
|
|
||||||
Download,
|
|
||||||
Star,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { MediaThumbnail } from "./media-thumbnail";
|
|
||||||
import { AdminSurface } from "./admin-surface";
|
|
||||||
import { formatRecordTime } from "@/lib/admin/record-time";
|
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
|
||||||
|
|
||||||
export type MediaRecord = {
|
|
||||||
id: string;
|
|
||||||
recordType: "image" | "video";
|
|
||||||
userName: string | null;
|
|
||||||
userEmail: string | null;
|
|
||||||
userDepartment: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
modelId: string | null;
|
|
||||||
chatId: string | null;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
originalUrl: string;
|
|
||||||
videoUrl?: string | null;
|
|
||||||
prompt: string | null;
|
|
||||||
imageSize: string | null;
|
|
||||||
isFeatured: boolean;
|
|
||||||
featuredCategory: string | null;
|
|
||||||
featuredAt?: string | null;
|
|
||||||
};
|
|
||||||
type MediaResponse = {
|
|
||||||
images: MediaRecord[];
|
|
||||||
pagination: {
|
|
||||||
total: number;
|
|
||||||
totalPages: number;
|
|
||||||
totalImages: number;
|
|
||||||
totalVideos: number;
|
|
||||||
page: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
export async function adminFetcher(url: string) {
|
|
||||||
const response = await fetch(url);
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok) throw new Error(data.error || data.message || "加载失败,请重试");
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
const selectClass =
|
|
||||||
"h-10 rounded-md border bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
|
||||||
function recentDate() {
|
|
||||||
const d = new Date();
|
|
||||||
d.setDate(d.getDate() - 6);
|
|
||||||
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MediaRecordsPanel({ categories }: { categories: string[] }) {
|
|
||||||
const [view, setView] = useState("assets");
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [filters, setFilters] = useState({
|
|
||||||
search: "",
|
|
||||||
type: "all",
|
|
||||||
startDate: recentDate(),
|
|
||||||
endDate: "",
|
|
||||||
model: "",
|
|
||||||
featured: "false",
|
|
||||||
status: "all",
|
|
||||||
});
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [selected, setSelected] = useState<MediaRecord | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [originalFailed, setOriginalFailed] = useState(false);
|
|
||||||
const update = (changes: Partial<typeof filters>) => {
|
|
||||||
setFilters((f) => ({ ...f, ...changes }));
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setFilters((f) => (f.search === search ? f : { ...f, search }));
|
|
||||||
setPage(1);
|
|
||||||
}, 350);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [search]);
|
|
||||||
const { data: options } = useSWR<{
|
|
||||||
models: Array<{ id: string; modelId: string; displayName: string; providerName: string }>;
|
|
||||||
}>("/api/admin/ai-models?view=options", adminFetcher, { revalidateOnFocus: false });
|
|
||||||
const modelNames = new Map<string, string>();
|
|
||||||
for (const model of options?.models || [])
|
|
||||||
if (!modelNames.has(model.modelId)) modelNames.set(model.modelId, model.displayName);
|
|
||||||
const recordModelNames = new Map(modelNames);
|
|
||||||
for (const model of options?.models || []) recordModelNames.set(model.id, model.displayName);
|
|
||||||
const openDetails = (item: MediaRecord) => {
|
|
||||||
setSelected(item);
|
|
||||||
setOriginalFailed(false);
|
|
||||||
};
|
|
||||||
const query = new URLSearchParams({ ...filters, page: String(page), pageSize: "40" });
|
|
||||||
const { data, error, isLoading, isValidating, mutate } = useSWR<MediaResponse>(
|
|
||||||
view === "assets" ? `/api/admin/images?${query}` : null,
|
|
||||||
adminFetcher,
|
|
||||||
{ keepPreviousData: true, revalidateOnFocus: false },
|
|
||||||
);
|
|
||||||
const tasks = useSWR<{
|
|
||||||
tasks: Array<{
|
|
||||||
id: string;
|
|
||||||
userName: string;
|
|
||||||
status: string;
|
|
||||||
createdAt: string;
|
|
||||||
error: string | null;
|
|
||||||
model: string | null;
|
|
||||||
elapsedMs: number | null;
|
|
||||||
}>;
|
|
||||||
total: number;
|
|
||||||
}>(view === "tasks" ? `/api/admin/media-tasks?${query}` : null, adminFetcher, {
|
|
||||||
keepPreviousData: true,
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
});
|
|
||||||
const busy = view === "assets" ? isValidating : tasks.isValidating;
|
|
||||||
const failure = view === "assets" ? error : tasks.error;
|
|
||||||
const loading = view === "assets" ? isLoading : tasks.isLoading;
|
|
||||||
const total = view === "assets" ? data?.pagination.total : tasks.data?.total;
|
|
||||||
const pages = Math.max(1, Math.ceil((total || 0) / 40));
|
|
||||||
async function save(changes: Partial<MediaRecord>) {
|
|
||||||
if (!selected || saving) return;
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/images", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ imageId: selected.id, ...changes }),
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error("保存失败,请重试");
|
|
||||||
setSelected({ ...selected, ...changes });
|
|
||||||
toast.success("已保存");
|
|
||||||
await mutate();
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<AdminSurface>
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">生成记录</h2>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
查看图片与视频产物,追踪画布任务。时间统一按北京时间展示。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`mr-2 size-4 ${busy ? "motion-safe:animate-spin" : ""}`} />
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2" role="tablist" aria-label="记录视图">
|
|
||||||
{[
|
|
||||||
["assets", "产物记录"],
|
|
||||||
["tasks", "画布任务"],
|
|
||||||
].map(([key, label]) => (
|
|
||||||
<Button
|
|
||||||
key={key}
|
|
||||||
role="tab"
|
|
||||||
aria-selected={view === key}
|
|
||||||
variant={view === key ? "default" : "ghost"}
|
|
||||||
onClick={() => {
|
|
||||||
setView(key);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-end gap-3 rounded-xl border bg-background p-4">
|
|
||||||
<label className="min-w-48 flex-1 text-xs font-medium">
|
|
||||||
用户
|
|
||||||
<div className="relative mt-2">
|
|
||||||
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
className="h-10 pl-9"
|
|
||||||
placeholder="搜索姓名或邮箱"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
{view === "assets" ? (
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
类型
|
|
||||||
<select
|
|
||||||
aria-label="产物类型"
|
|
||||||
className={`${selectClass} mt-2 block`}
|
|
||||||
value={filters.type}
|
|
||||||
onChange={(e) => update({ type: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="all">图片与视频</option>
|
|
||||||
<option value="image">只看图片</option>
|
|
||||||
<option value="video">只看视频</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
) : (
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
任务状态
|
|
||||||
<select
|
|
||||||
aria-label="任务状态"
|
|
||||||
className={`${selectClass} mt-2 block`}
|
|
||||||
value={filters.status}
|
|
||||||
onChange={(e) => update({ status: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="all">全部状态</option>
|
|
||||||
<option value="failed">失败</option>
|
|
||||||
<option value="running">运行中</option>
|
|
||||||
<option value="pending">排队中</option>
|
|
||||||
<option value="succeeded">成功</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
开始日期
|
|
||||||
<Input
|
|
||||||
aria-label="开始日期"
|
|
||||||
className="mt-2 h-10 w-40"
|
|
||||||
type="date"
|
|
||||||
value={filters.startDate}
|
|
||||||
onChange={(e) => update({ startDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
结束日期
|
|
||||||
<Input
|
|
||||||
aria-label="结束日期"
|
|
||||||
className="mt-2 h-10 w-40"
|
|
||||||
type="date"
|
|
||||||
value={filters.endDate}
|
|
||||||
onChange={(e) => update({ endDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setSearch("");
|
|
||||||
update({
|
|
||||||
search: "",
|
|
||||||
startDate: "",
|
|
||||||
endDate: "",
|
|
||||||
model: "",
|
|
||||||
featured: "false",
|
|
||||||
type: "all",
|
|
||||||
status: "all",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
全部时间 / 重置
|
|
||||||
</Button>
|
|
||||||
{view === "assets" && (
|
|
||||||
<div className="flex w-full flex-wrap items-center gap-3">
|
|
||||||
<select
|
|
||||||
aria-label="记录模型"
|
|
||||||
className={`${selectClass} max-w-full sm:max-w-96`}
|
|
||||||
value={filters.model}
|
|
||||||
onChange={(e) => update({ model: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="">全部模型</option>
|
|
||||||
{Array.from(modelNames, ([id, name]) => (
|
|
||||||
<option key={id} value={id}>
|
|
||||||
{name} · {id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={filters.featured === "true"}
|
|
||||||
onChange={(e) => update({ featured: String(e.target.checked) })}
|
|
||||||
/>
|
|
||||||
只看精选
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-live="polite"
|
|
||||||
className="flex items-center justify-between text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{total === undefined ? "正在读取记录…" : `共 ${total.toLocaleString()} 条记录`}
|
|
||||||
{view === "assets" && data
|
|
||||||
? ` · 图片 ${data.pagination.totalImages.toLocaleString()} · 视频 ${data.pagination.totalVideos.toLocaleString()}`
|
|
||||||
: ""}
|
|
||||||
</span>
|
|
||||||
{busy && !loading && <span>正在更新…</span>}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
第 {page} / {pages} 页 · 每页 40 条
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={busy || page === 1}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={busy || page >= pages}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{failure ? (
|
|
||||||
<div role="alert" className="rounded-xl border border-destructive/30 p-6 text-sm">
|
|
||||||
<p>{failure.message}</p>
|
|
||||||
<Button
|
|
||||||
className="mt-3"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
|
|
||||||
>
|
|
||||||
重试加载
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : loading ? (
|
|
||||||
<div role="status" className="space-y-3 rounded-xl border p-6">
|
|
||||||
{[0, 1, 2, 3].map((x) => (
|
|
||||||
<div key={x} className="h-16 rounded-lg bg-muted motion-safe:animate-pulse" />
|
|
||||||
))}
|
|
||||||
<span className="sr-only">正在加载记录</span>
|
|
||||||
</div>
|
|
||||||
) : total === 0 ? (
|
|
||||||
<div className="rounded-xl border border-dashed p-12 text-center text-sm text-muted-foreground">
|
|
||||||
没有符合条件的记录,请调整筛选条件。
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto rounded-xl border bg-background" aria-busy={busy}>
|
|
||||||
{view === "assets" ? (
|
|
||||||
<table className="w-full min-w-[720px] text-left text-sm">
|
|
||||||
<thead className="bg-muted/50 text-xs text-muted-foreground">
|
|
||||||
<tr>
|
|
||||||
{["产物", "用户", "生成时间", "模型 / 尺寸", "精选", ""].map((v, i) => (
|
|
||||||
<th key={i} className="px-4 py-3 font-medium">
|
|
||||||
{v}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{data?.images.map((item) => (
|
|
||||||
<tr key={item.id} className="hover:bg-muted/30">
|
|
||||||
<td className="p-4">
|
|
||||||
<div>
|
|
||||||
<MediaThumbnail
|
|
||||||
url={item.thumbnailUrl}
|
|
||||||
video={item.recordType === "video"}
|
|
||||||
onOpen={() => openDetails(item)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="font-medium">{item.userName || item.userEmail}</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{item.userDepartment || "未设置部门"}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="whitespace-nowrap px-4 py-3 tabular-nums">
|
|
||||||
{formatRecordTime(item.createdAt)}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-64 px-4 py-3">
|
|
||||||
<div className="truncate" title={item.modelId || ""}>
|
|
||||||
{recordModelNames.get(item.modelId || "") || item.modelId || "未记录模型"}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{item.recordType === "video" ? "视频" : "图片"} ·{" "}
|
|
||||||
{item.imageSize || "未知尺寸"}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{item.isFeatured ? (
|
|
||||||
<span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300">
|
|
||||||
<Star className="size-3" />
|
|
||||||
{item.featuredCategory || "精选"}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => openDetails(item)}>
|
|
||||||
详情
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
) : (
|
|
||||||
<table className="w-full min-w-[720px] text-left text-sm">
|
|
||||||
<thead className="bg-muted/50">
|
|
||||||
<tr>
|
|
||||||
{["用户 / 时间", "状态", "模型", "耗时", "失败原因 / 任务编号"].map((x) => (
|
|
||||||
<th key={x} className="px-4 py-3 font-medium">
|
|
||||||
{x}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{tasks.data?.tasks.map((task) => (
|
|
||||||
<tr key={task.id}>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
{task.userName}
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{formatRecordTime(task.createdAt)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
{{ failed: "失败", running: "运行中", succeeded: "成功", pending: "排队中" }[
|
|
||||||
task.status
|
|
||||||
] || task.status}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-48 break-words px-4 py-4">{task.model || "自动选择"}</td>
|
|
||||||
<td className="px-4 py-4 tabular-nums">
|
|
||||||
{task.elapsedMs === null ? "—" : `${Math.round(task.elapsedMs / 1000)} 秒`}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-xl break-words px-4 py-4">
|
|
||||||
<p className={task.error ? "text-destructive" : "text-muted-foreground"}>
|
|
||||||
{task.error || "—"}
|
|
||||||
</p>
|
|
||||||
<code className="mt-2 block text-xs text-muted-foreground">{task.id}</code>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
每页 40 条 · 第 {page} / {pages} 页
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" disabled={busy || page === 1} onClick={() => setPage(1)}>
|
|
||||||
首页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
aria-label="上一页"
|
|
||||||
variant="outline"
|
|
||||||
disabled={busy || page === 1}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
aria-label="下一页"
|
|
||||||
variant="outline"
|
|
||||||
disabled={busy || page >= pages}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" disabled={busy || page >= pages} onClick={() => setPage(pages)}>
|
|
||||||
末页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Dialog
|
|
||||||
open={!!selected}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setSelected(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto bg-background">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{selected?.recordType === "video" ? "视频详情" : "图片详情"}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
{selected && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex min-h-40 justify-center rounded-xl bg-muted">
|
|
||||||
{selected.recordType === "video" ? (
|
|
||||||
<video
|
|
||||||
src={selected.videoUrl || selected.originalUrl}
|
|
||||||
controls
|
|
||||||
preload="none"
|
|
||||||
className="max-h-[45vh] w-full"
|
|
||||||
/>
|
|
||||||
) : originalFailed ? (
|
|
||||||
<div role="alert" className="p-8 text-sm">
|
|
||||||
原图无法加载,源文件可能已过期或存储不可达。
|
|
||||||
<Button
|
|
||||||
className="ml-3"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setOriginalFailed(false)}
|
|
||||||
>
|
|
||||||
重试
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={selected.originalUrl}
|
|
||||||
alt="生成原图"
|
|
||||||
className="max-h-[45vh] object-contain"
|
|
||||||
onError={() => setOriginalFailed(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<dl className="grid gap-4 text-sm sm:grid-cols-2">
|
|
||||||
{[
|
|
||||||
["用户", selected.userName || selected.userEmail],
|
|
||||||
["部门", selected.userDepartment],
|
|
||||||
["生成时间", formatRecordTime(selected.createdAt)],
|
|
||||||
["尺寸", selected.imageSize],
|
|
||||||
["模型", recordModelNames.get(selected.modelId || "") || selected.modelId],
|
|
||||||
["精选时间", selected.featuredAt ? formatRecordTime(selected.featuredAt) : "—"],
|
|
||||||
].map(([k, v]) => (
|
|
||||||
<div key={k}>
|
|
||||||
<dt className="text-xs text-muted-foreground">{k}</dt>
|
|
||||||
<dd className="mt-1 break-words">{v || "未记录"}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium">提示词</h3>
|
|
||||||
<p className="mt-2 whitespace-pre-wrap break-words text-sm text-muted-foreground">
|
|
||||||
{selected.prompt || "未记录提示词"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<a href={selected.originalUrl} target="_blank" rel="noreferrer">
|
|
||||||
<ExternalLink className="mr-2 size-4" />
|
|
||||||
打开原文件
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
{selected.recordType === "image" && (
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<a
|
|
||||||
href={buildImageProxyUrl(selected.originalUrl, { delivery: "original" })}
|
|
||||||
download
|
|
||||||
>
|
|
||||||
<Download className="mr-2 size-4" />
|
|
||||||
下载原图
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{selected.recordType === "image" && (
|
|
||||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border p-4">
|
|
||||||
<select
|
|
||||||
aria-label="精选分类"
|
|
||||||
className={selectClass}
|
|
||||||
value={selected.featuredCategory || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelected({ ...selected, featuredCategory: e.target.value || null })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">未分类</option>
|
|
||||||
{[...new Set([...categories, selected.featuredCategory].filter(Boolean))].map(
|
|
||||||
(c) => (
|
|
||||||
<option key={c} value={c!}>
|
|
||||||
{c}
|
|
||||||
</option>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={saving}
|
|
||||||
onClick={() => save({ featuredCategory: selected.featuredCategory })}
|
|
||||||
>
|
|
||||||
保存分类
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={saving}
|
|
||||||
onClick={() => save({ isFeatured: !selected.isFeatured })}
|
|
||||||
>
|
|
||||||
<Star className="mr-2 size-4" />
|
|
||||||
{selected.isFeatured ? "取消精选" : "设为精选"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</AdminSurface>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { ImageOff, Video, RotateCw } from "lucide-react";
|
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
|
||||||
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
|
|
||||||
|
|
||||||
import { queuePreview } from "@/lib/admin/preview-queue";
|
|
||||||
|
|
||||||
export function MediaThumbnail({
|
|
||||||
url,
|
|
||||||
video = false,
|
|
||||||
onOpen,
|
|
||||||
}: {
|
|
||||||
url: string;
|
|
||||||
video?: boolean;
|
|
||||||
onOpen?: () => void;
|
|
||||||
}) {
|
|
||||||
const container = useRef<HTMLDivElement>(null);
|
|
||||||
const [state, setState] = useState<"waiting" | "loading" | "ready" | "error">("waiting");
|
|
||||||
const [attempt, setAttempt] = useState(0);
|
|
||||||
const [source, setSource] = useState("");
|
|
||||||
const [failure, setFailure] = useState("");
|
|
||||||
const retired = isRetiredStorageUrl(url);
|
|
||||||
useEffect(() => {
|
|
||||||
setState("waiting");
|
|
||||||
setSource("");
|
|
||||||
setFailure("");
|
|
||||||
if (video || retired || !url || !container.current) return;
|
|
||||||
let dispose: (() => void) | undefined;
|
|
||||||
let abort = () => {};
|
|
||||||
let objectUrl = "";
|
|
||||||
let alive = true;
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (!entry.isIntersecting || dispose) return;
|
|
||||||
observer.disconnect();
|
|
||||||
dispose = queuePreview((release) => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 48000);
|
|
||||||
setState("loading");
|
|
||||||
// Fetch is abortable, so leaving a page also stops in-flight previews.
|
|
||||||
const parsed = new URL(url, window.location.origin);
|
|
||||||
const target =
|
|
||||||
parsed.hostname === "scqnycdn.jiansun.vip" && !parsed.search
|
|
||||||
? `${url}?imageView2/2/w/192/q/72/format/webp`
|
|
||||||
: buildImageProxyUrl(url, {
|
|
||||||
width: 192,
|
|
||||||
quality: 72,
|
|
||||||
format: "webp",
|
|
||||||
delivery: "proxy",
|
|
||||||
});
|
|
||||||
fetch(target, { signal: controller.signal })
|
|
||||||
.then(async (response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
const detail = await response.json().catch(() => null);
|
|
||||||
throw new Error(`${detail?.error || "预览请求失败"}(HTTP ${response.status})`);
|
|
||||||
}
|
|
||||||
const blob = await response.blob();
|
|
||||||
if (!blob.type.startsWith("image/")) throw new Error("Not an image");
|
|
||||||
if (alive) {
|
|
||||||
objectUrl = URL.createObjectURL(blob);
|
|
||||||
setSource(objectUrl);
|
|
||||||
setState("ready");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (alive) {
|
|
||||||
setFailure(
|
|
||||||
error.name === "AbortError" ? "预览加载超时" : error.message || "预览加载失败",
|
|
||||||
);
|
|
||||||
setState("error");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
release();
|
|
||||||
});
|
|
||||||
abort = () => {
|
|
||||||
controller.abort();
|
|
||||||
clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ rootMargin: "80px" },
|
|
||||||
);
|
|
||||||
observer.observe(container.current);
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
observer.disconnect();
|
|
||||||
abort();
|
|
||||||
dispose?.();
|
|
||||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
||||||
};
|
|
||||||
}, [url, video, retired, attempt]);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={container}
|
|
||||||
className="relative flex h-16 w-20 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted"
|
|
||||||
data-preview-state={state}
|
|
||||||
>
|
|
||||||
{video ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="查看视频详情"
|
|
||||||
className="flex h-full w-full items-center justify-center"
|
|
||||||
onClick={onOpen}
|
|
||||||
>
|
|
||||||
<Video className="size-5 text-muted-foreground" aria-label="视频,点击查看后播放" />
|
|
||||||
</button>
|
|
||||||
) : retired ? (
|
|
||||||
<span className="text-center text-xs text-muted-foreground">
|
|
||||||
旧存储
|
|
||||||
<br />
|
|
||||||
已停用
|
|
||||||
</span>
|
|
||||||
) : source && state === "ready" ? (
|
|
||||||
<button type="button" aria-label="查看图片详情" className="h-full w-full" onClick={onOpen}>
|
|
||||||
<img
|
|
||||||
src={source}
|
|
||||||
alt="生成结果预览"
|
|
||||||
className="h-full w-full object-contain"
|
|
||||||
onError={() => {
|
|
||||||
setFailure("图片内容无法解码");
|
|
||||||
setState("error");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
) : state === "error" ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex h-full w-full flex-col items-center justify-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
title={`${failure},点击重试`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setAttempt((x) => x + 1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ImageOff className="size-4" />
|
|
||||||
加载失败
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<RotateCw
|
|
||||||
className={`size-4 text-muted-foreground ${state === "loading" ? "motion-safe:animate-spin" : ""}`}
|
|
||||||
aria-label={state === "loading" ? "正在加载预览" : "等待进入可见区域"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,191 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useState } from "react";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { adminFetcher } from "./media-records-panel";
|
|
||||||
import { formatRecordTime } from "@/lib/admin/record-time";
|
|
||||||
type Event = {
|
|
||||||
id: string;
|
|
||||||
modelDisplayName: string;
|
|
||||||
modelRuntimeId: string;
|
|
||||||
providerName: string;
|
|
||||||
eventType: string;
|
|
||||||
createdAt: string;
|
|
||||||
errorMessage: string;
|
|
||||||
elapsedMs: number | null;
|
|
||||||
consecutiveFailures: number;
|
|
||||||
autoDisabled: boolean;
|
|
||||||
};
|
|
||||||
export function ModelHealthPanel({
|
|
||||||
providers,
|
|
||||||
models,
|
|
||||||
initialModelId = "",
|
|
||||||
}: {
|
|
||||||
initialModelId?: string;
|
|
||||||
providers: Array<{ id: string; name: string }>;
|
|
||||||
models: Array<{ id: string; displayName: string; providerId: string }>;
|
|
||||||
}) {
|
|
||||||
const [filters, setFilters] = useState({
|
|
||||||
providerId: "",
|
|
||||||
modelId: initialModelId,
|
|
||||||
eventType: "",
|
|
||||||
startDate: "",
|
|
||||||
endDate: "",
|
|
||||||
});
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const query = new URLSearchParams({ ...filters, page: String(page), limit: "20" });
|
|
||||||
const { data, error, isLoading, isValidating, mutate } = useSWR<{
|
|
||||||
events: Event[];
|
|
||||||
hasMore: boolean;
|
|
||||||
}>(`/api/admin/ai-model-health?${query}`, adminFetcher, {
|
|
||||||
keepPreviousData: true,
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
});
|
|
||||||
const update = (change: Partial<typeof filters>) => {
|
|
||||||
setFilters((f) => ({ ...f, ...change }));
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold">运行健康</h3>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
按上游和模型追踪故障,查看完整原因与发生时间。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{[
|
|
||||||
["providerId", "全部上游", providers.map((p) => [p.id, p.name])],
|
|
||||||
[
|
|
||||||
"modelId",
|
|
||||||
"全部模型",
|
|
||||||
models
|
|
||||||
.filter((m) => !filters.providerId || m.providerId === filters.providerId)
|
|
||||||
.map((m) => [m.id, m.displayName]),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"eventType",
|
|
||||||
"全部错误",
|
|
||||||
[
|
|
||||||
["quota", "额度 / 余额"],
|
|
||||||
["timeout", "超时"],
|
|
||||||
["unavailable", "不可用"],
|
|
||||||
["invalid_model", "模型不可用"],
|
|
||||||
["download_error", "结果下载失败"],
|
|
||||||
["error", "其他错误"],
|
|
||||||
["success", "成功"],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
].map(([key, label, options]) => (
|
|
||||||
<select
|
|
||||||
key={key as string}
|
|
||||||
aria-label={label as string}
|
|
||||||
className="h-10 max-w-64 rounded-md border bg-background px-3 text-sm"
|
|
||||||
value={filters[key as keyof typeof filters]}
|
|
||||||
onChange={(e) =>
|
|
||||||
update({
|
|
||||||
[key as string]: e.target.value,
|
|
||||||
...(key === "providerId" ? { modelId: "" } : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">{label as string}</option>
|
|
||||||
{(options as string[][]).map(([value, text]) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{text}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
))}
|
|
||||||
<Input
|
|
||||||
aria-label="故障开始日期"
|
|
||||||
className="h-10 w-40"
|
|
||||||
type="date"
|
|
||||||
value={filters.startDate}
|
|
||||||
onChange={(e) => update({ startDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
aria-label="故障结束日期"
|
|
||||||
className="h-10 w-40"
|
|
||||||
type="date"
|
|
||||||
value={filters.endDate}
|
|
||||||
onChange={(e) => update({ endDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Button variant="outline" disabled={isValidating} onClick={() => mutate()}>
|
|
||||||
刷新日志
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{error ? (
|
|
||||||
<p role="alert" className="p-6 text-sm text-destructive">
|
|
||||||
日志加载失败,请点击刷新重试。
|
|
||||||
</p>
|
|
||||||
) : isLoading ? (
|
|
||||||
<p role="status" className="p-6 text-sm">
|
|
||||||
正在加载故障日志…
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y rounded-xl border">
|
|
||||||
{data?.events.map((event) => (
|
|
||||||
<div key={event.id} className="p-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
|
||||||
<strong className="font-semibold">
|
|
||||||
{event.modelDisplayName || event.modelRuntimeId || "已删除模型"}
|
|
||||||
</strong>
|
|
||||||
<span className="text-muted-foreground">{event.providerName || "未知上游"}</span>
|
|
||||||
<span className="rounded bg-muted px-2 py-1 text-xs">
|
|
||||||
{{
|
|
||||||
quota: "额度 / 余额",
|
|
||||||
timeout: "超时",
|
|
||||||
unavailable: "不可用",
|
|
||||||
error: "服务错误",
|
|
||||||
invalid_model: "模型不可用",
|
|
||||||
download_error: "结果下载失败",
|
|
||||||
success: "成功",
|
|
||||||
}[event.eventType] || event.eventType}
|
|
||||||
</span>
|
|
||||||
<time className="ml-auto text-xs tabular-nums text-muted-foreground">
|
|
||||||
{formatRecordTime(event.createdAt)}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 whitespace-pre-wrap break-words text-sm">
|
|
||||||
{event.errorMessage || "未记录详细原因"}
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
|
|
||||||
<span>连续失败 {event.consecutiveFailures} 次</span>
|
|
||||||
{event.elapsedMs !== null && (
|
|
||||||
<span>耗时 {Math.round(event.elapsedMs / 1000)} 秒</span>
|
|
||||||
)}
|
|
||||||
{event.autoDisabled && <span>已自动停用</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!data?.events.length && (
|
|
||||||
<p className="p-12 text-center text-sm text-muted-foreground">该范围内没有故障记录。</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
第 {page} 页{isValidating && " · 正在更新…"}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={page === 1 || isValidating}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={!data?.hasMore || isValidating}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,326 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import type { AiModel } from "@/lib/db/schema";
|
|
||||||
import {
|
|
||||||
is3DModelDescriptor,
|
|
||||||
isVideoModelDescriptor,
|
|
||||||
isImageGenerationModelDescriptor,
|
|
||||||
} from "@/lib/ai/model-classification";
|
|
||||||
|
|
||||||
type Model = AiModel & { isAgentModel?: boolean; isMultimodal?: boolean };
|
|
||||||
type Provider = { id: string; name: string; type: string; isEnabled: boolean };
|
|
||||||
const selectClass = "h-10 rounded-md border bg-background px-3 text-sm";
|
|
||||||
export function ModelManagementList({
|
|
||||||
models,
|
|
||||||
providers,
|
|
||||||
error,
|
|
||||||
loading,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onRestore,
|
|
||||||
onRefresh,
|
|
||||||
onHealth,
|
|
||||||
}: {
|
|
||||||
models: Model[];
|
|
||||||
providers: Provider[];
|
|
||||||
error?: Error;
|
|
||||||
loading: boolean;
|
|
||||||
onEdit: (model: Model) => Promise<void>;
|
|
||||||
onDelete: (id: string) => void;
|
|
||||||
onRestore: (model: Model) => void;
|
|
||||||
onRefresh: () => void;
|
|
||||||
onHealth?: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [provider, setProvider] = useState("");
|
|
||||||
const [status, setStatus] = useState("enabled");
|
|
||||||
const [kind, setKind] = useState("");
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [opening, setOpening] = useState<string | null>(null);
|
|
||||||
const rows = useMemo(
|
|
||||||
() =>
|
|
||||||
models
|
|
||||||
.map((model) => {
|
|
||||||
const upstream = providers.find((p) => p.id === model.providerId);
|
|
||||||
const descriptor = { ...model, providerType: upstream?.type };
|
|
||||||
const type = is3DModelDescriptor(descriptor)
|
|
||||||
? "3d"
|
|
||||||
: isVideoModelDescriptor(descriptor)
|
|
||||||
? "video"
|
|
||||||
: isImageGenerationModelDescriptor(descriptor)
|
|
||||||
? "image"
|
|
||||||
: "chat";
|
|
||||||
return { model, upstream, type };
|
|
||||||
})
|
|
||||||
.filter(({ model, upstream, type }) => {
|
|
||||||
const text =
|
|
||||||
`${model.displayName} ${model.modelId} ${upstream?.name || ""}`.toLowerCase();
|
|
||||||
return (
|
|
||||||
text.includes(search.trim().toLowerCase()) &&
|
|
||||||
(!provider || model.providerId === provider) &&
|
|
||||||
(!kind || kind === type) &&
|
|
||||||
(status === "all" ||
|
|
||||||
(status === "enabled" && model.isEnabled) ||
|
|
||||||
(status === "disabled" && !model.isEnabled) ||
|
|
||||||
(status === "unhealthy" && model.healthStatus !== "healthy") ||
|
|
||||||
(status === "visible" && !model.isHidden))
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
[models, providers, search, provider, status, kind],
|
|
||||||
);
|
|
||||||
const pages = Math.max(1, Math.ceil(rows.length / 25));
|
|
||||||
const currentPage = Math.min(page, pages);
|
|
||||||
const change = (fn: () => void) => {
|
|
||||||
fn();
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<div className="relative min-w-56 flex-1">
|
|
||||||
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
className="h-10 pl-9"
|
|
||||||
aria-label="搜索模型"
|
|
||||||
placeholder="搜索模型名称、ID 或上游"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => change(() => setSearch(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
aria-label="模型上游"
|
|
||||||
className={selectClass}
|
|
||||||
value={provider}
|
|
||||||
onChange={(e) => change(() => setProvider(e.target.value))}
|
|
||||||
>
|
|
||||||
<option value="">全部上游</option>
|
|
||||||
{providers.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
aria-label="模型类型"
|
|
||||||
className={selectClass}
|
|
||||||
value={kind}
|
|
||||||
onChange={(e) => change(() => setKind(e.target.value))}
|
|
||||||
>
|
|
||||||
<option value="">全部类型</option>
|
|
||||||
<option value="image">生图</option>
|
|
||||||
<option value="video">视频</option>
|
|
||||||
<option value="chat">对话</option>
|
|
||||||
<option value="3d">3D / CAD</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
aria-label="模型状态"
|
|
||||||
className={selectClass}
|
|
||||||
value={status}
|
|
||||||
onChange={(e) => change(() => setStatus(e.target.value))}
|
|
||||||
>
|
|
||||||
<option value="enabled">已启用</option>
|
|
||||||
<option value="all">全部模型</option>
|
|
||||||
<option value="disabled">已停用</option>
|
|
||||||
<option value="unhealthy">运行异常</option>
|
|
||||||
<option value="visible">前台可见</option>
|
|
||||||
</select>
|
|
||||||
<Button variant="outline" onClick={onRefresh} aria-label="刷新模型">
|
|
||||||
<RefreshCw className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{loading
|
|
||||||
? "正在加载模型…"
|
|
||||||
: `筛选结果 ${rows.length} 个 · 已启用 ${models.filter((m) => m.isEnabled).length} 个 · 全部 ${models.length} 个`}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
第 {currentPage} / {pages} 页
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
onClick={() => setPage(currentPage - 1)}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={currentPage === pages}
|
|
||||||
onClick={() => setPage(currentPage + 1)}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{error ? (
|
|
||||||
<div role="alert" className="rounded-xl border p-6 text-sm">
|
|
||||||
模型加载失败。
|
|
||||||
<Button variant="outline" onClick={onRefresh}>
|
|
||||||
重试
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : loading ? (
|
|
||||||
<div role="status" className="h-48 rounded-xl bg-muted motion-safe:animate-pulse" />
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto rounded-xl border">
|
|
||||||
<table className="w-full min-w-[850px] text-left text-sm">
|
|
||||||
<thead className="bg-muted/50 text-xs text-muted-foreground">
|
|
||||||
<tr>
|
|
||||||
{["模型", "上游 / 类型", "启用", "前台展示", "运行健康", "操作"].map((x) => (
|
|
||||||
<th key={x} className="px-4 py-3 font-medium">
|
|
||||||
{x}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{rows
|
|
||||||
.slice((currentPage - 1) * 25, currentPage * 25)
|
|
||||||
.map(({ model, upstream, type }) => (
|
|
||||||
<tr key={model.id} className="hover:bg-muted/30">
|
|
||||||
<td className="max-w-64 px-4 py-4">
|
|
||||||
<div className="font-medium">{model.displayName}</div>
|
|
||||||
<div
|
|
||||||
className="mt-1 truncate text-xs text-muted-foreground"
|
|
||||||
title={model.modelId}
|
|
||||||
>
|
|
||||||
{model.modelId}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
||||||
{model.isDefault && <span>默认模型</span>}
|
|
||||||
{model.isDefaultTextEditModel && <span>文字编辑默认</span>}
|
|
||||||
{model.isAgentModel && <span>Agent</span>}
|
|
||||||
{model.isMultimodal && <span>多模态</span>}
|
|
||||||
{model.isSubModel && <span>子模型</span>}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
{upstream?.name || "未知上游"}
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{{ image: "生图", video: "视频", chat: "对话", "3d": "3D / CAD" }[type]}
|
|
||||||
{upstream && !upstream.isEnabled ? " · 上游已停用" : ""}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="whitespace-nowrap px-4 py-4">
|
|
||||||
<span
|
|
||||||
className={`rounded-md px-2 py-1 text-xs ${model.isEnabled ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200" : "bg-muted text-muted-foreground"}`}
|
|
||||||
>
|
|
||||||
{model.isEnabled ? "已启用" : "已停用"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
{model.isHidden || model.isSubModel ? "隐藏" : "可见"}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-72 px-4 py-4">
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
model.healthStatus !== "healthy"
|
|
||||||
? "text-amber-700 dark:text-amber-300"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{model.healthStatus === "auto_disabled"
|
|
||||||
? "自动停用"
|
|
||||||
: model.healthStatus === "degraded"
|
|
||||||
? "观察中"
|
|
||||||
: "正常"}
|
|
||||||
</span>
|
|
||||||
{model.healthConsecutiveFailures > 0 && (
|
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
|
||||||
连续失败 {model.healthConsecutiveFailures} 次
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{model.healthLastError && (
|
|
||||||
<details className="mt-2 text-xs">
|
|
||||||
<summary className="cursor-pointer text-muted-foreground">
|
|
||||||
查看最近原因
|
|
||||||
</summary>
|
|
||||||
<p className="mt-2 whitespace-pre-wrap break-words">
|
|
||||||
{model.healthLastError}
|
|
||||||
</p>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mt-2 block text-xs underline underline-offset-4"
|
|
||||||
onClick={() => onHealth?.(model.id)}
|
|
||||||
>
|
|
||||||
故障记录
|
|
||||||
</button>
|
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
|
||||||
{model.excludeFromHealthBan && "不自动封禁 "}
|
|
||||||
{model.excludeFromFailover && "不参与迁移"}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={opening !== null}
|
|
||||||
onClick={async () => {
|
|
||||||
setOpening(model.id);
|
|
||||||
try {
|
|
||||||
await onEdit(model);
|
|
||||||
} finally {
|
|
||||||
setOpening(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opening === model.id ? "加载中…" : "编辑"}
|
|
||||||
</Button>
|
|
||||||
{model.healthStatus === "auto_disabled" && (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onRestore(model)}>
|
|
||||||
恢复
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive"
|
|
||||||
onClick={() => onDelete(model.id)}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{rows.length === 0 && (
|
|
||||||
<p className="p-12 text-center text-sm text-muted-foreground">没有符合条件的模型。</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
每页 25 个 · 第 {currentPage} / {pages} 页
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
aria-label="上一页模型"
|
|
||||||
variant="outline"
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
onClick={() => setPage(currentPage - 1)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
aria-label="下一页模型"
|
|
||||||
variant="outline"
|
|
||||||
disabled={currentPage === pages}
|
|
||||||
onClick={() => setPage(currentPage + 1)}
|
|
||||||
>
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
@ -576,11 +575,31 @@ export function AgentGenerationWorkspace({
|
|||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filename = buildDownloadFilename(raw);
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
const response = await fetch(raw);
|
||||||
toast.success("原图已开始下载");
|
if (!response.ok) {
|
||||||
} catch (error) {
|
throw new Error(`download failed: ${response.status}`);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
|
} catch {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = raw;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
toast.message("已打开原图,可右键另存为");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
|
||||||
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
||||||
import { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
|
|
||||||
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
|
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
import {
|
import {
|
||||||
@ -4172,7 +4169,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
? "agent-anchor-materials"
|
? "agent-anchor-materials"
|
||||||
: "agent-anchor-generate";
|
: "agent-anchor-generate";
|
||||||
|
|
||||||
const [finishedSceneRequestId, setFinishedSceneRequestId] = useState<string | null>(null);
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
setMessages,
|
setMessages,
|
||||||
@ -4193,11 +4189,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
void refreshHistory();
|
void refreshHistory();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFinish({ messages: finishedMessages }) {
|
onFinish() { void refreshHistory(); },
|
||||||
const lastUser = finishedMessages.filter((message) => message.role === "user").at(-1);
|
|
||||||
setFinishedSceneRequestId(lastUser?.id || null);
|
|
||||||
void refreshHistory();
|
|
||||||
},
|
|
||||||
experimental_throttle: 100,
|
experimental_throttle: 100,
|
||||||
generateId: generateUUID,
|
generateId: generateUUID,
|
||||||
transport: new DefaultChatTransport({
|
transport: new DefaultChatTransport({
|
||||||
@ -4880,14 +4872,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
return recoverReplacementRun({ messages, ...pendingGeneration });
|
return recoverReplacementRun({ messages, ...pendingGeneration });
|
||||||
}, [messages, pendingGeneration]);
|
}, [messages, pendingGeneration]);
|
||||||
const transportIsGenerating = status === "streaming" || status === "submitted";
|
const transportIsGenerating = status === "streaming" || status === "submitted";
|
||||||
useEffect(() => {
|
|
||||||
const beforeRefresh = (event: Event) => {
|
|
||||||
if (transportIsGenerating || pendingGeneration)
|
|
||||||
(event as CustomEvent<PageRefreshRequest>).detail.block("当前生图任务仍在处理中,请等待结果返回后再刷新。原任务无需重新提交。");
|
|
||||||
};
|
|
||||||
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
|
||||||
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
|
||||||
}, [transportIsGenerating, pendingGeneration]);
|
|
||||||
const pendingGenerationProgressCount = useMemo(() => {
|
const pendingGenerationProgressCount = useMemo(() => {
|
||||||
if (!pendingGeneration) {
|
if (!pendingGeneration) {
|
||||||
return 0;
|
return 0;
|
||||||
@ -5498,17 +5482,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
setActiveResultModeType(currentResultModeType);
|
setActiveResultModeType(currentResultModeType);
|
||||||
}, [currentResultModeType]);
|
}, [currentResultModeType]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pendingGeneration?.requestMessageId || pendingGeneration.modeType !== "scene_generation" || transportIsGenerating) return;
|
|
||||||
const result = getSceneRunResult(messages, pendingGeneration.requestMessageId);
|
|
||||||
const persisted = getSceneRunResult(historyMessages, pendingGeneration.requestMessageId);
|
|
||||||
if (!result || (finishedSceneRequestId !== pendingGeneration.requestMessageId && !persisted?.count)) return;
|
|
||||||
setPendingGeneration(null);
|
|
||||||
if (result.count < pendingGeneration.expectedCount) {
|
|
||||||
toast.warning(`本次生成已结束,收到 ${result.count}/${pendingGeneration.expectedCount} 张图片。缺少的图片未继续生成,可调整张数后重新提交。`);
|
|
||||||
}
|
|
||||||
}, [pendingGeneration, transportIsGenerating, finishedSceneRequestId, messages, historyMessages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
|
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
|
||||||
// The backend has settled every requested card; an open SSE connection is no longer work.
|
// The backend has settled every requested card; an open SSE connection is no longer work.
|
||||||
@ -6803,11 +6776,31 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filename = buildDownloadFilename(raw);
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
const response = await fetch(raw);
|
||||||
toast.success("原图已开始下载");
|
if (!response.ok) {
|
||||||
} catch (error) {
|
throw new Error(`download failed: ${response.status}`);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
|
} catch {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = raw;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
toast.message("已打开原图,可右键另存为");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {
|
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
@ -726,11 +725,30 @@ export function AgentGenerationWorkspaceNew({
|
|||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const filename = buildDownloadFilename(raw);
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
const response = await fetch(raw);
|
||||||
toast.success("原图已开始下载");
|
if (!response.ok) {
|
||||||
} catch (error) {
|
throw new Error(`download failed: ${response.status}`);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
|
} catch {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = raw;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
toast.message("已打开原图,可右键另存为");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -1,76 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog";
|
|
||||||
import { preparePageRefresh } from "@/lib/client/page-refresh";
|
|
||||||
|
|
||||||
export function AppUpdateNotice() {
|
|
||||||
const [availableVersion, setAvailableVersion] = useState("");
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
useEffect(() => {
|
|
||||||
let disposed = false;
|
|
||||||
let controller: AbortController | undefined;
|
|
||||||
let checking = false;
|
|
||||||
const check = async () => {
|
|
||||||
if (disposed || checking || document.visibilityState !== "visible") return;
|
|
||||||
checking = true;
|
|
||||||
controller = new AbortController();
|
|
||||||
const current = controller;
|
|
||||||
const timeout = window.setTimeout(() => current.abort(), 8000);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/app-version", { cache: "no-store", signal: current.signal });
|
|
||||||
if (!response.ok) return;
|
|
||||||
const data = await response.json();
|
|
||||||
if (!disposed && !current.signal.aborted && typeof data.version === "string" && data.version)
|
|
||||||
setAvailableVersion(data.version !== (process.env.NEXT_PUBLIC_APP_RELEASE || "development") ? data.version : "");
|
|
||||||
} catch {
|
|
||||||
// A failed version read leaves the current workspace available.
|
|
||||||
} finally {
|
|
||||||
window.clearTimeout(timeout);
|
|
||||||
checking = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void check();
|
|
||||||
const interval = window.setInterval(check, 60_000);
|
|
||||||
window.addEventListener("focus", check);
|
|
||||||
window.addEventListener("online", check);
|
|
||||||
document.addEventListener("visibilitychange", check);
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
controller?.abort();
|
|
||||||
clearInterval(interval);
|
|
||||||
window.removeEventListener("focus", check);
|
|
||||||
window.removeEventListener("online", check);
|
|
||||||
document.removeEventListener("visibilitychange", check);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
if (refreshing) return;
|
|
||||||
setRefreshing(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
await preparePageRefresh();
|
|
||||||
window.location.reload();
|
|
||||||
setRefreshing(false);
|
|
||||||
} catch (cause) {
|
|
||||||
setError(cause instanceof Error ? cause.message : "当前内容同步失败,请保留此页并重试。");
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return <AlertDialog open={Boolean(availableVersion)}>
|
|
||||||
<AlertDialogContent className="z-[10001]" overlayClassName="z-[10000]" onEscapeKeyDown={event => event.preventDefault()}>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>平台已更新</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>需要刷新页面后继续使用。点击后会先同步当前画布并保留输入;正在处理的任务完成后再刷新。</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
|
||||||
<AlertDialogFooter>
|
|
||||||
{error ? <Button variant="outline" disabled={refreshing} onClick={() => { setAvailableVersion(""); setError(""); }}>返回处理</Button> : null}
|
|
||||||
<Button disabled={refreshing} onClick={() => void refresh()}>{refreshing ? "正在同步内容…" : "同步并刷新"}</Button>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>;
|
|
||||||
}
|
|
||||||
@ -1,14 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { advancePastAtomicRanges, scanMarkdownImageStreaming } from "@/lib/canvas/markdown-image-streaming";
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { useStickToBottom } from "use-stick-to-bottom";
|
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
|
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
|
||||||
import { CanvasAgentProgressMessage } from "@/components/canvas/chat-panel/canvas-agent-progress";
|
import { CanvasAgentProgressMessage } from "@/components/canvas/chat-panel/canvas-agent-progress";
|
||||||
import { useCanvasRemoteOperation, useCanvasRemoteSnapshot } from "@/components/canvas/CanvasRemoteOperationContext";
|
|
||||||
import { readCanvasComposerDraft, writeCanvasComposerDraft, clearCanvasComposerDraft } from "@/lib/client/canvas-draft";
|
|
||||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
|
||||||
import { canvasHistoryForChat } from "@/lib/client/canvas-history-response";
|
import { canvasHistoryForChat } from "@/lib/client/canvas-history-response";
|
||||||
import { useCanvasAgentRun } from "@/components/canvas/chat-panel/use-canvas-agent-run";
|
import { useCanvasAgentRun } from "@/components/canvas/chat-panel/use-canvas-agent-run";
|
||||||
import { canvasAgentProgress, type CanvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
|
import { canvasAgentProgress, type CanvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
|
||||||
@ -70,8 +65,6 @@ import {
|
|||||||
import { toast } from "sonner";
|
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 { 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,
|
||||||
@ -86,8 +79,7 @@ import { canvasService } from "@/services/canvas";
|
|||||||
import {
|
import {
|
||||||
inferOriginalImageUrl,
|
inferOriginalImageUrl,
|
||||||
} from "@/lib/image-url";
|
} from "@/lib/image-url";
|
||||||
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
|
|
||||||
import {
|
import {
|
||||||
isImageGenerationModelDescriptor,
|
isImageGenerationModelDescriptor,
|
||||||
isVideoModelDescriptor,
|
isVideoModelDescriptor,
|
||||||
@ -380,9 +372,7 @@ function MarkdownResponseFallback({
|
|||||||
}: Pick<ResponseProps, "children" | "className">) {
|
}: Pick<ResponseProps, "children" | "className">) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className)}>
|
<div className={cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className)}>
|
||||||
{typeof children === "string"
|
{children}
|
||||||
? getGatewayErrorPageMessage(children) || children
|
|
||||||
: children}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1927,7 +1917,6 @@ type CanvasTypewriterTextProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
renderMarkdown?: boolean;
|
renderMarkdown?: boolean;
|
||||||
preserveMarkdownTables?: boolean;
|
preserveMarkdownTables?: boolean;
|
||||||
isMarkdownStreaming?: boolean;
|
|
||||||
enablePdfDownload?: boolean;
|
enablePdfDownload?: boolean;
|
||||||
pdfFileName?: string;
|
pdfFileName?: string;
|
||||||
pdfSiteLogo?: string | null;
|
pdfSiteLogo?: string | null;
|
||||||
@ -2368,6 +2357,20 @@ function getMarkdownTableBlockRanges(content: string): MarkdownTableBlockRange[]
|
|||||||
return ranges;
|
return ranges;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function advanceTypewriterCursor(
|
||||||
|
contentLength: number,
|
||||||
|
targetCursor: number,
|
||||||
|
tableRanges: MarkdownTableBlockRange[]
|
||||||
|
) {
|
||||||
|
const clampedCursor = Math.min(contentLength, targetCursor);
|
||||||
|
for (const range of tableRanges) {
|
||||||
|
if (clampedCursor >= range.start && clampedCursor < range.end) {
|
||||||
|
return Math.min(contentLength, range.end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clampedCursor;
|
||||||
|
}
|
||||||
|
|
||||||
function CanvasTypewriterCursor() {
|
function CanvasTypewriterCursor() {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@ -2384,7 +2387,6 @@ function CanvasTypewriterText({
|
|||||||
className,
|
className,
|
||||||
renderMarkdown = false,
|
renderMarkdown = false,
|
||||||
preserveMarkdownTables = false,
|
preserveMarkdownTables = false,
|
||||||
isMarkdownStreaming = false,
|
|
||||||
enablePdfDownload = false,
|
enablePdfDownload = false,
|
||||||
pdfFileName,
|
pdfFileName,
|
||||||
pdfSiteLogo,
|
pdfSiteLogo,
|
||||||
@ -2411,15 +2413,12 @@ function CanvasTypewriterText({
|
|||||||
const tableRanges = preserveMarkdownTables
|
const tableRanges = preserveMarkdownTables
|
||||||
? getMarkdownTableBlockRanges(content)
|
? getMarkdownTableBlockRanges(content)
|
||||||
: [];
|
: [];
|
||||||
const atomicRanges = renderMarkdown
|
|
||||||
? [...tableRanges, ...scanMarkdownImageStreaming(content).ranges]
|
|
||||||
: tableRanges;
|
|
||||||
activeRef.current = true;
|
activeRef.current = true;
|
||||||
onActiveChange?.(typingKey, true);
|
onActiveChange?.(typingKey, true);
|
||||||
setDisplayText("");
|
setDisplayText("");
|
||||||
|
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
cursor = advancePastAtomicRanges(content.length, cursor + 1, atomicRanges);
|
cursor = advanceTypewriterCursor(content.length, cursor + 1, tableRanges);
|
||||||
if (cursor >= content.length) {
|
if (cursor >= content.length) {
|
||||||
setDisplayText(content);
|
setDisplayText(content);
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
@ -2440,7 +2439,7 @@ function CanvasTypewriterText({
|
|||||||
onActiveChange?.(typingKey, false);
|
onActiveChange?.(typingKey, false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [content, enabled, onActiveChange, onComplete, preserveMarkdownTables, renderMarkdown, typingKey]);
|
}, [content, enabled, onActiveChange, onComplete, preserveMarkdownTables, typingKey]);
|
||||||
|
|
||||||
const showCursor = enabled && Boolean(content) && displayText.length < content.length;
|
const showCursor = enabled && Boolean(content) && displayText.length < content.length;
|
||||||
|
|
||||||
@ -2449,7 +2448,6 @@ function CanvasTypewriterText({
|
|||||||
<div className="w-full min-w-0">
|
<div className="w-full min-w-0">
|
||||||
<Response
|
<Response
|
||||||
className={className}
|
className={className}
|
||||||
isMarkdownStreaming={isMarkdownStreaming}
|
|
||||||
enablePdfDownload={enablePdfDownload}
|
enablePdfDownload={enablePdfDownload}
|
||||||
pdfFileName={pdfFileName}
|
pdfFileName={pdfFileName}
|
||||||
pdfSiteLogo={pdfSiteLogo}
|
pdfSiteLogo={pdfSiteLogo}
|
||||||
@ -3247,8 +3245,6 @@ export function CanvasChatPanel({
|
|||||||
onRequireLogin,
|
onRequireLogin,
|
||||||
onBeforeAgentRequest,
|
onBeforeAgentRequest,
|
||||||
}: CanvasChatPanelProps) {
|
}: CanvasChatPanelProps) {
|
||||||
const runCanvasRemoteOperation = useCanvasRemoteOperation();
|
|
||||||
const applyCanvasRemoteSnapshot = useCanvasRemoteSnapshot();
|
|
||||||
const isWorkspacePresentation = presentation === "workspace";
|
const isWorkspacePresentation = presentation === "workspace";
|
||||||
const isVideoWorkspace = workspaceVariant === "video";
|
const isVideoWorkspace = workspaceVariant === "video";
|
||||||
const propCanvasToolbarConfig = useMemo(
|
const propCanvasToolbarConfig = useMemo(
|
||||||
@ -3347,7 +3343,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);
|
||||||
@ -3369,15 +3364,15 @@ export function CanvasChatPanel({
|
|||||||
const latestCanvasSkillTagsRef = useRef<CanvasSkillTag[]>([]);
|
const latestCanvasSkillTagsRef = useRef<CanvasSkillTag[]>([]);
|
||||||
const pendingCanvasSkillTagOrderSaveRef = useRef<string[] | null>(null);
|
const pendingCanvasSkillTagOrderSaveRef = useRef<string[] | null>(null);
|
||||||
const suppressSkillTagClickRef = useRef(false);
|
const suppressSkillTagClickRef = useRef(false);
|
||||||
const { scrollRef: messagesScrollRef, contentRef: messagesContentRef, scrollToBottom: scrollMessagesToBottom, isAtBottom: isMessageListAtBottom } = useStickToBottom({ initial: false, resize: "instant" });
|
const [messageViewportKey, setMessageViewportKey] = useState(0);
|
||||||
const initialScrollChatRef = useRef<string | null>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const clawdFolderInputRef = useRef<HTMLInputElement | null>(null);
|
const clawdFolderInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||||
const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||||
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||||
const [clawdTableFiles, setClawdTableFiles] = useState<File[]>([]);
|
const [clawdTableFiles, setClawdTableFiles] = useState<File[]>([]);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const detailModeSwitchRef = useRef(false);
|
const detailModeSwitchRef = useRef(false);
|
||||||
const hiddenPromptRef = useRef<string | null>(null);
|
const hiddenPromptRef = useRef<string | null>(null);
|
||||||
const autoContextRef = useRef<string | null>(null);
|
const autoContextRef = useRef<string | null>(null);
|
||||||
@ -3406,6 +3401,7 @@ export function CanvasChatPanel({
|
|||||||
const hasInitializedVideoWorkspaceRef = useRef(false);
|
const hasInitializedVideoWorkspaceRef = useRef(false);
|
||||||
const sessionMenuRef = useRef<HTMLDivElement>(null);
|
const sessionMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const clawdHistoryMenuRef = useRef<HTMLDivElement>(null);
|
const clawdHistoryMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const historyScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const forceHistoryHydrationRef = useRef<string | null>(null);
|
const forceHistoryHydrationRef = useRef<string | null>(null);
|
||||||
const copiedUserMessageRef = useRef<CopiedUserMessagePayload | null>(null);
|
const copiedUserMessageRef = useRef<CopiedUserMessagePayload | null>(null);
|
||||||
const composerUserEditedAtRef = useRef(0);
|
const composerUserEditedAtRef = useRef(0);
|
||||||
@ -3442,7 +3438,6 @@ export function CanvasChatPanel({
|
|||||||
const [fullscreenPreview, setFullscreenPreview] = useState<{
|
const [fullscreenPreview, setFullscreenPreview] = useState<{
|
||||||
url: string;
|
url: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
preview: LoadedImagePreview;
|
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [downloadProgress, setDownloadProgress] =
|
const [downloadProgress, setDownloadProgress] =
|
||||||
useState<CanvasImageDownloadProgress | null>(null);
|
useState<CanvasImageDownloadProgress | null>(null);
|
||||||
@ -3480,6 +3475,16 @@ export function CanvasChatPanel({
|
|||||||
const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState("");
|
const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState("");
|
||||||
const [addingClawdImageUrl, setAddingClawdImageUrl] = useState("");
|
const [addingClawdImageUrl, setAddingClawdImageUrl] = useState("");
|
||||||
const downloadAbortControllerRef = useRef<AbortController | null>(null);
|
const downloadAbortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const fullscreenPreviewSrc = useMemo(() => {
|
||||||
|
if (!fullscreenPreview?.url) return "";
|
||||||
|
const originalUrl = inferOriginalImageUrl(fullscreenPreview.url);
|
||||||
|
return buildImageProxyUrl(originalUrl || fullscreenPreview.url, {
|
||||||
|
delivery: "proxy",
|
||||||
|
width: 2400,
|
||||||
|
quality: 90,
|
||||||
|
format: "webp",
|
||||||
|
});
|
||||||
|
}, [fullscreenPreview]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (composerUploadClearTimeoutRef.current) {
|
if (composerUploadClearTimeoutRef.current) {
|
||||||
@ -3649,20 +3654,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;
|
||||||
@ -5035,15 +5037,6 @@ export function CanvasChatPanel({
|
|||||||
console.log('[CanvasChat] 已插入图片标签:', url.substring(0, 50));
|
console.log('[CanvasChat] 已插入图片标签:', url.substring(0, 50));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setComposerEditorRef = useCallback((editor: HTMLDivElement | null) => {
|
|
||||||
textareaRef.current = editor as unknown as HTMLTextAreaElement;
|
|
||||||
if (!editor) return;
|
|
||||||
const draft = readCanvasComposerDraft<PickedObjectAttachment>(activeChatId);
|
|
||||||
const text = activeChatIdRef.current === activeChatId ? inputRef.current || draft?.text : draft?.text;
|
|
||||||
if (text) editor.textContent = text;
|
|
||||||
draft?.attachments.forEach((attachment, index) => insertImageTagInEditor(attachment.url, attachment.name || "参考图片", index));
|
|
||||||
}, [activeChatId, insertImageTagInEditor]);
|
|
||||||
|
|
||||||
const handleCopyUserMessage = useCallback(async (message: ChatMessage) => {
|
const handleCopyUserMessage = useCallback(async (message: ChatMessage) => {
|
||||||
const payload = getUserMessageCopyPayload(message);
|
const payload = getUserMessageCopyPayload(message);
|
||||||
if (!payload.text && payload.attachments.length === 0) {
|
if (!payload.text && payload.attachments.length === 0) {
|
||||||
@ -6108,7 +6101,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";
|
||||||
@ -7991,12 +7984,8 @@ export function CanvasChatPanel({
|
|||||||
|
|
||||||
const store = useCanvasStore.getState();
|
const store = useCanvasStore.getState();
|
||||||
store.markAgentOperationRefresh();
|
store.markAgentOperationRefresh();
|
||||||
if (applyCanvasRemoteSnapshot) {
|
|
||||||
if (!applyCanvasRemoteSnapshot({ projectId: targetProjectId, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt || "") })) return false;
|
|
||||||
} else {
|
|
||||||
store.loadCanvas(result.data.canvasData);
|
store.loadCanvas(result.data.canvasData);
|
||||||
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
|
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
|
||||||
}
|
|
||||||
if (createdNodeIds.length > 0) {
|
if (createdNodeIds.length > 0) {
|
||||||
const refreshedStore = useCanvasStore.getState();
|
const refreshedStore = useCanvasStore.getState();
|
||||||
refreshedStore.selectNodes(createdNodeIds);
|
refreshedStore.selectNodes(createdNodeIds);
|
||||||
@ -8024,7 +8013,7 @@ export function CanvasChatPanel({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[applyCanvasRemoteSnapshot, applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate]
|
[applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -8041,8 +8030,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-', '')
|
||||||
@ -8155,27 +8142,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,
|
||||||
@ -8443,11 +8423,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
|
||||||
: [];
|
: [];
|
||||||
@ -8516,7 +8491,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) {
|
||||||
@ -8842,6 +8817,12 @@ export function CanvasChatPanel({
|
|||||||
hasHydratedHistoryRef.current = true;
|
hasHydratedHistoryRef.current = true;
|
||||||
|
|
||||||
setMessages(sortCanvasMessagesByCreatedAt(dbMessages));
|
setMessages(sortCanvasMessagesByCreatedAt(dbMessages));
|
||||||
|
|
||||||
|
if (dbMessages.length > 0) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const reloadMessagesOnce = async () => {
|
const reloadMessagesOnce = async () => {
|
||||||
@ -8954,7 +8935,7 @@ export function CanvasChatPanel({
|
|||||||
onRunChange: (run) => {
|
onRunChange: (run) => {
|
||||||
// The live stream owns its message; polling only recovers disconnected replies.
|
// The live stream owns its message; polling only recovers disconnected replies.
|
||||||
if (status === "submitted" || status === "streaming") return;
|
if (status === "submitted" || status === "streaming") return;
|
||||||
if (!run?.assistantMessageId || !run.assistantText || run.chatId !== toRawCanvasChatId(activeChatIdRef.current)) return;
|
if (!run?.assistantMessageId || !run.assistantText || run.chatId !== activeChatIdRef.current) return;
|
||||||
// Recover the same assistant reply after a stream disconnect or refresh.
|
// Recover the same assistant reply after a stream disconnect or refresh.
|
||||||
setMessages(current => {
|
setMessages(current => {
|
||||||
const index = current.findIndex(message => message.id === run.assistantMessageId);
|
const index = current.findIndex(message => message.id === run.assistantMessageId);
|
||||||
@ -8969,33 +8950,6 @@ export function CanvasChatPanel({
|
|||||||
});
|
});
|
||||||
const activeCanvasAgentRun = canvasAgentTask.run;
|
const activeCanvasAgentRun = canvasAgentTask.run;
|
||||||
const isCanvasAgentRunning = activeCanvasAgentRun?.status === "running";
|
const isCanvasAgentRunning = activeCanvasAgentRun?.status === "running";
|
||||||
useEffect(() => {
|
|
||||||
const beforeRefresh = (event: Event) => {
|
|
||||||
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
|
||||||
if (composerUploadProgress) {
|
|
||||||
request.block("素材仍在上传,请等待上传完成后再刷新。当前页面和输入已保留。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isCanvasAgentRunning || isClawdSubmitting || isDetailGenerating || status === "streaming" || status === "submitted" || Object.values(sendGuardRef.current).some(guard => guard.inFlight)) {
|
|
||||||
request.block("当前对话任务仍在处理中,请等待结果返回后再刷新。原任务会继续执行,无需重新提交。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
request.save(async () => {
|
|
||||||
if ((input.trim() || attachments.length) && !writeCanvasComposerDraft(activeChatId, { text: input, attachments }))
|
|
||||||
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.returnValue = "true";
|
|
||||||
};
|
|
||||||
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
|
||||||
if (composerUploadProgress) window.addEventListener("beforeunload", beforeUnload);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
|
||||||
window.removeEventListener("beforeunload", beforeUnload);
|
|
||||||
};
|
|
||||||
}, [activeChatId, input, attachments, composerUploadProgress, isCanvasAgentRunning, isClawdSubmitting, isDetailGenerating, status]);
|
|
||||||
|
|
||||||
const restorePendingClawdRequestToComposer = useCallback(
|
const restorePendingClawdRequestToComposer = useCallback(
|
||||||
(snapshot: PendingClawdRequestRestoreSnapshot) => {
|
(snapshot: PendingClawdRequestRestoreSnapshot) => {
|
||||||
@ -9139,7 +9093,6 @@ export function CanvasChatPanel({
|
|||||||
},
|
},
|
||||||
} as ChatMessage;
|
} as ChatMessage;
|
||||||
|
|
||||||
void scrollMessagesToBottom({ animation: "instant" });
|
|
||||||
sendMessage(launchUserMessage as any);
|
sendMessage(launchUserMessage as any);
|
||||||
setMessages((previousMessages) =>
|
setMessages((previousMessages) =>
|
||||||
sortCanvasMessagesByCreatedAt([
|
sortCanvasMessagesByCreatedAt([
|
||||||
@ -9159,7 +9112,7 @@ export function CanvasChatPanel({
|
|||||||
if (editableDiv) {
|
if (editableDiv) {
|
||||||
editableDiv.innerHTML = "";
|
editableDiv.innerHTML = "";
|
||||||
}
|
}
|
||||||
}, [activeChatId, rawChatId, selectedModelId, status, sendMessage, setMessages, clearSelection, launchAutoSendTick, activeComposerMode, scrollMessagesToBottom]);
|
}, [activeChatId, rawChatId, selectedModelId, status, sendMessage, setMessages, clearSelection, launchAutoSendTick, activeComposerMode]);
|
||||||
|
|
||||||
// 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新)
|
// 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新)
|
||||||
const loadedHistorySignatureRef = useRef<string>("");
|
const loadedHistorySignatureRef = useRef<string>("");
|
||||||
@ -9179,6 +9132,7 @@ export function CanvasChatPanel({
|
|||||||
hiddenPromptRef.current = null;
|
hiddenPromptRef.current = null;
|
||||||
autoContextRef.current = null;
|
autoContextRef.current = null;
|
||||||
pendingLaunchRef.current = null;
|
pendingLaunchRef.current = null;
|
||||||
|
setMessageViewportKey((prev) => prev + 1);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setInput("");
|
setInput("");
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
@ -9193,20 +9147,11 @@ export function CanvasChatPanel({
|
|||||||
}, [activeChatId, setMessages]);
|
}, [activeChatId, setMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const draft = readCanvasComposerDraft<PickedObjectAttachment>(activeChatId);
|
if (historyScrollTimerRef.current) {
|
||||||
if (!draft || (input && input !== draft.text)) return;
|
clearTimeout(historyScrollTimerRef.current);
|
||||||
if (!input) setInput(draft.text);
|
historyScrollTimerRef.current = null;
|
||||||
setAttachments(draft.attachments);
|
}
|
||||||
const frame = requestAnimationFrame(() => {
|
|
||||||
const editor = textareaRef.current as HTMLDivElement | null;
|
|
||||||
if (!editor || getComposerPlainTextFromElement(editor).trim()) return;
|
|
||||||
editor.textContent = draft.text;
|
|
||||||
draft.attachments.forEach((attachment, index) => insertImageTagInEditor(attachment.url, attachment.name || "参考图片", index));
|
|
||||||
});
|
|
||||||
return () => cancelAnimationFrame(frame);
|
|
||||||
}, [activeChatId, activeComposerMode, input, insertImageTagInEditor]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!historyData || !rawChatId) return;
|
if (!historyData || !rawChatId) return;
|
||||||
const historyMessages = historyData.messages || [];
|
const historyMessages = historyData.messages || [];
|
||||||
const isClawdHistory = isClawdMode || historyMessages.some(isClawdChatMessage);
|
const isClawdHistory = isClawdMode || historyMessages.some(isClawdChatMessage);
|
||||||
@ -9247,14 +9192,29 @@ export function CanvasChatPanel({
|
|||||||
missingPendingMessage ? [...historyMessages, pendingMessage] : historyMessages
|
missingPendingMessage ? [...historyMessages, pendingMessage] : historyMessages
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
setMessageViewportKey((prev) => prev + 1);
|
||||||
console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature);
|
console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature);
|
||||||
|
|
||||||
|
if (historyMessages.length > 0) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
|
||||||
|
});
|
||||||
|
historyScrollTimerRef.current = setTimeout(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
|
||||||
|
historyScrollTimerRef.current = null;
|
||||||
|
}, 160);
|
||||||
|
return () => {
|
||||||
|
if (historyScrollTimerRef.current) {
|
||||||
|
clearTimeout(historyScrollTimerRef.current);
|
||||||
|
historyScrollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}, [activeChatId, activeClawdRequestUi?.userMessageId, historyData, isClawdMode, isClawdSubmitting, rawChatId, setMessages, status]);
|
}, [activeChatId, activeClawdRequestUi?.userMessageId, historyData, isClawdMode, isClawdSubmitting, rawChatId, setMessages, status]);
|
||||||
|
|
||||||
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 () => {
|
||||||
@ -9278,13 +9238,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");
|
||||||
@ -9301,7 +9254,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)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -9322,7 +9276,6 @@ export function CanvasChatPanel({
|
|||||||
messages,
|
messages,
|
||||||
streamImageGenerationStateByChatId,
|
streamImageGenerationStateByChatId,
|
||||||
clearStreamImageGenerationStateForChat,
|
clearStreamImageGenerationStateForChat,
|
||||||
setStreamImageGenerationStateForChat,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -9829,13 +9782,11 @@ export function CanvasChatPanel({
|
|||||||
});
|
});
|
||||||
}, [activeChatSessionMode, messages, nodes, updateNode]);
|
}, [activeChatSessionMode, messages, nodes, updateNode]);
|
||||||
|
|
||||||
// Open each conversation at its latest message; later updates respect manual scrolling.
|
// 滚动到底部
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length === 0 && !isClawdSubmitting) return;
|
if (messages.length === 0 && !isClawdSubmitting) return;
|
||||||
if (initialScrollChatRef.current === activeChatId) return;
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
initialScrollChatRef.current = activeChatId;
|
}, [isClawdSubmitting, messages]);
|
||||||
void scrollMessagesToBottom({ animation: "instant" });
|
|
||||||
}, [activeChatId, isClawdSubmitting, messages.length, scrollMessagesToBottom]);
|
|
||||||
|
|
||||||
// 标记是否已经为当前画布设置过标题(避免重复调用)
|
// 标记是否已经为当前画布设置过标题(避免重复调用)
|
||||||
const hasSetCanvasTitle = useRef(false);
|
const hasSetCanvasTitle = useRef(false);
|
||||||
@ -12604,7 +12555,6 @@ export function CanvasChatPanel({
|
|||||||
const supplement = typeof overrideInput === "string" ? overrideInput.trim() : input.trim();
|
const supplement = typeof overrideInput === "string" ? overrideInput.trim() : input.trim();
|
||||||
if (isCanvasAgentRunning && !isClawdMode && imageAgentEnabled) {
|
if (isCanvasAgentRunning && !isClawdMode && imageAgentEnabled) {
|
||||||
if (supplement && await canvasAgentTask.control("steer", supplement)) {
|
if (supplement && await canvasAgentTask.control("steer", supplement)) {
|
||||||
void scrollMessagesToBottom({ animation: "instant" });
|
|
||||||
setInput("");
|
setInput("");
|
||||||
const editor = textareaRef.current as HTMLDivElement | null;
|
const editor = textareaRef.current as HTMLDivElement | null;
|
||||||
if (editor) editor.innerHTML = "";
|
if (editor) editor.innerHTML = "";
|
||||||
@ -12902,7 +12852,6 @@ export function CanvasChatPanel({
|
|||||||
requestChatId,
|
requestChatId,
|
||||||
clawdRequestRestoreSnapshot
|
clawdRequestRestoreSnapshot
|
||||||
);
|
);
|
||||||
void scrollMessagesToBottom({ animation: "instant" });
|
|
||||||
startClawdRequestUi(requestChatId, userMessageId, nextProcessingHint);
|
startClawdRequestUi(requestChatId, userMessageId, nextProcessingHint);
|
||||||
setMessages((previousMessages) =>
|
setMessages((previousMessages) =>
|
||||||
sortCanvasMessagesByCreatedAt([
|
sortCanvasMessagesByCreatedAt([
|
||||||
@ -13391,26 +13340,6 @@ export function CanvasChatPanel({
|
|||||||
});
|
});
|
||||||
inFlightChatIdRef.current = activeChatId;
|
inFlightChatIdRef.current = activeChatId;
|
||||||
inFlightRawChatIdRef.current = rawChatId;
|
inFlightRawChatIdRef.current = rawChatId;
|
||||||
imageGenerationUserMessageIdsRef.current[activeChatId] = userMessageId;
|
|
||||||
if (shouldUseCanvasAgentRuntime({
|
|
||||||
composerMode: effectiveComposerMode,
|
|
||||||
agentPresetId: effectiveAgentEntryMode === "preset" ? selectedAgentPreset?.id : undefined,
|
|
||||||
workspaceVariant: isVideoWorkspace ? "video" : "canvas",
|
|
||||||
contextPayload: contextPayloadRef.current || undefined,
|
|
||||||
}) && onBeforeAgentRequest) {
|
|
||||||
try {
|
|
||||||
if (!(await onBeforeAgentRequest())) throw new Error("画布自动同步失败,本次任务未提交。输入内容已保留,请检查网络后再次发送。");
|
|
||||||
} catch (error) {
|
|
||||||
releaseSendGuard(activeChatId);
|
|
||||||
inFlightChatIdRef.current = null;
|
|
||||||
inFlightRawChatIdRef.current = null;
|
|
||||||
contextPayloadRef.current = null;
|
|
||||||
writeCanvasComposerDraft(activeChatId, { text: composerInput, attachments: effectiveAttachments });
|
|
||||||
toast.error(error instanceof Error ? error.message : "画布自动同步失败,本次任务未提交,输入内容已保留。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clearCanvasComposerDraft(activeChatId);
|
|
||||||
clearStreamImageGenerationStateForChat(activeChatId);
|
clearStreamImageGenerationStateForChat(activeChatId);
|
||||||
clearAgentProgressStateForChat(activeChatId);
|
clearAgentProgressStateForChat(activeChatId);
|
||||||
if (shouldShowOptimisticImageGeneration) {
|
if (shouldShowOptimisticImageGeneration) {
|
||||||
@ -13435,7 +13364,6 @@ export function CanvasChatPanel({
|
|||||||
|
|
||||||
// 使用与 multimodal-input 相同的格式发送消息
|
// 使用与 multimodal-input 相同的格式发送消息
|
||||||
try {
|
try {
|
||||||
void scrollMessagesToBottom({ animation: "instant" });
|
|
||||||
sendMessage(optimisticUserMessage as any);
|
sendMessage(optimisticUserMessage as any);
|
||||||
setMessages((previousMessages) =>
|
setMessages((previousMessages) =>
|
||||||
sortCanvasMessagesByCreatedAt([
|
sortCanvasMessagesByCreatedAt([
|
||||||
@ -13474,9 +13402,6 @@ export function CanvasChatPanel({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
isCanvasAgentRunning,
|
isCanvasAgentRunning,
|
||||||
scrollMessagesToBottom,
|
|
||||||
onBeforeAgentRequest,
|
|
||||||
isVideoWorkspace,
|
|
||||||
canvasAgentTask.control,
|
canvasAgentTask.control,
|
||||||
activeChatId,
|
activeChatId,
|
||||||
activeComposerMode,
|
activeComposerMode,
|
||||||
@ -13614,19 +13539,23 @@ export function CanvasChatPanel({
|
|||||||
|
|
||||||
console.log('[CanvasChat] 更新画布:', rawId);
|
console.log('[CanvasChat] 更新画布:', rawId);
|
||||||
|
|
||||||
if (!runCanvasRemoteOperation) return;
|
const updateResponse = await fetch(`/api/canvas/${rawId}`, {
|
||||||
await runCanvasRemoteOperation(async () => {
|
method: 'PUT',
|
||||||
if (useCanvasStore.getState().projectId !== rawId) return;
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const result = await canvasService.updateProject(rawId, { title, thumbnail: firstImageUrl });
|
body: JSON.stringify({
|
||||||
if (!result.success) throw new Error(result.error || "画布名称同步失败");
|
title,
|
||||||
|
thumbnail: firstImageUrl,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[CanvasChat] 画布更新响应:', updateResponse.status);
|
||||||
} else {
|
} else {
|
||||||
console.error('[CanvasChat] auto-title API 请求失败:', response.status);
|
console.error('[CanvasChat] auto-title API 请求失败:', response.status);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[CanvasChat] 自动命名失败:', error);
|
console.error('[CanvasChat] 自动命名失败:', error);
|
||||||
}
|
}
|
||||||
}, [chatId, runCanvasRemoteOperation]);
|
}, [chatId]);
|
||||||
|
|
||||||
const addUploadedImageAttachmentToComposer = useCallback(
|
const addUploadedImageAttachmentToComposer = useCallback(
|
||||||
(uploaded: ComposerUploadedImage) => {
|
(uploaded: ComposerUploadedImage) => {
|
||||||
@ -15180,7 +15109,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 ||
|
||||||
@ -15243,9 +15172,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;
|
||||||
@ -15301,10 +15230,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",
|
||||||
@ -15398,7 +15331,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;
|
||||||
@ -15708,7 +15640,7 @@ export function CanvasChatPanel({
|
|||||||
processedMessageIds.current.clear();
|
processedMessageIds.current.clear();
|
||||||
hasHydratedHistoryRef.current = true;
|
hasHydratedHistoryRef.current = true;
|
||||||
setMessages(sortCanvasMessagesByCreatedAt(fresh.messages));
|
setMessages(sortCanvasMessagesByCreatedAt(fresh.messages));
|
||||||
void scrollMessagesToBottom({ animation: "instant" });
|
setMessageViewportKey((previous) => previous + 1);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error("[CanvasChat] Failed to reopen current conversation:", error);
|
console.error("[CanvasChat] Failed to reopen current conversation:", error);
|
||||||
toast.error("聊天记录暂时读取失败,请重试");
|
toast.error("聊天记录暂时读取失败,请重试");
|
||||||
@ -15722,7 +15654,7 @@ export function CanvasChatPanel({
|
|||||||
replaceChatSessionQuery(sessionId);
|
replaceChatSessionQuery(sessionId);
|
||||||
setIsSessionMenuOpen(false);
|
setIsSessionMenuOpen(false);
|
||||||
toast.success("已切换聊天窗口");
|
toast.success("已切换聊天窗口");
|
||||||
}, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, scrollMessagesToBottom, setMessages, status]);
|
}, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, setMessages, status]);
|
||||||
|
|
||||||
const switchToClawdHistorySession = useCallback(
|
const switchToClawdHistorySession = useCallback(
|
||||||
(historyItem: ClawdHistoryItem) => {
|
(historyItem: ClawdHistoryItem) => {
|
||||||
@ -15884,8 +15816,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
|
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
|
event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16139,17 +16071,11 @@ export function CanvasChatPanel({
|
|||||||
{isDetailEmptyState ? (
|
{isDetailEmptyState ? (
|
||||||
<div className="flex-1 min-h-0" />
|
<div className="flex-1 min-h-0" />
|
||||||
) : (
|
) : (
|
||||||
<div key={activeChatId} className="relative flex min-h-0 flex-1 flex-col">
|
|
||||||
<div
|
<div
|
||||||
ref={messagesScrollRef}
|
key={`${activeChatId}-${messageViewportKey}`}
|
||||||
data-testid="canvas-chat-messages"
|
|
||||||
role="region"
|
|
||||||
aria-label="对话消息"
|
|
||||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: The scroll region needs PageUp/PageDown keyboard input.
|
|
||||||
tabIndex={0}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 min-h-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
"flex-1 min-h-0 overflow-x-hidden",
|
||||||
isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-auto",
|
isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-y-auto",
|
||||||
isWorkspacePresentation ? "p-5" : "p-3",
|
isWorkspacePresentation ? "p-5" : "p-3",
|
||||||
isPlainCanvasEmptyState
|
isPlainCanvasEmptyState
|
||||||
? "pt-0"
|
? "pt-0"
|
||||||
@ -16158,7 +16084,7 @@ export function CanvasChatPanel({
|
|||||||
: "pt-3"
|
: "pt-3"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div ref={messagesContentRef} className={cn(
|
<div className={cn(
|
||||||
"space-y-5 overflow-x-hidden pr-1",
|
"space-y-5 overflow-x-hidden pr-1",
|
||||||
isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24"
|
isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24"
|
||||||
)}>
|
)}>
|
||||||
@ -16209,8 +16135,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
|
if (event.currentTarget.src !== attachment.url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
|
event.currentTarget.src = attachment.url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16286,8 +16212,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
|
if (event.currentTarget.src !== attachment.url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
|
event.currentTarget.src = attachment.url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16472,8 +16398,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(tag.iconUrl)) {
|
if (event.currentTarget.src !== tag.iconUrl) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(tag.iconUrl);
|
event.currentTarget.src = tag.iconUrl || "";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16651,10 +16577,6 @@ export function CanvasChatPanel({
|
|||||||
(isClawdMode || isClawdChatMessage(message));
|
(isClawdMode || isClawdChatMessage(message));
|
||||||
const isLatestClawAssistantMessage =
|
const isLatestClawAssistantMessage =
|
||||||
message.id === latestClawAssistantMessageId;
|
message.id === latestClawAssistantMessageId;
|
||||||
const isMessageMarkdownStreaming =
|
|
||||||
message.role === "assistant" &&
|
|
||||||
index === renderableVisibleMessages.length - 1 &&
|
|
||||||
(isLoading || (isClawAssistantMessage && isClawdSubmitting));
|
|
||||||
const openClawArtifacts = isClawAssistantMessage
|
const openClawArtifacts = isClawAssistantMessage
|
||||||
? getOpenClawMessageArtifacts(message)
|
? getOpenClawMessageArtifacts(message)
|
||||||
: [];
|
: [];
|
||||||
@ -16773,8 +16695,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
|
if (event.currentTarget.src !== attachment.url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
|
event.currentTarget.src = attachment.url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16982,7 +16904,6 @@ export function CanvasChatPanel({
|
|||||||
<Response
|
<Response
|
||||||
key={key}
|
key={key}
|
||||||
className="break-words [overflow-wrap:anywhere]"
|
className="break-words [overflow-wrap:anywhere]"
|
||||||
isMarkdownStreaming={isMessageMarkdownStreaming}
|
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</Response>
|
</Response>
|
||||||
@ -17044,7 +16965,6 @@ export function CanvasChatPanel({
|
|||||||
className="break-words [overflow-wrap:anywhere]"
|
className="break-words [overflow-wrap:anywhere]"
|
||||||
renderMarkdown
|
renderMarkdown
|
||||||
preserveMarkdownTables={shouldTypeClawAssistantMessage}
|
preserveMarkdownTables={shouldTypeClawAssistantMessage}
|
||||||
isMarkdownStreaming={isMessageMarkdownStreaming}
|
|
||||||
enablePdfDownload={message.role === "assistant" && !shouldAnimateAnalysis}
|
enablePdfDownload={message.role === "assistant" && !shouldAnimateAnalysis}
|
||||||
pdfFileName={`Claw分析报告-${message.id}`}
|
pdfFileName={`Claw分析报告-${message.id}`}
|
||||||
pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null}
|
pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null}
|
||||||
@ -17067,7 +16987,6 @@ export function CanvasChatPanel({
|
|||||||
className="break-words [overflow-wrap:anywhere]"
|
className="break-words [overflow-wrap:anywhere]"
|
||||||
renderMarkdown
|
renderMarkdown
|
||||||
preserveMarkdownTables={shouldTypeClawAssistantMessage}
|
preserveMarkdownTables={shouldTypeClawAssistantMessage}
|
||||||
isMarkdownStreaming={isMessageMarkdownStreaming}
|
|
||||||
enablePdfDownload={message.role === "assistant" && !shouldAnimatePlainText}
|
enablePdfDownload={message.role === "assistant" && !shouldAnimatePlainText}
|
||||||
pdfFileName={`Claw分析报告-${message.id}`}
|
pdfFileName={`Claw分析报告-${message.id}`}
|
||||||
pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null}
|
pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null}
|
||||||
@ -17110,23 +17029,15 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(url)) {
|
if (event.currentTarget.src !== url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(url);
|
event.currentTarget.src = url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={(event) => setFullscreenPreview({ url, preview: {
|
onClick={() => setFullscreenPreview({ url })}
|
||||||
src: event.currentTarget.currentSrc || event.currentTarget.src,
|
|
||||||
width: event.currentTarget.naturalWidth,
|
|
||||||
height: event.currentTarget.naturalHeight,
|
|
||||||
} })}
|
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setFullscreenPreview({ url, preview: {
|
setFullscreenPreview({ url });
|
||||||
src: e.currentTarget.currentSrc || e.currentTarget.src,
|
|
||||||
width: e.currentTarget.naturalWidth,
|
|
||||||
height: e.currentTarget.naturalHeight,
|
|
||||||
} });
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-2 right-2 flex items-center gap-1.5">
|
<div className="absolute bottom-2 right-2 flex items-center gap-1.5">
|
||||||
@ -17361,22 +17272,9 @@ export function CanvasChatPanel({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isMessageListAtBottom && renderableVisibleMessages.length > 0 ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
aria-label="回到最新消息"
|
|
||||||
title="回到最新消息"
|
|
||||||
className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full shadow-sm"
|
|
||||||
onClick={() => void scrollMessagesToBottom({ animation: "instant" })}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{fullscreenPreview && createPortal(
|
{fullscreenPreview && createPortal(
|
||||||
@ -17385,7 +17283,7 @@ export function CanvasChatPanel({
|
|||||||
onClick={() => setFullscreenPreview(null)}
|
onClick={() => setFullscreenPreview(null)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
|
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
|
||||||
onClick={() => setFullscreenPreview(null)}
|
onClick={() => setFullscreenPreview(null)}
|
||||||
aria-label="关闭预览"
|
aria-label="关闭预览"
|
||||||
>
|
>
|
||||||
@ -17394,7 +17292,7 @@ export function CanvasChatPanel({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="absolute top-4 right-16 z-10 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white transition-colors hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-65"
|
className="absolute top-4 right-16 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white transition-colors hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-65"
|
||||||
disabled={isDownloadingOriginalImage}
|
disabled={isDownloadingOriginalImage}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@ -17411,11 +17309,13 @@ export function CanvasChatPanel({
|
|||||||
{isDownloadingOriginalImage ? "下载中" : "下载原图"}
|
{isDownloadingOriginalImage ? "下载中" : "下载原图"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ProgressiveImagePreview
|
<img
|
||||||
src={inferOriginalImageUrl(fullscreenPreview.url) || fullscreenPreview.url}
|
src={fullscreenPreviewSrc}
|
||||||
preview={fullscreenPreview.preview}
|
|
||||||
alt={fullscreenPreview.alt || "图片预览"}
|
alt={fullscreenPreview.alt || "图片预览"}
|
||||||
className="max-w-[95vw] max-h-[95vh] object-contain"
|
className="max-w-[95vw] max-h-[95vh] object-contain"
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
|
||||||
@ -17982,8 +17882,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
|
if (event.currentTarget.src !== slotAttachment.url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
|
event.currentTarget.src = slotAttachment.url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -18307,8 +18207,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
|
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
|
event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -18567,8 +18467,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(item.url)) {
|
if (event.currentTarget.src !== item.url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(item.url);
|
event.currentTarget.src = item.url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -18623,7 +18523,7 @@ export function CanvasChatPanel({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={setComposerEditorRef}
|
ref={textareaRef as any}
|
||||||
contentEditable={!isSubmitting || (isCanvasAgentRunning && !canvasAgentTask.stopPending)}
|
contentEditable={!isSubmitting || (isCanvasAgentRunning && !canvasAgentTask.stopPending)}
|
||||||
role="textbox"
|
role="textbox"
|
||||||
tabIndex={isSubmitting ? -1 : 0}
|
tabIndex={isSubmitting ? -1 : 0}
|
||||||
@ -18657,7 +18557,6 @@ export function CanvasChatPanel({
|
|||||||
setTagPreview(null);
|
setTagPreview(null);
|
||||||
}}
|
}}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
clearCanvasComposerDraft(activeChatId);
|
|
||||||
// 从 contenteditable 中提取纯文本(排除附件标签文本)
|
// 从 contenteditable 中提取纯文本(排除附件标签文本)
|
||||||
const target = e.target as HTMLDivElement;
|
const target = e.target as HTMLDivElement;
|
||||||
const clone = target.cloneNode(true) as HTMLDivElement;
|
const clone = target.cloneNode(true) as HTMLDivElement;
|
||||||
@ -18753,8 +18652,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
|
if (event.currentTarget.src !== slotAttachment.url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
|
event.currentTarget.src = slotAttachment.url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -18821,8 +18720,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
|
if (event.currentTarget.src !== slotAttachment.url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
|
event.currentTarget.src = slotAttachment.url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -19521,8 +19420,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(tagPreview.url)) {
|
if (event.currentTarget.src !== tagPreview.url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(tagPreview.url);
|
event.currentTarget.src = tagPreview.url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -19,7 +19,6 @@ const PROJECT_LIST_PAGE_SIZE = 24;
|
|||||||
interface CanvasFileListPanelProps {
|
interface CanvasFileListPanelProps {
|
||||||
currentId: string;
|
currentId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onNavigate?: (url: string) => void;
|
|
||||||
workspaceType?: CanvasWorkspaceType;
|
workspaceType?: CanvasWorkspaceType;
|
||||||
workspaceLabel?: string;
|
workspaceLabel?: string;
|
||||||
createUrl?: string;
|
createUrl?: string;
|
||||||
@ -29,7 +28,6 @@ interface CanvasFileListPanelProps {
|
|||||||
export function CanvasFileListPanel({
|
export function CanvasFileListPanel({
|
||||||
currentId,
|
currentId,
|
||||||
onClose,
|
onClose,
|
||||||
onNavigate,
|
|
||||||
workspaceType = "canvas",
|
workspaceType = "canvas",
|
||||||
workspaceLabel = "画布",
|
workspaceLabel = "画布",
|
||||||
createUrl = "/canvas?new=1",
|
createUrl = "/canvas?new=1",
|
||||||
@ -90,10 +88,6 @@ export function CanvasFileListPanel({
|
|||||||
|
|
||||||
// 创建新画布
|
// 创建新画布
|
||||||
const handleCreateNew = async () => {
|
const handleCreateNew = async () => {
|
||||||
if (onNavigate) {
|
|
||||||
onNavigate(createUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(createUrl);
|
router.push(createUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -106,10 +100,6 @@ export function CanvasFileListPanel({
|
|||||||
}
|
}
|
||||||
const targetUrl = buildProjectUrl(id);
|
const targetUrl = buildProjectUrl(id);
|
||||||
onClose(); // 关闭文件列表面板
|
onClose(); // 关闭文件列表面板
|
||||||
if (onNavigate) {
|
|
||||||
onNavigate(targetUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 强制整页跳转,避免仅更新 URL 不刷新
|
// 强制整页跳转,避免仅更新 URL 不刷新
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.assign(targetUrl);
|
window.location.assign(targetUrl);
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { createContext, useContext } from "react";
|
|
||||||
import type { CanvasState } from "@/types/canvas";
|
|
||||||
|
|
||||||
export type CanvasRemoteOperationResult = {
|
|
||||||
projectId: string;
|
|
||||||
canvasData: CanvasState;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
export type CanvasRemoteOperation = (() => Promise<void>) | (() => Promise<CanvasRemoteOperationResult | undefined>);
|
|
||||||
|
|
||||||
export const CanvasRemoteOperationContext = createContext<
|
|
||||||
{
|
|
||||||
run: (operation: CanvasRemoteOperation) => Promise<void>;
|
|
||||||
applySnapshot: (result: CanvasRemoteOperationResult) => boolean;
|
|
||||||
} | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
export function useCanvasRemoteOperation() {
|
|
||||||
return useContext(CanvasRemoteOperationContext)?.run || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCanvasRemoteSnapshot() {
|
|
||||||
return useContext(CanvasRemoteOperationContext)?.applySnapshot || null;
|
|
||||||
}
|
|
||||||
@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||||
|
|
||||||
|
import { Loader2, Save } from "lucide-react";
|
||||||
|
|
||||||
type CanvasSurfaceHeaderProps = {
|
type CanvasSurfaceHeaderProps = {
|
||||||
|
isSaving: boolean;
|
||||||
onAddImage: () => void;
|
onAddImage: () => void;
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
onOpenFiles: () => void;
|
onOpenFiles: () => void;
|
||||||
|
onSave: () => void;
|
||||||
projectTitle: string;
|
projectTitle: string;
|
||||||
rightInset: number;
|
rightInset: number;
|
||||||
};
|
};
|
||||||
@ -14,9 +18,11 @@ const iconButtonClass =
|
|||||||
"inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10";
|
"inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10";
|
||||||
|
|
||||||
export function CanvasSurfaceHeader({
|
export function CanvasSurfaceHeader({
|
||||||
|
isSaving,
|
||||||
onAddImage,
|
onAddImage,
|
||||||
onHome,
|
onHome,
|
||||||
onOpenFiles,
|
onOpenFiles,
|
||||||
|
onSave,
|
||||||
projectTitle,
|
projectTitle,
|
||||||
rightInset,
|
rightInset,
|
||||||
}: CanvasSurfaceHeaderProps) {
|
}: CanvasSurfaceHeaderProps) {
|
||||||
@ -71,6 +77,19 @@ export function CanvasSurfaceHeader({
|
|||||||
<ReferenceIcon name="plus" size={16} />
|
<ReferenceIcon name="plus" size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[8px] border border-[#e5e5e5] px-3 text-[12px] text-[#333333] transition-colors hover:bg-black/5 disabled:opacity-50 dark:border-white/10 dark:text-white"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={onSave}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -448,7 +448,7 @@ function extractChatMediaUrls(text: string, kind: ChatMediaKind): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const markdownLinkPattern = /(?<!!)\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
|
const markdownLinkPattern = /\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
|
||||||
while ((match = markdownLinkPattern.exec(text)) !== null) {
|
while ((match = markdownLinkPattern.exec(text)) !== null) {
|
||||||
const label = String(match[1] || "");
|
const label = String(match[1] || "");
|
||||||
const hintedKind = /音频|语音|声音|audio|mp3/i.test(label)
|
const hintedKind = /音频|语音|声音|audio|mp3/i.test(label)
|
||||||
@ -491,7 +491,7 @@ export function stripMediaMarkup(text: string): string {
|
|||||||
.replace(/<audio[\s\S]*?<\/audio>/gi, "")
|
.replace(/<audio[\s\S]*?<\/audio>/gi, "")
|
||||||
.replace(/<audio\b[^>]*\/?\s*>/gi, "")
|
.replace(/<audio\b[^>]*\/?\s*>/gi, "")
|
||||||
.replace(/<a[\s\S]*?<\/a>/gi, "")
|
.replace(/<a[\s\S]*?<\/a>/gi, "")
|
||||||
.replace(/(?<!!)\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => {
|
.replace(/\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => {
|
||||||
const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test(
|
const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test(
|
||||||
String(label || "")
|
String(label || "")
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,8 +6,6 @@ import { ArrowUp, MessageSquare, X } from "lucide-react";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { CanvasAgentRunPanel } from "./canvas-agent-run-panel";
|
import { CanvasAgentRunPanel } from "./canvas-agent-run-panel";
|
||||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
|
||||||
import { readCanvasComposerDraft, writeCanvasComposerDraft, clearCanvasComposerDraft } from "@/lib/client/canvas-draft";
|
|
||||||
import {
|
import {
|
||||||
videoAgentSettingsSchema,
|
videoAgentSettingsSchema,
|
||||||
type VideoAgentSettings,
|
type VideoAgentSettings,
|
||||||
@ -96,22 +94,6 @@ export function VideoCanvasAgentPanel(props: {
|
|||||||
};
|
};
|
||||||
}, [props.projectId, setMessages]);
|
}, [props.projectId, setMessages]);
|
||||||
const busy = backgroundRunning || preparing || status === "submitted" || status === "streaming";
|
const busy = backgroundRunning || preparing || status === "submitted" || status === "streaming";
|
||||||
useEffect(() => {
|
|
||||||
const draft = readCanvasComposerDraft(`video-agent-${props.projectId}`);
|
|
||||||
if (draft) setInput(draft.text);
|
|
||||||
}, [props.projectId]);
|
|
||||||
useEffect(() => {
|
|
||||||
const beforeRefresh = (event: Event) => {
|
|
||||||
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
|
||||||
if (busy) request.block("当前视频任务仍在处理中,请等待结果返回后再刷新。原任务无需重新提交。");
|
|
||||||
request.save(async () => {
|
|
||||||
if (input.trim() && !writeCanvasComposerDraft(`video-agent-${props.projectId}`, { text: input, attachments: [] }))
|
|
||||||
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
|
||||||
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
|
||||||
}, [busy, input, props.projectId]);
|
|
||||||
const submit = useCallback(
|
const submit = useCallback(
|
||||||
async (resumeRunId?: string) => {
|
async (resumeRunId?: string) => {
|
||||||
if (busy || loading) return;
|
if (busy || loading) return;
|
||||||
@ -123,7 +105,6 @@ export function VideoCanvasAgentPanel(props: {
|
|||||||
try {
|
try {
|
||||||
await latest.current.props.beforeSend();
|
await latest.current.props.beforeSend();
|
||||||
if (!mounted.current) return;
|
if (!mounted.current) return;
|
||||||
clearCanvasComposerDraft(`video-agent-${props.projectId}`);
|
|
||||||
resumeRef.current = resumeRunId;
|
resumeRef.current = resumeRunId;
|
||||||
setInput("");
|
setInput("");
|
||||||
await sendMessage({ text });
|
await sendMessage({ text });
|
||||||
@ -317,7 +298,7 @@ export function VideoCanvasAgentPanel(props: {
|
|||||||
<textarea
|
<textarea
|
||||||
aria-label="视频创作要求"
|
aria-label="视频创作要求"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(event) => { clearCanvasComposerDraft(`video-agent-${props.projectId}`); setInput(event.target.value); }}
|
onChange={(event) => setInput(event.target.value)}
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="例如:先分析选中素材,写分镜,再生成一条5秒商品展示视频"
|
placeholder="例如:先分析选中素材,写分镜,再生成一条5秒商品展示视频"
|
||||||
|
|||||||
@ -1,7 +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 {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@ -62,7 +59,6 @@ import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
|||||||
import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar";
|
import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar";
|
||||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||||
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
|
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
|
||||||
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
|
|
||||||
import {
|
import {
|
||||||
areImageAdjustmentsEqual,
|
areImageAdjustmentsEqual,
|
||||||
buildImageAdjustmentFilterStyle,
|
buildImageAdjustmentFilterStyle,
|
||||||
@ -189,7 +185,7 @@ import {
|
|||||||
normalizeDetailUiSubjectAnalysis,
|
normalizeDetailUiSubjectAnalysis,
|
||||||
} from "@/lib/detail-ui-subject-analysis";
|
} from "@/lib/detail-ui-subject-analysis";
|
||||||
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
|
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
|
||||||
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import {
|
import {
|
||||||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
||||||
getDefaultThreeDModelAliasId,
|
getDefaultThreeDModelAliasId,
|
||||||
@ -235,8 +231,6 @@ import {
|
|||||||
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||||||
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
||||||
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capability";
|
|
||||||
import { useCanvasRemoteOperation } from "@/components/canvas/CanvasRemoteOperationContext";
|
|
||||||
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
|
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
|
||||||
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
|
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
|
||||||
import { cn, fetcher } from "@/lib/utils";
|
import { cn, fetcher } from "@/lib/utils";
|
||||||
@ -1714,7 +1708,6 @@ export function ImageNode({
|
|||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
||||||
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
||||||
const [fullscreenPreviewImage, setFullscreenPreviewImage] = useState<LoadedImagePreview | null>(null);
|
|
||||||
const [downloadProgress, setDownloadProgress] =
|
const [downloadProgress, setDownloadProgress] =
|
||||||
useState<CanvasImageDownloadProgress | null>(null);
|
useState<CanvasImageDownloadProgress | null>(null);
|
||||||
const downloadAbortControllerRef = useRef<AbortController | null>(null);
|
const downloadAbortControllerRef = useRef<AbortController | null>(null);
|
||||||
@ -2084,7 +2077,7 @@ export function ImageNode({
|
|||||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||||
const projectId = useCanvasStore((state) => state.projectId);
|
const projectId = useCanvasStore((state) => state.projectId);
|
||||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||||||
const runCanvasRemoteOperation = useCanvasRemoteOperation();
|
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
|
||||||
const addPendingChatAttachment = useCanvasStore(
|
const addPendingChatAttachment = useCanvasStore(
|
||||||
(state) => state.addPendingChatAttachment
|
(state) => state.addPendingChatAttachment
|
||||||
);
|
);
|
||||||
@ -2504,13 +2497,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(
|
||||||
@ -4685,44 +4678,106 @@ export function ImageNode({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!projectId || !runCanvasRemoteOperation) {
|
setIsCreatingDetachedResultNode(true);
|
||||||
toast.error("画布还在加载,请稍后再试");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsCreatingDetachedResultNode(false);
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
const actionName = "框选抠图";
|
const actionName = "框选抠图";
|
||||||
|
const newNodeId = addNode({
|
||||||
try {
|
type: "image",
|
||||||
const command = createCanvasCapabilityClientCommand({
|
position: {
|
||||||
capabilityId: "image.box_cutout",
|
x: node.position.x + node.size.width + 20,
|
||||||
nodeIds: [node.id],
|
y: node.position.y,
|
||||||
projectId,
|
},
|
||||||
source: "toolbar",
|
size: { width: node.size.width, height: node.size.height },
|
||||||
args: {
|
data: {
|
||||||
...boxCutoutSelection,
|
src: "",
|
||||||
|
status: "generating",
|
||||||
|
prompt: `${actionName}处理中...`,
|
||||||
|
title: `${displayTitle}-${actionName}`,
|
||||||
|
originalSrc: data.src,
|
||||||
|
metadata: {
|
||||||
|
cropRect: boxCutoutSelection,
|
||||||
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
connections: [node.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await imageGenerationService.boxCutout({
|
||||||
|
imageUrl: data.src,
|
||||||
|
cropRect: boxCutoutSelection,
|
||||||
|
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
||||||
|
sourceNodeId: node.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.data?.url) {
|
||||||
|
updateNode(newNodeId, {
|
||||||
|
data: {
|
||||||
|
src: result.data.url,
|
||||||
|
status: "completed",
|
||||||
|
prompt: `${actionName}结果`,
|
||||||
|
title: `${displayTitle}-${actionName}`,
|
||||||
|
originalSrc: data.src,
|
||||||
|
metadata: {
|
||||||
|
cropRect: boxCutoutSelection,
|
||||||
|
cropRectApplied: result.data.cropRectApplied,
|
||||||
|
paddedCropRectApplied: result.data.paddedCropRectApplied,
|
||||||
|
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
||||||
|
editMode: "box-cutout",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
|
|
||||||
toast.success(`${actionName}完成`);
|
toast.success(`${actionName}完成`);
|
||||||
|
} else {
|
||||||
|
updateNode(newNodeId, {
|
||||||
|
data: {
|
||||||
|
src: "",
|
||||||
|
status: "failed",
|
||||||
|
prompt: `${actionName}失败`,
|
||||||
|
title: `${displayTitle}-${actionName}-失败`,
|
||||||
|
originalSrc: data.src,
|
||||||
|
metadata: {
|
||||||
|
cropRect: boxCutoutSelection,
|
||||||
|
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.error(result.error || `${actionName}失败`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("框选抠图失败:", error);
|
console.error("框选抠图失败:", error);
|
||||||
toast.error(error instanceof Error ? error.message : `${actionName}失败`);
|
updateNode(newNodeId, {
|
||||||
|
data: {
|
||||||
|
src: "",
|
||||||
|
status: "failed",
|
||||||
|
prompt: `${actionName}失败`,
|
||||||
|
title: `${displayTitle}-${actionName}-失败`,
|
||||||
|
originalSrc: data.src,
|
||||||
|
metadata: {
|
||||||
|
cropRect: boxCutoutSelection,
|
||||||
|
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.error(`${actionName}失败,请重试`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreatingDetachedResultNode(false);
|
setIsCreatingDetachedResultNode(false);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
resetBoxCutoutState();
|
resetBoxCutoutState();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
addNode,
|
||||||
boxCutoutSelection,
|
boxCutoutSelection,
|
||||||
data.src,
|
data.src,
|
||||||
|
displayTitle,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
node.id,
|
node.id,
|
||||||
projectId,
|
node.position.x,
|
||||||
|
node.position.y,
|
||||||
|
node.size.height,
|
||||||
|
node.size.width,
|
||||||
resetBoxCutoutState,
|
resetBoxCutoutState,
|
||||||
runCanvasRemoteOperation,
|
updateNode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const animateAddToChat = useCallback(async (previewUrl?: string) => {
|
const animateAddToChat = useCallback(async (previewUrl?: string) => {
|
||||||
@ -6652,7 +6707,7 @@ export function ImageNode({
|
|||||||
const executeSharedToolbarCapability = async (
|
const executeSharedToolbarCapability = async (
|
||||||
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
|
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
|
||||||
) => {
|
) => {
|
||||||
if (!projectId || !runCanvasRemoteOperation) {
|
if (!projectId) {
|
||||||
toast.error("画布还在加载,请稍后再试");
|
toast.error("画布还在加载,请稍后再试");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -6671,7 +6726,21 @@ export function ImageNode({
|
|||||||
projectId,
|
projectId,
|
||||||
source: "toolbar",
|
source: "toolbar",
|
||||||
});
|
});
|
||||||
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
|
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(command),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || payload?.success !== true) {
|
||||||
|
throw new Error(payload?.error || `${capability.title}失败`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectResult = await canvasService.getProject(projectId);
|
||||||
|
if (!projectResult.success || !projectResult.data?.canvasData) {
|
||||||
|
throw new Error(projectResult.error || "结果已生成,但画布同步失败");
|
||||||
|
}
|
||||||
|
loadCanvas(projectResult.data.canvasData);
|
||||||
toast.success(`${capability.title}完成`);
|
toast.success(`${capability.title}完成`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
@ -8370,11 +8439,7 @@ export function ImageNode({
|
|||||||
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
||||||
{data.src ? (
|
{data.src ? (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
{isRetiredStorageUrl(data.src) ? (
|
{!shouldRequestDisplayImage ? (
|
||||||
<div role="status" className="flex h-full w-full items-center justify-center bg-muted p-4 text-center text-sm text-muted-foreground">
|
|
||||||
{RETIRED_STORAGE_MESSAGE}
|
|
||||||
</div>
|
|
||||||
) : !shouldRequestDisplayImage ? (
|
|
||||||
<ImageNodeDeferredLoadState />
|
<ImageNodeDeferredLoadState />
|
||||||
) : isCompareMode && hasCompareSource ? (
|
) : isCompareMode && hasCompareSource ? (
|
||||||
<div
|
<div
|
||||||
@ -8401,16 +8466,15 @@ 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 !== data.src) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
event.currentTarget.src = data.src;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLoad={handleDisplayImageLoad}
|
onLoad={handleDisplayImageLoad}
|
||||||
@ -8431,16 +8495,15 @@ 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 !== compareSource) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(compareSource);
|
event.currentTarget.src = compareSource;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
src={displayCompareSrc}
|
src={displayCompareSrc}
|
||||||
@ -8482,8 +8545,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 +8557,7 @@ export function ImageNode({
|
|||||||
)}
|
)}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
loading="eager"
|
loading="lazy"
|
||||||
onClick={
|
onClick={
|
||||||
isThisNodePicking && !boxCutoutMode
|
isThisNodePicking && !boxCutoutMode
|
||||||
? handlePickerClick
|
? handlePickerClick
|
||||||
@ -8504,17 +8566,12 @@ export function ImageNode({
|
|||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
if (!isThisNodePicking && !boxCutoutMode && !localEditMode) {
|
if (!isThisNodePicking && !boxCutoutMode && !localEditMode) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setFullscreenPreviewImage({
|
|
||||||
src: e.currentTarget.currentSrc || e.currentTarget.src,
|
|
||||||
width: e.currentTarget.naturalWidth,
|
|
||||||
height: e.currentTarget.naturalHeight,
|
|
||||||
});
|
|
||||||
setFullscreenPreviewOpen(true);
|
setFullscreenPreviewOpen(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
|
if (event.currentTarget.src !== data.src) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
event.currentTarget.src = data.src;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLoad={handleDisplayImageLoad}
|
onLoad={handleDisplayImageLoad}
|
||||||
@ -9425,8 +9482,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}
|
||||||
@ -11916,7 +11971,6 @@ export function ImageNode({
|
|||||||
{/* 双击放大预览弹窗 */}
|
{/* 双击放大预览弹窗 */}
|
||||||
{fullscreenPreviewOpen &&
|
{fullscreenPreviewOpen &&
|
||||||
data.src &&
|
data.src &&
|
||||||
fullscreenPreviewImage &&
|
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90"
|
className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90"
|
||||||
@ -11924,7 +11978,7 @@ export function ImageNode({
|
|||||||
>
|
>
|
||||||
{/* 关闭按钮 */}
|
{/* 关闭按钮 */}
|
||||||
<button
|
<button
|
||||||
className="absolute top-4 right-4 z-10 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
|
className="absolute top-4 right-4 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
|
||||||
onClick={() => setFullscreenPreviewOpen(false)}
|
onClick={() => setFullscreenPreviewOpen(false)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@ -11942,7 +11996,7 @@ export function ImageNode({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="absolute top-4 right-16 z-10 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
|
className="absolute top-4 right-16 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
disabled={isDownloadingOriginalImage}
|
disabled={isDownloadingOriginalImage}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -11961,11 +12015,13 @@ export function ImageNode({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 图片 */}
|
{/* 图片 */}
|
||||||
<ProgressiveImagePreview
|
<img
|
||||||
alt={data.prompt || "图片预览"}
|
alt={data.prompt || "图片预览"}
|
||||||
className="max-h-[95vh] max-w-[95vw] object-contain"
|
className="max-h-[95vh] max-w-[95vw] object-contain"
|
||||||
src={resolveOriginalImageUrl(data.src) || data.src}
|
decoding="async"
|
||||||
preview={fullscreenPreviewImage}
|
draggable={false}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
src={fullscreenPreviewSrc}
|
||||||
style={activeImageAdjustmentsStyle}
|
style={activeImageAdjustmentsStyle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,10 +15,7 @@ import {
|
|||||||
splitOpenClawChartBlocks,
|
splitOpenClawChartBlocks,
|
||||||
} from "@/components/elements/openclaw-chart-card";
|
} from "@/components/elements/openclaw-chart-card";
|
||||||
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
|
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
|
||||||
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
|
|
||||||
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
|
|
||||||
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@ -29,7 +26,6 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
type ResponseProps = ComponentProps<typeof Streamdown> & {
|
type ResponseProps = ComponentProps<typeof Streamdown> & {
|
||||||
collapsibleImageAnalysis?: boolean;
|
collapsibleImageAnalysis?: boolean;
|
||||||
isMarkdownStreaming?: boolean;
|
|
||||||
enablePdfDownload?: boolean;
|
enablePdfDownload?: boolean;
|
||||||
pdfFileName?: string;
|
pdfFileName?: string;
|
||||||
pdfSiteLogo?: string | null;
|
pdfSiteLogo?: string | null;
|
||||||
@ -366,6 +362,43 @@ function normalizeMarkdownContent(value: string) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitMarkdownImages(content: string) {
|
||||||
|
const segments: Array<
|
||||||
|
| { type: "text"; value: string }
|
||||||
|
| { type: "image"; alt?: string; src: string }
|
||||||
|
> = [];
|
||||||
|
|
||||||
|
const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = imageRegex.exec(content)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
segments.push({
|
||||||
|
type: "text",
|
||||||
|
value: content.slice(lastIndex, match.index),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
segments.push({
|
||||||
|
type: "image",
|
||||||
|
alt: match[1],
|
||||||
|
src: match[2],
|
||||||
|
});
|
||||||
|
|
||||||
|
lastIndex = imageRegex.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < content.length) {
|
||||||
|
segments.push({
|
||||||
|
type: "text",
|
||||||
|
value: content.slice(lastIndex),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
function isLikelyImageUrl(value?: string | null) {
|
function isLikelyImageUrl(value?: string | null) {
|
||||||
return IMAGE_URL_REGEX.test(String(value || "").trim());
|
return IMAGE_URL_REGEX.test(String(value || "").trim());
|
||||||
}
|
}
|
||||||
@ -442,8 +475,8 @@ function MarkdownTableImagePreview({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(src)) {
|
if (event.currentTarget.src !== src) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(src);
|
event.currentTarget.src = src;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -473,8 +506,8 @@ function MarkdownTableImagePreview({
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(originalSrc)) {
|
if (event.currentTarget.src !== originalSrc) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(originalSrc);
|
event.currentTarget.src = originalSrc;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -764,7 +797,6 @@ function ResponseReportTableDownloadButton({
|
|||||||
export const Response = ({
|
export const Response = ({
|
||||||
className,
|
className,
|
||||||
collapsibleImageAnalysis = false,
|
collapsibleImageAnalysis = false,
|
||||||
isMarkdownStreaming = false,
|
|
||||||
enablePdfDownload = false,
|
enablePdfDownload = false,
|
||||||
pdfFileName,
|
pdfFileName,
|
||||||
pdfSiteLogo,
|
pdfSiteLogo,
|
||||||
@ -1029,10 +1061,7 @@ export const Response = ({
|
|||||||
|
|
||||||
const rawText =
|
const rawText =
|
||||||
typeof props.children === "string"
|
typeof props.children === "string"
|
||||||
? normalizeMarkdownContent(stableStreamingMarkdown(
|
? normalizeMarkdownContent(props.children)
|
||||||
getGatewayErrorPageMessage(props.children) || props.children,
|
|
||||||
isMarkdownStreaming
|
|
||||||
))
|
|
||||||
: null;
|
: null;
|
||||||
const shouldShowPdfDownload =
|
const shouldShowPdfDownload =
|
||||||
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
|
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
|
||||||
@ -1062,7 +1091,7 @@ export const Response = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (rawText !== null) {
|
if (rawText) {
|
||||||
const analysisSection = collapsibleImageAnalysis
|
const analysisSection = collapsibleImageAnalysis
|
||||||
? extractImageAnalysisSection(rawText)
|
? extractImageAnalysisSection(rawText)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, type ReactNode } from "react";
|
|
||||||
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
|
|
||||||
|
|
||||||
type HomeImageProps = {
|
|
||||||
sources: Array<string | null | undefined>;
|
|
||||||
alt: string;
|
|
||||||
className?: string;
|
|
||||||
imageClassName?: string;
|
|
||||||
fallback?: ReactNode;
|
|
||||||
loading?: "lazy" | "eager";
|
|
||||||
onUnavailable?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function imageCandidates(source: string) {
|
|
||||||
// These two domains are documented aliases for the same gongxiangpan store.
|
|
||||||
// Do not rewrite other buckets, providers, signed URLs or user-supplied hosts.
|
|
||||||
try {
|
|
||||||
const url = new URL(source);
|
|
||||||
if (url.origin === "https://s3.yydd.ai" &&
|
|
||||||
url.pathname.startsWith("/gongxiangpan/") && !url.search && !url.hash) {
|
|
||||||
return [`https://s3.jiansun.vip${url.pathname}`, source];
|
|
||||||
}
|
|
||||||
} catch { /* Relative site assets need no host rewrite. */ }
|
|
||||||
return [source];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keep failed image elements out of view, trying each distinct source once. */
|
|
||||||
export function HomeImage(props: HomeImageProps) {
|
|
||||||
const sources = [...new Set(props.sources
|
|
||||||
.filter((url): url is string => Boolean(url?.trim()) && !isRetiredStorageUrl(url))
|
|
||||||
.flatMap(imageCandidates))];
|
|
||||||
return <HomeImageAttempt key={JSON.stringify(sources)} {...props} sources={sources} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HomeImageAttempt({
|
|
||||||
sources, alt, className, imageClassName, fallback, loading = "lazy", onUnavailable,
|
|
||||||
}: Omit<HomeImageProps, "sources"> & { sources: string[] }) {
|
|
||||||
const [index, setIndex] = useState(0);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
const source = sources[index];
|
|
||||||
return (
|
|
||||||
<span className={`relative block overflow-hidden ${className || ""}`}>
|
|
||||||
{!loaded && (
|
|
||||||
<span className={fallback ? "block" : "block aspect-[4/5] rounded-[12px] bg-[#e8e8e8] dark:bg-[#1b1c20]"}>
|
|
||||||
{fallback}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{source ? (
|
|
||||||
<img
|
|
||||||
key={source}
|
|
||||||
src={source}
|
|
||||||
alt={alt}
|
|
||||||
loading={loading}
|
|
||||||
decoding="async"
|
|
||||||
className={`${imageClassName || ""} ${loaded ? "" : "absolute inset-0 opacity-0"}`}
|
|
||||||
onLoad={() => setLoaded(true)}
|
|
||||||
onError={() => {
|
|
||||||
setLoaded(false);
|
|
||||||
setIndex(index + 1);
|
|
||||||
if (index + 1 >= sources.length) onUnavailable?.();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||||
import { Gem, Heart, Loader2, PlayCircle } from "lucide-react";
|
import { Gem, Heart, Loader2, PlayCircle } from "lucide-react";
|
||||||
import { useState, type Ref } from "react";
|
import type { Ref } from "react";
|
||||||
import { HomeImage } from "./home-image";
|
|
||||||
import type { FeaturedImage } from "./types";
|
import type { FeaturedImage } from "./types";
|
||||||
|
|
||||||
type LovartFeaturedGalleryProps = {
|
type LovartFeaturedGalleryProps = {
|
||||||
@ -31,10 +30,6 @@ function getFeaturedPreviewUrl(item: FeaturedImage) {
|
|||||||
return `${source}?imageView2/2/w/900/q/78`;
|
return `${source}?imageView2/2/w/900/q/78`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageIdentity(item: FeaturedImage) {
|
|
||||||
return JSON.stringify([item.id, item.thumbnailUrl, item.originalUrl]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LovartFeaturedGallery({
|
export function LovartFeaturedGallery({
|
||||||
onOpenProjects,
|
onOpenProjects,
|
||||||
onOpenMaterials,
|
onOpenMaterials,
|
||||||
@ -47,13 +42,6 @@ export function LovartFeaturedGallery({
|
|||||||
isLoadingMoreFeatured,
|
isLoadingMoreFeatured,
|
||||||
setActiveCategory,
|
setActiveCategory,
|
||||||
}: LovartFeaturedGalleryProps) {
|
}: LovartFeaturedGalleryProps) {
|
||||||
const [unavailable, setUnavailable] = useState<Set<string>>(() => new Set());
|
|
||||||
const [retryVersion, setRetryVersion] = useState(0);
|
|
||||||
const uniqueItems = [...new Map(featuredItems.map((item) => [item.id, item])).values()];
|
|
||||||
const visibleItems = uniqueItems.filter((item) =>
|
|
||||||
Boolean(item.thumbnailUrl || item.originalUrl),
|
|
||||||
);
|
|
||||||
const unavailableCount = uniqueItems.filter(item => unavailable.has(imageIdentity(item))).length;
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={featuredSectionRef}
|
ref={featuredSectionRef}
|
||||||
@ -100,48 +88,44 @@ export function LovartFeaturedGallery({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{unavailableCount > 0 && (
|
{featuredItems.length === 0 ? (
|
||||||
<div role="status" className="mt-4 flex items-center gap-3 text-xs text-black/50 dark:text-white/50">
|
|
||||||
<span>{unavailableCount} 张灵感图片加载失败,请重试。</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shrink-0 underline underline-offset-4 hover:text-black dark:hover:text-white"
|
|
||||||
onClick={() => {
|
|
||||||
setUnavailable(new Set());
|
|
||||||
setRetryVersion((version) => version + 1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
重试加载
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{visibleItems.length === 0 ? (
|
|
||||||
<div className="mt-[22px] rounded-[10px] border border-dashed border-black/15 bg-[#f7f7f7] p-10 text-center text-[14px] text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
|
<div className="mt-[22px] rounded-[10px] border border-dashed border-black/15 bg-[#f7f7f7] p-10 text-center text-[14px] text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
|
||||||
{isLoadingFeatured
|
{isLoadingFeatured
|
||||||
? "灵感内容加载中..."
|
? "灵感内容加载中..."
|
||||||
: unavailableCount > 0 ? "图片暂时无法加载,请稍后重试。" : "当前没有可展示的灵感数据。"}
|
: "当前没有可展示的灵感数据。"}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4">
|
<div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4">
|
||||||
{visibleItems.map((item) => (
|
{featuredItems.map((item, index) => (
|
||||||
<button
|
<button
|
||||||
key={`${imageIdentity(item)}:${retryVersion}`}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={unavailable.has(imageIdentity(item))}
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open(item.originalUrl || item.thumbnailUrl, "_blank")
|
window.open(item.originalUrl || item.thumbnailUrl, "_blank")
|
||||||
}
|
}
|
||||||
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
|
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
|
||||||
>
|
>
|
||||||
{unavailable.has(imageIdentity(item)) ? (
|
<img
|
||||||
<div className="flex aspect-[4/5] items-center justify-center rounded-[12px] bg-muted p-4 text-sm text-muted-foreground">图片加载失败</div>
|
src={getFeaturedPreviewUrl(item)}
|
||||||
) : <HomeImage
|
|
||||||
sources={[getFeaturedPreviewUrl(item), item.thumbnailUrl, item.originalUrl]}
|
|
||||||
alt={item.prompt || "灵感图"}
|
alt={item.prompt || "灵感图"}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onUnavailable={() => setUnavailable((previous) => new Set(previous).add(imageIdentity(item)))}
|
decoding="async"
|
||||||
imageClassName="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
|
onError={(event) => {
|
||||||
/>}
|
const image = event.currentTarget;
|
||||||
|
const fallbackUrl = item.originalUrl || item.thumbnailUrl;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!fallbackUrl ||
|
||||||
|
image.dataset.fallbackApplied === "true"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
image.dataset.fallbackApplied = "true";
|
||||||
|
image.src = fallbackUrl;
|
||||||
|
}}
|
||||||
|
className="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
|
||||||
|
/>
|
||||||
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
|
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
|
||||||
<div className="flex min-w-0 items-center gap-[6px] pr-2">
|
<div className="flex min-w-0 items-center gap-[6px] pr-2">
|
||||||
<span className="flex h-[16px] w-[16px] shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#d8d8d8] text-[8px] font-medium text-white">
|
<span className="flex h-[16px] w-[16px] shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#d8d8d8] text-[8px] font-medium text-white">
|
||||||
@ -154,11 +138,11 @@ export function LovartFeaturedGallery({
|
|||||||
<div className="inline-flex shrink-0 items-center gap-[7px] text-[11px] leading-none">
|
<div className="inline-flex shrink-0 items-center gap-[7px] text-[11px] leading-none">
|
||||||
<span className="inline-flex items-center gap-[4px]">
|
<span className="inline-flex items-center gap-[4px]">
|
||||||
<PlayCircle className="h-[11px] w-[11px]" strokeWidth={2} />
|
<PlayCircle className="h-[11px] w-[11px]" strokeWidth={2} />
|
||||||
{(1200 + featuredItems.indexOf(item) * 37).toLocaleString()}
|
{(1200 + index * 37).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-[4px]">
|
<span className="inline-flex items-center gap-[4px]">
|
||||||
<Heart className="h-[11px] w-[11px]" strokeWidth={2} />
|
<Heart className="h-[11px] w-[11px]" strokeWidth={2} />
|
||||||
{(20 + (featuredItems.indexOf(item) % 9) * 11).toLocaleString()}
|
{(20 + (index % 9) * 11).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<Gem className="h-[11px] w-[11px]" strokeWidth={2} />
|
<Gem className="h-[11px] w-[11px]" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config";
|
import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config";
|
||||||
import type { NavLink, TutorialVideo } from "./types";
|
import type { NavLink, TutorialVideo } from "./types";
|
||||||
import { HomeImage } from "./home-image";
|
|
||||||
|
|
||||||
type SidebarItem = {
|
type SidebarItem = {
|
||||||
iconUrl?: string | null;
|
iconUrl?: string | null;
|
||||||
@ -184,18 +183,17 @@ export function LovartSidebar({
|
|||||||
onClick={onHome}
|
onClick={onHome}
|
||||||
title={siteName}
|
title={siteName}
|
||||||
>
|
>
|
||||||
<HomeImage
|
{siteLogo ? (
|
||||||
sources={[siteLogo]}
|
<img
|
||||||
|
src={siteLogo}
|
||||||
alt={siteName}
|
alt={siteName}
|
||||||
loading="eager"
|
|
||||||
className="h-7 w-7 rounded-full object-cover"
|
className="h-7 w-7 rounded-full object-cover"
|
||||||
imageClassName="h-7 w-7 rounded-full object-cover"
|
/>
|
||||||
fallback={
|
) : (
|
||||||
<span aria-hidden="true" className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-[12px] font-semibold text-white dark:bg-white dark:text-black">
|
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-[12px] font-semibold text-white dark:bg-white dark:text-black">
|
||||||
{siteName.slice(0, 1).toUpperCase()}
|
{siteName.slice(0, 1).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
}
|
)}
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">{siteName}</TooltipContent>
|
<TooltipContent side="right">{siteName}</TooltipContent>
|
||||||
|
|||||||
@ -918,9 +918,9 @@ export function ImageAnnotator({
|
|||||||
|
|
||||||
setIsRemovingBg(true);
|
setIsRemovingBg(true);
|
||||||
try {
|
try {
|
||||||
const input = /^https?:\/\//i.test(originalImageUrl)
|
// 使用原图URL获取原图数据,而不是canvas的预览图
|
||||||
? { imageUrl: originalImageUrl }
|
const base64Data = await imageUrlToBase64(originalImageUrl);
|
||||||
: { image_base64: await imageUrlToBase64(originalImageUrl) };
|
logger.debug("rmbg_source_prepared", { base64Length: base64Data.length });
|
||||||
|
|
||||||
// 调用抠图API
|
// 调用抠图API
|
||||||
const response = await fetch("/api/rmbg", {
|
const response = await fetch("/api/rmbg", {
|
||||||
@ -929,7 +929,7 @@ export function ImageAnnotator({
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...input,
|
image_base64: base64Data,
|
||||||
prompt: "画图标注-抠图处理", // 可选:添加提示词
|
prompt: "画图标注-抠图处理", // 可选:添加提示词
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react";
|
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
const ImageAnnotator = dynamic(
|
const ImageAnnotator = dynamic(
|
||||||
() => import("./image-annotator").then((module) => module.ImageAnnotator),
|
() => import("./image-annotator").then((module) => module.ImageAnnotator),
|
||||||
@ -24,6 +22,25 @@ const AngleEditDialog = dynamic(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function triggerBrowserDownload(
|
||||||
|
url: string,
|
||||||
|
filename?: string,
|
||||||
|
options?: { openInNewTab?: boolean }
|
||||||
|
) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
if (filename) {
|
||||||
|
link.download = filename;
|
||||||
|
}
|
||||||
|
if (options?.openInNewTab) {
|
||||||
|
link.target = "_blank";
|
||||||
|
}
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
export function ImagePreview({
|
export function ImagePreview({
|
||||||
src,
|
src,
|
||||||
alt,
|
alt,
|
||||||
@ -220,12 +237,34 @@ export function ImagePreview({
|
|||||||
toast.error("下载失败,请重试");
|
toast.error("下载失败,请重试");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const filename = `generated-image-${Date.now()}.jpg`;
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url: safeOriginalUrl });
|
const response = await fetch(
|
||||||
toast.success("原图已开始下载");
|
buildImageProxyUrl(safeOriginalUrl, { delivery: "proxy" })
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`下载失败(${response.status})`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
triggerBrowserDownload(url, filename);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
toast.success("下载成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("下载失败:", error);
|
console.error("下载失败:", error);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
try {
|
||||||
|
triggerBrowserDownload(safeOriginalUrl, filename);
|
||||||
|
toast.message("代理下载失败,已切换为原图直链下载");
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error("原图直链下载失败:", fallbackError);
|
||||||
|
try {
|
||||||
|
triggerBrowserDownload(safeOriginalUrl, undefined, { openInNewTab: true });
|
||||||
|
toast.message("已打开原图,可直接另存为");
|
||||||
|
} catch (openError) {
|
||||||
|
console.error("打开原图失败:", openError);
|
||||||
|
toast.error("下载失败,请重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -277,10 +316,6 @@ export function ImagePreview({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isRetiredStorageUrl(src) || isRetiredStorageUrl(original)) {
|
|
||||||
return <div role="status" className="my-2 rounded-lg border bg-muted p-4 text-sm text-muted-foreground">{RETIRED_STORAGE_MESSAGE}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 缩略图(显示压缩后的缩略图)*/}
|
{/* 缩略图(显示压缩后的缩略图)*/}
|
||||||
@ -307,18 +342,9 @@ export function ImagePreview({
|
|||||||
}}
|
}}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onLoad={(event) => {
|
|
||||||
event.currentTarget.style.aspectRatio = "";
|
|
||||||
event.currentTarget.parentElement?.style.removeProperty("width");
|
|
||||||
event.currentTarget.alt = "Generated Image";
|
|
||||||
}}
|
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (!normalizeLegacyAliyunStorageUrl(thumbnail || original) && event.currentTarget.src !== (thumbnail || original)) {
|
if (event.currentTarget.src !== (thumbnail || original)) {
|
||||||
event.currentTarget.src = thumbnail || original;
|
event.currentTarget.src = thumbnail || original;
|
||||||
} else if (!compactGrid) {
|
|
||||||
event.currentTarget.style.aspectRatio = "1";
|
|
||||||
event.currentTarget.parentElement?.style.setProperty("width", "100%");
|
|
||||||
event.currentTarget.alt = "预览暂不可用,可尝试下载原图";
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
@ -610,7 +636,7 @@ export function ImagePreview({
|
|||||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={normalizeLegacyAliyunStorageUrl(original) ? buildImageProxyUrl(original) : original}
|
src={original}
|
||||||
alt="Generated Image - Original"
|
alt="Generated Image - Original"
|
||||||
className={`max-h-[90vh] max-w-full rounded-lg object-contain ${
|
className={`max-h-[90vh] max-w-full rounded-lg object-contain ${
|
||||||
isAnnotationMode ? "cursor-crosshair" : ""
|
isAnnotationMode ? "cursor-crosshair" : ""
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import type { Vote } from "@/lib/db/schema";
|
|||||||
import type { ChatMessage } from "@/lib/types";
|
import type { ChatMessage } from "@/lib/types";
|
||||||
import { cn, sanitizeText } from "@/lib/utils";
|
import { cn, sanitizeText } from "@/lib/utils";
|
||||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||||
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
|
||||||
import { useDataStream } from "./data-stream-provider";
|
import { useDataStream } from "./data-stream-provider";
|
||||||
import { DocumentToolResult } from "./document";
|
import { DocumentToolResult } from "./document";
|
||||||
import { DocumentPreview } from "./document-preview";
|
import { DocumentPreview } from "./document-preview";
|
||||||
@ -522,7 +521,7 @@ const PurePreviewMessage = ({
|
|||||||
const displayModelName = extractedModelName || inferredFallbackModelName;
|
const displayModelName = extractedModelName || inferredFallbackModelName;
|
||||||
const filteredText =
|
const filteredText =
|
||||||
message.role === "assistant"
|
message.role === "assistant"
|
||||||
? getGatewayErrorPageMessage(contentWithoutBadge) || appendImageDoneSummary(contentWithoutBadge)
|
? appendImageDoneSummary(contentWithoutBadge)
|
||||||
: contentWithoutBadge;
|
: contentWithoutBadge;
|
||||||
const useAssistantTypewriter =
|
const useAssistantTypewriter =
|
||||||
message.role === "assistant" &&
|
message.role === "assistant" &&
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import { Button } from "./ui/button";
|
|||||||
import { X, Download, Target, Paintbrush } from "lucide-react";
|
import { X, Download, Target, Paintbrush } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ImageAnnotator } from "./image-annotator";
|
import { ImageAnnotator } from "./image-annotator";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
export const PreviewAttachment = ({
|
export const PreviewAttachment = ({
|
||||||
attachment,
|
attachment,
|
||||||
@ -74,11 +73,18 @@ export const PreviewAttachment = ({
|
|||||||
// 下载图片函数
|
// 下载图片函数
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url, name: name ?? "image" });
|
const response = await fetch(url);
|
||||||
toast.success("原图已开始下载");
|
const blob = await response.blob();
|
||||||
|
const downloadUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = downloadUrl;
|
||||||
|
link.download = name ?? `image-${Date.now()}.jpg`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(downloadUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("下载失败:", error);
|
console.error("下载失败:", error);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState, type CSSProperties, type MouseEventHandler } from "react";
|
|
||||||
import { buildImageProxyUrl, getFullscreenPreviewSize } from "@/lib/image-delivery";
|
|
||||||
|
|
||||||
export type LoadedImagePreview = { src: string; width: number; height: number };
|
|
||||||
|
|
||||||
/** Keep the already displayed image visible while its larger preview loads. */
|
|
||||||
export function ProgressiveImagePreview({
|
|
||||||
src,
|
|
||||||
preview,
|
|
||||||
alt,
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
src: string;
|
|
||||||
preview: LoadedImagePreview;
|
|
||||||
alt: string;
|
|
||||||
className?: string;
|
|
||||||
style?: CSSProperties;
|
|
||||||
onClick?: MouseEventHandler<HTMLImageElement>;
|
|
||||||
}) {
|
|
||||||
const size = getFullscreenPreviewSize({
|
|
||||||
imageWidth: preview.width,
|
|
||||||
imageHeight: preview.height,
|
|
||||||
viewportWidth: typeof window === "undefined" ? 1280 : window.innerWidth,
|
|
||||||
viewportHeight: typeof window === "undefined" ? 900 : window.innerHeight,
|
|
||||||
pixelRatio: typeof window === "undefined" ? 1 : window.devicePixelRatio,
|
|
||||||
});
|
|
||||||
const fullSrc = buildImageProxyUrl(src, {
|
|
||||||
delivery: "proxy",
|
|
||||||
width: size.requestWidth,
|
|
||||||
quality: 86,
|
|
||||||
format: "webp",
|
|
||||||
});
|
|
||||||
const [loadedSrc, setLoadedSrc] = useState("");
|
|
||||||
const [failedSrc, setFailedSrc] = useState("");
|
|
||||||
const ready = fullSrc === preview.src || loadedSrc === fullSrc;
|
|
||||||
const failed = failedSrc === fullSrc;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (fullSrc === preview.src) return;
|
|
||||||
const image = new Image();
|
|
||||||
image.decoding = "async";
|
|
||||||
image.onload = () => setLoadedSrc(fullSrc);
|
|
||||||
image.onerror = () => setFailedSrc(fullSrc);
|
|
||||||
image.src = fullSrc;
|
|
||||||
return () => {
|
|
||||||
image.onload = null;
|
|
||||||
image.onerror = null;
|
|
||||||
image.removeAttribute("src");
|
|
||||||
};
|
|
||||||
}, [fullSrc, preview.src]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex items-center justify-center" onClick={(event) => event.stopPropagation()}>
|
|
||||||
<img
|
|
||||||
alt={alt}
|
|
||||||
className={className}
|
|
||||||
decoding="async"
|
|
||||||
draggable={false}
|
|
||||||
onClick={onClick}
|
|
||||||
src={ready ? fullSrc : preview.src}
|
|
||||||
style={{ width: size.displayWidth, ...style }}
|
|
||||||
/>
|
|
||||||
{!ready && (
|
|
||||||
<div role="status" className="pointer-events-none absolute bottom-4 max-w-full rounded-lg bg-black/80 px-3 py-2 text-center text-sm text-white">
|
|
||||||
{failed ? "高清预览加载失败,已保留当前预览;可关闭后重试或下载原图" : "正在加载高清预览…"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@ -2074,9 +2073,18 @@ export function SceneFusionView({
|
|||||||
|
|
||||||
const downloadImage = async (url: string, filename: string) => {
|
const downloadImage = async (url: string, filename: string) => {
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url, name: filename });
|
const response = await fetch(url);
|
||||||
} catch (error) {
|
const blob = await response.blob();
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
|
||||||
|
} catch {
|
||||||
|
window.open(url, "_blank");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -29,10 +29,10 @@ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
|||||||
|
|
||||||
const AlertDialogContent = React.forwardRef<
|
const AlertDialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> & { overlayClassName?: string }
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
>(({ className, overlayClassName, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
<AlertDialogOverlay className={overlayClassName} />
|
<AlertDialogOverlay />
|
||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@ -92,16 +92,8 @@ public 先从当前运行版本创建写入时复制副本,保留未纳入 Git
|
|||||||
|
|
||||||
原生产 checkout 的支线及未提交改动仍保留,不能据其 Git HEAD 判断已发布版本。
|
原生产 checkout 的支线及未提交改动仍保留,不能据其 Git HEAD 判断已发布版本。
|
||||||
实际发布证据是:运行 cwd、`.next/BUILD_ID`、该目录 `release.json` 和 `activation.json`。
|
实际发布证据是:运行 cwd、`.next/BUILD_ID`、该目录 `release.json` 和 `activation.json`。
|
||||||
`next.config.ts` 将本流程的发布目录编号写入前后端版本标识;`/api/app-version` 返回该编号且不缓存,发布烟测会核对它与运行目录一致。网页每分钟及切回页面时检查更新,发现版本变化后提示同步并刷新;画布同步失败或当前任务仍在处理时保留页面。首次接入前已经打开的旧网页需先刷新一次,后续发布即可自动收到提示。
|
|
||||||
维护电脑的 `.git/releases/<发布ID>/` 保留提交、构建日志、烟测、传输校验和及操作回执。
|
维护电脑的 `.git/releases/<发布ID>/` 保留提交、构建日志、烟测、传输校验和及操作回执。
|
||||||
服务器回滚配置可能含环境元数据,目录权限为 0700、文件为 0600,不放入公开目录或 Git。
|
服务器回滚配置可能含环境元数据,目录权限为 0700、文件为 0600,不放入公开目录或 Git。
|
||||||
|
|
||||||
仓库历史全量 lint 仍有已记录的问题,不能声称全部质量门禁通过。
|
仓库历史全量 lint 仍有已记录的问题,不能声称全部质量门禁通过。
|
||||||
每次 PR 须记录本次改动的验证、历史失败和未覆盖项目;当前流程不会自动触发生产发布。
|
每次 PR 须记录本次改动的验证、历史失败和未覆盖项目;当前流程不会自动触发生产发布。
|
||||||
|
|
||||||
## ZIP 下载交付
|
|
||||||
|
|
||||||
默认存储为七牛时,完整 ZIP 通过分片上传至七牛,确认下载地址可用后才提供下载。
|
|
||||||
大文件使用三个并发分片,上传进度显示在原打包任务中。传送或链接检查失败会返回具体阶段、
|
|
||||||
下载编号及重试建议,保留已生成的 ZIP;再次点击下载复用原文件,已上传的完整对象不重复上传。
|
|
||||||
后台 ZIP 任务参与发布期间的工作等待。完成或失败的任务及文件保留 24 小时,待处理任务保留 6 小时。
|
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
# 旧存储停用与七牛上传核查
|
|
||||||
|
|
||||||
## s3.1080p.hk
|
|
||||||
|
|
||||||
该存储已永久停用,文件不可恢复。它与 2026 年 10 月 14 日迁入七牛的阿里云 OSS 资源不是同一套存储。
|
|
||||||
|
|
||||||
- 首页精选只展示原文件仍在有效存储上的记录,统计和分页按同一条件计算。
|
|
||||||
- 历史生成记录、聊天、画布节点保留。旧图片显示永久失效说明,不再发起下载、归档或上传到旧桶。
|
|
||||||
- 单图下载和代理返回 HTTP 410 / `storage_retired`;批量下载将对应文件记为失败,不重试旧源。
|
|
||||||
- 其他存储的临时加载失败仍展示错误卡片和重试入口。
|
|
||||||
- 黑色 Logo 为 `public/brand/logo-black.png`;白色版使用 SVG 色彩滤镜保留同一 PNG 的透明度和轮廓。两者随应用发布,不依赖对象存储。
|
|
||||||
|
|
||||||
一次性配置整理:在生产运行目录执行 `node scripts/storage/retire-1080p-storage.cjs` 预览,增加 `--apply` 停用旧配置、取消失效图片的精选标记并更新 Logo。执行前将原字段保存至 `/var/lib/ai-chatbot/retired-storage-20261103/`。该操作不删除图片记录或对象文件。
|
|
||||||
|
|
||||||
2026-11-03 核查:旧源生成记录 67,267 条;精选 65 条中 59 条来自旧源;旧源图片归档任务为 0。默认上传存储为七牛 `gongxiangpan-sc`。
|
|
||||||
|
|
||||||
## 2026-10-14 上传峰值
|
|
||||||
|
|
||||||
统计时区 UTC+8,GB 按十进制计算。数据来自七牛只读上传统计、对象清单和生产机迁移记录。
|
|
||||||
|
|
||||||
| 范围 | 对象数 | 字节数 | GB |
|
|
||||||
| --- | ---: | ---: | ---: |
|
|
||||||
| 七牛当天上传流量 | — | 825,113,404,030 | 825.11 |
|
|
||||||
| 当天写入且仍存在的 `legacy-oss/demo-sparkai/` | 409,821 | 812,788,883,061 | 812.79 |
|
|
||||||
| 当天写入且仍存在的 `legacy-s3/gongxiangpan/` | 539 | 865,360,590 | 0.87 |
|
|
||||||
| 当天写入且仍存在的其他目录 | 3,382 | 9,735,597,543 | 9.74 |
|
|
||||||
| 上传流量与当天现存对象大小差额 | — | 1,723,562,836 | 1.72 |
|
|
||||||
|
|
||||||
813.65 GB 位于本项目历史迁移目录。主要写入发生在 19:00–21:00。源分别为 `demo-sparkai.oss-cn-shanghai.aliyuncs.com` 和 `s3.jiansun.vip/gongxiangpan`,符合仓库迁移脚本的目标路径。
|
|
||||||
|
|
||||||
生产机 `/var/lib/ai-chatbot/aliyun-qiniu-20261014/verified.jsonl` 有 349,784 个去重后的目标对象、629,920,510,316 字节(629.92 GB)的逐对象验证记录。当前迁移目录比该回执多 60,576 个对象、183.73 GB;目录归属能够确认,现有回执没有覆盖这些对象。
|
|
||||||
|
|
||||||
桶由多个业务共用。其他目录中 `uploads/` 为 7.97 GB,其余包含 `dabi/`、`exports/`、`accio-ghost/` 和 `shared-drive/`,不能全部归为画布新生成资源。上传统计包含传输量,对象清单反映现存对象,不足以单独解释最后 1.72 GB 的差额。本次未删除或改动七牛对象。
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
|
||||||
import { mergeCanvasSnapshots, type CanvasConflictChoice } from "@/lib/video-canvas/merge-snapshot";
|
import { mergeCanvasSnapshots, type CanvasConflictChoice } from "@/lib/video-canvas/merge-snapshot";
|
||||||
import {
|
import {
|
||||||
CanvasRevisionConflict,
|
CanvasRevisionConflict,
|
||||||
@ -150,16 +149,6 @@ export function useVideoCanvasPersistence(
|
|||||||
return !queue.dirty && !queue.failed;
|
return !queue.dirty && !queue.failed;
|
||||||
}, [ready]);
|
}, [ready]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const beforeRefresh = (event: Event) => {
|
|
||||||
(event as CustomEvent<PageRefreshRequest>).detail.save(async () => {
|
|
||||||
if (!(await saveAndConfirm())) throw new Error("视频画布尚未同步完成,本地草稿已保留,请重试刷新。");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
|
||||||
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
|
||||||
}, [saveAndConfirm]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
import { sql, type SQL } from "drizzle-orm";
|
|
||||||
|
|
||||||
export function parseMediaQuery(params: URLSearchParams) {
|
|
||||||
const integer = (key: string, fallback: number, max: number) => {
|
|
||||||
const value = Number(params.get(key));
|
|
||||||
return Number.isInteger(value) && value > 0 ? Math.min(value, max) : fallback;
|
|
||||||
};
|
|
||||||
const date = (key: string) => {
|
|
||||||
const value = params.get(key) || "";
|
|
||||||
return /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
page: integer("page", 1, 100000),
|
|
||||||
pageSize: integer("pageSize", 40, 100),
|
|
||||||
search: (params.get("search") || "").trim().slice(0, 128),
|
|
||||||
type: ["image", "video"].includes(params.get("type") || "") ? params.get("type")! : "all",
|
|
||||||
model: params.get("model") || "",
|
|
||||||
featured: params.get("featured") === "true",
|
|
||||||
startDate: date("startDate"),
|
|
||||||
endDate: date("endDate"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mediaRecordsQuery(params: ReturnType<typeof parseMediaQuery>) {
|
|
||||||
const conditions: SQL[] = [sql`true`];
|
|
||||||
if (params.search)
|
|
||||||
conditions.push(
|
|
||||||
sql`exists (select 1 from "User" u where u.id=m."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`,
|
|
||||||
);
|
|
||||||
if (params.startDate) conditions.push(sql`m."createdAt" >= ${params.startDate}::date`);
|
|
||||||
if (params.endDate)
|
|
||||||
conditions.push(sql`m."createdAt" < ${params.endDate}::date + interval '1 day'`);
|
|
||||||
if (params.model) conditions.push(sql`(m."modelId" = ${params.model} or m."modelId" in (select id::text from "AiModel" where "modelId"=${params.model}))`);
|
|
||||||
const where = sql.join(conditions, sql` and `);
|
|
||||||
const videoFilters: SQL[] = [sql`true`];
|
|
||||||
if (params.startDate)
|
|
||||||
videoFilters.push(sql`v."createdAt">=${params.startDate}::date - interval '8 hours'`);
|
|
||||||
if (params.endDate)
|
|
||||||
videoFilters.push(sql`v."createdAt"<${params.endDate}::date+interval '16 hours'`);
|
|
||||||
if (params.model) videoFilters.push(sql`(v."video_metadata"->>'model'=${params.model} or v."video_metadata"->>'model' in (select id::text from "AiModel" where "modelId"=${params.model}))`);
|
|
||||||
if (params.search)
|
|
||||||
videoFilters.push(
|
|
||||||
sql`exists (select 1 from "User" u where u.id=c."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`,
|
|
||||||
);
|
|
||||||
return sql`
|
|
||||||
with video_candidates as materialized (
|
|
||||||
select v.id, v."chatId", v."createdAt" + interval '8 hours' as "createdAt", v."video_metadata", c."userId",
|
|
||||||
coalesce(nullif(v."permanent_video_url", ''), nullif(v."temporary_video_url", ''),
|
|
||||||
(regexp_match(t.body, '<video[^>]+src="([^"]+)"', 'i'))[1],
|
|
||||||
(regexp_match(t.body, '(https?://[^[:space:]"<>]+\\.mp4[^[:space:]"<>)]*)', 'i'))[1]) as url
|
|
||||||
from "Message_v2" v join "Chat" c on c.id=v."chatId"
|
|
||||||
cross join lateral (select case when jsonb_typeof(v.parts::jsonb)='array'
|
|
||||||
then (select string_agg(p->>'text', E'\\n') from jsonb_array_elements(v.parts::jsonb) p)
|
|
||||||
else v.parts::jsonb #>> '{}' end as body) t
|
|
||||||
where ${params.type !== "image" && !params.featured} and v.role='assistant'
|
|
||||||
and ${sql.join(videoFilters, sql` and `)}
|
|
||||||
and (v."permanent_video_url" is not null or v."temporary_video_url" is not null or v.parts::text ilike '%<video%' or v.parts::text ilike '%.mp4%')
|
|
||||||
), media as not materialized (
|
|
||||||
select g.id, 'image'::text as kind, g."createdAt", g."userId", g."modelId", null::text as url
|
|
||||||
from "GeneratedImage" g where ${params.type !== "video"} and (${!params.featured} or g."isFeatured")
|
|
||||||
union all
|
|
||||||
select id, 'video', "createdAt", "userId", "video_metadata"->>'model', url
|
|
||||||
from video_candidates where url is not null
|
|
||||||
), filtered as not materialized (select * from media m where ${where}),
|
|
||||||
totals as (select count(*)::int as total, count(*) filter(where kind='image')::int as images, count(*) filter(where kind='video')::int as videos from filtered),
|
|
||||||
page_rows as (
|
|
||||||
select * from filtered order by "createdAt" desc, kind, id desc
|
|
||||||
limit ${params.pageSize} offset ${(params.page - 1) * params.pageSize}
|
|
||||||
)
|
|
||||||
select t.total, t.images as "totalImages", t.videos as "totalVideos",
|
|
||||||
coalesce((select jsonb_agg(jsonb_build_object(
|
|
||||||
'id', case when p.kind='video' then 'video-'||p.id::text else p.id::text end,
|
|
||||||
'recordType',p.kind,'userId',p."userId",'userName',u.name,'userEmail',u.email,'userDepartment',u.department,
|
|
||||||
'createdAt',p."createdAt",'modelId',p."modelId",'chatId',coalesce(g."chatId",v."chatId"),
|
|
||||||
'thumbnailUrl',coalesce(p.url,g."thumbnailUrl"),'originalUrl',coalesce(p.url,g."originalUrl"),'videoUrl',p.url,
|
|
||||||
'prompt',coalesce(g.prompt,v."video_metadata"->>'prompt'),'imageSize',coalesce(g."imageSize",v."video_metadata"->>'resolution'),
|
|
||||||
'isFeatured',coalesce(g."isFeatured",false),'featuredCategory',g."featuredCategory",'featuredAt',g."featuredAt" + interval '8 hours'
|
|
||||||
) order by p."createdAt" desc,p.kind,p.id desc)
|
|
||||||
from page_rows p left join "User" u on u.id=p."userId"
|
|
||||||
left join "GeneratedImage" g on p.kind='image' and g.id=p.id
|
|
||||||
left join "Message_v2" v on p.kind='video' and v.id=p.id),'[]'::jsonb) as records
|
|
||||||
from totals t`;
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
// One queue for the records screen: visible previews get four network slots.
|
|
||||||
const waiting: Array<() => void> = [];
|
|
||||||
let active = 0;
|
|
||||||
function pump() {
|
|
||||||
while (active < 4 && waiting.length) waiting.shift()?.();
|
|
||||||
}
|
|
||||||
export function queuePreview(start: (release: () => void) => void) {
|
|
||||||
let released = false;
|
|
||||||
let started = false;
|
|
||||||
const release = () => {
|
|
||||||
if (released) return;
|
|
||||||
released = true;
|
|
||||||
if (started) active--;
|
|
||||||
else {
|
|
||||||
const index = waiting.indexOf(run);
|
|
||||||
if (index >= 0) waiting.splice(index, 1);
|
|
||||||
}
|
|
||||||
pump();
|
|
||||||
};
|
|
||||||
const run = () => {
|
|
||||||
started = true;
|
|
||||||
active++;
|
|
||||||
start(release);
|
|
||||||
};
|
|
||||||
waiting.push(run);
|
|
||||||
pump();
|
|
||||||
// Active fetches release their slot in finally, after abort has settled.
|
|
||||||
return () => {
|
|
||||||
if (!started) release();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
export function formatRecordTime(value: string) {
|
|
||||||
// The admin API normalizes each storage convention to Beijing wall-clock time.
|
|
||||||
return value
|
|
||||||
.replace("T", " ")
|
|
||||||
.replace(/(?:\.\d+)?Z$/, "")
|
|
||||||
.slice(0, 16);
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
|
||||||
import { messageText } from "./replacement-run-recovery";
|
|
||||||
|
|
||||||
type Message = { id: string; role: string; parts?: unknown[] };
|
|
||||||
|
|
||||||
export function getSceneRunResult(messages: Message[], requestMessageId: string) {
|
|
||||||
const index = messages.findIndex((message) => message.id === requestMessageId && message.role === "user");
|
|
||||||
if (index < 0) return null;
|
|
||||||
const replies = messages.slice(index + 1);
|
|
||||||
// A later user turn belongs to another run.
|
|
||||||
if (replies.some((message) => message.role === "user")) return null;
|
|
||||||
const urls = new Set(replies.filter((message) => message.role === "assistant")
|
|
||||||
.flatMap((message) => extractImageUrlsFromText(messageText(message))));
|
|
||||||
return { count: urls.size };
|
|
||||||
}
|
|
||||||
@ -4,9 +4,6 @@ import {
|
|||||||
executeRmbgJob,
|
executeRmbgJob,
|
||||||
fetchImageBufferFromUrl,
|
fetchImageBufferFromUrl,
|
||||||
} from "@/lib/ai/rmbg/service";
|
} from "@/lib/ai/rmbg/service";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
|
||||||
|
|
||||||
const logger = createLogger("box-cutout-service");
|
|
||||||
|
|
||||||
export type NormalizedCropRect = {
|
export type NormalizedCropRect = {
|
||||||
x: number;
|
x: number;
|
||||||
@ -16,7 +13,6 @@ export type NormalizedCropRect = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface ExecuteBoxCutoutJobParams {
|
export interface ExecuteBoxCutoutJobParams {
|
||||||
requestId?: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
cropRect: Partial<NormalizedCropRect>;
|
cropRect: Partial<NormalizedCropRect>;
|
||||||
@ -147,7 +143,6 @@ function applyPadding(
|
|||||||
export async function executeBoxCutoutJob(
|
export async function executeBoxCutoutJob(
|
||||||
params: ExecuteBoxCutoutJobParams,
|
params: ExecuteBoxCutoutJobParams,
|
||||||
): Promise<ExecuteBoxCutoutJobResult> {
|
): Promise<ExecuteBoxCutoutJobResult> {
|
||||||
const requestId = params.requestId || crypto.randomUUID();
|
|
||||||
const imageUrl = String(params.imageUrl || "").trim();
|
const imageUrl = String(params.imageUrl || "").trim();
|
||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
throw new BoxCutoutServiceError("缺少 imageUrl 参数", 400);
|
throw new BoxCutoutServiceError("缺少 imageUrl 参数", 400);
|
||||||
@ -155,7 +150,7 @@ export async function executeBoxCutoutJob(
|
|||||||
|
|
||||||
const cropRect = sanitizeNormalizedCropRect(params.cropRect);
|
const cropRect = sanitizeNormalizedCropRect(params.cropRect);
|
||||||
if (!cropRect) {
|
if (!cropRect) {
|
||||||
throw new BoxCutoutServiceError("框选范围无效,请重新框选需要抠出的区域后提交。", 400);
|
throw new BoxCutoutServiceError("缺少有效的 cropRect 参数", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const paddingRatio = normalizeBoxCutoutPaddingRatio(params.paddingRatio);
|
const paddingRatio = normalizeBoxCutoutPaddingRatio(params.paddingRatio);
|
||||||
@ -175,7 +170,7 @@ export async function executeBoxCutoutJob(
|
|||||||
|
|
||||||
const cropRectPixels = toPixelRect(cropRect, sourceWidth, sourceHeight);
|
const cropRectPixels = toPixelRect(cropRect, sourceWidth, sourceHeight);
|
||||||
if (cropRectPixels.width < MIN_CROP_EDGE_PX || cropRectPixels.height < MIN_CROP_EDGE_PX) {
|
if (cropRectPixels.width < MIN_CROP_EDGE_PX || cropRectPixels.height < MIN_CROP_EDGE_PX) {
|
||||||
throw new BoxCutoutServiceError("框选区域过小,宽、高均须至少 24 像素,请扩大框选范围后重新提交。", 400);
|
throw new BoxCutoutServiceError("框选区域过小,请重新框选", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const paddedCropRect = applyPadding(cropRect, paddingRatio);
|
const paddedCropRect = applyPadding(cropRect, paddingRatio);
|
||||||
@ -189,18 +184,7 @@ export async function executeBoxCutoutJob(
|
|||||||
})
|
})
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
logger.info("crop_prepared", {
|
|
||||||
requestId,
|
|
||||||
sourceNodeId: params.sourceNodeId,
|
|
||||||
sourceWidth,
|
|
||||||
sourceHeight,
|
|
||||||
cropRectPixels,
|
|
||||||
paddedCropRectPixels,
|
|
||||||
inputBytes: croppedBuffer.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await executeRmbgJob({
|
const result = await executeRmbgJob({
|
||||||
requestId,
|
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
imageBuffer: croppedBuffer,
|
imageBuffer: croppedBuffer,
|
||||||
imageBase64: croppedBuffer.toString("base64"),
|
imageBase64: croppedBuffer.toString("base64"),
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
summarizeAgentCapabilities,
|
summarizeAgentCapabilities,
|
||||||
} from "./capability-access";
|
} from "./capability-access";
|
||||||
import type { CanvasAgentTool } from "./loop";
|
import type { CanvasAgentTool } from "./loop";
|
||||||
import { AgentToolRejectedError } from "./protocol";
|
|
||||||
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
|
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
|
||||||
|
|
||||||
export function registerImageCanvasAccessTools(params: {
|
export function registerImageCanvasAccessTools(params: {
|
||||||
@ -60,10 +59,10 @@ export function registerImageCanvasAccessTools(params: {
|
|||||||
(item) => item.workspace === "canvas" && item.id === input.featureId,
|
(item) => item.workspace === "canvas" && item.id === input.featureId,
|
||||||
);
|
);
|
||||||
if (!feature?.authorized || !feature.id.startsWith("canvas.ui."))
|
if (!feature?.authorized || !feature.id.startsWith("canvas.ui."))
|
||||||
throw new AgentToolRejectedError("请使用清单中的图片画布界面入口,媒体能力请使用对应专用工具;本次未打开面板或提交处理。");
|
throw new Error("请使用清单中的图片画布界面入口,媒体能力请使用对应专用工具");
|
||||||
const { nodes } = await readOwnedCanvas(params.userId, params.projectId);
|
const { nodes } = await readOwnedCanvas(params.userId, params.projectId);
|
||||||
if (input.nodeIds.some((nodeId) => !nodes.some((node) => node.id === nodeId)))
|
if (input.nodeIds.some((nodeId) => !nodes.some((node) => node.id === nodeId)))
|
||||||
throw new AgentToolRejectedError("目标节点不属于当前画布,本次未打开面板或提交处理。");
|
throw new Error("目标节点不属于当前画布");
|
||||||
params.emit("data-canvasAgentUiCommand", {
|
params.emit("data-canvasAgentUiCommand", {
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
requestId: `${params.runId}:${id}`,
|
requestId: `${params.runId}:${id}`,
|
||||||
|
|||||||
@ -13,18 +13,13 @@ export type AgentImageParameters = {
|
|||||||
};
|
};
|
||||||
export type AgentImagePreferences = { modelId?: string; parameters?: AgentImageParameters };
|
export type AgentImagePreferences = { modelId?: string; parameters?: AgentImageParameters };
|
||||||
|
|
||||||
export function selectedAgentImageCount(preferences?: AgentImagePreferences) {
|
|
||||||
const count = preferences?.parameters?.imageCount ?? preferences?.parameters?.n;
|
|
||||||
return count === undefined ? undefined : Math.floor(count);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyAgentImagePreferences<T extends Record<string, unknown>>(
|
export function applyAgentImagePreferences<T extends Record<string, unknown>>(
|
||||||
input: T,
|
input: T,
|
||||||
preferences?: AgentImagePreferences,
|
preferences?: AgentImagePreferences,
|
||||||
): T & AgentImageParameters & { modelId?: string } {
|
): T & AgentImageParameters & { modelId?: string } {
|
||||||
const parameters = Object.fromEntries(Object.entries(preferences?.parameters || {})
|
const parameters = Object.fromEntries(Object.entries(preferences?.parameters || {})
|
||||||
.filter(([, value]) => value !== undefined));
|
.filter(([, value]) => value !== undefined));
|
||||||
const count = selectedAgentImageCount(preferences);
|
const count = preferences?.parameters?.imageCount ?? preferences?.parameters?.n;
|
||||||
return {
|
return {
|
||||||
...input,
|
...input,
|
||||||
...parameters,
|
...parameters,
|
||||||
|
|||||||
@ -1,14 +1,5 @@
|
|||||||
import type { AgentCallRecord, AgentRunSnapshot } from "./protocol";
|
import type { AgentCallRecord, AgentRunSnapshot } from "./protocol";
|
||||||
|
|
||||||
export function countAgentGeneratedImages(calls: AgentCallRecord[]) {
|
|
||||||
return calls.reduce((count, call) => {
|
|
||||||
const output = call.output;
|
|
||||||
if (call.status !== "succeeded" || !output?.appliedImagePreferences ||
|
|
||||||
!Array.isArray(output.createdNodeIds)) return count;
|
|
||||||
return count + output.createdNodeIds.length;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A spontaneous download command is not an unfinished image edit. Keep genuine
|
// A spontaneous download command is not an unfinished image edit. Keep genuine
|
||||||
// download requests and all editor panels waiting for their actual interaction.
|
// download requests and all editor panels waiting for their actual interaction.
|
||||||
export function isAncillaryDownload(snapshot: AgentRunSnapshot, call: AgentCallRecord) {
|
export function isAncillaryDownload(snapshot: AgentRunSnapshot, call: AgentCallRecord) {
|
||||||
|
|||||||
@ -5,8 +5,7 @@ import sharp from "sharp";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { readCanvasImages, readOwnedCanvas, type RuntimeImageNode } from "./canvas";
|
import { readCanvasImages, readOwnedCanvas, type RuntimeImageNode } from "./canvas";
|
||||||
import type { AgentRunSnapshot } from "./protocol";
|
import type { AgentRunSnapshot } from "./protocol";
|
||||||
import { countAgentGeneratedImages, finalResultNodeIds, originalSourceNodeIds, requiresCanvasInteraction } from "./results";
|
import { finalResultNodeIds, originalSourceNodeIds, requiresCanvasInteraction } from "./results";
|
||||||
import { selectedAgentImageCount } from "./image-preferences";
|
|
||||||
import { isCompletedSimpleCutout } from "./cutout-completion";
|
import { isCompletedSimpleCutout } from "./cutout-completion";
|
||||||
|
|
||||||
export async function inspectImageAlpha(source: Buffer) {
|
export async function inspectImageAlpha(source: Buffer) {
|
||||||
@ -125,16 +124,14 @@ export async function verifyCanvasAgentResults(params: {
|
|||||||
(call) =>
|
(call) =>
|
||||||
call.status !== "succeeded" ||
|
call.status !== "succeeded" ||
|
||||||
requiresCanvasInteraction(snapshot, call) ||
|
requiresCanvasInteraction(snapshot, call) ||
|
||||||
(typeof call.output?.status === "string" && call.output.status !== "succeeded" &&
|
(typeof call.output?.status === "string" && call.output.status !== "succeeded"),
|
||||||
!(call.output.status === "partial" && call.output.appliedImagePreferences)),
|
|
||||||
);
|
);
|
||||||
if (incomplete.length) return { passed: false, issues: ["部分操作仍在等待交互或未完成"] };
|
if (incomplete.length) return { passed: false, issues: ["部分操作仍在等待交互或未完成"] };
|
||||||
const plan = calls.filter((call) => call.name === "planCanvasTask").at(-1)?.output;
|
const plan = calls.filter((call) => call.name === "planCanvasTask").at(-1)?.output;
|
||||||
if (!plan) return { passed: false, issues: ["缺少明确的任务验收计划"] };
|
if (!plan) return { passed: false, issues: ["缺少明确的任务验收计划"] };
|
||||||
// Intermediate images consumed by later write tools do not count as final deliverables.
|
// Intermediate images consumed by later write tools do not count as final deliverables.
|
||||||
const finalIds = finalResultNodeIds(snapshot);
|
const finalIds = finalResultNodeIds(snapshot);
|
||||||
const expectedCount = snapshot.requirements?.expectedImageCount ?? plan.expectedImageCount ??
|
const expectedCount = snapshot.requirements?.expectedImageCount ?? plan.expectedImageCount;
|
||||||
(countAgentGeneratedImages(calls) > 0 ? selectedAgentImageCount(snapshot.imagePreferences) : undefined);
|
|
||||||
if (expectedCount && finalIds.length !== expectedCount) {
|
if (expectedCount && finalIds.length !== expectedCount) {
|
||||||
return {
|
return {
|
||||||
passed: false,
|
passed: false,
|
||||||
|
|||||||
@ -1,11 +1,5 @@
|
|||||||
import type { AgentVisionExecutor } from "../runtime/agent-vision-failover";
|
import type { AgentVisionExecutor } from "../runtime/agent-vision-failover";
|
||||||
import {
|
import { resolveUserImageModel, applyAgentImagePreferences, agentProviderImageParameters, type AgentImagePreferences } from "./image-preferences";
|
||||||
agentProviderImageParameters,
|
|
||||||
applyAgentImagePreferences,
|
|
||||||
resolveUserImageModel,
|
|
||||||
selectedAgentImageCount,
|
|
||||||
type AgentImagePreferences,
|
|
||||||
} from "./image-preferences";
|
|
||||||
import { tool, type LanguageModel, type ToolSet } from "ai";
|
import { tool, type LanguageModel, type ToolSet } from "ai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createCanvasCapabilityTools } from "@/lib/canvas/capabilities/ai-sdk-tools";
|
import { createCanvasCapabilityTools } from "@/lib/canvas/capabilities/ai-sdk-tools";
|
||||||
@ -24,8 +18,6 @@ import {
|
|||||||
sortStyleProImageModelEntries,
|
sortStyleProImageModelEntries,
|
||||||
} from "@/lib/style-pro/model-options";
|
} from "@/lib/style-pro/model-options";
|
||||||
import type { CanvasAgentLoopPorts, CanvasAgentTool } from "./loop";
|
import type { CanvasAgentLoopPorts, CanvasAgentTool } from "./loop";
|
||||||
import { AgentToolRejectedError, type AgentCallRecord } from "./protocol";
|
|
||||||
import { countAgentGeneratedImages } from "./results";
|
|
||||||
import { readCanvasImages, readOwnedCanvas } from "./canvas";
|
import { readCanvasImages, readOwnedCanvas } from "./canvas";
|
||||||
import { analyzeRuntimeImages } from "./review";
|
import { analyzeRuntimeImages } from "./review";
|
||||||
import { isExplicitDestructiveIntent } from "./capability-access";
|
import { isExplicitDestructiveIntent } from "./capability-access";
|
||||||
@ -45,13 +37,11 @@ export async function buildCanvasRuntimeTools(params: {
|
|||||||
imageSize?: string;
|
imageSize?: string;
|
||||||
preferredModelId?: string;
|
preferredModelId?: string;
|
||||||
imagePreferences?: AgentImagePreferences;
|
imagePreferences?: AgentImagePreferences;
|
||||||
priorCalls?: AgentCallRecord[];
|
|
||||||
emit: (type: string, data: unknown) => void;
|
emit: (type: string, data: unknown) => void;
|
||||||
objective?: string;
|
objective?: string;
|
||||||
operationSelectedNodeIds?: string[];
|
operationSelectedNodeIds?: string[];
|
||||||
}) {
|
}) {
|
||||||
const { nodes } = await readOwnedCanvas(params.userId, params.projectId);
|
const { nodes } = await readOwnedCanvas(params.userId, params.projectId);
|
||||||
const selectedImageCount = selectedAgentImageCount(params.imagePreferences);
|
|
||||||
const imageNodeIds = nodes.filter((node) => node.type === "image").map((node) => node.id);
|
const imageNodeIds = nodes.filter((node) => node.type === "image").map((node) => node.id);
|
||||||
const defaults = params.selectedNodeIds.filter((id) => imageNodeIds.includes(id));
|
const defaults = params.selectedNodeIds.filter((id) => imageNodeIds.includes(id));
|
||||||
const sdk = await createCanvasCapabilityTools({
|
const sdk = await createCanvasCapabilityTools({
|
||||||
@ -138,8 +128,8 @@ export async function buildCanvasRuntimeTools(params: {
|
|||||||
visualCriteria: z.array(z.string().max(200)).max(10),
|
visualCriteria: z.array(z.string().max(200)).max(10),
|
||||||
}),
|
}),
|
||||||
async (input) => ({ ...input,
|
async (input) => ({ ...input,
|
||||||
...(input.expectedImageCount !== undefined && selectedImageCount !== undefined
|
...(input.expectedImageCount !== undefined && params.imagePreferences?.parameters?.imageCount !== undefined
|
||||||
? { expectedImageCount: selectedImageCount } : {}),
|
? { expectedImageCount: params.imagePreferences.parameters.imageCount } : {}),
|
||||||
...(params.imagePreferences?.parameters?.aspectRatio && params.imagePreferences.parameters.aspectRatio !== "auto"
|
...(params.imagePreferences?.parameters?.aspectRatio && params.imagePreferences.parameters.aspectRatio !== "auto"
|
||||||
? { aspectRatio: params.imagePreferences.parameters.aspectRatio } : {}),
|
? { aspectRatio: params.imagePreferences.parameters.aspectRatio } : {}),
|
||||||
}),
|
}),
|
||||||
@ -184,9 +174,7 @@ export async function buildCanvasRuntimeTools(params: {
|
|||||||
async (input) => analyzeRuntimeImages({ ...params, ...input }),
|
async (input) => analyzeRuntimeImages({ ...params, ...input }),
|
||||||
);
|
);
|
||||||
|
|
||||||
let submittedCount = 0;
|
let generatedCount = 0;
|
||||||
let occupiedImageSlots = countAgentGeneratedImages(params.priorCalls ?? []);
|
|
||||||
const countLimitMessage = "本次设定的生成张数已占满,请先查看正在生成或已经生成的结果。";
|
|
||||||
register(
|
register(
|
||||||
"generateCanvasImages",
|
"generateCanvasImages",
|
||||||
"生成图片",
|
"生成图片",
|
||||||
@ -201,8 +189,9 @@ export async function buildCanvasRuntimeTools(params: {
|
|||||||
}),
|
}),
|
||||||
async (toolInput, callId) => {
|
async (toolInput, callId) => {
|
||||||
const input = applyAgentImagePreferences(toolInput, params.imagePreferences);
|
const input = applyAgentImagePreferences(toolInput, params.imagePreferences);
|
||||||
if (selectedImageCount !== undefined && occupiedImageSlots >= selectedImageCount)
|
// Bound per-task spend without introducing any global/model concurrency queue.
|
||||||
throw new AgentToolRejectedError(countLimitMessage);
|
if (generatedCount + input.imageCount > 24)
|
||||||
|
throw new Error("本次任务已达到 24 张生成预算,请检查结果后继续。");
|
||||||
const sources = await readCanvasImages(
|
const sources = await readCanvasImages(
|
||||||
params.userId,
|
params.userId,
|
||||||
params.projectId,
|
params.projectId,
|
||||||
@ -225,17 +214,10 @@ export async function buildCanvasRuntimeTools(params: {
|
|||||||
if (!chosen) throw new Error("系统未配置可用的图片生成模型");
|
if (!chosen) throw new Error("系统未配置可用的图片生成模型");
|
||||||
const imageSize = input.imageSize || params.imageSize || "2K";
|
const imageSize = input.imageSize || params.imageSize || "2K";
|
||||||
const quota = await checkUserImageQuota(params.userId);
|
const quota = await checkUserImageQuota(params.userId);
|
||||||
const points = await getPointsSystemConfig();
|
|
||||||
const user = await getUserById({ id: params.userId });
|
|
||||||
// Reserve the remaining slots before dispatch, including calls still in flight.
|
|
||||||
if (selectedImageCount !== undefined) {
|
|
||||||
input.imageCount = Math.min(input.imageCount, selectedImageCount - occupiedImageSlots);
|
|
||||||
if (input.imageCount < 1) throw new AgentToolRejectedError(countLimitMessage);
|
|
||||||
}
|
|
||||||
if (submittedCount + input.imageCount > 24)
|
|
||||||
throw new AgentToolRejectedError("本次任务已达到 24 张生成预算,请检查结果后继续。");
|
|
||||||
if (!quota.allowed || (quota.remaining !== null && input.imageCount > quota.remaining))
|
if (!quota.allowed || (quota.remaining !== null && input.imageCount > quota.remaining))
|
||||||
throw new Error("本月图片生成配额不足。");
|
throw new Error("本月图片生成配额不足。");
|
||||||
|
const points = await getPointsSystemConfig();
|
||||||
|
const user = await getUserById({ id: params.userId });
|
||||||
const cost =
|
const cost =
|
||||||
getModelGenerationCreditCost(
|
getModelGenerationCreditCost(
|
||||||
{ imageGenConfig: chosen.model.imageGenConfig as DiscountableImageGenConfigLike | null },
|
{ imageGenConfig: chosen.model.imageGenConfig as DiscountableImageGenConfigLike | null },
|
||||||
@ -249,8 +231,7 @@ export async function buildCanvasRuntimeTools(params: {
|
|||||||
(user?.pointsBalance ?? 0) < cost
|
(user?.pointsBalance ?? 0) < cost
|
||||||
)
|
)
|
||||||
throw new Error("积分不足,请充值或联系管理员。");
|
throw new Error("积分不足,请充值或联系管理员。");
|
||||||
submittedCount += input.imageCount;
|
generatedCount += input.imageCount;
|
||||||
occupiedImageSlots += input.imageCount;
|
|
||||||
const result = await generateCanvasImage({
|
const result = await generateCanvasImage({
|
||||||
prompt: input.prompt,
|
prompt: input.prompt,
|
||||||
model: chosen.model.id,
|
model: chosen.model.id,
|
||||||
@ -330,7 +311,6 @@ export async function buildCanvasRuntimeTools(params: {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
occupiedImageSlots += result.images.length - input.imageCount;
|
|
||||||
return {
|
return {
|
||||||
status: result.images.length < input.imageCount ? "partial" : "succeeded",
|
status: result.images.length < input.imageCount ? "partial" : "succeeded",
|
||||||
appliedImagePreferences: { modelId: result.modelRef, imageSize, aspectRatio: input.aspectRatio, imageCount: input.imageCount },
|
appliedImagePreferences: { modelId: result.modelRef, imageSize, aspectRatio: input.aspectRatio, imageCount: input.imageCount },
|
||||||
|
|||||||
@ -72,7 +72,6 @@ export async function processVideoCanvasImage(
|
|||||||
const result = await executeRmbgJob({
|
const result = await executeRmbgJob({
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
imageBuffer: await fetchImageBufferFromUrl(imageUrl),
|
imageBuffer: await fetchImageBufferFromUrl(imageUrl),
|
||||||
inputImageUrl: imageUrl,
|
|
||||||
sourceImageUrl: imageUrl,
|
sourceImageUrl: imageUrl,
|
||||||
prompt: params.prompt,
|
prompt: params.prompt,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
|
|
||||||
|
|
||||||
import { createOpenAI } from "@ai-sdk/openai";
|
import { createOpenAI } from "@ai-sdk/openai";
|
||||||
import {
|
import {
|
||||||
@ -419,7 +418,7 @@ export async function getDynamicModel(
|
|||||||
): Promise<LanguageModel | null> {
|
): Promise<LanguageModel | null> {
|
||||||
try {
|
try {
|
||||||
const model = await getAiModelById({ id: modelId });
|
const model = await getAiModelById({ id: modelId });
|
||||||
if (!model || !model.isEnabled || (model.supportsImageGen && isImageModelBalanceBlocked(model))) {
|
if (!model || !model.isEnabled) {
|
||||||
logger.warn("model unavailable", { modelId });
|
logger.warn("model unavailable", { modelId });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
* - qwen-image-edit-plus 系列:支持 1-6 张输出,自定义分辨率,提示词优化
|
* - qwen-image-edit-plus 系列:支持 1-6 张输出,自定义分辨率,提示词优化
|
||||||
* - qwen-image-edit:仅支持 1 张输出
|
* - qwen-image-edit:仅支持 1 张输出
|
||||||
*
|
*
|
||||||
* API 文档:https://help.aliyun.com/en/model-studio/qwen-image-generation-and-editing-api-reference
|
* API 文档:https://help.aliyun.com/zh/model-studio/developer-reference/qwen-vl-plus
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
@ -14,7 +14,6 @@ import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification"
|
|||||||
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
|
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
|
||||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
|
|
||||||
import {
|
import {
|
||||||
isAlibabaQwenImage3ModelId,
|
isAlibabaQwenImage3ModelId,
|
||||||
resolveAlibabaImageAsyncEndpoint,
|
resolveAlibabaImageAsyncEndpoint,
|
||||||
@ -47,8 +46,6 @@ interface AlibabaImageResponse {
|
|||||||
output?: {
|
output?: {
|
||||||
task_id?: string;
|
task_id?: string;
|
||||||
task_status?: string;
|
task_status?: string;
|
||||||
code?: string;
|
|
||||||
message?: string;
|
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
finish_reason: string;
|
finish_reason: string;
|
||||||
message: {
|
message: {
|
||||||
@ -125,21 +122,11 @@ async function fetchImageBufferWithRetry(
|
|||||||
timeoutMs = ALIBABA_IMAGE_FETCH_TIMEOUT_MS,
|
timeoutMs = ALIBABA_IMAGE_FETCH_TIMEOUT_MS,
|
||||||
attempts = 2
|
attempts = 2
|
||||||
): Promise<{ buffer: Buffer; contentType: string | null }> {
|
): Promise<{ buffer: Buffer; contentType: string | null }> {
|
||||||
if (imageUrl.startsWith("data:image/")) {
|
|
||||||
const match = imageUrl.match(/^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i);
|
|
||||||
if (!match) throw new Error("输入图片数据格式无效");
|
|
||||||
return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] };
|
|
||||||
}
|
|
||||||
let sourceUrl = imageUrl;
|
|
||||||
if (normalizeLegacyAliyunStorageUrl(sourceUrl)) {
|
|
||||||
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
|
|
||||||
sourceUrl = await resolveLegacyAliyunStorageUrl(sourceUrl);
|
|
||||||
}
|
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
const ctx = createAbortSignal(timeoutMs);
|
const ctx = createAbortSignal(timeoutMs);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(sourceUrl, {
|
const response = await fetch(imageUrl, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
signal: ctx.signal,
|
signal: ctx.signal,
|
||||||
@ -181,7 +168,7 @@ function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number | null> {
|
async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number | null> {
|
||||||
if (!/^(?:https?:\/\/|data:image\/)/i.test(imageUrl)) return null;
|
if (!/^https?:\/\//i.test(imageUrl)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
||||||
@ -199,7 +186,7 @@ async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number |
|
|||||||
async function inferImageDimensionsFromImageUrl(
|
async function inferImageDimensionsFromImageUrl(
|
||||||
imageUrl: string,
|
imageUrl: string,
|
||||||
): Promise<{ width: number; height: number; aspectRatio: number } | null> {
|
): Promise<{ width: number; height: number; aspectRatio: number } | null> {
|
||||||
if (!/^(?:https?:\/\/|data:image\/)/i.test(imageUrl)) return null;
|
if (!/^https?:\/\//i.test(imageUrl)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
||||||
@ -315,14 +302,6 @@ async function prepareAlibabaInputImageUrl(
|
|||||||
maxSide: ALIBABA_MAX_INPUT_SIDE,
|
maxSide: ALIBABA_MAX_INPUT_SIDE,
|
||||||
modelId,
|
modelId,
|
||||||
});
|
});
|
||||||
if (isAlibabaQwenImage3ModelId(modelId)) {
|
|
||||||
if (prepared.buffer.length > maxInputBytes) {
|
|
||||||
throw new Error("输入图片超过模型大小限制,请先压缩图片后重试");
|
|
||||||
}
|
|
||||||
const format = prepared.transformed ? prepared.extension : metadata.format;
|
|
||||||
const mime = format === "jpg" || format === "jpeg" ? "image/jpeg" : `image/${format}`;
|
|
||||||
return `data:${mime};base64,${prepared.buffer.toString("base64")}`;
|
|
||||||
}
|
|
||||||
if (!prepared.transformed) {
|
if (!prepared.transformed) {
|
||||||
return normalizedUrl;
|
return normalizedUrl;
|
||||||
}
|
}
|
||||||
@ -586,10 +565,8 @@ async function sendAlibabaImageAsyncRequest(params: {
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
|
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
|
||||||
const reason = task.output?.message || task.message || `阿里图片任务${taskStatus || "失败"}`;
|
|
||||||
const code = task.output?.code || task.code;
|
|
||||||
throw new AlibabaImageTerminalTaskError(
|
throw new AlibabaImageTerminalTaskError(
|
||||||
`${reason}${code ? `(${code})` : ""}(task_id: ${taskId})`,
|
task.message || `阿里图片任务${taskStatus || "失败"}(task_id: ${taskId})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lastPollError = null;
|
lastPollError = null;
|
||||||
@ -792,15 +769,16 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
|
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
|
||||||
const preparedImageUrls = await Promise.all(
|
const preparedImageUrls: string[] = [];
|
||||||
selectedImageUrls.map((imageUrl) =>
|
for (const imageUrl of selectedImageUrls) {
|
||||||
prepareAlibabaInputImageUrl(
|
preparedImageUrls.push(
|
||||||
|
await prepareAlibabaInputImageUrl(
|
||||||
imageUrl,
|
imageUrl,
|
||||||
activeModelId,
|
activeModelId,
|
||||||
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
|
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。
|
// qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。
|
||||||
if (
|
if (
|
||||||
|
|||||||
@ -48,7 +48,6 @@ import {
|
|||||||
VIP98K_REFERENCE_STAGING_KEY_PREFIX,
|
VIP98K_REFERENCE_STAGING_KEY_PREFIX,
|
||||||
} from "@/lib/ai/vip98k-reference-staging";
|
} from "@/lib/ai/vip98k-reference-staging";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
|
||||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||||
import {
|
import {
|
||||||
IMAGE_UPLOAD_MAX_BYTES,
|
IMAGE_UPLOAD_MAX_BYTES,
|
||||||
@ -1024,77 +1023,6 @@ function getConfiguredPollSeconds(config: Vip98kModelRuntimeConfig, key: string,
|
|||||||
return Math.min(Math.max(value, 1), 3600);
|
return Math.min(Math.max(value, 1), 3600);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSynchronousImagePath(path: string) {
|
|
||||||
return /^\/v1\/images\/(?:generations|edits)\/?$/.test(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function recoverImageSubmission(params: {
|
|
||||||
apiKey: string;
|
|
||||||
baseURL: string;
|
|
||||||
modelId: string;
|
|
||||||
path: string;
|
|
||||||
idempotencyKey: string;
|
|
||||||
pollIntervalMs: number;
|
|
||||||
maxPollMs: number;
|
|
||||||
}) {
|
|
||||||
if (!isSynchronousImagePath(params.path)) return null;
|
|
||||||
const startedAt = Date.now();
|
|
||||||
const deadline = startedAt + params.maxPollMs;
|
|
||||||
logger.info("recovering original image request", { modelId: params.modelId });
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
const lookup = await fetch(`${params.baseURL}/api/external/requests/by-idempotency-key`, {
|
|
||||||
cache: "no-store",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${params.apiKey}`,
|
|
||||||
"Idempotency-Key": params.idempotencyKey,
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(Math.min(60_000, deadline - Date.now())),
|
|
||||||
});
|
|
||||||
if ([401, 403, 404, 410].includes(lookup.status)) return null;
|
|
||||||
if (lookup.ok) {
|
|
||||||
const state = toRecord(await parseJsonResponse(lookup));
|
|
||||||
if (!state || !["processing", "succeeded"].includes(String(state.status))) return null;
|
|
||||||
if (state.status === "succeeded") {
|
|
||||||
if (state.result_available !== true || !state.request_id) return null;
|
|
||||||
const result = await fetch(
|
|
||||||
`${params.baseURL}/api/external/requests/${encodeURIComponent(String(state.request_id))}/result`,
|
|
||||||
{
|
|
||||||
cache: "no-store",
|
|
||||||
headers: { Authorization: `Bearer ${params.apiKey}` },
|
|
||||||
signal: AbortSignal.timeout(Math.max(1, Math.min(60_000, deadline - Date.now()))),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if ([401, 403, 404, 410].includes(result.status)) return null;
|
|
||||||
if (result.ok) {
|
|
||||||
const payload = await parseJsonResponse(result);
|
|
||||||
const outputs = collectOutputUrls(payload);
|
|
||||||
if (!outputs.length) {
|
|
||||||
throw new ProviderTaskUncertainError("原生图请求已完成,但未取回图片结果,请勿重复生成");
|
|
||||||
}
|
|
||||||
logger.info("original image request recovered", {
|
|
||||||
modelId: params.modelId,
|
|
||||||
providerRequestId: state.request_id,
|
|
||||||
outputCount: outputs.length,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
});
|
|
||||||
return { payload, taskId: extractTaskId(payload), resultPath: extractResultPath(payload), outputs };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ProviderTaskUncertainError) throw error;
|
|
||||||
}
|
|
||||||
await delay(Math.max(0, Math.min(params.pollIntervalMs, deadline - Date.now())));
|
|
||||||
}
|
|
||||||
logger.warn("original image request recovery timed out", {
|
|
||||||
modelId: params.modelId,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitTask(params: {
|
async function submitTask(params: {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseURL?: string | null;
|
baseURL?: string | null;
|
||||||
@ -1107,8 +1035,6 @@ async function submitTask(params: {
|
|||||||
submissionTimeoutMs: number;
|
submissionTimeoutMs: number;
|
||||||
maxSubmissionAttempts: number;
|
maxSubmissionAttempts: number;
|
||||||
submissionRetryBaseDelayMs: number;
|
submissionRetryBaseDelayMs: number;
|
||||||
pollIntervalMs: number;
|
|
||||||
maxPollMs: number;
|
|
||||||
}) {
|
}) {
|
||||||
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
|
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
|
||||||
const path = String(params.path || "/api/external/task/generate").trim();
|
const path = String(params.path || "/api/external/task/generate").trim();
|
||||||
@ -1172,9 +1098,7 @@ async function submitTask(params: {
|
|||||||
signal: AbortSignal.timeout(params.submissionTimeoutMs),
|
signal: AbortSignal.timeout(params.submissionTimeoutMs),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const recovered = await recoverImageSubmission({ ...params, baseURL, path, idempotencyKey });
|
if (attempt >= maxAttempts) {
|
||||||
if (recovered) return recovered;
|
|
||||||
if (isSynchronousImagePath(path) || attempt >= maxAttempts) {
|
|
||||||
logger.error("provider submission transport failed", {
|
logger.error("provider submission transport failed", {
|
||||||
modelId: params.modelId,
|
modelId: params.modelId,
|
||||||
path,
|
path,
|
||||||
@ -1220,14 +1144,6 @@ async function submitTask(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if ([502, 504, 524].includes(response.status)) {
|
|
||||||
const recovered = await recoverImageSubmission({ ...params, baseURL, path, idempotencyKey });
|
|
||||||
if (recovered) return recovered;
|
|
||||||
if (isSynchronousImagePath(path)) {
|
|
||||||
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${getUserFacingImageGenerationErrorMessage({ message: extractErrorMessage(payload), status: response.status })} (HTTP ${response.status})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage = extractErrorMessage(payload);
|
const errorMessage = extractErrorMessage(payload);
|
||||||
const stagedMediaRetryDelayMs = getVip98kStagedMediaRetryDelay({
|
const stagedMediaRetryDelayMs = getVip98kStagedMediaRetryDelay({
|
||||||
status: response.status,
|
status: response.status,
|
||||||
@ -1272,9 +1188,9 @@ async function submitTask(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ((response.status === 409 && /idempotency\s+key.*(?:still\s+in\s+progress|processing)/i.test(errorMessage))
|
if ((response.status === 409 && /idempotency\s+key.*(?:still\s+in\s+progress|processing)/i.test(errorMessage))
|
||||||
|| [502, 504, 524].includes(response.status)
|
|| [502, 504].includes(response.status)
|
||||||
|| /pending\s+safe\s+reconciliation|request\s+outcome\s+(?:is\s+)?unknown|final\s+outcome\s+could\s+not\s+be\s+confirmed/i.test(errorMessage))
|
|| /pending\s+safe\s+reconciliation|request\s+outcome\s+(?:is\s+)?unknown|final\s+outcome\s+could\s+not\s+be\s+confirmed/i.test(errorMessage))
|
||||||
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${getUserFacingImageGenerationErrorMessage({ message: errorMessage, status: response.status })} (HTTP ${response.status})`);
|
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${errorMessage} (HTTP ${response.status})`);
|
||||||
throw new Error(`${errorMessage} (HTTP ${response.status})`);
|
throw new Error(`${errorMessage} (HTTP ${response.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1345,7 +1261,6 @@ export async function submitVip98kExternalTask(params: {
|
|||||||
const configuredInvocations = getVip98kInvocations(taskParams.modelConfig);
|
const configuredInvocations = getVip98kInvocations(taskParams.modelConfig);
|
||||||
const invocation = selectVip98kTaskInvocation({
|
const invocation = selectVip98kTaskInvocation({
|
||||||
modelId: taskParams.modelId,
|
modelId: taskParams.modelId,
|
||||||
kind: taskParams.kind,
|
|
||||||
configuredInvocation: configuredInvocations[0] || null,
|
configuredInvocation: configuredInvocations[0] || null,
|
||||||
configuredInvocations,
|
configuredInvocations,
|
||||||
imageCount: imageUrls.length,
|
imageCount: imageUrls.length,
|
||||||
@ -1394,12 +1309,6 @@ export async function submitVip98kExternalTask(params: {
|
|||||||
),
|
),
|
||||||
maxSubmissionAttempts: params.durable ? 1 : runtimeConfig.maxSubmissionAttempts,
|
maxSubmissionAttempts: params.durable ? 1 : runtimeConfig.maxSubmissionAttempts,
|
||||||
submissionRetryBaseDelayMs: runtimeConfig.submissionRetryBaseDelayMs,
|
submissionRetryBaseDelayMs: runtimeConfig.submissionRetryBaseDelayMs,
|
||||||
pollIntervalMs: getConfiguredPollSeconds(
|
|
||||||
taskParams.modelConfig, "pollIntervalSeconds", DEFAULT_POLL_INTERVAL_SECONDS,
|
|
||||||
) * 1000,
|
|
||||||
maxPollMs: getConfiguredPollSeconds(
|
|
||||||
taskParams.modelConfig, "maxPollSeconds", DEFAULT_MAX_POLL_SECONDS,
|
|
||||||
) * 1000,
|
|
||||||
});
|
});
|
||||||
const resultPath = submitted.resultPath || invocation?.resultPath || "";
|
const resultPath = submitted.resultPath || invocation?.resultPath || "";
|
||||||
return {
|
return {
|
||||||
@ -1529,7 +1438,7 @@ function normalizeDuration(value: unknown) {
|
|||||||
return Math.round(numeric);
|
return Math.round(numeric);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) {
|
function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) {
|
||||||
const videoParams = params.videoParams || {};
|
const videoParams = params.videoParams || {};
|
||||||
const imageParams = params.imageParams || {};
|
const imageParams = params.imageParams || {};
|
||||||
const promptTitle = params.prompt.split(/\s+/).slice(0, 8).join(" ").slice(0, 80);
|
const promptTitle = params.prompt.split(/\s+/).slice(0, 8).join(" ").slice(0, 80);
|
||||||
@ -1539,9 +1448,6 @@ export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvoca
|
|||||||
body.displayName = promptTitle || params.modelId;
|
body.displayName = promptTitle || params.modelId;
|
||||||
}
|
}
|
||||||
if ("prompt" in body || !invocation) body.prompt = params.prompt;
|
if ("prompt" in body || !invocation) body.prompt = params.prompt;
|
||||||
if (params.kind === "image" && invocationSupportsBodyField(invocation, "n")) {
|
|
||||||
body.n = imageParams.imageCount ?? imageParams.n ?? 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const aspectRatio =
|
const aspectRatio =
|
||||||
normalizeRatio(videoParams.ratio) ||
|
normalizeRatio(videoParams.ratio) ||
|
||||||
@ -1791,7 +1697,7 @@ async function runTask(params: Vip98kTaskParams) {
|
|||||||
}
|
}
|
||||||
const imageSize = String(params.imageParams?.imageSize || "2K").trim() || "2K";
|
const imageSize = String(params.imageParams?.imageSize || "2K").trim() || "2K";
|
||||||
const archiveStartedAt = Date.now();
|
const archiveStartedAt = Date.now();
|
||||||
const archived = await Promise.all(outputUrls.map(archiveOutputImage));
|
const archived = await Promise.all(outputUrls.slice(0, 1).map(archiveOutputImage));
|
||||||
logger.info("result archive completed", {
|
logger.info("result archive completed", {
|
||||||
modelId: params.modelId,
|
modelId: params.modelId,
|
||||||
outputCount: archived.length,
|
outputCount: archived.length,
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage } from "@/lib/db/schema";
|
import { generatedImage } from "@/lib/db/schema";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
const logger = createLogger("rmbg-service");
|
const logger = createLogger("rmbg-service");
|
||||||
@ -28,7 +27,6 @@ type RmbgConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ExecuteRmbgJobParams = {
|
export type ExecuteRmbgJobParams = {
|
||||||
requestId?: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
imageBuffer: Buffer;
|
imageBuffer: Buffer;
|
||||||
imageBase64?: string;
|
imageBase64?: string;
|
||||||
@ -37,8 +35,6 @@ export type ExecuteRmbgJobParams = {
|
|||||||
chatId?: string | null;
|
chatId?: string | null;
|
||||||
messageId?: string | null;
|
messageId?: string | null;
|
||||||
skipGallerySave?: boolean;
|
skipGallerySave?: boolean;
|
||||||
// 仅当 imageBuffer 与 URL 对应的输入文件一致时提供。
|
|
||||||
inputImageUrl?: string | null;
|
|
||||||
sourceImageUrl?: string | null;
|
sourceImageUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -57,7 +53,7 @@ export class RmbgServiceError extends Error {
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
|
|
||||||
constructor(message: string, status = 500, detail?: string) {
|
constructor(message: string, status = 500, detail?: string) {
|
||||||
super(detail && detail !== message ? `${message}:${detail}` : message);
|
super(message);
|
||||||
this.name = "RmbgServiceError";
|
this.name = "RmbgServiceError";
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.detail = detail;
|
this.detail = detail;
|
||||||
@ -65,11 +61,7 @@ export class RmbgServiceError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
// https://picwish.cn/background-removal-api-doc
|
|
||||||
const AOS_MAX_RESOLUTION = 4096;
|
const AOS_MAX_RESOLUTION = 4096;
|
||||||
const AOS_MAX_INPUT_BYTES = 50 * 1024 * 1024;
|
|
||||||
// The shared AOS state -5 documents a separate limit for image_url downloads.
|
|
||||||
const AOS_MAX_URL_INPUT_BYTES = 30 * 1024 * 1024;
|
|
||||||
const AOS_CREATE_TIMEOUT_MS = 120_000;
|
const AOS_CREATE_TIMEOUT_MS = 120_000;
|
||||||
const AOS_POLL_TIMEOUT_MS = 60_000;
|
const AOS_POLL_TIMEOUT_MS = 60_000;
|
||||||
const AOS_DOWNLOAD_TIMEOUT_MS = 60_000;
|
const AOS_DOWNLOAD_TIMEOUT_MS = 60_000;
|
||||||
@ -155,18 +147,6 @@ const withTimeout = async <T,>(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function readAosResult<T>(read: (signal: AbortSignal) => Promise<T>, timeoutMs: number): Promise<T> {
|
|
||||||
for (let attempt = 0; ; attempt++) {
|
|
||||||
try {
|
|
||||||
return await withTimeout(read, timeoutMs);
|
|
||||||
} catch (error) {
|
|
||||||
const name = error instanceof Error ? error.name : "";
|
|
||||||
if (attempt >= 2 || (name !== "TypeError" && name !== "AbortError")) throw error;
|
|
||||||
await sleep(1000 * (attempt + 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAosFriendlyErrorMessage(detail: string): string | null {
|
function getAosFriendlyErrorMessage(detail: string): string | null {
|
||||||
const resolutionMatch = detail.match(/resolution excess,\s*width=(\d+),\s*height=(\d+)/i);
|
const resolutionMatch = detail.match(/resolution excess,\s*width=(\d+),\s*height=(\d+)/i);
|
||||||
if (resolutionMatch) {
|
if (resolutionMatch) {
|
||||||
@ -184,44 +164,52 @@ function getAosFriendlyErrorMessage(detail: string): string | null {
|
|||||||
async function prepareImageForAos(
|
async function prepareImageForAos(
|
||||||
imageBuffer: Buffer
|
imageBuffer: Buffer
|
||||||
): Promise<{ buffer: Buffer; mimeType: string; filename: string }> {
|
): Promise<{ buffer: Buffer; mimeType: string; filename: string }> {
|
||||||
if (imageBuffer.length > AOS_MAX_INPUT_BYTES) {
|
|
||||||
throw new RmbgServiceError(
|
|
||||||
`抠图参考图文件大小超限(${(imageBuffer.length / 1024 / 1024).toFixed(2)} MB),当前 AOS 上游最大支持 50 MB。`,
|
|
||||||
400
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let metadata: import("sharp").Metadata;
|
|
||||||
try {
|
try {
|
||||||
const sharp = await import("sharp");
|
const sharp = await import("sharp");
|
||||||
metadata = await sharp.default(imageBuffer, { limitInputPixels: false }).metadata();
|
const image = sharp.default(imageBuffer);
|
||||||
} catch (error) {
|
const metadata = await image.metadata();
|
||||||
// AOS also accepts BMP/RAW/RGB formats that Sharp cannot identify.
|
const width = metadata.width;
|
||||||
logger.warn("aos_image_metadata_unavailable", error);
|
const height = metadata.height;
|
||||||
return { buffer: imageBuffer, mimeType: "application/octet-stream", filename: "image" };
|
const format = (metadata.format || "png").toLowerCase();
|
||||||
}
|
|
||||||
|
|
||||||
const { width, height } = metadata;
|
|
||||||
if (width && height && (width > AOS_MAX_RESOLUTION || height > AOS_MAX_RESOLUTION)) {
|
|
||||||
throw new RmbgServiceError(
|
|
||||||
`抠图参考图分辨率超限(${width}×${height} 像素),当前 AOS 上游要求宽、高均不超过 ${AOS_MAX_RESOLUTION} 像素。`,
|
|
||||||
400
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const format = metadata.format || "";
|
|
||||||
if (["gif", "svg", "heif", "avif", "jp2", "jxl"].includes(format)) {
|
|
||||||
const name = format === "heif" ? "HEIF/AVIF" : format.toUpperCase();
|
|
||||||
throw new RmbgServiceError(`抠图参考图格式不支持(${name}),当前 AOS 上游不支持此格式。`, 400);
|
|
||||||
}
|
|
||||||
const ext = format === "jpeg" ? "jpg" : format;
|
const ext = format === "jpeg" ? "jpg" : format;
|
||||||
|
const filenameExt =
|
||||||
|
ext === "jpg" || ext === "png" || ext === "webp" || ext === "tiff" ? ext : "png";
|
||||||
|
const mimeType =
|
||||||
|
ext === "jpg"
|
||||||
|
? "image/jpeg"
|
||||||
|
: ext === "webp"
|
||||||
|
? "image/webp"
|
||||||
|
: ext === "tiff"
|
||||||
|
? "image/tiff"
|
||||||
|
: "image/png";
|
||||||
|
|
||||||
|
if (!width || !height || (width <= AOS_MAX_RESOLUTION && height <= AOS_MAX_RESOLUTION)) {
|
||||||
return {
|
return {
|
||||||
buffer: imageBuffer,
|
buffer: imageBuffer,
|
||||||
mimeType: format ? `image/${format}` : "application/octet-stream",
|
mimeType,
|
||||||
filename: ext ? `image.${ext}` : "image",
|
filename: `image.${filenameExt}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resizedBuffer = await image
|
||||||
|
.resize(AOS_MAX_RESOLUTION, AOS_MAX_RESOLUTION, {
|
||||||
|
fit: "inside",
|
||||||
|
withoutEnlargement: true,
|
||||||
|
})
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: resizedBuffer,
|
||||||
|
mimeType,
|
||||||
|
filename: `image.${filenameExt}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("aos_image_preprocess_failed", error);
|
||||||
|
return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function processWithRMBG(
|
async function processWithRMBG(
|
||||||
imageBase64: string,
|
imageBase64: string,
|
||||||
config: { apiUrl: string; apiKey?: string }
|
config: { apiUrl: string; apiKey?: string }
|
||||||
@ -381,38 +369,20 @@ async function processWithAosSegmentation(
|
|||||||
foregroundType?: "" | "person" | "object" | "stamp";
|
foregroundType?: "" | "person" | "object" | "stamp";
|
||||||
crop?: 0 | 1;
|
crop?: 0 | 1;
|
||||||
format?: "png" | "jpg";
|
format?: "png" | "jpg";
|
||||||
inputImageUrl?: string | null;
|
|
||||||
requestId?: string;
|
|
||||||
}
|
}
|
||||||
): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> {
|
): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const baseUrl = (config.apiBaseUrl || "https://techsz.aoscdn.com").replace(/\/+$/, "");
|
const baseUrl = (config.apiBaseUrl || "https://techsz.aoscdn.com").replace(/\/+$/, "");
|
||||||
const createUrl = `${baseUrl}/api/tasks/visual/segmentation`;
|
const createUrl = `${baseUrl}/api/tasks/visual/segmentation`;
|
||||||
const headers = { "X-API-KEY": config.apiKey };
|
const headers = { "X-API-KEY": config.apiKey };
|
||||||
let stage = "create";
|
|
||||||
let providerTaskId: string | undefined;
|
|
||||||
const sourceImage = await prepareImageForAos(imageBuffer);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const normalizedImage = await prepareImageForAos(imageBuffer);
|
||||||
const createForm = new FormData();
|
const createForm = new FormData();
|
||||||
let publicSourceUrl: string | null = null;
|
const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], {
|
||||||
if (config.inputImageUrl && imageBuffer.length <= AOS_MAX_URL_INPUT_BYTES) {
|
type: normalizedImage.mimeType,
|
||||||
try {
|
|
||||||
const source = new URL(config.inputImageUrl);
|
|
||||||
const cdn = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "");
|
|
||||||
if (["http:", "https:"].includes(source.protocol) && source.hostname === cdn.hostname) {
|
|
||||||
publicSourceUrl = source.toString();
|
|
||||||
}
|
|
||||||
} catch { /* Uploaded files and other sources use the file API. */ }
|
|
||||||
}
|
|
||||||
if (publicSourceUrl) {
|
|
||||||
createForm.append("image_url", publicSourceUrl);
|
|
||||||
} else {
|
|
||||||
const imageBlob = new Blob([new Uint8Array(sourceImage.buffer)], {
|
|
||||||
type: sourceImage.mimeType,
|
|
||||||
});
|
});
|
||||||
createForm.append("image_file", imageBlob, sourceImage.filename);
|
createForm.append("image_file", imageBlob, normalizedImage.filename);
|
||||||
}
|
|
||||||
createForm.append("sync", "0");
|
createForm.append("sync", "0");
|
||||||
createForm.append("return_type", "1");
|
createForm.append("return_type", "1");
|
||||||
createForm.append("output_type", "2");
|
createForm.append("output_type", "2");
|
||||||
@ -422,9 +392,6 @@ async function processWithAosSegmentation(
|
|||||||
createForm.append("type", config.foregroundType);
|
createForm.append("type", config.foregroundType);
|
||||||
}
|
}
|
||||||
|
|
||||||
const createStartedAt = Date.now();
|
|
||||||
logger.info("aos_task_create_started", { requestId: config.requestId, sourceMode: publicSourceUrl ? "url" : "file", inputBytes: imageBuffer.length, uploadBytes: publicSourceUrl ? 0 : sourceImage.buffer.length });
|
|
||||||
|
|
||||||
const createResponse = await withTimeout(
|
const createResponse = await withTimeout(
|
||||||
(signal) =>
|
(signal) =>
|
||||||
fetch(createUrl, {
|
fetch(createUrl, {
|
||||||
@ -444,37 +411,28 @@ async function processWithAosSegmentation(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `AOS任务创建失败(HTTP ${createResponse.status}): ${createPayload?.message || createResponse.statusText}`,
|
error: `AOS任务创建失败: ${createPayload?.message || createResponse.statusText}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskId = createPayload.data.task_id as string;
|
const taskId = createPayload.data.task_id as string;
|
||||||
providerTaskId = taskId;
|
|
||||||
logger.info("aos_task_created", { requestId: config.requestId, taskId, createMs: Date.now() - createStartedAt });
|
|
||||||
const taskUrl = `${createUrl}/${taskId}`;
|
const taskUrl = `${createUrl}/${taskId}`;
|
||||||
const maxPollCount = 60;
|
const maxPollCount = 60;
|
||||||
|
|
||||||
for (let pollCount = 0; pollCount < maxPollCount; pollCount += 1) {
|
for (let pollCount = 0; pollCount < maxPollCount; pollCount += 1) {
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
stage = "poll";
|
|
||||||
|
|
||||||
const pollResponse = await readAosResult(
|
const pollResponse = await withTimeout(
|
||||||
async (signal) => {
|
(signal) =>
|
||||||
const response = await fetch(taskUrl, {
|
fetch(taskUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers,
|
headers,
|
||||||
signal,
|
signal,
|
||||||
});
|
}),
|
||||||
const payload = await response.json().catch((error) => {
|
|
||||||
if (error instanceof Error && error.name !== "SyntaxError") throw error;
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
return { ok: response.ok, status: response.status, statusText: response.statusText, payload };
|
|
||||||
},
|
|
||||||
AOS_POLL_TIMEOUT_MS
|
AOS_POLL_TIMEOUT_MS
|
||||||
);
|
);
|
||||||
|
|
||||||
const pollPayload = pollResponse.payload;
|
const pollPayload = await pollResponse.json().catch(() => null);
|
||||||
if (!pollResponse.ok || !pollPayload?.data) {
|
if (!pollResponse.ok || !pollPayload?.data) {
|
||||||
logger.warn("aos_task_poll_failed", {
|
logger.warn("aos_task_poll_failed", {
|
||||||
hasPayload: pollPayload != null,
|
hasPayload: pollPayload != null,
|
||||||
@ -482,7 +440,7 @@ async function processWithAosSegmentation(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `AOS任务查询失败(HTTP ${pollResponse.status},任务 ${taskId}): ${pollPayload?.message || pollResponse.statusText}`,
|
error: `AOS任务查询失败: ${pollPayload?.message || pollResponse.statusText}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -496,15 +454,12 @@ async function processWithAosSegmentation(
|
|||||||
pollCount,
|
pollCount,
|
||||||
taskId,
|
taskId,
|
||||||
});
|
});
|
||||||
stage = "download";
|
const imageResponse = await withTimeout(
|
||||||
const imageResponse = await readAosResult(
|
(signal) =>
|
||||||
async (signal) => {
|
fetch(imageUrl, {
|
||||||
const response = await fetch(imageUrl, {
|
|
||||||
method: "GET",
|
method: "GET",
|
||||||
signal,
|
signal,
|
||||||
});
|
}),
|
||||||
return { ok: response.ok, status: response.status, statusText: response.statusText, buffer: response.ok ? Buffer.from(await response.arrayBuffer()) : null };
|
|
||||||
},
|
|
||||||
AOS_DOWNLOAD_TIMEOUT_MS
|
AOS_DOWNLOAD_TIMEOUT_MS
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -515,11 +470,11 @@ async function processWithAosSegmentation(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `AOS结果下载失败(HTTP ${imageResponse.status},任务 ${taskId}): ${imageResponse.statusText}`,
|
error: `AOS结果下载失败: ${imageResponse.status} ${imageResponse.statusText}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultBuffer = imageResponse.buffer!;
|
const resultBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
imageBuffer: resultBuffer,
|
imageBuffer: resultBuffer,
|
||||||
@ -535,30 +490,19 @@ async function processWithAosSegmentation(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `AOS任务失败(状态 ${state},任务 ${taskId}): ${errMessage || stateDetail || pollPayload?.message || "未提供详情"}`,
|
error: `AOS任务失败: ${errMessage || stateDetail || pollPayload?.message || "状态异常"}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn("aos_task_timeout", { requestId: config.requestId, taskId, maxPollCount });
|
logger.warn("aos_task_timeout", { maxPollCount });
|
||||||
return { success: false, error: `AOS查询任务超时,结果尚未确认(任务 ${taskId})` };
|
return { success: false, error: "AOS任务超时(最多60秒)" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
logger.warn("aos_request_timeout", { requestId: config.requestId, stage, taskId: providerTaskId, inputBytes: imageBuffer.length });
|
return { success: false, error: "AOS请求超时,请稍后重试" };
|
||||||
const message = stage === "create"
|
|
||||||
? "AOS创建任务超时(120秒),结果尚未确认"
|
|
||||||
: stage === "poll"
|
|
||||||
? `AOS查询任务超时,结果尚未确认(任务 ${providerTaskId})`
|
|
||||||
: `AOS结果下载超时,任务已完成(任务 ${providerTaskId})`;
|
|
||||||
return { success: false, error: message };
|
|
||||||
}
|
}
|
||||||
logger.warn("aos_provider_request_error", error, { requestId: config.requestId, stage, taskId: providerTaskId });
|
logger.warn("aos_provider_request_error", error);
|
||||||
const detail = error instanceof Error ? error.message : "未知错误";
|
return { success: false, error: error instanceof Error ? error.message : "未知错误" };
|
||||||
return { success: false, error: stage === "create"
|
|
||||||
? `AOS创建任务连接失败,结果尚未确认:${detail}`
|
|
||||||
: stage === "poll"
|
|
||||||
? `AOS查询任务连接失败,结果尚未确认(任务 ${providerTaskId}):${detail}`
|
|
||||||
: `AOS结果下载连接失败,任务已完成(任务 ${providerTaskId}):${detail}` };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -655,15 +599,8 @@ export async function fetchImageBufferFromUrl(imageUrl: string): Promise<Buffer>
|
|||||||
|
|
||||||
export async function executeRmbgJob(
|
export async function executeRmbgJob(
|
||||||
params: ExecuteRmbgJobParams
|
params: ExecuteRmbgJobParams
|
||||||
): Promise<ExecuteRmbgJobResult> {
|
|
||||||
return trackRuntimeWork(() => runRmbgJob(params));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runRmbgJob(
|
|
||||||
params: ExecuteRmbgJobParams
|
|
||||||
): Promise<ExecuteRmbgJobResult> {
|
): Promise<ExecuteRmbgJobResult> {
|
||||||
const {
|
const {
|
||||||
requestId = crypto.randomUUID(),
|
|
||||||
userId,
|
userId,
|
||||||
imageBuffer,
|
imageBuffer,
|
||||||
imageBase64 = imageBuffer.toString("base64"),
|
imageBase64 = imageBuffer.toString("base64"),
|
||||||
@ -672,13 +609,11 @@ async function runRmbgJob(
|
|||||||
chatId = null,
|
chatId = null,
|
||||||
messageId = null,
|
messageId = null,
|
||||||
skipGallerySave = false,
|
skipGallerySave = false,
|
||||||
inputImageUrl = null,
|
|
||||||
sourceImageUrl = null,
|
sourceImageUrl = null,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const { provider, config } = await getValidatedRmbgConfig();
|
const { provider, config } = await getValidatedRmbgConfig();
|
||||||
logger.info("job_started", {
|
logger.info("job_started", {
|
||||||
requestId,
|
|
||||||
hasChatId: Boolean(chatId),
|
hasChatId: Boolean(chatId),
|
||||||
hasMessageId: Boolean(messageId),
|
hasMessageId: Boolean(messageId),
|
||||||
hasSourceImageUrl: Boolean(sourceImageUrl),
|
hasSourceImageUrl: Boolean(sourceImageUrl),
|
||||||
@ -722,8 +657,6 @@ async function runRmbgJob(
|
|||||||
foregroundType: foregroundType ?? config.aosForegroundType ?? "",
|
foregroundType: foregroundType ?? config.aosForegroundType ?? "",
|
||||||
crop: config.aosCrop === 1 ? 1 : 0,
|
crop: config.aosCrop === 1 ? 1 : 0,
|
||||||
format: config.aosFormat === "jpg" ? "jpg" : "png",
|
format: config.aosFormat === "jpg" ? "jpg" : "png",
|
||||||
inputImageUrl,
|
|
||||||
requestId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success || !result.imageBuffer) {
|
if (!result.success || !result.imageBuffer) {
|
||||||
@ -860,7 +793,6 @@ async function runRmbgJob(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info("job_completed", {
|
logger.info("job_completed", {
|
||||||
requestId,
|
|
||||||
generatedImageId: savedImage.id,
|
generatedImageId: savedImage.id,
|
||||||
mediaAssetId,
|
mediaAssetId,
|
||||||
processingSeconds: Number(processingTime.toFixed(2)),
|
processingSeconds: Number(processingTime.toFixed(2)),
|
||||||
@ -883,7 +815,6 @@ async function runRmbgJob(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info("job_completed", {
|
logger.info("job_completed", {
|
||||||
requestId,
|
|
||||||
processingSeconds: Number(processingTime.toFixed(2)),
|
processingSeconds: Number(processingTime.toFixed(2)),
|
||||||
provider,
|
provider,
|
||||||
savedToGallery: false,
|
savedToGallery: false,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -516,7 +516,6 @@ export async function executeDirectAgentImageEditTool(
|
|||||||
const result = await executeRmbgJob({
|
const result = await executeRmbgJob({
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
imageBuffer: sourceBuffer,
|
imageBuffer: sourceBuffer,
|
||||||
inputImageUrl: params.sourceImageUrl,
|
|
||||||
prompt: params.requestTextContent || "抠图处理",
|
prompt: params.requestTextContent || "抠图处理",
|
||||||
chatId: params.chatId,
|
chatId: params.chatId,
|
||||||
messageId: params.messageId,
|
messageId: params.messageId,
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
import { isProviderBalanceError } from "@/lib/request-error";
|
|
||||||
|
|
||||||
type BalanceState = {
|
|
||||||
healthLastEventType?: unknown;
|
|
||||||
healthLastError?: unknown;
|
|
||||||
healthAutoDisabledReason?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function isImageModelBalanceBlocked(model: BalanceState | null | undefined) {
|
|
||||||
return Boolean(
|
|
||||||
model &&
|
|
||||||
((model.healthLastEventType === "quota" && isProviderBalanceError(model.healthLastError)) ||
|
|
||||||
isProviderBalanceError(model.healthAutoDisabledReason)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveConfiguredImageBalanceFallback<
|
|
||||||
T extends {
|
|
||||||
model: BalanceState & {
|
|
||||||
id: string;
|
|
||||||
modelId: string;
|
|
||||||
isEnabled: boolean;
|
|
||||||
supportsImageGen: boolean;
|
|
||||||
imageGenConfig?: unknown;
|
|
||||||
};
|
|
||||||
provider: { isEnabled?: boolean };
|
|
||||||
},
|
|
||||||
>(
|
|
||||||
requested: BalanceState & { fallbackModelId1?: string | null; fallbackModelId2?: string | null },
|
|
||||||
entries: T[],
|
|
||||||
input: {
|
|
||||||
imageSize?: unknown;
|
|
||||||
aspectRatio?: unknown;
|
|
||||||
referenceImageCount?: number;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (!isImageModelBalanceBlocked(requested)) return undefined;
|
|
||||||
for (const ref of [requested.fallbackModelId1, requested.fallbackModelId2]) {
|
|
||||||
if (!ref) continue;
|
|
||||||
const entry = entries.find(({ model }) => model.id === ref || model.modelId === ref);
|
|
||||||
if (
|
|
||||||
!entry ||
|
|
||||||
!entry.model.isEnabled ||
|
|
||||||
entry.provider.isEnabled === false ||
|
|
||||||
!entry.model.supportsImageGen ||
|
|
||||||
isImageModelBalanceBlocked(entry.model)
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
const config = entry.model.imageGenConfig as Record<string, unknown> | null;
|
|
||||||
const size = String(input.imageSize || "").toUpperCase();
|
|
||||||
const ratio = String(input.aspectRatio || "");
|
|
||||||
if (
|
|
||||||
size &&
|
|
||||||
Array.isArray(config?.supportedImageSizes) &&
|
|
||||||
!config.supportedImageSizes.includes(size)
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
if (
|
|
||||||
ratio &&
|
|
||||||
ratio !== "auto" &&
|
|
||||||
Array.isArray(config?.supportedAspectRatios) &&
|
|
||||||
!config.supportedAspectRatios.includes(ratio)
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
if (
|
|
||||||
input.referenceImageCount &&
|
|
||||||
typeof config?.maxReferenceImages === "number" &&
|
|
||||||
input.referenceImageCount > config.maxReferenceImages
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
18
lib/ai/runtime/image-fallback-balance-cooldown.ts
Normal file
18
lib/ai/runtime/image-fallback-balance-cooldown.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { isProviderBalanceError } from "@/lib/request-error";
|
||||||
|
|
||||||
|
const IMAGE_FAILOVER_BALANCE_COOLDOWN_MS = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export function isImageFallbackModelBalanceCoolingDown(
|
||||||
|
model: {
|
||||||
|
healthLastEventType?: unknown;
|
||||||
|
healthLastError?: unknown;
|
||||||
|
healthLastFailureAt?: unknown;
|
||||||
|
} | null | undefined,
|
||||||
|
now = Date.now(),
|
||||||
|
) {
|
||||||
|
if (model?.healthLastEventType !== "quota" || !isProviderBalanceError(model.healthLastError)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const failedAt = new Date(String(model.healthLastFailureAt || "")).getTime();
|
||||||
|
return Number.isFinite(failedAt) && now - failedAt < IMAGE_FAILOVER_BALANCE_COOLDOWN_MS && now >= failedAt;
|
||||||
|
}
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { ProviderTaskUncertainError } from "@/lib/video-canvas/video-task-lifecycle";
|
|
||||||
import { isProviderAuthenticationFailure, isCredentialFailureBan } from "./authentication-failover";
|
import { isProviderAuthenticationFailure, isCredentialFailureBan } from "./authentication-failover";
|
||||||
|
|
||||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||||
@ -13,7 +12,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 { isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
||||||
|
|
||||||
export type AiModelHealthEventType =
|
export type AiModelHealthEventType =
|
||||||
| "success"
|
| "success"
|
||||||
@ -344,12 +343,6 @@ export function classifyAiModelHealthFailure(error: unknown): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldFailoverAiModelError(error: unknown) {
|
export function shouldFailoverAiModelError(error: unknown) {
|
||||||
if (
|
|
||||||
error instanceof ProviderTaskUncertainError ||
|
|
||||||
isGatewayResponseTimeout(error) ||
|
|
||||||
getRequestFailureDetails(error).outcome === "unknown" ||
|
|
||||||
["AbortError", "TimeoutError"].includes(String((error as { name?: string })?.name || ""))
|
|
||||||
) return false;
|
|
||||||
const classification = classifyAiModelHealthFailure(error);
|
const classification = classifyAiModelHealthFailure(error);
|
||||||
const normalized = getErrorMessage(error).toLowerCase();
|
const normalized = getErrorMessage(error).toLowerCase();
|
||||||
const imageProviderTaskFailed =
|
const imageProviderTaskFailed =
|
||||||
@ -506,7 +499,6 @@ export async function reviveAiModelsFromCooldown(params?: { modelId?: string })
|
|||||||
|
|
||||||
const dueModels = candidates.filter((model) => {
|
const dueModels = candidates.filter((model) => {
|
||||||
if (isCredentialFailureBan(model.healthAutoDisabledReason)) return false;
|
if (isCredentialFailureBan(model.healthAutoDisabledReason)) return false;
|
||||||
if (isProviderBalanceError(model.healthAutoDisabledReason)) return false;
|
|
||||||
const cooldownStartedAt =
|
const cooldownStartedAt =
|
||||||
model.healthAutoDisabledAt ||
|
model.healthAutoDisabledAt ||
|
||||||
model.healthLastFailureAt ||
|
model.healthLastFailureAt ||
|
||||||
@ -576,7 +568,6 @@ export async function recordAiModelHealthSuccess(params: {
|
|||||||
healthStatus: aiModel.healthStatus,
|
healthStatus: aiModel.healthStatus,
|
||||||
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
|
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
|
||||||
healthObservationStartedAt: aiModel.healthObservationStartedAt,
|
healthObservationStartedAt: aiModel.healthObservationStartedAt,
|
||||||
healthAutoDisabledReason: aiModel.healthAutoDisabledReason,
|
|
||||||
})
|
})
|
||||||
.from(aiModel)
|
.from(aiModel)
|
||||||
.where(eq(aiModel.id, modelId))
|
.where(eq(aiModel.id, modelId))
|
||||||
@ -584,9 +575,6 @@ export async function recordAiModelHealthSuccess(params: {
|
|||||||
|
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|
||||||
// A previously accepted task can finish after the account runs out of credit.
|
|
||||||
if (isProviderBalanceError(existing.healthAutoDisabledReason)) return;
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// A request that started before the circuit opened can still prove the
|
// A request that started before the circuit opened can still prove the
|
||||||
// provider is reachable. Treat that success as the circuit's half-open
|
// provider is reachable. Treat that success as the circuit's half-open
|
||||||
@ -674,7 +662,6 @@ export async function recordAiModelHealthFailure(params: {
|
|||||||
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
|
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
|
||||||
healthObservationStartedAt: aiModel.healthObservationStartedAt,
|
healthObservationStartedAt: aiModel.healthObservationStartedAt,
|
||||||
healthAutoDisabledAt: aiModel.healthAutoDisabledAt,
|
healthAutoDisabledAt: aiModel.healthAutoDisabledAt,
|
||||||
healthAutoDisabledReason: aiModel.healthAutoDisabledReason,
|
|
||||||
})
|
})
|
||||||
.from(aiModel)
|
.from(aiModel)
|
||||||
.where(eq(aiModel.id, modelId))
|
.where(eq(aiModel.id, modelId))
|
||||||
@ -691,15 +678,13 @@ export async function recordAiModelHealthFailure(params: {
|
|||||||
: existing.healthConsecutiveFailures || 0;
|
: existing.healthConsecutiveFailures || 0;
|
||||||
const shouldAutoDisable =
|
const shouldAutoDisable =
|
||||||
classification.counted &&
|
classification.counted &&
|
||||||
(classification.eventType === "quota" ||
|
!existing.excludeFromFailover &&
|
||||||
(!existing.excludeFromFailover &&
|
|
||||||
!existing.excludeFromHealthBan &&
|
!existing.excludeFromHealthBan &&
|
||||||
(classification.reason === "provider_authentication_failed" ||
|
(classification.reason === "provider_authentication_failed" ||
|
||||||
(isObservation && existing.isEnabled) ||
|
(isObservation && existing.isEnabled) ||
|
||||||
(existing.healthStatus !== "auto_disabled" &&
|
(existing.healthStatus !== "auto_disabled" &&
|
||||||
nextConsecutiveFailures >= AUTO_DISABLE_FAILURE_THRESHOLD))));
|
nextConsecutiveFailures >= AUTO_DISABLE_FAILURE_THRESHOLD));
|
||||||
const balanceDisabled = isProviderBalanceError(existing.healthAutoDisabledReason);
|
const nextStatus = shouldAutoDisable
|
||||||
const nextStatus = shouldAutoDisable || balanceDisabled
|
|
||||||
? "auto_disabled"
|
? "auto_disabled"
|
||||||
: classification.counted
|
: classification.counted
|
||||||
? "degraded"
|
? "degraded"
|
||||||
@ -752,7 +737,7 @@ export async function recordAiModelHealthFailure(params: {
|
|||||||
healthUpdatedAt: now,
|
healthUpdatedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
if (shouldAutoDisable && !balanceDisabled) {
|
if (shouldAutoDisable) {
|
||||||
updateData.healthAutoDisabledAt = now;
|
updateData.healthAutoDisabledAt = now;
|
||||||
updateData.healthAutoDisabledReason = autoDisabledReason || errorMessage;
|
updateData.healthAutoDisabledReason = autoDisabledReason || errorMessage;
|
||||||
} else if (existing.healthStatus !== "auto_disabled" && !isObservation) {
|
} else if (existing.healthStatus !== "auto_disabled" && !isObservation) {
|
||||||
|
|||||||
@ -238,10 +238,7 @@ export function bindVip98kImages(
|
|||||||
imageUrls: string[],
|
imageUrls: string[],
|
||||||
) {
|
) {
|
||||||
const bindingKeys: string[] = [];
|
const bindingKeys: string[] = [];
|
||||||
if (imageUrls.length === 0) {
|
if (imageUrls.length === 0) return bindingKeys;
|
||||||
for (const key of [...IMAGE_ARRAY_INPUT_KEYS, ...IMAGE_SINGLE_INPUT_KEYS, "video_clips"]) delete body[key];
|
|
||||||
return bindingKeys;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of IMAGE_ARRAY_INPUT_KEYS) {
|
for (const key of IMAGE_ARRAY_INPUT_KEYS) {
|
||||||
if (!(key in body)) continue;
|
if (!(key in body)) continue;
|
||||||
@ -308,7 +305,6 @@ function scoreReferenceInvocation(invocation: Vip98kTaskInvocation) {
|
|||||||
|
|
||||||
export function selectVip98kTaskInvocation(params: {
|
export function selectVip98kTaskInvocation(params: {
|
||||||
modelId: string;
|
modelId: string;
|
||||||
kind?: string;
|
|
||||||
configuredInvocation: Vip98kTaskInvocation | null;
|
configuredInvocation: Vip98kTaskInvocation | null;
|
||||||
configuredInvocations?: Vip98kTaskInvocation[];
|
configuredInvocations?: Vip98kTaskInvocation[];
|
||||||
imageCount?: number;
|
imageCount?: number;
|
||||||
@ -338,17 +334,6 @@ export function selectVip98kTaskInvocation(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.kind === "image" && !params.imageCount) {
|
|
||||||
const textImageTask = candidates.find(candidate => {
|
|
||||||
const properties = candidate.inputSchema?.properties as Record<string, unknown> | undefined;
|
|
||||||
const required = candidate.inputSchema?.required as string[] | undefined;
|
|
||||||
return candidate.kind === "task" && candidate.path === "/extensions/v1/tasks" &&
|
|
||||||
invocationTargetsModel(candidate, params.modelId) && properties?.resolution && properties?.aspect_ratio &&
|
|
||||||
!required?.some(key => [...IMAGE_ARRAY_INPUT_KEYS, ...IMAGE_SINGLE_INPUT_KEYS, "video_clips"].includes(key));
|
|
||||||
});
|
|
||||||
if (textImageTask) return resolveVip98kTaskInvocation(params.modelId, textImageTask);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolveVip98kTaskInvocation(
|
return resolveVip98kTaskInvocation(
|
||||||
params.modelId,
|
params.modelId,
|
||||||
params.configuredInvocation || candidates[0] || null,
|
params.configuredInvocation || candidates[0] || null,
|
||||||
|
|||||||
@ -4,11 +4,9 @@ import { join } from "node:path";
|
|||||||
import type { NativeEvent, NativeResult } from "@/services/zcode-agent/runner.mjs";
|
import type { NativeEvent, NativeResult } from "@/services/zcode-agent/runner.mjs";
|
||||||
import { runNativeZcode } from "./native-runtime";
|
import { runNativeZcode } from "./native-runtime";
|
||||||
import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop";
|
import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop";
|
||||||
import { AgentToolRejectedError, publicAgentError, type AgentCallRecord, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol";
|
import { AgentToolRejectedError, publicAgentError, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol";
|
||||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
|
||||||
import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion";
|
import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion";
|
||||||
import { countAgentGeneratedImages, requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results";
|
import { requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results";
|
||||||
import { selectedAgentImageCount } from "@/lib/ai/canvas-agent-runtime/image-preferences";
|
|
||||||
import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
||||||
import { publicAssistantText } from "./assistant-output";
|
import { publicAssistantText } from "./assistant-output";
|
||||||
|
|
||||||
@ -39,27 +37,6 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
|||||||
snapshot.message = message;
|
snapshot.message = message;
|
||||||
await ports.checkpoint(structuredClone(snapshot), status); return status;
|
await ports.checkpoint(structuredClone(snapshot), status); return status;
|
||||||
}
|
}
|
||||||
function failureMessage(call: AgentCallRecord) {
|
|
||||||
if (call.confirmedNoResult) return `${call.error}\n\n请求编号:${ports.runId}`;
|
|
||||||
const details = getRequestFailureDetails(call.error || "任务结果尚未确认");
|
|
||||||
if (call.uncertain || call.status === "started") {
|
|
||||||
if (details.outcome !== "unknown") details.message += "结果尚未确认。";
|
|
||||||
details.outcome = "unknown";
|
|
||||||
details.recommendation = "请联系管理员按请求编号核对原任务结果;不要换模型、新建对话或重新提交,以免重复生成。";
|
|
||||||
}
|
|
||||||
if (!details.recommendation) details.recommendation = "请联系管理员并提供请求编号,核对该步骤的错误。";
|
|
||||||
return `${call.title || "创作工具"}:${formatRequestFailure(details, ports.runId)}`;
|
|
||||||
}
|
|
||||||
function imageCountReached() {
|
|
||||||
const count = snapshot.workspace === "video" ? undefined : selectedAgentImageCount(snapshot.imagePreferences);
|
|
||||||
return count !== undefined && countAgentGeneratedImages(snapshot.calls) >= count;
|
|
||||||
}
|
|
||||||
function reviewMessage(issues: string[]) {
|
|
||||||
const summary = `结果待核对:${issues.join(";")}`;
|
|
||||||
return imageCountReached()
|
|
||||||
? `${summary}\n\n已保留本次生成的图片。如需再生成,请发送新指令。`
|
|
||||||
: summary;
|
|
||||||
}
|
|
||||||
let modelError = "";
|
let modelError = "";
|
||||||
let toolFailure = "";
|
let toolFailure = "";
|
||||||
let nativeFailed = false;
|
let nativeFailed = false;
|
||||||
@ -67,11 +44,11 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
|||||||
const seenTextEvents = new Set<string>();
|
const seenTextEvents = new Set<string>();
|
||||||
let lastTextMessage = "";
|
let lastTextMessage = "";
|
||||||
async function appendText(delta: string) {
|
async function appendText(delta: string) {
|
||||||
const text = publicAssistantText(delta).slice(0, Math.max(0, 100_000 - (snapshot.assistantText?.length || 0)));
|
delta = publicAssistantText(delta).slice(0, Math.max(0, 100_000 - (snapshot.assistantText?.length || 0)));
|
||||||
if (!text) return;
|
if (!delta) return;
|
||||||
snapshot.assistantText = (snapshot.assistantText || "") + text;
|
snapshot.assistantText = (snapshot.assistantText || "") + delta;
|
||||||
await ports.checkpoint(structuredClone(snapshot), "running");
|
await ports.checkpoint(structuredClone(snapshot), "running");
|
||||||
ports.onAssistantText?.(text);
|
ports.onAssistantText?.(delta);
|
||||||
}
|
}
|
||||||
async function finishVerifiedCutout() {
|
async function finishVerifiedCutout() {
|
||||||
snapshot.nativeRuntime!.state = "stopped";
|
snapshot.nativeRuntime!.state = "stopped";
|
||||||
@ -79,8 +56,8 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
|||||||
return save(verifiedCompletion, "completed");
|
return save(verifiedCompletion, "completed");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const unconfirmed = snapshot.calls.find(call => call.status === "started" || call.uncertain);
|
if (snapshot.calls.some(call => call.status === "started" || call.uncertain))
|
||||||
if (unconfirmed) return await save(failureMessage(unconfirmed), "needs_review");
|
return await save("存在结果未确认的工具调用,请先核对画布,避免重复生成。", "needs_review");
|
||||||
snapshot.nativeRuntime = { ...snapshot.nativeRuntime, engine: "zcode", state: "starting" };
|
snapshot.nativeRuntime = { ...snapshot.nativeRuntime, engine: "zcode", state: "starting" };
|
||||||
const schemas = new Map(Object.entries(ports.definitions).map(([name, definition]) => [name, asSchema(definition.inputSchema!)]));
|
const schemas = new Map(Object.entries(ports.definitions).map(([name, definition]) => [name, asSchema(definition.inputSchema!)]));
|
||||||
const toolDescriptors = Object.entries(ports.tools).map(([name, entry]) => ({ name,
|
const toolDescriptors = Object.entries(ports.tools).map(([name, entry]) => ({ name,
|
||||||
@ -95,7 +72,6 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
|||||||
`可用业务工具:${tools.join("、")}。工具名称带 mcp__canvas__ 前缀。`,
|
`可用业务工具:${tools.join("、")}。工具名称带 mcp__canvas__ 前缀。`,
|
||||||
"原生文件、Bash、记忆及子任务能力仅作用于此任务的私有工作区,不能通过 shell 直接访问模型服务商或本站后台;业务操作使用 MCP。",
|
"原生文件、Bash、记忆及子任务能力仅作用于此任务的私有工作区,不能通过 shell 直接访问模型服务商或本站后台;业务操作使用 MCP。",
|
||||||
"用户只要求生图或改图时,把结果写入画布并验收即可。除非用户明确要求下载或导出,不要主动调用 image_download,也不要为收尾打开交互面板。",
|
"用户只要求生图或改图时,把结果写入画布并验收即可。除非用户明确要求下载或导出,不要主动调用 image_download,也不要为收尾打开交互面板。",
|
||||||
"用户设置的生成张数是本次任务的总数,包括验收未通过的已生成图片。达到张数后,保留结果并说明问题;如需再次生成,等待用户发送新指令。",
|
|
||||||
"用户指定的生图模型被明确拒绝时,停止本轮收费操作,询问用户希望切换生图模型还是稍后重试。切换需用户确认并更新模型选择;结果未知的超时必须先核对原任务,不可直接换模型重发。不要为普通图片编辑调用 appearance.post 或其他无关业务接口。",
|
"用户指定的生图模型被明确拒绝时,停止本轮收费操作,询问用户希望切换生图模型还是稍后重试。切换需用户确认并更新模型选择;结果未知的超时必须先核对原任务,不可直接换模型重发。不要为普通图片编辑调用 appearance.post 或其他无关业务接口。",
|
||||||
"imagePreferences 是用户在输入框选择的生图模型和图片参数,与当前规划模型不同。生成和编辑图片优先用 generateCanvasImages;用户选定的模型不得被技能默认值、你选的其他模型或重试覆盖。自动选择生图模型时,图片工具内部会在明确失败后切换备用模型并补齐结果,不要自行重放付费操作;用户明确指定模型时禁止故障切换。比例、尺寸、数量、反向提示词等控件必须传入;不要通过其他生成接口绕过用户选择。分析、问答、排版等不需要出图的任务无需生成图片。",
|
"imagePreferences 是用户在输入框选择的生图模型和图片参数,与当前规划模型不同。生成和编辑图片优先用 generateCanvasImages;用户选定的模型不得被技能默认值、你选的其他模型或重试覆盖。自动选择生图模型时,图片工具内部会在明确失败后切换备用模型并补齐结果,不要自行重放付费操作;用户明确指定模型时禁止故障切换。比例、尺寸、数量、反向提示词等控件必须传入;不要通过其他生成接口绕过用户选择。分析、问答、排版等不需要出图的任务无需生成图片。",
|
||||||
"转正面、换角度、旋转主体视角时,直接用 generateCanvasImages,referenceNodeIds 指向要编辑的真实图片,提示词描述目标视角并保留产品结构和未要求修改的细节。旧多视角功能、angleEditImage 和 image.multi_angle 已废弃,即使历史对话用过也不得再调用或打开旧面板。",
|
"转正面、换角度、旋转主体视角时,直接用 generateCanvasImages,referenceNodeIds 指向要编辑的真实图片,提示词描述目标视角并保留产品结构和未要求修改的细节。旧多视角功能、angleEditImage 和 image.multi_angle 已废弃,即使历史对话用过也不得再调用或打开旧面板。",
|
||||||
@ -135,7 +111,6 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
|||||||
await save("正在准备处理任务");
|
await save("正在准备处理任务");
|
||||||
}),
|
}),
|
||||||
onEvent: async (event: NativeEvent | NativeResult) => {
|
onEvent: async (event: NativeEvent | NativeResult) => {
|
||||||
if (attemptController.signal.aborted) return;
|
|
||||||
if (event.type === "model.streaming" && event.payload?.kind === "text_delta" && typeof event.payload.delta === "string") {
|
if (event.type === "model.streaming" && event.payload?.kind === "text_delta" && typeof event.payload.delta === "string") {
|
||||||
// Child sessions and reasoning/tool-argument deltas are private.
|
// Child sessions and reasoning/tool-argument deltas are private.
|
||||||
if (snapshot.nativeRuntime?.sessionId && event.sessionId !== snapshot.nativeRuntime.sessionId) return;
|
if (snapshot.nativeRuntime?.sessionId && event.sessionId !== snapshot.nativeRuntime.sessionId) return;
|
||||||
@ -180,11 +155,9 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
|||||||
if (!entry.readOnly && !snapshot.calls.some(call => call.name === "planCanvasTask" && call.status === "succeeded"))
|
if (!entry.readOnly && !snapshot.calls.some(call => call.name === "planCanvasTask" && call.status === "succeeded"))
|
||||||
throw new Error("请先制定任务计划再执行画布修改");
|
throw new Error("请先制定任务计划再执行画布修改");
|
||||||
if (!entry.readOnly && snapshot.verification?.retryable && snapshot.verification.failedNodeIds?.length) {
|
if (!entry.readOnly && snapshot.verification?.retryable && snapshot.verification.failedNodeIds?.length) {
|
||||||
const targets = [input.nodeId, ...(Array.isArray(input.nodeIds) ? input.nodeIds : [])]
|
const targets = [input.nodeId, ...(Array.isArray(input.nodeIds) ? input.nodeIds : []),
|
||||||
.filter((id): id is string => typeof id === "string");
|
...(Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds : [])].filter((id): id is string => typeof id === "string");
|
||||||
const references = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds : [];
|
if (!targets.length || targets.some(id => !snapshot.verification!.failedNodeIds!.includes(id)))
|
||||||
const failedIds = snapshot.verification.failedNodeIds;
|
|
||||||
if (targets.length ? targets.some(id => !failedIds.includes(id)) : !references.some(id => failedIds.includes(String(id))))
|
|
||||||
throw new Error("修复只能处理未通过验收的节点,禁止重复生成已通过的结果");
|
throw new Error("修复只能处理未通过验收的节点,禁止重复生成已通过的结果");
|
||||||
}
|
}
|
||||||
snapshot.calls.push({ id: callId, name, title: entry.title, input, status: "started" });
|
snapshot.calls.push({ id: callId, name, title: entry.title, input, status: "started" });
|
||||||
@ -232,9 +205,8 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
|||||||
call.status = "failed"; call.error = publicAgentError(error); call.rejected = error instanceof AgentToolRejectedError;
|
call.status = "failed"; call.error = publicAgentError(error); call.rejected = error instanceof AgentToolRejectedError;
|
||||||
call.confirmedNoResult = error instanceof AgentImageGenerationError && error.confirmedNoResult;
|
call.confirmedNoResult = error instanceof AgentImageGenerationError && error.confirmedNoResult;
|
||||||
call.uncertain = !entry.readOnly && !call.rejected && !call.confirmedNoResult;
|
call.uncertain = !entry.readOnly && !call.rejected && !call.confirmedNoResult;
|
||||||
if (call.uncertain || call.confirmedNoResult) toolFailure = failureMessage(call);
|
if (call.confirmedNoResult) toolFailure = call.error;
|
||||||
await save(toolFailure || call.error);
|
await save(call.error);
|
||||||
if (call.uncertain || call.confirmedNoResult) attemptController.abort();
|
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -256,15 +228,15 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
|||||||
if (toolFailure) return await save(toolFailure, "needs_review");
|
if (toolFailure) return await save(toolFailure, "needs_review");
|
||||||
// A failed read is not an uncertain paid operation. Independently verify
|
// A failed read is not an uncertain paid operation. Independently verify
|
||||||
// actual outputs below instead of failing a task because an earlier read timed out.
|
// actual outputs below instead of failing a task because an earlier read timed out.
|
||||||
const failedWrite = snapshot.calls.find(call => (call.status === "failed" && !call.rejected && !call.confirmedNoResult && !ports.tools[call.name]?.readOnly) || call.uncertain);
|
if (snapshot.calls.some(call => (call.status === "failed" && !call.rejected && !call.confirmedNoResult && !ports.tools[call.name]?.readOnly) || call.uncertain))
|
||||||
if (failedWrite) return await save(failureMessage(failedWrite), "needs_review");
|
return await save("部分工具结果待确认,已保留执行记录,请核对后继续。", "needs_review");
|
||||||
const unresolvedRead = snapshot.calls.find((call, index) => call.status === "failed" && !call.rejected && ports.tools[call.name]?.readOnly &&
|
const unresolvedRead = snapshot.calls.some((call, index) => call.status === "failed" && !call.rejected && ports.tools[call.name]?.readOnly &&
|
||||||
!snapshot.calls.slice(index + 1).some(later => later.name === call.name && later.status === "succeeded" &&
|
!snapshot.calls.slice(index + 1).some(later => later.name === call.name && later.status === "succeeded" &&
|
||||||
JSON.stringify(later.input) === JSON.stringify(call.input)));
|
JSON.stringify(later.input) === JSON.stringify(call.input)));
|
||||||
// Without a real result to independently inspect, a viewport/UI action
|
// Without a real result to independently inspect, a viewport/UI action
|
||||||
// cannot turn a failed analysis into a completed task.
|
// cannot turn a failed analysis into a completed task.
|
||||||
if (unresolvedRead && !snapshot.resultNodeIds.length)
|
if (unresolvedRead && !snapshot.resultNodeIds.length)
|
||||||
return await save(failureMessage(unresolvedRead), "needs_review");
|
return await save("图片或资料暂未读取成功,已保留记录,请稍后继续核对。", "needs_review");
|
||||||
if (question || snapshot.calls.some(call => requiresCanvasInteraction(snapshot, call)))
|
if (question || snapshot.calls.some(call => requiresCanvasInteraction(snapshot, call)))
|
||||||
return await save(question || "请在已打开的工具面板中完成操作,然后继续任务。", "needs_review");
|
return await save(question || "请在已打开的工具面板中完成操作,然后继续任务。", "needs_review");
|
||||||
const supplements = snapshot.inputs.filter(input => !startedInputs.has(input.id));
|
const supplements = snapshot.inputs.filter(input => !startedInputs.has(input.id));
|
||||||
@ -289,12 +261,12 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
|||||||
const verification = await ports.verify(reviewSnapshot); snapshot.verification = verification;
|
const verification = await ports.verify(reviewSnapshot); snapshot.verification = verification;
|
||||||
snapshot.inputTokens = reviewSnapshot.inputTokens; snapshot.outputTokens = reviewSnapshot.outputTokens;
|
snapshot.inputTokens = reviewSnapshot.inputTokens; snapshot.outputTokens = reviewSnapshot.outputTokens;
|
||||||
if (verification.passed) return await save(result.response || "任务完成,结果已写入画布。", "completed");
|
if (verification.passed) return await save(result.response || "任务完成,结果已写入画布。", "completed");
|
||||||
if (verification.retryable && snapshot.repairs < 2 && !imageCountReached()) {
|
if (verification.retryable && snapshot.repairs < 2) {
|
||||||
snapshot.repairs++;
|
snapshot.repairs++;
|
||||||
prompt = `验收需要定点修复:${JSON.stringify(verification)}。只处理失败结果,禁止整批重新生成;保留所有通过结果。`;
|
prompt = `验收需要定点修复:${JSON.stringify(verification)}。只处理失败结果,禁止整批重新生成;保留所有通过结果。`;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
return await save(reviewMessage(verification.issues), "needs_review");
|
return await save(`结果待核对:${verification.issues.join(";")}`, "needs_review");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (snapshot.nativeRuntime) snapshot.nativeRuntime.state = "stopped";
|
if (snapshot.nativeRuntime) snapshot.nativeRuntime.state = "stopped";
|
||||||
@ -318,7 +290,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
|||||||
if (control.cancelled || ports.signal.aborted) return await save("已停止后续步骤,完成的结果保留在画布。", "cancelled");
|
if (control.cancelled || ports.signal.aborted) return await save("已停止后续步骤,完成的结果保留在画布。", "cancelled");
|
||||||
if (inputs !== JSON.stringify(snapshot.inputs)) return await save("已收到新的补充要求,图片已保留,请继续处理。", "needs_review");
|
if (inputs !== JSON.stringify(snapshot.inputs)) return await save("已收到新的补充要求,图片已保留,请继续处理。", "needs_review");
|
||||||
return await save(verification.passed ? "图片已完成并通过检查,结果已保留在画布。"
|
return await save(verification.passed ? "图片已完成并通过检查,结果已保留在画布。"
|
||||||
: `图片已返回画布,${reviewMessage(verification.issues)}`, verification.passed ? "completed" : "needs_review");
|
: `图片已返回画布,结果待核对:${verification.issues.join(";")}`, verification.passed ? "completed" : "needs_review");
|
||||||
} catch { /* Keep the original interruption if independent inspection also fails. */ }
|
} catch { /* Keep the original interruption if independent inspection also fails. */ }
|
||||||
}
|
}
|
||||||
return await save(cancelled ? "已停止后续步骤,完成的结果保留在画布。" : toolFailure || modelError || publicAgentError(error), cancelled ? "cancelled" : "needs_review");
|
return await save(cancelled ? "已停止后续步骤,完成的结果保留在画布。" : toolFailure || modelError || publicAgentError(error), cancelled ? "cancelled" : "needs_review");
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
import { executeBoxCutoutJob } from "@/lib/ai/box-cutout/service";
|
import {
|
||||||
|
executeBoxCutoutJob,
|
||||||
|
} from "@/lib/ai/box-cutout/service";
|
||||||
import { executeExtendJob } from "@/lib/ai/extend/service";
|
import { executeExtendJob } from "@/lib/ai/extend/service";
|
||||||
import { executeLayerSplitJob } from "@/lib/ai/layer-split/service";
|
import { executeLayerSplitJob } from "@/lib/ai/layer-split/service";
|
||||||
import { executeResizeJob } from "@/lib/ai/resize/service";
|
import { executeResizeJob } from "@/lib/ai/resize/service";
|
||||||
import {
|
import {
|
||||||
executeRmbgJob,
|
executeRmbgJob,
|
||||||
fetchImageBufferFromUrl,
|
fetchImageBufferFromUrl,
|
||||||
RmbgServiceError,
|
|
||||||
} from "@/lib/ai/rmbg/service";
|
} from "@/lib/ai/rmbg/service";
|
||||||
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
import type { CanvasCapabilityTarget } from "@/lib/canvas/capabilities/context";
|
import type { CanvasCapabilityTarget } from "@/lib/canvas/capabilities/context";
|
||||||
import { resolveCanvasRuntimeEndpoint } from "@/lib/canvas/capabilities/internal-endpoint";
|
import { resolveCanvasRuntimeEndpoint } from "@/lib/canvas/capabilities/internal-endpoint";
|
||||||
import { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout";
|
|
||||||
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
||||||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||||||
import {
|
import {
|
||||||
@ -128,10 +128,8 @@ async function executeForTarget(
|
|||||||
if (command.capabilityId === "image.remove_background") {
|
if (command.capabilityId === "image.remove_background") {
|
||||||
const imageBuffer = await fetchImageBufferFromUrl(target.sourceImageUrl);
|
const imageBuffer = await fetchImageBufferFromUrl(target.sourceImageUrl);
|
||||||
const result = await executeRmbgJob({
|
const result = await executeRmbgJob({
|
||||||
requestId: command.requestId,
|
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
imageBuffer,
|
imageBuffer,
|
||||||
inputImageUrl: target.sourceImageUrl,
|
|
||||||
sourceImageUrl: target.sourceImageUrl,
|
sourceImageUrl: target.sourceImageUrl,
|
||||||
prompt: prompt || "抠图处理",
|
prompt: prompt || "抠图处理",
|
||||||
skipGallerySave: false,
|
skipGallerySave: false,
|
||||||
@ -187,7 +185,8 @@ async function executeForTarget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command.capabilityId === "image.box_cutout") {
|
if (command.capabilityId === "image.box_cutout") {
|
||||||
const job = {
|
const result = await executeBoxCutoutJob({
|
||||||
|
userId: params.userId,
|
||||||
imageUrl: target.sourceImageUrl,
|
imageUrl: target.sourceImageUrl,
|
||||||
sourceNodeId: target.nodeId,
|
sourceNodeId: target.nodeId,
|
||||||
cropRect: {
|
cropRect: {
|
||||||
@ -198,30 +197,13 @@ async function executeForTarget(
|
|||||||
},
|
},
|
||||||
paddingRatio: asFiniteNumber(command.args.paddingRatio),
|
paddingRatio: asFiniteNumber(command.args.paddingRatio),
|
||||||
prompt: prompt || "框选抠图",
|
prompt: prompt || "框选抠图",
|
||||||
};
|
});
|
||||||
let result;
|
|
||||||
if (command.source === "toolbar") {
|
|
||||||
const response = await executeBoxCutoutPOST(new Request(params.requestUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json", "X-Request-ID": command.requestId },
|
|
||||||
body: JSON.stringify(job),
|
|
||||||
}), params.userId);
|
|
||||||
result = await response.json();
|
|
||||||
if (!response.ok || !result.success) {
|
|
||||||
throw new RmbgServiceError(result.detail || result.message || result.error || "框选抠图未能完成", response.status);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result = await executeBoxCutoutJob({ ...job, requestId: command.requestId, userId: params.userId });
|
|
||||||
}
|
|
||||||
return [{
|
return [{
|
||||||
...common,
|
...common,
|
||||||
assetId: result.mediaAssetId || undefined,
|
assetId: result.mediaAssetId || undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
editMode: "box-cutout",
|
|
||||||
cropRectApplied: result.cropRectApplied,
|
cropRectApplied: result.cropRectApplied,
|
||||||
paddedCropRectApplied: result.paddedCropRectApplied,
|
paddedCropRectApplied: result.paddedCropRectApplied,
|
||||||
cropRectPixels: result.cropRectPixels,
|
|
||||||
paddedCropRectPixels: result.paddedCropRectPixels,
|
|
||||||
},
|
},
|
||||||
title: `${target.title}-框选抠图`,
|
title: `${target.title}-框选抠图`,
|
||||||
url: result.original_url,
|
url: result.original_url,
|
||||||
|
|||||||
@ -14,16 +14,13 @@ import { getCanvasCapability } from "@/lib/canvas/capabilities/registry";
|
|||||||
import { enqueueCanvasCapabilityExecution } from "@/lib/canvas/capabilities/task-queue";
|
import { enqueueCanvasCapabilityExecution } from "@/lib/canvas/capabilities/task-queue";
|
||||||
import {
|
import {
|
||||||
createOrRecoverCanvasCapabilityTask,
|
createOrRecoverCanvasCapabilityTask,
|
||||||
getCanvasCapabilityTask,
|
MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS,
|
||||||
heartbeatCanvasCapabilityTask,
|
|
||||||
markCanvasCapabilityTaskFailed,
|
markCanvasCapabilityTaskFailed,
|
||||||
markCanvasCapabilityTaskRunning,
|
markCanvasCapabilityTaskRunning,
|
||||||
markCanvasCapabilityTaskSucceeded,
|
markCanvasCapabilityTaskSucceeded,
|
||||||
} from "@/lib/canvas/capabilities/task-store";
|
} from "@/lib/canvas/capabilities/task-store";
|
||||||
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
|
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
|
||||||
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
|
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
|
||||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
|
||||||
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
|
||||||
|
|
||||||
export type CanvasCapabilityRuntimeResult = {
|
export type CanvasCapabilityRuntimeResult = {
|
||||||
clientCommand?: CanvasCapabilityClientCommand;
|
clientCommand?: CanvasCapabilityClientCommand;
|
||||||
@ -61,11 +58,9 @@ function serializeTask(task: {
|
|||||||
status: string;
|
status: string;
|
||||||
version: number;
|
version: number;
|
||||||
}) {
|
}) {
|
||||||
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
|
|
||||||
return {
|
return {
|
||||||
attempts: task.attempts,
|
attempts: task.attempts,
|
||||||
error: failure ? formatRequestFailure(failure, task.id) : task.error,
|
error: task.error,
|
||||||
failure,
|
|
||||||
id: task.id,
|
id: task.id,
|
||||||
requestId: task.requestId,
|
requestId: task.requestId,
|
||||||
result: task.result,
|
result: task.result,
|
||||||
@ -74,13 +69,6 @@ function serializeTask(task: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function storedTaskResult(task: Parameters<typeof serializeTask>[0], mode: "headless" | "interactive"): CanvasCapabilityRuntimeResult {
|
|
||||||
const result = task.status === "succeeded" && task.result && typeof task.result === "object"
|
|
||||||
? task.result as Omit<CanvasCapabilityRuntimeResult, "task">
|
|
||||||
: { mode };
|
|
||||||
return { ...result, task: serializeTask(task) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMediaOperationPlan(params: {
|
function buildMediaOperationPlan(params: {
|
||||||
command: CanvasCapabilityClientCommand;
|
command: CanvasCapabilityClientCommand;
|
||||||
mediaResults: CanvasCapabilityMediaResult[];
|
mediaResults: CanvasCapabilityMediaResult[];
|
||||||
@ -223,7 +211,11 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (task.status === "succeeded") {
|
if (task.status === "succeeded") {
|
||||||
return storedTaskResult(task, "headless");
|
const storedResult =
|
||||||
|
task.result && typeof task.result === "object"
|
||||||
|
? (task.result as Omit<CanvasCapabilityRuntimeResult, "task">)
|
||||||
|
: ({ mode: "headless" } as const);
|
||||||
|
return { ...storedResult, task: serializeTask(task) };
|
||||||
}
|
}
|
||||||
if (task.status === "running") {
|
if (task.status === "running") {
|
||||||
return {
|
return {
|
||||||
@ -233,8 +225,15 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
task: serializeTask(task),
|
task: serializeTask(task),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (task.status === "failed") {
|
if (
|
||||||
return { mode: "headless", task: serializeTask(task) };
|
task.status === "failed" &&
|
||||||
|
task.attempts >= MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
|
||||||
|
) {
|
||||||
|
throw new CanvasCapabilityRuntimeError(
|
||||||
|
task.error || "画布能力任务超过最大重试次数",
|
||||||
|
409,
|
||||||
|
"CANVAS_CAPABILITY_RETRY_EXHAUSTED"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldRunHeadlessly = shouldExecuteCanvasCapabilityHeadlessly({
|
const shouldRunHeadlessly = shouldExecuteCanvasCapabilityHeadlessly({
|
||||||
@ -242,9 +241,7 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
command,
|
command,
|
||||||
});
|
});
|
||||||
if (!shouldRunHeadlessly) {
|
if (!shouldRunHeadlessly) {
|
||||||
const claimed = await markCanvasCapabilityTaskRunning(task.id);
|
task = await markCanvasCapabilityTaskRunning(task.id);
|
||||||
if (!claimed) return storedTaskResult((await getCanvasCapabilityTask({ requestId: command.requestId, userId: params.userId, projectId: params.projectId })) || task, "interactive");
|
|
||||||
task = claimed;
|
|
||||||
const result = {
|
const result = {
|
||||||
mode: "interactive" as const,
|
mode: "interactive" as const,
|
||||||
clientCommand: command,
|
clientCommand: command,
|
||||||
@ -253,18 +250,10 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
return { ...result, task: serializeTask(task) };
|
return { ...result, task: serializeTask(task) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
return enqueueCanvasCapabilityExecution({
|
||||||
void heartbeatCanvasCapabilityTask(task.id).catch((error) => {
|
|
||||||
console.warn("[Canvas Capability] Task heartbeat failed:", error);
|
|
||||||
});
|
|
||||||
}, 30_000);
|
|
||||||
try {
|
|
||||||
return await trackRuntimeWork(() => enqueueCanvasCapabilityExecution({
|
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
execute: async () => {
|
execute: async () => {
|
||||||
const claimed = await markCanvasCapabilityTaskRunning(task.id);
|
task = await markCanvasCapabilityTaskRunning(task.id);
|
||||||
if (!claimed) return storedTaskResult((await getCanvasCapabilityTask({ requestId: command.requestId, userId: params.userId, projectId: params.projectId })) || task, "headless");
|
|
||||||
task = claimed;
|
|
||||||
try {
|
try {
|
||||||
const projectRevision = context.project.updatedAt.toISOString();
|
const projectRevision = context.project.updatedAt.toISOString();
|
||||||
const mutationPlan = buildMutationOperationPlan({
|
const mutationPlan = buildMutationOperationPlan({
|
||||||
@ -310,8 +299,5 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
});
|
||||||
} finally {
|
|
||||||
clearInterval(heartbeat);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { and, eq, inArray, lt, sql } from "drizzle-orm";
|
import { and, eq, lt, sql } from "drizzle-orm";
|
||||||
|
|
||||||
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { canvasCapabilityTask } from "@/lib/db/schema";
|
import { canvasCapabilityTask } from "@/lib/db/schema";
|
||||||
|
|
||||||
const STALE_RUNNING_TASK_MS = 15 * 60 * 1000;
|
const STALE_RUNNING_TASK_MS = 15 * 60 * 1000;
|
||||||
const INTERRUPTED_TASK_MESSAGE = "任务执行中断,结果尚未确认。请先查看画布和历史记录,或联系管理员核对原任务,避免重复处理。";
|
export const MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS = 3;
|
||||||
|
|
||||||
export type CanvasCapabilityTaskResult = Record<string, unknown>;
|
export type CanvasCapabilityTaskResult = Record<string, unknown>;
|
||||||
|
|
||||||
@ -25,25 +25,7 @@ export async function getCanvasCapabilityTask(params: {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!task) return null;
|
return task || null;
|
||||||
const staleBefore = new Date(Date.now() - STALE_RUNNING_TASK_MS);
|
|
||||||
if (["pending", "running"].includes(task.status) && task.updatedAt < staleBefore) {
|
|
||||||
const [interrupted] = await db.update(canvasCapabilityTask).set({
|
|
||||||
status: "failed",
|
|
||||||
error: INTERRUPTED_TASK_MESSAGE,
|
|
||||||
completedAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}).where(and(
|
|
||||||
eq(canvasCapabilityTask.id, task.id),
|
|
||||||
inArray(canvasCapabilityTask.status, ["pending", "running"]),
|
|
||||||
lt(canvasCapabilityTask.updatedAt, staleBefore),
|
|
||||||
)).returning();
|
|
||||||
if (interrupted) return interrupted;
|
|
||||||
const [latest] = await db.select().from(canvasCapabilityTask)
|
|
||||||
.where(eq(canvasCapabilityTask.id, task.id)).limit(1);
|
|
||||||
return latest || task;
|
|
||||||
}
|
|
||||||
return task;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createOrRecoverCanvasCapabilityTask(params: {
|
export async function createOrRecoverCanvasCapabilityTask(params: {
|
||||||
@ -60,7 +42,6 @@ export async function createOrRecoverCanvasCapabilityTask(params: {
|
|||||||
capabilityId: params.command.capabilityId,
|
capabilityId: params.command.capabilityId,
|
||||||
source: params.command.source,
|
source: params.command.source,
|
||||||
request: params.command,
|
request: params.command,
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
})
|
||||||
.onConflictDoNothing({ target: canvasCapabilityTask.requestId });
|
.onConflictDoNothing({ target: canvasCapabilityTask.requestId });
|
||||||
|
|
||||||
@ -69,17 +50,57 @@ export async function createOrRecoverCanvasCapabilityTask(params: {
|
|||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
};
|
};
|
||||||
const task = await getCanvasCapabilityTask(taskLookup);
|
let task = await getCanvasCapabilityTask(taskLookup);
|
||||||
if (!task) {
|
if (!task) {
|
||||||
throw new Error("无法创建画布能力任务");
|
throw new Error("无法创建画布能力任务");
|
||||||
}
|
}
|
||||||
|
|
||||||
return task;
|
const staleBefore = new Date(Date.now() - STALE_RUNNING_TASK_MS);
|
||||||
|
if (
|
||||||
|
task.status === "failed" &&
|
||||||
|
task.attempts < MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
|
||||||
|
) {
|
||||||
|
await db
|
||||||
|
.update(canvasCapabilityTask)
|
||||||
|
.set({
|
||||||
|
status: "pending",
|
||||||
|
error: null,
|
||||||
|
completedAt: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(canvasCapabilityTask.id, task.id));
|
||||||
|
} else if (
|
||||||
|
task.status === "running" &&
|
||||||
|
task.updatedAt < staleBefore &&
|
||||||
|
task.attempts < MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
|
||||||
|
) {
|
||||||
|
await db
|
||||||
|
.update(canvasCapabilityTask)
|
||||||
|
.set({
|
||||||
|
status: "pending",
|
||||||
|
error: "恢复超时的执行中任务",
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canvasCapabilityTask.id, task.id),
|
||||||
|
lt(canvasCapabilityTask.updatedAt, staleBefore)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (task.status === "running" && task.updatedAt < staleBefore) {
|
||||||
|
await db
|
||||||
|
.update(canvasCapabilityTask)
|
||||||
|
.set({
|
||||||
|
status: "failed",
|
||||||
|
error: "画布能力任务超过最大重试次数",
|
||||||
|
completedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(canvasCapabilityTask.id, task.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function heartbeatCanvasCapabilityTask(taskId: string) {
|
task = (await getCanvasCapabilityTask(taskLookup)) || task;
|
||||||
await db.update(canvasCapabilityTask).set({ updatedAt: new Date() })
|
return task;
|
||||||
.where(and(eq(canvasCapabilityTask.id, taskId), inArray(canvasCapabilityTask.status, ["pending", "running"])));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markCanvasCapabilityTaskRunning(taskId: string) {
|
export async function markCanvasCapabilityTaskRunning(taskId: string) {
|
||||||
@ -94,10 +115,11 @@ export async function markCanvasCapabilityTaskRunning(taskId: string) {
|
|||||||
error: null,
|
error: null,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.where(and(eq(canvasCapabilityTask.id, taskId), eq(canvasCapabilityTask.status, "pending")))
|
.where(eq(canvasCapabilityTask.id, taskId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return task || null;
|
if (!task) throw new Error("画布能力任务不存在");
|
||||||
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markCanvasCapabilityTaskSucceeded(
|
export async function markCanvasCapabilityTaskSucceeded(
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
export type DownloadFailure = {
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
recommendation: string;
|
|
||||||
status: number;
|
|
||||||
retryable: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getDownloadFailure(error: unknown): DownloadFailure {
|
|
||||||
const text = error && typeof error === "object" && "message" in error
|
|
||||||
? `${"name" in error ? error.name : "Error"}: ${error.message}` : String(error || "");
|
|
||||||
const failure = (code: string, message: string, recommendation: string, status: number, retryable = false) =>
|
|
||||||
({ code, message, recommendation, status, retryable });
|
|
||||||
if (/旧存储已停用|storage_retired/.test(text)) return failure("STORAGE_RETIRED", "原图所在的旧存储已停用。", "请重新上传原图;重试下载无法恢复该文件。", 410);
|
|
||||||
if (/HTTP (?:404|410)|SOURCE_NOT_FOUND/i.test(text)) return failure("SOURCE_NOT_FOUND", "原图链接已失效或源文件已被删除。", "请重新上传原图,或联系管理员核对归档副本;无需重新生成图片。", 404);
|
|
||||||
if (/HTTP (?:401|403)|SOURCE_FORBIDDEN/i.test(text)) return failure("SOURCE_FORBIDDEN", "图片源站拒绝访问原图。", "请联系管理员检查原图访问权限或链接有效期,并提供下载编号。", 403);
|
|
||||||
if (/queue.*busy|正在准备的原图较多|DOWNLOAD_BUSY/i.test(text)) return failure("DOWNLOAD_BUSY", "正在准备的下载较多。", "请等待当前下载完成后再试,避免同时重复点击。", 429, true);
|
|
||||||
if (/TimeoutError|AbortError|timeout|timed out|deadline|超时|HTTP (?:408|504|524)/i.test(text)) return failure("SOURCE_TIMEOUT", "读取原图或准备下载文件超时。", "请稍后再次下载;持续失败时联系管理员并提供下载编号,已归档的原图无需重新生成。", 504, true);
|
|
||||||
if (/fetch failed|failed to fetch|ECONN|ENOTFOUND|EAI_AGAIN|network|HTTP (?:429|5\d\d)/i.test(text)) return failure("SOURCE_UNAVAILABLE", "暂时无法连接图片源站。", "请稍后重试下载;持续失败时联系管理员检查取图线路,并提供下载编号。", 503, true);
|
|
||||||
if (/not.*image|error document|不是图片|无法识别图片|empty response|image is empty|Invalid prepared original/i.test(text)) return failure("INVALID_IMAGE", "源地址没有返回可用的图片文件。", "请重新上传可打开的原图;若原图正常,请联系管理员并提供下载编号。", 422);
|
|
||||||
if (/sharp|pngload|jpegload|unsupported image|无法解析图片|convert|转换/i.test(text)) return failure("IMAGE_CONVERSION_FAILED", "原图无法转换为下载所需的 PNG 文件。", "请保留原图并联系管理员提供下载编号,或重新上传 PNG/JPEG 图片。", 422);
|
|
||||||
if (/upload|存储桶|保存下载|STORAGE_WRITE_FAILED/i.test(text)) return failure("DOWNLOAD_STORAGE_FAILED", "下载文件保存失败,已归档的原图仍然保留。", "请稍后重试;持续失败时联系管理员检查存储,并提供下载编号。", 503, true);
|
|
||||||
if (/未登录/.test(text)) return failure("LOGIN_REQUIRED", "登录状态已失效。", "请重新登录后下载,当前图片不会因此删除。", 401);
|
|
||||||
if (/无法确认原图/.test(text)) return failure("ORIGINAL_IMAGE_UNCONFIRMED", "图片链接包含处理参数,无法确认原图。", "请选择未裁剪、未缩放的原图链接,或重新上传原图。", 400);
|
|
||||||
if (/图片地址无效/.test(text)) return failure("INVALID_IMAGE_URL", "图片地址无效。", "请刷新画布后重新选择图片;仍然失败时请重新上传原图。", 400);
|
|
||||||
return failure("DOWNLOAD_FAILED", "下载准备未能完成。", "请联系管理员并提供下载编号,以便核对原图、转换和存储的具体错误;无需重新生成。", 500, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDownloadFailure(failure: DownloadFailure, requestId?: string) {
|
|
||||||
return `${failure.message}${failure.recommendation}${requestId ? `\n下载编号:${requestId}` : ""}`;
|
|
||||||
}
|
|
||||||
@ -1,10 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { cacheImageBytes, readImageBytes, type ImageBytes } from "@/lib/storage/image-byte-cache";
|
import { cacheImageBytes, readImageBytes, type ImageBytes } from "@/lib/storage/image-byte-cache";
|
||||||
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "./download-error";
|
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
|
||||||
|
|
||||||
const logger = createLogger("canvas.download");
|
|
||||||
|
|
||||||
export type DownloadPreparationInput = {
|
export type DownloadPreparationInput = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -13,18 +9,17 @@ export type DownloadPreparationInput = {
|
|||||||
cookie: string;
|
cookie: string;
|
||||||
};
|
};
|
||||||
type Result =
|
type Result =
|
||||||
| { status: "preparing"; requestId: string }
|
| { status: "preparing" }
|
||||||
| { status: "ready"; asset: ImageBytes }
|
| { status: "ready"; asset: ImageBytes }
|
||||||
| { status: "failed"; error: string; failure: DownloadFailure; requestId: string };
|
| { status: "failed"; error: string };
|
||||||
type Job = {
|
type Job = {
|
||||||
requestId: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
input: DownloadPreparationInput;
|
input: DownloadPreparationInput;
|
||||||
status: "queued" | "running" | "ready" | "failed";
|
status: "queued" | "running" | "ready" | "failed";
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
asset?: ImageBytes;
|
asset?: ImageBytes;
|
||||||
failure?: DownloadFailure;
|
|
||||||
};
|
};
|
||||||
|
const FAILURE_MESSAGE = "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
|
||||||
const MAX_MEMORY_BYTES = 128 * 1024 * 1024;
|
const MAX_MEMORY_BYTES = 128 * 1024 * 1024;
|
||||||
|
|
||||||
// A download is independent of node autosave and of the archive worker's batch.
|
// A download is independent of node autosave and of the archive worker's batch.
|
||||||
@ -74,7 +69,7 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
reject(new Error("Download preparation deadline"));
|
reject(new Error("Download preparation deadline"));
|
||||||
}, deps.timeoutMs || 150_000);
|
}, deps.timeoutMs || 90_000);
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
if (asset.contentType !== "image/png" || !asset.buffer.length || asset.buffer.length > MAX_MEMORY_BYTES) {
|
if (asset.contentType !== "image/png" || !asset.buffer.length || asset.buffer.length > MAX_MEMORY_BYTES) {
|
||||||
@ -86,10 +81,8 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
const stored = await deps.read(key).catch(() => null);
|
const stored = await deps.read(key).catch(() => null);
|
||||||
if (stored?.contentType === "image/png" && stored.buffer.length) job.asset = undefined;
|
if (stored?.contentType === "image/png" && stored.buffer.length) job.asset = undefined;
|
||||||
job.status = "ready";
|
job.status = "ready";
|
||||||
} catch (error) {
|
} catch {
|
||||||
job.status = "failed";
|
job.status = "failed";
|
||||||
job.failure = getDownloadFailure(error);
|
|
||||||
logger.warn("preparation_failed", error, { requestId: job.requestId, code: job.failure.code });
|
|
||||||
} finally {
|
} finally {
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
// Do not retain authentication data after a preparation finishes.
|
// Do not retain authentication data after a preparation finishes.
|
||||||
@ -112,11 +105,8 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
prune();
|
prune();
|
||||||
const current = jobs.get(key);
|
const current = jobs.get(key);
|
||||||
if (current?.status === "ready" && current.asset) return { status: "ready", asset: current.asset };
|
if (current?.status === "ready" && current.asset) return { status: "ready", asset: current.asset };
|
||||||
if (current?.status === "failed") {
|
if (current?.status === "failed") return { status: "failed", error: FAILURE_MESSAGE };
|
||||||
const failure = current.failure || getDownloadFailure(null);
|
if (current && current.status !== "ready") return { status: "preparing" };
|
||||||
return { status: "failed", error: formatDownloadFailure(failure, current.requestId), failure, requestId: current.requestId };
|
|
||||||
}
|
|
||||||
if (current && current.status !== "ready") return { status: "preparing", requestId: current.requestId };
|
|
||||||
if (jobs.size >= 64) {
|
if (jobs.size >= 64) {
|
||||||
for (const [oldKey, job] of jobs) {
|
for (const [oldKey, job] of jobs) {
|
||||||
if (job.status === "ready" || job.status === "failed") jobs.delete(oldKey);
|
if (job.status === "ready" || job.status === "failed") jobs.delete(oldKey);
|
||||||
@ -125,14 +115,11 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
}
|
}
|
||||||
if (jobs.size >= 64 || [...jobs.values()].filter(job => job.userId === input.userId &&
|
if (jobs.size >= 64 || [...jobs.values()].filter(job => job.userId === input.userId &&
|
||||||
(job.status === "queued" || job.status === "running")).length >= 4) {
|
(job.status === "queued" || job.status === "running")).length >= 4) {
|
||||||
const requestId = randomUUID();
|
return { status: "failed", error: "正在准备的原图较多,请等待当前下载完成后再试" };
|
||||||
const failure = getDownloadFailure("DOWNLOAD_BUSY");
|
|
||||||
return { status: "failed", error: formatDownloadFailure(failure, requestId), failure, requestId };
|
|
||||||
}
|
}
|
||||||
const requestId = randomUUID();
|
jobs.set(key, { userId: input.userId, input, status: "queued", updatedAt: now() });
|
||||||
jobs.set(key, { requestId, userId: input.userId, input, status: "queued", updatedAt: now() });
|
|
||||||
deps.schedule(drain);
|
deps.schedule(drain);
|
||||||
return { status: "preparing", requestId };
|
return { status: "preparing" };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { generateCanvasImage, type CanvasGenerateImageRequest } from "@/lib/canv
|
|||||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
import { getImageToolCreditCost, type ImageToolCreditFeature } from "@/lib/points/config";
|
import { getImageToolCreditCost, type ImageToolCreditFeature } from "@/lib/points/config";
|
||||||
import { getUserFacingImageGenerationErrorMessage, getRequestFailureDetails } from "@/lib/request-error";
|
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
||||||
|
|
||||||
interface GenerateImageRequest extends CanvasGenerateImageRequest {
|
interface GenerateImageRequest extends CanvasGenerateImageRequest {
|
||||||
async?: boolean;
|
async?: boolean;
|
||||||
@ -121,7 +121,7 @@ export async function executeCanvasImageResponse(body: GenerateImageRequest, use
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Canvas Generate Image] Error:", error);
|
console.error("[Canvas Generate Image] Error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: getUserFacingImageGenerationErrorMessage(error), failure: getRequestFailureDetails(error),
|
{ success: false, error: getUserFacingImageGenerationErrorMessage(error),
|
||||||
...(error instanceof ProviderTaskUncertainError ? { code: "PROVIDER_TASK_UNCERTAIN" } : {}) },
|
...(error instanceof ProviderTaskUncertainError ? { code: "PROVIDER_TASK_UNCERTAIN" } : {}) },
|
||||||
{ status: error instanceof ProviderTaskUncertainError ? 503 : 500 },
|
{ status: error instanceof ProviderTaskUncertainError ? 503 : 500 },
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { agentImageCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-sto
|
|||||||
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
|
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
|
||||||
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
|
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
|
||||||
import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
||||||
import { getAiModelById, getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||||
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
||||||
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
|
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
|
||||||
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
|
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
|
||||||
@ -43,9 +43,7 @@ import {
|
|||||||
shouldFailoverAiModelError,
|
shouldFailoverAiModelError,
|
||||||
} 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 { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
|
||||||
import { getRequestFailureDetails, isProviderBalanceError } from "@/lib/request-error";
|
|
||||||
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
|
|
||||||
|
|
||||||
export interface CanvasLocalEditCompositeRegion {
|
export interface CanvasLocalEditCompositeRegion {
|
||||||
x: number;
|
x: number;
|
||||||
@ -200,7 +198,6 @@ export interface CanvasGenerateImageRequest {
|
|||||||
agentImageMaxCreditCost?: number;
|
agentImageMaxCreditCost?: number;
|
||||||
internalFailoverTriedModelRefs?: string[];
|
internalFailoverTriedModelRefs?: string[];
|
||||||
internalFailoverTriedProviderTypes?: string[];
|
internalFailoverTriedProviderTypes?: string[];
|
||||||
internalBalanceBlockedProviderIds?: string[];
|
|
||||||
onQueueStatusChange?: (
|
onQueueStatusChange?: (
|
||||||
status: CanvasImageModelQueueStatus & {
|
status: CanvasImageModelQueueStatus & {
|
||||||
modelName?: string | null;
|
modelName?: string | null;
|
||||||
@ -4515,7 +4512,7 @@ function findCanvasFailoverModel(params: {
|
|||||||
!item.model.isEnabled ||
|
!item.model.isEnabled ||
|
||||||
item.model.healthStatus === "auto_disabled" ||
|
item.model.healthStatus === "auto_disabled" ||
|
||||||
item.model.isSubModel ||
|
item.model.isSubModel ||
|
||||||
isImageModelBalanceBlocked(item.model) ||
|
isImageFallbackModelBalanceCoolingDown(item.model) ||
|
||||||
(item.model.excludeFromFailover && !isSameNameScaleAdjustFallback)
|
(item.model.excludeFromFailover && !isSameNameScaleAdjustFallback)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@ -4633,10 +4630,10 @@ function findCanvasFailoverModel(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateCanvasImage(
|
export async function generateCanvasImage(
|
||||||
input: CanvasGenerateImageRequest
|
request: CanvasGenerateImageRequest
|
||||||
): Promise<CanvasGenerateImageResult> {
|
): Promise<CanvasGenerateImageResult> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const request = input.agentImageMode === "pinned" ? { ...input, allowFailover: false } : input;
|
if (request.agentImageMode === "pinned") request = { ...request, allowFailover: false };
|
||||||
const prompt = String(request.prompt || "").trim();
|
const prompt = String(request.prompt || "").trim();
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
throw new Error("请输入提示词");
|
throw new Error("请输入提示词");
|
||||||
@ -4874,16 +4871,11 @@ export async function generateCanvasImage(
|
|||||||
typeof request.ownerKey === "string" && request.ownerKey.trim()
|
typeof request.ownerKey === "string" && request.ownerKey.trim()
|
||||||
? request.ownerKey.trim()
|
? request.ownerKey.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
const resolveReference = async (url: string) => {
|
const referenceImage =
|
||||||
if (!normalizeLegacyAliyunStorageUrl(url)) return url;
|
typeof request.referenceImage === "string" ? request.referenceImage : undefined;
|
||||||
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
|
const referenceImages = Array.isArray(request.referenceImages)
|
||||||
return resolveLegacyAliyunStorageUrl(url);
|
|
||||||
};
|
|
||||||
const referenceImage = typeof request.referenceImage === "string"
|
|
||||||
? await resolveReference(request.referenceImage) : undefined;
|
|
||||||
const referenceImages = await Promise.all((Array.isArray(request.referenceImages)
|
|
||||||
? request.referenceImages
|
? request.referenceImages
|
||||||
: []).map(resolveReference));
|
: [];
|
||||||
const debugLabel = String(request.debugLabel || "").trim() || "canvas-image";
|
const debugLabel = String(request.debugLabel || "").trim() || "canvas-image";
|
||||||
const debugMeta =
|
const debugMeta =
|
||||||
request.debugMeta && typeof request.debugMeta === "object"
|
request.debugMeta && typeof request.debugMeta === "object"
|
||||||
@ -4912,20 +4904,7 @@ export async function generateCanvasImage(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const allModels = await getEnabledAiModelsWithProvider();
|
const allModels = await getEnabledAiModelsWithProvider();
|
||||||
const balanceBlockedProviderIds = new Set(request.internalBalanceBlockedProviderIds || []);
|
let imageModels = allModels.filter((m) => m.model.supportsImageGen);
|
||||||
let imageModels = allModels.filter((m) => m.model.supportsImageGen && !isImageModelBalanceBlocked(m.model) && !balanceBlockedProviderIds.has(m.provider.id));
|
|
||||||
const requestedModel = allModels.find(entry => entry.model.id === model || entry.model.modelId === model)?.model ||
|
|
||||||
(/^[0-9a-f-]{36}$/i.test(String(model || "")) ? await getAiModelById({ id: model! }) : undefined);
|
|
||||||
let effectiveModel = model;
|
|
||||||
if (requestedModel?.supportsImageGen && isImageModelBalanceBlocked(requestedModel)) {
|
|
||||||
const replacement = request.allowFailover !== false && request.agentImageMode !== "pinned"
|
|
||||||
? resolveConfiguredImageBalanceFallback(requestedModel, imageModels, {
|
|
||||||
imageSize: request.imageSize, aspectRatio: request.aspectRatio,
|
|
||||||
referenceImageCount: new Set([...(referenceImages || []), referenceImage].filter(Boolean)).size,
|
|
||||||
}) : undefined;
|
|
||||||
if (!replacement) throw new Error("当前生图线路余额不足,已暂停新请求;请切换可用模型,已有任务和图片会保留。");
|
|
||||||
effectiveModel = replacement.model.id;
|
|
||||||
}
|
|
||||||
if (request.agentImageMode === "auto") {
|
if (request.agentImageMode === "auto") {
|
||||||
const states = await Promise.all(imageModels.map(async entry => ({ entry, paused: await agentImageCircuitStore.isPaused(entry) })));
|
const states = await Promise.all(imageModels.map(async entry => ({ entry, paused: await agentImageCircuitStore.isPaused(entry) })));
|
||||||
imageModels = states.filter(item => !item.paused && item.entry.model.healthStatus !== "auto_disabled" &&
|
imageModels = states.filter(item => !item.paused && item.entry.model.healthStatus !== "auto_disabled" &&
|
||||||
@ -4937,8 +4916,8 @@ export async function generateCanvasImage(
|
|||||||
throw new Error("暂无可用的图片生成模型");
|
throw new Error("暂无可用的图片生成模型");
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedModel = effectiveModel
|
let selectedModel = model
|
||||||
? imageModels.find((m) => m.model.modelId === effectiveModel || m.model.id === effectiveModel)
|
? imageModels.find((m) => m.model.modelId === model || m.model.id === model)
|
||||||
: imageModels.find((m) => m.model.isDefault) || imageModels[0];
|
: imageModels.find((m) => m.model.isDefault) || imageModels[0];
|
||||||
|
|
||||||
if (!selectedModel) {
|
if (!selectedModel) {
|
||||||
@ -5663,11 +5642,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
|
||||||
@ -5679,7 +5654,7 @@ export async function generateCanvasImage(
|
|||||||
? request.internalRetryVariantIndex + 1
|
? request.internalRetryVariantIndex + 1
|
||||||
: 0;
|
: 0;
|
||||||
const retryVariant = retryVariants[nextRetryVariantIndex];
|
const retryVariant = retryVariants[nextRetryVariantIndex];
|
||||||
if (retryVariant && !request.agentImageMode && !isProviderBalanceError(caughtError)) {
|
if (retryVariant && !request.agentImageMode) {
|
||||||
const {
|
const {
|
||||||
debugMeta: retryVariantDebugMeta,
|
debugMeta: retryVariantDebugMeta,
|
||||||
label: retryVariantLabel,
|
label: retryVariantLabel,
|
||||||
@ -5735,8 +5710,7 @@ export async function generateCanvasImage(
|
|||||||
} else if (request.agentImageMode) {
|
} else if (request.agentImageMode) {
|
||||||
const failure = agentImageFailurePolicy(caughtError);
|
const failure = agentImageFailurePolicy(caughtError);
|
||||||
if (failure.pause) await agentImageCircuitStore.fail(selectedModel, failure.pause);
|
if (failure.pause) await agentImageCircuitStore.fail(selectedModel, failure.pause);
|
||||||
}
|
} else {
|
||||||
if (!scaleAdjustRejected && (!request.agentImageMode || isProviderBalanceError(caughtError))) {
|
|
||||||
await recordAiModelHealthFailure({
|
await recordAiModelHealthFailure({
|
||||||
model: selectedModel.model,
|
model: selectedModel.model,
|
||||||
provider,
|
provider,
|
||||||
@ -5782,18 +5756,17 @@ export async function generateCanvasImage(
|
|||||||
...(Array.isArray(request.internalFailoverTriedProviderTypes)
|
...(Array.isArray(request.internalFailoverTriedProviderTypes)
|
||||||
? request.internalFailoverTriedProviderTypes
|
? request.internalFailoverTriedProviderTypes
|
||||||
: []),
|
: []),
|
||||||
...(request.agentImageMode !== "auto" && !isProviderBalanceError(caughtError) && shouldFailover &&
|
...(request.agentImageMode !== "auto" && shouldFailover &&
|
||||||
failureClassification.reason !== "non_provider_failure" &&
|
failureClassification.reason !== "non_provider_failure" &&
|
||||||
failureClassification.reason !== "user_cancelled"
|
failureClassification.reason !== "user_cancelled"
|
||||||
? [provider.type]
|
? [provider.type]
|
||||||
: []),
|
: []),
|
||||||
].filter(Boolean))
|
].filter(Boolean))
|
||||||
);
|
);
|
||||||
if (isProviderBalanceError(caughtError)) balanceBlockedProviderIds.add(provider.id);
|
|
||||||
const fallbackModel = allowFailover && shouldFailover
|
const fallbackModel = allowFailover && shouldFailover
|
||||||
? findCanvasFailoverModel({
|
? findCanvasFailoverModel({
|
||||||
selectedModel,
|
selectedModel,
|
||||||
imageModels: imageModels.filter(entry => !balanceBlockedProviderIds.has(entry.provider.id)),
|
imageModels,
|
||||||
failedModelRefs,
|
failedModelRefs,
|
||||||
failedProviderTypes,
|
failedProviderTypes,
|
||||||
preferLowCostSameNameProviders: Boolean(
|
preferLowCostSameNameProviders: Boolean(
|
||||||
@ -5824,7 +5797,6 @@ export async function generateCanvasImage(
|
|||||||
model: fallbackModel.model.id,
|
model: fallbackModel.model.id,
|
||||||
internalFailoverTriedModelRefs: failedModelRefs,
|
internalFailoverTriedModelRefs: failedModelRefs,
|
||||||
internalFailoverTriedProviderTypes: failedProviderTypes,
|
internalFailoverTriedProviderTypes: failedProviderTypes,
|
||||||
internalBalanceBlockedProviderIds: [...balanceBlockedProviderIds],
|
|
||||||
debugMeta: {
|
debugMeta: {
|
||||||
...debugMeta,
|
...debugMeta,
|
||||||
failoverFromModelId: selectedModel.model.modelId,
|
failoverFromModelId: selectedModel.model.modelId,
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -38,7 +38,6 @@ import {
|
|||||||
} from "@/lib/agents/detail-replacement-batch";
|
} from "@/lib/agents/detail-replacement-batch";
|
||||||
import { executeDetailReplacementBatch } from "@/lib/agents/detail-replacement-batch-runner";
|
import { executeDetailReplacementBatch } from "@/lib/agents/detail-replacement-batch-runner";
|
||||||
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
||||||
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
|
||||||
|
|
||||||
type CanvasImageTaskStatus =
|
type CanvasImageTaskStatus =
|
||||||
| "waiting"
|
| "waiting"
|
||||||
@ -329,7 +328,7 @@ async function markStaleDatabaseRunningTasksFailed() {
|
|||||||
.update(canvasImageTask)
|
.update(canvasImageTask)
|
||||||
.set({
|
.set({
|
||||||
status: "failed",
|
status: "failed",
|
||||||
error: "图片任务执行中断或等待超时,结果尚未确认。请先查看原任务和历史记录,联系管理员核对,避免重复生成。",
|
error: "图片生成任务已超时,请重新发起生成",
|
||||||
completedAt,
|
completedAt,
|
||||||
updatedAt: completedAt,
|
updatedAt: completedAt,
|
||||||
})
|
})
|
||||||
@ -384,7 +383,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const runningTimeoutMs = getCanvasImageRunningTimeoutMs(request);
|
const runningTimeoutMs = getCanvasImageRunningTimeoutMs(request);
|
||||||
const execution: Promise<CanvasImageTaskResult> = trackRuntimeWork<CanvasImageTaskResult>(() => isStyleProTaskRequest(request)
|
const execution: Promise<CanvasImageTaskResult> = isStyleProTaskRequest(request)
|
||||||
? executeStyleProTask({
|
? executeStyleProTask({
|
||||||
request,
|
request,
|
||||||
userId: String(task.userId || entry.userId),
|
userId: String(task.userId || entry.userId),
|
||||||
@ -413,7 +412,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
|||||||
ownerKey,
|
ownerKey,
|
||||||
request: imageRequest,
|
request: imageRequest,
|
||||||
});
|
});
|
||||||
})());
|
})();
|
||||||
const heartbeat = isDetailReplacementBatchRequest(request)
|
const heartbeat = isDetailReplacementBatchRequest(request)
|
||||||
? setInterval(() => {
|
? setInterval(() => {
|
||||||
void db.update(canvasImageTask).set({ updatedAt: new Date() })
|
void db.update(canvasImageTask).set({ updatedAt: new Date() })
|
||||||
@ -427,7 +426,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
|||||||
try {
|
try {
|
||||||
result = await withCanvasImageTaskExecutionTimeout(
|
result = await withCanvasImageTaskExecutionTimeout(
|
||||||
execution,
|
execution,
|
||||||
Math.max(runningTimeoutMs, LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS),
|
runningTimeoutMs,
|
||||||
entry.taskId
|
entry.taskId
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@ -752,7 +751,7 @@ export async function getCanvasImageTaskForUser(params: {
|
|||||||
|
|
||||||
if (shouldMarkStale) {
|
if (shouldMarkStale) {
|
||||||
const staleBefore = new Date(now - runningTimeoutMs);
|
const staleBefore = new Date(now - runningTimeoutMs);
|
||||||
const timeoutMessage = "图片任务执行中断或等待超时,结果尚未确认。请先查看原任务和历史记录,联系管理员核对,避免重复生成。";
|
const timeoutMessage = "图片生成任务已超时,请重新发起生成";
|
||||||
const completedAt = new Date();
|
const completedAt = new Date();
|
||||||
const [timedOutTask] = await db
|
const [timedOutTask] = await db
|
||||||
.update(canvasImageTask)
|
.update(canvasImageTask)
|
||||||
|
|||||||
@ -1,113 +0,0 @@
|
|||||||
export type MarkdownImageSegment =
|
|
||||||
| { type: "text"; value: string }
|
|
||||||
| { type: "image"; alt?: string; src: string };
|
|
||||||
|
|
||||||
// Markdown destinations may include balanced parentheses and backslash escapes.
|
|
||||||
function readDestination(content: string, open: number) {
|
|
||||||
if (content[open] !== "(") return null;
|
|
||||||
let depth = 1;
|
|
||||||
for (let index = open + 1; index < content.length; index += 1) {
|
|
||||||
const character = content[index];
|
|
||||||
if (character === "\\") {
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (character === "(") depth += 1;
|
|
||||||
if (character === ")" && --depth === 0) {
|
|
||||||
const raw = content.slice(open + 1, index);
|
|
||||||
if (!/^https?:\/\//i.test(raw) || /\s/.test(raw)) return null;
|
|
||||||
return { url: raw.replace(/\\([()\\])/g, "$1"), end: index + 1 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readImage(content: string, start: number) {
|
|
||||||
if (!content.startsWith("![", start)) return null;
|
|
||||||
let depth = 1;
|
|
||||||
for (let index = start + 2; index < content.length; index += 1) {
|
|
||||||
const character = content[index];
|
|
||||||
if (character === "\\") {
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (character === "[") depth += 1;
|
|
||||||
if (character !== "]" || --depth !== 0) continue;
|
|
||||||
const destination = readDestination(content, index + 1);
|
|
||||||
if (!destination) return null;
|
|
||||||
return {
|
|
||||||
alt: content.slice(start + 2, index).replace(/\\([\[\]\\])/g, "$1"),
|
|
||||||
src: destination.url,
|
|
||||||
end: destination.end,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function splitMarkdownImages(content: string): MarkdownImageSegment[] {
|
|
||||||
const segments: MarkdownImageSegment[] = [];
|
|
||||||
let textStart = 0;
|
|
||||||
let cursor = 0;
|
|
||||||
while (cursor < content.length) {
|
|
||||||
// Leave Markdown examples in code spans and fences untouched.
|
|
||||||
if (content[cursor] === "`") {
|
|
||||||
let endOfRun = cursor + 1;
|
|
||||||
while (content[endOfRun] === "`") endOfRun += 1;
|
|
||||||
const marker = content.slice(cursor, endOfRun);
|
|
||||||
const close = content.indexOf(marker, endOfRun);
|
|
||||||
cursor = close < 0 ? content.length : close + marker.length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const lineStart = cursor === 0 || content[cursor - 1] === "\n";
|
|
||||||
if (lineStart) {
|
|
||||||
const fence = content.slice(cursor).match(/^ {0,3}(~{3,})[^\n]*(?:\n|$)/);
|
|
||||||
if (fence) {
|
|
||||||
const marker = fence[1];
|
|
||||||
const closing = new RegExp(`^ {0,3}~{${marker.length},}\\s*$`);
|
|
||||||
let line = cursor + fence[0].length;
|
|
||||||
while (line < content.length) {
|
|
||||||
const next = content.indexOf("\n", line);
|
|
||||||
const end = next < 0 ? content.length : next;
|
|
||||||
if (closing.test(content.slice(line, end))) {
|
|
||||||
line = next < 0 ? end : end + 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
line = next < 0 ? end : end + 1;
|
|
||||||
}
|
|
||||||
cursor = line;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (content[cursor] === "\\") {
|
|
||||||
cursor += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const wrapped = content.startsWith("[![", cursor);
|
|
||||||
const image = readImage(content, cursor + (wrapped ? 1 : 0));
|
|
||||||
if (!image) {
|
|
||||||
cursor += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let end = image.end;
|
|
||||||
if (wrapped) {
|
|
||||||
const outer = content[end] === "]" ? readDestination(content, end + 1) : null;
|
|
||||||
// Preserve incomplete, unsafe and different-target links as Markdown so
|
|
||||||
// Streamdown applies its normal link sanitization and click behavior.
|
|
||||||
if (!outer || outer.url !== image.src) {
|
|
||||||
cursor = outer ? outer.end : end;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
end = outer.end;
|
|
||||||
}
|
|
||||||
if (cursor > textStart) {
|
|
||||||
segments.push({ type: "text", value: content.slice(textStart, cursor) });
|
|
||||||
}
|
|
||||||
segments.push({ type: "image", alt: image.alt, src: image.src });
|
|
||||||
cursor = end;
|
|
||||||
textStart = end;
|
|
||||||
}
|
|
||||||
if (textStart < content.length) {
|
|
||||||
segments.push({ type: "text", value: content.slice(textStart) });
|
|
||||||
}
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
/** A half-open span containing a complete Markdown link or image. */
|
|
||||||
export type MarkdownImageRange = { start: number; end: number };
|
|
||||||
|
|
||||||
type Parsed = { kind: "complete"; end: number } | { kind: "pending" } | { kind: "invalid" };
|
|
||||||
|
|
||||||
// This scanner recognizes syntax only; rendering and URL safety remain the
|
|
||||||
// responsibility of the existing Markdown renderer and image splitter.
|
|
||||||
function readDestination(text: string, start: number, allowWhitespace = false): Parsed {
|
|
||||||
if (start >= text.length) return { kind: "pending" };
|
|
||||||
if (text[start] !== "(") return { kind: "invalid" };
|
|
||||||
let depth = 1;
|
|
||||||
for (let i = start + 1; i < text.length; i += 1) {
|
|
||||||
const char = text[i];
|
|
||||||
if (char === "\\") {
|
|
||||||
if (i + 1 >= text.length) return { kind: "pending" };
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (char === "\n" || char === "\r" || (!allowWhitespace && /\s/.test(char))) return { kind: "invalid" };
|
|
||||||
if (char === "(") depth += 1;
|
|
||||||
if (char === ")" && --depth === 0) return { kind: "complete", end: i + 1 };
|
|
||||||
}
|
|
||||||
return { kind: "pending" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function readImage(text: string, start: number, wrapped: boolean): Parsed {
|
|
||||||
const imageStart = start + (wrapped ? 1 : 0);
|
|
||||||
if (imageStart + 1 >= text.length) return { kind: "pending" };
|
|
||||||
if (text[imageStart] !== "!" || text[imageStart + 1] !== "[") return { kind: "invalid" };
|
|
||||||
let depth = 1;
|
|
||||||
for (let i = imageStart + 2; i < text.length; i += 1) {
|
|
||||||
const char = text[i];
|
|
||||||
if (char === "\n" || char === "\r") return { kind: "invalid" };
|
|
||||||
if (char === "\\") {
|
|
||||||
if (i + 1 >= text.length) return { kind: "pending" };
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (char === "[") depth += 1;
|
|
||||||
if (char !== "]" || --depth !== 0) continue;
|
|
||||||
const inner = readDestination(text, i + 1);
|
|
||||||
if (inner.kind !== "complete" || !wrapped) return inner;
|
|
||||||
if (inner.end >= text.length) return { kind: "pending" };
|
|
||||||
if (text[inner.end] !== "]") return { kind: "invalid" };
|
|
||||||
return readDestination(text, inner.end + 1);
|
|
||||||
}
|
|
||||||
return { kind: "pending" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function readLink(text: string, start: number): Parsed {
|
|
||||||
let depth = 1;
|
|
||||||
for (let i = start + 1; i < text.length; i += 1) {
|
|
||||||
const char = text[i];
|
|
||||||
// An unfinished bracket on an earlier line must not hide later output.
|
|
||||||
if (char === "\n" || char === "\r") return { kind: "invalid" };
|
|
||||||
if (char === "\\") {
|
|
||||||
if (i + 1 >= text.length) return { kind: "pending" };
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (char === "[") depth += 1;
|
|
||||||
if (char !== "]" || --depth !== 0) continue;
|
|
||||||
if (text[i + 1] === "[") {
|
|
||||||
for (let j = i + 2; j < text.length; j += 1) {
|
|
||||||
if (text[j] === "\n" || text[j] === "\r") return { kind: "invalid" };
|
|
||||||
if (text[j] === "\\") {
|
|
||||||
if (j + 1 >= text.length) return { kind: "pending" };
|
|
||||||
j += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (text[j] === "]") return { kind: "complete", end: j + 1 };
|
|
||||||
}
|
|
||||||
return { kind: "pending" };
|
|
||||||
}
|
|
||||||
return readDestination(text, i + 1, true);
|
|
||||||
}
|
|
||||||
return { kind: "pending" };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Locate complete Markdown links/images and an incomplete trailing construct.
|
|
||||||
* Code spans/fences and escaped markers remain normal text. */
|
|
||||||
export function scanMarkdownImageStreaming(text: string): {
|
|
||||||
ranges: MarkdownImageRange[];
|
|
||||||
pendingStart: number | null;
|
|
||||||
} {
|
|
||||||
const ranges: MarkdownImageRange[] = [];
|
|
||||||
let cursor = 0;
|
|
||||||
while (cursor < text.length) {
|
|
||||||
if (text[cursor] === "\\") { cursor += 2; continue; }
|
|
||||||
if (text[cursor] === "`") {
|
|
||||||
let end = cursor + 1;
|
|
||||||
while (text[end] === "`") end += 1;
|
|
||||||
const marker = text.slice(cursor, end);
|
|
||||||
const close = text.indexOf(marker, end);
|
|
||||||
cursor = close < 0 ? text.length : close + marker.length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (cursor === 0 || text[cursor - 1] === "\n") {
|
|
||||||
const fence = text.slice(cursor).match(/^ {0,3}(~{3,})[^\n]*(?:\n|$)/);
|
|
||||||
if (fence) {
|
|
||||||
const closing = new RegExp(`^ {0,3}~{${fence[1].length},}\\s*$`);
|
|
||||||
let line = cursor + fence[0].length;
|
|
||||||
while (line < text.length) {
|
|
||||||
const next = text.indexOf("\n", line);
|
|
||||||
const end = next < 0 ? text.length : next;
|
|
||||||
if (closing.test(text.slice(line, end))) { line = next < 0 ? end : end + 1; break; }
|
|
||||||
line = next < 0 ? end : end + 1;
|
|
||||||
}
|
|
||||||
cursor = line;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const char = text[cursor];
|
|
||||||
if (char !== "[" && char !== "!") { cursor += 1; continue; }
|
|
||||||
const wrapped = char === "[";
|
|
||||||
const parsed = wrapped && text[cursor + 1] !== "!"
|
|
||||||
? readLink(text, cursor)
|
|
||||||
: readImage(text, cursor, wrapped);
|
|
||||||
if (parsed.kind === "pending") return { ranges, pendingStart: cursor };
|
|
||||||
if (parsed.kind === "complete") { ranges.push({ start: cursor, end: parsed.end }); cursor = parsed.end; }
|
|
||||||
else cursor += 1;
|
|
||||||
}
|
|
||||||
return { ranges, pendingStart: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** During active delivery, withhold only unfinished link/image syntax at the tail. */
|
|
||||||
export function stableStreamingMarkdown(text: string, streaming: boolean): string {
|
|
||||||
if (!streaming) return text;
|
|
||||||
const { pendingStart } = scanMarkdownImageStreaming(text);
|
|
||||||
return pendingStart === null ? text : text.slice(0, pendingStart);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function advancePastAtomicRanges(length: number, target: number, ranges: MarkdownImageRange[]) {
|
|
||||||
let position = Math.min(length, target);
|
|
||||||
// Overlapping table/link ranges can require more than one jump.
|
|
||||||
for (let changed = true; changed;) {
|
|
||||||
changed = false;
|
|
||||||
for (const range of ranges) {
|
|
||||||
if (position > range.start && position < range.end) {
|
|
||||||
position = Math.min(length, range.end);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return position;
|
|
||||||
}
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import {
|
|
||||||
BoxCutoutServiceError,
|
|
||||||
executeBoxCutoutJob,
|
|
||||||
} from "@/lib/ai/box-cutout/service";
|
|
||||||
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
|
|
||||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
|
||||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
|
||||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
|
||||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
|
||||||
|
|
||||||
type BoxCutoutRequest = {
|
|
||||||
imageUrl?: string;
|
|
||||||
cropRect?: {
|
|
||||||
x?: number;
|
|
||||||
y?: number;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
};
|
|
||||||
paddingRatio?: number;
|
|
||||||
prompt?: string;
|
|
||||||
chatId?: string | null;
|
|
||||||
messageId?: string | null;
|
|
||||||
skipGallerySave?: boolean;
|
|
||||||
sourceNodeId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function executeBoxCutoutPOST(request: Request, userId: string) {
|
|
||||||
const requestId = request.headers.get("x-request-id") || crypto.randomUUID();
|
|
||||||
try {
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await request.json()) as BoxCutoutRequest;
|
|
||||||
const rawImageUrl = String(body.imageUrl || "").trim();
|
|
||||||
if (!rawImageUrl) {
|
|
||||||
throw new BoxCutoutServiceError("缺少原图链接,请重新选择图片后提交。", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
|
|
||||||
const pointsConfig = await getPointsSystemConfig();
|
|
||||||
const pointsCharge = await prepareFeaturePointsCharge({
|
|
||||||
userId,
|
|
||||||
unitCost: getImageToolCreditCost(pointsConfig, "box-cutout"),
|
|
||||||
reason: "image_box_cutout",
|
|
||||||
modelId: "box-cutout",
|
|
||||||
note: "框选抠图消耗积分",
|
|
||||||
pointsConfig,
|
|
||||||
});
|
|
||||||
if (!pointsCharge.ok) {
|
|
||||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await executeBoxCutoutJob({
|
|
||||||
requestId,
|
|
||||||
userId,
|
|
||||||
imageUrl: resolvedImageUrl,
|
|
||||||
cropRect: body.cropRect || {},
|
|
||||||
paddingRatio: body.paddingRatio,
|
|
||||||
prompt: body.prompt || "框选抠图",
|
|
||||||
chatId: body.chatId || null,
|
|
||||||
messageId: body.messageId || null,
|
|
||||||
skipGallerySave: body.skipGallerySave === true,
|
|
||||||
sourceNodeId: body.sourceNodeId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pointsCharge.chargeable) {
|
|
||||||
try {
|
|
||||||
await pointsCharge.consume();
|
|
||||||
} catch (pointsError) {
|
|
||||||
console.error("[Points] Failed to consume box cutout points:", pointsError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ ...result, requestId });
|
|
||||||
} catch (error) {
|
|
||||||
const failure = getRequestFailureDetails(error, "框选抠图未能完成");
|
|
||||||
const payload = {
|
|
||||||
success: false,
|
|
||||||
error: formatRequestFailure(failure, requestId),
|
|
||||||
detail: error instanceof Error ? error.message : "框选抠图未能完成",
|
|
||||||
code: failure.code,
|
|
||||||
failure,
|
|
||||||
requestId,
|
|
||||||
};
|
|
||||||
if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) {
|
|
||||||
return NextResponse.json(payload, { status: error.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error("[BoxCutout] 处理失败:", error);
|
|
||||||
return NextResponse.json(payload, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,11 +9,9 @@ import {
|
|||||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
|
||||||
|
|
||||||
|
|
||||||
export async function executeRemoveBackgroundPOST(request: Request, trustedUserId: string) {
|
export async function executeRemoveBackgroundPOST(request: Request, trustedUserId: string) {
|
||||||
const requestId = request.headers.get("x-request-id") || crypto.randomUUID();
|
|
||||||
try {
|
try {
|
||||||
const session = { user: { id: trustedUserId } };
|
const session = { user: { id: trustedUserId } };
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
@ -29,7 +27,6 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
|
|||||||
let messageId: string | null = null;
|
let messageId: string | null = null;
|
||||||
let skipGallerySave = false;
|
let skipGallerySave = false;
|
||||||
let sourceImageUrl: string | null = null;
|
let sourceImageUrl: string | null = null;
|
||||||
let inputImageUrl: string | null = null;
|
|
||||||
|
|
||||||
if (contentType.includes("application/json")) {
|
if (contentType.includes("application/json")) {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@ -43,7 +40,6 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
|
|||||||
|
|
||||||
if (imageUrl) {
|
if (imageUrl) {
|
||||||
sourceImageUrl = String(imageUrl);
|
sourceImageUrl = String(imageUrl);
|
||||||
inputImageUrl = sourceImageUrl;
|
|
||||||
imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl);
|
imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl);
|
||||||
} else if (body.image_base64) {
|
} else if (body.image_base64) {
|
||||||
imageBase64 = String(body.image_base64);
|
imageBase64 = String(body.image_base64);
|
||||||
@ -98,7 +94,6 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await executeRmbgJob({
|
const result = await executeRmbgJob({
|
||||||
requestId,
|
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
imageBuffer,
|
imageBuffer,
|
||||||
imageBase64,
|
imageBase64,
|
||||||
@ -106,7 +101,6 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
|
|||||||
chatId,
|
chatId,
|
||||||
messageId,
|
messageId,
|
||||||
skipGallerySave,
|
skipGallerySave,
|
||||||
inputImageUrl,
|
|
||||||
sourceImageUrl,
|
sourceImageUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -120,29 +114,22 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
requestId,
|
|
||||||
...result,
|
...result,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof RmbgServiceError) {
|
if (error instanceof RmbgServiceError) {
|
||||||
const failure = getRequestFailureDetails(error);
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
error: error.message,
|
||||||
error: formatRequestFailure(failure, requestId),
|
|
||||||
detail: error.detail || error.message,
|
detail: error.detail || error.message,
|
||||||
code: failure.code,
|
|
||||||
failure,
|
|
||||||
requestId,
|
|
||||||
},
|
},
|
||||||
{ status: error.status }
|
{ status: error.status }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("[RMBG API] 处理错误:", error);
|
console.error("[RMBG API] 处理错误:", error);
|
||||||
const failure = getRequestFailureDetails(error, "抠图请求未能完成");
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: formatRequestFailure(failure, requestId), detail: error instanceof Error ? error.message : "未知错误", code: failure.code, failure, requestId },
|
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user