Compare commits

..

No commits in common. "main" and "codex/sku-original-image-display" have entirely different histories.

207 changed files with 3418 additions and 11411 deletions

View File

@ -2377,10 +2377,8 @@ export function LocalAiImagePipelineSections({
items={[ items={[
{ {
label: label:
extendConfig.provider === "fal" extendConfig.provider === "vip98k"
? "fal · FLUX 2 Pro 扩图" ? "VIP98K 服务商"
: extendConfig.provider === "vip98k"
? `98K · ${extendConfig.modelId || "image-expansion"}`
: "阿里云服务商", : "阿里云服务商",
tone: "enabled", tone: "enabled",
}, },
@ -2393,13 +2391,9 @@ export function LocalAiImagePipelineSections({
} }
title="图像拓展API配置" title="图像拓展API配置"
> >
{extendConfig.provider === "fal" ? ( {extendConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm"> <div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 FLUX 2 Pro Outpaint 扩展图片。密钥从已启用的 fal 官方服务商读取。 使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
</div>
) : extendConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 98K 的 {extendConfig.modelId || "image-expansion"} 模型。API Key 从“AI 模型设置”中的
VIP98K 服务商读取,仅在这里维护扩展比例。 VIP98K 服务商读取,仅在这里维护扩展比例。
</div> </div>
) : ( ) : (

View File

@ -274,9 +274,7 @@ export type EnhanceConfig = {
}; };
export type ExtendConfig = { export type ExtendConfig = {
provider: "vip98k" | "dashscope" | "fal"; provider: "vip98k" | "dashscope";
modelId: string;
routeOptionId: string;
xScale: string; xScale: string;
yScale: string; yScale: string;
}; };

File diff suppressed because it is too large Load Diff

View File

@ -4,8 +4,7 @@ import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { import {
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY, DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
findConfiguredDetailReplacementImageModel, normalizeDetailReplacementImageModelConfig,
isDetailReplacementImageModel,
} from "@/lib/ai/detail-replacement-image-model-config"; } from "@/lib/ai/detail-replacement-image-model-config";
import { import {
compositeReplacementWithReferenceLocks, compositeReplacementWithReferenceLocks,
@ -30,6 +29,7 @@ import {
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { bufferToArrayBuffer } from "./persistence"; import { bufferToArrayBuffer } from "./persistence";
import { isVideoProviderType } from "./video-models";
import type { AgentPresetMessageImagePart } from "./agent-scene-workflow"; import type { AgentPresetMessageImagePart } from "./agent-scene-workflow";
import type { PostRequestBody } from "../schema"; import type { PostRequestBody } from "../schema";
@ -2161,10 +2161,22 @@ export async function resolveDirectReplacementModelRef(params: {
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>; models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}) { }) {
const models = params.models || (await getEnabledAiModelsWithProvider(false)); const models = params.models || (await getEnabledAiModelsWithProvider(false));
const imageModels = models.filter(
({ model, provider }) =>
model.supportsImageGen &&
!model.isSubModel &&
!isVideoProviderType(provider?.type, model),
);
const configuredModelSetting = await getSystemSetting({ const configuredModelSetting = await getSystemSetting({
key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY, key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
}).catch(() => null); }).catch(() => null);
const configuredModel = findConfiguredDetailReplacementImageModel(models, configuredModelSetting?.value); const configuredModelId = normalizeDetailReplacementImageModelConfig(
configuredModelSetting?.value,
).modelId;
const configuredModel = imageModels.find(
({ model }) =>
model.id === configuredModelId || model.modelId === configuredModelId,
);
if (configuredModel) { if (configuredModel) {
return configuredModel.model.id; return configuredModel.model.id;
} }
@ -2178,7 +2190,6 @@ export async function resolveDirectReplacementModelRef(params: {
return routedModel; return routedModel;
} }
const imageModels = models.filter(isDetailReplacementImageModel);
const preferred = const preferred =
imageModels.find(({ model }) => model.isDefault) || imageModels.find(({ model }) => model.isDefault) ||
imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) || imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) ||

View File

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

View File

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

View File

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

View File

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

View File

@ -10,10 +10,6 @@ import {
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability"; import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability"; import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability";
import {
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
findConfiguredDetailReplacementImageModel,
} from "@/lib/ai/detail-replacement-image-model-config";
import { import {
isImageGenerationModelDescriptor, isImageGenerationModelDescriptor,
isVideoModelDescriptor, isVideoModelDescriptor,
@ -161,17 +157,10 @@ export async function GET(request: Request) {
: await getEnabledAiModelsWithProvider(false, true); : await getEnabledAiModelsWithProvider(false, true);
const agentModelWithProvider = await getAgentAiModelWithProvider(); const agentModelWithProvider = await getAgentAiModelWithProvider();
const defaultModel = await getDefaultAiModel(); const defaultModel = await getDefaultAiModel();
const [siteSettings, agentWorkflowUiConfigSetting, detailReplacementModelSetting] = await Promise.all([ const [siteSettings, agentWorkflowUiConfigSetting] = await Promise.all([
getSiteSettings(), getSiteSettings(),
getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY }).catch(() => null), getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY }).catch(() => null),
getSystemSetting({ key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY }).catch(() => null),
]); ]);
const detailReplacementModel = findConfiguredDetailReplacementImageModel(
includeUnavailable ? modelsWithProvider.filter(({ model, provider }) =>
provider.isEnabled && model.isEnabled && getAiModelFrontendAvailability(model).selectable !== false
) : modelsWithProvider,
detailReplacementModelSetting?.value,
);
type ModelWithProvider = (typeof modelsWithProvider)[number]; type ModelWithProvider = (typeof modelsWithProvider)[number];
const serializeModel = ({ model, provider }: ModelWithProvider) => ({ const serializeModel = ({ model, provider }: ModelWithProvider) => ({
id: model.id, id: model.id,
@ -320,7 +309,6 @@ export async function GET(request: Request) {
: null, : null,
agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.model.id || null, agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.model.id || null,
agentWorkflowConfiguredModels, agentWorkflowConfiguredModels,
detailReplacementModel: detailReplacementModel ? serializeModel(detailReplacementModel) : null,
agentModel: agentModelWithProvider agentModel: agentModelWithProvider
? { ? {
id: agentModelWithProvider.model.id, id: agentModelWithProvider.model.id,

View File

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

View File

@ -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";
@ -13,7 +12,6 @@ import { requestLongTaskResult } from "@/lib/client/long-task";
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery"; import { buildImageProxyUrl, getImageDownloadExtension } 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;
@ -60,7 +58,7 @@ function buildGalleryDownloadFilename(url: string, alt: string, contentType: str
} }
} }
return `${safeBase}.${getImageDownloadExtension(contentType) || extension}`; return `${safeBase}-${Date.now()}.${getImageDownloadExtension(contentType) || extension}`;
} }
export default function GalleryPage() { export default function GalleryPage() {
@ -120,11 +118,25 @@ export default function GalleryPage() {
const parsed = new URL(originalUrl, window.location.origin); const parsed = new URL(originalUrl, window.location.origin);
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl); const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
await downloadOriginalImage({ url: source, name: buildGalleryDownloadFilename(originalUrl, alt, "") }); const response = await fetch(
buildImageProxyUrl(source, { delivery: "original" })
);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = buildGalleryDownloadFilename(originalUrl, alt, blob.type);
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)); toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
} }
}; };
@ -304,12 +316,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 +325,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}

View File

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

View File

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

View File

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

View File

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

View File

@ -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[] = [];

View File

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

View File

@ -1,20 +0,0 @@
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { resolveAgentWorkflowComposerSelections } from "@/lib/agents/workflow-composer";
import { AGENT_WORKFLOW_UI_CONFIG_KEY, normalizeAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
import { getSystemSetting } from "@/lib/db/queries";
const selectionsSchema = z.record(z.string().max(160), z.string().max(8192))
.refine((selections) => Object.keys(selections).length <= 100);
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return Response.json({ error: "请先登录" }, { status: 401 });
const selections = selectionsSchema.safeParse(await request.json().catch(() => null));
if (!selections.success) return Response.json({ error: "风格模板选择无效" }, { status: 400 });
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY });
const config = normalizeAgentWorkflowUiConfig(setting?.value);
return Response.json({ selections: resolveAgentWorkflowComposerSelections({ config, selections: selections.data }) });
}

View File

@ -4,7 +4,6 @@ import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canvasImageTask, canvasProject, message } from "@/lib/db/schema"; import { canvasImageTask, canvasProject, message } from "@/lib/db/schema";
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat"; import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
import { MAX_DETAIL_REPLACEMENT_REFERENCES } from "@/lib/agents/mode-workspace-state";
import { import {
createCanvasImageTask, createCanvasImageTask,
enqueueCanvasImageTask, enqueueCanvasImageTask,
@ -147,14 +146,6 @@ export async function POST(request: Request) {
.map((item) => [String(item.id), String(item.imageUrl)]), .map((item) => [String(item.id), String(item.imageUrl)]),
); );
const requested = Array.isArray(body.references) ? body.references : []; const requested = Array.isArray(body.references) ? body.references : [];
if (requested.length === 0) {
return NextResponse.json({ error: "请先选择详情参考图" }, { status: 400 });
}
if (requested.length > MAX_DETAIL_REPLACEMENT_REFERENCES) {
return NextResponse.json({
error: `本次选中了 ${requested.length} 张详情参考图,一次最多替换 ${MAX_DETAIL_REPLACEMENT_REFERENCES} 张,请减少本次选择后重试`,
}, { status: 400 });
}
const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => { const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => {
const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {}; const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
return { return {
@ -169,6 +160,7 @@ export async function POST(request: Request) {
}; };
}); });
if ( if (
references.length === 0 || references.length > 40 ||
new Set(references.map((item) => item.id)).size !== references.length || new Set(references.map((item) => item.id)).size !== references.length ||
references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl) references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl)
) { ) {
@ -195,12 +187,6 @@ export async function POST(request: Request) {
if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) { if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) {
return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 }); return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 });
} }
if (typeof body.expectedModelId === "string" && body.expectedModelId !== model) {
return NextResponse.json({
code: "DETAIL_REPLACEMENT_MODEL_CHANGED",
error: "详情替换模型已更新,请确认页面显示的模型后重新提交",
}, { status: 409 });
}
const quota = await checkUserImageQuota(session.user.id); const quota = await checkUserImageQuota(session.user.id);
if (!quota.allowed) { if (!quota.allowed) {
return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 }); return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 });

View File

@ -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" },
});
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 },
);
}
} }

View File

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

View File

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

View File

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

View File

@ -976,8 +976,7 @@ export async function POST(request: NextRequest) {
const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`; const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
const annotatedUpload = await uploadFile( const annotatedUpload = await uploadFile(
annotatedFilename, annotatedFilename,
toArrayBuffer(annotatedBuffer), toArrayBuffer(annotatedBuffer)
{ preferQiniuForm: true }
); );
const prompt = buildLocalEditPrompt(regions); const prompt = buildLocalEditPrompt(regions);
const manualRegionReferenceImageUrls = regions.flatMap((region) => const manualRegionReferenceImageUrls = regions.flatMap((region) =>

View File

@ -1,11 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { and, eq, inArray } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { callQwen3Vl } from "@/lib/ai/qwen3-vl"; import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
import { executeConfiguredVip98kTask, getVip98kRuntimeProviders } from "@/lib/ai/vip98k-runtime";
import { db } from "@/lib/db";
import { aiModel } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import sharp from "sharp"; import sharp from "sharp";
@ -94,39 +90,6 @@ function fallbackItemsFromRaw(raw: string): OcrItem[] {
})); }));
} }
async function detectTextWithVip98k(imageUrl: string): Promise<OcrItem[] | null> {
const providers = await getVip98kRuntimeProviders();
if (!providers.length) return null;
const model = await db.query.aiModel.findFirst({
columns: { providerId: true },
where: and(
inArray(aiModel.providerId, providers.map(({ id }) => id)),
eq(aiModel.modelId, "ocr"),
eq(aiModel.isEnabled, true),
),
});
if (!model) return null;
const result = await executeConfiguredVip98kTask({
modelId: "ocr",
kind: "image-tool",
imageUrls: [imageUrl],
preferredProviderId: model.providerId,
});
const textUrl = result.outputUrls[0];
if (!textUrl) throw new Error("98K 文字识别未返回文本文件");
const response = await fetch(textUrl, { signal: AbortSignal.timeout(15_000) });
const type = response.headers.get("content-type") || "";
if (!response.ok || !/^(?:text\/plain|application\/octet-stream)(?:;|$)/i.test(type)) {
await response.body?.cancel();
throw new Error(`无法读取 98K 文字识别结果(HTTP ${response.status})`);
}
return (await response.text()).split(/\r\n?|\n/).map((line) => line.trim())
.filter(Boolean).slice(0, 50).map((text, index) => ({ id: String(index + 1), text }));
}
function shouldUseDataUrlFallback(error: unknown) { function shouldUseDataUrlFallback(error: unknown) {
const message = const message =
error instanceof Error ? error.message.toLowerCase() : String(error || "").toLowerCase(); error instanceof Error ? error.message.toLowerCase() : String(error || "").toLowerCase();
@ -184,11 +147,6 @@ export async function POST(request: Request) {
return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 }); return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 });
} }
const detected = await detectTextWithVip98k(imageUrl);
if (detected !== null) {
return NextResponse.json({ success: true, data: { items: detected, model: "ocr" } });
}
logger.debug("model_selected", { model: "qwen3-vl" }); logger.debug("model_selected", { model: "qwen3-vl" });
const prompt = [ const prompt = [
"你是图片文字识别助手。", "你是图片文字识别助手。",
@ -233,9 +191,15 @@ export async function POST(request: Request) {
} }
const parsedJson = parseJsonText(text || ""); const parsedJson = parseJsonText(text || "");
const items = Array.isArray(parsedJson?.items) let items: OcrItem[] = [];
? sanitizeItems(parsedJson.items)
: fallbackItemsFromRaw(text); if (parsedJson && Array.isArray(parsedJson.items)) {
items = sanitizeItems(parsedJson.items);
}
if (items.length === 0 && text) {
items = fallbackItemsFromRaw(text);
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
@ -249,7 +213,7 @@ export async function POST(request: Request) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "图片文字识别暂不可用,请稍后重试", error: error instanceof Error ? error.message : "文字识别失败",
}, },
{ status: 500 }, { status: 500 },
); );

View File

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

View File

@ -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,13 +127,15 @@ 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": "public, max-age=31536000, immutable, stale-while-revalidate=86400", "Cache-Control": params.cacheControl || "public, max-age=31536000, immutable, stale-while-revalidate=86400",
}; };
if (params.vary) { if (params.vary) {
headers.Vary = params.vary; headers.Vary = params.vary;
@ -147,6 +143,11 @@ 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,
@ -236,7 +237,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;
} }
@ -265,19 +265,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 });
} }
@ -293,24 +281,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 });
} }
@ -331,48 +301,6 @@ export async function GET(request: NextRequest) {
); );
} }
if (!original && !forceProxy && isBrowserImageRequest(request)) {
const direct = buildImageProxyUrl(target.toString(), {
width: width || undefined, quality: directQuality || undefined,
format: ["webp", "avif", "jpeg", "png"].includes(requestedFormat || "") ? requestedFormat as OutputFormat : undefined,
});
if (process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN && /^https:\/\//.test(direct) &&
new URL(direct).origin === new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN).origin) {
return redirectToDirectImage(new URL(direct));
}
}
if (!original && (isAlibabaModelImageUrl(target.toString()) || (!forceProxy && isBrowserImageRequest(request)))) {
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) { if (!forceProxy && !original) {
const directImageTarget = resolveDirectImageTarget( const directImageTarget = resolveDirectImageTarget(
request, request,
@ -411,81 +339,39 @@ 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: original ? "*/*" : "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 });
} }
const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
if (original) { if (original) {
if (upstreamResponse.status !== 200 || !upstreamResponse.body) { return buildImageResponse(originalBuffer, {
await upstreamResponse.body?.cancel(); contentType,
return downloadFailure("源图片传输中断,请重试"); etag: `"${crypto.createHash("sha256").update(originalBuffer).digest("hex")}"`,
} cacheControl: "private, no-store",
const headers = new Headers({ attachment: true,
"Content-Type": contentType,
"Content-Disposition": "attachment",
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
}); });
const length = upstreamResponse.headers.get("content-length");
const encoding = upstreamResponse.headers.get("content-encoding");
if (length && (!encoding || encoding.toLowerCase() === "identity")) headers.set("Content-Length", length);
return new NextResponse(upstreamResponse.body, { headers });
}
try {
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
} catch (error) {
return upstreamFailure(error);
}
} }
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");

View File

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

View File

@ -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";
@ -10,8 +9,7 @@ import {
systemSettingSnapshot, systemSettingSnapshot,
systemSettings, systemSettings,
} from "@/lib/db/schema"; } from "@/lib/db/schema";
import { and, eq, inArray, sql } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { omitAgentWorkflowPrompts } from "@/lib/agents/workflow-loading";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { isAgentWorkflowAdminType, isSiteAdminType } from "@/lib/middleware/admin"; import { isAgentWorkflowAdminType, isSiteAdminType } from "@/lib/middleware/admin";
import { import {
@ -154,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";
@ -554,9 +552,6 @@ function normalizeAgentCategoryTreeConfig(value: unknown): AgentCategoryTreeConf
type PublicSiteSettingsScope = type PublicSiteSettingsScope =
| "agent-brand-model" | "agent-brand-model"
| "agent-canvas" | "agent-canvas"
| "agent-canvas-base"
| "agent-workflow-templates"
| "agent-entry"
| "agent-presets" | "agent-presets"
| "auth" | "auth"
| "branding" | "branding"
@ -568,9 +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-canvas-base",
"agent-workflow-templates",
"agent-entry",
"agent-presets", "agent-presets",
"auth", "auth",
"branding", "branding",
@ -614,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(
@ -687,7 +641,7 @@ function createScopedSiteSettingsResponse(
return response; return response;
} }
async function readScopedSiteSettings(keys: string[], omitWorkflowSteps = false) { async function readScopedSiteSettings(keys: string[]) {
const [settings, settingRows] = await Promise.all([ const [settings, settingRows] = await Promise.all([
db db
.select() .select()
@ -695,14 +649,8 @@ async function readScopedSiteSettings(keys: string[], omitWorkflowSteps = false)
.limit(1) .limit(1)
.then((rows) => rows[0]), .then((rows) => rows[0]),
keys.length > 0 keys.length > 0
? (omitWorkflowSteps ? db
? db.select({ .select()
key: systemSettings.key,
value: sql<unknown>`case when ${systemSettings.key} = ${AGENT_WORKFLOW_UI_CONFIG_KEY}
and jsonb_typeof(${systemSettings.value}) = 'object'
then ${systemSettings.value} - 'steps' else ${systemSettings.value} end`,
})
: db.select())
.from(systemSettings) .from(systemSettings)
.where(inArray(systemSettings.key, keys)) .where(inArray(systemSettings.key, keys))
: Promise.resolve([]), : Promise.resolve([]),
@ -715,12 +663,12 @@ async function readScopedSiteSettings(keys: string[], omitWorkflowSteps = false)
} }
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(
@ -728,7 +676,6 @@ async function getScopedPublicSiteSettings(
options: { options: {
brandName?: string; brandName?: string;
cacheKey?: string; cacheKey?: string;
etag?: string;
} = {} } = {}
) { ) {
if (scope === "auth") { if (scope === "auth") {
@ -790,17 +737,23 @@ async function getScopedPublicSiteSettings(
}); });
} }
if (scope === "agent-workflow-templates") { if (scope === "agent-canvas") {
const [setting] = await db.select({ value: systemSettings.value }) const { settings, valueByKey } = await readScopedSiteSettings([
.from(systemSettings).where(eq(systemSettings.key, AGENT_WORKFLOW_UI_CONFIG_KEY)).limit(1); REALTIME_CANVAS_CONFIG_KEY,
const config = normalizeAgentWorkflowUiConfig(setting?.value); REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
return createScopedSiteSettingsResponse(scope, { CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
steps: omitAgentWorkflowPrompts(config.steps), LAYER_SPLIT_CONFIG_KEY,
}, "MISS", options.cacheKey || scope, { etag: options.etag }); ANGLE_EDIT_CONFIG_KEY,
} CANVAS_AGENT_MODE_ENABLED_KEY,
...CANVAS_AGENT_FEATURE_SETTING_KEYS,
if (scope === "agent-canvas" || scope === "agent-canvas-base" || scope === "agent-entry") { VIDEO_CANVAS_ENABLED_KEY,
const { settings, valueByKey } = await readScopedSiteSettings(AGENT_CANVAS_SETTING_KEYS, scope !== "agent-canvas"); 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
); );
@ -808,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,
@ -845,20 +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,
}
: scope === "agent-canvas-base"
? { ...rawUiConfig, steps: [] }
: 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") {
@ -1112,24 +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" ||
scopedScope === "agent-canvas-base" || scopedScope === "agent-workflow-templates"
? 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") {
@ -1140,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")
@ -1610,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 折优惠",

View File

@ -6,7 +6,6 @@ import { eq } from "drizzle-orm";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { import {
createMediaAssetRelation, createMediaAssetRelation,
getDefaultStorageConfig,
getPointsSystemConfig, getPointsSystemConfig,
getMediaAssetsByUserAndUrls, getMediaAssetsByUserAndUrls,
upsertMediaAsset, upsertMediaAsset,
@ -22,27 +21,6 @@ export const maxDuration = 600;
const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000; const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000;
const WATERMARK_POLL_INTERVAL_MS = 1200; const WATERMARK_POLL_INTERVAL_MS = 1200;
async function resolveStoredQiniuImageUrl(source: string): Promise<string | null> {
const config = await getDefaultStorageConfig().catch(() => null);
if (config?.type !== "qiniu-kodo" || !config.isEnabled || !config.publicUrl) return null;
try {
const base = new URL(config.publicUrl);
const url = new URL(source);
const prefix = base.pathname.replace(/\/$/, "") + "/";
if (base.protocol !== "https:" || url.origin !== base.origin ||
url.username || url.password || url.search || url.hash ||
!url.pathname.startsWith(prefix)) return null;
const objectKey = url.pathname.slice(prefix.length);
return /^uploads\/(?:direct\/[0-9a-f-]{36}\/)?[\w.-]+$/i.test(objectKey)
? url.toString()
: null;
} catch {
return null;
}
}
// 获取去水印API配置 // 获取去水印API配置
async function getWatermarkRemoveConfig() { async function getWatermarkRemoveConfig() {
try { try {
@ -153,16 +131,13 @@ async function persistWatermarkResult(params: {
processing_time?: number; processing_time?: number;
}> { }> {
try { try {
let resultUrl = await resolveStoredQiniuImageUrl(params.processedImageUrl);
if (!resultUrl) {
const imageResponse = await fetch(params.processedImageUrl); const imageResponse = await fetch(params.processedImageUrl);
if (!imageResponse.ok) { if (!imageResponse.ok) {
throw new Error("下载处理后的图片失败"); throw new Error("下载处理后的图片失败");
} }
const imageBuffer = await imageResponse.arrayBuffer(); const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-removed-${Date.now()}.png`; const filename = `watermark-removed-${Date.now()}.png`;
resultUrl = (await uploadFile(filename, imageBuffer)).url; const uploadResult = await uploadFile(filename, imageBuffer);
}
let saved = false; let saved = false;
let saveError: unknown = null; let saveError: unknown = null;
@ -175,8 +150,8 @@ async function persistWatermarkResult(params: {
try { try {
([savedImage] = await db.insert(generatedImage).values({ ([savedImage] = await db.insert(generatedImage).values({
userId: params.userId, userId: params.userId,
thumbnailUrl: resultUrl, thumbnailUrl: uploadResult.url,
originalUrl: resultUrl, originalUrl: uploadResult.url,
imageSize: "2K", imageSize: "2K",
prompt: "去水印处理", prompt: "去水印处理",
modelId: "watermark-remove", modelId: "watermark-remove",
@ -209,9 +184,9 @@ async function persistWatermarkResult(params: {
mediaType: "image", mediaType: "image",
sourceType: "edited", sourceType: "edited",
status: "ready", status: "ready",
originalUrl: resultUrl, originalUrl: uploadResult.url,
previewUrl: resultUrl, previewUrl: uploadResult.url,
thumbnailUrl: resultUrl, thumbnailUrl: uploadResult.url,
displayName: "去水印处理", displayName: "去水印处理",
prompt: "去水印处理", prompt: "去水印处理",
modelId: "watermark-remove", modelId: "watermark-remove",
@ -242,8 +217,8 @@ async function persistWatermarkResult(params: {
return { return {
success: true, success: true,
original_url: resultUrl, original_url: uploadResult.url,
thumbnail_url: resultUrl, thumbnail_url: uploadResult.url,
processing_time: processing_time:
typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined, typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined,
}; };
@ -387,7 +362,7 @@ export async function POST(request: NextRequest) {
const body = await request.json(); const body = await request.json();
const { image_url } = body; const { image_url } = body;
if (typeof image_url !== "string" || !image_url) { if (!image_url) {
return NextResponse.json( return NextResponse.json(
{ error: "请提供图片URL" }, { error: "请提供图片URL" },
{ status: 400 } { status: 400 }
@ -438,11 +413,8 @@ export async function POST(request: NextRequest) {
); );
} }
} }
// 七牛上的公开图片可以直接交给去水印服务读取。 // 如果是 HTTP/HTTPS URL,下载后重新上传确保公网可访问
else if (image_url.startsWith("http://") || image_url.startsWith("https://")) { else if (image_url.startsWith("http://") || image_url.startsWith("https://")) {
const storedImageUrl = await resolveStoredQiniuImageUrl(image_url);
publicImageUrl = storedImageUrl || image_url;
if (!storedImageUrl) {
try { try {
console.log("[WatermarkRemove] 下载原图并上传到S3..."); console.log("[WatermarkRemove] 下载原图并上传到S3...");
const imageResponse = await fetch(image_url); const imageResponse = await fetch(image_url);
@ -451,11 +423,14 @@ export async function POST(request: NextRequest) {
} }
const imageBuffer = await imageResponse.arrayBuffer(); const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-input-${Date.now()}.jpg`; const filename = `watermark-input-${Date.now()}.jpg`;
publicImageUrl = (await uploadFile(filename, imageBuffer)).url; const uploadResult = await uploadFile(filename, imageBuffer);
publicImageUrl = uploadResult.url;
console.log("[WatermarkRemove] 图片已上传到S3:", publicImageUrl); console.log("[WatermarkRemove] 图片已上传到S3:", publicImageUrl);
} catch (downloadError) { } catch (downloadError) {
console.error("[WatermarkRemove] 下载/上传原图失败,使用原始URL:", downloadError); console.error("[WatermarkRemove] 下载/上传原图失败:", downloadError);
} // 如果下载失败,尝试使用原始URL
console.log("[WatermarkRemove] 尝试使用原始URL");
publicImageUrl = image_url;
} }
} else { } else {
return NextResponse.json( return NextResponse.json(

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -3,8 +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 { PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE } from "@/lib/client/page-refresh";
import { getSiteSettings } from "@/lib/db/queries"; import { getSiteSettings } from "@/lib/db/queries";
import "./globals.css"; import "./globals.css";
@ -143,7 +141,7 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
showManualRecoveryNotice(); showManualRecoveryNotice();
return; return;
} }
if (lastReloadAt || document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) return; if (lastReloadAt) return;
var now = Date.now(); var now = Date.now();
// If storage is blocked, a reload cannot remember its attempt and must not run. // If storage is blocked, a reload cannot remember its attempt and must not run.
if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return; if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return;
@ -154,10 +152,6 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
showManualRecoveryNotice(); showManualRecoveryNotice();
return; return;
} }
if (document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) {
if (safeSetSessionStorage(RECOVERY_KEY, '0')) lastReloadAt = 0;
return;
}
window.location.reload(); window.location.reload();
}, 800); }, 800);
} }
@ -221,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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("已打开原图,可右键另存为");
} }
}, []); }, []);

View File

@ -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 {
@ -159,7 +156,6 @@ import type {
} from "@/types/agent"; } from "@/types/agent";
import { import {
createEmptyAgentModeWorkspaceState, createEmptyAgentModeWorkspaceState,
MAX_DETAIL_REPLACEMENT_REFERENCES,
} from "@/lib/agents/mode-workspace-state"; } from "@/lib/agents/mode-workspace-state";
import { import {
createEmptyAgentGenerationProjectWorkspaceState, createEmptyAgentGenerationProjectWorkspaceState,
@ -177,7 +173,6 @@ export interface AgentGenerationWorkspaceDarkV2Props {
workflowFeatureSettings?: AgentWorkflowPageFeatureSettings | null; workflowFeatureSettings?: AgentWorkflowPageFeatureSettings | null;
workflowPromptOptimizationEnabled?: boolean; workflowPromptOptimizationEnabled?: boolean;
getLatestWorkflowContext?: () => AgentWorkflowComposerContext | null; getLatestWorkflowContext?: () => AgentWorkflowComposerContext | null;
getWorkflowPromptStatus?: () => "ready" | "loading" | "error";
onBack?: () => void; onBack?: () => void;
brandStorePanel?: ReactNode; brandStorePanel?: ReactNode;
materialSelectionPanel?: ReactNode; materialSelectionPanel?: ReactNode;
@ -214,7 +209,6 @@ export interface AgentGenerationWorkspaceDarkV2Props {
interface AgentWorkflowModelOption { interface AgentWorkflowModelOption {
id: string; id: string;
displayName: string; displayName: string;
providerName?: string;
providerType: string; providerType: string;
supportsImageGen?: boolean; supportsImageGen?: boolean;
isMultimodal?: boolean; isMultimodal?: boolean;
@ -2645,9 +2639,7 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
? record.requestedCardIds ? record.requestedCardIds
.map((entry) => String(entry || "").trim()) .map((entry) => String(entry || "").trim())
.filter(Boolean) .filter(Boolean)
.slice(0, record.modeType === "detail_replacement" .slice(0, MAX_AGENT_MODE_REFERENCE_COUNT)
? MAX_DETAIL_REPLACEMENT_REFERENCES
: MAX_AGENT_MODE_REFERENCE_COUNT)
: null, : null,
} satisfies LocalGenerationPendingState; } satisfies LocalGenerationPendingState;
}); });
@ -3090,7 +3082,6 @@ export function AgentGenerationWorkspaceDarkV2({
workflowFeatureSettings = null, workflowFeatureSettings = null,
workflowPromptOptimizationEnabled = true, workflowPromptOptimizationEnabled = true,
getLatestWorkflowContext, getLatestWorkflowContext,
getWorkflowPromptStatus,
onBack, onBack,
brandStorePanel, brandStorePanel,
materialSelectionPanel, materialSelectionPanel,
@ -3388,13 +3379,12 @@ export function AgentGenerationWorkspaceDarkV2({
} }
); );
const { data: modelsData, error: modelsError, mutate: refreshModels } = useSWR<{ const { data: modelsData } = useSWR<{
models: AgentWorkflowModelOption[]; models: AgentWorkflowModelOption[];
defaultModelId?: string | null; defaultModelId?: string | null;
agentWorkflowConfiguredDefaultModelId?: string | null; agentWorkflowConfiguredDefaultModelId?: string | null;
agentWorkflowDefaultModelId?: string | null; agentWorkflowDefaultModelId?: string | null;
agentWorkflowConfiguredModels?: AgentWorkflowModelOption[]; agentWorkflowConfiguredModels?: AgentWorkflowModelOption[];
detailReplacementModel?: AgentWorkflowModelOption | null;
agentModel?: any; agentModel?: any;
}>("/api/models", fetcher); }>("/api/models", fetcher);
@ -3440,6 +3430,17 @@ export function AgentGenerationWorkspaceDarkV2({
const activeBusinessModeDefaultModelId = !requiresPresetSelection const activeBusinessModeDefaultModelId = !requiresPresetSelection
? String(activeBusinessMode?.defaultModelId || "").trim() || null ? String(activeBusinessMode?.defaultModelId || "").trim() || null
: null; : null;
const lockedWorkflowModelId =
activeBusinessModeDefaultModelId ||
selectedAgentPreset?.defaultModelId ||
modelsData?.agentWorkflowConfiguredDefaultModelId ||
modelsData?.agentWorkflowDefaultModelId ||
null;
const isWorkflowModelLocked = Boolean(lockedWorkflowModelId);
const effectiveDefaultModelId =
lockedWorkflowModelId ||
modelsData?.defaultModelId ||
null;
const presetDrivenResultModeType: AgentGenerationResultModeType = const presetDrivenResultModeType: AgentGenerationResultModeType =
selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement" selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement"
? detailReplacementMethod === "ai_detail_generate" ? detailReplacementMethod === "ai_detail_generate"
@ -3455,19 +3456,6 @@ export function AgentGenerationWorkspaceDarkV2({
const currentResultModeType: AgentGenerationResultModeType = requiresPresetSelection const currentResultModeType: AgentGenerationResultModeType = requiresPresetSelection
? presetDrivenResultModeType ? presetDrivenResultModeType
: businessModeResultModeType; : businessModeResultModeType;
const detailReplacementModel = currentResultModeType === "detail_replacement"
? modelsData?.detailReplacementModel
: null;
const isDetailReplacementModelLoading = currentResultModeType === "detail_replacement" && !modelsData;
const lockedWorkflowModelId =
detailReplacementModel?.id ||
activeBusinessModeDefaultModelId ||
selectedAgentPreset?.defaultModelId ||
modelsData?.agentWorkflowConfiguredDefaultModelId ||
modelsData?.agentWorkflowDefaultModelId ||
null;
const isWorkflowModelLocked = Boolean(lockedWorkflowModelId);
const effectiveDefaultModelId = lockedWorkflowModelId || modelsData?.defaultModelId || null;
const isSceneGenerationPreset = currentResultModeType === "scene_generation"; const isSceneGenerationPreset = currentResultModeType === "scene_generation";
const isDetailReplacementPreset = const isDetailReplacementPreset =
currentResultModeType === "detail_replacement" || currentResultModeType === "detail_replacement" ||
@ -4119,15 +4107,12 @@ export function AgentGenerationWorkspaceDarkV2({
); );
const executableModelOptions = useMemo(() => { const executableModelOptions = useMemo(() => {
const seen = new Set<string>(); const seen = new Set<string>();
const workflowModels = detailReplacementModel return [...selectableModels, ...configuredWorkflowModels].filter((model) => {
? [detailReplacementModel, ...configuredWorkflowModels]
: configuredWorkflowModels;
return [...selectableModels, ...workflowModels].filter((model) => {
if (seen.has(model.id)) return false; if (seen.has(model.id)) return false;
seen.add(model.id); seen.add(model.id);
return true; return true;
}); });
}, [configuredWorkflowModels, detailReplacementModel, selectableModels]); }, [configuredWorkflowModels, selectableModels]);
const executableModelIdSet = useMemo( const executableModelIdSet = useMemo(
() => new Set(executableModelOptions.map((model) => model.id)), () => new Set(executableModelOptions.map((model) => model.id)),
[executableModelOptions] [executableModelOptions]
@ -4146,9 +4131,8 @@ export function AgentGenerationWorkspaceDarkV2({
selectableModels[0], selectableModels[0],
[executableModelOptions, lockedWorkflowModel, selectableModels, selectedModelId] [executableModelOptions, lockedWorkflowModel, selectableModels, selectedModelId]
); );
const lockedWorkflowModelLabel = detailReplacementModel?.providerName const lockedWorkflowModelLabel =
? `${detailReplacementModel.displayName} 「${detailReplacementModel.providerName}」` lockedWorkflowModel?.displayName ||
: lockedWorkflowModel?.displayName ||
selectedAgentPreset?.defaultModel?.displayName || selectedAgentPreset?.defaultModel?.displayName ||
(lockedWorkflowModelId ? `配置模型不可用(${lockedWorkflowModelId})` : "未配置"); (lockedWorkflowModelId ? `配置模型不可用(${lockedWorkflowModelId})` : "未配置");
const [activeToolStep, setActiveToolStep] = useState<ToolStepKey>("settings"); const [activeToolStep, setActiveToolStep] = useState<ToolStepKey>("settings");
@ -4185,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,
@ -4206,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({
@ -4893,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;
@ -5511,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.
@ -6327,24 +6287,10 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`); toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`);
return; return;
} }
if (isDetailReplacementModelLoading) {
if (modelsError) {
toast.error("详情替换模型加载失败,正在重新读取");
void refreshModels().catch(() => {});
} else {
toast.info("正在读取详情替换模型,请稍候");
}
return;
}
if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) { if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) {
toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`); toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`);
return; return;
} }
const promptStatus = getWorkflowPromptStatus?.();
if (promptStatus === "loading" || promptStatus === "error") {
toast.error(promptStatus === "error" ? "请先在风格模板区重试加载提示词" : "正在加载所选模板的提示词,请稍候");
return;
}
const resolvedWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext; const resolvedWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext;
if ( if (
isDetailReplacementPreset && isDetailReplacementPreset &&
@ -6403,10 +6349,6 @@ export function AgentGenerationWorkspaceDarkV2({
} }
if (replacementModeType === "detail_replacement") { if (replacementModeType === "detail_replacement") {
if (detailBatchSubmittingRef.current) return; if (detailBatchSubmittingRef.current) return;
if (replacementExecutionCards.length > MAX_DETAIL_REPLACEMENT_REFERENCES) {
toast.error(`本次选中了 ${replacementExecutionCards.length} 张详情参考图,一次最多替换 ${MAX_DETAIL_REPLACEMENT_REFERENCES} 张,请减少本次选择后重试`);
return;
}
if (!isValidChatUuid(projectId)) { if (!isValidChatUuid(projectId)) {
toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换"); toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换");
return; return;
@ -6427,14 +6369,13 @@ export function AgentGenerationWorkspaceDarkV2({
const saved = await onPersistProject?.(); const saved = await onPersistProject?.();
if (saved === false) throw new Error("项目尚未保存,请重试"); if (saved === false) throw new Error("项目尚未保存,请重试");
const requestId = generateUUID(); const requestId = generateUUID();
const response = await fetch("/api/agents/detail-replacement/batches", { const response = await fetchWithErrorHandlers("/api/agents/detail-replacement/batches", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
projectId, projectId,
requestId, requestId,
model: resolvedModelId, model: resolvedModelId,
expectedModelId: resolvedModelId === "auto" ? undefined : resolvedModelId,
imageSize: imageParams.imageSize, imageSize: imageParams.imageSize,
replacementStrategy: detailReplacementStrategy, replacementStrategy: detailReplacementStrategy,
prompt: detailReplacementStrategy === "product_only" prompt: detailReplacementStrategy === "product_only"
@ -6458,7 +6399,6 @@ export function AgentGenerationWorkspaceDarkV2({
}); });
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.task?.id) { if (!response.ok || !payload?.task?.id) {
if (payload?.code === "DETAIL_REPLACEMENT_MODEL_CHANGED") void refreshModels().catch(() => {});
throw new Error(payload?.error || "详情替换任务提交失败"); throw new Error(payload?.error || "详情替换任务提交失败");
} }
setPendingGeneration({ setPendingGeneration({
@ -6646,8 +6586,6 @@ export function AgentGenerationWorkspaceDarkV2({
input, input,
isDetailAiGenerationMethod, isDetailAiGenerationMethod,
isDetailReplacementPreset, isDetailReplacementPreset,
isDetailReplacementModelLoading,
modelsError,
isSelectedBusinessModeUnavailable, isSelectedBusinessModeUnavailable,
isSkuReplacementPreset, isSkuReplacementPreset,
effectiveDefaultModelId, effectiveDefaultModelId,
@ -6664,7 +6602,6 @@ export function AgentGenerationWorkspaceDarkV2({
skuResultCards, skuResultCards,
userMessageCount, userMessageCount,
getLatestWorkflowContext, getLatestWorkflowContext,
getWorkflowPromptStatus,
isSceneGenerationPreset, isSceneGenerationPreset,
workflowContext, workflowContext,
sceneMessageAttachments, sceneMessageAttachments,
@ -6675,7 +6612,6 @@ export function AgentGenerationWorkspaceDarkV2({
onPersistProject, onPersistProject,
projectId, projectId,
refreshDetailBatch, refreshDetailBatch,
refreshModels,
]); ]);
const handleStopGeneration = useCallback(() => { const handleStopGeneration = useCallback(() => {
detailGenerationCancelRef.current = true; detailGenerationCancelRef.current = true;
@ -6840,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) => {
@ -7839,11 +7795,6 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error(detailGenerationValidationIssues[0] || "请先补齐 AI详情生成参数"); toast.error(detailGenerationValidationIssues[0] || "请先补齐 AI详情生成参数");
return; return;
} }
const promptStatus = getWorkflowPromptStatus?.();
if (promptStatus === "loading" || promptStatus === "error") {
toast.error(promptStatus === "error" ? "请先在风格模板区重试加载提示词" : "正在加载所选模板的提示词,请稍候");
return;
}
const productAttachments = detailGenerationProductSlotIndexes.map((index) => const productAttachments = detailGenerationProductSlotIndexes.map((index) =>
detailGenerationSlots[index] detailGenerationSlots[index]
@ -9448,11 +9399,9 @@ export function AgentGenerationWorkspaceDarkV2({
</Select> </Select>
) : ( ) : (
<div className="rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 text-sm text-white/70"> <div className="rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 text-sm text-white/70">
{isDetailReplacementModelLoading {isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"}
? modelsError ? "详情替换模型加载失败,点击批量替换可重试" : "正在读取详情替换模型"
: isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"}
<span className="ml-2 font-medium text-white"> <span className="ml-2 font-medium text-white">
{isDetailReplacementModelLoading ? null : isWorkflowModelLocked {isWorkflowModelLocked
? lockedWorkflowModelLabel ? lockedWorkflowModelLabel
: currentModel?.displayName || "自动选择"} : currentModel?.displayName || "自动选择"}
</span> </span>

View File

@ -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("已打开原图,可右键另存为");
} }
}, []); }, []);

View File

@ -3,7 +3,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { Check, ImageIcon, Loader2, UserRound } from "lucide-react"; import { Check, ImageIcon, Loader2, UserRound } from "lucide-react";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn, fetcher } from "@/lib/utils"; import { cn, fetcher } from "@/lib/utils";
import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config"; import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config";
@ -230,11 +229,12 @@ export function AgentWorkflowBrandModelPicker({
isDark ? "bg-white/[0.04]" : "bg-muted/40" isDark ? "bg-white/[0.04]" : "bg-muted/40"
)} )}
> >
<AgentPreviewImage <img
src={image.imageUrl} src={image.imageUrl}
alt={image.displayName || `${normalizedBrand} 模特图`} alt={image.displayName || `${normalizedBrand} 模特图`}
className="h-full w-full object-cover" className="h-full w-full object-cover"
previewWidth={360} loading="lazy"
decoding="async"
/> />
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2"> <div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2">
<span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white"> <span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white">

View File

@ -1,77 +0,0 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog";
import { isPageDownloadActive, preparePageRefresh, subscribeToPageDownloadActivity } from "@/lib/client/page-refresh";
export function AppUpdateNotice() {
const [availableVersion, setAvailableVersion] = useState("");
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState("");
const downloading = useSyncExternalStore(subscribeToPageDownloadActivity, isPageDownloadActive, () => false);
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) && !downloading}>
<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>;
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -28,7 +28,6 @@ export function CanvasImageDownloadProgressDialog({
: null; : null;
const loadedLabel = formatBytes(progress.loadedBytes); const loadedLabel = formatBytes(progress.loadedBytes);
const totalLabel = formatBytes(progress.totalBytes); const totalLabel = formatBytes(progress.totalBytes);
const converting = progress.phase === "converting";
return createPortal( return createPortal(
<div <div
@ -42,12 +41,12 @@ export function CanvasImageDownloadProgressDialog({
{progress.phase === "saving" ? ( {progress.phase === "saving" ? (
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
) : ( ) : (
<Loader2 className="h-4 w-4 motion-safe:animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
)} )}
</span> </span>
<div> <div>
<div className="text-[14px] font-semibold">下载原图</div> <div className="text-[14px] font-semibold">下载原图</div>
<div className="mt-0.5 text-[12px] text-white/58" role="status">{progress.message}</div> <div className="mt-0.5 text-[12px] text-white/58">{progress.message}</div>
</div> </div>
</div> </div>
{onCancel ? ( {onCancel ? (
@ -64,19 +63,14 @@ export function CanvasImageDownloadProgressDialog({
<div className="mt-4"> <div className="mt-4">
<div className="mb-2 flex items-center justify-between text-[12px] text-white/55"> <div className="mb-2 flex items-center justify-between text-[12px] text-white/55">
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : converting ? "格式转换中" : "正在处理文件"}</span> <span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : "正在处理文件"}</span>
<span>{percent !== null ? `${percent}%` : "..."}</span> <span>{percent !== null ? `${percent}%` : "..."}</span>
</div> </div>
<div <div className="relative h-2 overflow-hidden rounded-full bg-white/10">
className="relative h-2 overflow-hidden rounded-full bg-white/10"
role="progressbar"
aria-label={converting ? "图片格式转换" : "图片下载"}
aria-valuenow={percent ?? undefined}
>
<div <div
className={cn( className={cn(
"absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-cyan-300 via-sky-400 to-emerald-300", "absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-cyan-300 via-sky-400 to-emerald-300",
percent === null && "w-1/3 motion-safe:animate-[download-indeterminate_1.15s_ease-in-out_infinite]" percent === null && "w-1/3 animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
)} )}
style={percent !== null ? { width: `${percent}%` } : undefined} style={percent !== null ? { width: `${percent}%` } : undefined}
/> />

View File

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

View File

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

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { useCanvasGroupRuns } from "@/hooks/use-canvas-group-runs"; import { useCanvasGroupRuns } from "@/hooks/use-canvas-group-runs";
import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery"; import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery";
import { GroupStoryboardPreview } from "./GroupStoryboardPreview"; import { GroupStoryboardPreview } from "./GroupStoryboardPreview";
@ -15105,7 +15104,7 @@ function DirectorNodeComposer({
if(file.size>20*1024*1024){setError("参考图需小于 20 MiB");return;} if(file.size>20*1024*1024){setError("参考图需小于 20 MiB");return;}
setBusy(true);setError("");try{ setBusy(true);setError("");try{
const form=new FormData();form.append("file",file);form.append("purpose","director-studio"); const form=new FormData();form.append("file",file);form.append("purpose","director-studio");
const response=await fetchUploadWithRetry(form,{signal:AbortSignal.timeout(120000)}); const response=await fetch("/api/files/upload",{method:"POST",body:form,signal:AbortSignal.timeout(120000)});
const result=await response.json();const url=result.url||result.data?.url; const result=await response.json();const url=result.url||result.data?.url;
if(!response.ok||!url)throw new Error(result.error||"图片上传失败"); if(!response.ok||!url)throw new Error(result.error||"图片上传失败");
onDraft({directorReferenceUrl:String(url)}); onDraft({directorReferenceUrl:String(url)});

View File

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

View File

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

View File

@ -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秒商品展示视频"

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { readDirectorScene, type DirectorScene } from "@/lib/video-canvas/director-scene"; import { readDirectorScene, type DirectorScene } from "@/lib/video-canvas/director-scene";
@ -65,7 +64,9 @@ export function DirectorReconstructionPanel({
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
form.append("purpose", "director-studio"); form.append("purpose", "director-studio");
const response = await fetchUploadWithRetry(form, { const response = await fetch("/api/files/upload", {
method: "POST",
body: form,
signal: AbortSignal.timeout(120000), signal: AbortSignal.timeout(120000),
}); });
const data = await response.json(); const data = await response.json();

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { import {
commitDirectorScene, commitDirectorScene,
resolveDirectorCamera, resolveDirectorCamera,
@ -44,13 +43,14 @@ async function upload(file: File) {
"purpose", "purpose",
file.type.startsWith("video/") ? "canvas-video-process" : "director-studio", file.type.startsWith("video/") ? "canvas-video-process" : "director-studio",
); );
const response = /\.glb$/i.test(file.name) const response = await fetch(
? await fetch("/api/canvas/director/upload-model", { /\.glb$/i.test(file.name) ? "/api/canvas/director/upload-model" : "/api/files/upload",
{
method: "POST", method: "POST",
body: form, body: form,
signal: AbortSignal.timeout(120000), signal: AbortSignal.timeout(120000),
}) },
: await fetchUploadWithRetry(form, { signal: AbortSignal.timeout(120000) }); );
const result = await response.json(); const result = await response.json();
const url = result.url || result.data?.url; const url = result.url || result.data?.url;
if (!response.ok || !url) throw new Error(result.error || "上传失败"); if (!response.ok || !url) throw new Error(result.error || "上传失败");

View File

@ -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,
@ -178,7 +174,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { import {
downloadOriginalImage, downloadCanvasImageAsPng,
getCanvasImageDownloadErrorMessage, getCanvasImageDownloadErrorMessage,
type CanvasImageDownloadProgress, type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download"; } from "@/lib/client/canvas-image-download";
@ -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
); );
@ -2482,11 +2475,14 @@ export function ImageNode({
[compareButtonVisualSize] [compareButtonVisualSize]
); );
const shouldUseHighFidelityDisplayImage = const shouldUseHighFidelityDisplayImage =
isSelected ||
isHovered ||
isCompareMode || isCompareMode ||
boxCutoutMode || boxCutoutMode ||
localEditMode || localEditMode ||
cropPanelOpen || cropPanelOpen ||
imageAdjustPanelOpen || imageAdjustPanelOpen ||
fullscreenPreviewOpen ||
isThisNodePicking; isThisNodePicking;
const displayImageWidth = useMemo(() => { const displayImageWidth = useMemo(() => {
const devicePixelRatio = const devicePixelRatio =
@ -2501,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(
@ -2519,6 +2515,15 @@ export function ImageNode({
}), }),
[data.src, displayImageQuality, displayImageWidth] [data.src, displayImageQuality, displayImageWidth]
); );
const fullscreenPreviewSrc = useMemo(() => {
const originalUrl = resolveOriginalImageUrl(data.src || "");
return buildImageProxyUrl(originalUrl || data.src || "", {
delivery: "proxy",
width: 4096,
quality: 92,
format: "webp",
});
}, [data.src]);
const displayCompareSrc = useMemo( const displayCompareSrc = useMemo(
() => () =>
buildImageProxyUrl(compareSource, { buildImageProxyUrl(compareSource, {
@ -4673,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) => {
@ -6640,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;
} }
@ -6659,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 =
@ -8223,6 +8304,7 @@ export function ImageNode({
} }
}; };
// 下载图片 - 服务端统一转为 Photoshop 兼容 PNG,避免后缀和真实格式错配。
const isDownloadingOriginalImage = Boolean(downloadProgress); const isDownloadingOriginalImage = Boolean(downloadProgress);
const handleDownload = async () => { const handleDownload = async () => {
if (!data.src) return; if (!data.src) return;
@ -8236,16 +8318,24 @@ export function ImageNode({
setDownloadProgress({ setDownloadProgress({
phase: "preparing", phase: "preparing",
message: "正在准备原图...", message: "正在准备原图...",
percent: 5,
}); });
try { try {
await downloadOriginalImage({ await downloadCanvasImageAsPng({
url: resolveOriginalImageUrl(data.src) || data.src, url: data.src,
name: displayTitle, name: displayTitle,
candidates: [
displayImageSrc,
fullscreenPreviewSrc,
buildImageProxyUrl(resolveOriginalImageUrl(data.src) || data.src, {
delivery: "proxy",
}),
],
signal: controller.signal, signal: controller.signal,
onProgress: setDownloadProgress, onProgress: setDownloadProgress,
}); });
toast.success("已开始下载"); toast.success("原图已开始下载");
} catch (error) { } catch (error) {
if (controller.signal.aborted) { if (controller.signal.aborted) {
toast.message("已取消下载"); toast.message("已取消下载");
@ -8349,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
@ -8380,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}
@ -8410,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}
@ -8461,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",
@ -8474,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
@ -8483,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}
@ -9404,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}
@ -11895,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"
@ -11903,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
@ -11921,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();
@ -11940,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}
/> />

View File

@ -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);
}}
/>;
})}</>;
});

View File

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

View File

@ -15,10 +15,8 @@ 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 { 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 +27,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;
@ -442,8 +439,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 +470,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 +761,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 +1025,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 +1055,7 @@ export const Response = ({
); );
}; };
if (rawText !== null) { if (rawText) {
const analysisSection = collapsibleImageAnalysis const analysisSection = collapsibleImageAnalysis
? extractImageAnalysisSection(rawText) ? extractImageAnalysisSection(rawText)
: null; : null;

View File

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

View File

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

View File

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

View File

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

View File

@ -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: "画图标注-抠图处理", // 可选:添加提示词
}), }),
}); });

View File

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

View File

@ -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, getImageDownloadExtension } 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,24 @@ export function ImagePreview({
toast.error("下载失败,请重试"); toast.error("下载失败,请重试");
return; return;
} }
const filenameBase = `generated-image-${Date.now()}`;
try { try {
await downloadOriginalImage({ url: safeOriginalUrl }); const response = await fetch(
toast.success("原图已开始下载"); buildImageProxyUrl(safeOriginalUrl, { delivery: "original" })
);
if (!response.ok) {
throw new Error(`下载失败(${response.status})`);
}
const blob = await response.blob();
const extension = getImageDownloadExtension(blob.type);
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
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)); toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
} }
}; };
@ -277,10 +306,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 +332,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 +626,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" : ""

View File

@ -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" &&

View File

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

View File

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

View File

@ -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");
} }
}; };

View File

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

View File

@ -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 小时。

View File

@ -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 的差额。本次未删除或改动七牛对象。

View File

@ -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") {

View File

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

View File

@ -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();
};
}

View File

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

View File

@ -14,8 +14,6 @@ import {
sanitizeAgentDetailBrief, sanitizeAgentDetailBrief,
} from "@/lib/agents/detail-generation-config"; } from "@/lib/agents/detail-generation-config";
export const MAX_DETAIL_REPLACEMENT_REFERENCES = 64;
export function createEmptyAgentModeWorkspaceState(): AgentModeWorkspaceState { export function createEmptyAgentModeWorkspaceState(): AgentModeWorkspaceState {
return { return {
detailReplacement: { detailReplacement: {

View File

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

View File

@ -407,32 +407,6 @@ function buildPromptSection(title: string, lines: string[]) {
return `【${title}】\n${normalizedLines.join("\n")}`; return `【${title}】\n${normalizedLines.join("\n")}`;
} }
export function resolveAgentWorkflowComposerSelections(params: {
config?: AgentWorkflowUiConfig | null;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
}): AgentWorkflowComposerContext["selections"] {
const config = params.config || defaultAgentWorkflowUiConfig;
const steps = flattenVisibleAgentWorkflowSteps(
getEnabledAgentWorkflowStepTree(Array.isArray(config.steps) ? config.steps : []),
params.selections
)
.map(({ step }) => step)
.filter((step) => Array.isArray(step.options) && step.options.length > 0);
return steps.flatMap((step) => {
const selectedOptions = getAgentWorkflowSelectedOptionPath({
step,
selectionValue: params.selections[step.key],
});
return selectedOptions.map((option) => ({
stepKey: step.key,
stepTitle: step.title,
optionKey: option.key,
optionName: option.buttonName,
promptFragment: option.promptFragment || option.buttonName,
}));
});
}
export function buildAgentWorkflowComposerContext(params: { export function buildAgentWorkflowComposerContext(params: {
config?: AgentWorkflowUiConfig | null; config?: AgentWorkflowUiConfig | null;
inputs: AgentWorkflowInputsValue; inputs: AgentWorkflowInputsValue;
@ -440,9 +414,30 @@ export function buildAgentWorkflowComposerContext(params: {
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null; selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
appearanceRecognitionEnabled?: boolean; appearanceRecognitionEnabled?: boolean;
selections: Partial<Record<AgentWorkflowStepKey, string>>; selections: Partial<Record<AgentWorkflowStepKey, string>>;
resolvedSelections?: AgentWorkflowComposerContext["selections"];
}): AgentWorkflowComposerContext | null { }): AgentWorkflowComposerContext | null {
const selections = params.resolvedSelections ?? resolveAgentWorkflowComposerSelections(params); const config = params.config || defaultAgentWorkflowUiConfig;
const steps = flattenVisibleAgentWorkflowSteps(
getEnabledAgentWorkflowStepTree(Array.isArray(config.steps) ? config.steps : []),
params.selections
)
.map(({ step }) => step)
.filter((step) => Array.isArray(step.options) && step.options.length > 0);
const selections = steps
.map((step) => {
const selectedOptions = getAgentWorkflowSelectedOptionPath({
step,
selectionValue: params.selections[step.key],
});
if (selectedOptions.length === 0) return [];
return selectedOptions.map((option) => ({
stepKey: step.key,
stepTitle: step.title,
optionKey: option.key,
optionName: option.buttonName,
promptFragment: option.promptFragment || option.buttonName,
}));
})
.flat() as AgentWorkflowComposerContext["selections"];
const referenceImages = getSelectedWorkflowReferenceImages({ const referenceImages = getSelectedWorkflowReferenceImages({
inputs: params.inputs, inputs: params.inputs,
materials: params.materials, materials: params.materials,

View File

@ -1,18 +0,0 @@
import type { AgentWorkflowOptionUiConfig, AgentWorkflowStepUiConfig } from "@/types/agent";
export function omitAgentWorkflowPrompts(steps: AgentWorkflowStepUiConfig[]): AgentWorkflowStepUiConfig[] {
const omitOptionPrompt = ({ promptFragment: _prompt, children, ...option }: AgentWorkflowOptionUiConfig): AgentWorkflowOptionUiConfig => ({
...option,
...(children?.length ? { children: children.map(omitOptionPrompt) } : {}),
});
return steps.map(({ options, children, ...step }) => ({
...step,
options: options.map(omitOptionPrompt),
...(children?.length ? { children: omitAgentWorkflowPrompts(children) } : {}),
}));
}
export function getWorkflowSelectionsRequest(selections: Record<string, string | undefined>) {
const entries = Object.entries(selections).filter(([, value]) => value?.trim());
return entries.length ? JSON.stringify(Object.fromEntries(entries.sort(([a], [b]) => a.localeCompare(b)))) : "";
}

View File

@ -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"),

View File

@ -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}`,

View File

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

View File

@ -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) {

View File

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

View File

@ -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 } : {}),
}), }),
@ -173,8 +163,6 @@ export async function buildCanvasRuntimeTools(params: {
}; };
}, },
); );
const inspectedImages = new Map<string, Buffer>();
let inspectedImageBytes = 0;
register( register(
"analyzeCanvasImages", "analyzeCanvasImages",
"检查图片", "检查图片",
@ -183,28 +171,10 @@ export async function buildCanvasRuntimeTools(params: {
nodeIds: z.array(z.string().min(1).max(180)).min(1).max(4), nodeIds: z.array(z.string().min(1).max(180)).min(1).max(4),
question: z.string().min(1).max(2000), question: z.string().min(1).max(2000),
}), }),
async (input) => analyzeRuntimeImages({ async (input) => analyzeRuntimeImages({ ...params, ...input }),
...params,
...input,
reviewPorts: {
readImage: async (url) => {
const cached = inspectedImages.get(url);
if (cached) return cached;
const { fetchImageBufferFromUrl } = await import("@/lib/ai/rmbg/service");
const original = await fetchImageBufferFromUrl(url);
if (!inspectedImages.has(url) && inspectedImageBytes + original.length <= 32 * 1024 * 1024) {
inspectedImages.set(url, original);
inspectedImageBytes += original.length;
}
return original;
},
},
}),
); );
let submittedCount = 0; let generatedCount = 0;
let occupiedImageSlots = countAgentGeneratedImages(params.priorCalls ?? []);
const countLimitMessage = "本次设定的生成张数已占满,请先查看正在生成或已经生成的结果。";
register( register(
"generateCanvasImages", "generateCanvasImages",
"生成图片", "生成图片",
@ -219,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,
@ -243,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 },
@ -267,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,
@ -284,7 +247,6 @@ export async function buildCanvasRuntimeTools(params: {
? Math.max(0, user?.pointsBalance ?? 0) / input.imageCount : undefined, ? Math.max(0, user?.pointsBalance ?? 0) / input.imageCount : undefined,
ownerKey: params.userId, ownerKey: params.userId,
referenceImages: sources.map((node) => node.data.src || node.data.url!), referenceImages: sources.map((node) => node.data.src || node.data.url!),
referenceImageBuffers: inspectedImages,
debugLabel: "canvas-agent-runtime", debugLabel: "canvas-agent-runtime",
debugMeta: { runId: params.runId, callId }, debugMeta: { runId: params.runId, callId },
}); });
@ -349,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 },

View File

@ -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,
}); });

View File

@ -1,5 +1,3 @@
import { isVideoModelDescriptor } from "@/lib/ai/model-classification";
export const DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY = export const DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY =
"detail_replacement_image_model_config"; "detail_replacement_image_model_config";
@ -22,28 +20,3 @@ export function normalizeDetailReplacementImageModelConfig(
modelId: modelId || DEFAULT_DETAIL_REPLACEMENT_IMAGE_MODEL_ID, modelId: modelId || DEFAULT_DETAIL_REPLACEMENT_IMAGE_MODEL_ID,
}; };
} }
export function isDetailReplacementImageModel({
model,
provider,
}: {
model: { modelId: string; supportsImageGen: boolean; isSubModel: boolean; capabilities?: unknown };
provider?: { type?: string | null } | null;
}) {
return model.supportsImageGen && !model.isSubModel && !isVideoModelDescriptor({
providerType: provider?.type,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
export function findConfiguredDetailReplacementImageModel<
T extends Parameters<typeof isDetailReplacementImageModel>[0] & { model: { id: string } },
>(models: T[], setting: unknown): T | undefined {
const { modelId } = normalizeDetailReplacementImageModelConfig(setting);
return models.find((entry) =>
(entry.model.id === modelId || entry.model.modelId === modelId) &&
isDetailReplacementImageModel(entry)
);
}

View File

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

View File

@ -3,19 +3,6 @@ export type ExtendProviderCredential = {
updatedAt?: Date | string | null; updatedAt?: Date | string | null;
}; };
export type ExtendProvider = "vip98k" | "dashscope" | "fal";
export function resolveFalExtendApiKey(providers: Array<ExtendProviderCredential & { baseUrl?: string | null }>) {
for (const { apiKey, baseUrl } of providers) {
if (!apiKey || !baseUrl) continue;
const url = URL.canParse(baseUrl) ? new URL(baseUrl) : null;
if (url?.protocol === "https:" && ["queue.fal.run", "fal.run", "fal.ai", "api.fal.ai"].includes(url.hostname)) {
return apiKey.trim().replace(/^(?:Bearer|Key)\s+/i, "");
}
}
return null;
}
function getUpdatedAtTimestamp(value: Date | string | null | undefined): number { function getUpdatedAtTimestamp(value: Date | string | null | undefined): number {
if (!value) return 0; if (!value) return 0;
const timestamp = value instanceof Date ? value.getTime() : Date.parse(value); const timestamp = value instanceof Date ? value.getTime() : Date.parse(value);

View File

@ -1,93 +0,0 @@
import { createFalClient } from "@fal-ai/client";
import sharp from "sharp";
export const FAL_OUTPAINT_MODEL = "fal-ai/flux-2-pro/outpaint";
export function getOutpaintGeometry(width: number, height: number, xScale: number, yScale: number) {
const targetWidth = Math.round(width * xScale);
const targetHeight = Math.round(height * yScale);
const ratio = Math.min(1, 2048 / Math.max(targetWidth, targetHeight));
const modelWidth = Math.max(1, Math.floor(targetWidth * ratio));
const modelHeight = Math.max(1, Math.floor(targetHeight * ratio));
const sourceWidth = Math.max(1, Math.round(width * modelWidth / targetWidth));
const sourceHeight = Math.max(1, Math.round(height * modelHeight / targetHeight));
const left = Math.floor((targetWidth - width) / 2);
const top = Math.floor((targetHeight - height) / 2);
const modelLeft = Math.round(left * modelWidth / targetWidth);
const modelTop = Math.round(top * modelHeight / targetHeight);
// Align the model canvas to its 16-pixel grid, then remove the extra border.
const extraWidth = (16 - modelWidth % 16) % 16;
const extraHeight = (16 - modelHeight % 16) % 16;
const cropLeft = Math.floor(extraWidth / 2);
const cropTop = Math.floor(extraHeight / 2);
return {
targetWidth, targetHeight, left, top, modelWidth, modelHeight, sourceWidth, sourceHeight,
paddedWidth: modelWidth + extraWidth,
paddedHeight: modelHeight + extraHeight,
cropLeft,
cropTop,
expandLeft: modelLeft + cropLeft,
expandRight: modelWidth - sourceWidth - modelLeft + extraWidth - cropLeft,
expandTop: modelTop + cropTop,
expandBottom: modelHeight - sourceHeight - modelTop + extraHeight - cropTop,
};
}
export async function composeOutpaint(source: Buffer, expanded: Buffer, geometry: ReturnType<typeof getOutpaintGeometry>) {
const { targetWidth, targetHeight, left, top, modelWidth, modelHeight, paddedWidth, paddedHeight, cropLeft, cropTop } = geometry;
const modelImage = sharp(expanded);
const info = await modelImage.metadata();
if (info.width !== paddedWidth || info.height !== paddedHeight) {
throw new Error("扩图服务返回的图片尺寸不正确");
}
// Copy the source rectangle, including transparent pixels, without blending or resizing it.
const { data: original, info: sourceInfo } = await sharp(source).toColourspace("srgb").ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const result = await modelImage
.extract({ left: cropLeft, top: cropTop, width: modelWidth, height: modelHeight })
.resize(targetWidth, targetHeight, { fit: "fill" })
.toColourspace("srgb").ensureAlpha().raw().toBuffer();
const stride = sourceInfo.width * 4;
for (let row = 0; row < sourceInfo.height; row++) {
original.copy(result, ((top + row) * targetWidth + left) * 4, row * stride, (row + 1) * stride);
}
return sharp(result, { raw: { width: targetWidth, height: targetHeight, channels: 4 } }).png().toBuffer();
}
export async function expandWithFalOutpaint({ imageUrl, apiKey, xScale, yScale }: {
imageUrl: string;
apiKey: string;
xScale: number;
yScale: number;
}) {
const response = await fetch(imageUrl, { signal: AbortSignal.timeout(45_000) });
if (!response.ok) throw new Error("无法读取待扩展的原图");
const { data: source, info } = await sharp(Buffer.from(await response.arrayBuffer()))
.rotate().png().toBuffer({ resolveWithObject: true });
const geometry = getOutpaintGeometry(info.width, info.height, xScale, yScale);
if (geometry.targetWidth * geometry.targetHeight > 64_000_000) {
throw new Error("扩图后的尺寸过大,请降低扩展倍数");
}
const fal = createFalClient({ credentials: apiKey });
const modelSource = geometry.sourceWidth === info.width && geometry.sourceHeight === info.height
? source
: await sharp(source).resize(geometry.sourceWidth, geometry.sourceHeight, { fit: "fill" }).png().toBuffer();
const image = await fal.storage.upload(new Blob([new Uint8Array(modelSource)], { type: "image/png" }));
const { data } = await fal.subscribe(FAL_OUTPAINT_MODEL, {
input: {
image_url: image,
expand_left: geometry.expandLeft,
expand_right: geometry.expandRight,
expand_top: geometry.expandTop,
expand_bottom: geometry.expandBottom,
mode: "high",
output_format: "png",
},
timeout: 90_000,
});
const outputUrl = (data as { images?: Array<{ url?: string }> }).images?.[0]?.url;
if (!outputUrl) throw new Error("扩图服务未返回图片");
const output = await fetch(outputUrl, { signal: AbortSignal.timeout(25_000) });
if (!output.ok) throw new Error("无法读取扩图结果");
return composeOutpaint(source, Buffer.from(await output.arrayBuffer()), geometry);
}

View File

@ -5,26 +5,21 @@ import {
getSystemSetting, getSystemSetting,
upsertMediaAsset, upsertMediaAsset,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { type ExtendProvider, resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config"; import { resolveAlibabaExtendApiKey } from "@/lib/ai/extend/config";
import { expandWithFalOutpaint, FAL_OUTPAINT_MODEL } from "@/lib/ai/extend/fal-outpaint"; import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
import { executeConfiguredVip98kTask, getVip98kRuntimeProvider } from "@/lib/ai/vip98k-runtime";
import { expandWithVip98kOutpaint, VIP98K_OUTPAINT_MODEL } from "@/lib/ai/extend/vip98k-outpaint";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema"; import { generatedImage } from "@/lib/db/schema";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { archiveTemporaryImage } from "@/lib/storage/image-archive";
import { randomUUID } from "node:crypto";
type ExtendConfig = { type ExtendConfig = {
provider?: ExtendProvider; provider?: "vip98k" | "dashscope";
apiKey?: string; apiKey?: string;
xScale?: number; xScale?: number;
yScale?: number; yScale?: number;
modelId?: string;
routeOptionId?: string;
}; };
const SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000; const VIP98K_EXPANSION_FALLBACK_MODEL = "google/nano-banana-pro/edit";
const VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const DASHSCOPE_MIN_IMAGE_EDGE = 512; const DASHSCOPE_MIN_IMAGE_EDGE = 512;
const DASHSCOPE_MAX_IMAGE_EDGE = 4096; const DASHSCOPE_MAX_IMAGE_EDGE = 4096;
const DASHSCOPE_MAX_IMAGE_BYTES = 10 * 1024 * 1024; const DASHSCOPE_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
@ -88,12 +83,12 @@ async function createThumbnail(buffer: Buffer): Promise<Buffer> {
} }
} }
async function uploadBufferPairToS3(buffer: Buffer, originalExtension = "jpg"): Promise<{ thumbnail: string; original: string }> { async function uploadBufferPairToS3(buffer: Buffer): Promise<{ thumbnail: string; original: string }> {
try { try {
const timestamp = Date.now(); const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8); const randomStr = Math.random().toString(36).slice(2, 8);
const thumbnailFilename = `extend-thumb-${timestamp}-${randomStr}.jpg`; const thumbnailFilename = `extend-thumb-${timestamp}-${randomStr}.jpg`;
const originalFilename = `extend-orig-${timestamp}-${randomStr}.${originalExtension}`; const originalFilename = `extend-orig-${timestamp}-${randomStr}.jpg`;
const thumbnailBuffer = await createThumbnail(buffer); const thumbnailBuffer = await createThumbnail(buffer);
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice( const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset, thumbnailBuffer.byteOffset,
@ -147,7 +142,7 @@ async function normalizeDashscopeExpansionInput(imageUrl: string): Promise<strin
const response = await fetch(imageUrl, { const response = await fetch(imageUrl, {
cache: "no-store", cache: "no-store",
redirect: "follow", redirect: "follow",
signal: AbortSignal.timeout(SOURCE_DOWNLOAD_TIMEOUT_MS), signal: AbortSignal.timeout(VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS),
}); });
if (!response.ok) { if (!response.ok) {
throw new ExtendServiceError( throw new ExtendServiceError(
@ -242,6 +237,97 @@ async function normalizeDashscopeExpansionInput(imageUrl: string): Promise<strin
return uploaded.url; return uploaded.url;
} }
function isTemporaryVip98kExpansionError(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
return /HTTP\s+(?:429|5\d\d)|temporar(?:y|ily)|timeout|timed out|unavailable/i.test(
message,
);
}
async function createVip98kExpansionCanvas(
imageUrl: string,
xScale: number,
yScale: number,
) {
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new ExtendServiceError(
"下载待拓展图片失败",
502,
`${response.status} ${response.statusText}`,
);
}
const sourceBuffer = Buffer.from(await response.arrayBuffer());
const sharp = await import("sharp");
const image = sharp.default(sourceBuffer, { failOn: "none" });
const metadata = await image.metadata();
const width = Number(metadata.width || 0);
const height = Number(metadata.height || 0);
if (!width || !height) {
throw new ExtendServiceError("无法识别待拓展图片尺寸", 422);
}
const targetWidth = Math.max(width, Math.round(width * xScale));
const targetHeight = Math.max(height, Math.round(height * yScale));
const left = Math.floor((targetWidth - width) / 2);
const top = Math.floor((targetHeight - height) / 2);
const canvas = await image
.ensureAlpha()
.extend({
left,
right: targetWidth - width - left,
top,
bottom: targetHeight - height - top,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png()
.toBuffer();
const arrayBuffer = canvas.buffer.slice(
canvas.byteOffset,
canvas.byteOffset + canvas.byteLength,
) as ArrayBuffer;
const uploaded = await uploadFile(
`extend-vip98k-canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`,
arrayBuffer,
);
return {
imageUrl: uploaded.url,
width,
height,
targetWidth,
targetHeight,
};
}
async function extendImageWithVip98kEditFallback(
imageUrl: string,
xScale: number,
yScale: number,
) {
const canvas = await createVip98kExpansionCanvas(imageUrl, xScale, yScale);
const result = await executeConfiguredVip98kTask({
modelId: VIP98K_EXPANSION_FALLBACK_MODEL,
kind: "image",
prompt: [
"Outpaint only the transparent regions of this image.",
"Keep every original pixel, subject, scale, position, perspective, lighting and style unchanged.",
`Complete the ${canvas.targetWidth}x${canvas.targetHeight} canvas naturally from the centered ${canvas.width}x${canvas.height} source.`,
"Do not crop, zoom, move, replace or redraw the original content.",
].join(" "),
imageUrls: [canvas.imageUrl],
});
const outputUrl = result.outputUrls[0];
if (!outputUrl) {
throw new ExtendServiceError("VIP98K 生成式扩图未返回图片", 502);
}
return outputUrl;
}
async function extendImage( async function extendImage(
imageUrl: string, imageUrl: string,
config: ExtendConfig, config: ExtendConfig,
@ -250,9 +336,11 @@ async function extendImage(
requestId?: string | null, requestId?: string | null,
): Promise<string> { ): Promise<string> {
if (config.provider === "vip98k") { if (config.provider === "vip98k") {
try {
const result = await executeConfiguredVip98kTask({ const result = await executeConfiguredVip98kTask({
modelId: "image-expansion", modelId: "image-expansion",
kind: "image-tool", kind: "image-tool",
prompt: `Expand the image canvas naturally to ${xScale}x width and ${yScale}x height. Preserve the original subject, perspective, lighting and style.`,
imageUrls: [imageUrl], imageUrls: [imageUrl],
imageParams: { xScale, yScale }, imageParams: { xScale, yScale },
}); });
@ -261,6 +349,14 @@ async function extendImage(
throw new ExtendServiceError("VIP98K 图像拓展未返回图片", 502); throw new ExtendServiceError("VIP98K 图像拓展未返回图片", 502);
} }
return outputUrl; return outputUrl;
} catch (error) {
if (!isTemporaryVip98kExpansionError(error)) throw error;
console.warn(
"[Extend Service] VIP98K expansion tool is temporarily unavailable; using VIP98K generative outpainting fallback",
error instanceof Error ? error.message : String(error),
);
return extendImageWithVip98kEditFallback(imageUrl, xScale, yScale);
}
} }
if (!config.apiKey) { if (!config.apiKey) {
@ -377,15 +473,6 @@ async function getValidatedExtendConfig(): Promise<ExtendConfig> {
if (config.provider === "vip98k") { if (config.provider === "vip98k") {
return config; return config;
} }
if (config.provider === "fal") {
const [imageProviders, videoProviders] = await Promise.all([
getAiProvidersByType({ type: "fal-image", enabledOnly: true }),
getAiProvidersByType({ type: "fal-video", enabledOnly: true }),
]);
const apiKey = resolveFalExtendApiKey([...imageProviders, ...videoProviders]);
if (!apiKey) throw new ExtendServiceError("扩图服务未配置可用的 fal 密钥", 503);
return { ...config, apiKey };
}
const alibabaProviders = await getAiProvidersByType({ const alibabaProviders = await getAiProvidersByType({
type: "alibaba-image", type: "alibaba-image",
@ -435,48 +522,36 @@ export async function executeExtendJob(
throw new ExtendServiceError("无法获取输入图片URL", 400); throw new ExtendServiceError("无法获取输入图片URL", 400);
} }
const preparedInputImageUrl =
config.provider === "vip98k"
? inputImageUrl
: await normalizeDashscopeExpansionInput(inputImageUrl);
const startTime = Date.now(); const startTime = Date.now();
let buffer!: Buffer; const extendedImageUrl = await extendImage(
let uploadResult: { thumbnail: string; original: string } | undefined; preparedInputImageUrl,
let archivedBytes: number | undefined; config,
let downloadAsset: Awaited<ReturnType<typeof archiveTemporaryImage>>["downloadAsset"]; xScale,
let modelId: string; yScale,
if (config.provider === "fal") { requestId,
buffer = await expandWithFalOutpaint({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale }); );
modelId = FAL_OUTPAINT_MODEL; const imageResponse = await fetch(extendedImageUrl);
} else if (config.provider === "vip98k" && config.modelId === VIP98K_OUTPAINT_MODEL) {
if (!config.routeOptionId) throw new ExtendServiceError("98K 扩图线路未配置", 503);
const provider = await getVip98kRuntimeProvider();
buffer = await expandWithVip98kOutpaint({
imageUrl: inputImageUrl, baseUrl: provider.baseUrl, apiKey: provider.apiKey,
routeOptionId: config.routeOptionId, xScale, yScale,
});
modelId = VIP98K_OUTPAINT_MODEL;
} else {
const prepared = config.provider === "vip98k" ? inputImageUrl : await normalizeDashscopeExpansionInput(inputImageUrl);
const url = await extendImage(prepared, config, xScale, yScale, requestId);
if (config.provider === "vip98k" && !includeBase64) {
const archived = await archiveTemporaryImage(url, requestId || randomUUID());
uploadResult = { original: archived.url, thumbnail: archived.thumbnailUrl };
archivedBytes = archived.bytes;
downloadAsset = archived.downloadAsset;
} else {
const imageResponse = await fetch(url);
if (!imageResponse.ok) { if (!imageResponse.ok) {
throw new ExtendServiceError("下载拓展图片失败", 500, `${imageResponse.status} ${imageResponse.statusText}`); throw new ExtendServiceError(
} "下载拓展图片失败",
buffer = Buffer.from(await imageResponse.arrayBuffer()); 500,
} `${imageResponse.status} ${imageResponse.statusText}`,
modelId = config.provider === "vip98k" ? "image-expansion" : "extend-1.0"; );
} }
const buffer = Buffer.from(await imageResponse.arrayBuffer());
const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined; const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined;
uploadResult ??= await uploadBufferPairToS3(buffer, config.provider === "fal" || config.provider === "vip98k" ? "png" : "jpg"); const uploadResult = await uploadBufferPairToS3(buffer);
console.log("[Extend Service] Result uploaded:", { console.log("[Extend Service] Result uploaded:", {
requestId, requestId,
includeBase64, includeBase64,
inputImageUrl: inputImageUrl.slice(0, 120), inputImageUrl: inputImageUrl.slice(0, 120),
modelId, extendedImageUrl: extendedImageUrl.slice(0, 120),
outputBytes: archivedBytes ?? buffer.length, outputBytes: buffer.length,
}); });
let mediaAssetId: string | null = null; let mediaAssetId: string | null = null;
let generatedImageId: string | null = null; let generatedImageId: string | null = null;
@ -492,7 +567,7 @@ export async function executeExtendJob(
thumbnailUrl: uploadResult.thumbnail, thumbnailUrl: uploadResult.thumbnail,
originalUrl: uploadResult.original, originalUrl: uploadResult.original,
prompt, prompt,
modelId, modelId: config.provider === "vip98k" ? "image-expansion" : "extend-1.0",
imageSize: "2K", imageSize: "2K",
}) })
.returning(); .returning();
@ -516,7 +591,7 @@ export async function executeExtendJob(
thumbnailUrl: uploadResult.thumbnail, thumbnailUrl: uploadResult.thumbnail,
displayName: prompt.slice(0, 80), displayName: prompt.slice(0, 80),
prompt, prompt,
modelId, modelId: config.provider === "vip98k" ? "image-expansion" : "extend-1.0",
sourceMessageId: messageId, sourceMessageId: messageId,
sourceTable: "GeneratedImage", sourceTable: "GeneratedImage",
sourceRowId: generatedImageId, sourceRowId: generatedImageId,
@ -525,7 +600,6 @@ export async function executeExtendJob(
xScale, xScale,
yScale, yScale,
imageSize: "2K", imageSize: "2K",
...(downloadAsset ? { downloadAsset } : {}),
}, },
}); });
mediaAssetId = media?.id || null; mediaAssetId = media?.id || null;

View File

@ -1,85 +0,0 @@
import sharp from "sharp";
export const VIP98K_OUTPAINT_MODEL = "gpt-image-2.5-flare";
const RATIOS = [[1, 1], [4, 3], [3, 4], [3, 2], [2, 3], [16, 9], [9, 16]];
export function getVip98kOutpaintGeometry(width: number, height: number, xScale: number, yScale: number) {
const targetWidth = Math.round(width * xScale);
const targetHeight = Math.round(height * yScale);
if (targetWidth * targetHeight > 64_000_000) throw new Error("扩图后的尺寸过大,请降低扩展倍数");
const ratio = targetWidth / targetHeight;
const [a, b] = RATIOS.reduce((best, next) =>
Math.abs(Math.log(ratio / (next[0] / next[1]))) < Math.abs(Math.log(ratio / (best[0] / best[1]))) ? next : best);
const paddedWidth = Math.max(targetWidth, Math.ceil(targetHeight * a / b));
const paddedHeight = Math.max(targetHeight, Math.ceil(targetWidth * b / a));
const factor = Math.min(1, 2048 / Math.max(paddedWidth, paddedHeight));
const modelWidth = Math.max(1, Math.round(paddedWidth * factor));
const modelHeight = Math.max(1, Math.round(paddedHeight * factor));
const sourceWidth = Math.max(1, Math.round(width * factor));
const sourceHeight = Math.max(1, Math.round(height * factor));
return {
targetWidth, targetHeight, modelWidth, modelHeight, sourceWidth, sourceHeight,
left: Math.floor((modelWidth - sourceWidth) / 2),
top: Math.floor((modelHeight - sourceHeight) / 2),
aspectRatio: `${a}:${b}`,
resolution: Math.max(targetWidth, targetHeight) > 1536 ? "2K" : "1K",
};
}
export async function expandWithVip98kOutpaint(params: {
imageUrl: string;
baseUrl: string;
apiKey: string;
routeOptionId: string;
xScale: number;
yScale: number;
}) {
const response = await fetch(params.imageUrl, { signal: AbortSignal.timeout(120_000) });
if (!response.ok) throw new Error("无法读取待扩展的原图");
const { data: source, info } = await sharp(Buffer.from(await response.arrayBuffer()))
.rotate().png().toBuffer({ resolveWithObject: true });
const g = getVip98kOutpaintGeometry(info.width, info.height, params.xScale, params.yScale);
const input = await sharp(source).resize(g.sourceWidth, g.sourceHeight).ensureAlpha().extend({
left: g.left, right: g.modelWidth - g.sourceWidth - g.left,
top: g.top, bottom: g.modelHeight - g.sourceHeight - g.top,
background: { r: 0, g: 0, b: 0, alpha: 0 },
}).png().toBuffer();
const body = new FormData();
const prompt = [
"Extend this product image outward on all four sides, continuing its background naturally.",
`The reference canvas is ${g.modelWidth}x${g.modelHeight}; the original image is centered at (${g.left},${g.top}) with size ${g.sourceWidth}x${g.sourceHeight}.`,
"Fill only the surrounding empty area as a continuation of the same scene.",
"Preserve the existing product identity, structure, colors, and all Chinese text and dimension numbers.",
"Keep approximately the same composition and the product centered.",
"Avoid adding new products or additional text. Return a complete seamless image.",
].join(" ");
for (const [key, value] of Object.entries({
model: VIP98K_OUTPAINT_MODEL, prompt, n: "1", size: g.aspectRatio,
resolution: g.resolution, quality: "high", response_format: "url",
})) body.append(key, value);
body.append("image[]", new Blob([new Uint8Array(input)], { type: "image/png" }), "outpaint.png");
const base = params.baseUrl.replace(/\/$/, "").replace(/\/v1$/, "");
const generated = await fetch(`${base}/v1/images/edits`, {
method: "POST", body,
headers: { Authorization: `Bearer ${params.apiKey}`, "X-Relay-Route-Option": params.routeOptionId },
signal: AbortSignal.timeout(180_000),
});
const result = await generated.json();
if (!generated.ok) throw new Error(`98K 扩图失败:${result?.error?.message || `HTTP ${generated.status}`}`);
const image = result?.data?.[0];
let expanded: Buffer;
if (typeof image?.b64_json === "string") {
expanded = Buffer.from(image.b64_json, "base64");
} else if (typeof image?.url === "string") {
const outputUrl = new URL(image.url);
const headers = new Headers();
if (outputUrl.origin === new URL(base).origin) headers.set("Authorization", `Bearer ${params.apiKey}`);
const output = await fetch(outputUrl, { headers, signal: AbortSignal.timeout(60_000) });
if (!output.ok) throw new Error(`无法读取扩图结果(HTTP ${output.status})`);
expanded = Buffer.from(await output.arrayBuffer());
} else {
throw new Error("98K 扩图未返回图片");
}
return sharp(expanded).resize(g.targetWidth, g.targetHeight, { fit: "cover", position: "centre" }).png().toBuffer();
}

View File

@ -5,17 +5,15 @@
* - 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";
import { constrainImageDimensions } from "@/lib/ai/image-size-constraints"; import { constrainImageDimensions } from "@/lib/ai/image-size-constraints";
import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification"; 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 { ImageReferenceReadTimeoutError } from "@/lib/ai/runtime/image-generation-error";
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,
@ -48,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: {
@ -126,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,
@ -166,7 +152,7 @@ async function fetchImageBufferWithRetry(
} }
} }
throw lastError ?? new Error("下载图片失败"); throw lastError instanceof Error ? lastError : new Error(String(lastError || "下载图片失败"));
} }
function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): string { function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): string {
@ -182,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);
@ -200,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);
@ -290,20 +276,12 @@ async function prepareAlibabaInputImageUrl(
imageUrl: string, imageUrl: string,
modelId: string, modelId: string,
inputRole?: AlibabaImageInputRole, inputRole?: AlibabaImageInputRole,
original?: Buffer,
): Promise<string> { ): Promise<string> {
const normalizedUrl = String(imageUrl || "").trim(); const normalizedUrl = String(imageUrl || "").trim();
if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl; if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl;
const maxInputBytes = getAlibabaMaxInputBytes(modelId); const maxInputBytes = getAlibabaMaxInputBytes(modelId);
const { buffer, contentType } = original const { buffer, contentType } = await fetchImageBufferWithRetry(normalizedUrl);
? { buffer: original, contentType: null }
: await fetchImageBufferWithRetry(normalizedUrl).catch((error) => {
if (["AbortError", "TimeoutError"].includes(error?.name)) {
throw new ImageReferenceReadTimeoutError();
}
throw error;
});
let metadata: sharp.Metadata; let metadata: sharp.Metadata;
try { try {
metadata = await sharp(buffer, { failOn: "none" }).metadata(); metadata = await sharp(buffer, { failOn: "none" }).metadata();
@ -324,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;
} }
@ -570,7 +540,7 @@ async function sendAlibabaImageAsyncRequest(params: {
url: params.requestURL, url: params.requestURL,
}); });
const taskId = String(submitted.output?.task_id || "").trim(); const taskId = String(submitted.output?.task_id || "").trim();
if (!taskId) throw new Error("阿里图片异步任务未返回 task_id,结果尚未确认"); if (!taskId) throw new Error("阿里图片异步任务未返回 task_id");
const taskURL = resolveAlibabaImageTaskEndpoint(params.requestURL, taskId); const taskURL = resolveAlibabaImageTaskEndpoint(params.requestURL, taskId);
const startedAt = Date.now(); const startedAt = Date.now();
@ -594,11 +564,9 @@ async function sendAlibabaImageAsyncRequest(params: {
console.log("[Alibaba Image] Async task completed:", taskId); console.log("[Alibaba Image] Async task completed:", taskId);
return task; return task;
} }
if (["FAILED", "CANCELED"].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;
@ -610,7 +578,7 @@ async function sendAlibabaImageAsyncRequest(params: {
error.status < 500 && error.status < 500 &&
![408, 425, 429].includes(error.status) ![408, 425, 429].includes(error.status)
) { ) {
throw new Error(`阿里图片任务已提交,结果尚未确认(task_id: ${taskId})`, { cause: error }); throw error;
} }
lastPollError = error; lastPollError = error;
console.warn("[Alibaba Image] Async task poll failed; retrying:", { console.warn("[Alibaba Image] Async task poll failed; retrying:", {
@ -732,7 +700,6 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
doGenerate: async (options: { doGenerate: async (options: {
messages: Array<{ role: string; content: string | Array<{ type: string; text?: string; image?: string }> }>; messages: Array<{ role: string; content: string | Array<{ type: string; text?: string; image?: string }> }>;
imageParams?: AlibabaImageParams & { aspectRatio?: string; imageSize?: string; imageCount?: number }; imageParams?: AlibabaImageParams & { aspectRatio?: string; imageSize?: string; imageCount?: number };
referenceImageBuffers?: ReadonlyMap<string, Buffer>;
modelDisplayName?: string; // 🆕 模型显示名称,用于错误提示 modelDisplayName?: string; // 🆕 模型显示名称,用于错误提示
overrideModelId?: string; // 🆕 备用模型 ID overrideModelId?: string; // 🆕 备用模型 ID
}) => { }) => {
@ -802,16 +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],
options.referenceImageBuffers?.get(imageUrl),
),
), ),
); );
}
// qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。 // qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。
if ( if (
@ -949,9 +916,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
); );
// 处理响应 // 处理响应
const images = await processResponse(response).catch((error) => { const images = await processResponse(response);
throw new Error("阿里图片任务已提交,结果尚未确认。", { cause: error });
});
const markdownText = formatImageResponse(images, params.size, imageSize); const markdownText = formatImageResponse(images, params.size, imageSize);
console.log(`[Alibaba Image] Generated ${images.length} images successfully`); console.log(`[Alibaba Image] Generated ${images.length} images successfully`);

View File

@ -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,
@ -99,7 +98,6 @@ const DEFAULT_POLL_INTERVAL_SECONDS = 8;
const DEFAULT_MAX_POLL_SECONDS = 600; const DEFAULT_MAX_POLL_SECONDS = 600;
const REFERENCE_DOWNLOAD_ATTEMPTS = 3; const REFERENCE_DOWNLOAD_ATTEMPTS = 3;
const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000; const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const REFERENCE_CDN_TIMEOUT_MS = 10_000;
const REFERENCE_CACHE_LOOKUP_TIMEOUT_MS = 15_000; const REFERENCE_CACHE_LOOKUP_TIMEOUT_MS = 15_000;
const MAX_REFERENCE_DOWNLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES; const MAX_REFERENCE_DOWNLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES;
const REFERENCE_STAGING_CACHE_MS = 60_000; const REFERENCE_STAGING_CACHE_MS = 60_000;
@ -683,26 +681,25 @@ async function findStagedReference(
} }
} }
async function fetchReferenceImage(mediaUrl: string, preferCdn = false) { async function downloadReferenceImage(mediaUrl: string) {
let lastError: unknown;
for (let attempt = 1; attempt <= REFERENCE_DOWNLOAD_ATTEMPTS; attempt += 1) {
try {
const response = await fetch(mediaUrl, { const response = await fetch(mediaUrl, {
cache: preferCdn ? "default" : "no-store", cache: "no-store",
redirect: preferCdn ? "error" : "follow", redirect: "follow",
headers: { "User-Agent": "yedir-ai-vip98k-staging/1.0" }, headers: { "User-Agent": "yedir-ai-vip98k-staging/1.0" },
signal: AbortSignal.timeout(preferCdn ? REFERENCE_CDN_TIMEOUT_MS : REFERENCE_DOWNLOAD_TIMEOUT_MS), signal: AbortSignal.timeout(REFERENCE_DOWNLOAD_TIMEOUT_MS),
}); });
if (!response.ok || (preferCdn && response.status !== 200)) { if (!response.ok) {
await response.body?.cancel();
throw new Error(`source returned HTTP ${response.status}`); throw new Error(`source returned HTTP ${response.status}`);
} }
const mimeType = response.headers.get("content-type") || "image/png";
if (preferCdn && !/^image\//i.test(mimeType) && !/^application\/octet-stream\b/i.test(mimeType)) {
await response.body?.cancel();
throw new Error("CDN response was not an image");
}
const declaredLength = Number(response.headers.get("content-length")); const declaredLength = Number(response.headers.get("content-length"));
if (declaredLength > MAX_REFERENCE_DOWNLOAD_BYTES) { if (
await response.body?.cancel(); Number.isFinite(declaredLength) &&
declaredLength > MAX_REFERENCE_DOWNLOAD_BYTES
) {
throw new Vip98kReferenceTooLargeError(); throw new Vip98kReferenceTooLargeError();
} }
@ -710,17 +707,17 @@ async function fetchReferenceImage(mediaUrl: string, preferCdn = false) {
if (buffer.byteLength > MAX_REFERENCE_DOWNLOAD_BYTES) { if (buffer.byteLength > MAX_REFERENCE_DOWNLOAD_BYTES) {
throw new Vip98kReferenceTooLargeError(); throw new Vip98kReferenceTooLargeError();
} }
if ((declaredLength > 0 && buffer.byteLength !== declaredLength) || (preferCdn && !buffer.byteLength)) { if (
Number.isFinite(declaredLength) &&
declaredLength > 0 &&
buffer.byteLength !== declaredLength
) {
throw new Error("source image download was incomplete"); throw new Error("source image download was incomplete");
} }
return { buffer, mimeType }; return {
} buffer,
mimeType: response.headers.get("content-type") || "image/png",
async function downloadReferenceImage(mediaUrl: string) { };
let lastError: unknown;
for (let attempt = 1; attempt <= REFERENCE_DOWNLOAD_ATTEMPTS; attempt += 1) {
try {
return await fetchReferenceImage(mediaUrl);
} catch (error) { } catch (error) {
lastError = error; lastError = error;
if (error instanceof Vip98kReferenceTooLargeError) throw error; if (error instanceof Vip98kReferenceTooLargeError) throw error;
@ -740,22 +737,6 @@ async function downloadReferenceImage(mediaUrl: string) {
); );
} }
async function downloadReferenceImageFromCdn(mediaUrl: string, stagingConfig: ReferenceStagingConfig) {
if (stagingConfig.storageConfig.provider !== "qiniu-kodo") return null;
const cdnUrl = process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "";
if (!URL.canParse(cdnUrl) || !getStorageObjectKeyFromPublicUrl(mediaUrl, stagingConfig.sourcePublicUrl)) return null;
const source = new URL(mediaUrl);
if (source.origin !== new URL(cdnUrl).origin || source.search || source.username || source.password) return null;
try {
return await fetchReferenceImage(mediaUrl, true);
} catch (error) {
if (error instanceof Vip98kReferenceTooLargeError) throw error;
logger.warn("reference CDN download failed; trying source storage", { sourceHost: source.hostname });
return null;
}
}
async function downloadReferenceImageFromStorage( async function downloadReferenceImageFromStorage(
mediaUrl: string, mediaUrl: string,
stagingConfig: ReferenceStagingConfig, stagingConfig: ReferenceStagingConfig,
@ -870,10 +851,9 @@ async function stageReferenceImage(
} }
const downloadStartedAt = Date.now(); const downloadStartedAt = Date.now();
const cdn = await downloadReferenceImageFromCdn(mediaUrl, stagingConfig);
const storage = cdn ? null : await downloadReferenceImageFromStorage(mediaUrl, stagingConfig);
const downloaded = const downloaded =
cdn || storage || (await downloadReferenceImage(mediaUrl)); (await downloadReferenceImageFromStorage(mediaUrl, stagingConfig)) ||
(await downloadReferenceImage(mediaUrl));
const downloadMs = Date.now() - downloadStartedAt; const downloadMs = Date.now() - downloadStartedAt;
const normalizeStartedAt = Date.now(); const normalizeStartedAt = Date.now();
const normalized = await normalizeReferenceImage( const normalized = await normalizeReferenceImage(
@ -891,7 +871,6 @@ async function stageReferenceImage(
cacheStagedReference(cacheKey, uploadedUrl, runtimeConfig); cacheStagedReference(cacheKey, uploadedUrl, runtimeConfig);
logger.info("reference image staged", { logger.info("reference image staged", {
sourceHost: getUrlHost(mediaUrl), sourceHost: getUrlHost(mediaUrl),
sourceDelivery: cdn ? "cdn" : storage ? "storage" : "public-url",
targetHost: getUrlHost(uploadedUrl), targetHost: getUrlHost(uploadedUrl),
sourceBytes: downloaded.buffer.byteLength, sourceBytes: downloaded.buffer.byteLength,
stagedBytes: normalized.buffer.byteLength, stagedBytes: normalized.buffer.byteLength,
@ -1044,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;
@ -1127,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();
@ -1192,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,
@ -1240,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,
@ -1292,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})`);
} }
@ -1365,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,
@ -1414,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 {
@ -1549,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);
@ -1559,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) ||
@ -1666,16 +1552,6 @@ export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvoca
} }
if (params.modelId.toLowerCase() === "image-expansion") { if (params.modelId.toLowerCase() === "image-expansion") {
bindVip98kExpansionParams(body, imageParams); bindVip98kExpansionParams(body, imageParams);
body.output_format = "png";
}
if (params.kind === "image-tool" && params.modelId.toLowerCase() === "ocr") {
// Old synced examples include generic image fields and both language aliases.
const metadata = toRecord(body.metadata);
const language = String(metadata?.language || body.language || "").trim();
body.metadata = { language: language || "English,ChinesePRC,Digits" };
body.output_format = "txt";
delete body.language;
delete body.size;
} }
const imageBindingKeys = bindVip98kImages(body, params.imageUrls); const imageBindingKeys = bindVip98kImages(body, params.imageUrls);
if (params.imageUrls.length > 0 && !invocation && imageBindingKeys.length === 0) { if (params.imageUrls.length > 0 && !invocation && imageBindingKeys.length === 0) {
@ -1821,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,

Some files were not shown because too many files have changed in this diff Show More