Compare commits
42 Commits
codex/sku-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 13684505a6 | |||
| 65b235e747 | |||
| d1ee6d53d5 | |||
| a484942fb9 | |||
| 084f538e77 | |||
| 5250aed7b7 | |||
| cbec13b285 | |||
| 5fda42495a | |||
| 73eb276e7f | |||
| 8c7ca174bc | |||
| 8fd0dba59f | |||
| ce94241234 | |||
| 198deca47b | |||
| 8908b0d39a | |||
| 34ba1919c4 | |||
| 2fdc4299e0 | |||
| ac2369b657 | |||
| 6642c39198 | |||
| ac7928fe2d | |||
| b06fdbb38b | |||
| 61e2bbe0e4 | |||
| 70f954382a | |||
| 00cef71be7 | |||
| 525e105876 | |||
| 5371ae2617 | |||
| af8189908d | |||
| 6053271352 | |||
| 6b7b24a86e | |||
| c431a574c8 | |||
| 1daf08a964 | |||
| 348cf4cf5d | |||
| a43a51adfc | |||
| 6ba5d9606d | |||
| 02b9575a47 | |||
| 349735601c | |||
| e837788d7a | |||
|
|
dfab95a654 | ||
| c35f5fc5a8 | |||
|
|
e6c6e19e5a | ||
| bb9d02a053 | |||
|
|
d9b06cf0b3 | ||
| 086c58fcc3 |
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,7 @@ import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-c
|
|||||||
import type { ChatMessage } from "@/lib/types";
|
import type { ChatMessage } from "@/lib/types";
|
||||||
import { convertMessagesWithImages } from "./image-media";
|
import { convertMessagesWithImages } from "./image-media";
|
||||||
import { isProviderBalanceError } from "@/lib/request-error";
|
import { isProviderBalanceError } from "@/lib/request-error";
|
||||||
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
|
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
|
||||||
|
|
||||||
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) || isImageFallbackModelBalanceCoolingDown(item.model)) {
|
if (isImageFallbackModelCoolingDown(item.model.id) || isImageModelBalanceBlocked(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) || isImageFallbackModelBalanceCoolingDown(m.model)) {
|
if (isImageFallbackModelCoolingDown(m.model.id) || isImageModelBalanceBlocked(m.model)) {
|
||||||
const cooldown = imageFailoverModelCooldown.get(m.model.id);
|
const cooldown = imageFailoverModelCooldown.get(m.model.id);
|
||||||
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
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,
|
createUIMessageStream as createAiUIMessageStream,
|
||||||
generateText,
|
generateText,
|
||||||
smoothStream,
|
smoothStream,
|
||||||
stepCountIs,
|
stepCountIs,
|
||||||
@ -336,6 +337,7 @@ 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,
|
||||||
@ -344,6 +346,8 @@ 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,
|
||||||
@ -423,6 +427,8 @@ import {
|
|||||||
} from "./_lib/video-models";
|
} from "./_lib/video-models";
|
||||||
|
|
||||||
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;
|
||||||
@ -440,13 +446,13 @@ 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) {
|
function getPublicImageGenerationErrorText(error: unknown, requestId: string) {
|
||||||
const message = error instanceof Error ? error.message : String(error || "");
|
const message = error instanceof Error ? error.message : String(error || "");
|
||||||
const publicMessage = getUserFacingImageGenerationErrorMessage(error);
|
const publicMessage = getUserFacingImageGenerationErrorMessage(error);
|
||||||
if (!message || (publicMessage === message && INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message))) {
|
if (!message || (publicMessage === message && INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message))) {
|
||||||
return "图片生成失败,请稍后重试";
|
return `图片生成失败,请稍后重试。若连续失败,请联系管理员。\n\n请求编号:${requestId}`;
|
||||||
}
|
}
|
||||||
return publicMessage;
|
return `${publicMessage}\n\n请求编号:${requestId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -8769,6 +8775,19 @@ 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(
|
||||||
@ -9614,9 +9633,10 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
// 如果故障转移也失败,返回错误消息
|
// 如果故障转移也失败,返回错误消息
|
||||||
const errorMessageId = generateUUID();
|
const errorMessageId = generateUUID();
|
||||||
|
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||||
writeAssistantTextChunk(
|
writeAssistantTextChunk(
|
||||||
writer,
|
writer,
|
||||||
"图片生成失败,请稍后重试",
|
errorText,
|
||||||
"gemini:error",
|
"gemini:error",
|
||||||
errorMessageId,
|
errorMessageId,
|
||||||
);
|
);
|
||||||
@ -9636,7 +9656,7 @@ export async function POST(request: Request) {
|
|||||||
id: errorMessageId,
|
id: errorMessageId,
|
||||||
chatId: id,
|
chatId: id,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [{ type: "text", text: "图片生成失败,请稍后重试" }],
|
parts: [{ type: "text", text: errorText }],
|
||||||
attachments: [],
|
attachments: [],
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
videoStorageStatus: null,
|
videoStorageStatus: null,
|
||||||
@ -10002,7 +10022,10 @@ 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})`);
|
||||||
@ -10025,6 +10048,7 @@ 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);
|
||||||
@ -10073,9 +10097,10 @@ 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 || "")));
|
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")) && !balanceBlockedProviders.has(candidate.provider.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})`);
|
||||||
@ -10157,6 +10182,7 @@ 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);
|
||||||
@ -10310,7 +10336,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
// 返回错误消息
|
// 返回错误消息
|
||||||
const errorMessageId = generateUUID();
|
const errorMessageId = generateUUID();
|
||||||
const errorText = getPublicImageGenerationErrorText(error);
|
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||||
writeAssistantTextChunk(
|
writeAssistantTextChunk(
|
||||||
writer,
|
writer,
|
||||||
errorText,
|
errorText,
|
||||||
@ -10750,9 +10776,10 @@ 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);
|
||||||
const errorMessageId = generateUUID();
|
const errorMessageId = generateUUID();
|
||||||
|
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||||
writeAssistantTextChunk(
|
writeAssistantTextChunk(
|
||||||
writer,
|
writer,
|
||||||
"图片生成失败,请稍后重试",
|
errorText,
|
||||||
"volcano:error",
|
"volcano:error",
|
||||||
errorMessageId,
|
errorMessageId,
|
||||||
);
|
);
|
||||||
@ -10772,7 +10799,7 @@ export async function POST(request: Request) {
|
|||||||
id: errorMessageId,
|
id: errorMessageId,
|
||||||
chatId: id,
|
chatId: id,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [{ type: "text", text: "图片生成失败,请稍后重试" }],
|
parts: [{ type: "text", text: errorText }],
|
||||||
attachments: [], // 必须提供 attachments 字段
|
attachments: [], // 必须提供 attachments 字段
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
videoStorageStatus: null,
|
videoStorageStatus: null,
|
||||||
@ -11680,7 +11707,7 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error);
|
chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error);
|
||||||
|
|
||||||
const errorMessageId = generateUUID();
|
const errorMessageId = generateUUID();
|
||||||
const errorText = getPublicImageGenerationErrorText(error);
|
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||||
|
|
||||||
dataStream.write({
|
dataStream.write({
|
||||||
type: "data-appendMessage",
|
type: "data-appendMessage",
|
||||||
|
|||||||
@ -8,6 +8,8 @@ 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;
|
||||||
@ -74,18 +76,11 @@ export default function FeaturedPage() {
|
|||||||
|
|
||||||
const handleDownload = async (url: string, prompt: string) => {
|
const handleDownload = async (url: string, prompt: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
await downloadOriginalImage({ url, name: "featured-image" });
|
||||||
const blob = await response.blob();
|
toast.success("原图已开始下载");
|
||||||
const blobUrl = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = blobUrl;
|
|
||||||
link.download = `featured-${Date.now()}.png`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(blobUrl);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Download failed:", error);
|
console.error("Download failed:", error);
|
||||||
|
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"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";
|
||||||
@ -12,6 +13,7 @@ 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;
|
||||||
@ -58,7 +60,7 @@ function buildGalleryDownloadFilename(url: string, alt: string, contentType: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${safeBase}-${Date.now()}.${getImageDownloadExtension(contentType) || extension}`;
|
return `${safeBase}.${getImageDownloadExtension(contentType) || extension}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GalleryPage() {
|
export default function GalleryPage() {
|
||||||
@ -118,25 +120,11 @@ 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);
|
||||||
|
|
||||||
const response = await fetch(
|
await downloadOriginalImage({ url: source, name: buildGalleryDownloadFilename(originalUrl, alt, "") });
|
||||||
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(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
|
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -316,7 +304,12 @@ 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) => (
|
{images.map((img) => isRetiredStorageUrl(img.url) ? (
|
||||||
|
<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"
|
||||||
@ -325,7 +318,7 @@ export default function GalleryPage() {
|
|||||||
>
|
>
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<Image
|
<Image
|
||||||
src={img.thumbnailUrl || img.url}
|
src={isRetiredStorageUrl(img.thumbnailUrl) ? img.url : img.thumbnailUrl || img.url}
|
||||||
alt={img.alt}
|
alt={img.alt}
|
||||||
width={200}
|
width={200}
|
||||||
height={270}
|
height={270}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
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";
|
||||||
|
|
||||||
@ -15,14 +17,30 @@ 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") || undefined;
|
const modelId = searchParams.get("modelId");
|
||||||
const limit = Number(searchParams.get("limit") || 80);
|
const providerId = searchParams.get("providerId");
|
||||||
const events = await listAiModelHealthEvents({
|
const rawLimit = Number(searchParams.get("limit") || 20);
|
||||||
modelId,
|
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(200, Math.floor(rawLimit))) : 20;
|
||||||
limit: Number.isFinite(limit) ? limit : 80,
|
const rawPage = Number(searchParams.get("page") || 1);
|
||||||
});
|
const page = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1;
|
||||||
|
const filters = [];
|
||||||
return Response.json({ events });
|
if (modelId) filters.push(eq(aiModelHealthEvent.modelId, modelId));
|
||||||
|
if (providerId) filters.push(eq(aiModelHealthEvent.providerId, providerId));
|
||||||
|
if (searchParams.get("eventType")) filters.push(eq(aiModelHealthEvent.eventType, searchParams.get("eventType")!));
|
||||||
|
const start = searchParams.get("startDate");
|
||||||
|
const end = searchParams.get("endDate");
|
||||||
|
if (start && /^\d{4}-\d{2}-\d{2}$/.test(start)) filters.push(sql`${aiModelHealthEvent.createdAt}>=${start}::date`);
|
||||||
|
if (end && /^\d{4}-\d{2}-\d{2}$/.test(end)) filters.push(sql`${aiModelHealthEvent.createdAt}<${end}::date+interval '1 day'`);
|
||||||
|
const events = await db.select({
|
||||||
|
id: aiModelHealthEvent.id, modelId: aiModelHealthEvent.modelId, modelDisplayName: aiModelHealthEvent.modelDisplayName,
|
||||||
|
modelRuntimeId: aiModelHealthEvent.modelRuntimeId, providerName: aiModelHealthEvent.providerName,
|
||||||
|
providerId: aiModelHealthEvent.providerId, eventType: aiModelHealthEvent.eventType,
|
||||||
|
errorMessage: aiModelHealthEvent.errorMessage, elapsedMs: aiModelHealthEvent.elapsedMs,
|
||||||
|
consecutiveFailures: aiModelHealthEvent.consecutiveFailures, autoDisabled: aiModelHealthEvent.autoDisabled,
|
||||||
|
counted: aiModelHealthEvent.counted, createdAt: aiModelHealthEvent.createdAt,
|
||||||
|
}).from(aiModelHealthEvent).where(and(...filters)).orderBy(desc(aiModelHealthEvent.createdAt), desc(aiModelHealthEvent.id))
|
||||||
|
.limit(limit + 1).offset((page - 1) * limit);
|
||||||
|
return Response.json({ events: events.slice(0, limit), hasMore: events.length > limit, page });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ChatSDKError) {
|
if (error instanceof ChatSDKError) {
|
||||||
return error.toResponse();
|
return error.toResponse();
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
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,
|
||||||
@ -154,8 +155,14 @@ 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 getAiModelById({ id });
|
const model = await db.query.aiModel.findFirst({ where: eq(aiModel.id, 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();
|
||||||
}
|
}
|
||||||
@ -163,12 +170,19 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (providerId) {
|
if (providerId) {
|
||||||
const models = await getAiModelsByProviderId({ providerId });
|
const models = await db.select().from(aiModel).where(eq(aiModel.providerId, providerId));
|
||||||
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = await getAllAiModels();
|
const models = await db.select().from(aiModel);
|
||||||
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
const normalized = models.map(normalizeModel);
|
||||||
|
const output = searchParams.get("view") === "summary" ? normalized.map(model => {
|
||||||
|
const { capabilities, defaultParams, imageGenConfig, description, ...summary } = model;
|
||||||
|
const fields = ["kind", "mediaType", "modelType", "outputType", "supportedModes"];
|
||||||
|
return { ...summary, healthLastError: summary.healthLastError?.slice(0, 1200),
|
||||||
|
capabilities: Object.fromEntries(fields.filter(key => capabilities?.[key] !== undefined).map(key => [key, capabilities[key]])) };
|
||||||
|
}) : normalized;
|
||||||
|
return Response.json({ models: sortModelsForDisplay(output) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ChatSDKError) {
|
if (error instanceof ChatSDKError) {
|
||||||
return error.toResponse();
|
return error.toResponse();
|
||||||
|
|||||||
@ -1,237 +1,31 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { chat, generatedImage, message, user } from "@/lib/db/schema";
|
import { generatedImage, user } from "@/lib/db/schema";
|
||||||
import { eq, desc, sql, and, gte, lte, like, or, isNotNull } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
function extractVideoUrlFromParts(parts: unknown): string | null {
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
const text = Array.isArray(parts)
|
import { mediaRecordsQuery, parseMediaQuery } from "@/lib/admin/media-records";
|
||||||
? parts
|
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
||||||
.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 auth();
|
const session = await requireAdmin();
|
||||||
|
const operator = await db.query.user.findFirst({ where: eq(user.id, session.user.id) });
|
||||||
if (!session?.user?.id) {
|
if (operator?.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
const query = parseMediaQuery(request.nextUrl.searchParams);
|
||||||
|
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 currentUser = await db.query.user.findFirst({
|
const total = Number(result.total);
|
||||||
where: eq(user.id, session.user.id),
|
return NextResponse.json({ images, pagination: { page: query.page, pageSize: query.pageSize, total,
|
||||||
});
|
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) {
|
||||||
console.error("[Admin Images API] Error:", error);
|
if (error instanceof ChatSDKError) return error.toResponse();
|
||||||
return NextResponse.json({ error: "Failed to fetch images" }, { status: 500 });
|
console.error("[Admin Images API] GET failed", error);
|
||||||
|
return NextResponse.json({ error: "记录加载失败,请重试" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
app/api/admin/media-tasks/route.ts
Normal file
31
app/api/admin/media-tasks/route.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
|
import { parseMediaQuery } from "@/lib/admin/media-records";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const q = parseMediaQuery(request.nextUrl.searchParams);
|
||||||
|
const status = request.nextUrl.searchParams.get("status") || "all";
|
||||||
|
const filters = [sql`true`];
|
||||||
|
if (q.search)
|
||||||
|
filters.push(sql`(u.name ilike ${`%${q.search}%`} or u.email ilike ${`%${q.search}%`})`);
|
||||||
|
if (["pending", "running", "failed", "succeeded"].includes(status))
|
||||||
|
filters.push(sql`t.status=${status}`);
|
||||||
|
if (q.startDate) filters.push(sql`t."createdAt">=${q.startDate}::date - interval '8 hours'`);
|
||||||
|
if (q.endDate) filters.push(sql`t."createdAt"<${q.endDate}::date+interval '16 hours'`);
|
||||||
|
const [result] = await db.execute(sql`with filtered as not materialized (
|
||||||
|
select t.id,u.name as "userName",t.status,t."createdAt"+interval '8 hours' as "createdAt",t.error,coalesce(t.request->>'modelId',t.request->>'model') as model,
|
||||||
|
extract(epoch from (t."completedAt"-t."startedAt"))*1000 as "elapsedMs"
|
||||||
|
from "CanvasImageTask" t left join "User" u on u.id=t."userId" where ${sql.join(filters, sql` and `)}
|
||||||
|
) select (select count(*)::int from filtered) as total,
|
||||||
|
coalesce((select jsonb_agg(p) from (select * from filtered order by "createdAt" desc,id desc limit ${q.pageSize} offset ${(q.page - 1) * q.pageSize}) p),'[]'::jsonb) as tasks`);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) return error.toResponse();
|
||||||
|
return NextResponse.json({ error: "任务记录加载失败,请重试" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -106,6 +106,22 @@ export async function POST(request: Request) {
|
|||||||
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
|
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
|
||||||
: catalogPresets;
|
: catalogPresets;
|
||||||
|
|
||||||
|
if (body?.dryRun === true) {
|
||||||
|
const catalogIds = new Set(catalogPresets.map(preset => preset.modelId));
|
||||||
|
const fields = ["displayName", "description", "supportsVision", "supportsImageGen", "isHidden", "sortOrder", "capabilities", "defaultParams", "imageGenConfig"] as const;
|
||||||
|
const added = presets.filter(preset => !existingModelIds.has(preset.modelId)).map(preset => ({ modelId: preset.modelId, displayName: preset.displayName }));
|
||||||
|
const updated = presets.flatMap(preset => {
|
||||||
|
const existing = existingModels.find(model => model.modelId === preset.modelId);
|
||||||
|
if (!existing) return [];
|
||||||
|
const changedFields: string[] = fields.filter(field => preset[field] !== undefined && JSON.stringify(existing[field]) !== JSON.stringify(preset[field]));
|
||||||
|
if (existing.avatarUrl) changedFields.push("avatarUrl");
|
||||||
|
if (requestedModelIds !== null && !existing.isEnabled) changedFields.push("isEnabled");
|
||||||
|
return changedFields.length ? [{ modelId: preset.modelId, displayName: existing.displayName, changedFields }] : [];
|
||||||
|
});
|
||||||
|
const disabled = requestedModelIds === null ? existingModels.filter(model => !catalogIds.has(model.modelId) && (model.isEnabled || !model.isHidden)).map(model => ({ modelId: model.modelId, displayName: model.displayName })) : [];
|
||||||
|
return NextResponse.json({ preview: { added, updated, disabled }, total: catalogPresets.length });
|
||||||
|
}
|
||||||
|
|
||||||
const addedModels: AiModel[] = [];
|
const addedModels: AiModel[] = [];
|
||||||
const updatedModels: AiModel[] = [];
|
const updatedModels: AiModel[] = [];
|
||||||
const disabledModels: AiModel[] = [];
|
const disabledModels: AiModel[] = [];
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { and, 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";
|
||||||
@ -56,14 +57,20 @@ 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 [project] = await db
|
const [insertedProject] = await db
|
||||||
.insert(canvasProject)
|
.insert(canvasProject)
|
||||||
.values({
|
.values({
|
||||||
|
id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@ -78,8 +85,22 @@ export async function POST(request: NextRequest) {
|
|||||||
agentProjectState: projectState,
|
agentProjectState: projectState,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
})
|
||||||
|
.onConflictDoNothing({ target: canvasProject.id })
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
const project = insertedProject || (id ? (await db
|
||||||
|
.select()
|
||||||
|
.from(canvasProject)
|
||||||
|
.where(and(
|
||||||
|
eq(canvasProject.id, id),
|
||||||
|
eq(canvasProject.userId, session.user.id),
|
||||||
|
eq(canvasProject.workspaceType, "agent")
|
||||||
|
))
|
||||||
|
.limit(1))[0] : undefined);
|
||||||
|
if (!project) {
|
||||||
|
return NextResponse.json({ success: false, error: "项目编号不可用,请重试" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
|
return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AgentProjects API] 创建项目失败:", error);
|
console.error("[AgentProjects API] 创建项目失败:", error);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { isRetiredStorageUrl, 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";
|
||||||
|
|
||||||
@ -12,7 +13,6 @@ 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,7 +25,6 @@ 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",
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -165,6 +164,9 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ 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;
|
||||||
@ -39,7 +40,8 @@ export async function GET(
|
|||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return NextResponse.json({ success: true, task });
|
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
|
||||||
|
return NextResponse.json({ success: true, task: { ...task, failure, error: failure ? formatRequestFailure(failure, task.id) : task.error } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
@ -51,9 +53,11 @@ 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,
|
||||||
@ -67,8 +71,9 @@ 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: error.message, code: error.code },
|
{ success: false, error: formatRequestFailure(failure, requestId), code: error.code, failure, requestId },
|
||||||
{ status: error.status }
|
{ status: error.status }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -82,9 +87,9 @@ export async function POST(
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : "执行画布能力失败";
|
const failure = getRequestFailureDetails(error, "执行画布能力失败");
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: message },
|
{ success: false, error: formatRequestFailure(failure, requestId), failure, requestId },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -207,7 +207,11 @@ export async function PUT(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
return NextResponse.json({ success: true, data: saved });
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: saved,
|
||||||
|
previousCanvasRevision: existingProject?.updatedAt.toISOString() ?? null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("更新/创建画布失败:", error);
|
console.error("更新/创建画布失败:", error);
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
||||||
import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
|
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";
|
||||||
@ -12,6 +16,22 @@ 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
|
||||||
@ -20,22 +40,25 @@ 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;
|
||||||
}))];
|
}))];
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
let lastError: unknown;
|
||||||
|
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: 12_000, retryCount: 1, signal,
|
includeSequencePrefix: false, timeoutMs: 60_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 < 2) await new Promise(resolve => setTimeout(resolve, 1_000));
|
if (attempt < 1 && getDownloadFailure(lastError).retryable) await new Promise(resolve => setTimeout(resolve, 1_000));
|
||||||
|
else break;
|
||||||
}
|
}
|
||||||
throw new Error("Original image sources unavailable");
|
throw new Error(String(lastError || "Original image sources unavailable"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRequestOrigin(request: NextRequest): string {
|
function getRequestOrigin(request: NextRequest): string {
|
||||||
@ -75,27 +98,43 @@ 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 NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
return failed(getDownloadFailure("未登录"), requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 urls = resolveDownloadUrls(body, requestOrigin);
|
const candidates = 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 NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
return failed(getDownloadFailure("图片地址无效"), requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch(() => ({ task: null, direct: null }));
|
const { task, direct, canPrepareDirect } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch((error) => {
|
||||||
|
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);
|
||||||
after(() => prepareImageArchiveDownload(task.id));
|
scheduleImageDownloadPreparation(task.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cookie = request.headers.get("cookie") || "";
|
const cookie = request.headers.get("cookie") || "";
|
||||||
@ -104,13 +143,10 @@ 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 NextResponse.json({ delivery: "preparing", retryAfter: 3,
|
return preparing(result.requestId);
|
||||||
message: "正在准备原图,完成后将自动下载" }, {
|
|
||||||
status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (result.status === "failed") {
|
if (result.status === "failed") {
|
||||||
return NextResponse.json({ success: false, error: result.error }, { status: 502 });
|
return failed(result.failure, result.requestId);
|
||||||
}
|
}
|
||||||
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,
|
||||||
@ -131,17 +167,14 @@ export async function POST(request: NextRequest) {
|
|||||||
includeSequencePrefix: false,
|
includeSequencePrefix: false,
|
||||||
});
|
});
|
||||||
if (result.ok) break;
|
if (result.ok) break;
|
||||||
failures.push(`${url}: ${result.error}`);
|
failures.push(result.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result?.ok) {
|
if (!result?.ok) {
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
console.warn("[CanvasDownloadImage] All candidates failed:", failures);
|
logger.warn("all_sources_failed", { requestId, failures });
|
||||||
}
|
}
|
||||||
return NextResponse.json(
|
return failed(getDownloadFailure(result?.error), requestId);
|
||||||
{ success: false, error: result?.error || "图片源文件暂时不可访问" },
|
|
||||||
{ status: 502 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NextResponse(result.bytes, {
|
return new NextResponse(result.bytes, {
|
||||||
@ -154,7 +187,7 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[CanvasDownloadImage] Failed:", error);
|
logger.error("download_failed", error, { requestId });
|
||||||
return NextResponse.json({ success: false, error: "图片下载失败" }, { status: 500 });
|
return failed(getDownloadFailure(error), requestId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ 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;
|
||||||
|
|
||||||
@ -23,13 +24,15 @@ 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: task.error || null,
|
error: failure ? formatRequestFailure(failure, task.id) : null,
|
||||||
|
failure,
|
||||||
result: task.result || null,
|
result: task.result || null,
|
||||||
createdAt: task.createdAt,
|
createdAt: task.createdAt,
|
||||||
startedAt: task.startedAt,
|
startedAt: task.startedAt,
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
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 { eq, desc, sql } from "drizzle-orm";
|
import { and, 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;
|
||||||
@ -57,13 +58,17 @@ 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: aiModel.displayName,
|
modelName: sql<string | null>`(select ${aiModel.displayName} from ${aiModel}
|
||||||
|
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))
|
||||||
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
|
.where(and(
|
||||||
.where(eq(generatedImage.isFeatured, true))
|
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);
|
||||||
@ -74,7 +79,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return {
|
return {
|
||||||
images: featuredImages.map((img) => ({
|
images: featuredImages.map((img) => ({
|
||||||
id: img.id,
|
id: img.id,
|
||||||
thumbnailUrl: img.thumbnailUrl,
|
thumbnailUrl: isRetiredStorageUrl(img.thumbnailUrl) ? img.originalUrl : img.thumbnailUrl,
|
||||||
originalUrl: img.originalUrl,
|
originalUrl: img.originalUrl,
|
||||||
imageSize: img.imageSize,
|
imageSize: img.imageSize,
|
||||||
prompt: img.prompt,
|
prompt: img.prompt,
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import { isRetiredStorageUrl, 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";
|
||||||
|
|
||||||
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;
|
||||||
@ -9,6 +13,7 @@ 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;
|
||||||
|
|
||||||
@ -265,7 +270,19 @@ 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 });
|
||||||
}
|
}
|
||||||
@ -301,6 +318,18 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@ -339,20 +368,33 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchSignal = AbortSignal.any([
|
||||||
|
request.signal,
|
||||||
|
AbortSignal.timeout(original ? 60_000 : 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 upstreamResponse: Response;
|
let upstreamResponse: Response;
|
||||||
try {
|
try {
|
||||||
upstreamResponse = await fetch(target.toString(), {
|
upstreamResponse = await fetch(target.toString(), {
|
||||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
signal: fetchSignal,
|
||||||
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) {
|
||||||
console.error("[ImageProxy] Upstream fetch failed:", error);
|
return upstreamFailure(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 }
|
||||||
@ -361,10 +403,16 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const 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());
|
let originalBuffer: Buffer;
|
||||||
|
try {
|
||||||
|
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||||
|
} catch (error) {
|
||||||
|
return upstreamFailure(error);
|
||||||
|
}
|
||||||
if (original) {
|
if (original) {
|
||||||
return buildImageResponse(originalBuffer, {
|
return buildImageResponse(originalBuffer, {
|
||||||
contentType,
|
contentType,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
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";
|
||||||
@ -152,7 +153,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 } from "@/types/agent";
|
import type { AgentCategoryTreeConfig, AgentWorkflowUiConfig } 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";
|
||||||
@ -552,6 +553,7 @@ function normalizeAgentCategoryTreeConfig(value: unknown): AgentCategoryTreeConf
|
|||||||
type PublicSiteSettingsScope =
|
type PublicSiteSettingsScope =
|
||||||
| "agent-brand-model"
|
| "agent-brand-model"
|
||||||
| "agent-canvas"
|
| "agent-canvas"
|
||||||
|
| "agent-entry"
|
||||||
| "agent-presets"
|
| "agent-presets"
|
||||||
| "auth"
|
| "auth"
|
||||||
| "branding"
|
| "branding"
|
||||||
@ -563,6 +565,7 @@ type PublicSiteSettingsScope =
|
|||||||
const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
|
const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
|
||||||
"agent-brand-model",
|
"agent-brand-model",
|
||||||
"agent-canvas",
|
"agent-canvas",
|
||||||
|
"agent-entry",
|
||||||
"agent-presets",
|
"agent-presets",
|
||||||
"auth",
|
"auth",
|
||||||
"branding",
|
"branding",
|
||||||
@ -606,22 +609,60 @@ 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 } = {}
|
options: { fallback?: boolean; startedAt?: number; etag?: string } = {}
|
||||||
) {
|
) {
|
||||||
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(
|
||||||
@ -663,12 +704,12 @@ async function readScopedSiteSettings(keys: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
||||||
return {
|
return resolveSiteBranding({
|
||||||
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
||||||
siteLogo: settings?.siteLogo || null,
|
siteLogo: String(settings?.siteLogo || ""),
|
||||||
siteLogoDark: settings?.siteLogoDark || null,
|
siteLogoDark: String(settings?.siteLogoDark || ""),
|
||||||
favicon: settings?.favicon || null,
|
favicon: String(settings?.favicon || ""),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getScopedPublicSiteSettings(
|
async function getScopedPublicSiteSettings(
|
||||||
@ -676,6 +717,7 @@ async function getScopedPublicSiteSettings(
|
|||||||
options: {
|
options: {
|
||||||
brandName?: string;
|
brandName?: string;
|
||||||
cacheKey?: string;
|
cacheKey?: string;
|
||||||
|
etag?: string;
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
if (scope === "auth") {
|
if (scope === "auth") {
|
||||||
@ -737,23 +779,8 @@ async function getScopedPublicSiteSettings(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === "agent-canvas") {
|
if (scope === "agent-canvas" || scope === "agent-entry") {
|
||||||
const { settings, valueByKey } = await readScopedSiteSettings([
|
const { settings, valueByKey } = await readScopedSiteSettings(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,
|
|
||||||
]);
|
|
||||||
const canvasFeatureAvailability = await readCanvasFeatureAvailability(
|
const canvasFeatureAvailability = await readCanvasFeatureAvailability(
|
||||||
valueByKey
|
valueByKey
|
||||||
);
|
);
|
||||||
@ -761,6 +788,8 @@ 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,
|
||||||
@ -796,12 +825,18 @@ async function getScopedPublicSiteSettings(
|
|||||||
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
|
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
|
||||||
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
|
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
|
||||||
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
|
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
|
||||||
valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY)
|
scope === "agent-entry"
|
||||||
|
? {
|
||||||
|
steps: [],
|
||||||
|
pageFeatureSettings: rawUiConfig?.pageFeatureSettings,
|
||||||
|
businessModeSettings: rawUiConfig?.businessModeSettings,
|
||||||
|
}
|
||||||
|
: rawUiConfig
|
||||||
),
|
),
|
||||||
agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig(
|
agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig(
|
||||||
valueByKey.get(AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY)
|
valueByKey.get(AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY)
|
||||||
),
|
),
|
||||||
});
|
}, "MISS", options.cacheKey || scope, { etag: options.etag });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === "branding") {
|
if (scope === "branding") {
|
||||||
@ -1055,13 +1090,23 @@ export async function GET(request: NextRequest) {
|
|||||||
const cacheKey = brandName.trim()
|
const cacheKey = brandName.trim()
|
||||||
? `${scopedScope}:${brandName.trim().toLowerCase()}`
|
? `${scopedScope}:${brandName.trim().toLowerCase()}`
|
||||||
: scopedScope;
|
: scopedScope;
|
||||||
|
const etag = scopedScope === "agent-canvas" || scopedScope === "agent-entry"
|
||||||
|
? await getAgentCanvasSettingsEtag(scopedScope)
|
||||||
|
: undefined;
|
||||||
|
if (etag && request.headers.get("If-None-Match") === etag) {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 304,
|
||||||
|
headers: { ETag: etag, "Cache-Control": "private, no-cache" },
|
||||||
|
});
|
||||||
|
}
|
||||||
const cached = getScopedSiteSettingsCache(cacheKey);
|
const cached = getScopedSiteSettingsCache(cacheKey);
|
||||||
if (cached && cached.expiresAt > Date.now()) {
|
if (cached && cached.expiresAt > Date.now() && (!etag || cached.etag === etag)) {
|
||||||
return createScopedSiteSettingsResponse(
|
return createScopedSiteSettingsResponse(
|
||||||
scopedScope,
|
scopedScope,
|
||||||
cached.data,
|
cached.data,
|
||||||
"HIT",
|
"HIT",
|
||||||
cacheKey
|
cacheKey,
|
||||||
|
{ etag }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (cached && scopedScope !== "home") {
|
if (cached && scopedScope !== "home") {
|
||||||
@ -1072,6 +1117,7 @@ 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")
|
||||||
@ -1541,7 +1587,7 @@ export async function GET(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
...baseSettings,
|
...resolveSiteBranding(baseSettings),
|
||||||
lovartPromoText:
|
lovartPromoText:
|
||||||
baseSettings.lovartPromoText ||
|
baseSettings.lovartPromoText ||
|
||||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
||||||
|
import { CanvasRemoteOperationContext } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@ -227,6 +228,7 @@ import {
|
|||||||
type WorkflowSceneRecord,
|
type WorkflowSceneRecord,
|
||||||
} from "@/lib/video-workflow/runtime";
|
} from "@/lib/video-workflow/runtime";
|
||||||
import { agentProjectService } from "@/services/agent-projects";
|
import { agentProjectService } from "@/services/agent-projects";
|
||||||
|
import { fetchCanvasSiteSettings } from "@/lib/client/canvas-site-settings";
|
||||||
import {
|
import {
|
||||||
canvasService,
|
canvasService,
|
||||||
imageGenerationService,
|
imageGenerationService,
|
||||||
@ -264,30 +266,8 @@ import type {
|
|||||||
} from "@/types/canvas";
|
} from "@/types/canvas";
|
||||||
|
|
||||||
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
|
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
|
||||||
const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000;
|
|
||||||
const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
|
const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
|
||||||
|
|
||||||
const fetchCanvasSiteSettings = async (url: string) => {
|
|
||||||
const separator = url.includes("?") ? "&" : "?";
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
controller.abort();
|
|
||||||
}, CANVAS_SITE_SETTINGS_TIMEOUT_MS);
|
|
||||||
|
|
||||||
const response = await fetch(`${url}${separator}t=${Date.now()}`, {
|
|
||||||
cache: "no-store",
|
|
||||||
signal: controller.signal,
|
|
||||||
}).finally(() => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch canvas site settings");
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
function CanvasLazyPanelLoader({
|
function CanvasLazyPanelLoader({
|
||||||
label,
|
label,
|
||||||
className,
|
className,
|
||||||
@ -2184,7 +2164,9 @@ export default function CanvasEditorLegacyPage({
|
|||||||
[agentProjectId, id]
|
[agentProjectId, id]
|
||||||
);
|
);
|
||||||
const canvasSiteSettingsKey = requestedAgentMode
|
const canvasSiteSettingsKey = requestedAgentMode
|
||||||
? "/api/site-settings?scope=agent-canvas"
|
? hasActiveAgentProject
|
||||||
|
? "/api/site-settings?scope=agent-canvas"
|
||||||
|
: "/api/site-settings?scope=agent-entry"
|
||||||
: "/api/site-settings?scope=canvas";
|
: "/api/site-settings?scope=canvas";
|
||||||
const [hasResolvedCanvasSiteSettings, setHasResolvedCanvasSiteSettings] =
|
const [hasResolvedCanvasSiteSettings, setHasResolvedCanvasSiteSettings] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@ -2220,6 +2202,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
? 0
|
? 0
|
||||||
: CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS,
|
: CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS,
|
||||||
errorRetryCount: 1,
|
errorRetryCount: 1,
|
||||||
|
dedupingInterval: 30_000,
|
||||||
revalidateOnFocus: true,
|
revalidateOnFocus: true,
|
||||||
revalidateOnReconnect: true,
|
revalidateOnReconnect: true,
|
||||||
});
|
});
|
||||||
@ -2336,11 +2319,18 @@ export default function CanvasEditorLegacyPage({
|
|||||||
});
|
});
|
||||||
const sanitizedWorkflowSelections = useMemo(
|
const sanitizedWorkflowSelections = useMemo(
|
||||||
() =>
|
() =>
|
||||||
sanitizeAgentWorkflowSelections({
|
requestedAgentMode && !siteSettingsData?.agentWorkflowUiConfig
|
||||||
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
|
? workflowSelections
|
||||||
selections: workflowSelections,
|
: sanitizeAgentWorkflowSelections({
|
||||||
}),
|
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
|
||||||
[resolvedWorkflowUiConfig, workflowSelections]
|
selections: workflowSelections,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
requestedAgentMode,
|
||||||
|
siteSettingsData?.agentWorkflowUiConfig,
|
||||||
|
resolvedWorkflowUiConfig,
|
||||||
|
workflowSelections,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
const deferredSanitizedWorkflowSelections = useDeferredValue(
|
const deferredSanitizedWorkflowSelections = useDeferredValue(
|
||||||
sanitizedWorkflowSelections
|
sanitizedWorkflowSelections
|
||||||
@ -3946,20 +3936,18 @@ export default function CanvasEditorLegacyPage({
|
|||||||
loadCanvas(normalized.canvasData);
|
loadCanvas(normalized.canvasData);
|
||||||
setProjectTitle(nextTitle);
|
setProjectTitle(nextTitle);
|
||||||
|
|
||||||
const [saveResult, updateResult] = await Promise.all([
|
const saveResult = await canvasService.saveCanvasState(
|
||||||
canvasService.saveCanvasState(
|
|
||||||
id,
|
id,
|
||||||
normalized.canvasData,
|
normalized.canvasData,
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
),
|
);
|
||||||
canvasService.updateProject(
|
const updateResult = await canvasService.updateProject(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
title: nextTitle,
|
title: nextTitle,
|
||||||
},
|
},
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
),
|
);
|
||||||
]);
|
|
||||||
|
|
||||||
if (!saveResult.success) {
|
if (!saveResult.success) {
|
||||||
throw new Error(saveResult.error || "导入后保存失败");
|
throw new Error(saveResult.error || "导入后保存失败");
|
||||||
@ -4700,7 +4688,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
title: nextTitle,
|
title: nextTitle,
|
||||||
thumbnail: agentProjectThumbnail,
|
thumbnail: agentProjectThumbnail,
|
||||||
projectState: buildPersistedAgentProjectState(),
|
projectState: buildPersistedAgentProjectState(),
|
||||||
});
|
}, { creationKey: `save-as:${id}:${agentProjectId}:${nextTitle}` });
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
if (isUnauthorizedApiError(result.error)) {
|
if (isUnauthorizedApiError(result.error)) {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
@ -4725,6 +4713,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
agentProjectThumbnail,
|
agentProjectThumbnail,
|
||||||
agentProjectTitle,
|
agentProjectTitle,
|
||||||
buildPersistedAgentProjectState,
|
buildPersistedAgentProjectState,
|
||||||
|
id,
|
||||||
openLoginDialog,
|
openLoginDialog,
|
||||||
persistAgentProject,
|
persistAgentProject,
|
||||||
updateCanvasSearchParams,
|
updateCanvasSearchParams,
|
||||||
@ -4789,7 +4778,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const result = await agentProjectService.createProject({
|
const result = await agentProjectService.createProject({
|
||||||
title: buildDefaultAgentProjectTitle(),
|
title: buildDefaultAgentProjectTitle(),
|
||||||
projectState: createEmptyAgentGenerationProjectPersistedState(),
|
projectState: createEmptyAgentGenerationProjectPersistedState(),
|
||||||
});
|
}, { creationKey: `blank:${id}:${agentProjectId}` });
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
if (isUnauthorizedApiError(result.error)) {
|
if (isUnauthorizedApiError(result.error)) {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
@ -4812,6 +4801,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}, [
|
}, [
|
||||||
agentProjectId,
|
agentProjectId,
|
||||||
isAgentProjectSaving,
|
isAgentProjectSaving,
|
||||||
|
id,
|
||||||
openLoginDialog,
|
openLoginDialog,
|
||||||
persistAgentProject,
|
persistAgentProject,
|
||||||
updateCanvasSearchParams,
|
updateCanvasSearchParams,
|
||||||
@ -5080,9 +5070,10 @@ export default function CanvasEditorLegacyPage({
|
|||||||
if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue();
|
if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue();
|
||||||
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const canvasPersistenceScope = useRef(id);
|
const canvasPersistenceScope = useRef(id);
|
||||||
|
const canvasRemoteOperationActive = useRef(false);
|
||||||
canvasPersistenceScope.current = id;
|
canvasPersistenceScope.current = id;
|
||||||
const persistCanvasState = useCallback(
|
const persistCanvasStateNow = useCallback(
|
||||||
(reason: "autosave" | "delete" | "beforeunload") => canvasSaveQueue.current!.enqueue(async () => {
|
async (reason: "autosave" | "delete" | "beforeunload") => {
|
||||||
if (canvasPersistenceScope.current !== id) return false;
|
if (canvasPersistenceScope.current !== id) return false;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@ -5122,10 +5113,37 @@ export default function CanvasEditorLegacyPage({
|
|||||||
} finally {
|
} finally {
|
||||||
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
[activeCanvasWorkspaceType, buildPersistedCanvasData, id]
|
[activeCanvasWorkspaceType, buildPersistedCanvasData, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const persistCanvasState = useCallback(
|
||||||
|
(reason: "autosave" | "delete" | "beforeunload") =>
|
||||||
|
canvasSaveQueue.current!.enqueue(() => persistCanvasStateNow(reason)),
|
||||||
|
[persistCanvasStateNow]
|
||||||
|
);
|
||||||
|
|
||||||
|
const runCanvasRemoteOperation = useCallback(
|
||||||
|
(operation: () => Promise<void>) => {
|
||||||
|
if (canvasAutosaveTimer.current) {
|
||||||
|
clearTimeout(canvasAutosaveTimer.current);
|
||||||
|
canvasAutosaveTimer.current = null;
|
||||||
|
}
|
||||||
|
return canvasSaveQueue.current!.enqueueOperation(
|
||||||
|
() => persistCanvasStateNow("autosave"),
|
||||||
|
async () => {
|
||||||
|
canvasRemoteOperationActive.current = true;
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
} finally {
|
||||||
|
canvasRemoteOperationActive.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[persistCanvasStateNow]
|
||||||
|
);
|
||||||
|
|
||||||
const persistCanvasBeforeAgent = useCallback(() => {
|
const persistCanvasBeforeAgent = useCallback(() => {
|
||||||
// Flush the pending local edit once instead of racing its scheduled autosave.
|
// Flush the pending local edit once instead of racing its scheduled autosave.
|
||||||
if (canvasAutosaveTimer.current) {
|
if (canvasAutosaveTimer.current) {
|
||||||
@ -5168,6 +5186,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const refreshIfExternallyUpdated = async () => {
|
const refreshIfExternallyUpdated = async () => {
|
||||||
if (
|
if (
|
||||||
cancelled ||
|
cancelled ||
|
||||||
|
canvasRemoteOperationActive.current ||
|
||||||
document.visibilityState !== "visible" ||
|
document.visibilityState !== "visible" ||
|
||||||
isSaving ||
|
isSaving ||
|
||||||
externalCanvasConflictRef.current ||
|
externalCanvasConflictRef.current ||
|
||||||
@ -5197,7 +5216,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await canvasService.getProject(id);
|
const result = await canvasService.getProject(id);
|
||||||
if (cancelled || !result.success || !result.data) return;
|
if (cancelled || canvasRemoteOperationActive.current || !result.success || !result.data) return;
|
||||||
useCanvasStore.getState().markAgentOperationRefresh();
|
useCanvasStore.getState().markAgentOperationRefresh();
|
||||||
hydrateCanvasProject(result.data);
|
hydrateCanvasProject(result.data);
|
||||||
lastLocalCanvasMutationAtRef.current = Date.now();
|
lastLocalCanvasMutationAtRef.current = Date.now();
|
||||||
@ -5320,11 +5339,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (autoThumbnailSyncedRef.current !== autoTitleKey) {
|
if (autoThumbnailSyncedRef.current !== autoTitleKey) {
|
||||||
await canvasService.updateProject(
|
await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
|
||||||
id,
|
id,
|
||||||
{ thumbnail: imageSrc },
|
{ thumbnail: imageSrc },
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
);
|
));
|
||||||
autoThumbnailSyncedRef.current = autoTitleKey;
|
autoThumbnailSyncedRef.current = autoTitleKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5340,11 +5359,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.title) {
|
if (data.title) {
|
||||||
setProjectTitle(data.title);
|
setProjectTitle(data.title);
|
||||||
await canvasService.updateProject(
|
await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
|
||||||
id,
|
id,
|
||||||
{ title: data.title },
|
{ title: data.title },
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
);
|
));
|
||||||
toast.success(`${workspaceLabel}已自动命名为:${data.title}`);
|
toast.success(`${workspaceLabel}已自动命名为:${data.title}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -5395,11 +5414,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await canvasService.updateProject(
|
const result = await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
|
||||||
id,
|
id,
|
||||||
{ title: editTitle.trim() },
|
{ title: editTitle.trim() },
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
);
|
));
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setProjectTitle(editTitle.trim());
|
setProjectTitle(editTitle.trim());
|
||||||
} else {
|
} else {
|
||||||
@ -7032,7 +7051,9 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
const result = existingProject
|
const result = existingProject
|
||||||
? await agentProjectService.updateProject(existingProject.id, payload)
|
? await agentProjectService.updateProject(existingProject.id, payload)
|
||||||
: await agentProjectService.createProject(payload);
|
: await agentProjectService.createProject(payload, {
|
||||||
|
creationKey: `workflow:${id}:${nodeId}`,
|
||||||
|
});
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
if (isUnauthorizedApiError(result.error)) {
|
if (isUnauthorizedApiError(result.error)) {
|
||||||
@ -10281,6 +10302,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<CanvasRemoteOperationContext.Provider value={runCanvasRemoteOperation}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div
|
<div
|
||||||
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
|
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
|
||||||
@ -12079,5 +12101,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
{projectLoadingBadge}
|
{projectLoadingBadge}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</CanvasRemoteOperationContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
30
components/admin/admin-surface.tsx
Normal file
30
components/admin/admin-surface.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
603
components/admin/media-records-panel.tsx
Normal file
603
components/admin/media-records-panel.tsx
Normal file
@ -0,0 +1,603 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
components/admin/media-thumbnail.tsx
Normal file
150
components/admin/media-thumbnail.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
components/admin/model-health-panel.tsx
Normal file
191
components/admin/model-health-panel.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
326
components/admin/model-management-list.tsx
Normal file
326
components/admin/model-management-list.tsx
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import type { AiModel } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
is3DModelDescriptor,
|
||||||
|
isVideoModelDescriptor,
|
||||||
|
isImageGenerationModelDescriptor,
|
||||||
|
} from "@/lib/ai/model-classification";
|
||||||
|
|
||||||
|
type Model = AiModel & { isAgentModel?: boolean; isMultimodal?: boolean };
|
||||||
|
type Provider = { id: string; name: string; type: string; isEnabled: boolean };
|
||||||
|
const selectClass = "h-10 rounded-md border bg-background px-3 text-sm";
|
||||||
|
export function ModelManagementList({
|
||||||
|
models,
|
||||||
|
providers,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onRestore,
|
||||||
|
onRefresh,
|
||||||
|
onHealth,
|
||||||
|
}: {
|
||||||
|
models: Model[];
|
||||||
|
providers: Provider[];
|
||||||
|
error?: Error;
|
||||||
|
loading: boolean;
|
||||||
|
onEdit: (model: Model) => Promise<void>;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onRestore: (model: Model) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onHealth?: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [provider, setProvider] = useState("");
|
||||||
|
const [status, setStatus] = useState("enabled");
|
||||||
|
const [kind, setKind] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [opening, setOpening] = useState<string | null>(null);
|
||||||
|
const rows = useMemo(
|
||||||
|
() =>
|
||||||
|
models
|
||||||
|
.map((model) => {
|
||||||
|
const upstream = providers.find((p) => p.id === model.providerId);
|
||||||
|
const descriptor = { ...model, providerType: upstream?.type };
|
||||||
|
const type = is3DModelDescriptor(descriptor)
|
||||||
|
? "3d"
|
||||||
|
: isVideoModelDescriptor(descriptor)
|
||||||
|
? "video"
|
||||||
|
: isImageGenerationModelDescriptor(descriptor)
|
||||||
|
? "image"
|
||||||
|
: "chat";
|
||||||
|
return { model, upstream, type };
|
||||||
|
})
|
||||||
|
.filter(({ model, upstream, type }) => {
|
||||||
|
const text =
|
||||||
|
`${model.displayName} ${model.modelId} ${upstream?.name || ""}`.toLowerCase();
|
||||||
|
return (
|
||||||
|
text.includes(search.trim().toLowerCase()) &&
|
||||||
|
(!provider || model.providerId === provider) &&
|
||||||
|
(!kind || kind === type) &&
|
||||||
|
(status === "all" ||
|
||||||
|
(status === "enabled" && model.isEnabled) ||
|
||||||
|
(status === "disabled" && !model.isEnabled) ||
|
||||||
|
(status === "unhealthy" && model.healthStatus !== "healthy") ||
|
||||||
|
(status === "visible" && !model.isHidden))
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[models, providers, search, provider, status, kind],
|
||||||
|
);
|
||||||
|
const pages = Math.max(1, Math.ceil(rows.length / 25));
|
||||||
|
const currentPage = Math.min(page, pages);
|
||||||
|
const change = (fn: () => void) => {
|
||||||
|
fn();
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<div className="relative min-w-56 flex-1">
|
||||||
|
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="h-10 pl-9"
|
||||||
|
aria-label="搜索模型"
|
||||||
|
placeholder="搜索模型名称、ID 或上游"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => change(() => setSearch(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
aria-label="模型上游"
|
||||||
|
className={selectClass}
|
||||||
|
value={provider}
|
||||||
|
onChange={(e) => change(() => setProvider(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">全部上游</option>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label="模型类型"
|
||||||
|
className={selectClass}
|
||||||
|
value={kind}
|
||||||
|
onChange={(e) => change(() => setKind(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">全部类型</option>
|
||||||
|
<option value="image">生图</option>
|
||||||
|
<option value="video">视频</option>
|
||||||
|
<option value="chat">对话</option>
|
||||||
|
<option value="3d">3D / CAD</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label="模型状态"
|
||||||
|
className={selectClass}
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => change(() => setStatus(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="enabled">已启用</option>
|
||||||
|
<option value="all">全部模型</option>
|
||||||
|
<option value="disabled">已停用</option>
|
||||||
|
<option value="unhealthy">运行异常</option>
|
||||||
|
<option value="visible">前台可见</option>
|
||||||
|
</select>
|
||||||
|
<Button variant="outline" onClick={onRefresh} aria-label="刷新模型">
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{loading
|
||||||
|
? "正在加载模型…"
|
||||||
|
: `筛选结果 ${rows.length} 个 · 已启用 ${models.filter((m) => m.isEnabled).length} 个 · 全部 ${models.length} 个`}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
第 {currentPage} / {pages} 页
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => setPage(currentPage - 1)}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage === pages}
|
||||||
|
onClick={() => setPage(currentPage + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<div role="alert" className="rounded-xl border p-6 text-sm">
|
||||||
|
模型加载失败。
|
||||||
|
<Button variant="outline" onClick={onRefresh}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div role="status" className="h-48 rounded-xl bg-muted motion-safe:animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full min-w-[850px] text-left text-sm">
|
||||||
|
<thead className="bg-muted/50 text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
{["模型", "上游 / 类型", "启用", "前台展示", "运行健康", "操作"].map((x) => (
|
||||||
|
<th key={x} className="px-4 py-3 font-medium">
|
||||||
|
{x}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{rows
|
||||||
|
.slice((currentPage - 1) * 25, currentPage * 25)
|
||||||
|
.map(({ model, upstream, type }) => (
|
||||||
|
<tr key={model.id} className="hover:bg-muted/30">
|
||||||
|
<td className="max-w-64 px-4 py-4">
|
||||||
|
<div className="font-medium">{model.displayName}</div>
|
||||||
|
<div
|
||||||
|
className="mt-1 truncate text-xs text-muted-foreground"
|
||||||
|
title={model.modelId}
|
||||||
|
>
|
||||||
|
{model.modelId}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||||
|
{model.isDefault && <span>默认模型</span>}
|
||||||
|
{model.isDefaultTextEditModel && <span>文字编辑默认</span>}
|
||||||
|
{model.isAgentModel && <span>Agent</span>}
|
||||||
|
{model.isMultimodal && <span>多模态</span>}
|
||||||
|
{model.isSubModel && <span>子模型</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
{upstream?.name || "未知上游"}
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{{ image: "生图", video: "视频", chat: "对话", "3d": "3D / CAD" }[type]}
|
||||||
|
{upstream && !upstream.isEnabled ? " · 上游已停用" : ""}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-4">
|
||||||
|
<span
|
||||||
|
className={`rounded-md px-2 py-1 text-xs ${model.isEnabled ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200" : "bg-muted text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
{model.isEnabled ? "已启用" : "已停用"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
{model.isHidden || model.isSubModel ? "隐藏" : "可见"}
|
||||||
|
</td>
|
||||||
|
<td className="max-w-72 px-4 py-4">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
model.healthStatus !== "healthy"
|
||||||
|
? "text-amber-700 dark:text-amber-300"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{model.healthStatus === "auto_disabled"
|
||||||
|
? "自动停用"
|
||||||
|
: model.healthStatus === "degraded"
|
||||||
|
? "观察中"
|
||||||
|
: "正常"}
|
||||||
|
</span>
|
||||||
|
{model.healthConsecutiveFailures > 0 && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
连续失败 {model.healthConsecutiveFailures} 次
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{model.healthLastError && (
|
||||||
|
<details className="mt-2 text-xs">
|
||||||
|
<summary className="cursor-pointer text-muted-foreground">
|
||||||
|
查看最近原因
|
||||||
|
</summary>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap break-words">
|
||||||
|
{model.healthLastError}
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-2 block text-xs underline underline-offset-4"
|
||||||
|
onClick={() => onHealth?.(model.id)}
|
||||||
|
>
|
||||||
|
故障记录
|
||||||
|
</button>
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{model.excludeFromHealthBan && "不自动封禁 "}
|
||||||
|
{model.excludeFromFailover && "不参与迁移"}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={opening !== null}
|
||||||
|
onClick={async () => {
|
||||||
|
setOpening(model.id);
|
||||||
|
try {
|
||||||
|
await onEdit(model);
|
||||||
|
} finally {
|
||||||
|
setOpening(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opening === model.id ? "加载中…" : "编辑"}
|
||||||
|
</Button>
|
||||||
|
{model.healthStatus === "auto_disabled" && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onRestore(model)}>
|
||||||
|
恢复
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => onDelete(model.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<p className="p-12 text-center text-sm text-muted-foreground">没有符合条件的模型。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
每页 25 个 · 第 {currentPage} / {pages} 页
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
aria-label="上一页模型"
|
||||||
|
variant="outline"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => setPage(currentPage - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
aria-label="下一页模型"
|
||||||
|
variant="outline"
|
||||||
|
disabled={currentPage === pages}
|
||||||
|
onClick={() => setPage(currentPage + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
"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";
|
||||||
@ -575,31 +576,11 @@ export function AgentGenerationWorkspace({
|
|||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = buildDownloadFilename(raw);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(raw);
|
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
||||||
if (!response.ok) {
|
toast.success("原图已开始下载");
|
||||||
throw new Error(`download failed: ${response.status}`);
|
} catch (error) {
|
||||||
}
|
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||||
const blob = await response.blob();
|
|
||||||
const blobUrl = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = blobUrl;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(blobUrl);
|
|
||||||
} catch {
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = raw;
|
|
||||||
link.target = "_blank";
|
|
||||||
link.rel = "noopener noreferrer";
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
toast.message("已打开原图,可右键另存为");
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
"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 { 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 {
|
||||||
@ -4169,6 +4171,7 @@ 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,
|
||||||
@ -4189,7 +4192,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
void refreshHistory();
|
void refreshHistory();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFinish() { void refreshHistory(); },
|
onFinish({ messages: finishedMessages }) {
|
||||||
|
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({
|
||||||
@ -5482,6 +5489,17 @@ 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.
|
||||||
@ -6776,31 +6794,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = buildDownloadFilename(raw);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(raw);
|
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
||||||
if (!response.ok) {
|
toast.success("原图已开始下载");
|
||||||
throw new Error(`download failed: ${response.status}`);
|
} catch (error) {
|
||||||
}
|
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||||
const blob = await response.blob();
|
|
||||||
const blobUrl = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = blobUrl;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(blobUrl);
|
|
||||||
} catch {
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = raw;
|
|
||||||
link.target = "_blank";
|
|
||||||
link.rel = "noopener noreferrer";
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
toast.message("已打开原图,可右键另存为");
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {
|
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"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";
|
||||||
@ -725,30 +726,11 @@ export function AgentGenerationWorkspaceNew({
|
|||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const filename = buildDownloadFilename(raw);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(raw);
|
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
||||||
if (!response.ok) {
|
toast.success("原图已开始下载");
|
||||||
throw new Error(`download failed: ${response.status}`);
|
} catch (error) {
|
||||||
}
|
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("已打开原图,可右键另存为");
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -66,6 +66,7 @@ import {
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
||||||
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
fetchUploadWithRetry,
|
fetchUploadWithRetry,
|
||||||
@ -373,7 +374,9 @@ function MarkdownResponseFallback({
|
|||||||
}: Pick<ResponseProps, "children" | "className">) {
|
}: Pick<ResponseProps, "children" | "className">) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className)}>
|
<div className={cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className)}>
|
||||||
{children}
|
{typeof children === "string"
|
||||||
|
? getGatewayErrorPageMessage(children) || children
|
||||||
|
: children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
11
components/canvas/CanvasRemoteOperationContext.tsx
Normal file
11
components/canvas/CanvasRemoteOperationContext.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export const CanvasRemoteOperationContext = createContext<
|
||||||
|
((operation: () => Promise<void>) => Promise<void>) | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
export function useCanvasRemoteOperation() {
|
||||||
|
return useContext(CanvasRemoteOperationContext);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@ -231,6 +232,8 @@ 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";
|
||||||
@ -2077,7 +2080,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 loadCanvas = useCanvasStore((state) => state.loadCanvas);
|
const runCanvasRemoteOperation = useCanvasRemoteOperation();
|
||||||
const addPendingChatAttachment = useCanvasStore(
|
const addPendingChatAttachment = useCanvasStore(
|
||||||
(state) => state.addPendingChatAttachment
|
(state) => state.addPendingChatAttachment
|
||||||
);
|
);
|
||||||
@ -6707,7 +6710,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) {
|
if (!projectId || !runCanvasRemoteOperation) {
|
||||||
toast.error("画布还在加载,请稍后再试");
|
toast.error("画布还在加载,请稍后再试");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -6726,21 +6729,7 @@ export function ImageNode({
|
|||||||
projectId,
|
projectId,
|
||||||
source: "toolbar",
|
source: "toolbar",
|
||||||
});
|
});
|
||||||
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
|
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
|
||||||
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 =
|
||||||
@ -8439,7 +8428,11 @@ 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">
|
||||||
{!shouldRequestDisplayImage ? (
|
{isRetiredStorageUrl(data.src) ? (
|
||||||
|
<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
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-do
|
|||||||
import { 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 { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
|
||||||
|
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@ -1028,7 +1029,10 @@ export const Response = ({
|
|||||||
|
|
||||||
const rawText =
|
const rawText =
|
||||||
typeof props.children === "string"
|
typeof props.children === "string"
|
||||||
? normalizeMarkdownContent(stableStreamingMarkdown(props.children, isMarkdownStreaming))
|
? normalizeMarkdownContent(stableStreamingMarkdown(
|
||||||
|
getGatewayErrorPageMessage(props.children) || props.children,
|
||||||
|
isMarkdownStreaming
|
||||||
|
))
|
||||||
: null;
|
: null;
|
||||||
const shouldShowPdfDownload =
|
const shouldShowPdfDownload =
|
||||||
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
|
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
|
||||||
|
|||||||
68
components/home/lovart/home-image.tsx
Normal file
68
components/home/lovart/home-image.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
|
||||||
|
|
||||||
|
type HomeImageProps = {
|
||||||
|
sources: Array<string | null | undefined>;
|
||||||
|
alt: string;
|
||||||
|
className?: string;
|
||||||
|
imageClassName?: string;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
loading?: "lazy" | "eager";
|
||||||
|
onUnavailable?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function imageCandidates(source: string) {
|
||||||
|
// These two domains are documented aliases for the same gongxiangpan store.
|
||||||
|
// Do not rewrite other buckets, providers, signed URLs or user-supplied hosts.
|
||||||
|
try {
|
||||||
|
const url = new URL(source);
|
||||||
|
if (url.origin === "https://s3.yydd.ai" &&
|
||||||
|
url.pathname.startsWith("/gongxiangpan/") && !url.search && !url.hash) {
|
||||||
|
return [`https://s3.jiansun.vip${url.pathname}`, source];
|
||||||
|
}
|
||||||
|
} catch { /* Relative site assets need no host rewrite. */ }
|
||||||
|
return [source];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep failed image elements out of view, trying each distinct source once. */
|
||||||
|
export function HomeImage(props: HomeImageProps) {
|
||||||
|
const sources = [...new Set(props.sources
|
||||||
|
.filter((url): url is string => Boolean(url?.trim()) && !isRetiredStorageUrl(url))
|
||||||
|
.flatMap(imageCandidates))];
|
||||||
|
return <HomeImageAttempt key={JSON.stringify(sources)} {...props} sources={sources} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeImageAttempt({
|
||||||
|
sources, alt, className, imageClassName, fallback, loading = "lazy", onUnavailable,
|
||||||
|
}: Omit<HomeImageProps, "sources"> & { sources: string[] }) {
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const source = sources[index];
|
||||||
|
return (
|
||||||
|
<span className={`relative block overflow-hidden ${className || ""}`}>
|
||||||
|
{!loaded && (
|
||||||
|
<span className={fallback ? "block" : "block aspect-[4/5] rounded-[12px] bg-[#e8e8e8] dark:bg-[#1b1c20]"}>
|
||||||
|
{fallback}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{source ? (
|
||||||
|
<img
|
||||||
|
key={source}
|
||||||
|
src={source}
|
||||||
|
alt={alt}
|
||||||
|
loading={loading}
|
||||||
|
decoding="async"
|
||||||
|
className={`${imageClassName || ""} ${loaded ? "" : "absolute inset-0 opacity-0"}`}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
onError={() => {
|
||||||
|
setLoaded(false);
|
||||||
|
setIndex(index + 1);
|
||||||
|
if (index + 1 >= sources.length) onUnavailable?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
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 type { Ref } from "react";
|
import { useState, type Ref } from "react";
|
||||||
|
import { HomeImage } from "./home-image";
|
||||||
import type { FeaturedImage } from "./types";
|
import type { FeaturedImage } from "./types";
|
||||||
|
|
||||||
type LovartFeaturedGalleryProps = {
|
type LovartFeaturedGalleryProps = {
|
||||||
@ -30,6 +31,10 @@ 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,
|
||||||
@ -42,6 +47,13 @@ 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}
|
||||||
@ -88,44 +100,48 @@ export function LovartFeaturedGallery({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{featuredItems.length === 0 ? (
|
{unavailableCount > 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">
|
||||||
{featuredItems.map((item, index) => (
|
{visibleItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={`${imageIdentity(item)}:${retryVersion}`}
|
||||||
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"
|
||||||
>
|
>
|
||||||
<img
|
{unavailable.has(imageIdentity(item)) ? (
|
||||||
src={getFeaturedPreviewUrl(item)}
|
<div className="flex aspect-[4/5] items-center justify-center rounded-[12px] bg-muted p-4 text-sm text-muted-foreground">图片加载失败</div>
|
||||||
|
) : <HomeImage
|
||||||
|
sources={[getFeaturedPreviewUrl(item), item.thumbnailUrl, item.originalUrl]}
|
||||||
alt={item.prompt || "灵感图"}
|
alt={item.prompt || "灵感图"}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
onUnavailable={() => setUnavailable((previous) => new Set(previous).add(imageIdentity(item)))}
|
||||||
onError={(event) => {
|
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]"
|
||||||
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">
|
||||||
@ -138,11 +154,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 + index * 37).toLocaleString()}
|
{(1200 + featuredItems.indexOf(item) * 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 + (index % 9) * 11).toLocaleString()}
|
{(20 + (featuredItems.indexOf(item) % 9) * 11).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<Gem className="h-[11px] w-[11px]" strokeWidth={2} />
|
<Gem className="h-[11px] w-[11px]" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ 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;
|
||||||
@ -183,17 +184,18 @@ export function LovartSidebar({
|
|||||||
onClick={onHome}
|
onClick={onHome}
|
||||||
title={siteName}
|
title={siteName}
|
||||||
>
|
>
|
||||||
{siteLogo ? (
|
<HomeImage
|
||||||
<img
|
sources={[siteLogo]}
|
||||||
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">
|
||||||
|
{siteName.slice(0, 1).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<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()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">{siteName}</TooltipContent>
|
<TooltipContent side="right">{siteName}</TooltipContent>
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { isRetiredStorageUrl, 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, getImageDownloadExtension } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
|
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||||
|
|
||||||
const ImageAnnotator = dynamic(
|
const ImageAnnotator = dynamic(
|
||||||
() => import("./image-annotator").then((module) => module.ImageAnnotator),
|
() => import("./image-annotator").then((module) => module.ImageAnnotator),
|
||||||
@ -22,25 +24,6 @@ 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,
|
||||||
@ -237,24 +220,12 @@ export function ImagePreview({
|
|||||||
toast.error("下载失败,请重试");
|
toast.error("下载失败,请重试");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const filenameBase = `generated-image-${Date.now()}`;
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
await downloadOriginalImage({ url: safeOriginalUrl });
|
||||||
buildImageProxyUrl(safeOriginalUrl, { delivery: "original" })
|
toast.success("原图已开始下载");
|
||||||
);
|
|
||||||
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(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
|
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -306,6 +277,10 @@ 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 (
|
||||||
<>
|
<>
|
||||||
{/* 缩略图(显示压缩后的缩略图)*/}
|
{/* 缩略图(显示压缩后的缩略图)*/}
|
||||||
@ -332,9 +307,18 @@ 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 (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)}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ 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";
|
||||||
@ -521,7 +522,7 @@ const PurePreviewMessage = ({
|
|||||||
const displayModelName = extractedModelName || inferredFallbackModelName;
|
const displayModelName = extractedModelName || inferredFallbackModelName;
|
||||||
const filteredText =
|
const filteredText =
|
||||||
message.role === "assistant"
|
message.role === "assistant"
|
||||||
? appendImageDoneSummary(contentWithoutBadge)
|
? getGatewayErrorPageMessage(contentWithoutBadge) || appendImageDoneSummary(contentWithoutBadge)
|
||||||
: contentWithoutBadge;
|
: contentWithoutBadge;
|
||||||
const useAssistantTypewriter =
|
const useAssistantTypewriter =
|
||||||
message.role === "assistant" &&
|
message.role === "assistant" &&
|
||||||
|
|||||||
@ -9,6 +9,7 @@ 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,
|
||||||
@ -73,18 +74,11 @@ export const PreviewAttachment = ({
|
|||||||
// 下载图片函数
|
// 下载图片函数
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
await downloadOriginalImage({ url, name: name ?? "image" });
|
||||||
const blob = await response.blob();
|
toast.success("原图已开始下载");
|
||||||
const downloadUrl = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = downloadUrl;
|
|
||||||
link.download = name ?? `image-${Date.now()}.jpg`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(downloadUrl);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("下载失败:", error);
|
console.error("下载失败:", error);
|
||||||
|
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@ -2073,18 +2074,9 @@ export function SceneFusionView({
|
|||||||
|
|
||||||
const downloadImage = async (url: string, filename: string) => {
|
const downloadImage = async (url: string, filename: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
await downloadOriginalImage({ url, name: filename });
|
||||||
const blob = await response.blob();
|
} catch (error) {
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
33
docs/retired-storage.md
Normal file
33
docs/retired-storage.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# 旧存储停用与七牛上传核查
|
||||||
|
|
||||||
|
## 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 的差额。本次未删除或改动七牛对象。
|
||||||
83
lib/admin/media-records.ts
Normal file
83
lib/admin/media-records.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
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`;
|
||||||
|
}
|
||||||
31
lib/admin/preview-queue.ts
Normal file
31
lib/admin/preview-queue.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
7
lib/admin/record-time.ts
Normal file
7
lib/admin/record-time.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
15
lib/agents/scene-run-recovery.ts
Normal file
15
lib/agents/scene-run-recovery.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
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 {
|
||||||
@ -418,7 +419,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) {
|
if (!model || !model.isEnabled || (model.supportsImageGen && isImageModelBalanceBlocked(model))) {
|
||||||
logger.warn("model unavailable", { modelId });
|
logger.warn("model unavailable", { modelId });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
* - qwen-image-edit-plus 系列:支持 1-6 张输出,自定义分辨率,提示词优化
|
* - qwen-image-edit-plus 系列:支持 1-6 张输出,自定义分辨率,提示词优化
|
||||||
* - qwen-image-edit:仅支持 1 张输出
|
* - qwen-image-edit:仅支持 1 张输出
|
||||||
*
|
*
|
||||||
* API 文档:https://help.aliyun.com/zh/model-studio/developer-reference/qwen-vl-plus
|
* API 文档:https://help.aliyun.com/en/model-studio/qwen-image-generation-and-editing-api-reference
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
@ -46,6 +46,8 @@ 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: {
|
||||||
@ -122,6 +124,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 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);
|
||||||
@ -168,7 +175,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?:\/\//i.test(imageUrl)) return null;
|
if (!/^(?:https?:\/\/|data:image\/)/i.test(imageUrl)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
||||||
@ -186,7 +193,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?:\/\//i.test(imageUrl)) return null;
|
if (!/^(?:https?:\/\/|data:image\/)/i.test(imageUrl)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
||||||
@ -302,6 +309,14 @@ 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;
|
||||||
}
|
}
|
||||||
@ -565,8 +580,10 @@ async function sendAlibabaImageAsyncRequest(params: {
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
|
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
|
||||||
|
const reason = task.output?.message || task.message || `阿里图片任务${taskStatus || "失败"}`;
|
||||||
|
const code = task.output?.code || task.code;
|
||||||
throw new AlibabaImageTerminalTaskError(
|
throw new AlibabaImageTerminalTaskError(
|
||||||
task.message || `阿里图片任务${taskStatus || "失败"}(task_id: ${taskId})`,
|
`${reason}${code ? `(${code})` : ""}(task_id: ${taskId})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lastPollError = null;
|
lastPollError = null;
|
||||||
@ -769,16 +786,15 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
|
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
|
||||||
const preparedImageUrls: string[] = [];
|
const preparedImageUrls = await Promise.all(
|
||||||
for (const imageUrl of selectedImageUrls) {
|
selectedImageUrls.map((imageUrl) =>
|
||||||
preparedImageUrls.push(
|
prepareAlibabaInputImageUrl(
|
||||||
await prepareAlibabaInputImageUrl(
|
|
||||||
imageUrl,
|
imageUrl,
|
||||||
activeModelId,
|
activeModelId,
|
||||||
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
|
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
|
|
||||||
// qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。
|
// qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。
|
||||||
if (
|
if (
|
||||||
|
|||||||
@ -48,6 +48,7 @@ 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,
|
||||||
@ -1023,6 +1024,77 @@ 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;
|
||||||
@ -1035,6 +1107,8 @@ 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();
|
||||||
@ -1098,7 +1172,9 @@ async function submitTask(params: {
|
|||||||
signal: AbortSignal.timeout(params.submissionTimeoutMs),
|
signal: AbortSignal.timeout(params.submissionTimeoutMs),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (attempt >= maxAttempts) {
|
const recovered = await recoverImageSubmission({ ...params, baseURL, path, idempotencyKey });
|
||||||
|
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,
|
||||||
@ -1144,6 +1220,14 @@ 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,
|
||||||
@ -1188,9 +1272,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].includes(response.status)
|
|| [502, 504, 524].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(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${errorMessage} (HTTP ${response.status})`);
|
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${getUserFacingImageGenerationErrorMessage({ message: errorMessage, status: response.status })} (HTTP ${response.status})`);
|
||||||
throw new Error(`${errorMessage} (HTTP ${response.status})`);
|
throw new Error(`${errorMessage} (HTTP ${response.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1261,6 +1345,7 @@ 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,
|
||||||
@ -1309,6 +1394,12 @@ 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 {
|
||||||
@ -1438,7 +1529,7 @@ function normalizeDuration(value: unknown) {
|
|||||||
return Math.round(numeric);
|
return Math.round(numeric);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) {
|
export 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);
|
||||||
@ -1448,6 +1539,9 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
|
|||||||
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) ||
|
||||||
@ -1697,7 +1791,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.slice(0, 1).map(archiveOutputImage));
|
const archived = await Promise.all(outputUrls.map(archiveOutputImage));
|
||||||
logger.info("result archive completed", {
|
logger.info("result archive completed", {
|
||||||
modelId: params.modelId,
|
modelId: params.modelId,
|
||||||
outputCount: archived.length,
|
outputCount: archived.length,
|
||||||
|
|||||||
74
lib/ai/runtime/image-balance.ts
Normal file
74
lib/ai/runtime/image-balance.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { isProviderBalanceError } from "@/lib/request-error";
|
||||||
|
|
||||||
|
type BalanceState = {
|
||||||
|
healthLastEventType?: unknown;
|
||||||
|
healthLastError?: unknown;
|
||||||
|
healthAutoDisabledReason?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isImageModelBalanceBlocked(model: BalanceState | null | undefined) {
|
||||||
|
return Boolean(
|
||||||
|
model &&
|
||||||
|
((model.healthLastEventType === "quota" && isProviderBalanceError(model.healthLastError)) ||
|
||||||
|
isProviderBalanceError(model.healthAutoDisabledReason)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfiguredImageBalanceFallback<
|
||||||
|
T extends {
|
||||||
|
model: BalanceState & {
|
||||||
|
id: string;
|
||||||
|
modelId: string;
|
||||||
|
isEnabled: boolean;
|
||||||
|
supportsImageGen: boolean;
|
||||||
|
imageGenConfig?: unknown;
|
||||||
|
};
|
||||||
|
provider: { isEnabled?: boolean };
|
||||||
|
},
|
||||||
|
>(
|
||||||
|
requested: BalanceState & { fallbackModelId1?: string | null; fallbackModelId2?: string | null },
|
||||||
|
entries: T[],
|
||||||
|
input: {
|
||||||
|
imageSize?: unknown;
|
||||||
|
aspectRatio?: unknown;
|
||||||
|
referenceImageCount?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!isImageModelBalanceBlocked(requested)) return undefined;
|
||||||
|
for (const ref of [requested.fallbackModelId1, requested.fallbackModelId2]) {
|
||||||
|
if (!ref) continue;
|
||||||
|
const entry = entries.find(({ model }) => model.id === ref || model.modelId === ref);
|
||||||
|
if (
|
||||||
|
!entry ||
|
||||||
|
!entry.model.isEnabled ||
|
||||||
|
entry.provider.isEnabled === false ||
|
||||||
|
!entry.model.supportsImageGen ||
|
||||||
|
isImageModelBalanceBlocked(entry.model)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const config = entry.model.imageGenConfig as Record<string, unknown> | null;
|
||||||
|
const size = String(input.imageSize || "").toUpperCase();
|
||||||
|
const ratio = String(input.aspectRatio || "");
|
||||||
|
if (
|
||||||
|
size &&
|
||||||
|
Array.isArray(config?.supportedImageSizes) &&
|
||||||
|
!config.supportedImageSizes.includes(size)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
if (
|
||||||
|
ratio &&
|
||||||
|
ratio !== "auto" &&
|
||||||
|
Array.isArray(config?.supportedAspectRatios) &&
|
||||||
|
!config.supportedAspectRatios.includes(ratio)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
if (
|
||||||
|
input.referenceImageCount &&
|
||||||
|
typeof config?.maxReferenceImages === "number" &&
|
||||||
|
input.referenceImageCount > config.maxReferenceImages
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import { isProviderBalanceError } from "@/lib/request-error";
|
|
||||||
|
|
||||||
const IMAGE_FAILOVER_BALANCE_COOLDOWN_MS = 2 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
export function isImageFallbackModelBalanceCoolingDown(
|
|
||||||
model: {
|
|
||||||
healthLastEventType?: unknown;
|
|
||||||
healthLastError?: unknown;
|
|
||||||
healthLastFailureAt?: unknown;
|
|
||||||
} | null | undefined,
|
|
||||||
now = Date.now(),
|
|
||||||
) {
|
|
||||||
if (model?.healthLastEventType !== "quota" || !isProviderBalanceError(model.healthLastError)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const failedAt = new Date(String(model.healthLastFailureAt || "")).getTime();
|
|
||||||
return Number.isFinite(failedAt) && now - failedAt < IMAGE_FAILOVER_BALANCE_COOLDOWN_MS && now >= failedAt;
|
|
||||||
}
|
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { ProviderTaskUncertainError } from "@/lib/video-canvas/video-task-lifecycle";
|
||||||
import { isProviderAuthenticationFailure, isCredentialFailureBan } from "./authentication-failover";
|
import { isProviderAuthenticationFailure, isCredentialFailureBan } from "./authentication-failover";
|
||||||
|
|
||||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||||
@ -12,7 +13,7 @@ import {
|
|||||||
isMediaInputFailure,
|
isMediaInputFailure,
|
||||||
isNonProviderRequestStatus,
|
isNonProviderRequestStatus,
|
||||||
} from "@/lib/ai/runtime/request-failure-classification";
|
} from "@/lib/ai/runtime/request-failure-classification";
|
||||||
import { isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
import { isGatewayResponseTimeout, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
||||||
|
|
||||||
export type AiModelHealthEventType =
|
export type AiModelHealthEventType =
|
||||||
| "success"
|
| "success"
|
||||||
@ -343,6 +344,7 @@ export function classifyAiModelHealthFailure(error: unknown): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldFailoverAiModelError(error: unknown) {
|
export function shouldFailoverAiModelError(error: unknown) {
|
||||||
|
if (error instanceof ProviderTaskUncertainError || isGatewayResponseTimeout(error)) return false;
|
||||||
const classification = classifyAiModelHealthFailure(error);
|
const classification = classifyAiModelHealthFailure(error);
|
||||||
const normalized = getErrorMessage(error).toLowerCase();
|
const normalized = getErrorMessage(error).toLowerCase();
|
||||||
const imageProviderTaskFailed =
|
const imageProviderTaskFailed =
|
||||||
@ -499,6 +501,7 @@ export async function reviveAiModelsFromCooldown(params?: { modelId?: string })
|
|||||||
|
|
||||||
const dueModels = candidates.filter((model) => {
|
const dueModels = candidates.filter((model) => {
|
||||||
if (isCredentialFailureBan(model.healthAutoDisabledReason)) return false;
|
if (isCredentialFailureBan(model.healthAutoDisabledReason)) return false;
|
||||||
|
if (isProviderBalanceError(model.healthAutoDisabledReason)) return false;
|
||||||
const cooldownStartedAt =
|
const cooldownStartedAt =
|
||||||
model.healthAutoDisabledAt ||
|
model.healthAutoDisabledAt ||
|
||||||
model.healthLastFailureAt ||
|
model.healthLastFailureAt ||
|
||||||
@ -568,6 +571,7 @@ export async function recordAiModelHealthSuccess(params: {
|
|||||||
healthStatus: aiModel.healthStatus,
|
healthStatus: aiModel.healthStatus,
|
||||||
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
|
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
|
||||||
healthObservationStartedAt: aiModel.healthObservationStartedAt,
|
healthObservationStartedAt: aiModel.healthObservationStartedAt,
|
||||||
|
healthAutoDisabledReason: aiModel.healthAutoDisabledReason,
|
||||||
})
|
})
|
||||||
.from(aiModel)
|
.from(aiModel)
|
||||||
.where(eq(aiModel.id, modelId))
|
.where(eq(aiModel.id, modelId))
|
||||||
@ -575,6 +579,9 @@ export async function recordAiModelHealthSuccess(params: {
|
|||||||
|
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|
||||||
|
// A previously accepted task can finish after the account runs out of credit.
|
||||||
|
if (isProviderBalanceError(existing.healthAutoDisabledReason)) return;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// A request that started before the circuit opened can still prove the
|
// A request that started before the circuit opened can still prove the
|
||||||
// provider is reachable. Treat that success as the circuit's half-open
|
// provider is reachable. Treat that success as the circuit's half-open
|
||||||
@ -662,6 +669,7 @@ export async function recordAiModelHealthFailure(params: {
|
|||||||
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
|
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
|
||||||
healthObservationStartedAt: aiModel.healthObservationStartedAt,
|
healthObservationStartedAt: aiModel.healthObservationStartedAt,
|
||||||
healthAutoDisabledAt: aiModel.healthAutoDisabledAt,
|
healthAutoDisabledAt: aiModel.healthAutoDisabledAt,
|
||||||
|
healthAutoDisabledReason: aiModel.healthAutoDisabledReason,
|
||||||
})
|
})
|
||||||
.from(aiModel)
|
.from(aiModel)
|
||||||
.where(eq(aiModel.id, modelId))
|
.where(eq(aiModel.id, modelId))
|
||||||
@ -678,13 +686,15 @@ export async function recordAiModelHealthFailure(params: {
|
|||||||
: existing.healthConsecutiveFailures || 0;
|
: existing.healthConsecutiveFailures || 0;
|
||||||
const shouldAutoDisable =
|
const shouldAutoDisable =
|
||||||
classification.counted &&
|
classification.counted &&
|
||||||
!existing.excludeFromFailover &&
|
(classification.eventType === "quota" ||
|
||||||
!existing.excludeFromHealthBan &&
|
(!existing.excludeFromFailover &&
|
||||||
(classification.reason === "provider_authentication_failed" ||
|
!existing.excludeFromHealthBan &&
|
||||||
(isObservation && existing.isEnabled) ||
|
(classification.reason === "provider_authentication_failed" ||
|
||||||
(existing.healthStatus !== "auto_disabled" &&
|
(isObservation && existing.isEnabled) ||
|
||||||
nextConsecutiveFailures >= AUTO_DISABLE_FAILURE_THRESHOLD));
|
(existing.healthStatus !== "auto_disabled" &&
|
||||||
const nextStatus = shouldAutoDisable
|
nextConsecutiveFailures >= AUTO_DISABLE_FAILURE_THRESHOLD))));
|
||||||
|
const balanceDisabled = isProviderBalanceError(existing.healthAutoDisabledReason);
|
||||||
|
const nextStatus = shouldAutoDisable || balanceDisabled
|
||||||
? "auto_disabled"
|
? "auto_disabled"
|
||||||
: classification.counted
|
: classification.counted
|
||||||
? "degraded"
|
? "degraded"
|
||||||
@ -737,7 +747,7 @@ export async function recordAiModelHealthFailure(params: {
|
|||||||
healthUpdatedAt: now,
|
healthUpdatedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
if (shouldAutoDisable) {
|
if (shouldAutoDisable && !balanceDisabled) {
|
||||||
updateData.healthAutoDisabledAt = now;
|
updateData.healthAutoDisabledAt = now;
|
||||||
updateData.healthAutoDisabledReason = autoDisabledReason || errorMessage;
|
updateData.healthAutoDisabledReason = autoDisabledReason || errorMessage;
|
||||||
} else if (existing.healthStatus !== "auto_disabled" && !isObservation) {
|
} else if (existing.healthStatus !== "auto_disabled" && !isObservation) {
|
||||||
|
|||||||
@ -238,7 +238,10 @@ export function bindVip98kImages(
|
|||||||
imageUrls: string[],
|
imageUrls: string[],
|
||||||
) {
|
) {
|
||||||
const bindingKeys: string[] = [];
|
const bindingKeys: string[] = [];
|
||||||
if (imageUrls.length === 0) return bindingKeys;
|
if (imageUrls.length === 0) {
|
||||||
|
for (const key of [...IMAGE_ARRAY_INPUT_KEYS, ...IMAGE_SINGLE_INPUT_KEYS, "video_clips"]) delete body[key];
|
||||||
|
return bindingKeys;
|
||||||
|
}
|
||||||
|
|
||||||
for (const key of IMAGE_ARRAY_INPUT_KEYS) {
|
for (const key of IMAGE_ARRAY_INPUT_KEYS) {
|
||||||
if (!(key in body)) continue;
|
if (!(key in body)) continue;
|
||||||
@ -305,6 +308,7 @@ function scoreReferenceInvocation(invocation: Vip98kTaskInvocation) {
|
|||||||
|
|
||||||
export function selectVip98kTaskInvocation(params: {
|
export function selectVip98kTaskInvocation(params: {
|
||||||
modelId: string;
|
modelId: string;
|
||||||
|
kind?: string;
|
||||||
configuredInvocation: Vip98kTaskInvocation | null;
|
configuredInvocation: Vip98kTaskInvocation | null;
|
||||||
configuredInvocations?: Vip98kTaskInvocation[];
|
configuredInvocations?: Vip98kTaskInvocation[];
|
||||||
imageCount?: number;
|
imageCount?: number;
|
||||||
@ -334,6 +338,17 @@ export function selectVip98kTaskInvocation(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.kind === "image" && !params.imageCount) {
|
||||||
|
const textImageTask = candidates.find(candidate => {
|
||||||
|
const properties = candidate.inputSchema?.properties as Record<string, unknown> | undefined;
|
||||||
|
const required = candidate.inputSchema?.required as string[] | undefined;
|
||||||
|
return candidate.kind === "task" && candidate.path === "/extensions/v1/tasks" &&
|
||||||
|
invocationTargetsModel(candidate, params.modelId) && properties?.resolution && properties?.aspect_ratio &&
|
||||||
|
!required?.some(key => [...IMAGE_ARRAY_INPUT_KEYS, ...IMAGE_SINGLE_INPUT_KEYS, "video_clips"].includes(key));
|
||||||
|
});
|
||||||
|
if (textImageTask) return resolveVip98kTaskInvocation(params.modelId, textImageTask);
|
||||||
|
}
|
||||||
|
|
||||||
return resolveVip98kTaskInvocation(
|
return resolveVip98kTaskInvocation(
|
||||||
params.modelId,
|
params.modelId,
|
||||||
params.configuredInvocation || candidates[0] || null,
|
params.configuredInvocation || candidates[0] || null,
|
||||||
|
|||||||
@ -14,13 +14,16 @@ import { getCanvasCapability } from "@/lib/canvas/capabilities/registry";
|
|||||||
import { enqueueCanvasCapabilityExecution } from "@/lib/canvas/capabilities/task-queue";
|
import { enqueueCanvasCapabilityExecution } from "@/lib/canvas/capabilities/task-queue";
|
||||||
import {
|
import {
|
||||||
createOrRecoverCanvasCapabilityTask,
|
createOrRecoverCanvasCapabilityTask,
|
||||||
MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS,
|
getCanvasCapabilityTask,
|
||||||
|
heartbeatCanvasCapabilityTask,
|
||||||
markCanvasCapabilityTaskFailed,
|
markCanvasCapabilityTaskFailed,
|
||||||
markCanvasCapabilityTaskRunning,
|
markCanvasCapabilityTaskRunning,
|
||||||
markCanvasCapabilityTaskSucceeded,
|
markCanvasCapabilityTaskSucceeded,
|
||||||
} from "@/lib/canvas/capabilities/task-store";
|
} from "@/lib/canvas/capabilities/task-store";
|
||||||
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
|
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
|
||||||
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
|
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
|
||||||
|
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
||||||
|
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||||
|
|
||||||
export type CanvasCapabilityRuntimeResult = {
|
export type CanvasCapabilityRuntimeResult = {
|
||||||
clientCommand?: CanvasCapabilityClientCommand;
|
clientCommand?: CanvasCapabilityClientCommand;
|
||||||
@ -58,9 +61,11 @@ function serializeTask(task: {
|
|||||||
status: string;
|
status: string;
|
||||||
version: number;
|
version: number;
|
||||||
}) {
|
}) {
|
||||||
|
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
|
||||||
return {
|
return {
|
||||||
attempts: task.attempts,
|
attempts: task.attempts,
|
||||||
error: task.error,
|
error: failure ? formatRequestFailure(failure, task.id) : task.error,
|
||||||
|
failure,
|
||||||
id: task.id,
|
id: task.id,
|
||||||
requestId: task.requestId,
|
requestId: task.requestId,
|
||||||
result: task.result,
|
result: task.result,
|
||||||
@ -69,6 +74,13 @@ function serializeTask(task: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function storedTaskResult(task: Parameters<typeof serializeTask>[0], mode: "headless" | "interactive"): CanvasCapabilityRuntimeResult {
|
||||||
|
const result = task.status === "succeeded" && task.result && typeof task.result === "object"
|
||||||
|
? task.result as Omit<CanvasCapabilityRuntimeResult, "task">
|
||||||
|
: { mode };
|
||||||
|
return { ...result, task: serializeTask(task) };
|
||||||
|
}
|
||||||
|
|
||||||
function buildMediaOperationPlan(params: {
|
function buildMediaOperationPlan(params: {
|
||||||
command: CanvasCapabilityClientCommand;
|
command: CanvasCapabilityClientCommand;
|
||||||
mediaResults: CanvasCapabilityMediaResult[];
|
mediaResults: CanvasCapabilityMediaResult[];
|
||||||
@ -211,11 +223,7 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (task.status === "succeeded") {
|
if (task.status === "succeeded") {
|
||||||
const storedResult =
|
return storedTaskResult(task, "headless");
|
||||||
task.result && typeof task.result === "object"
|
|
||||||
? (task.result as Omit<CanvasCapabilityRuntimeResult, "task">)
|
|
||||||
: ({ mode: "headless" } as const);
|
|
||||||
return { ...storedResult, task: serializeTask(task) };
|
|
||||||
}
|
}
|
||||||
if (task.status === "running") {
|
if (task.status === "running") {
|
||||||
return {
|
return {
|
||||||
@ -225,15 +233,8 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
task: serializeTask(task),
|
task: serializeTask(task),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (
|
if (task.status === "failed") {
|
||||||
task.status === "failed" &&
|
return { mode: "headless", task: serializeTask(task) };
|
||||||
task.attempts >= MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
|
|
||||||
) {
|
|
||||||
throw new CanvasCapabilityRuntimeError(
|
|
||||||
task.error || "画布能力任务超过最大重试次数",
|
|
||||||
409,
|
|
||||||
"CANVAS_CAPABILITY_RETRY_EXHAUSTED"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldRunHeadlessly = shouldExecuteCanvasCapabilityHeadlessly({
|
const shouldRunHeadlessly = shouldExecuteCanvasCapabilityHeadlessly({
|
||||||
@ -241,7 +242,9 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
command,
|
command,
|
||||||
});
|
});
|
||||||
if (!shouldRunHeadlessly) {
|
if (!shouldRunHeadlessly) {
|
||||||
task = await markCanvasCapabilityTaskRunning(task.id);
|
const claimed = await markCanvasCapabilityTaskRunning(task.id);
|
||||||
|
if (!claimed) return storedTaskResult((await getCanvasCapabilityTask({ requestId: command.requestId, userId: params.userId, projectId: params.projectId })) || task, "interactive");
|
||||||
|
task = claimed;
|
||||||
const result = {
|
const result = {
|
||||||
mode: "interactive" as const,
|
mode: "interactive" as const,
|
||||||
clientCommand: command,
|
clientCommand: command,
|
||||||
@ -250,10 +253,18 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
return { ...result, task: serializeTask(task) };
|
return { ...result, task: serializeTask(task) };
|
||||||
}
|
}
|
||||||
|
|
||||||
return enqueueCanvasCapabilityExecution({
|
const heartbeat = setInterval(() => {
|
||||||
|
void heartbeatCanvasCapabilityTask(task.id).catch((error) => {
|
||||||
|
console.warn("[Canvas Capability] Task heartbeat failed:", error);
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
try {
|
||||||
|
return await trackRuntimeWork(() => enqueueCanvasCapabilityExecution({
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
execute: async () => {
|
execute: async () => {
|
||||||
task = await markCanvasCapabilityTaskRunning(task.id);
|
const claimed = await markCanvasCapabilityTaskRunning(task.id);
|
||||||
|
if (!claimed) return storedTaskResult((await getCanvasCapabilityTask({ requestId: command.requestId, userId: params.userId, projectId: params.projectId })) || task, "headless");
|
||||||
|
task = claimed;
|
||||||
try {
|
try {
|
||||||
const projectRevision = context.project.updatedAt.toISOString();
|
const projectRevision = context.project.updatedAt.toISOString();
|
||||||
const mutationPlan = buildMutationOperationPlan({
|
const mutationPlan = buildMutationOperationPlan({
|
||||||
@ -299,5 +310,8 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
} finally {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { and, eq, lt, sql } from "drizzle-orm";
|
import { and, eq, inArray, lt, sql } from "drizzle-orm";
|
||||||
|
|
||||||
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { canvasCapabilityTask } from "@/lib/db/schema";
|
import { canvasCapabilityTask } from "@/lib/db/schema";
|
||||||
|
|
||||||
const STALE_RUNNING_TASK_MS = 15 * 60 * 1000;
|
const STALE_RUNNING_TASK_MS = 15 * 60 * 1000;
|
||||||
export const MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS = 3;
|
const INTERRUPTED_TASK_MESSAGE = "任务执行中断,结果尚未确认。请先查看画布和历史记录,或联系管理员核对原任务,避免重复处理。";
|
||||||
|
|
||||||
export type CanvasCapabilityTaskResult = Record<string, unknown>;
|
export type CanvasCapabilityTaskResult = Record<string, unknown>;
|
||||||
|
|
||||||
@ -25,7 +25,25 @@ export async function getCanvasCapabilityTask(params: {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return task || null;
|
if (!task) return null;
|
||||||
|
const staleBefore = new Date(Date.now() - STALE_RUNNING_TASK_MS);
|
||||||
|
if (["pending", "running"].includes(task.status) && task.updatedAt < staleBefore) {
|
||||||
|
const [interrupted] = await db.update(canvasCapabilityTask).set({
|
||||||
|
status: "failed",
|
||||||
|
error: INTERRUPTED_TASK_MESSAGE,
|
||||||
|
completedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}).where(and(
|
||||||
|
eq(canvasCapabilityTask.id, task.id),
|
||||||
|
inArray(canvasCapabilityTask.status, ["pending", "running"]),
|
||||||
|
lt(canvasCapabilityTask.updatedAt, staleBefore),
|
||||||
|
)).returning();
|
||||||
|
if (interrupted) return interrupted;
|
||||||
|
const [latest] = await db.select().from(canvasCapabilityTask)
|
||||||
|
.where(eq(canvasCapabilityTask.id, task.id)).limit(1);
|
||||||
|
return latest || task;
|
||||||
|
}
|
||||||
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createOrRecoverCanvasCapabilityTask(params: {
|
export async function createOrRecoverCanvasCapabilityTask(params: {
|
||||||
@ -42,6 +60,7 @@ export async function createOrRecoverCanvasCapabilityTask(params: {
|
|||||||
capabilityId: params.command.capabilityId,
|
capabilityId: params.command.capabilityId,
|
||||||
source: params.command.source,
|
source: params.command.source,
|
||||||
request: params.command,
|
request: params.command,
|
||||||
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.onConflictDoNothing({ target: canvasCapabilityTask.requestId });
|
.onConflictDoNothing({ target: canvasCapabilityTask.requestId });
|
||||||
|
|
||||||
@ -50,59 +69,19 @@ export async function createOrRecoverCanvasCapabilityTask(params: {
|
|||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
};
|
};
|
||||||
let task = await getCanvasCapabilityTask(taskLookup);
|
const task = await getCanvasCapabilityTask(taskLookup);
|
||||||
if (!task) {
|
if (!task) {
|
||||||
throw new Error("无法创建画布能力任务");
|
throw new Error("无法创建画布能力任务");
|
||||||
}
|
}
|
||||||
|
|
||||||
const staleBefore = new Date(Date.now() - STALE_RUNNING_TASK_MS);
|
|
||||||
if (
|
|
||||||
task.status === "failed" &&
|
|
||||||
task.attempts < MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
|
|
||||||
) {
|
|
||||||
await db
|
|
||||||
.update(canvasCapabilityTask)
|
|
||||||
.set({
|
|
||||||
status: "pending",
|
|
||||||
error: null,
|
|
||||||
completedAt: null,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(canvasCapabilityTask.id, task.id));
|
|
||||||
} else if (
|
|
||||||
task.status === "running" &&
|
|
||||||
task.updatedAt < staleBefore &&
|
|
||||||
task.attempts < MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
|
|
||||||
) {
|
|
||||||
await db
|
|
||||||
.update(canvasCapabilityTask)
|
|
||||||
.set({
|
|
||||||
status: "pending",
|
|
||||||
error: "恢复超时的执行中任务",
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(canvasCapabilityTask.id, task.id),
|
|
||||||
lt(canvasCapabilityTask.updatedAt, staleBefore)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else if (task.status === "running" && task.updatedAt < staleBefore) {
|
|
||||||
await db
|
|
||||||
.update(canvasCapabilityTask)
|
|
||||||
.set({
|
|
||||||
status: "failed",
|
|
||||||
error: "画布能力任务超过最大重试次数",
|
|
||||||
completedAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(canvasCapabilityTask.id, task.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
task = (await getCanvasCapabilityTask(taskLookup)) || task;
|
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function heartbeatCanvasCapabilityTask(taskId: string) {
|
||||||
|
await db.update(canvasCapabilityTask).set({ updatedAt: new Date() })
|
||||||
|
.where(and(eq(canvasCapabilityTask.id, taskId), inArray(canvasCapabilityTask.status, ["pending", "running"])));
|
||||||
|
}
|
||||||
|
|
||||||
export async function markCanvasCapabilityTaskRunning(taskId: string) {
|
export async function markCanvasCapabilityTaskRunning(taskId: string) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [task] = await db
|
const [task] = await db
|
||||||
@ -115,11 +94,10 @@ export async function markCanvasCapabilityTaskRunning(taskId: string) {
|
|||||||
error: null,
|
error: null,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.where(eq(canvasCapabilityTask.id, taskId))
|
.where(and(eq(canvasCapabilityTask.id, taskId), eq(canvasCapabilityTask.status, "pending")))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!task) throw new Error("画布能力任务不存在");
|
return task || null;
|
||||||
return task;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markCanvasCapabilityTaskSucceeded(
|
export async function markCanvasCapabilityTaskSucceeded(
|
||||||
|
|||||||
31
lib/canvas/download-error.ts
Normal file
31
lib/canvas/download-error.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
export type DownloadFailure = {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
recommendation: string;
|
||||||
|
status: number;
|
||||||
|
retryable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDownloadFailure(error: unknown): DownloadFailure {
|
||||||
|
const text = error && typeof error === "object" && "message" in error
|
||||||
|
? `${"name" in error ? error.name : "Error"}: ${error.message}` : String(error || "");
|
||||||
|
const failure = (code: string, message: string, recommendation: string, status: number, retryable = false) =>
|
||||||
|
({ code, message, recommendation, status, retryable });
|
||||||
|
if (/旧存储已停用|storage_retired/.test(text)) return failure("STORAGE_RETIRED", "原图所在的旧存储已停用。", "请重新上传原图;重试下载无法恢复该文件。", 410);
|
||||||
|
if (/HTTP (?:404|410)|SOURCE_NOT_FOUND/i.test(text)) return failure("SOURCE_NOT_FOUND", "原图链接已失效或源文件已被删除。", "请重新上传原图,或联系管理员核对归档副本;无需重新生成图片。", 404);
|
||||||
|
if (/HTTP (?:401|403)|SOURCE_FORBIDDEN/i.test(text)) return failure("SOURCE_FORBIDDEN", "图片源站拒绝访问原图。", "请联系管理员检查原图访问权限或链接有效期,并提供下载编号。", 403);
|
||||||
|
if (/queue.*busy|正在准备的原图较多|DOWNLOAD_BUSY/i.test(text)) return failure("DOWNLOAD_BUSY", "正在准备的下载较多。", "请等待当前下载完成后再试,避免同时重复点击。", 429, true);
|
||||||
|
if (/TimeoutError|AbortError|timeout|timed out|deadline|超时|HTTP (?:408|504|524)/i.test(text)) return failure("SOURCE_TIMEOUT", "读取原图或准备下载文件超时。", "请稍后再次下载;持续失败时联系管理员并提供下载编号,已归档的原图无需重新生成。", 504, true);
|
||||||
|
if (/fetch failed|failed to fetch|ECONN|ENOTFOUND|EAI_AGAIN|network|HTTP (?:429|5\d\d)/i.test(text)) return failure("SOURCE_UNAVAILABLE", "暂时无法连接图片源站。", "请稍后重试下载;持续失败时联系管理员检查取图线路,并提供下载编号。", 503, true);
|
||||||
|
if (/not.*image|error document|不是图片|无法识别图片|empty response|image is empty|Invalid prepared original/i.test(text)) return failure("INVALID_IMAGE", "源地址没有返回可用的图片文件。", "请重新上传可打开的原图;若原图正常,请联系管理员并提供下载编号。", 422);
|
||||||
|
if (/sharp|pngload|jpegload|unsupported image|无法解析图片|convert|转换/i.test(text)) return failure("IMAGE_CONVERSION_FAILED", "原图无法转换为下载所需的 PNG 文件。", "请保留原图并联系管理员提供下载编号,或重新上传 PNG/JPEG 图片。", 422);
|
||||||
|
if (/upload|存储桶|保存下载|STORAGE_WRITE_FAILED/i.test(text)) return failure("DOWNLOAD_STORAGE_FAILED", "下载文件保存失败,已归档的原图仍然保留。", "请稍后重试;持续失败时联系管理员检查存储,并提供下载编号。", 503, true);
|
||||||
|
if (/未登录/.test(text)) return failure("LOGIN_REQUIRED", "登录状态已失效。", "请重新登录后下载,当前图片不会因此删除。", 401);
|
||||||
|
if (/无法确认原图/.test(text)) return failure("ORIGINAL_IMAGE_UNCONFIRMED", "图片链接包含处理参数,无法确认原图。", "请选择未裁剪、未缩放的原图链接,或重新上传原图。", 400);
|
||||||
|
if (/图片地址无效/.test(text)) return failure("INVALID_IMAGE_URL", "图片地址无效。", "请刷新画布后重新选择图片;仍然失败时请重新上传原图。", 400);
|
||||||
|
return failure("DOWNLOAD_FAILED", "下载准备未能完成。", "请联系管理员并提供下载编号,以便核对原图、转换和存储的具体错误;无需重新生成。", 500, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDownloadFailure(failure: DownloadFailure, requestId?: string) {
|
||||||
|
return `${failure.message}${failure.recommendation}${requestId ? `\n下载编号:${requestId}` : ""}`;
|
||||||
|
}
|
||||||
@ -1,6 +1,10 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { cacheImageBytes, readImageBytes, type ImageBytes } from "@/lib/storage/image-byte-cache";
|
import { cacheImageBytes, readImageBytes, type ImageBytes } from "@/lib/storage/image-byte-cache";
|
||||||
|
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "./download-error";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
|
||||||
|
const logger = createLogger("canvas.download");
|
||||||
|
|
||||||
export type DownloadPreparationInput = {
|
export type DownloadPreparationInput = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -9,17 +13,18 @@ export type DownloadPreparationInput = {
|
|||||||
cookie: string;
|
cookie: string;
|
||||||
};
|
};
|
||||||
type Result =
|
type Result =
|
||||||
| { status: "preparing" }
|
| { status: "preparing"; requestId: string }
|
||||||
| { status: "ready"; asset: ImageBytes }
|
| { status: "ready"; asset: ImageBytes }
|
||||||
| { status: "failed"; error: string };
|
| { status: "failed"; error: string; failure: DownloadFailure; requestId: string };
|
||||||
type Job = {
|
type Job = {
|
||||||
|
requestId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
input: DownloadPreparationInput;
|
input: DownloadPreparationInput;
|
||||||
status: "queued" | "running" | "ready" | "failed";
|
status: "queued" | "running" | "ready" | "failed";
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
asset?: ImageBytes;
|
asset?: ImageBytes;
|
||||||
|
failure?: DownloadFailure;
|
||||||
};
|
};
|
||||||
const FAILURE_MESSAGE = "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
|
|
||||||
const MAX_MEMORY_BYTES = 128 * 1024 * 1024;
|
const MAX_MEMORY_BYTES = 128 * 1024 * 1024;
|
||||||
|
|
||||||
// A download is independent of node autosave and of the archive worker's batch.
|
// A download is independent of node autosave and of the archive worker's batch.
|
||||||
@ -69,7 +74,7 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
reject(new Error("Download preparation deadline"));
|
reject(new Error("Download preparation deadline"));
|
||||||
}, deps.timeoutMs || 90_000);
|
}, deps.timeoutMs || 150_000);
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
if (asset.contentType !== "image/png" || !asset.buffer.length || asset.buffer.length > MAX_MEMORY_BYTES) {
|
if (asset.contentType !== "image/png" || !asset.buffer.length || asset.buffer.length > MAX_MEMORY_BYTES) {
|
||||||
@ -81,8 +86,10 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
const stored = await deps.read(key).catch(() => null);
|
const stored = await deps.read(key).catch(() => null);
|
||||||
if (stored?.contentType === "image/png" && stored.buffer.length) job.asset = undefined;
|
if (stored?.contentType === "image/png" && stored.buffer.length) job.asset = undefined;
|
||||||
job.status = "ready";
|
job.status = "ready";
|
||||||
} catch {
|
} catch (error) {
|
||||||
job.status = "failed";
|
job.status = "failed";
|
||||||
|
job.failure = getDownloadFailure(error);
|
||||||
|
logger.warn("preparation_failed", error, { requestId: job.requestId, code: job.failure.code });
|
||||||
} finally {
|
} finally {
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
// Do not retain authentication data after a preparation finishes.
|
// Do not retain authentication data after a preparation finishes.
|
||||||
@ -105,8 +112,11 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
prune();
|
prune();
|
||||||
const current = jobs.get(key);
|
const current = jobs.get(key);
|
||||||
if (current?.status === "ready" && current.asset) return { status: "ready", asset: current.asset };
|
if (current?.status === "ready" && current.asset) return { status: "ready", asset: current.asset };
|
||||||
if (current?.status === "failed") return { status: "failed", error: FAILURE_MESSAGE };
|
if (current?.status === "failed") {
|
||||||
if (current && current.status !== "ready") return { status: "preparing" };
|
const failure = current.failure || getDownloadFailure(null);
|
||||||
|
return { status: "failed", error: formatDownloadFailure(failure, current.requestId), failure, requestId: current.requestId };
|
||||||
|
}
|
||||||
|
if (current && current.status !== "ready") return { status: "preparing", requestId: current.requestId };
|
||||||
if (jobs.size >= 64) {
|
if (jobs.size >= 64) {
|
||||||
for (const [oldKey, job] of jobs) {
|
for (const [oldKey, job] of jobs) {
|
||||||
if (job.status === "ready" || job.status === "failed") jobs.delete(oldKey);
|
if (job.status === "ready" || job.status === "failed") jobs.delete(oldKey);
|
||||||
@ -115,11 +125,14 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
}
|
}
|
||||||
if (jobs.size >= 64 || [...jobs.values()].filter(job => job.userId === input.userId &&
|
if (jobs.size >= 64 || [...jobs.values()].filter(job => job.userId === input.userId &&
|
||||||
(job.status === "queued" || job.status === "running")).length >= 4) {
|
(job.status === "queued" || job.status === "running")).length >= 4) {
|
||||||
return { status: "failed", error: "正在准备的原图较多,请等待当前下载完成后再试" };
|
const requestId = randomUUID();
|
||||||
|
const failure = getDownloadFailure("DOWNLOAD_BUSY");
|
||||||
|
return { status: "failed", error: formatDownloadFailure(failure, requestId), failure, requestId };
|
||||||
}
|
}
|
||||||
jobs.set(key, { userId: input.userId, input, status: "queued", updatedAt: now() });
|
const requestId = randomUUID();
|
||||||
|
jobs.set(key, { requestId, userId: input.userId, input, status: "queued", updatedAt: now() });
|
||||||
deps.schedule(drain);
|
deps.schedule(drain);
|
||||||
return { status: "preparing" };
|
return { status: "preparing", requestId };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { generateCanvasImage, type CanvasGenerateImageRequest } from "@/lib/canv
|
|||||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
import { getImageToolCreditCost, type ImageToolCreditFeature } from "@/lib/points/config";
|
import { getImageToolCreditCost, type ImageToolCreditFeature } from "@/lib/points/config";
|
||||||
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
import { getUserFacingImageGenerationErrorMessage, getRequestFailureDetails } from "@/lib/request-error";
|
||||||
|
|
||||||
interface GenerateImageRequest extends CanvasGenerateImageRequest {
|
interface GenerateImageRequest extends CanvasGenerateImageRequest {
|
||||||
async?: boolean;
|
async?: boolean;
|
||||||
@ -121,7 +121,7 @@ export async function executeCanvasImageResponse(body: GenerateImageRequest, use
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Canvas Generate Image] Error:", error);
|
console.error("[Canvas Generate Image] Error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: getUserFacingImageGenerationErrorMessage(error),
|
{ success: false, error: getUserFacingImageGenerationErrorMessage(error), failure: getRequestFailureDetails(error),
|
||||||
...(error instanceof ProviderTaskUncertainError ? { code: "PROVIDER_TASK_UNCERTAIN" } : {}) },
|
...(error instanceof ProviderTaskUncertainError ? { code: "PROVIDER_TASK_UNCERTAIN" } : {}) },
|
||||||
{ status: error instanceof ProviderTaskUncertainError ? 503 : 500 },
|
{ status: error instanceof ProviderTaskUncertainError ? 503 : 500 },
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { agentImageCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-sto
|
|||||||
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
|
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
|
||||||
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
|
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
|
||||||
import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
||||||
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
import { getAiModelById, getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||||
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
||||||
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
|
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
|
||||||
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
|
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
|
||||||
@ -43,7 +43,8 @@ import {
|
|||||||
shouldFailoverAiModelError,
|
shouldFailoverAiModelError,
|
||||||
} from "@/lib/ai/runtime/model-health";
|
} from "@/lib/ai/runtime/model-health";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
|
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
|
||||||
|
import { isProviderBalanceError } from "@/lib/request-error";
|
||||||
|
|
||||||
export interface CanvasLocalEditCompositeRegion {
|
export interface CanvasLocalEditCompositeRegion {
|
||||||
x: number;
|
x: number;
|
||||||
@ -198,6 +199,7 @@ export interface CanvasGenerateImageRequest {
|
|||||||
agentImageMaxCreditCost?: number;
|
agentImageMaxCreditCost?: number;
|
||||||
internalFailoverTriedModelRefs?: string[];
|
internalFailoverTriedModelRefs?: string[];
|
||||||
internalFailoverTriedProviderTypes?: string[];
|
internalFailoverTriedProviderTypes?: string[];
|
||||||
|
internalBalanceBlockedProviderIds?: string[];
|
||||||
onQueueStatusChange?: (
|
onQueueStatusChange?: (
|
||||||
status: CanvasImageModelQueueStatus & {
|
status: CanvasImageModelQueueStatus & {
|
||||||
modelName?: string | null;
|
modelName?: string | null;
|
||||||
@ -4512,7 +4514,7 @@ function findCanvasFailoverModel(params: {
|
|||||||
!item.model.isEnabled ||
|
!item.model.isEnabled ||
|
||||||
item.model.healthStatus === "auto_disabled" ||
|
item.model.healthStatus === "auto_disabled" ||
|
||||||
item.model.isSubModel ||
|
item.model.isSubModel ||
|
||||||
isImageFallbackModelBalanceCoolingDown(item.model) ||
|
isImageModelBalanceBlocked(item.model) ||
|
||||||
(item.model.excludeFromFailover && !isSameNameScaleAdjustFallback)
|
(item.model.excludeFromFailover && !isSameNameScaleAdjustFallback)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@ -4904,7 +4906,20 @@ export async function generateCanvasImage(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const allModels = await getEnabledAiModelsWithProvider();
|
const allModels = await getEnabledAiModelsWithProvider();
|
||||||
let imageModels = allModels.filter((m) => m.model.supportsImageGen);
|
const balanceBlockedProviderIds = new Set(request.internalBalanceBlockedProviderIds || []);
|
||||||
|
let imageModels = allModels.filter((m) => m.model.supportsImageGen && !isImageModelBalanceBlocked(m.model) && !balanceBlockedProviderIds.has(m.provider.id));
|
||||||
|
const requestedModel = allModels.find(entry => entry.model.id === model || entry.model.modelId === model)?.model ||
|
||||||
|
(/^[0-9a-f-]{36}$/i.test(String(model || "")) ? await getAiModelById({ id: model! }) : undefined);
|
||||||
|
let effectiveModel = model;
|
||||||
|
if (requestedModel?.supportsImageGen && isImageModelBalanceBlocked(requestedModel)) {
|
||||||
|
const replacement = request.allowFailover !== false && request.agentImageMode !== "pinned"
|
||||||
|
? resolveConfiguredImageBalanceFallback(requestedModel, imageModels, {
|
||||||
|
imageSize: request.imageSize, aspectRatio: request.aspectRatio,
|
||||||
|
referenceImageCount: new Set([...(referenceImages || []), referenceImage].filter(Boolean)).size,
|
||||||
|
}) : undefined;
|
||||||
|
if (!replacement) throw new Error("当前生图线路余额不足,已暂停新请求;请切换可用模型,已有任务和图片会保留。");
|
||||||
|
effectiveModel = replacement.model.id;
|
||||||
|
}
|
||||||
if (request.agentImageMode === "auto") {
|
if (request.agentImageMode === "auto") {
|
||||||
const states = await Promise.all(imageModels.map(async entry => ({ entry, paused: await agentImageCircuitStore.isPaused(entry) })));
|
const states = await Promise.all(imageModels.map(async entry => ({ entry, paused: await agentImageCircuitStore.isPaused(entry) })));
|
||||||
imageModels = states.filter(item => !item.paused && item.entry.model.healthStatus !== "auto_disabled" &&
|
imageModels = states.filter(item => !item.paused && item.entry.model.healthStatus !== "auto_disabled" &&
|
||||||
@ -4916,8 +4931,8 @@ export async function generateCanvasImage(
|
|||||||
throw new Error("暂无可用的图片生成模型");
|
throw new Error("暂无可用的图片生成模型");
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedModel = model
|
let selectedModel = effectiveModel
|
||||||
? imageModels.find((m) => m.model.modelId === model || m.model.id === model)
|
? imageModels.find((m) => m.model.modelId === effectiveModel || m.model.id === effectiveModel)
|
||||||
: imageModels.find((m) => m.model.isDefault) || imageModels[0];
|
: imageModels.find((m) => m.model.isDefault) || imageModels[0];
|
||||||
|
|
||||||
if (!selectedModel) {
|
if (!selectedModel) {
|
||||||
@ -5654,7 +5669,7 @@ export async function generateCanvasImage(
|
|||||||
? request.internalRetryVariantIndex + 1
|
? request.internalRetryVariantIndex + 1
|
||||||
: 0;
|
: 0;
|
||||||
const retryVariant = retryVariants[nextRetryVariantIndex];
|
const retryVariant = retryVariants[nextRetryVariantIndex];
|
||||||
if (retryVariant && !request.agentImageMode) {
|
if (retryVariant && !request.agentImageMode && !isProviderBalanceError(caughtError)) {
|
||||||
const {
|
const {
|
||||||
debugMeta: retryVariantDebugMeta,
|
debugMeta: retryVariantDebugMeta,
|
||||||
label: retryVariantLabel,
|
label: retryVariantLabel,
|
||||||
@ -5710,7 +5725,8 @@ export async function generateCanvasImage(
|
|||||||
} else if (request.agentImageMode) {
|
} else if (request.agentImageMode) {
|
||||||
const failure = agentImageFailurePolicy(caughtError);
|
const failure = agentImageFailurePolicy(caughtError);
|
||||||
if (failure.pause) await agentImageCircuitStore.fail(selectedModel, failure.pause);
|
if (failure.pause) await agentImageCircuitStore.fail(selectedModel, failure.pause);
|
||||||
} else {
|
}
|
||||||
|
if (!scaleAdjustRejected && (!request.agentImageMode || isProviderBalanceError(caughtError))) {
|
||||||
await recordAiModelHealthFailure({
|
await recordAiModelHealthFailure({
|
||||||
model: selectedModel.model,
|
model: selectedModel.model,
|
||||||
provider,
|
provider,
|
||||||
@ -5756,17 +5772,18 @@ export async function generateCanvasImage(
|
|||||||
...(Array.isArray(request.internalFailoverTriedProviderTypes)
|
...(Array.isArray(request.internalFailoverTriedProviderTypes)
|
||||||
? request.internalFailoverTriedProviderTypes
|
? request.internalFailoverTriedProviderTypes
|
||||||
: []),
|
: []),
|
||||||
...(request.agentImageMode !== "auto" && shouldFailover &&
|
...(request.agentImageMode !== "auto" && !isProviderBalanceError(caughtError) && shouldFailover &&
|
||||||
failureClassification.reason !== "non_provider_failure" &&
|
failureClassification.reason !== "non_provider_failure" &&
|
||||||
failureClassification.reason !== "user_cancelled"
|
failureClassification.reason !== "user_cancelled"
|
||||||
? [provider.type]
|
? [provider.type]
|
||||||
: []),
|
: []),
|
||||||
].filter(Boolean))
|
].filter(Boolean))
|
||||||
);
|
);
|
||||||
|
if (isProviderBalanceError(caughtError)) balanceBlockedProviderIds.add(provider.id);
|
||||||
const fallbackModel = allowFailover && shouldFailover
|
const fallbackModel = allowFailover && shouldFailover
|
||||||
? findCanvasFailoverModel({
|
? findCanvasFailoverModel({
|
||||||
selectedModel,
|
selectedModel,
|
||||||
imageModels,
|
imageModels: imageModels.filter(entry => !balanceBlockedProviderIds.has(entry.provider.id)),
|
||||||
failedModelRefs,
|
failedModelRefs,
|
||||||
failedProviderTypes,
|
failedProviderTypes,
|
||||||
preferLowCostSameNameProviders: Boolean(
|
preferLowCostSameNameProviders: Boolean(
|
||||||
@ -5797,6 +5814,7 @@ export async function generateCanvasImage(
|
|||||||
model: fallbackModel.model.id,
|
model: fallbackModel.model.id,
|
||||||
internalFailoverTriedModelRefs: failedModelRefs,
|
internalFailoverTriedModelRefs: failedModelRefs,
|
||||||
internalFailoverTriedProviderTypes: failedProviderTypes,
|
internalFailoverTriedProviderTypes: failedProviderTypes,
|
||||||
|
internalBalanceBlockedProviderIds: [...balanceBlockedProviderIds],
|
||||||
debugMeta: {
|
debugMeta: {
|
||||||
...debugMeta,
|
...debugMeta,
|
||||||
failoverFromModelId: selectedModel.model.modelId,
|
failoverFromModelId: selectedModel.model.modelId,
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import {
|
|||||||
} from "@/lib/agents/detail-replacement-batch";
|
} from "@/lib/agents/detail-replacement-batch";
|
||||||
import { executeDetailReplacementBatch } from "@/lib/agents/detail-replacement-batch-runner";
|
import { executeDetailReplacementBatch } from "@/lib/agents/detail-replacement-batch-runner";
|
||||||
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
||||||
|
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||||
|
|
||||||
type CanvasImageTaskStatus =
|
type CanvasImageTaskStatus =
|
||||||
| "waiting"
|
| "waiting"
|
||||||
@ -328,7 +329,7 @@ async function markStaleDatabaseRunningTasksFailed() {
|
|||||||
.update(canvasImageTask)
|
.update(canvasImageTask)
|
||||||
.set({
|
.set({
|
||||||
status: "failed",
|
status: "failed",
|
||||||
error: "图片生成任务已超时,请重新发起生成",
|
error: "图片任务执行中断或等待超时,结果尚未确认。请先查看原任务和历史记录,联系管理员核对,避免重复生成。",
|
||||||
completedAt,
|
completedAt,
|
||||||
updatedAt: completedAt,
|
updatedAt: completedAt,
|
||||||
})
|
})
|
||||||
@ -383,7 +384,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const runningTimeoutMs = getCanvasImageRunningTimeoutMs(request);
|
const runningTimeoutMs = getCanvasImageRunningTimeoutMs(request);
|
||||||
const execution: Promise<CanvasImageTaskResult> = isStyleProTaskRequest(request)
|
const execution: Promise<CanvasImageTaskResult> = trackRuntimeWork<CanvasImageTaskResult>(() => isStyleProTaskRequest(request)
|
||||||
? executeStyleProTask({
|
? executeStyleProTask({
|
||||||
request,
|
request,
|
||||||
userId: String(task.userId || entry.userId),
|
userId: String(task.userId || entry.userId),
|
||||||
@ -412,7 +413,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
|||||||
ownerKey,
|
ownerKey,
|
||||||
request: imageRequest,
|
request: imageRequest,
|
||||||
});
|
});
|
||||||
})();
|
})());
|
||||||
const heartbeat = isDetailReplacementBatchRequest(request)
|
const heartbeat = isDetailReplacementBatchRequest(request)
|
||||||
? setInterval(() => {
|
? setInterval(() => {
|
||||||
void db.update(canvasImageTask).set({ updatedAt: new Date() })
|
void db.update(canvasImageTask).set({ updatedAt: new Date() })
|
||||||
@ -426,7 +427,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
|||||||
try {
|
try {
|
||||||
result = await withCanvasImageTaskExecutionTimeout(
|
result = await withCanvasImageTaskExecutionTimeout(
|
||||||
execution,
|
execution,
|
||||||
runningTimeoutMs,
|
Math.max(runningTimeoutMs, LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS),
|
||||||
entry.taskId
|
entry.taskId
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@ -751,7 +752,7 @@ export async function getCanvasImageTaskForUser(params: {
|
|||||||
|
|
||||||
if (shouldMarkStale) {
|
if (shouldMarkStale) {
|
||||||
const staleBefore = new Date(now - runningTimeoutMs);
|
const staleBefore = new Date(now - runningTimeoutMs);
|
||||||
const timeoutMessage = "图片生成任务已超时,请重新发起生成";
|
const timeoutMessage = "图片任务执行中断或等待超时,结果尚未确认。请先查看原任务和历史记录,联系管理员核对,避免重复生成。";
|
||||||
const completedAt = new Date();
|
const completedAt = new Date();
|
||||||
const [timedOutTask] = await db
|
const [timedOutTask] = await db
|
||||||
.update(canvasImageTask)
|
.update(canvasImageTask)
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
/** A half-open span containing a complete Markdown image or linked image. */
|
/** A half-open span containing a complete Markdown link or image. */
|
||||||
export type MarkdownImageRange = { start: number; end: number };
|
export type MarkdownImageRange = { start: number; end: number };
|
||||||
|
|
||||||
type Parsed = { kind: "complete"; end: number } | { kind: "pending" } | { kind: "invalid" };
|
type Parsed = { kind: "complete"; end: number } | { kind: "pending" } | { kind: "invalid" };
|
||||||
|
|
||||||
// This scanner recognizes syntax only; rendering and URL safety remain the
|
// This scanner recognizes syntax only; rendering and URL safety remain the
|
||||||
// responsibility of the existing Markdown renderer and image splitter.
|
// responsibility of the existing Markdown renderer and image splitter.
|
||||||
function readDestination(text: string, start: number): Parsed {
|
function readDestination(text: string, start: number, allowWhitespace = false): Parsed {
|
||||||
if (start >= text.length) return { kind: "pending" };
|
if (start >= text.length) return { kind: "pending" };
|
||||||
if (text[start] !== "(") return { kind: "invalid" };
|
if (text[start] !== "(") return { kind: "invalid" };
|
||||||
let depth = 1;
|
let depth = 1;
|
||||||
@ -16,7 +16,7 @@ function readDestination(text: string, start: number): Parsed {
|
|||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (/\s/.test(char)) return { kind: "invalid" };
|
if (char === "\n" || char === "\r" || (!allowWhitespace && /\s/.test(char))) return { kind: "invalid" };
|
||||||
if (char === "(") depth += 1;
|
if (char === "(") depth += 1;
|
||||||
if (char === ")" && --depth === 0) return { kind: "complete", end: i + 1 };
|
if (char === ")" && --depth === 0) return { kind: "complete", end: i + 1 };
|
||||||
}
|
}
|
||||||
@ -47,7 +47,37 @@ function readImage(text: string, start: number, wrapped: boolean): Parsed {
|
|||||||
return { kind: "pending" };
|
return { kind: "pending" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Locate completed image syntax and a potentially incomplete trailing image.
|
function readLink(text: string, start: number): Parsed {
|
||||||
|
let depth = 1;
|
||||||
|
for (let i = start + 1; i < text.length; i += 1) {
|
||||||
|
const char = text[i];
|
||||||
|
// An unfinished bracket on an earlier line must not hide later output.
|
||||||
|
if (char === "\n" || char === "\r") return { kind: "invalid" };
|
||||||
|
if (char === "\\") {
|
||||||
|
if (i + 1 >= text.length) return { kind: "pending" };
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "[") depth += 1;
|
||||||
|
if (char !== "]" || --depth !== 0) continue;
|
||||||
|
if (text[i + 1] === "[") {
|
||||||
|
for (let j = i + 2; j < text.length; j += 1) {
|
||||||
|
if (text[j] === "\n" || text[j] === "\r") return { kind: "invalid" };
|
||||||
|
if (text[j] === "\\") {
|
||||||
|
if (j + 1 >= text.length) return { kind: "pending" };
|
||||||
|
j += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (text[j] === "]") return { kind: "complete", end: j + 1 };
|
||||||
|
}
|
||||||
|
return { kind: "pending" };
|
||||||
|
}
|
||||||
|
return readDestination(text, i + 1, true);
|
||||||
|
}
|
||||||
|
return { kind: "pending" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locate complete Markdown links/images and an incomplete trailing construct.
|
||||||
* Code spans/fences and escaped markers remain normal text. */
|
* Code spans/fences and escaped markers remain normal text. */
|
||||||
export function scanMarkdownImageStreaming(text: string): {
|
export function scanMarkdownImageStreaming(text: string): {
|
||||||
ranges: MarkdownImageRange[];
|
ranges: MarkdownImageRange[];
|
||||||
@ -83,8 +113,9 @@ export function scanMarkdownImageStreaming(text: string): {
|
|||||||
const char = text[cursor];
|
const char = text[cursor];
|
||||||
if (char !== "[" && char !== "!") { cursor += 1; continue; }
|
if (char !== "[" && char !== "!") { cursor += 1; continue; }
|
||||||
const wrapped = char === "[";
|
const wrapped = char === "[";
|
||||||
if (wrapped && cursor + 1 < text.length && text[cursor + 1] !== "!") { cursor += 1; continue; }
|
const parsed = wrapped && text[cursor + 1] !== "!"
|
||||||
const parsed = readImage(text, cursor, wrapped);
|
? readLink(text, cursor)
|
||||||
|
: readImage(text, cursor, wrapped);
|
||||||
if (parsed.kind === "pending") return { ranges, pendingStart: cursor };
|
if (parsed.kind === "pending") return { ranges, pendingStart: cursor };
|
||||||
if (parsed.kind === "complete") { ranges.push({ start: cursor, end: parsed.end }); cursor = parsed.end; }
|
if (parsed.kind === "complete") { ranges.push({ start: cursor, end: parsed.end }); cursor = parsed.end; }
|
||||||
else cursor += 1;
|
else cursor += 1;
|
||||||
@ -92,7 +123,7 @@ export function scanMarkdownImageStreaming(text: string): {
|
|||||||
return { ranges, pendingStart: null };
|
return { ranges, pendingStart: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** During active delivery, withhold only unfinished image syntax at the tail. */
|
/** During active delivery, withhold only unfinished link/image syntax at the tail. */
|
||||||
export function stableStreamingMarkdown(text: string, streaming: boolean): string {
|
export function stableStreamingMarkdown(text: string, streaming: boolean): string {
|
||||||
if (!streaming) return text;
|
if (!streaming) return text;
|
||||||
const { pendingStart } = scanMarkdownImageStreaming(text);
|
const { pendingStart } = scanMarkdownImageStreaming(text);
|
||||||
@ -101,7 +132,7 @@ export function stableStreamingMarkdown(text: string, streaming: boolean): strin
|
|||||||
|
|
||||||
export function advancePastAtomicRanges(length: number, target: number, ranges: MarkdownImageRange[]) {
|
export function advancePastAtomicRanges(length: number, target: number, ranges: MarkdownImageRange[]) {
|
||||||
let position = Math.min(length, target);
|
let position = Math.min(length, target);
|
||||||
// Overlapping table/image ranges can require more than one jump.
|
// Overlapping table/link ranges can require more than one jump.
|
||||||
for (let changed = true; changed;) {
|
for (let changed = true; changed;) {
|
||||||
changed = false;
|
changed = false;
|
||||||
for (const range of ranges) {
|
for (const range of ranges) {
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
import { fetchImageBytes, onceImageBytes } from "@/lib/storage/image-byte-cache";
|
import { fetchImageBytes, onceImageBytes } from "@/lib/storage/image-byte-cache";
|
||||||
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
|
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
|
import { getDownloadFailure } from "./download-error";
|
||||||
|
|
||||||
type DownloadFileItem = {
|
type DownloadFileItem = {
|
||||||
url: string;
|
url: string;
|
||||||
@ -30,6 +32,7 @@ function sleep(ms: number) {
|
|||||||
|
|
||||||
function normalizeDownloadError(error: unknown): string {
|
function normalizeDownloadError(error: unknown): string {
|
||||||
if (error instanceof Error) return error.message || "download failed";
|
if (error instanceof Error) return error.message || "download failed";
|
||||||
|
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message;
|
||||||
if (typeof error === "string") return error;
|
if (typeof error === "string") return error;
|
||||||
return "download failed";
|
return "download failed";
|
||||||
}
|
}
|
||||||
@ -296,6 +299,9 @@ export async function fetchCanvasDownloadAsset(params: {
|
|||||||
retryCount?: number;
|
retryCount?: number;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}): Promise<CanvasDownloadAssetResult> {
|
}): Promise<CanvasDownloadAssetResult> {
|
||||||
|
if (isRetiredStorageUrl(params.file.url)) {
|
||||||
|
return { ok: false, url: params.file.url, name: params.file.name, error: RETIRED_STORAGE_MESSAGE };
|
||||||
|
}
|
||||||
let lastError = "";
|
let lastError = "";
|
||||||
const timeoutMs = params.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS;
|
const timeoutMs = params.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS;
|
||||||
const retryCount = params.retryCount || DEFAULT_FETCH_RETRY_COUNT;
|
const retryCount = params.retryCount || DEFAULT_FETCH_RETRY_COUNT;
|
||||||
@ -326,6 +332,7 @@ export async function fetchCanvasDownloadAsset(params: {
|
|||||||
try {
|
try {
|
||||||
const data = await fetchImageBytes(candidateUrl, {
|
const data = await fetchImageBytes(candidateUrl, {
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
|
signal: params.signal,
|
||||||
headers: buildRemoteFetchHeaders(candidateUrl, params.requestOrigin, params.cookie),
|
headers: buildRemoteFetchHeaders(candidateUrl, params.requestOrigin, params.cookie),
|
||||||
// Authenticated local assets must never share a cache across users.
|
// Authenticated local assets must never share a cache across users.
|
||||||
scope: new URL(candidateUrl).origin === params.requestOrigin && params.cookie
|
scope: new URL(candidateUrl).origin === params.requestOrigin && params.cookie
|
||||||
@ -340,11 +347,13 @@ export async function fetchCanvasDownloadAsset(params: {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
params.signal?.throwIfAborted();
|
params.signal?.throwIfAborted();
|
||||||
lastError = normalizeDownloadError(error);
|
const message = normalizeDownloadError(error);
|
||||||
|
if (!lastError || getDownloadFailure(message).retryable || !getDownloadFailure(lastError).retryable) lastError = message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Archiving may have completed during the failed fetch. Resolve aliases
|
// Archiving may have completed during the failed fetch. Resolve aliases
|
||||||
// again before retrying, rather than spending every attempt on the old URL.
|
// again before retrying, rather than spending every attempt on the old URL.
|
||||||
|
if (!getDownloadFailure(lastError).retryable) break;
|
||||||
if (attempt < retryCount) await sleep(350 * attempt);
|
if (attempt < retryCount) await sleep(350 * attempt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
|
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
||||||
|
|
||||||
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
|
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
|
||||||
|
|
||||||
@ -26,6 +27,8 @@ export class CanvasImageDownloadError extends Error {
|
|||||||
code: CanvasImageDownloadErrorCode;
|
code: CanvasImageDownloadErrorCode;
|
||||||
retryAfterSeconds?: number;
|
retryAfterSeconds?: number;
|
||||||
status?: number;
|
status?: number;
|
||||||
|
failure?: DownloadFailure;
|
||||||
|
requestId?: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
@ -33,6 +36,8 @@ export class CanvasImageDownloadError extends Error {
|
|||||||
code: CanvasImageDownloadErrorCode;
|
code: CanvasImageDownloadErrorCode;
|
||||||
retryAfterSeconds?: number;
|
retryAfterSeconds?: number;
|
||||||
status?: number;
|
status?: number;
|
||||||
|
failure?: DownloadFailure;
|
||||||
|
requestId?: string;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
@ -40,11 +45,14 @@ export class CanvasImageDownloadError extends Error {
|
|||||||
this.code = options.code;
|
this.code = options.code;
|
||||||
this.retryAfterSeconds = options.retryAfterSeconds;
|
this.retryAfterSeconds = options.retryAfterSeconds;
|
||||||
this.status = options.status;
|
this.status = options.status;
|
||||||
|
this.failure = options.failure;
|
||||||
|
this.requestId = options.requestId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCanvasImageDownloadErrorMessage(error: unknown) {
|
export function getCanvasImageDownloadErrorMessage(error: unknown) {
|
||||||
if (error instanceof CanvasImageDownloadError) {
|
if (error instanceof CanvasImageDownloadError) {
|
||||||
|
if (error.failure) return formatDownloadFailure(error.failure, error.requestId);
|
||||||
if (error.code === "rate_limited") {
|
if (error.code === "rate_limited") {
|
||||||
return error.retryAfterSeconds
|
return error.retryAfterSeconds
|
||||||
? `图片下载请求过于频繁,请 ${error.retryAfterSeconds} 秒后重试`
|
? `图片下载请求过于频繁,请 ${error.retryAfterSeconds} 秒后重试`
|
||||||
@ -54,10 +62,10 @@ export function getCanvasImageDownloadErrorMessage(error: unknown) {
|
|||||||
return "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
|
return "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
|
||||||
}
|
}
|
||||||
if (error.code === "preparation_timeout") {
|
if (error.code === "preparation_timeout") {
|
||||||
return "原图仍在后台准备中,请稍后再次点击下载";
|
return `原图仍在后台准备中,请稍后再次点击下载${error.requestId ? `\n下载编号:${error.requestId}` : ""}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return error instanceof Error ? error.message : "图片下载失败,请稍后重试";
|
return formatDownloadFailure(getDownloadFailure(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadCanvasImageParams = {
|
type DownloadCanvasImageParams = {
|
||||||
@ -71,6 +79,8 @@ type DownloadCanvasImageParams = {
|
|||||||
type DownloadErrorPayload = {
|
type DownloadErrorPayload = {
|
||||||
error?: string;
|
error?: string;
|
||||||
retryAfter?: number;
|
retryAfter?: number;
|
||||||
|
failure?: DownloadFailure;
|
||||||
|
requestId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_RATE_LIMIT_RETRIES = 1;
|
const MAX_RATE_LIMIT_RETRIES = 1;
|
||||||
@ -166,6 +176,63 @@ function triggerBlobDownload(blob: Blob, filename: string) {
|
|||||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerCdnDownload(result: { downloadUrl: string; filename: string; bytes?: number }, signal?: AbortSignal) {
|
||||||
|
if (typeof result.downloadUrl !== "string" || new URL(result.downloadUrl).protocol !== "https:") {
|
||||||
|
throw new Error("下载链接无效");
|
||||||
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const frame = document.createElement("iframe");
|
||||||
|
frame.hidden = true;
|
||||||
|
frame.src = result.downloadUrl;
|
||||||
|
document.body.appendChild(frame);
|
||||||
|
window.setTimeout(() => frame.remove(), 10 * 60_000);
|
||||||
|
return { filename: result.filename, bytes: result.bytes || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the source format and bytes for chat, gallery and reference downloads. */
|
||||||
|
export async function downloadOriginalImage({ url, name, signal, onProgress }: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
|
||||||
|
const source = String(url || "").trim();
|
||||||
|
if (!source) throw new Error("图片地址无效");
|
||||||
|
const proxy = buildImageProxyUrl(source, { delivery: "original" });
|
||||||
|
const filenameBase = sanitizeDownloadName(name || inferDownloadNameFromUrl(source), "image");
|
||||||
|
const target = new URL(proxy, window.location.origin);
|
||||||
|
onProgress?.({ phase: "preparing", message: "正在准备原图..." });
|
||||||
|
const checkedFetch = async (input: string) => {
|
||||||
|
let response: Response;
|
||||||
|
try { response = await fetch(input, { signal }); }
|
||||||
|
catch (error) {
|
||||||
|
if (signal?.aborted) throw error;
|
||||||
|
const failure = getDownloadFailure(error);
|
||||||
|
throw new CanvasImageDownloadError(failure.message, { code: "request_failed", failure });
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => null) as DownloadErrorPayload | null;
|
||||||
|
throw new CanvasImageDownloadError(payload?.error || "原图下载失败", { code: "request_failed",
|
||||||
|
status: response.status, failure: payload?.failure || getDownloadFailure(`HTTP ${response.status}`), requestId: payload?.requestId });
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
if (target.origin === window.location.origin && target.pathname === "/api/image-proxy") {
|
||||||
|
target.searchParams.set("download", "resolve");
|
||||||
|
target.searchParams.set("name", filenameBase);
|
||||||
|
const metadata = await checkedFetch(target.pathname + target.search);
|
||||||
|
const delivery = await metadata.json();
|
||||||
|
if (delivery.delivery === "cdn") {
|
||||||
|
const result = triggerCdnDownload(delivery, signal);
|
||||||
|
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (delivery.delivery !== "proxy") throw new Error("下载链接无效");
|
||||||
|
}
|
||||||
|
const response = await checkedFetch(proxy);
|
||||||
|
const blob = await readResponseBlobWithProgress(response, onProgress);
|
||||||
|
if (!blob.type.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
|
||||||
|
const extension = getImageDownloadExtension(blob.type);
|
||||||
|
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
|
||||||
|
triggerBlobDownload(blob, filename);
|
||||||
|
return { filename, bytes: blob.size };
|
||||||
|
}
|
||||||
|
|
||||||
function toDownloadCandidates(url: string, candidates: string[] = []) {
|
function toDownloadCandidates(url: string, candidates: string[] = []) {
|
||||||
const raw = String(url || "").trim();
|
const raw = String(url || "").trim();
|
||||||
const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim();
|
const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim();
|
||||||
@ -257,7 +324,8 @@ export async function downloadCanvasImageAsPng({
|
|||||||
let preparationPolls = 0;
|
let preparationPolls = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
response = await fetch("/api/canvas/download-image", {
|
try {
|
||||||
|
response = await fetch("/api/canvas/download-image", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -268,7 +336,12 @@ export async function downloadCanvasImageAsPng({
|
|||||||
preparation: "async",
|
preparation: "async",
|
||||||
}),
|
}),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (signal?.aborted) throw error;
|
||||||
|
const failure = getDownloadFailure(error);
|
||||||
|
throw new CanvasImageDownloadError(failure.message, { code: "request_failed", failure });
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === 202) {
|
if (response.status === 202) {
|
||||||
rateLimitRetries = 0;
|
rateLimitRetries = 0;
|
||||||
@ -278,7 +351,7 @@ export async function downloadCanvasImageAsPng({
|
|||||||
throw new CanvasImageDownloadError("下载准备状态无效", { code: "request_failed", status: 202 });
|
throw new CanvasImageDownloadError("下载准备状态无效", { code: "request_failed", status: 202 });
|
||||||
}
|
}
|
||||||
if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
|
if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
|
||||||
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202 });
|
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId });
|
||||||
}
|
}
|
||||||
onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" });
|
onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" });
|
||||||
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
|
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
|
||||||
@ -299,6 +372,8 @@ export async function downloadCanvasImageAsPng({
|
|||||||
code: "rate_limited",
|
code: "rate_limited",
|
||||||
retryAfterSeconds,
|
retryAfterSeconds,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
|
failure: errorPayload?.failure,
|
||||||
|
requestId: errorPayload?.requestId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -321,22 +396,18 @@ export async function downloadCanvasImageAsPng({
|
|||||||
{
|
{
|
||||||
code: status === 502 ? "source_unavailable" : "request_failed",
|
code: status === 502 ? "source_unavailable" : "request_failed",
|
||||||
status,
|
status,
|
||||||
|
failure: errorPayload?.failure,
|
||||||
|
requestId: errorPayload?.requestId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.headers.get("content-type")?.includes("application/json")) {
|
if (response.headers.get("content-type")?.includes("application/json")) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.delivery !== "cdn" || typeof result.downloadUrl !== "string"
|
if (result.delivery !== "cdn") throw new Error("下载链接无效");
|
||||||
|| new URL(result.downloadUrl).protocol !== "https:") throw new Error("下载链接无效");
|
const downloaded = triggerCdnDownload(result, signal);
|
||||||
if (signal?.aborted) throw signal.reason || new DOMException("已取消下载", "AbortError");
|
|
||||||
const frame = document.createElement("iframe");
|
|
||||||
frame.hidden = true;
|
|
||||||
frame.src = result.downloadUrl;
|
|
||||||
document.body.appendChild(frame);
|
|
||||||
window.setTimeout(() => frame.remove(), 10 * 60_000);
|
|
||||||
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
|
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
|
||||||
return { filename: result.filename, bytes: result.bytes || 0 };
|
return downloaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename =
|
const filename =
|
||||||
|
|||||||
@ -1,11 +1,20 @@
|
|||||||
/** Serialize local saves so each request reads the revision returned by the last. */
|
/** Serialize local saves so each request reads the revision returned by the last. */
|
||||||
export function createCanvasSaveQueue() {
|
export function createCanvasSaveQueue() {
|
||||||
let pending: Promise<unknown> = Promise.resolve();
|
let pending: Promise<unknown> = Promise.resolve();
|
||||||
return {
|
const queue = {
|
||||||
enqueue<T>(save: () => Promise<T>): Promise<T> {
|
enqueue<T>(save: () => Promise<T>): Promise<T> {
|
||||||
const next = pending.then(save, save);
|
const next = pending.then(save, save);
|
||||||
pending = next.catch(() => undefined);
|
pending = next.catch(() => undefined);
|
||||||
return next;
|
return next;
|
||||||
},
|
},
|
||||||
|
enqueueOperation(save: () => Promise<boolean>, operation: () => Promise<void>) {
|
||||||
|
return queue.enqueue(async () => {
|
||||||
|
if (!(await save())) {
|
||||||
|
throw new Error("画布尚未保存成功,请确认网络或画布同步状态后重试");
|
||||||
|
}
|
||||||
|
await operation();
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
return queue;
|
||||||
}
|
}
|
||||||
|
|||||||
24
lib/client/canvas-site-settings.ts
Normal file
24
lib/client/canvas-site-settings.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
const settingsCache = new Map<string, { etag: string; data: unknown }>();
|
||||||
|
|
||||||
|
export async function fetchCanvasSiteSettings(url: string) {
|
||||||
|
const cached = settingsCache.get(url);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
url === "/api/site-settings?scope=agent-canvas" ? 30_000 : 10_000
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: cached ? { "If-None-Match": cached.etag } : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (response.status === 304 && cached) return cached.data;
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch canvas site settings");
|
||||||
|
const data = await response.json();
|
||||||
|
const etag = response.headers.get("ETag");
|
||||||
|
if (etag) settingsCache.set(url, { etag, data });
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
lib/client/canvas-toolbar-capability.ts
Normal file
66
lib/client/canvas-toolbar-capability.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
|
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
|
||||||
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
|
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
||||||
|
|
||||||
|
async function requestCapabilityResult(command: CanvasCapabilityClientCommand) {
|
||||||
|
const endpoint = `/api/canvas/${command.projectId}/capabilities`;
|
||||||
|
let payload: any = null;
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(command),
|
||||||
|
});
|
||||||
|
payload = await response.json().catch(() => null);
|
||||||
|
if (payload?.success === false || (!response.ok && ![502, 503, 504, 524].includes(response.status))) {
|
||||||
|
throw Object.assign(new Error(payload?.error || "图片处理请求未通过"), { confirmedRejection: true });
|
||||||
|
}
|
||||||
|
if (!response.ok) payload = null;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { confirmedRejection?: boolean })?.confirmedRejection) throw error;
|
||||||
|
}
|
||||||
|
const deadline = Date.now() + 20 * 60 * 1000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const task = payload?.task;
|
||||||
|
if (task?.status === "succeeded") return payload;
|
||||||
|
if (task?.status === "failed") throw new Error(task.error || "图片处理失败,请联系管理员核对任务结果");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${endpoint}?requestId=${encodeURIComponent(command.requestId)}`, { cache: "no-store", signal: AbortSignal.timeout(12_000) });
|
||||||
|
payload = response.ok ? await response.json() : null;
|
||||||
|
} catch {
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(formatRequestFailure(getRequestFailureDetails({ providerOutcomeUnknown: true }), command.requestId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs after the editor has saved and while its save queue is reserved. */
|
||||||
|
export async function executeCanvasToolbarCapability(
|
||||||
|
command: CanvasCapabilityClientCommand,
|
||||||
|
) {
|
||||||
|
const projectId = command.projectId!;
|
||||||
|
const before = useCanvasStore.getState();
|
||||||
|
if (before.projectId !== projectId) throw new Error("画布已切换,请重新选择图片");
|
||||||
|
const base = JSON.stringify({ canvasData: before.getCanvasState() });
|
||||||
|
await requestCapabilityResult(command);
|
||||||
|
|
||||||
|
const projectResponse = await fetch(`/api/canvas/${projectId}`);
|
||||||
|
const result = await projectResponse.json();
|
||||||
|
if (!projectResponse.ok || !result.success || !result.data?.canvasData) {
|
||||||
|
throw new Error(result.error || "结果已生成,但画布同步失败");
|
||||||
|
}
|
||||||
|
const current = useCanvasStore.getState();
|
||||||
|
if (current.projectId !== projectId) return;
|
||||||
|
const merged = mergeCanvasSnapshots(
|
||||||
|
base,
|
||||||
|
JSON.stringify({ canvasData: current.getCanvasState() }),
|
||||||
|
JSON.stringify({ canvasData: result.data.canvasData }),
|
||||||
|
);
|
||||||
|
if (merged.conflicts.length > 0) {
|
||||||
|
throw new Error("结果已生成,但画布有冲突;请保留本地改动后刷新查看结果");
|
||||||
|
}
|
||||||
|
current.loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
||||||
|
current.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
||||||
|
}
|
||||||
@ -1,4 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
|
||||||
|
import { resolveSiteBranding } from "@/lib/site-branding";
|
||||||
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -4554,7 +4556,7 @@ export async function getEnabledAiModelsWithProvider(
|
|||||||
return 3;
|
return 3;
|
||||||
};
|
};
|
||||||
|
|
||||||
return [...models].sort((left, right) => {
|
return models.filter(({ model }) => !model.supportsImageGen || !isImageModelBalanceBlocked(model)).sort((left, right) => {
|
||||||
const byHealth =
|
const byHealth =
|
||||||
healthPriority(left.model.healthStatus) -
|
healthPriority(left.model.healthStatus) -
|
||||||
healthPriority(right.model.healthStatus);
|
healthPriority(right.model.healthStatus);
|
||||||
@ -4744,9 +4746,9 @@ export async function getSiteSettings() {
|
|||||||
? videoCanvasEnabledSetting.value !== false
|
? videoCanvasEnabledSetting.value !== false
|
||||||
: videoCanvasSidebarEnabledSetting?.value !== false;
|
: videoCanvasSidebarEnabledSetting?.value !== false;
|
||||||
|
|
||||||
return { ...(settings || defaultSettings), videoCanvasEnabled };
|
return resolveSiteBranding({ ...(settings || defaultSettings), videoCanvasEnabled });
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return defaultSettings;
|
return resolveSiteBranding(defaultSettings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,8 @@ export function getImageDownloadExtension(contentType: string) {
|
|||||||
"image/avif": "avif",
|
"image/avif": "avif",
|
||||||
"image/gif": "gif",
|
"image/gif": "gif",
|
||||||
"image/svg+xml": "svg",
|
"image/svg+xml": "svg",
|
||||||
|
"image/bmp": "bmp",
|
||||||
|
"image/tiff": "tiff",
|
||||||
};
|
};
|
||||||
return extensions[mime] || "";
|
return extensions[mime] || "";
|
||||||
}
|
}
|
||||||
@ -183,8 +185,8 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
|
|||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
// An original download must go through the same-origin byte-preserving
|
// The same-origin endpoint can resolve a native CDN download without relying
|
||||||
// proxy even for trusted storage hosts: a direct fetch may fail CORS.
|
// on cross-origin fetch, while retaining a byte-preserving proxy fallback.
|
||||||
if (options.delivery === "original") {
|
if (options.delivery === "original") {
|
||||||
const params = new URLSearchParams({ url: source, original: "1" });
|
const params = new URLSearchParams({ url: source, original: "1" });
|
||||||
return `${IMAGE_PROXY_PATH}?${params.toString()}`;
|
return `${IMAGE_PROXY_PATH}?${params.toString()}`;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { preparePendingImageDownloads } from "@/lib/storage/image-download-preparation";
|
import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation";
|
||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||||
|
|
||||||
import { archiveTemporaryImage } from "@/lib/storage/image-archive";
|
import { archiveTemporaryImage } from "@/lib/storage/image-archive";
|
||||||
import {
|
import {
|
||||||
@ -26,10 +27,12 @@ function getBatchSize() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runImageArchiveJob() {
|
export async function runImageArchiveJob() {
|
||||||
|
return trackRuntimeWork(async () => {
|
||||||
const batchSize = getBatchSize();
|
const batchSize = getBatchSize();
|
||||||
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
|
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
|
||||||
|
|
||||||
await recoverStaleImageArchiveTasks();
|
await recoverStaleImageArchiveTasks();
|
||||||
|
scheduleImageDownloadPreparation();
|
||||||
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize });
|
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize });
|
||||||
let storeSucceeded = 0;
|
let storeSucceeded = 0;
|
||||||
let storeFailed = 0;
|
let storeFailed = 0;
|
||||||
@ -43,6 +46,7 @@ export async function runImageArchiveJob() {
|
|||||||
id: task.id,
|
id: task.id,
|
||||||
targetUrl: archived.url,
|
targetUrl: archived.url,
|
||||||
});
|
});
|
||||||
|
scheduleImageDownloadPreparation(task.id);
|
||||||
storeSucceeded += 1;
|
storeSucceeded += 1;
|
||||||
console.log("[ImageArchiveJob] stored", {
|
console.log("[ImageArchiveJob] stored", {
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
@ -106,7 +110,6 @@ export async function runImageArchiveJob() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadTasks = await preparePendingImageDownloads();
|
|
||||||
const stats = await getImageArchiveTaskStats();
|
const stats = await getImageArchiveTaskStats();
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@ -122,7 +125,8 @@ export async function runImageArchiveJob() {
|
|||||||
failed: replaceFailed,
|
failed: replaceFailed,
|
||||||
},
|
},
|
||||||
stats,
|
stats,
|
||||||
downloadTasks,
|
downloadTasks: 0,
|
||||||
pendingDownloads: await hasPendingImageDownloads(),
|
pendingDownloads: await hasPendingImageDownloads(),
|
||||||
};
|
};
|
||||||
|
}, { durable: true });
|
||||||
}
|
}
|
||||||
|
|||||||
24
lib/observability/active-work.ts
Normal file
24
lib/observability/active-work.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { unlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const state = globalThis as typeof globalThis & { __aiChatbotActiveWork?: number; __aiChatbotDurableWork?: number };
|
||||||
|
const path = join(process.platform === "win32" ? tmpdir() : "/tmp", `ai-chatbot-active-work-${process.pid}.json`);
|
||||||
|
|
||||||
|
function update(delta: number, durable: boolean) {
|
||||||
|
state.__aiChatbotActiveWork = (state.__aiChatbotActiveWork || 0) + delta;
|
||||||
|
if (durable) state.__aiChatbotDurableWork = (state.__aiChatbotDurableWork || 0) + delta;
|
||||||
|
try {
|
||||||
|
writeFileSync(path, JSON.stringify({ pid: process.pid, active: state.__aiChatbotActiveWork, durable: state.__aiChatbotDurableWork || 0 }), { mode: 0o600 });
|
||||||
|
} catch (error) {
|
||||||
|
try { unlinkSync(path); } catch {}
|
||||||
|
console.warn("[Active Work] Failed to record running work:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Includes generation that continues after the client connection closes. */
|
||||||
|
export async function trackRuntimeWork<T>(execute: () => T | Promise<T>, options: { durable?: boolean } = {}): Promise<T> {
|
||||||
|
update(1, options.durable === true);
|
||||||
|
try { return await execute(); } finally { update(-1, options.durable === true); }
|
||||||
|
}
|
||||||
@ -175,6 +175,20 @@ function getRequestErrorText(error: unknown) {
|
|||||||
return candidates.join(" ").slice(0, 4000);
|
return candidates.join(" ").slice(0, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGatewayErrorPageMessage(content: string): string | null {
|
||||||
|
if (!/^\s*(?:<!doctype\s+html\b|<html\b)/i.test(content)) return null;
|
||||||
|
if (!/\bcf-error-details\b/i.test(content)) return null;
|
||||||
|
if (/\b524\b/.test(content)) {
|
||||||
|
return "模型服务响应超时(HTTP 524),生成结果尚未确认。请先查看历史记录,避免重复提交。";
|
||||||
|
}
|
||||||
|
return "模型服务暂时不可用,请稍后再试或联系管理员。";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGatewayResponseTimeout(error: unknown): boolean {
|
||||||
|
return getRequestErrorStatus(error) === 524 ||
|
||||||
|
/\bHTTP\s*524\b|\b524\s*:\s*A timeout occurred\b|Error code\s*524\b/i.test(getRequestErrorText(error));
|
||||||
|
}
|
||||||
|
|
||||||
export function isProviderBalanceError(error: unknown): boolean {
|
export function isProviderBalanceError(error: unknown): boolean {
|
||||||
const message = getRequestErrorText(error);
|
const message = getRequestErrorText(error);
|
||||||
return PROVIDER_BALANCE_PATTERNS.some((pattern) => pattern.test(message));
|
return PROVIDER_BALANCE_PATTERNS.some((pattern) => pattern.test(message));
|
||||||
@ -199,17 +213,80 @@ export function isRateLimitError(error: unknown): boolean {
|
|||||||
RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(message));
|
RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RequestFailureDetails = {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
recommendation: string;
|
||||||
|
outcome: "failed" | "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getRequestFailureDetails(
|
||||||
|
error: unknown,
|
||||||
|
fallbackMessage = "请求未能完成",
|
||||||
|
): RequestFailureDetails {
|
||||||
|
const text = getRequestErrorText(error);
|
||||||
|
const status = getRequestErrorStatus(error);
|
||||||
|
const detail = (code: string, message: string, recommendation: string, outcome: "failed" | "unknown" = "failed") =>
|
||||||
|
({ code, message, recommendation, outcome });
|
||||||
|
if (isGatewayResponseTimeout(error)) {
|
||||||
|
return detail("RESULT_UNCONFIRMED", "模型服务响应超时(HTTP 524),生成结果尚未确认。", "请先查看历史记录,避免重复提交。", "unknown");
|
||||||
|
}
|
||||||
|
if ((isRecord(error) && error.providerOutcomeUnknown === true) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running/i.test(text)) {
|
||||||
|
return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
|
||||||
|
}
|
||||||
|
if (isProviderBalanceError(error)) {
|
||||||
|
return detail("PROVIDER_BALANCE_EXHAUSTED", "图片服务商线路余额或额度不足,与您的积分余额无关。", "请切换可用模型或联系管理员处理线路额度。");
|
||||||
|
}
|
||||||
|
if (isRateLimitError(error)) {
|
||||||
|
return detail("RATE_LIMITED", "当前请求过于频繁。", "请稍后重试,避免连续点击;模型线路限流时可切换其他可用模型。");
|
||||||
|
}
|
||||||
|
if (/content.?polic|moderation|safety|policy_rejected|内容(?:敏感|审核|限制)|不能生成像那样|不能生成那样|违规/i.test(text)) {
|
||||||
|
return detail("CONTENT_POLICY_REJECTED", "模型服务因内容审核限制拒绝了本次请求。", "请调整描述或参考图中的受限内容后再提交;重复提交相同内容通常无法解决。");
|
||||||
|
}
|
||||||
|
if (/上传到存储桶失败|存储.*失败|归档.*失败|archive.*failed|storage.*failed/i.test(text)) {
|
||||||
|
return detail("RESULT_STORAGE_FAILED", "处理结果保存失败,生成结果可能已经存在。", "请保留当前页面并联系管理员提供请求编号,先不要重复生成。", "unknown");
|
||||||
|
}
|
||||||
|
if (/failed to download (?:the )?image url|failed to download.*reference.*image/i.test(text)) {
|
||||||
|
return detail("PROVIDER_REFERENCE_FETCH_FAILED", "模型服务未能读取参考图。", "请重新上传参考图或切换模型后再提交;若原图仍可打开,请联系管理员检查模型取图线路,并提供请求编号。");
|
||||||
|
}
|
||||||
|
if (/参考图.*(?:下载失败|不可|失效)|无法获取图片|图片(?:源文件|链接).*不可|获取图片.*失败|get image url failed/i.test(text)) {
|
||||||
|
return detail("SOURCE_IMAGE_UNAVAILABLE", "请求中的图片无法读取,链接可能已失效或没有访问权限。", "请重新上传原图后再提交;若原图仍可打开,请联系管理员检查取图服务。");
|
||||||
|
}
|
||||||
|
if (/版本冲突|画布.*(?:更新|过期)|CANVAS_REVISION_CONFLICT|stale.canvas/i.test(text)) {
|
||||||
|
return detail("CANVAS_REVISION_CONFLICT", "画布已更新,本次保存与当前版本冲突。", "请先重新同步画布,再继续操作,避免覆盖较新的修改。");
|
||||||
|
}
|
||||||
|
if (/no_eligible_route|service_unavailable|service_rejected|channel.*unavailable|暂无可用服务|未配置.*模型|没有可用.*(?:模型|通道)|通道.*(?:不可用|未通过)|鉴权/i.test(text)) {
|
||||||
|
return detail("PROVIDER_UNAVAILABLE", "当前模型服务或通道暂不可用。", "请切换其他可用模型;若多个模型都失败,请联系管理员检查服务配置。");
|
||||||
|
}
|
||||||
|
if (/neither a task ID nor output URLs|已完成.*未取回|completed without.*output/i.test(text)) {
|
||||||
|
return detail("RESULT_UNCONFIRMED", "模型服务没有返回可读取的任务结果,生成状态尚未确认。", "请保留当前任务并联系管理员核对,避免重复生成。", "unknown");
|
||||||
|
}
|
||||||
|
if (/(?:抠图|去水印|图片增强|图片|扩图)处理失败/.test(text)) {
|
||||||
|
return detail("PROCESSING_FAILED", "图片处理服务执行失败,未返回具体原因。", "请联系管理员并提供请求编号,先核对原任务结果,避免重复处理。", "unknown");
|
||||||
|
}
|
||||||
|
if (/service_error|upstream_server_error|excessive system load|generate(?: image)? failed/i.test(text)) {
|
||||||
|
return detail("PROVIDER_EXECUTION_FAILED", "模型服务未能完成本次处理。", "请稍后重试;若连续失败,请切换模型或联系管理员,并提供请求编号。");
|
||||||
|
}
|
||||||
|
if (/timeout|timed out|超时|fetch failed|ECONNRESET|ECONNREFUSED|failed to fetch|networkerror|load failed|network request failed/i.test(text) || [502, 504].includes(status || 0)) {
|
||||||
|
return detail("RESULT_UNCONFIRMED", "请求连接中断或等待超时,任务结果尚未确认。", "请先查看原任务或历史记录,避免重复提交;持续没有结果时联系管理员。", "unknown");
|
||||||
|
}
|
||||||
|
if (/generate(?: image)? failed|generation failed|upstream_server_error|service_error|excessive system load|未返回可用图片|任务FAILED/i.test(text) || (status !== null && status >= 500)) {
|
||||||
|
return detail("PROVIDER_EXECUTION_FAILED", "模型服务未能完成本次处理。", "请稍后重试;若连续失败,请切换模型或联系管理员,并提供请求编号。");
|
||||||
|
}
|
||||||
|
const message = getRequestErrorMessage(error);
|
||||||
|
return detail("REQUEST_FAILED", message && !/^\s*</.test(message) ? message : fallbackMessage, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRequestFailure(details: RequestFailureDetails, requestId?: string) {
|
||||||
|
const text = [details.message, details.recommendation].filter(Boolean).join("");
|
||||||
|
return requestId ? `${text}\n\n请求编号:${requestId}` : text;
|
||||||
|
}
|
||||||
|
|
||||||
export function getUserFacingImageGenerationErrorMessage(
|
export function getUserFacingImageGenerationErrorMessage(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
fallbackMessage = "图片生成失败,请稍后重试",
|
fallbackMessage = "图片生成失败,请稍后重试",
|
||||||
) {
|
) {
|
||||||
if (isProviderBalanceError(error)) {
|
return formatRequestFailure(getRequestFailureDetails(error, fallbackMessage));
|
||||||
return "图片服务商线路余额或额度不足,请切换模型或联系管理员;与您的积分余额无关。";
|
|
||||||
}
|
|
||||||
if (isRateLimitError(error)) {
|
|
||||||
return "图片服务请求过于频繁,请稍后重试或切换模型。";
|
|
||||||
}
|
|
||||||
return getRequestErrorMessage(error) || fallbackMessage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFriendlyRateLimitMessage(
|
export function getFriendlyRateLimitMessage(
|
||||||
@ -227,13 +304,12 @@ export function getUserFacingRequestErrorMessage(
|
|||||||
rateLimitMessage?: string;
|
rateLimitMessage?: string;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
if (isProviderBalanceError(error)) {
|
|
||||||
return "模型服务商线路余额或额度不足,请切换模型或联系管理员。";
|
|
||||||
}
|
|
||||||
if (isRateLimitError(error)) {
|
if (isRateLimitError(error)) {
|
||||||
return getFriendlyRateLimitMessage(options?.rateLimitMessage);
|
return getFriendlyRateLimitMessage(options?.rateLimitMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const failure = getRequestFailureDetails(error, fallbackMessage);
|
||||||
|
if (failure.code !== "REQUEST_FAILED") return formatRequestFailure(failure);
|
||||||
const message = getRequestErrorMessage(error);
|
const message = getRequestErrorMessage(error);
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return fallbackMessage;
|
return fallbackMessage;
|
||||||
|
|||||||
13
lib/site-branding.ts
Normal file
13
lib/site-branding.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { isRetiredStorageUrl } from '@/lib/storage/retired-storage';
|
||||||
|
|
||||||
|
export const DEFAULT_SITE_LOGO = '/brand/logo-black.png';
|
||||||
|
export const DEFAULT_SITE_LOGO_DARK = '/brand/logo-white.svg';
|
||||||
|
|
||||||
|
export function resolveSiteBranding<T extends { siteLogo?: string | null; siteLogoDark?: string | null; favicon?: string | null }>(settings: T) {
|
||||||
|
return {
|
||||||
|
...settings,
|
||||||
|
siteLogo: !settings.siteLogo || isRetiredStorageUrl(settings.siteLogo) ? DEFAULT_SITE_LOGO : settings.siteLogo,
|
||||||
|
siteLogoDark: !settings.siteLogoDark || isRetiredStorageUrl(settings.siteLogoDark) ? DEFAULT_SITE_LOGO_DARK : settings.siteLogoDark,
|
||||||
|
favicon: !settings.favicon || isRetiredStorageUrl(settings.favicon) ? DEFAULT_SITE_LOGO : settings.favicon,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ const PUBLIC_SITE_SETTINGS_CACHE_KEY = Symbol.for(
|
|||||||
type PublicSiteSettingsCacheEntry = {
|
type PublicSiteSettingsCacheEntry = {
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
etag?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getPublicSiteSettingsCacheStore() {
|
function getPublicSiteSettingsCacheStore() {
|
||||||
@ -34,10 +35,12 @@ export function setScopedSiteSettingsCache(
|
|||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
ttlMs: number = PUBLIC_SITE_SETTINGS_CACHE_TTL_MS,
|
ttlMs: number = PUBLIC_SITE_SETTINGS_CACHE_TTL_MS,
|
||||||
|
etag?: string,
|
||||||
) {
|
) {
|
||||||
getPublicSiteSettingsCacheStore().set(cacheKey, {
|
getPublicSiteSettingsCacheStore().set(cacheKey, {
|
||||||
data,
|
data,
|
||||||
expiresAt: Date.now() + ttlMs,
|
expiresAt: Date.now() + ttlMs,
|
||||||
|
etag,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,17 +10,21 @@ export async function resolveCanvasCdnDownload(urls: string[], filename: string)
|
|||||||
})));
|
})));
|
||||||
const task = await findImageArchiveDownloadTask(sources, true);
|
const task = await findImageArchiveDownloadTask(sources, true);
|
||||||
const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset;
|
const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset;
|
||||||
if (asset?.status !== "ready" || !asset.url || asset.contentType !== "image/png") return { task, direct: null };
|
if (!task) return { task, direct: null, canPrepareDirect: false };
|
||||||
const config = await getDefaultStorageConfig();
|
const config = await getDefaultStorageConfig();
|
||||||
if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null };
|
if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null, canPrepareDirect: false };
|
||||||
const url = new URL(asset.url);
|
const ready = asset?.status === "ready" && asset.url && asset.contentType === "image/png";
|
||||||
|
const source = ready ? asset.url : task.targetUrl;
|
||||||
|
if (!source) return { task, direct: null, canPrepareDirect: false };
|
||||||
|
const url = new URL(source);
|
||||||
const base = new URL(config.publicUrl);
|
const base = new URL(config.publicUrl);
|
||||||
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
|
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
|
||||||
if (origin && url.origin === new URL(origin).origin) url.host = base.host;
|
if (origin && url.origin === new URL(origin).origin) url.host = base.host;
|
||||||
if (base.protocol !== "https:" || url.origin !== base.origin
|
if (base.protocol !== "https:" || url.origin !== base.origin
|
||||||
|| !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null };
|
|| !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null, canPrepareDirect: false };
|
||||||
|
if (!ready) return { task, direct: null, canPrepareDirect: true };
|
||||||
// attname is interpreted by Kodo/CDN as Content-Disposition, including Chinese
|
// attname is interpreted by Kodo/CDN as Content-Disposition, including Chinese
|
||||||
// filenames. Keep all processing/auth parameters intact for correct cache keys.
|
// filenames. Keep all processing/auth parameters intact for correct cache keys.
|
||||||
url.searchParams.set("attname", filename);
|
url.searchParams.set("attname", filename);
|
||||||
return { task, direct: { delivery: "cdn", downloadUrl: url.toString(), filename, bytes: asset.bytes || 0 } };
|
return { task, direct: { delivery: "cdn", downloadUrl: url.toString(), filename, bytes: asset?.bytes || 0 }, canPrepareDirect: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isRetiredStorageUrl } from "./retired-storage";
|
||||||
|
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
@ -38,7 +39,7 @@ export async function enqueueImageArchiveTask(params: {
|
|||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
}) {
|
}) {
|
||||||
const sourceUrl = String(params.sourceUrl || "").trim();
|
const sourceUrl = String(params.sourceUrl || "").trim();
|
||||||
if (!isGeneratedImageSource(sourceUrl)) return null;
|
if (!isGeneratedImageSource(sourceUrl) || isRetiredStorageUrl(sourceUrl)) return null;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const archiveAfterAt = params.archiveAfterAt || now;
|
const archiveAfterAt = params.archiveAfterAt || now;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { fetchImageBytes, cacheImageBytes } from "@/lib/storage/image-byte-cache";
|
import { fetchImageBytes, cacheImageBytes } from "@/lib/storage/image-byte-cache";
|
||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { isGeneratedImageSource } from "./image-archive-urls";
|
import { isGeneratedImageSource } from "./image-archive-urls";
|
||||||
@ -170,6 +171,7 @@ function toArrayBuffer(buffer: Buffer): ArrayBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadTemporaryImage(params: { sourceUrl: string; retries?: number }) {
|
export async function downloadTemporaryImage(params: { sourceUrl: string; retries?: number }) {
|
||||||
|
if (isRetiredStorageUrl(params.sourceUrl)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
|
||||||
const retries = Math.max(0, Math.min(5, params.retries ?? 2));
|
const retries = Math.max(0, Math.min(5, params.retries ?? 2));
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { mkdir, readFile, writeFile, rename, readdir, stat, statfs, unlink } from "node:fs/promises";
|
import { mkdir, readFile, writeFile, rename, readdir, stat, statfs, unlink } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
@ -80,7 +81,8 @@ function isMediaResponse(data: ImageBytes) {
|
|||||||
if (/^<\?xml/i.test(prefix) && !/<svg[\s>]/i.test(prefix)) return false;
|
if (/^<\?xml/i.test(prefix) && !/<svg[\s>]/i.test(prefix)) return false;
|
||||||
return data.buffer.length > 0;
|
return data.buffer.length > 0;
|
||||||
}
|
}
|
||||||
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string } = {}) {
|
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string; signal?: AbortSignal } = {}) {
|
||||||
|
if (isRetiredStorageUrl(url)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
|
||||||
return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => {
|
return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => {
|
||||||
if (url.startsWith("data:")) {
|
if (url.startsWith("data:")) {
|
||||||
const match = url.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
|
const match = url.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
|
||||||
@ -90,7 +92,9 @@ export async function fetchImageBytes(url: string, options: { headers?: HeadersI
|
|||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: options.headers, cache: "no-store", redirect: "follow",
|
headers: options.headers, cache: "no-store", redirect: "follow",
|
||||||
// The deadline covers the body as well as the response headers.
|
// The deadline covers the body as well as the response headers.
|
||||||
signal: AbortSignal.timeout(options.timeoutMs || 60_000),
|
signal: options.signal
|
||||||
|
? AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs || 60_000)])
|
||||||
|
: AbortSignal.timeout(options.timeoutMs || 60_000),
|
||||||
});
|
});
|
||||||
if (!response.ok) { await response.body?.cancel(); throw new Error(`HTTP ${response.status}`); }
|
if (!response.ok) { await response.body?.cancel(); throw new Error(`HTTP ${response.status}`); }
|
||||||
const buffer = Buffer.from(await response.arrayBuffer());
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
|||||||
@ -2,14 +2,21 @@ import "server-only";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { fetchImageBytes, cacheImageBytes } from "./image-byte-cache";
|
import { fetchImageBytes, cacheImageBytes } from "./image-byte-cache";
|
||||||
import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
||||||
import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks } from "./image-archive-db";
|
import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks, hasPendingImageDownloads } from "./image-archive-db";
|
||||||
import { uploadFile } from "./service";
|
import { uploadFile } from "./service";
|
||||||
|
import { getDownloadFailure } from "@/lib/canvas/download-error";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||||
|
|
||||||
|
const logger = createLogger("canvas.download");
|
||||||
|
|
||||||
export async function prepareImageArchiveDownload(id: string) {
|
export async function prepareImageArchiveDownload(id: string) {
|
||||||
const task = await claimImageDownloadTask(id);
|
const task = await claimImageDownloadTask(id);
|
||||||
if (!task?.targetUrl) return;
|
if (!task?.targetUrl) return;
|
||||||
|
let phase: "read" | "convert" | "store" = "read";
|
||||||
try {
|
try {
|
||||||
const source = await fetchImageBytes(task.targetUrl);
|
const source = await fetchImageBytes(task.targetUrl);
|
||||||
|
phase = "convert";
|
||||||
const asset = await normalizeCanvasDownloadAsset({
|
const asset = await normalizeCanvasDownloadAsset({
|
||||||
item: { url: task.targetUrl }, index: 0, bytes: source.buffer,
|
item: { url: task.targetUrl }, index: 0, bytes: source.buffer,
|
||||||
contentType: source.contentType, includeSequencePrefix: false,
|
contentType: source.contentType, includeSequencePrefix: false,
|
||||||
@ -17,15 +24,22 @@ export async function prepareImageArchiveDownload(id: string) {
|
|||||||
if (!asset.ok) throw new Error(asset.error);
|
if (!asset.ok) throw new Error(asset.error);
|
||||||
// An already compatible PNG shares the original object; other formats get
|
// An already compatible PNG shares the original object; other formats get
|
||||||
// one prepared PNG, without changing the original dimensions or transparency.
|
// one prepared PNG, without changing the original dimensions or transparency.
|
||||||
|
phase = "store";
|
||||||
const url = source.buffer.equals(asset.bytes) && asset.contentType === "image/png"
|
const url = source.buffer.equals(asset.bytes) && asset.contentType === "image/png"
|
||||||
? task.targetUrl
|
? task.targetUrl
|
||||||
: (await uploadFile(`canvas-download-${randomUUID()}.${asset.filename.split(".").pop() || "png"}`, Uint8Array.from(asset.bytes).buffer)).url;
|
: (await uploadFile(`canvas-download-${randomUUID()}.${asset.filename.split(".").pop() || "png"}`, Uint8Array.from(asset.bytes).buffer)).url;
|
||||||
await saveImageDownloadAsset(id, { status: "ready", url, bytes: asset.bytes.length,
|
await saveImageDownloadAsset(id, { status: "ready", url, bytes: asset.bytes.length,
|
||||||
contentType: asset.contentType, preparedAt: new Date().toISOString() });
|
contentType: asset.contentType, preparedAt: new Date().toISOString() });
|
||||||
await cacheImageBytes(`source::${url}`, { buffer: asset.bytes, contentType: asset.contentType });
|
await cacheImageBytes(`source::${url}`, { buffer: asset.bytes, contentType: asset.contentType });
|
||||||
} catch {
|
} catch (error) {
|
||||||
const attempts = Number((task.metadata as { downloadAsset?: { attempts?: number } } | null)?.downloadAsset?.attempts || 1);
|
const attempts = Number((task.metadata as { downloadAsset?: { attempts?: number } } | null)?.downloadAsset?.attempts || 1);
|
||||||
await saveImageDownloadAsset(id, { status: attempts >= 12 ? "abandoned" : "failed", attempts, retryAt: new Date(Date.now() + 10 * 60_000).toISOString() });
|
const failure = phase === "store"
|
||||||
|
? getDownloadFailure("STORAGE_WRITE_FAILED")
|
||||||
|
: phase === "convert" && getDownloadFailure(error).code === "DOWNLOAD_FAILED"
|
||||||
|
? getDownloadFailure("图片转换失败") : getDownloadFailure(error);
|
||||||
|
logger.warn("archive_download_preparation_failed", error, { requestId: id, code: failure.code });
|
||||||
|
await saveImageDownloadAsset(id, { status: !failure.retryable || attempts >= 12 ? "abandoned" : "failed", attempts,
|
||||||
|
failure, retryAt: new Date(Date.now() + 60_000).toISOString() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export async function preparePendingImageDownloads() {
|
export async function preparePendingImageDownloads() {
|
||||||
@ -36,3 +50,45 @@ export async function preparePendingImageDownloads() {
|
|||||||
}));
|
}));
|
||||||
return tasks.length;
|
return tasks.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const state = globalThis as typeof globalThis & {
|
||||||
|
__imageDownloadWorker?: { running: boolean; scanPending: boolean; priority: Set<string>; retry?: ReturnType<typeof setTimeout> };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Download preparation runs independently of the archive URL replacement batch. */
|
||||||
|
export function scheduleImageDownloadPreparation(id?: string) {
|
||||||
|
const worker = state.__imageDownloadWorker ??= { running: false, scanPending: false, priority: new Set() };
|
||||||
|
if (id) worker.priority.add(id);
|
||||||
|
else worker.scanPending = true;
|
||||||
|
if (worker.running) return;
|
||||||
|
if (worker.retry) { clearTimeout(worker.retry); worker.retry = undefined; }
|
||||||
|
worker.running = true;
|
||||||
|
setImmediate(() => {
|
||||||
|
void trackRuntimeWork(async () => {
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const priority = [...worker.priority].slice(0, 2);
|
||||||
|
for (const taskId of priority) worker.priority.delete(taskId);
|
||||||
|
if (priority.length) {
|
||||||
|
await Promise.all(priority.map(prepareImageArchiveDownload));
|
||||||
|
} else if (!worker.scanPending || await preparePendingImageDownloads() === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("download_worker_failed", error);
|
||||||
|
} finally {
|
||||||
|
worker.running = false;
|
||||||
|
// Eligible retry times live in the database, including interrupted leases.
|
||||||
|
if (worker.priority.size) {
|
||||||
|
scheduleImageDownloadPreparation([...worker.priority][0]);
|
||||||
|
} else if (worker.scanPending && await hasPendingImageDownloads().catch(() => true)) {
|
||||||
|
worker.retry = setTimeout(() => scheduleImageDownloadPreparation(), 60_000);
|
||||||
|
worker.retry.unref?.();
|
||||||
|
} else {
|
||||||
|
worker.scanPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isRetiredStorageUrl } from "./retired-storage";
|
||||||
|
|
||||||
import { and, asc, desc, eq, inArray, lte, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, inArray, lte, sql } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@ -16,6 +17,8 @@ function isUsableS3Config(config: StorageConfig | null | undefined): config is S
|
|||||||
config &&
|
config &&
|
||||||
config.type === "s3" &&
|
config.type === "s3" &&
|
||||||
config.isEnabled &&
|
config.isEnabled &&
|
||||||
|
!isRetiredStorageUrl(config.endpoint) &&
|
||||||
|
!isRetiredStorageUrl(config.publicUrl) &&
|
||||||
String(config.endpoint || "").trim() &&
|
String(config.endpoint || "").trim() &&
|
||||||
String(config.bucket || "").trim() &&
|
String(config.bucket || "").trim() &&
|
||||||
String(config.accessKeyId || "").trim() &&
|
String(config.accessKeyId || "").trim() &&
|
||||||
@ -80,7 +83,7 @@ export async function enqueueStorageMigrationTask({
|
|||||||
migrateAfterAt: Date;
|
migrateAfterAt: Date;
|
||||||
cleanupAfterAt: Date;
|
cleanupAfterAt: Date;
|
||||||
}) {
|
}) {
|
||||||
if (!sourceUrl || !sourcePath || !sourceStorageConfigId) return;
|
if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl)) return;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(storageMigrationTask)
|
.insert(storageMigrationTask)
|
||||||
|
|||||||
45
lib/storage/original-image-download.ts
Normal file
45
lib/storage/original-image-download.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { getDefaultStorageConfig } from "@/lib/db/queries";
|
||||||
|
import { findImageArchiveDownloadTask } from "./image-archive-db";
|
||||||
|
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
|
import { getImageDownloadExtension } from "@/lib/image-delivery";
|
||||||
|
import { sanitizeCanvasDownloadFileName } from "@/lib/canvas/photoshop-safe-download";
|
||||||
|
|
||||||
|
/** Resolve the archived original without waiting for a separate PNG conversion. */
|
||||||
|
export async function resolveOriginalCdnDownload(source: string, name: string, signal: AbortSignal) {
|
||||||
|
const config = await getDefaultStorageConfig();
|
||||||
|
if (config?.type !== "qiniu-kodo" || !config.isEnabled || !config.publicUrl) return null;
|
||||||
|
const base = new URL(config.publicUrl);
|
||||||
|
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
|
||||||
|
let target = new URL(source);
|
||||||
|
if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) {
|
||||||
|
const task = await findImageArchiveDownloadTask([...new Set([source, inferOriginalImageUrl(source)])]);
|
||||||
|
target = new URL(task?.targetUrl || source);
|
||||||
|
}
|
||||||
|
if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) {
|
||||||
|
target.host = base.host;
|
||||||
|
target.protocol = base.protocol;
|
||||||
|
}
|
||||||
|
if (base.protocol !== "https:" || target.origin !== base.origin ||
|
||||||
|
!target.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/")) return null;
|
||||||
|
// A Kodo download token signs the complete URL; adding attname would invalidate it.
|
||||||
|
if (target.searchParams.has("token")) return null;
|
||||||
|
|
||||||
|
// Only metadata crosses the application server. The browser receives the file
|
||||||
|
// from the CDN, including when cross-origin fetch is disabled by storage CORS.
|
||||||
|
const response = await fetch(target, {
|
||||||
|
method: "HEAD", cache: "no-store",
|
||||||
|
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
if (!contentType.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
|
||||||
|
const extension = getImageDownloadExtension(contentType);
|
||||||
|
if (!extension) return null;
|
||||||
|
const basename = sanitizeCanvasDownloadFileName(name || target.pathname.split("/").pop() || "image", "image")
|
||||||
|
.replace(/\.(?:png|jpe?g|webp|avif|gif|svg|bmp|tiff?)$/i, "");
|
||||||
|
const filename = `${basename}.${extension}`;
|
||||||
|
target.searchParams.set("attname", filename);
|
||||||
|
return { delivery: "cdn" as const, downloadUrl: target.toString(), filename,
|
||||||
|
bytes: Number(response.headers.get("content-length")) || 0 };
|
||||||
|
}
|
||||||
16
lib/storage/retired-storage.ts
Normal file
16
lib/storage/retired-storage.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/** Permanently decommissioned storage. Historical records remain for reference. */
|
||||||
|
export const RETIRED_STORAGE_URL_PATTERN = '^https?://s3[.]1080p[.]hk([/:?#]|$)';
|
||||||
|
export const RETIRED_STORAGE_MESSAGE = '旧存储已停用,源文件已无法恢复,请重新上传。';
|
||||||
|
|
||||||
|
export function isRetiredStorageUrl(value: string | null | undefined): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
try {
|
||||||
|
const url = new URL(value, 'https://local.invalid');
|
||||||
|
if (url.hostname === 's3.1080p.hk') return true;
|
||||||
|
if (url.pathname === '/api/image-proxy' || url.pathname === '/api/canvas/3d/model-proxy') {
|
||||||
|
const source = url.searchParams.get('url');
|
||||||
|
return Boolean(source && new URL(source).hostname === 's3.1080p.hk');
|
||||||
|
}
|
||||||
|
} catch { /* Relative local assets and non-URL values are not retired storage. */ }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
||||||
|
|
||||||
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||||
import { put } from "@vercel/blob";
|
import { put } from "@vercel/blob";
|
||||||
@ -149,6 +150,9 @@ export function getContentType(filename: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createS3Client(config: S3UploadConfig): S3Client {
|
export function createS3Client(config: S3UploadConfig): S3Client {
|
||||||
|
if (isRetiredStorageUrl(config.endpoint) || isRetiredStorageUrl(config.publicUrl)) {
|
||||||
|
throw new Error(RETIRED_STORAGE_MESSAGE);
|
||||||
|
}
|
||||||
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
|
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
|
||||||
const lowerEndpoint = normalizedEndpoint.toLowerCase();
|
const lowerEndpoint = normalizedEndpoint.toLowerCase();
|
||||||
const isCloudS3 =
|
const isCloudS3 =
|
||||||
@ -337,6 +341,8 @@ export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
|
|||||||
!value ||
|
!value ||
|
||||||
!provider ||
|
!provider ||
|
||||||
!value.isEnabled ||
|
!value.isEnabled ||
|
||||||
|
isRetiredStorageUrl(endpoint) ||
|
||||||
|
isRetiredStorageUrl(String(value.publicUrl || "")) ||
|
||||||
!endpoint ||
|
!endpoint ||
|
||||||
!bucket ||
|
!bucket ||
|
||||||
!accessKeyId ||
|
!accessKeyId ||
|
||||||
@ -364,7 +370,7 @@ function buildEnvS3Config(): S3UploadConfig | null {
|
|||||||
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() ?? "";
|
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() ?? "";
|
||||||
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim() ?? "";
|
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim() ?? "";
|
||||||
|
|
||||||
if (endpoint && bucket && accessKeyId && secretAccessKey) {
|
if (endpoint && !isRetiredStorageUrl(endpoint) && !isRetiredStorageUrl(process.env.S3_PUBLIC_URL) && bucket && accessKeyId && secretAccessKey) {
|
||||||
return {
|
return {
|
||||||
endpoint,
|
endpoint,
|
||||||
region: process.env.S3_REGION?.trim() || "auto",
|
region: process.env.S3_REGION?.trim() || "auto",
|
||||||
|
|||||||
@ -132,6 +132,7 @@
|
|||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
|
"gsap": "3.14.2",
|
||||||
"heic-convert": "^2.1.0",
|
"heic-convert": "^2.1.0",
|
||||||
"immer": "^11.1.3",
|
"immer": "^11.1.3",
|
||||||
"is-hotkey": "^0.2.0",
|
"is-hotkey": "^0.2.0",
|
||||||
|
|||||||
@ -3,10 +3,22 @@ import { defineConfig } from "@playwright/test";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests",
|
testDir: "./tests",
|
||||||
testMatch: [
|
testMatch: [
|
||||||
|
/routes\/original-image-download\.test\.ts/,
|
||||||
|
/routes\/canvas-download-speed\.test\.ts/,
|
||||||
|
/routes\/canvas-download-recovery\.test\.ts/,
|
||||||
|
/routes\/canvas-download-rate-limit\.test\.ts/,
|
||||||
|
/routes\/canvas-zip-download\.test\.ts/,
|
||||||
|
/routes\/image-archive-flow\.test\.ts/,
|
||||||
|
/routes\/runtime-active-work\.test\.ts/,
|
||||||
|
/routes\/canvas-capability-task-recovery\.test\.ts/,
|
||||||
|
/routes\/canvas-toolbar-capability\.test\.ts/,
|
||||||
|
/routes\/agent-project-recovery\.test\.ts/,
|
||||||
|
/routes\/admin-media-loading\.test\.ts/,
|
||||||
/routes\/video-canvas-production\.test\.ts/,
|
/routes\/video-canvas-production\.test\.ts/,
|
||||||
/routes\/video-canvas-media-import\.test\.ts/,
|
/routes\/video-canvas-media-import\.test\.ts/,
|
||||||
/routes\/video-canvas-toolbar-jobs\.test\.ts/,
|
/routes\/video-canvas-toolbar-jobs\.test\.ts/,
|
||||||
/routes\/image-provider-error-classification\.test\.ts/,
|
/routes\/image-provider-error-classification\.test\.ts/,
|
||||||
|
/routes\/canvas-agent-runtime-image-failover\.test\.ts/,
|
||||||
/routes\/video-canvas-audio-remote\.test\.ts/,
|
/routes\/video-canvas-audio-remote\.test\.ts/,
|
||||||
/routes\/video-canvas-groups-upscale\.test\.ts/,
|
/routes\/video-canvas-groups-upscale\.test\.ts/,
|
||||||
/routes\/tvstar-creative-analysis\.test\.ts/,
|
/routes\/tvstar-creative-analysis\.test\.ts/,
|
||||||
@ -39,6 +51,7 @@ export default defineConfig({
|
|||||||
/routes\/replacement-product-identity\.test\.ts/,
|
/routes\/replacement-product-identity\.test\.ts/,
|
||||||
/routes\/detail-replacement-batch\.test\.ts/,
|
/routes\/detail-replacement-batch\.test\.ts/,
|
||||||
/routes\/alibaba-image-input\.test\.ts/,
|
/routes\/alibaba-image-input\.test\.ts/,
|
||||||
|
/routes\/alibaba-task-errors\.test\.ts/,
|
||||||
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
||||||
/routes\/clawd-request-lifecycle\.test\.ts/,
|
/routes\/clawd-request-lifecycle\.test\.ts/,
|
||||||
/routes\/canvas-skill-tag-order\.test\.ts/,
|
/routes\/canvas-skill-tag-order\.test\.ts/,
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -234,6 +234,9 @@ importers:
|
|||||||
geist:
|
geist:
|
||||||
specifier: ^1.3.1
|
specifier: ^1.3.1
|
||||||
version: 1.3.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))
|
version: 1.3.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))
|
||||||
|
gsap:
|
||||||
|
specifier: 3.14.2
|
||||||
|
version: 3.14.2
|
||||||
heic-convert:
|
heic-convert:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.0
|
version: 2.1.0
|
||||||
@ -460,6 +463,9 @@ importers:
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
gsap@3.14.2:
|
||||||
|
resolution: {integrity: sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==}
|
||||||
|
|
||||||
'@ai-sdk/gateway@1.0.15':
|
'@ai-sdk/gateway@1.0.15':
|
||||||
resolution: {integrity: sha512-xySXoQ29+KbGuGfmDnABx+O6vc7Gj7qugmj1kGpn0rW0rQNn6UKUuvscKMzWyv1Uv05GyC1vqHq8ZhEOLfXscQ==}
|
resolution: {integrity: sha512-xySXoQ29+KbGuGfmDnABx+O6vc7Gj7qugmj1kGpn0rW0rQNn6UKUuvscKMzWyv1Uv05GyC1vqHq8ZhEOLfXscQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -6967,6 +6973,8 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
|
gsap@3.14.2: {}
|
||||||
|
|
||||||
'@ai-sdk/gateway@1.0.15(zod@3.25.76)':
|
'@ai-sdk/gateway@1.0.15(zod@3.25.76)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/provider': 2.0.0
|
'@ai-sdk/provider': 2.0.0
|
||||||
|
|||||||
16
proxy.ts
16
proxy.ts
@ -43,6 +43,7 @@ const manualIpBlockCache = new Map<
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
function isHighFrequencyReadApi(pathname: string, method: string) {
|
function isHighFrequencyReadApi(pathname: string, method: string) {
|
||||||
|
if (method === "POST" && /^\/api\/canvas\/[^/]+\/image-archives$/.test(pathname)) return true;
|
||||||
if (method === "GET" && pathname === "/api/canvas/download-zip-task") return true;
|
if (method === "GET" && pathname === "/api/canvas/download-zip-task") return true;
|
||||||
if (method !== "GET") return false;
|
if (method !== "GET") return false;
|
||||||
if (/^\/api\/canvas\/[^/]+\/detail-archives$/.test(pathname)) return true;
|
if (/^\/api\/canvas\/[^/]+\/detail-archives$/.test(pathname)) return true;
|
||||||
@ -668,7 +669,20 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (isHighFrequencyReadApi(pathname, request.method)) {
|
} else if (request.method === "POST" && /^\/api\/canvas\/[^/]+\/image-archives$/.test(pathname)) {
|
||||||
|
const token = await resolveToken();
|
||||||
|
const identifier = typeof token?.sub === "string" && token.sub
|
||||||
|
? `user:${token.sub}:image-archives`
|
||||||
|
: `ip:${clientIP}:image-archives`;
|
||||||
|
const result = checkRateLimitByIdentifier(identifier, rateLimiters.uiPolling);
|
||||||
|
if (!result.allowed) {
|
||||||
|
const retryAfter = Math.max(1, Math.ceil((result.resetTime - Date.now()) / 1000));
|
||||||
|
return withAccessLog(NextResponse.json(
|
||||||
|
{ error: "图片状态查询过于频繁,请稍后再查看", retryAfter },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(retryAfter) } },
|
||||||
|
), { outcome: "rate_limited", severity: "watch", reasons: ["图片归档状态查询限流"] });
|
||||||
|
}
|
||||||
|
} else if (isHighFrequencyReadApi(pathname, request.method)) {
|
||||||
const pollingRateLimit = checkRateLimit(request, rateLimiters.uiPolling);
|
const pollingRateLimit = checkRateLimit(request, rateLimiters.uiPolling);
|
||||||
if (!pollingRateLimit.allowed) {
|
if (!pollingRateLimit.allowed) {
|
||||||
console.warn(`[Security] UI polling rate limit exceeded for IP: ${clientIP}`, {
|
console.warn(`[Security] UI polling rate limit exceeded for IP: ${clientIP}`, {
|
||||||
|
|||||||
BIN
public/brand/logo-black.png
Normal file
BIN
public/brand/logo-black.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
1
public/brand/logo-white.svg
Normal file
1
public/brand/logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
@ -4,7 +4,6 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Neural Particle - Scatter Effect</title>
|
<title>Neural Particle - Scatter Effect</title>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
|
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body { overflow: hidden; background-color: #000; }
|
body { overflow: hidden; background-color: #000; }
|
||||||
@ -14,7 +13,8 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="particle-container"></div>
|
<div id="particle-container"></div>
|
||||||
<script>
|
<script type="module">
|
||||||
|
import * as THREE from "/three/0.160.0/three.module.min.js";
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
effect: "scatter",
|
effect: "scatter",
|
||||||
effectMode: 1,
|
effectMode: 1,
|
||||||
|
|||||||
21
public/three/0.160.0/LICENSE
Normal file
21
public/three/0.160.0/LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
The MIT License
|
||||||
|
|
||||||
|
Copyright © 2010-2023 three.js authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
6
public/three/0.160.0/three.module.min.js
vendored
Normal file
6
public/three/0.160.0/three.module.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
scripts/sql/admin-media-record-indexes.sql
Normal file
10
scripts/sql/admin-media-record-indexes.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
-- Run outside a transaction. Concurrent creation keeps normal inserts available.
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS "GeneratedImage_created_id_idx"
|
||||||
|
ON public."GeneratedImage" ("createdAt" DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS "Message_v2_video_created_id_idx"
|
||||||
|
ON public."Message_v2" ("createdAt" DESC, id DESC)
|
||||||
|
WHERE role = 'assistant' AND (
|
||||||
|
permanent_video_url IS NOT NULL OR temporary_video_url IS NOT NULL
|
||||||
|
OR parts::text ILIKE '%<video%' OR parts::text ILIKE '%.mp4%'
|
||||||
|
);
|
||||||
29
scripts/storage/retire-1080p-storage.cjs
Normal file
29
scripts/storage/retire-1080p-storage.cjs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
require('node:module').createRequire(require.resolve('next/package.json'))('@next/env').loadEnvConfig(process.cwd());
|
||||||
|
const sql = require('postgres')(process.env.POSTGRES_URL, { max: 1, prepare: false });
|
||||||
|
const pattern = '^https?://s3[.]1080p[.]hk([/:?#]|$)';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const apply = process.argv.includes('--apply');
|
||||||
|
const result = await sql.begin(async tx => {
|
||||||
|
const storage = await tx`select id,"isEnabled","isDefault" from "StorageConfig" where endpoint ~* ${pattern} or "publicUrl" ~* ${pattern}`;
|
||||||
|
const featured = await tx`select id,"isFeatured" from "GeneratedImage" where "isFeatured" and "originalUrl" ~* ${pattern}`;
|
||||||
|
const branding = await tx`select id,"siteLogo","siteLogoDark",favicon from "SiteSettings" where "siteLogo" ~* ${pattern} or "siteLogoDark" ~* ${pattern} or favicon ~* ${pattern}`;
|
||||||
|
if (!apply) return { apply, storage: storage.length, featured: featured.length, branding: branding.length };
|
||||||
|
const directory = process.env.STORAGE_RETIREMENT_BACKUP_DIR || '/var/lib/ai-chatbot/retired-storage-20261103';
|
||||||
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||||
|
const backup = path.join(directory, `before-${Date.now()}.json`);
|
||||||
|
fs.writeFileSync(backup, JSON.stringify({ storage, featured, branding }, null, 2), { flag: 'wx', mode: 0o600 });
|
||||||
|
const disabled = await tx`update "StorageConfig" set "isEnabled"=false,"isDefault"=false,"updatedAt"=now() where (endpoint ~* ${pattern} or "publicUrl" ~* ${pattern}) and ("isEnabled" or "isDefault") returning id`;
|
||||||
|
const unfeatured = await tx`update "GeneratedImage" set "isFeatured"=false where "isFeatured" and "originalUrl" ~* ${pattern} returning id`;
|
||||||
|
const branded = await tx`update "SiteSettings" set
|
||||||
|
"siteLogo"=case when "siteLogo" ~* ${pattern} then '/brand/logo-black.png' else "siteLogo" end,
|
||||||
|
"siteLogoDark"=case when "siteLogoDark" ~* ${pattern} then '/brand/logo-white.svg' else "siteLogoDark" end,
|
||||||
|
favicon=case when favicon ~* ${pattern} then '/brand/logo-black.png' else favicon end
|
||||||
|
where "siteLogo" ~* ${pattern} or "siteLogoDark" ~* ${pattern} or favicon ~* ${pattern} returning id`;
|
||||||
|
return { apply, backup, disabled: disabled.length, unfeatured: unfeatured.length, branded: branded.length };
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(result));
|
||||||
|
}
|
||||||
|
main().catch(error => { console.error(error.message); process.exitCode = 1; }).finally(() => sql.end());
|
||||||
@ -7,6 +7,7 @@ interface ApiResponse<T = unknown> {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
data?: T;
|
data?: T;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
status?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AGENT_PROJECT_REQUEST_TIMEOUT_MS = 15_000;
|
const AGENT_PROJECT_REQUEST_TIMEOUT_MS = 15_000;
|
||||||
@ -26,12 +27,38 @@ async function fetchAgentProjectApi<T>(
|
|||||||
...init,
|
...init,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
return await response.json();
|
const result = await response.json();
|
||||||
|
if (!response.ok) result.status = response.status;
|
||||||
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.clearTimeout(timeoutId);
|
globalThis.clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateProjectPayload = {
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
thumbnail?: string | null;
|
||||||
|
projectState?: AgentGenerationProjectPersistedState;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingCreations = new Map<string, {
|
||||||
|
id: string;
|
||||||
|
promise?: Promise<ApiResponse<AgentGenerationProject>>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function pendingCreationStorage(key: string, value?: string | null) {
|
||||||
|
try {
|
||||||
|
if (typeof sessionStorage === "undefined") return null;
|
||||||
|
if (value === undefined) return sessionStorage.getItem(key);
|
||||||
|
if (value === null) sessionStorage.removeItem(key);
|
||||||
|
else sessionStorage.setItem(key, value);
|
||||||
|
} catch {
|
||||||
|
// Keep the in-memory request identity when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function getAgentProjectRequestErrorMessage(error: unknown, fallback: string) {
|
function getAgentProjectRequestErrorMessage(error: unknown, fallback: string) {
|
||||||
if (error instanceof DOMException && error.name === "AbortError") {
|
if (error instanceof DOMException && error.name === "AbortError") {
|
||||||
return `${fallback}:请求超时`;
|
return `${fallback}:请求超时`;
|
||||||
@ -71,27 +98,52 @@ export const agentProjectService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async createProject(payload: {
|
async createProject(
|
||||||
title?: string;
|
payload: CreateProjectPayload,
|
||||||
description?: string | null;
|
options: { creationKey?: string } = {}
|
||||||
thumbnail?: string | null;
|
): Promise<ApiResponse<AgentGenerationProject>> {
|
||||||
projectState?: AgentGenerationProjectPersistedState;
|
const key = `agent-project:create:${options.creationKey || JSON.stringify(payload)}`;
|
||||||
}): Promise<ApiResponse<AgentGenerationProject>> {
|
let pending = pendingCreations.get(key);
|
||||||
try {
|
if (!pending) {
|
||||||
return await fetchAgentProjectApi<AgentGenerationProject>(
|
pending = { id: pendingCreationStorage(key) || crypto.randomUUID() };
|
||||||
"/api/agent-projects",
|
pendingCreations.set(key, pending);
|
||||||
{
|
pendingCreationStorage(key, pending.id);
|
||||||
method: "POST",
|
}
|
||||||
headers: { "Content-Type": "application/json" },
|
if (pending.promise) return pending.promise;
|
||||||
body: JSON.stringify(payload),
|
const current = pending;
|
||||||
|
const clearPending = () => {
|
||||||
|
pendingCreations.delete(key);
|
||||||
|
pendingCreationStorage(key, null);
|
||||||
|
};
|
||||||
|
current.promise = (async () => {
|
||||||
|
try {
|
||||||
|
const result = await fetchAgentProjectApi<AgentGenerationProject>(
|
||||||
|
"/api/agent-projects",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...payload, id: current.id }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (result.success || (result.status && result.status < 500)) {
|
||||||
|
clearPending();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
);
|
} catch {
|
||||||
} catch (error) {
|
// A lost response does not establish whether the project was created.
|
||||||
|
}
|
||||||
|
const recovered = await agentProjectService.getProject(current.id);
|
||||||
|
if (recovered.success && recovered.data) {
|
||||||
|
clearPending();
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
if (recovered.status === 401) return recovered;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: getAgentProjectRequestErrorMessage(error, "创建生图项目失败"),
|
error: "创建结果尚未确认,请重试核对或打开历史项目;重试不会重复创建。",
|
||||||
};
|
};
|
||||||
}
|
})().finally(() => { current.promise = undefined; });
|
||||||
|
return current.promise;
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateProject(
|
async updateProject(
|
||||||
|
|||||||
@ -248,6 +248,17 @@ export const canvasService = {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
if (data?.success && data?.previousCanvasRevision && data?.data?.updatedAt) {
|
||||||
|
const current = useCanvasStore.getState();
|
||||||
|
// Metadata-only writes advance the same revision used by canvas saves.
|
||||||
|
// Adopt it only when no unseen canvas revision lies between them.
|
||||||
|
if (
|
||||||
|
current.projectId === id &&
|
||||||
|
current.canvasRevision === data.previousCanvasRevision
|
||||||
|
) {
|
||||||
|
current.setCanvasRevision(String(data.data.updatedAt), String(data.data.updatedAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: '更新画布失败' };
|
return { success: false, error: '更新画布失败' };
|
||||||
|
|||||||
2
tests/canvas-toolbar.config.ts
Normal file
2
tests/canvas-toolbar.config.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
export default defineConfig({ testDir: "./routes", testMatch: /canvas-toolbar-capability\.test\.ts|canvas-capability-runtime\.test\.ts/, workers: 1, reporter: "list" });
|
||||||
@ -6,7 +6,7 @@ export function agentImageProviderHarness(options: { generate?: (id: string, cou
|
|||||||
const models = options.models || ["primary", "backup"].map((id, i) => ({ model: { id, modelId: id, displayName: id, isEnabled: true, supportsImageGen: true, supportsVision: true, isDefault: !i, imageGenConfig: { maxImages: 12 }, capabilities: { kind: "image" } }, provider: { id: "same-provider", type: "openai-image", apiKey: "synthetic", isEnabled: true } }));
|
const models = options.models || ["primary", "backup"].map((id, i) => ({ model: { id, modelId: id, displayName: id, isEnabled: true, supportsImageGen: true, supportsVision: true, isDefault: !i, imageGenConfig: { maxImages: 12 }, capabilities: { kind: "image" } }, provider: { id: "same-provider", type: "openai-image", apiKey: "synthetic", isEnabled: true } }));
|
||||||
const mocks: Record<string, any> = {
|
const mocks: Record<string, any> = {
|
||||||
"@/lib/ai/runtime/image-generation-error": generationErrors,
|
"@/lib/ai/runtime/image-generation-error": generationErrors,
|
||||||
"@/lib/db/queries": { getEnabledAiModelsWithProvider: async () => models },
|
"@/lib/db/queries": { getEnabledAiModelsWithProvider: async () => models.filter(entry => entry.model.isEnabled), getAiModelById: async ({ id }: { id: string }) => models.find(entry => entry.model.id === id)?.model },
|
||||||
"@/lib/ai/rmbg/service": {},
|
"@/lib/ai/rmbg/service": {},
|
||||||
"@/lib/net/url-access": { filterReachableRemoteUrls: async (urls: string[]) => ({ kept: urls, dropped: [] }) },
|
"@/lib/net/url-access": { filterReachableRemoteUrls: async (urls: string[]) => ({ kept: urls, dropped: [] }) },
|
||||||
"@/lib/canvas/image-concurrency": { acquireCanvasImageModelSlot: async () => ({ waitMs: 0, maxConcurrent: 999, release: async () => {} }), normalizeCanvasImageModelKey: (v: string) => v, recordCanvasImageModelExecutionDuration() {} },
|
"@/lib/canvas/image-concurrency": { acquireCanvasImageModelSlot: async () => ({ waitMs: 0, maxConcurrent: 999, release: async () => {} }), normalizeCanvasImageModelKey: (v: string) => v, recordCanvasImageModelExecutionDuration() {} },
|
||||||
|
|||||||
@ -63,3 +63,42 @@ test("coalesces overlapping typewriter table and image ranges without looping",
|
|||||||
assert.equal(advancePastAtomicRanges(50, 36, ranges), 40);
|
assert.equal(advancePastAtomicRanges(50, 36, ranges), 40);
|
||||||
assert.equal(advancePastAtomicRanges(50, 50, ranges), 50);
|
assert.equal(advancePastAtomicRanges(50, 50, ranges), 50);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("SKU result links are atomic across every typewriter cursor and live fragment", () => {
|
||||||
|
const links = [
|
||||||
|
"[beike-sku-template2-batch.json](https://scqny.jiansun.vip/ps-skill-api/results/job_test/795c72cb-batch.json)",
|
||||||
|
"[beike-sku-template2-report.json](https://scqny.jiansun.vip/ps-skill-api/results/job_test/af588253-report.json)",
|
||||||
|
];
|
||||||
|
const text = `生成图\n${wrapped}\n可编辑文件\n- ${links[0]}\n- ${links[1]}\n完成`;
|
||||||
|
const ranges = scanMarkdownImageStreaming(text).ranges;
|
||||||
|
assert.equal(ranges.length, 3);
|
||||||
|
for (const link of links) {
|
||||||
|
const start = text.indexOf(link);
|
||||||
|
for (let n = 1; n < link.length; n += 1) {
|
||||||
|
const prefix = text.slice(0, start + n);
|
||||||
|
assert.equal(stableStreamingMarkdown(prefix, true), text.slice(0, start), `link prefix ${n}`);
|
||||||
|
}
|
||||||
|
assert.equal(stableStreamingMarkdown(text.slice(0, start + link.length), true), text.slice(0, start + link.length));
|
||||||
|
}
|
||||||
|
let cursor = 0;
|
||||||
|
while (cursor < text.length) {
|
||||||
|
cursor = advancePastAtomicRanges(text.length, cursor + 1, ranges);
|
||||||
|
assert.ok(ranges.every((range) => cursor <= range.start || cursor >= range.end), `split at ${cursor}`);
|
||||||
|
}
|
||||||
|
assert.equal(cursor, text.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ordinary brackets, code, escaped markers and unfinished final links remain available", () => {
|
||||||
|
for (const value of ["普通文字", "[状态] 已完成", "[引用][reference]", "\\[转义](https://example.com)", "`[code](https://example.com`", "~~~md\n[example](https://example.com\n~~~\n"]) {
|
||||||
|
assert.equal(stableStreamingMarkdown(value, true), value);
|
||||||
|
assert.equal(stableStreamingMarkdown(value, false), value);
|
||||||
|
}
|
||||||
|
for (const partial of ["[状态", "[文件](https://example.com/", "[文件]"]) {
|
||||||
|
assert.equal(stableStreamingMarkdown(partial, false), partial);
|
||||||
|
assert.equal(stableStreamingMarkdown(partial + "\n普通文字", true), partial + "\n普通文字");
|
||||||
|
}
|
||||||
|
const titleLink = '[文档](https://example.com/a "标题")';
|
||||||
|
assert.deepEqual(scanMarkdownImageStreaming(titleLink).ranges, [{ start: 0, end: titleLink.length }]);
|
||||||
|
const unsafeLink = "[危险链接](javascript:alert(1))";
|
||||||
|
assert.equal(stableStreamingMarkdown(unsafeLink, false), unsafeLink);
|
||||||
|
});
|
||||||
|
|||||||
2
tests/retired-storage.config.ts
Normal file
2
tests/retired-storage.config.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
export default defineConfig({ testDir: './routes', testMatch: /retired-storage\.test\.ts/, workers: 1, reporter: 'list' });
|
||||||
45
tests/routes/admin-media-loading.test.ts
Normal file
45
tests/routes/admin-media-loading.test.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { queuePreview } from "../../lib/admin/preview-queue";
|
||||||
|
import { formatRecordTime } from "../../lib/admin/record-time";
|
||||||
|
import { parseMediaQuery } from "../../lib/admin/media-records";
|
||||||
|
|
||||||
|
test("pagination rejects invalid values and bounds a requested page size", () => {
|
||||||
|
expect(parseMediaQuery(new URLSearchParams("page=-1&pageSize=200"))).toMatchObject({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
parseMediaQuery(new URLSearchParams("page=2&pageSize=40&search=%E9%99%88%E9%BE%99")),
|
||||||
|
).toMatchObject({ page: 2, pageSize: 40, search: "陈龙" });
|
||||||
|
expect(parseMediaQuery(new URLSearchParams("page=NaN&pageSize=0"))).toMatchObject({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalized Beijing record times do not receive a second timezone offset", () => {
|
||||||
|
expect(formatRecordTime("2026-11-03T23:55:00.123Z")).toBe("2026-11-03 23:55");
|
||||||
|
expect(formatRecordTime("2026-11-03T17:12:00.123456")).toBe("2026-11-03 17:12");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaving a page cancels waiting previews and retains active slots until fetch settles", () => {
|
||||||
|
const started: number[] = [];
|
||||||
|
const releases: Array<() => void> = [];
|
||||||
|
const cancel = Array.from({ length: 7 }, (_, index) =>
|
||||||
|
queuePreview((release) => {
|
||||||
|
started.push(index);
|
||||||
|
releases[index] = release;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(started).toEqual([0, 1, 2, 3]);
|
||||||
|
cancel[0]();
|
||||||
|
cancel[4]();
|
||||||
|
expect(started).toEqual([0, 1, 2, 3]);
|
||||||
|
releases[0]();
|
||||||
|
expect(started).toEqual([0, 1, 2, 3, 5]);
|
||||||
|
releases[0]();
|
||||||
|
expect(started).toHaveLength(5);
|
||||||
|
releases[1]();
|
||||||
|
expect(started).toEqual([0, 1, 2, 3, 5, 6]);
|
||||||
|
for (const release of releases) release?.();
|
||||||
|
});
|
||||||
229
tests/routes/agent-project-recovery.test.ts
Normal file
229
tests/routes/agent-project-recovery.test.ts
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import path from "node:path";
|
||||||
|
import vm from "node:vm";
|
||||||
|
import { randomUUID, webcrypto } from "node:crypto";
|
||||||
|
import ts from "typescript";
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "../..");
|
||||||
|
const id = randomUUID();
|
||||||
|
const owner = randomUUID();
|
||||||
|
|
||||||
|
function loadRuntime(mocks: Record<string, any> = {}, globals: Record<string, any> = {}) {
|
||||||
|
const modules = new Map<string, any>();
|
||||||
|
const context = vm.createContext({
|
||||||
|
console, Buffer, URL, Response, Request, Headers, DOMException,
|
||||||
|
AbortController, AbortSignal, crypto: webcrypto, setTimeout, clearTimeout,
|
||||||
|
process: { env: {} },
|
||||||
|
fetch: () => { throw new Error("Unexpected network request"); },
|
||||||
|
...globals,
|
||||||
|
});
|
||||||
|
const load = (relative: string): any => {
|
||||||
|
let filename = path.resolve(root, relative);
|
||||||
|
if (!existsSync(filename)) filename = path.join(filename.replace(/\.ts$/, ""), "index.ts");
|
||||||
|
if (modules.has(filename)) return modules.get(filename).exports;
|
||||||
|
const module = { exports: {} };
|
||||||
|
modules.set(filename, module);
|
||||||
|
const nativeRequire = createRequire(filename);
|
||||||
|
const requireMock = (name: string) => {
|
||||||
|
if (name === "server-only") return {};
|
||||||
|
if (Object.hasOwn(mocks, name)) return mocks[name];
|
||||||
|
if (name.startsWith("@/")) return load(`${name.slice(2)}.ts`);
|
||||||
|
if (name.startsWith(".")) return load(path.relative(root, path.resolve(path.dirname(filename), `${name}.ts`)));
|
||||||
|
return nativeRequire(name);
|
||||||
|
};
|
||||||
|
const source = ts.transpileModule(readFileSync(filename, "utf8"), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
|
||||||
|
}).outputText;
|
||||||
|
context.module = module;
|
||||||
|
context.exports = module.exports;
|
||||||
|
context.require = requireMock;
|
||||||
|
const fn = new vm.Script(`(function(module,exports,require){${source}\n})`, { filename }).runInContext(context);
|
||||||
|
fn(module, module.exports, requireMock);
|
||||||
|
return module.exports;
|
||||||
|
};
|
||||||
|
return load;
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryStorage() {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
return { getItem: (key: string) => values.get(key) || null, setItem: (key: string, value: string) => values.set(key, value), removeItem: (key: string) => values.delete(key) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectRoute() {
|
||||||
|
const projects = new Map<string, any>();
|
||||||
|
let userId: string | null = owner;
|
||||||
|
const db = {
|
||||||
|
insert: () => ({ values: (value: any) => ({ onConflictDoNothing: () => ({ returning: async () => {
|
||||||
|
const row = { ...value, id: value.id || randomUUID(), createdAt: new Date(), updatedAt: new Date() };
|
||||||
|
if (projects.has(row.id)) return [];
|
||||||
|
projects.set(row.id, row);
|
||||||
|
return [row];
|
||||||
|
} }) }) }),
|
||||||
|
select: () => ({ from: () => ({ where: (conditions: any[]) => ({ limit: async () => [...projects.values()].filter((row) => conditions.every(([key, value]) => row[key] === value)) }) }) }),
|
||||||
|
};
|
||||||
|
const load = loadRuntime({
|
||||||
|
"@/lib/db": { db },
|
||||||
|
"@/lib/db/schema": { canvasProject: { id: "id", userId: "userId", workspaceType: "workspaceType" } },
|
||||||
|
"@/app/(auth)/auth": { auth: async () => userId ? { user: { id: userId } } : null },
|
||||||
|
"drizzle-orm": { eq: (key: string, value: unknown) => [key, value], and: (...parts: unknown[]) => parts, desc: (value: unknown) => value },
|
||||||
|
});
|
||||||
|
return { route: load("app/api/agent-projects/route.ts"), projects, setUser: (value: string | null) => { userId = value; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("duplicate POSTs return one project without overwriting saved state", async () => {
|
||||||
|
const { route, projects } = projectRoute();
|
||||||
|
const post = (title: string) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify({ id, title }) }));
|
||||||
|
const responses = await Promise.all([post("原项目"), post("重试标题")]);
|
||||||
|
expect(projects.size).toBe(1);
|
||||||
|
for (const response of responses) {
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect((await response.json()).data).toMatchObject({ id, title: "原项目", userId: owner });
|
||||||
|
}
|
||||||
|
projects.get(id).title = "已编辑项目";
|
||||||
|
expect((await (await post("迟到的重试")).json()).data.title).toBe("已编辑项目");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creation retains account ownership and accepts existing clients", async () => {
|
||||||
|
const { route, projects, setUser } = projectRoute();
|
||||||
|
const post = (body: unknown) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify(body) }));
|
||||||
|
expect((await post({ id })).status).toBe(200);
|
||||||
|
setUser(randomUUID());
|
||||||
|
const conflict = await post({ id });
|
||||||
|
expect(conflict.status).toBe(409);
|
||||||
|
expect((await conflict.json()).data).toBeUndefined();
|
||||||
|
expect(projects.size).toBe(1);
|
||||||
|
expect((await post({ title: "旧客户端" })).status).toBe(200);
|
||||||
|
expect((await post({ id: "invalid" })).status).toBe(400);
|
||||||
|
setUser(null);
|
||||||
|
expect((await post({ id: randomUUID() })).status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a lost create response recovers the inserted project", async () => {
|
||||||
|
let project: any;
|
||||||
|
let posts = 0;
|
||||||
|
const fetchMock = async (_url: string, init?: RequestInit) => {
|
||||||
|
if (init?.method === "POST") {
|
||||||
|
posts++;
|
||||||
|
project = JSON.parse(String(init.body));
|
||||||
|
throw new DOMException("Aborted", "AbortError");
|
||||||
|
}
|
||||||
|
return Response.json({ success: true, data: project });
|
||||||
|
};
|
||||||
|
const { agentProjectService } = loadRuntime({}, { fetch: fetchMock, sessionStorage: memoryStorage() })("services/agent-projects/index.ts");
|
||||||
|
const result = await agentProjectService.createProject({ title: "新项目" }, { creationKey: "blank:test" });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data.id).toBe(project.id);
|
||||||
|
expect(posts).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unconfirmed creation survives reload and retries with the same project ID", async () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const ids: string[] = [];
|
||||||
|
let offline = true;
|
||||||
|
const fetchMock = async (_url: string, init?: RequestInit) => {
|
||||||
|
if (init?.method === "POST") {
|
||||||
|
const body = JSON.parse(String(init.body));
|
||||||
|
ids.push(body.id);
|
||||||
|
if (!offline) return Response.json({ success: true, data: body });
|
||||||
|
throw new TypeError("Connection closed");
|
||||||
|
}
|
||||||
|
return Response.json({ success: false }, { status: 404 });
|
||||||
|
};
|
||||||
|
const service = () => loadRuntime({}, { fetch: fetchMock, sessionStorage: storage })("services/agent-projects/index.ts").agentProjectService;
|
||||||
|
const first = await service().createProject({ title: "09:18" }, { creationKey: "blank:test" });
|
||||||
|
expect(first.error).toContain("尚未确认");
|
||||||
|
offline = false;
|
||||||
|
const reloaded = service();
|
||||||
|
expect((await reloaded.createProject({ title: "09:21" }, { creationKey: "blank:test" })).success).toBe(true);
|
||||||
|
expect(ids[1]).toBe(ids[0]);
|
||||||
|
await reloaded.createProject({ title: "新的独立项目" }, { creationKey: "blank:test" });
|
||||||
|
expect(ids[2]).not.toBe(ids[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simultaneous clicks share one create request", async () => {
|
||||||
|
let posts = 0;
|
||||||
|
let finish!: () => void;
|
||||||
|
const wait = new Promise<void>((resolve) => { finish = resolve; });
|
||||||
|
const service = loadRuntime({}, { fetch: async (_url: string, init: RequestInit) => {
|
||||||
|
posts++;
|
||||||
|
await wait;
|
||||||
|
return Response.json({ success: true, data: JSON.parse(String(init.body)) });
|
||||||
|
} })("services/agent-projects/index.ts").agentProjectService;
|
||||||
|
const first = service.createProject({}, { creationKey: "blank:double" });
|
||||||
|
const second = service.createProject({}, { creationKey: "blank:double" });
|
||||||
|
finish();
|
||||||
|
const results = await Promise.all([first, second]);
|
||||||
|
expect(posts).toBe(1);
|
||||||
|
expect(results[0].data.id).toBe(results[1].data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("conditional settings requests reuse the full config after 304", async () => {
|
||||||
|
const headers: Headers[] = [];
|
||||||
|
const config = { steps: [{ key: "style", promptFragment: "保留提示词" }] };
|
||||||
|
const { fetchCanvasSiteSettings } = loadRuntime({}, { fetch: async (_url: string, init: RequestInit) => {
|
||||||
|
headers.push(new Headers(init.headers));
|
||||||
|
return headers.length === 1 ? Response.json(config, { headers: { ETag: 'W/"revision-1"' } }) : new Response(null, { status: 304 });
|
||||||
|
} })("lib/client/canvas-site-settings.ts");
|
||||||
|
const first = await fetchCanvasSiteSettings("/api/site-settings?scope=agent-canvas");
|
||||||
|
const second = await fetchCanvasSiteSettings("/api/site-settings?scope=agent-canvas");
|
||||||
|
expect(second).toBe(first);
|
||||||
|
expect(second).toEqual(config);
|
||||||
|
expect(headers[1].get("If-None-Match")).toBe('W/"revision-1"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings timeout also aborts a stalled response body", async () => {
|
||||||
|
const { fetchCanvasSiteSettings } = loadRuntime({}, {
|
||||||
|
setTimeout: (callback: () => void) => setTimeout(callback, 5),
|
||||||
|
fetch: async (_url: string, init: RequestInit) => ({ ok: true, status: 200, json: () => new Promise((_, reject) => {
|
||||||
|
init.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
|
||||||
|
}) }),
|
||||||
|
})("lib/client/canvas-site-settings.ts");
|
||||||
|
await expect(fetchCanvasSiteSettings("/api/site-settings?scope=agent-canvas")).rejects.toHaveProperty("name", "AbortError");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("entry config stays small and unchanged config returns 304 before loading values", async () => {
|
||||||
|
let revision = new Date("2026-11-04T00:00:00Z");
|
||||||
|
let fullReads = 0;
|
||||||
|
const tables = { siteSettings: { id: "siteId", updatedAt: "siteUpdatedAt" }, systemSettings: { key: "key", updatedAt: "updatedAt" }, aiProvider: { id: "providerId", updatedAt: "providerUpdatedAt", isEnabled: "enabled", type: "type" } };
|
||||||
|
const ui = { steps: [{ key: "style", title: "风格", enabled: true, sortOrder: 0, options: [{ key: "test", buttonName: "风格一", promptFragment: "生成提示词".repeat(3000), enabled: true, sortOrder: 0 }] }] };
|
||||||
|
const db = { select: (columns: unknown) => ({ from: (table: unknown) => {
|
||||||
|
const result = () => {
|
||||||
|
if (table === tables.aiProvider) return [];
|
||||||
|
if (table === tables.siteSettings) return [{ id: "site", siteName: "由壹点AI", updatedAt: revision }];
|
||||||
|
if (!columns) fullReads++;
|
||||||
|
return [{ key: "agent_workflow_ui_config", value: ui, updatedAt: revision }];
|
||||||
|
};
|
||||||
|
const query: any = Object.assign(Promise.resolve(result()), {
|
||||||
|
where: () => query,
|
||||||
|
limit: () => query,
|
||||||
|
});
|
||||||
|
return query;
|
||||||
|
} }) };
|
||||||
|
const route = loadRuntime({
|
||||||
|
"@/lib/db": { db }, "@/lib/db/schema": tables,
|
||||||
|
"@/app/(auth)/auth": { auth: async () => null },
|
||||||
|
})("app/api/site-settings/route.ts");
|
||||||
|
const request = (scope: string, etag?: string) => {
|
||||||
|
const req = new Request(`http://localhost/api/site-settings?scope=${scope}`, { headers: etag ? { "If-None-Match": etag } : {} });
|
||||||
|
return Object.assign(req, { nextUrl: new URL(req.url) });
|
||||||
|
};
|
||||||
|
const entry = await route.GET(request("agent-entry"));
|
||||||
|
expect(entry.status).toBe(200);
|
||||||
|
const entryData = await entry.json();
|
||||||
|
expect(entryData.agentWorkflowUiConfig.steps).toEqual([]);
|
||||||
|
expect(JSON.stringify(entryData).length).toBeLessThan(20_000);
|
||||||
|
const full = await route.GET(request("agent-canvas"));
|
||||||
|
expect((await full.json()).agentWorkflowUiConfig.steps[0].options[0].promptFragment).toBe("生成提示词".repeat(3000).slice(0, 20_000));
|
||||||
|
const etag = full.headers.get("ETag");
|
||||||
|
const reads = fullReads;
|
||||||
|
const unchanged = await route.GET(request("agent-canvas", etag));
|
||||||
|
expect(unchanged.status).toBe(304);
|
||||||
|
expect(await unchanged.text()).toBe("");
|
||||||
|
expect(fullReads).toBe(reads);
|
||||||
|
revision = new Date("2026-11-04T00:01:00Z");
|
||||||
|
const changed = await route.GET(request("agent-canvas", etag));
|
||||||
|
expect(changed.status).toBe(200);
|
||||||
|
expect(changed.headers.get("ETag")).not.toBe(etag);
|
||||||
|
});
|
||||||
59
tests/routes/alibaba-task-errors.test.ts
Normal file
59
tests/routes/alibaba-task-errors.test.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
test("Alibaba terminal task errors preserve the nested provider reason and do not resubmit", async () => {
|
||||||
|
const methods: string[] = [];
|
||||||
|
const loader = serverLoader({
|
||||||
|
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
|
||||||
|
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
||||||
|
fetch: async (_url: string, init: RequestInit) => {
|
||||||
|
methods.push(init.method || "GET");
|
||||||
|
return Response.json(methods.length === 1
|
||||||
|
? { output: { task_id: "original-task", task_status: "PENDING" } }
|
||||||
|
: { output: { task_id: "original-task", task_status: "FAILED", code: "InvalidParameter", message: "Failed to download image URL after retries." } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example/api/v1/services/aigc/multimodal-generation/generation" });
|
||||||
|
let error: unknown;
|
||||||
|
try { await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: "fixture" }], imageParams: { aspectRatio: "1:1" } }); } catch (caught) { error = caught; }
|
||||||
|
expect(String(error)).toContain("Failed to download image URL after retries.");
|
||||||
|
expect(String(error)).toContain("InvalidParameter");
|
||||||
|
expect(String(error)).toContain("original-task");
|
||||||
|
expect(methods).toEqual(["POST", "GET"]);
|
||||||
|
const failure = loader("lib/request-error.ts").getRequestFailureDetails(error);
|
||||||
|
expect(failure).toMatchObject({ code: "PROVIDER_REFERENCE_FETCH_FAILED", message: "模型服务未能读取参考图。" });
|
||||||
|
expect(failure.recommendation).toContain("模型取图线路");
|
||||||
|
expect(failure.recommendation).not.toContain("稍后重试");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto resolution", async () => {
|
||||||
|
const png = await sharp({ create: { width: 32, height: 64, channels: 3, background: "blue" } }).png().toBuffer();
|
||||||
|
const jpeg = await sharp(png).jpeg().toBuffer();
|
||||||
|
const webp = await sharp(png).webp().toBuffer();
|
||||||
|
const originals = [png, jpeg, webp];
|
||||||
|
let submitted: any;
|
||||||
|
let posts = 0;
|
||||||
|
const loader = serverLoader({
|
||||||
|
"@/lib/storage/image-archive": {}, "@/lib/storage/service": { uploadFile() { throw new Error("Reference transport must not upload an intermediate file"); } },
|
||||||
|
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
||||||
|
fetch: async (url: string, init: RequestInit = {}) => {
|
||||||
|
if (init.method === "POST") { posts++; submitted = JSON.parse(String(init.body)); return Response.json({ output: { task_id: "inline-task", task_status: "PENDING" } }); }
|
||||||
|
if (url.includes("/tasks/")) return Response.json({ output: { task_status: "FAILED", message: "transport-inspection-complete" } });
|
||||||
|
const index = Number(new URL(url).pathname.slice(1));
|
||||||
|
return new Response(originals[index], { headers: { "content-type": ["image/png", "image/jpeg", "image/webp"][index] } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
|
||||||
|
await expect(provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [
|
||||||
|
{ type: "text", text: "fixture" }, ...originals.map((_bytes, index) => ({ type: "image", image: `https://source.example/${index}` })),
|
||||||
|
] }], imageParams: { aspectRatio: "auto", imageSize: "2K" } })).rejects.toThrow("transport-inspection-complete");
|
||||||
|
expect(posts).toBe(1);
|
||||||
|
expect(submitted.parameters.size).toBe("1024*2048");
|
||||||
|
const images = submitted.input.messages[0].content.filter((part: any) => part.image).map((part: any) => part.image);
|
||||||
|
expect(images).toHaveLength(3);
|
||||||
|
images.forEach((value: string, index: number) => {
|
||||||
|
expect(value.split(";", 1)[0]).toBe(["data:image/png", "data:image/jpeg", "data:image/webp"][index]);
|
||||||
|
expect(Buffer.from(value.split(",")[1], "base64").equals(originals[index])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user