Compare commits
No commits in common. "main" and "codex/sku-stream-links" have entirely different histories.
main
...
codex/sku-
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,7 @@ import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-c
|
|||||||
import type { ChatMessage } from "@/lib/types";
|
import type { ChatMessage } from "@/lib/types";
|
||||||
import { convertMessagesWithImages } from "./image-media";
|
import { convertMessagesWithImages } from "./image-media";
|
||||||
import { isProviderBalanceError } from "@/lib/request-error";
|
import { isProviderBalanceError } from "@/lib/request-error";
|
||||||
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
|
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
|
||||||
|
|
||||||
const chatApiLogger = createLogger("chat.imageFailover");
|
const chatApiLogger = createLogger("chat.imageFailover");
|
||||||
|
|
||||||
@ -326,7 +326,7 @@ export function buildImageFallbackCandidates(
|
|||||||
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
|
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (isImageFallbackModelCoolingDown(item.model.id) || isImageModelBalanceBlocked(item.model)) {
|
if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(item.model)) {
|
||||||
const cooldown = imageFailoverModelCooldown.get(item.model.id);
|
const cooldown = imageFailoverModelCooldown.get(item.model.id);
|
||||||
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
||||||
return false;
|
return false;
|
||||||
@ -488,7 +488,7 @@ export function getImageCrossProviderCandidates(
|
|||||||
}
|
}
|
||||||
if (m.model.id === options.currentModelId) return false;
|
if (m.model.id === options.currentModelId) return false;
|
||||||
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
|
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
|
||||||
if (isImageFallbackModelCoolingDown(m.model.id) || isImageModelBalanceBlocked(m.model)) {
|
if (isImageFallbackModelCoolingDown(m.model.id) || isImageFallbackModelBalanceCoolingDown(m.model)) {
|
||||||
const cooldown = imageFailoverModelCooldown.get(m.model.id);
|
const cooldown = imageFailoverModelCooldown.get(m.model.id);
|
||||||
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import { isExplicitDestructiveIntent } from "@/lib/ai/canvas-agent-runtime/capability-access";
|
import { isExplicitDestructiveIntent } from "@/lib/ai/canvas-agent-runtime/capability-access";
|
||||||
import { inferVideoTaskRequirements } from "@/lib/ai/canvas-agent-runtime/video-protocol";
|
import { inferVideoTaskRequirements } from "@/lib/ai/canvas-agent-runtime/video-protocol";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
|
||||||
import { geolocation } from "@vercel/functions";
|
import { geolocation } from "@vercel/functions";
|
||||||
import {
|
import {
|
||||||
convertToModelMessages,
|
convertToModelMessages,
|
||||||
createUIMessageStream as createAiUIMessageStream,
|
createUIMessageStream,
|
||||||
generateText,
|
generateText,
|
||||||
smoothStream,
|
smoothStream,
|
||||||
stepCountIs,
|
stepCountIs,
|
||||||
@ -337,7 +336,6 @@ import {
|
|||||||
} from "./_lib/image-media";
|
} from "./_lib/image-media";
|
||||||
import {
|
import {
|
||||||
buildImageFallbackCandidates,
|
buildImageFallbackCandidates,
|
||||||
countChatMessageImageInputs,
|
|
||||||
generateImageWithFailoverCandidate,
|
generateImageWithFailoverCandidate,
|
||||||
getImageCrossProviderCandidates,
|
getImageCrossProviderCandidates,
|
||||||
IMAGE_FAILOVER_PROVIDER_ALLOWLIST,
|
IMAGE_FAILOVER_PROVIDER_ALLOWLIST,
|
||||||
@ -346,8 +344,6 @@ import {
|
|||||||
setImageFallbackModelCooldown,
|
setImageFallbackModelCooldown,
|
||||||
shouldCooldownImageFallbackModel,
|
shouldCooldownImageFallbackModel,
|
||||||
} from "./_lib/image-failover";
|
} from "./_lib/image-failover";
|
||||||
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
|
|
||||||
import { isProviderBalanceError } from "@/lib/request-error";
|
|
||||||
import {
|
import {
|
||||||
buildAgentSceneWorkflowPrompt,
|
buildAgentSceneWorkflowPrompt,
|
||||||
buildAgentSlotPrompt,
|
buildAgentSlotPrompt,
|
||||||
@ -427,8 +423,6 @@ 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;
|
||||||
@ -446,13 +440,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, requestId: string) {
|
function getPublicImageGenerationErrorText(error: unknown) {
|
||||||
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 `图片生成失败,请稍后重试。若连续失败,请联系管理员。\n\n请求编号:${requestId}`;
|
return "图片生成失败,请稍后重试";
|
||||||
}
|
}
|
||||||
return `${publicMessage}\n\n请求编号:${requestId}`;
|
return publicMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -8775,19 +8769,6 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^[0-9a-f-]{36}$/i.test(finalModelId)) {
|
|
||||||
const requested = await getAiModelById({ id: finalModelId });
|
|
||||||
if (requested?.supportsImageGen && isImageModelBalanceBlocked(requested)) {
|
|
||||||
const replacement = resolveConfiguredImageBalanceFallback(requested, await getEnabledAiModelsWithProvider(), {
|
|
||||||
imageSize: effectiveImageGenerationParams.imageSize,
|
|
||||||
aspectRatio: effectiveImageGenerationParams.aspectRatio,
|
|
||||||
referenceImageCount: countChatMessageImageInputs(uiMessagesForModel.filter(item => item.role === "user").slice(-1)),
|
|
||||||
});
|
|
||||||
if (!replacement) return new ChatSDKError("bad_request:api", "当前生图线路余额不足,已暂停新请求;请切换可用模型,已有任务和图片会保留。").toResponse();
|
|
||||||
finalModelId = replacement.model.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否是 UUID 格式(数据库中的模型 ID)
|
// 检查是否是 UUID 格式(数据库中的模型 ID)
|
||||||
const isUUID =
|
const isUUID =
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||||
@ -9633,10 +9614,9 @@ 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,
|
||||||
);
|
);
|
||||||
@ -9656,7 +9636,7 @@ export async function POST(request: Request) {
|
|||||||
id: errorMessageId,
|
id: errorMessageId,
|
||||||
chatId: id,
|
chatId: id,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [{ type: "text", text: errorText }],
|
parts: [{ type: "text", text: "图片生成失败,请稍后重试" }],
|
||||||
attachments: [],
|
attachments: [],
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
videoStorageStatus: null,
|
videoStorageStatus: null,
|
||||||
@ -10022,10 +10002,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
let fallbackSuccess = false;
|
let fallbackSuccess = false;
|
||||||
const triedFallbackModelIds = new Set<string>();
|
const triedFallbackModelIds = new Set<string>();
|
||||||
const balanceBlockedProviders = new Set<string>();
|
|
||||||
if (isProviderBalanceError(primaryError) && currentModelEntry?.provider.id) balanceBlockedProviders.add(currentModelEntry.provider.id);
|
|
||||||
for (const candidate of fallbackCandidates) {
|
for (const candidate of fallbackCandidates) {
|
||||||
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
|
|
||||||
try {
|
try {
|
||||||
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||||
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
|
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||||
@ -10048,7 +10025,6 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.info(`[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!`);
|
chatApiLogger.info(`[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!`);
|
||||||
break;
|
break;
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
if (isProviderBalanceError(fallbackError)) balanceBlockedProviders.add(candidate.provider.id);
|
|
||||||
const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError);
|
const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError);
|
||||||
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
|
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
|
||||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||||
@ -10097,10 +10073,9 @@ export async function POST(request: Request) {
|
|||||||
currentModelKey: actualModelId,
|
currentModelKey: actualModelId,
|
||||||
currentProviderId: alibabaModelConfig?.providerId,
|
currentProviderId: alibabaModelConfig?.providerId,
|
||||||
providerPriority,
|
providerPriority,
|
||||||
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")) && !balanceBlockedProviders.has(candidate.provider.id));
|
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")));
|
||||||
|
|
||||||
for (const candidate of crossProviderCandidates) {
|
for (const candidate of crossProviderCandidates) {
|
||||||
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
|
|
||||||
try {
|
try {
|
||||||
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||||
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
|
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||||
@ -10182,7 +10157,6 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.info(`[Chat API] ✅ Cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`);
|
chatApiLogger.info(`[Chat API] ✅ Cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||||
break;
|
break;
|
||||||
} catch (crossError) {
|
} catch (crossError) {
|
||||||
if (isProviderBalanceError(crossError)) balanceBlockedProviders.add(candidate.provider.id);
|
|
||||||
const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
|
const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
|
||||||
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
|
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
|
||||||
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
|
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
|
||||||
@ -10336,7 +10310,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
// 返回错误消息
|
// 返回错误消息
|
||||||
const errorMessageId = generateUUID();
|
const errorMessageId = generateUUID();
|
||||||
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
const errorText = getPublicImageGenerationErrorText(error);
|
||||||
writeAssistantTextChunk(
|
writeAssistantTextChunk(
|
||||||
writer,
|
writer,
|
||||||
errorText,
|
errorText,
|
||||||
@ -10776,10 +10750,9 @@ 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,
|
||||||
);
|
);
|
||||||
@ -10799,7 +10772,7 @@ export async function POST(request: Request) {
|
|||||||
id: errorMessageId,
|
id: errorMessageId,
|
||||||
chatId: id,
|
chatId: id,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [{ type: "text", text: errorText }],
|
parts: [{ type: "text", text: "图片生成失败,请稍后重试" }],
|
||||||
attachments: [], // 必须提供 attachments 字段
|
attachments: [], // 必须提供 attachments 字段
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
videoStorageStatus: null,
|
videoStorageStatus: null,
|
||||||
@ -11707,7 +11680,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, errorMessageId);
|
const errorText = getPublicImageGenerationErrorText(error);
|
||||||
|
|
||||||
dataStream.write({
|
dataStream.write({
|
||||||
type: "data-appendMessage",
|
type: "data-appendMessage",
|
||||||
|
|||||||
@ -8,8 +8,6 @@ import { ArrowLeft, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsR
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { formatBeijingTime } from "@/lib/utils";
|
import { formatBeijingTime } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
type FeaturedImage = {
|
type FeaturedImage = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -76,11 +74,18 @@ export default function FeaturedPage() {
|
|||||||
|
|
||||||
const handleDownload = async (url: string, prompt: string) => {
|
const handleDownload = async (url: string, prompt: string) => {
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url, name: "featured-image" });
|
const response = await fetch(url);
|
||||||
toast.success("原图已开始下载");
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = `featured-${Date.now()}.png`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Download failed:", error);
|
console.error("Download failed:", error);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@ -13,7 +12,6 @@ import { requestLongTaskResult } from "@/lib/client/long-task";
|
|||||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
import { formatBeijingTime } from "@/lib/utils";
|
import { formatBeijingTime } from "@/lib/utils";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
type GalleryImage = {
|
type GalleryImage = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -60,7 +58,7 @@ function buildGalleryDownloadFilename(url: string, alt: string, contentType: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${safeBase}.${getImageDownloadExtension(contentType) || extension}`;
|
return `${safeBase}-${Date.now()}.${getImageDownloadExtension(contentType) || extension}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GalleryPage() {
|
export default function GalleryPage() {
|
||||||
@ -120,11 +118,25 @@ export default function GalleryPage() {
|
|||||||
const parsed = new URL(originalUrl, window.location.origin);
|
const parsed = new URL(originalUrl, window.location.origin);
|
||||||
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
|
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
|
||||||
|
|
||||||
await downloadOriginalImage({ url: source, name: buildGalleryDownloadFilename(originalUrl, alt, "") });
|
const response = await fetch(
|
||||||
|
buildImageProxyUrl(source, { delivery: "original" })
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`download failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = buildGalleryDownloadFilename(originalUrl, alt, blob.type);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
|
||||||
toast.success("图片已开始下载");
|
toast.success("图片已开始下载");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Download failed:", error);
|
console.error("Download failed:", error);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -304,12 +316,7 @@ export default function GalleryPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex flex-wrap justify-center gap-6 px-4">
|
<div className="flex flex-wrap justify-center gap-6 px-4">
|
||||||
{images.map((img) => isRetiredStorageUrl(img.url) ? (
|
{images.map((img) => (
|
||||||
<div key={img.id} className="flex h-[270px] w-[200px] shrink-0 flex-col justify-center rounded-lg border bg-muted p-4 text-center text-sm text-muted-foreground">
|
|
||||||
<p>{RETIRED_STORAGE_MESSAGE}</p>
|
|
||||||
<p className="mt-3 line-clamp-3 text-xs">{img.alt}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
<div
|
||||||
key={img.id}
|
key={img.id}
|
||||||
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
|
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
|
||||||
@ -318,7 +325,7 @@ export default function GalleryPage() {
|
|||||||
>
|
>
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<Image
|
<Image
|
||||||
src={isRetiredStorageUrl(img.thumbnailUrl) ? img.url : img.thumbnailUrl || img.url}
|
src={img.thumbnailUrl || img.url}
|
||||||
alt={img.alt}
|
alt={img.alt}
|
||||||
width={200}
|
width={200}
|
||||||
height={270}
|
height={270}
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
import { ChatSDKError } from "@/lib/errors";
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { aiModelHealthEvent } from "@/lib/db/schema";
|
|
||||||
import { and, desc, eq, sql } from "drizzle-orm";
|
|
||||||
import {
|
import {
|
||||||
|
listAiModelHealthEvents,
|
||||||
restoreAiModelHealth,
|
restoreAiModelHealth,
|
||||||
} from "@/lib/ai/runtime/model-health";
|
} from "@/lib/ai/runtime/model-health";
|
||||||
|
|
||||||
@ -17,30 +15,14 @@ export async function GET(request: Request) {
|
|||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const modelId = searchParams.get("modelId");
|
const modelId = searchParams.get("modelId") || undefined;
|
||||||
const providerId = searchParams.get("providerId");
|
const limit = Number(searchParams.get("limit") || 80);
|
||||||
const rawLimit = Number(searchParams.get("limit") || 20);
|
const events = await listAiModelHealthEvents({
|
||||||
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(200, Math.floor(rawLimit))) : 20;
|
modelId,
|
||||||
const rawPage = Number(searchParams.get("page") || 1);
|
limit: Number.isFinite(limit) ? limit : 80,
|
||||||
const page = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1;
|
});
|
||||||
const filters = [];
|
|
||||||
if (modelId) filters.push(eq(aiModelHealthEvent.modelId, modelId));
|
return Response.json({ events });
|
||||||
if (providerId) filters.push(eq(aiModelHealthEvent.providerId, providerId));
|
|
||||||
if (searchParams.get("eventType")) filters.push(eq(aiModelHealthEvent.eventType, searchParams.get("eventType")!));
|
|
||||||
const start = searchParams.get("startDate");
|
|
||||||
const end = searchParams.get("endDate");
|
|
||||||
if (start && /^\d{4}-\d{2}-\d{2}$/.test(start)) filters.push(sql`${aiModelHealthEvent.createdAt}>=${start}::date`);
|
|
||||||
if (end && /^\d{4}-\d{2}-\d{2}$/.test(end)) filters.push(sql`${aiModelHealthEvent.createdAt}<${end}::date+interval '1 day'`);
|
|
||||||
const events = await db.select({
|
|
||||||
id: aiModelHealthEvent.id, modelId: aiModelHealthEvent.modelId, modelDisplayName: aiModelHealthEvent.modelDisplayName,
|
|
||||||
modelRuntimeId: aiModelHealthEvent.modelRuntimeId, providerName: aiModelHealthEvent.providerName,
|
|
||||||
providerId: aiModelHealthEvent.providerId, eventType: aiModelHealthEvent.eventType,
|
|
||||||
errorMessage: aiModelHealthEvent.errorMessage, elapsedMs: aiModelHealthEvent.elapsedMs,
|
|
||||||
consecutiveFailures: aiModelHealthEvent.consecutiveFailures, autoDisabled: aiModelHealthEvent.autoDisabled,
|
|
||||||
counted: aiModelHealthEvent.counted, createdAt: aiModelHealthEvent.createdAt,
|
|
||||||
}).from(aiModelHealthEvent).where(and(...filters)).orderBy(desc(aiModelHealthEvent.createdAt), desc(aiModelHealthEvent.id))
|
|
||||||
.limit(limit + 1).offset((page - 1) * limit);
|
|
||||||
return Response.json({ events: events.slice(0, limit), hasMore: events.length > limit, page });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ChatSDKError) {
|
if (error instanceof ChatSDKError) {
|
||||||
return error.toResponse();
|
return error.toResponse();
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { aiModel, aiProvider } from "@/lib/db/schema";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
import {
|
import {
|
||||||
|
getAllAiModels,
|
||||||
getAiModelById,
|
getAiModelById,
|
||||||
|
getAiModelsByProviderId,
|
||||||
createAiModel,
|
createAiModel,
|
||||||
updateAiModel,
|
updateAiModel,
|
||||||
deleteAiModel,
|
deleteAiModel,
|
||||||
@ -155,14 +154,8 @@ export async function GET(request: Request) {
|
|||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
const providerId = searchParams.get("providerId");
|
const providerId = searchParams.get("providerId");
|
||||||
|
|
||||||
if (searchParams.get("view") === "options") {
|
|
||||||
const models = await db.select({ id: aiModel.id, modelId: aiModel.modelId, displayName: aiModel.displayName,
|
|
||||||
providerName: aiProvider.name }).from(aiModel).leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id));
|
|
||||||
return Response.json({ models: sortModelsForDisplay(models) });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
const model = await db.query.aiModel.findFirst({ where: eq(aiModel.id, id) });
|
const model = await getAiModelById({ id });
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return new ChatSDKError("bad_request:api", "Model not found").toResponse();
|
return new ChatSDKError("bad_request:api", "Model not found").toResponse();
|
||||||
}
|
}
|
||||||
@ -170,19 +163,12 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (providerId) {
|
if (providerId) {
|
||||||
const models = await db.select().from(aiModel).where(eq(aiModel.providerId, providerId));
|
const models = await getAiModelsByProviderId({ providerId });
|
||||||
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = await db.select().from(aiModel);
|
const models = await getAllAiModels();
|
||||||
const normalized = models.map(normalizeModel);
|
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
||||||
const output = searchParams.get("view") === "summary" ? normalized.map(model => {
|
|
||||||
const { capabilities, defaultParams, imageGenConfig, description, ...summary } = model;
|
|
||||||
const fields = ["kind", "mediaType", "modelType", "outputType", "supportedModes"];
|
|
||||||
return { ...summary, healthLastError: summary.healthLastError?.slice(0, 1200),
|
|
||||||
capabilities: Object.fromEntries(fields.filter(key => capabilities?.[key] !== undefined).map(key => [key, capabilities[key]])) };
|
|
||||||
}) : normalized;
|
|
||||||
return Response.json({ models: sortModelsForDisplay(output) });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ChatSDKError) {
|
if (error instanceof ChatSDKError) {
|
||||||
return error.toResponse();
|
return error.toResponse();
|
||||||
|
|||||||
@ -1,31 +1,237 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage, user } from "@/lib/db/schema";
|
import { chat, generatedImage, message, user } from "@/lib/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, desc, sql, and, gte, lte, like, or, isNotNull } from "drizzle-orm";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
|
||||||
import { ChatSDKError } from "@/lib/errors";
|
function extractVideoUrlFromParts(parts: unknown): string | null {
|
||||||
import { mediaRecordsQuery, parseMediaQuery } from "@/lib/admin/media-records";
|
const text = Array.isArray(parts)
|
||||||
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
? parts
|
||||||
|
.map((part) => (part && typeof part === "object" && "text" in part ? String((part as any).text || "") : ""))
|
||||||
|
.join("\n")
|
||||||
|
: typeof parts === "string"
|
||||||
|
? parts
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
const htmlMatch = text.match(/<video[^>]+src="([^"]+)"/i);
|
||||||
|
if (htmlMatch?.[1]) return htmlMatch[1];
|
||||||
|
|
||||||
|
const markdownMatch = text.match(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/i);
|
||||||
|
if (markdownMatch?.[1]) return markdownMatch[1];
|
||||||
|
|
||||||
|
const plainMatch = text.match(/(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/i);
|
||||||
|
return plainMatch?.[1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await requireAdmin();
|
const session = await auth();
|
||||||
const operator = await db.query.user.findFirst({ where: eq(user.id, session.user.id) });
|
|
||||||
if (operator?.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
if (!session?.user?.id) {
|
||||||
const query = parseMediaQuery(request.nextUrl.searchParams);
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (query.startDate && query.endDate && query.startDate > query.endDate) {
|
|
||||||
return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 });
|
|
||||||
}
|
}
|
||||||
const [result] = await db.execute(mediaRecordsQuery(query));
|
|
||||||
const images = await resolveArchivedImageUrls(result.records);
|
// 检查是否为管理员
|
||||||
const total = Number(result.total);
|
const currentUser = await db.query.user.findFirst({
|
||||||
return NextResponse.json({ images, pagination: { page: query.page, pageSize: query.pageSize, total,
|
where: eq(user.id, session.user.id),
|
||||||
totalPages: Math.ceil(total / query.pageSize), totalImages: Number(result.totalImages), totalVideos: Number(result.totalVideos) } });
|
});
|
||||||
|
|
||||||
|
if (currentUser?.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取查询参数
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const page = Number.parseInt(searchParams.get("page") || "1");
|
||||||
|
const pageSize = Number.parseInt(searchParams.get("pageSize") || "200");
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const search = searchParams.get("search") || "";
|
||||||
|
const startDate = searchParams.get("startDate");
|
||||||
|
const endDate = searchParams.get("endDate");
|
||||||
|
|
||||||
|
// 构建查询条件
|
||||||
|
const imageConditions = [];
|
||||||
|
const videoConditions = [
|
||||||
|
eq(message.role, "assistant"),
|
||||||
|
or(
|
||||||
|
isNotNull(message.temporaryVideoUrl),
|
||||||
|
isNotNull(message.permanentVideoUrl),
|
||||||
|
sql`${message.parts}::text ILIKE '%<video%'`,
|
||||||
|
sql`${message.parts}::text ILIKE '%.mp4%'`
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (search) {
|
||||||
|
// 搜索用户名或邮箱
|
||||||
|
const matchingUsers = await db
|
||||||
|
.select({ id: user.id })
|
||||||
|
.from(user)
|
||||||
|
.where(or(
|
||||||
|
like(user.name, `%${search}%`),
|
||||||
|
like(user.email, `%${search}%`)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (matchingUsers.length > 0) {
|
||||||
|
const matchedUserIds = matchingUsers.map((u) => u.id);
|
||||||
|
imageConditions.push(
|
||||||
|
sql`${generatedImage.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
|
||||||
|
);
|
||||||
|
videoConditions.push(
|
||||||
|
sql`${chat.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 没有匹配的用户,返回空结果
|
||||||
|
return NextResponse.json({
|
||||||
|
images: [],
|
||||||
|
pagination: { page, pageSize, total: 0, totalPages: 0 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
const start = new Date(startDate);
|
||||||
|
imageConditions.push(gte(generatedImage.createdAt, start));
|
||||||
|
videoConditions.push(gte(message.createdAt, start));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
const end = new Date(endDate);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
imageConditions.push(lte(generatedImage.createdAt, end));
|
||||||
|
videoConditions.push(lte(message.createdAt, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageWhereClause = imageConditions.length > 0 ? and(...imageConditions) : undefined;
|
||||||
|
const videoWhereClause = videoConditions.length > 0 ? and(...videoConditions) : undefined;
|
||||||
|
|
||||||
|
// 查询图片总数
|
||||||
|
const imageCountResult = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(generatedImage)
|
||||||
|
.where(imageWhereClause);
|
||||||
|
|
||||||
|
// 查询视频总数
|
||||||
|
const videoCountResult = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(message)
|
||||||
|
.innerJoin(chat, eq(message.chatId, chat.id))
|
||||||
|
.where(videoWhereClause);
|
||||||
|
|
||||||
|
const totalImages = imageCountResult[0]?.count || 0;
|
||||||
|
const totalVideos = videoCountResult[0]?.count || 0;
|
||||||
|
const total = totalImages + totalVideos;
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
const fetchLimit = Math.max(page * pageSize, pageSize);
|
||||||
|
|
||||||
|
// 查询图片列表(包含用户信息)
|
||||||
|
const imageRows = await db
|
||||||
|
.select({
|
||||||
|
id: generatedImage.id,
|
||||||
|
userId: generatedImage.userId,
|
||||||
|
userName: user.name,
|
||||||
|
userEmail: user.email,
|
||||||
|
userDepartment: user.department,
|
||||||
|
thumbnailUrl: generatedImage.thumbnailUrl,
|
||||||
|
originalUrl: generatedImage.originalUrl,
|
||||||
|
imageSize: generatedImage.imageSize,
|
||||||
|
prompt: generatedImage.prompt,
|
||||||
|
modelId: generatedImage.modelId,
|
||||||
|
isFeatured: generatedImage.isFeatured,
|
||||||
|
featuredCategory: generatedImage.featuredCategory,
|
||||||
|
featuredAt: generatedImage.featuredAt,
|
||||||
|
createdAt: generatedImage.createdAt,
|
||||||
|
chatId: generatedImage.chatId,
|
||||||
|
recordType: sql<"image">`'image'`,
|
||||||
|
videoUrl: sql<string | null>`null`,
|
||||||
|
})
|
||||||
|
.from(generatedImage)
|
||||||
|
.leftJoin(user, eq(generatedImage.userId, user.id))
|
||||||
|
.where(imageWhereClause)
|
||||||
|
.orderBy(desc(generatedImage.createdAt))
|
||||||
|
.limit(fetchLimit);
|
||||||
|
|
||||||
|
// 查询视频列表(来自消息表)
|
||||||
|
const videoRows = await db
|
||||||
|
.select({
|
||||||
|
id: message.id,
|
||||||
|
userId: chat.userId,
|
||||||
|
userName: user.name,
|
||||||
|
userEmail: user.email,
|
||||||
|
userDepartment: user.department,
|
||||||
|
temporaryVideoUrl: message.temporaryVideoUrl,
|
||||||
|
permanentVideoUrl: message.permanentVideoUrl,
|
||||||
|
parts: message.parts,
|
||||||
|
videoMetadata: message.videoMetadata,
|
||||||
|
createdAt: message.createdAt,
|
||||||
|
chatId: message.chatId,
|
||||||
|
})
|
||||||
|
.from(message)
|
||||||
|
.innerJoin(chat, eq(message.chatId, chat.id))
|
||||||
|
.leftJoin(user, eq(chat.userId, user.id))
|
||||||
|
.where(videoWhereClause)
|
||||||
|
.orderBy(desc(message.createdAt))
|
||||||
|
.limit(fetchLimit);
|
||||||
|
|
||||||
|
const normalizedVideos = videoRows
|
||||||
|
.map((item) => {
|
||||||
|
const metadata = (item.videoMetadata as Record<string, any> | null) || {};
|
||||||
|
const videoUrl =
|
||||||
|
item.permanentVideoUrl ||
|
||||||
|
item.temporaryVideoUrl ||
|
||||||
|
extractVideoUrlFromParts(item.parts);
|
||||||
|
|
||||||
|
if (!videoUrl) return null;
|
||||||
|
|
||||||
|
const resolution =
|
||||||
|
typeof metadata.resolution === "string" && metadata.resolution.trim()
|
||||||
|
? metadata.resolution.trim()
|
||||||
|
: null;
|
||||||
|
const prompt =
|
||||||
|
typeof metadata.prompt === "string" && metadata.prompt.trim()
|
||||||
|
? metadata.prompt.trim()
|
||||||
|
: null;
|
||||||
|
const modelId =
|
||||||
|
typeof metadata.model === "string" && metadata.model.trim()
|
||||||
|
? metadata.model.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `video-${item.id}`,
|
||||||
|
sourceMessageId: item.id,
|
||||||
|
userId: item.userId,
|
||||||
|
userName: item.userName,
|
||||||
|
userEmail: item.userEmail,
|
||||||
|
userDepartment: item.userDepartment,
|
||||||
|
thumbnailUrl: videoUrl,
|
||||||
|
originalUrl: videoUrl,
|
||||||
|
videoUrl,
|
||||||
|
imageSize: resolution,
|
||||||
|
prompt,
|
||||||
|
modelId,
|
||||||
|
isFeatured: false,
|
||||||
|
featuredCategory: null,
|
||||||
|
featuredAt: null,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
chatId: item.chatId,
|
||||||
|
recordType: "video" as const,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is NonNullable<typeof item> => Boolean(item));
|
||||||
|
|
||||||
|
const merged = [...imageRows, ...normalizedVideos].sort(
|
||||||
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
);
|
||||||
|
const images = merged.slice(offset, offset + pageSize);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
images,
|
||||||
|
pagination: { page, pageSize, total, totalPages, totalImages, totalVideos }
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ChatSDKError) return error.toResponse();
|
console.error("[Admin Images API] Error:", error);
|
||||||
console.error("[Admin Images API] GET failed", error);
|
return NextResponse.json({ error: "Failed to fetch images" }, { status: 500 });
|
||||||
return NextResponse.json({ error: "记录加载失败,请重试" }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
|
||||||
import { ChatSDKError } from "@/lib/errors";
|
|
||||||
import { parseMediaQuery } from "@/lib/admin/media-records";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
await requireAdmin();
|
|
||||||
const q = parseMediaQuery(request.nextUrl.searchParams);
|
|
||||||
const status = request.nextUrl.searchParams.get("status") || "all";
|
|
||||||
const filters = [sql`true`];
|
|
||||||
if (q.search)
|
|
||||||
filters.push(sql`(u.name ilike ${`%${q.search}%`} or u.email ilike ${`%${q.search}%`})`);
|
|
||||||
if (["pending", "running", "failed", "succeeded"].includes(status))
|
|
||||||
filters.push(sql`t.status=${status}`);
|
|
||||||
if (q.startDate) filters.push(sql`t."createdAt">=${q.startDate}::date - interval '8 hours'`);
|
|
||||||
if (q.endDate) filters.push(sql`t."createdAt"<${q.endDate}::date+interval '16 hours'`);
|
|
||||||
const [result] = await db.execute(sql`with filtered as not materialized (
|
|
||||||
select t.id,u.name as "userName",t.status,t."createdAt"+interval '8 hours' as "createdAt",t.error,coalesce(t.request->>'modelId',t.request->>'model') as model,
|
|
||||||
extract(epoch from (t."completedAt"-t."startedAt"))*1000 as "elapsedMs"
|
|
||||||
from "CanvasImageTask" t left join "User" u on u.id=t."userId" where ${sql.join(filters, sql` and `)}
|
|
||||||
) select (select count(*)::int from filtered) as total,
|
|
||||||
coalesce((select jsonb_agg(p) from (select * from filtered order by "createdAt" desc,id desc limit ${q.pageSize} offset ${(q.page - 1) * q.pageSize}) p),'[]'::jsonb) as tasks`);
|
|
||||||
return NextResponse.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ChatSDKError) return error.toResponse();
|
|
||||||
return NextResponse.json({ error: "任务记录加载失败,请重试" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -106,22 +106,6 @@ export async function POST(request: Request) {
|
|||||||
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
|
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
|
||||||
: catalogPresets;
|
: catalogPresets;
|
||||||
|
|
||||||
if (body?.dryRun === true) {
|
|
||||||
const catalogIds = new Set(catalogPresets.map(preset => preset.modelId));
|
|
||||||
const fields = ["displayName", "description", "supportsVision", "supportsImageGen", "isHidden", "sortOrder", "capabilities", "defaultParams", "imageGenConfig"] as const;
|
|
||||||
const added = presets.filter(preset => !existingModelIds.has(preset.modelId)).map(preset => ({ modelId: preset.modelId, displayName: preset.displayName }));
|
|
||||||
const updated = presets.flatMap(preset => {
|
|
||||||
const existing = existingModels.find(model => model.modelId === preset.modelId);
|
|
||||||
if (!existing) return [];
|
|
||||||
const changedFields: string[] = fields.filter(field => preset[field] !== undefined && JSON.stringify(existing[field]) !== JSON.stringify(preset[field]));
|
|
||||||
if (existing.avatarUrl) changedFields.push("avatarUrl");
|
|
||||||
if (requestedModelIds !== null && !existing.isEnabled) changedFields.push("isEnabled");
|
|
||||||
return changedFields.length ? [{ modelId: preset.modelId, displayName: existing.displayName, changedFields }] : [];
|
|
||||||
});
|
|
||||||
const disabled = requestedModelIds === null ? existingModels.filter(model => !catalogIds.has(model.modelId) && (model.isEnabled || !model.isHidden)).map(model => ({ modelId: model.modelId, displayName: model.displayName })) : [];
|
|
||||||
return NextResponse.json({ preview: { added, updated, disabled }, total: catalogPresets.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
const addedModels: AiModel[] = [];
|
const addedModels: AiModel[] = [];
|
||||||
const updatedModels: AiModel[] = [];
|
const updatedModels: AiModel[] = [];
|
||||||
const disabledModels: AiModel[] = [];
|
const disabledModels: AiModel[] = [];
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { canvasProject } from "@/lib/db/schema";
|
import { canvasProject } from "@/lib/db/schema";
|
||||||
@ -57,20 +56,14 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const requestedId = body.id === undefined ? undefined : z.string().uuid().safeParse(body.id);
|
|
||||||
if (requestedId && !requestedId.success) {
|
|
||||||
return NextResponse.json({ success: false, error: "项目编号无效" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const id = requestedId?.data;
|
|
||||||
const title = String(body.title || "").trim() || "未命名生图项目";
|
const title = String(body.title || "").trim() || "未命名生图项目";
|
||||||
const description = String(body.description || "").trim() || null;
|
const description = String(body.description || "").trim() || null;
|
||||||
const thumbnail = String(body.thumbnail || "").trim() || null;
|
const thumbnail = String(body.thumbnail || "").trim() || null;
|
||||||
const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState);
|
const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState);
|
||||||
|
|
||||||
const [insertedProject] = await db
|
const [project] = await db
|
||||||
.insert(canvasProject)
|
.insert(canvasProject)
|
||||||
.values({
|
.values({
|
||||||
id,
|
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@ -85,22 +78,8 @@ export async function POST(request: NextRequest) {
|
|||||||
agentProjectState: projectState,
|
agentProjectState: projectState,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
})
|
||||||
.onConflictDoNothing({ target: canvasProject.id })
|
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const project = insertedProject || (id ? (await db
|
|
||||||
.select()
|
|
||||||
.from(canvasProject)
|
|
||||||
.where(and(
|
|
||||||
eq(canvasProject.id, id),
|
|
||||||
eq(canvasProject.userId, session.user.id),
|
|
||||||
eq(canvasProject.workspaceType, "agent")
|
|
||||||
))
|
|
||||||
.limit(1))[0] : undefined);
|
|
||||||
if (!project) {
|
|
||||||
return NextResponse.json({ success: false, error: "项目编号不可用,请重试" }, { status: 409 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
|
return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AgentProjects API] 创建项目失败:", error);
|
console.error("[AgentProjects API] 创建项目失败:", error);
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
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";
|
||||||
|
|
||||||
@ -13,6 +12,7 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
|||||||
"tripo3d.ai",
|
"tripo3d.ai",
|
||||||
"tripo3d.com",
|
"tripo3d.com",
|
||||||
"worldlabs.ai",
|
"worldlabs.ai",
|
||||||
|
"s3.1080p.hk",
|
||||||
"img.yydd.ai",
|
"img.yydd.ai",
|
||||||
"scqny.jiansun.vip",
|
"scqny.jiansun.vip",
|
||||||
"blob.vercel-storage.com",
|
"blob.vercel-storage.com",
|
||||||
@ -25,6 +25,7 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
|||||||
|
|
||||||
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
|
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
|
||||||
"bbyy.ai",
|
"bbyy.ai",
|
||||||
|
"s3.1080p.hk",
|
||||||
"img.yydd.ai",
|
"img.yydd.ai",
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -164,9 +165,6 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
|
|||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const rawUrl = String(searchParams.get("url") || "").trim();
|
const rawUrl = String(searchParams.get("url") || "").trim();
|
||||||
if (isRetiredStorageUrl(rawUrl)) {
|
|
||||||
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
|
||||||
}
|
|
||||||
if (!rawUrl) {
|
if (!rawUrl) {
|
||||||
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,6 @@ 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;
|
||||||
@ -40,8 +39,7 @@ export async function GET(
|
|||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
|
return NextResponse.json({ success: true, task });
|
||||||
return NextResponse.json({ success: true, task: { ...task, failure, error: failure ? formatRequestFailure(failure, task.id) : task.error } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
@ -53,11 +51,9 @@ export async function POST(
|
|||||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let requestId: string | undefined;
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
|
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
|
||||||
requestId = command.requestId;
|
|
||||||
const result = await executeCanvasCapabilityRuntime({
|
const result = await executeCanvasCapabilityRuntime({
|
||||||
command,
|
command,
|
||||||
projectId: id,
|
projectId: id,
|
||||||
@ -71,9 +67,8 @@ export async function POST(
|
|||||||
error instanceof CanvasCapabilityRuntimeError ||
|
error instanceof CanvasCapabilityRuntimeError ||
|
||||||
error instanceof CanvasAgentMutationError
|
error instanceof CanvasAgentMutationError
|
||||||
) {
|
) {
|
||||||
const failure = getRequestFailureDetails(error);
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: formatRequestFailure(failure, requestId), code: error.code, failure, requestId },
|
{ success: false, error: error.message, code: error.code },
|
||||||
{ status: error.status }
|
{ status: error.status }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -87,9 +82,9 @@ export async function POST(
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const failure = getRequestFailureDetails(error, "执行画布能力失败");
|
const message = error instanceof Error ? error.message : "执行画布能力失败";
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: formatRequestFailure(failure, requestId), failure, requestId },
|
{ success: false, error: message },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -207,11 +207,7 @@ export async function PUT(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
return NextResponse.json({
|
return NextResponse.json({ success: true, data: saved });
|
||||||
success: true,
|
|
||||||
data: saved,
|
|
||||||
previousCanvasRevision: existingProject?.updatedAt.toISOString() ?? null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("更新/创建画布失败:", error);
|
console.error("更新/创建画布失败:", error);
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
|
||||||
import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation";
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
|
||||||
import { after, NextRequest, NextResponse } from "next/server";
|
import { after, NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
|
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
|
||||||
@ -16,22 +12,6 @@ import {
|
|||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 120;
|
||||||
const logger = createLogger("canvas.download");
|
|
||||||
|
|
||||||
function failed(failure: DownloadFailure, requestId: string) {
|
|
||||||
logger.warn("request_failed", { requestId, code: failure.code, status: failure.status });
|
|
||||||
return NextResponse.json({ success: false, error: formatDownloadFailure(failure, requestId), failure, requestId }, {
|
|
||||||
status: failure.status,
|
|
||||||
headers: { "Cache-Control": "no-store", ...(failure.status === 429 ? { "Retry-After": "3" } : {}) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function preparing(requestId: string) {
|
|
||||||
return NextResponse.json({ delivery: "preparing", retryAfter: 2, requestId,
|
|
||||||
message: "正在准备原图,完成后将自动开始下载" }, {
|
|
||||||
status: 202, headers: { "Retry-After": "2", "Cache-Control": "no-store" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
|
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
|
||||||
// A proxy candidate for the same original must not repeat the same slow
|
// A proxy candidate for the same original must not repeat the same slow
|
||||||
@ -40,25 +20,22 @@ async function prepareDownload(input: DownloadPreparationInput, signal: AbortSig
|
|||||||
const source = extractImageProxySourceUrl(url) || url;
|
const source = extractImageProxySourceUrl(url) || url;
|
||||||
return inferOriginalImageUrl(source) || source;
|
return inferOriginalImageUrl(source) || source;
|
||||||
}))];
|
}))];
|
||||||
let lastError: unknown;
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
for (let attempt = 0; attempt < 2; attempt++) {
|
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
for (const url of sources) {
|
for (const url of sources) {
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
const result = await fetchCanvasDownloadAsset({
|
const result = await fetchCanvasDownloadAsset({
|
||||||
file: { url, name: "original" }, index: 0,
|
file: { url, name: "original" }, index: 0,
|
||||||
requestOrigin: input.requestOrigin, cookie: input.cookie,
|
requestOrigin: input.requestOrigin, cookie: input.cookie,
|
||||||
includeSequencePrefix: false, timeoutMs: 60_000, retryCount: 1, signal,
|
includeSequencePrefix: false, timeoutMs: 12_000, retryCount: 1, signal,
|
||||||
});
|
});
|
||||||
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
|
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
|
||||||
lastError = result.error;
|
|
||||||
}
|
}
|
||||||
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
|
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
|
||||||
// No AI/model request is involved and a completed source fetch is shared.
|
// No AI/model request is involved and a completed source fetch is shared.
|
||||||
if (attempt < 1 && getDownloadFailure(lastError).retryable) await new Promise(resolve => setTimeout(resolve, 1_000));
|
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 1_000));
|
||||||
else break;
|
|
||||||
}
|
}
|
||||||
throw new Error(String(lastError || "Original image sources unavailable"));
|
throw new Error("Original image sources unavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRequestOrigin(request: NextRequest): string {
|
function getRequestOrigin(request: NextRequest): string {
|
||||||
@ -98,43 +75,27 @@ function resolveDownloadUrls(body: Record<string, unknown>, origin: string): str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const requestId = randomUUID();
|
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return failed(getDownloadFailure("未登录"), requestId);
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
const requestOrigin = getRequestOrigin(request);
|
const requestOrigin = getRequestOrigin(request);
|
||||||
const candidates = resolveDownloadUrls(body, requestOrigin);
|
const urls = resolveDownloadUrls(body, requestOrigin);
|
||||||
if (candidates.length && candidates.every(isRetiredStorageUrl)) {
|
|
||||||
return failed(getDownloadFailure(RETIRED_STORAGE_MESSAGE), requestId);
|
|
||||||
}
|
|
||||||
const urls = candidates.filter(url => !isRetiredStorageUrl(url));
|
|
||||||
const requestedUrls = [...urls];
|
const requestedUrls = [...urls];
|
||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
return failed(getDownloadFailure("图片地址无效"), requestId);
|
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
||||||
if (body.preferDirect === true) {
|
if (body.preferDirect === true) {
|
||||||
const { task, direct, canPrepareDirect } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch((error) => {
|
const { task, direct } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch(() => ({ task: null, direct: null }));
|
||||||
logger.warn("cdn_lookup_failed", error, { requestId });
|
|
||||||
return { task: null, direct: null, canPrepareDirect: false };
|
|
||||||
});
|
|
||||||
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
|
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
|
||||||
if (task && canPrepareDirect && body.preparation === "async") {
|
|
||||||
const asset = (task.metadata as { downloadAsset?: { status?: string; failure?: DownloadFailure; retryAt?: string } } | null)?.downloadAsset;
|
|
||||||
if (asset?.status === "abandoned" || (asset?.status === "failed" && Date.parse(asset.retryAt || "") > Date.now())) {
|
|
||||||
return failed(asset.failure || getDownloadFailure(null), task.id);
|
|
||||||
}
|
|
||||||
scheduleImageDownloadPreparation(task.id);
|
|
||||||
return preparing(task.id);
|
|
||||||
}
|
|
||||||
if (task?.targetUrl) {
|
if (task?.targetUrl) {
|
||||||
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
|
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
|
||||||
scheduleImageDownloadPreparation(task.id);
|
after(() => prepareImageArchiveDownload(task.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cookie = request.headers.get("cookie") || "";
|
const cookie = request.headers.get("cookie") || "";
|
||||||
@ -143,10 +104,13 @@ export async function POST(request: NextRequest) {
|
|||||||
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
|
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
|
||||||
});
|
});
|
||||||
if (result.status === "preparing") {
|
if (result.status === "preparing") {
|
||||||
return preparing(result.requestId);
|
return NextResponse.json({ delivery: "preparing", retryAfter: 3,
|
||||||
|
message: "正在准备原图,完成后将自动下载" }, {
|
||||||
|
status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (result.status === "failed") {
|
if (result.status === "failed") {
|
||||||
return failed(result.failure, result.requestId);
|
return NextResponse.json({ success: false, error: result.error }, { status: 502 });
|
||||||
}
|
}
|
||||||
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
|
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
|
||||||
"Content-Type": result.asset.contentType,
|
"Content-Type": result.asset.contentType,
|
||||||
@ -167,14 +131,17 @@ export async function POST(request: NextRequest) {
|
|||||||
includeSequencePrefix: false,
|
includeSequencePrefix: false,
|
||||||
});
|
});
|
||||||
if (result.ok) break;
|
if (result.ok) break;
|
||||||
failures.push(result.error);
|
failures.push(`${url}: ${result.error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result?.ok) {
|
if (!result?.ok) {
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
logger.warn("all_sources_failed", { requestId, failures });
|
console.warn("[CanvasDownloadImage] All candidates failed:", failures);
|
||||||
}
|
}
|
||||||
return failed(getDownloadFailure(result?.error), requestId);
|
return NextResponse.json(
|
||||||
|
{ success: false, error: result?.error || "图片源文件暂时不可访问" },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NextResponse(result.bytes, {
|
return new NextResponse(result.bytes, {
|
||||||
@ -187,7 +154,7 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("download_failed", error, { requestId });
|
console.error("[CanvasDownloadImage] Failed:", error);
|
||||||
return failed(getDownloadFailure(error), requestId);
|
return NextResponse.json({ success: false, error: "图片下载失败" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
|
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
|
||||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
|
||||||
|
|
||||||
export const maxDuration = 1200;
|
export const maxDuration = 1200;
|
||||||
|
|
||||||
@ -24,15 +23,13 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
|
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
task: {
|
task: {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
attempts: task.attempts,
|
attempts: task.attempts,
|
||||||
error: failure ? formatRequestFailure(failure, task.id) : null,
|
error: task.error || null,
|
||||||
failure,
|
|
||||||
result: task.result || null,
|
result: task.result || null,
|
||||||
createdAt: task.createdAt,
|
createdAt: task.createdAt,
|
||||||
startedAt: task.startedAt,
|
startedAt: task.startedAt,
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage, user, aiModel } from "@/lib/db/schema";
|
import { generatedImage, user, aiModel } from "@/lib/db/schema";
|
||||||
import { and, eq, desc, sql } from "drizzle-orm";
|
import { eq, desc, sql } from "drizzle-orm";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_URL_PATTERN } from "@/lib/storage/retired-storage";
|
|
||||||
import { cachedPublicJson } from "@/lib/public-api/performance";
|
import { cachedPublicJson } from "@/lib/public-api/performance";
|
||||||
|
|
||||||
const FEATURED_TIMEOUT_MS = 1800;
|
const FEATURED_TIMEOUT_MS = 1800;
|
||||||
@ -58,17 +57,13 @@ export async function GET(request: NextRequest) {
|
|||||||
userName: user.name,
|
userName: user.name,
|
||||||
userEmail: user.email,
|
userEmail: user.email,
|
||||||
userDepartment: user.department,
|
userDepartment: user.department,
|
||||||
modelName: sql<string | null>`(select ${aiModel.displayName} from ${aiModel}
|
modelName: aiModel.displayName,
|
||||||
where ${aiModel.modelId} = ${generatedImage.modelId}
|
|
||||||
order by ${aiModel.isEnabled} desc, ${aiModel.createdAt} desc, ${aiModel.id} asc limit 1)`,
|
|
||||||
totalCount: sql<number>`count(*) over()::int`,
|
totalCount: sql<number>`count(*) over()::int`,
|
||||||
})
|
})
|
||||||
.from(generatedImage)
|
.from(generatedImage)
|
||||||
.leftJoin(user, eq(generatedImage.userId, user.id))
|
.leftJoin(user, eq(generatedImage.userId, user.id))
|
||||||
.where(and(
|
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
|
||||||
eq(generatedImage.isFeatured, true),
|
.where(eq(generatedImage.isFeatured, true))
|
||||||
sql`${generatedImage.originalUrl} !~* ${RETIRED_STORAGE_URL_PATTERN}`,
|
|
||||||
))
|
|
||||||
.orderBy(desc(generatedImage.featuredAt))
|
.orderBy(desc(generatedImage.featuredAt))
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
@ -79,7 +74,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return {
|
return {
|
||||||
images: featuredImages.map((img) => ({
|
images: featuredImages.map((img) => ({
|
||||||
id: img.id,
|
id: img.id,
|
||||||
thumbnailUrl: isRetiredStorageUrl(img.thumbnailUrl) ? img.originalUrl : img.thumbnailUrl,
|
thumbnailUrl: img.thumbnailUrl,
|
||||||
originalUrl: img.originalUrl,
|
originalUrl: img.originalUrl,
|
||||||
imageSize: img.imageSize,
|
imageSize: img.imageSize,
|
||||||
prompt: img.prompt,
|
prompt: img.prompt,
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
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;
|
||||||
@ -13,7 +9,6 @@ const MIN_WIDTH = 64;
|
|||||||
const MIN_QUALITY = 40;
|
const MIN_QUALITY = 40;
|
||||||
const MAX_QUALITY = 100;
|
const MAX_QUALITY = 100;
|
||||||
const FETCH_TIMEOUT_MS = 15000;
|
const FETCH_TIMEOUT_MS = 15000;
|
||||||
const SMALL_PREVIEW_FETCH_TIMEOUT_MS = 45000;
|
|
||||||
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
||||||
const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256;
|
const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256;
|
||||||
|
|
||||||
@ -270,19 +265,7 @@ function redirectToDirectImage(target: URL): NextResponse {
|
|||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
const requestId = crypto.randomUUID();
|
|
||||||
const downloadFailure = (error: unknown) => {
|
|
||||||
const failure = getDownloadFailure(error);
|
|
||||||
logger.warn("original_download_failed", error, { requestId, code: failure.code, status: failure.status });
|
|
||||||
return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId }, {
|
|
||||||
status: failure.status, headers: { "Cache-Control": "no-store" },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const rawUrl = (searchParams.get("url") || "").trim();
|
const rawUrl = (searchParams.get("url") || "").trim();
|
||||||
if (isRetiredStorageUrl(rawUrl)) {
|
|
||||||
if (searchParams.get("original") === "1") return downloadFailure(RETIRED_STORAGE_MESSAGE);
|
|
||||||
return NextResponse.json({ error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
|
||||||
}
|
|
||||||
if (!rawUrl) {
|
if (!rawUrl) {
|
||||||
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
|
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@ -318,18 +301,6 @@ 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,
|
||||||
@ -368,33 +339,20 @@ 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: fetchSignal,
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
headers: {
|
headers: {
|
||||||
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return upstreamFailure(error);
|
console.error("[ImageProxy] Upstream fetch failed:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch upstream image" }, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!upstreamResponse.ok) {
|
if (!upstreamResponse.ok) {
|
||||||
if (original) return downloadFailure(`HTTP ${upstreamResponse.status}`);
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Upstream responded with ${upstreamResponse.status}` },
|
{ error: `Upstream responded with ${upstreamResponse.status}` },
|
||||||
{ status: 502 }
|
{ status: 502 }
|
||||||
@ -403,16 +361,10 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let originalBuffer: Buffer;
|
const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||||
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,5 +1,4 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { resolveSiteBranding } from "@/lib/site-branding";
|
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@ -153,7 +152,7 @@ import {
|
|||||||
} from "@/lib/ai-wash/config";
|
} from "@/lib/ai-wash/config";
|
||||||
import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config";
|
import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config";
|
||||||
import { withTimeout } from "@/lib/public-api/performance";
|
import { withTimeout } from "@/lib/public-api/performance";
|
||||||
import type { AgentCategoryTreeConfig, AgentWorkflowUiConfig } from "@/types/agent";
|
import type { AgentCategoryTreeConfig } from "@/types/agent";
|
||||||
|
|
||||||
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
|
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
|
||||||
const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories";
|
const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories";
|
||||||
@ -553,7 +552,6 @@ function normalizeAgentCategoryTreeConfig(value: unknown): AgentCategoryTreeConf
|
|||||||
type PublicSiteSettingsScope =
|
type PublicSiteSettingsScope =
|
||||||
| "agent-brand-model"
|
| "agent-brand-model"
|
||||||
| "agent-canvas"
|
| "agent-canvas"
|
||||||
| "agent-entry"
|
|
||||||
| "agent-presets"
|
| "agent-presets"
|
||||||
| "auth"
|
| "auth"
|
||||||
| "branding"
|
| "branding"
|
||||||
@ -565,7 +563,6 @@ type PublicSiteSettingsScope =
|
|||||||
const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
|
const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
|
||||||
"agent-brand-model",
|
"agent-brand-model",
|
||||||
"agent-canvas",
|
"agent-canvas",
|
||||||
"agent-entry",
|
|
||||||
"agent-presets",
|
"agent-presets",
|
||||||
"auth",
|
"auth",
|
||||||
"branding",
|
"branding",
|
||||||
@ -609,60 +606,22 @@ const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
|
|||||||
lovartHomeSidebarConfig: normalizeLovartHomeSidebarConfig(undefined),
|
lovartHomeSidebarConfig: normalizeLovartHomeSidebarConfig(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
const AGENT_CANVAS_SETTING_KEYS = [
|
|
||||||
REALTIME_CANVAS_CONFIG_KEY,
|
|
||||||
REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
|
|
||||||
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
|
|
||||||
LAYER_SPLIT_CONFIG_KEY,
|
|
||||||
ANGLE_EDIT_CONFIG_KEY,
|
|
||||||
CANVAS_AGENT_MODE_ENABLED_KEY,
|
|
||||||
...CANVAS_AGENT_FEATURE_SETTING_KEYS,
|
|
||||||
VIDEO_CANVAS_ENABLED_KEY,
|
|
||||||
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
|
|
||||||
ENHANCE_PRO_KREA_SETTING_KEY,
|
|
||||||
AI_WASH_CONFIG_KEY,
|
|
||||||
STYLE_PRO_CONFIG_KEY,
|
|
||||||
AGENT_WORKFLOW_UI_CONFIG_KEY,
|
|
||||||
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
|
|
||||||
];
|
|
||||||
|
|
||||||
async function getAgentCanvasSettingsEtag(scope: string) {
|
|
||||||
const [settings, rows, providers] = await Promise.all([
|
|
||||||
db.select({ id: siteSettings.id, updatedAt: siteSettings.updatedAt })
|
|
||||||
.from(siteSettings).limit(1),
|
|
||||||
db.select({ key: systemSettings.key, updatedAt: systemSettings.updatedAt })
|
|
||||||
.from(systemSettings)
|
|
||||||
.where(inArray(systemSettings.key, AGENT_CANVAS_SETTING_KEYS)),
|
|
||||||
db.select({ id: aiProvider.id, updatedAt: aiProvider.updatedAt, enabled: aiProvider.isEnabled })
|
|
||||||
.from(aiProvider)
|
|
||||||
.where(inArray(aiProvider.type, ["tripo-3d", "worldlabs-3d"])),
|
|
||||||
]);
|
|
||||||
const versions = [
|
|
||||||
...settings.map((row) => `${row.id}:${row.updatedAt.getTime()}`),
|
|
||||||
...rows.map((row) => `${row.key}:${row.updatedAt.getTime()}`),
|
|
||||||
...providers.map((row) => `${row.id}:${row.updatedAt.getTime()}:${row.enabled}`),
|
|
||||||
].sort();
|
|
||||||
return `W/"${scope}/${versions.join(",")}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createScopedSiteSettingsResponse(
|
function createScopedSiteSettingsResponse(
|
||||||
scope: PublicSiteSettingsScope,
|
scope: PublicSiteSettingsScope,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
cacheStatus: "HIT" | "MISS" | "STALE" | "FALLBACK" = "MISS",
|
cacheStatus: "HIT" | "MISS" | "STALE" | "FALLBACK" = "MISS",
|
||||||
cacheKey: string = scope,
|
cacheKey: string = scope,
|
||||||
options: { fallback?: boolean; startedAt?: number; etag?: string } = {}
|
options: { fallback?: boolean; startedAt?: number } = {}
|
||||||
) {
|
) {
|
||||||
if (cacheStatus === "MISS") {
|
if (cacheStatus === "MISS") {
|
||||||
setScopedSiteSettingsCache(
|
setScopedSiteSettingsCache(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
data,
|
data,
|
||||||
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS,
|
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS
|
||||||
options.etag
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = NextResponse.json(data);
|
const response = NextResponse.json(data);
|
||||||
if (options.etag) response.headers.set("ETag", options.etag);
|
|
||||||
// Lightweight public settings change rarely; keep them browser-local briefly
|
// Lightweight public settings change rarely; keep them browser-local briefly
|
||||||
// so page opens do not repeatedly wait on the large full settings endpoint.
|
// so page opens do not repeatedly wait on the large full settings endpoint.
|
||||||
response.headers.set(
|
response.headers.set(
|
||||||
@ -704,12 +663,12 @@ async function readScopedSiteSettings(keys: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
||||||
return resolveSiteBranding({
|
return {
|
||||||
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
||||||
siteLogo: String(settings?.siteLogo || ""),
|
siteLogo: settings?.siteLogo || null,
|
||||||
siteLogoDark: String(settings?.siteLogoDark || ""),
|
siteLogoDark: settings?.siteLogoDark || null,
|
||||||
favicon: String(settings?.favicon || ""),
|
favicon: settings?.favicon || null,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getScopedPublicSiteSettings(
|
async function getScopedPublicSiteSettings(
|
||||||
@ -717,7 +676,6 @@ async function getScopedPublicSiteSettings(
|
|||||||
options: {
|
options: {
|
||||||
brandName?: string;
|
brandName?: string;
|
||||||
cacheKey?: string;
|
cacheKey?: string;
|
||||||
etag?: string;
|
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
if (scope === "auth") {
|
if (scope === "auth") {
|
||||||
@ -779,8 +737,23 @@ async function getScopedPublicSiteSettings(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === "agent-canvas" || scope === "agent-entry") {
|
if (scope === "agent-canvas") {
|
||||||
const { settings, valueByKey } = await readScopedSiteSettings(AGENT_CANVAS_SETTING_KEYS);
|
const { settings, valueByKey } = await readScopedSiteSettings([
|
||||||
|
REALTIME_CANVAS_CONFIG_KEY,
|
||||||
|
REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
|
||||||
|
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
|
||||||
|
LAYER_SPLIT_CONFIG_KEY,
|
||||||
|
ANGLE_EDIT_CONFIG_KEY,
|
||||||
|
CANVAS_AGENT_MODE_ENABLED_KEY,
|
||||||
|
...CANVAS_AGENT_FEATURE_SETTING_KEYS,
|
||||||
|
VIDEO_CANVAS_ENABLED_KEY,
|
||||||
|
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
|
||||||
|
ENHANCE_PRO_KREA_SETTING_KEY,
|
||||||
|
AI_WASH_CONFIG_KEY,
|
||||||
|
STYLE_PRO_CONFIG_KEY,
|
||||||
|
AGENT_WORKFLOW_UI_CONFIG_KEY,
|
||||||
|
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
|
||||||
|
]);
|
||||||
const canvasFeatureAvailability = await readCanvasFeatureAvailability(
|
const canvasFeatureAvailability = await readCanvasFeatureAvailability(
|
||||||
valueByKey
|
valueByKey
|
||||||
);
|
);
|
||||||
@ -788,8 +761,6 @@ async function getScopedPublicSiteSettings(
|
|||||||
const videoCanvasSidebarEnabled = normalizeVideoCanvasSidebarEnabled(
|
const videoCanvasSidebarEnabled = normalizeVideoCanvasSidebarEnabled(
|
||||||
valueByKey.get(VIDEO_CANVAS_SIDEBAR_ENABLED_KEY)
|
valueByKey.get(VIDEO_CANVAS_SIDEBAR_ENABLED_KEY)
|
||||||
);
|
);
|
||||||
const rawUiConfig = valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY) as
|
|
||||||
Partial<AgentWorkflowUiConfig> | undefined;
|
|
||||||
return createScopedSiteSettingsResponse(scope, {
|
return createScopedSiteSettingsResponse(scope, {
|
||||||
...createBrandingSettings(typedSettings),
|
...createBrandingSettings(typedSettings),
|
||||||
agentEnabled: typedSettings?.agentEnabled ?? true,
|
agentEnabled: typedSettings?.agentEnabled ?? true,
|
||||||
@ -825,18 +796,12 @@ async function getScopedPublicSiteSettings(
|
|||||||
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
|
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
|
||||||
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
|
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
|
||||||
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
|
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
|
||||||
scope === "agent-entry"
|
valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY)
|
||||||
? {
|
|
||||||
steps: [],
|
|
||||||
pageFeatureSettings: rawUiConfig?.pageFeatureSettings,
|
|
||||||
businessModeSettings: rawUiConfig?.businessModeSettings,
|
|
||||||
}
|
|
||||||
: rawUiConfig
|
|
||||||
),
|
),
|
||||||
agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig(
|
agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig(
|
||||||
valueByKey.get(AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY)
|
valueByKey.get(AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY)
|
||||||
),
|
),
|
||||||
}, "MISS", options.cacheKey || scope, { etag: options.etag });
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === "branding") {
|
if (scope === "branding") {
|
||||||
@ -1090,23 +1055,13 @@ export async function GET(request: NextRequest) {
|
|||||||
const cacheKey = brandName.trim()
|
const cacheKey = brandName.trim()
|
||||||
? `${scopedScope}:${brandName.trim().toLowerCase()}`
|
? `${scopedScope}:${brandName.trim().toLowerCase()}`
|
||||||
: scopedScope;
|
: scopedScope;
|
||||||
const etag = scopedScope === "agent-canvas" || scopedScope === "agent-entry"
|
|
||||||
? await getAgentCanvasSettingsEtag(scopedScope)
|
|
||||||
: undefined;
|
|
||||||
if (etag && request.headers.get("If-None-Match") === etag) {
|
|
||||||
return new NextResponse(null, {
|
|
||||||
status: 304,
|
|
||||||
headers: { ETag: etag, "Cache-Control": "private, no-cache" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const cached = getScopedSiteSettingsCache(cacheKey);
|
const cached = getScopedSiteSettingsCache(cacheKey);
|
||||||
if (cached && cached.expiresAt > Date.now() && (!etag || cached.etag === etag)) {
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
return createScopedSiteSettingsResponse(
|
return createScopedSiteSettingsResponse(
|
||||||
scopedScope,
|
scopedScope,
|
||||||
cached.data,
|
cached.data,
|
||||||
"HIT",
|
"HIT",
|
||||||
cacheKey,
|
cacheKey
|
||||||
{ etag }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (cached && scopedScope !== "home") {
|
if (cached && scopedScope !== "home") {
|
||||||
@ -1117,7 +1072,6 @@ export async function GET(request: NextRequest) {
|
|||||||
const responsePromise = getScopedPublicSiteSettings(scopedScope, {
|
const responsePromise = getScopedPublicSiteSettings(scopedScope, {
|
||||||
brandName,
|
brandName,
|
||||||
cacheKey,
|
cacheKey,
|
||||||
etag,
|
|
||||||
});
|
});
|
||||||
return scopedScope === "home"
|
return scopedScope === "home"
|
||||||
? await withTimeout(responsePromise, 1600, "site-settings:home")
|
? await withTimeout(responsePromise, 1600, "site-settings:home")
|
||||||
@ -1587,7 +1541,7 @@ export async function GET(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
...resolveSiteBranding(baseSettings),
|
...baseSettings,
|
||||||
lovartPromoText:
|
lovartPromoText:
|
||||||
baseSettings.lovartPromoText ||
|
baseSettings.lovartPromoText ||
|
||||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"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,
|
||||||
@ -228,7 +227,6 @@ import {
|
|||||||
type WorkflowSceneRecord,
|
type WorkflowSceneRecord,
|
||||||
} from "@/lib/video-workflow/runtime";
|
} from "@/lib/video-workflow/runtime";
|
||||||
import { agentProjectService } from "@/services/agent-projects";
|
import { agentProjectService } from "@/services/agent-projects";
|
||||||
import { fetchCanvasSiteSettings } from "@/lib/client/canvas-site-settings";
|
|
||||||
import {
|
import {
|
||||||
canvasService,
|
canvasService,
|
||||||
imageGenerationService,
|
imageGenerationService,
|
||||||
@ -266,8 +264,30 @@ import type {
|
|||||||
} from "@/types/canvas";
|
} from "@/types/canvas";
|
||||||
|
|
||||||
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
|
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
|
||||||
|
const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000;
|
||||||
const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
|
const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
|
||||||
|
|
||||||
|
const fetchCanvasSiteSettings = async (url: string) => {
|
||||||
|
const separator = url.includes("?") ? "&" : "?";
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
}, CANVAS_SITE_SETTINGS_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const response = await fetch(`${url}${separator}t=${Date.now()}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: controller.signal,
|
||||||
|
}).finally(() => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch canvas site settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
function CanvasLazyPanelLoader({
|
function CanvasLazyPanelLoader({
|
||||||
label,
|
label,
|
||||||
className,
|
className,
|
||||||
@ -2164,9 +2184,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
[agentProjectId, id]
|
[agentProjectId, id]
|
||||||
);
|
);
|
||||||
const canvasSiteSettingsKey = requestedAgentMode
|
const canvasSiteSettingsKey = requestedAgentMode
|
||||||
? hasActiveAgentProject
|
|
||||||
? "/api/site-settings?scope=agent-canvas"
|
? "/api/site-settings?scope=agent-canvas"
|
||||||
: "/api/site-settings?scope=agent-entry"
|
|
||||||
: "/api/site-settings?scope=canvas";
|
: "/api/site-settings?scope=canvas";
|
||||||
const [hasResolvedCanvasSiteSettings, setHasResolvedCanvasSiteSettings] =
|
const [hasResolvedCanvasSiteSettings, setHasResolvedCanvasSiteSettings] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@ -2202,7 +2220,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
? 0
|
? 0
|
||||||
: CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS,
|
: CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS,
|
||||||
errorRetryCount: 1,
|
errorRetryCount: 1,
|
||||||
dedupingInterval: 30_000,
|
|
||||||
revalidateOnFocus: true,
|
revalidateOnFocus: true,
|
||||||
revalidateOnReconnect: true,
|
revalidateOnReconnect: true,
|
||||||
});
|
});
|
||||||
@ -2319,18 +2336,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
});
|
});
|
||||||
const sanitizedWorkflowSelections = useMemo(
|
const sanitizedWorkflowSelections = useMemo(
|
||||||
() =>
|
() =>
|
||||||
requestedAgentMode && !siteSettingsData?.agentWorkflowUiConfig
|
sanitizeAgentWorkflowSelections({
|
||||||
? workflowSelections
|
|
||||||
: sanitizeAgentWorkflowSelections({
|
|
||||||
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
|
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
|
||||||
selections: workflowSelections,
|
selections: workflowSelections,
|
||||||
}),
|
}),
|
||||||
[
|
[resolvedWorkflowUiConfig, workflowSelections]
|
||||||
requestedAgentMode,
|
|
||||||
siteSettingsData?.agentWorkflowUiConfig,
|
|
||||||
resolvedWorkflowUiConfig,
|
|
||||||
workflowSelections,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
const deferredSanitizedWorkflowSelections = useDeferredValue(
|
const deferredSanitizedWorkflowSelections = useDeferredValue(
|
||||||
sanitizedWorkflowSelections
|
sanitizedWorkflowSelections
|
||||||
@ -3936,18 +3946,20 @@ export default function CanvasEditorLegacyPage({
|
|||||||
loadCanvas(normalized.canvasData);
|
loadCanvas(normalized.canvasData);
|
||||||
setProjectTitle(nextTitle);
|
setProjectTitle(nextTitle);
|
||||||
|
|
||||||
const saveResult = await canvasService.saveCanvasState(
|
const [saveResult, updateResult] = await Promise.all([
|
||||||
|
canvasService.saveCanvasState(
|
||||||
id,
|
id,
|
||||||
normalized.canvasData,
|
normalized.canvasData,
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
);
|
),
|
||||||
const updateResult = await canvasService.updateProject(
|
canvasService.updateProject(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
title: nextTitle,
|
title: nextTitle,
|
||||||
},
|
},
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
);
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!saveResult.success) {
|
if (!saveResult.success) {
|
||||||
throw new Error(saveResult.error || "导入后保存失败");
|
throw new Error(saveResult.error || "导入后保存失败");
|
||||||
@ -4688,7 +4700,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
title: nextTitle,
|
title: nextTitle,
|
||||||
thumbnail: agentProjectThumbnail,
|
thumbnail: agentProjectThumbnail,
|
||||||
projectState: buildPersistedAgentProjectState(),
|
projectState: buildPersistedAgentProjectState(),
|
||||||
}, { creationKey: `save-as:${id}:${agentProjectId}:${nextTitle}` });
|
});
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
if (isUnauthorizedApiError(result.error)) {
|
if (isUnauthorizedApiError(result.error)) {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
@ -4713,7 +4725,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
agentProjectThumbnail,
|
agentProjectThumbnail,
|
||||||
agentProjectTitle,
|
agentProjectTitle,
|
||||||
buildPersistedAgentProjectState,
|
buildPersistedAgentProjectState,
|
||||||
id,
|
|
||||||
openLoginDialog,
|
openLoginDialog,
|
||||||
persistAgentProject,
|
persistAgentProject,
|
||||||
updateCanvasSearchParams,
|
updateCanvasSearchParams,
|
||||||
@ -4778,7 +4789,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const result = await agentProjectService.createProject({
|
const result = await agentProjectService.createProject({
|
||||||
title: buildDefaultAgentProjectTitle(),
|
title: buildDefaultAgentProjectTitle(),
|
||||||
projectState: createEmptyAgentGenerationProjectPersistedState(),
|
projectState: createEmptyAgentGenerationProjectPersistedState(),
|
||||||
}, { creationKey: `blank:${id}:${agentProjectId}` });
|
});
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
if (isUnauthorizedApiError(result.error)) {
|
if (isUnauthorizedApiError(result.error)) {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
@ -4801,7 +4812,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}, [
|
}, [
|
||||||
agentProjectId,
|
agentProjectId,
|
||||||
isAgentProjectSaving,
|
isAgentProjectSaving,
|
||||||
id,
|
|
||||||
openLoginDialog,
|
openLoginDialog,
|
||||||
persistAgentProject,
|
persistAgentProject,
|
||||||
updateCanvasSearchParams,
|
updateCanvasSearchParams,
|
||||||
@ -5070,10 +5080,9 @@ 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 persistCanvasStateNow = useCallback(
|
const persistCanvasState = useCallback(
|
||||||
async (reason: "autosave" | "delete" | "beforeunload") => {
|
(reason: "autosave" | "delete" | "beforeunload") => canvasSaveQueue.current!.enqueue(async () => {
|
||||||
if (canvasPersistenceScope.current !== id) return false;
|
if (canvasPersistenceScope.current !== id) return false;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@ -5113,37 +5122,10 @@ 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) {
|
||||||
@ -5186,7 +5168,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const refreshIfExternallyUpdated = async () => {
|
const refreshIfExternallyUpdated = async () => {
|
||||||
if (
|
if (
|
||||||
cancelled ||
|
cancelled ||
|
||||||
canvasRemoteOperationActive.current ||
|
|
||||||
document.visibilityState !== "visible" ||
|
document.visibilityState !== "visible" ||
|
||||||
isSaving ||
|
isSaving ||
|
||||||
externalCanvasConflictRef.current ||
|
externalCanvasConflictRef.current ||
|
||||||
@ -5216,7 +5197,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await canvasService.getProject(id);
|
const result = await canvasService.getProject(id);
|
||||||
if (cancelled || canvasRemoteOperationActive.current || !result.success || !result.data) return;
|
if (cancelled || !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();
|
||||||
@ -5339,11 +5320,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (autoThumbnailSyncedRef.current !== autoTitleKey) {
|
if (autoThumbnailSyncedRef.current !== autoTitleKey) {
|
||||||
await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
|
await canvasService.updateProject(
|
||||||
id,
|
id,
|
||||||
{ thumbnail: imageSrc },
|
{ thumbnail: imageSrc },
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
));
|
);
|
||||||
autoThumbnailSyncedRef.current = autoTitleKey;
|
autoThumbnailSyncedRef.current = autoTitleKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5359,11 +5340,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.title) {
|
if (data.title) {
|
||||||
setProjectTitle(data.title);
|
setProjectTitle(data.title);
|
||||||
await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
|
await canvasService.updateProject(
|
||||||
id,
|
id,
|
||||||
{ title: data.title },
|
{ title: data.title },
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
));
|
);
|
||||||
toast.success(`${workspaceLabel}已自动命名为:${data.title}`);
|
toast.success(`${workspaceLabel}已自动命名为:${data.title}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -5414,11 +5395,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
|
const result = await canvasService.updateProject(
|
||||||
id,
|
id,
|
||||||
{ title: editTitle.trim() },
|
{ title: editTitle.trim() },
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
));
|
);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setProjectTitle(editTitle.trim());
|
setProjectTitle(editTitle.trim());
|
||||||
} else {
|
} else {
|
||||||
@ -7051,9 +7032,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
const result = existingProject
|
const result = existingProject
|
||||||
? await agentProjectService.updateProject(existingProject.id, payload)
|
? await agentProjectService.updateProject(existingProject.id, payload)
|
||||||
: await agentProjectService.createProject(payload, {
|
: await agentProjectService.createProject(payload);
|
||||||
creationKey: `workflow:${id}:${nodeId}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
if (isUnauthorizedApiError(result.error)) {
|
if (isUnauthorizedApiError(result.error)) {
|
||||||
@ -10302,7 +10281,6 @@ 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"}
|
||||||
@ -12101,6 +12079,5 @@ export default function CanvasEditorLegacyPage({
|
|||||||
{projectLoadingBadge}
|
{projectLoadingBadge}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</CanvasRemoteOperationContext.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useEffect, useRef, type ReactNode } from "react";
|
|
||||||
|
|
||||||
export function AdminSurface({ children }: { children: ReactNode }) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
||||||
let context: { revert: () => void } | undefined;
|
|
||||||
let disposed = false;
|
|
||||||
import("gsap").then(({ gsap }) => {
|
|
||||||
if (disposed) return;
|
|
||||||
context = gsap.context(() => {
|
|
||||||
gsap.fromTo(
|
|
||||||
ref.current,
|
|
||||||
{ y: 6, opacity: 0.85 },
|
|
||||||
{ y: 0, opacity: 1, duration: 0.25, ease: "power3.out", clearProps: "transform,opacity" },
|
|
||||||
);
|
|
||||||
}, ref);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
context?.revert();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="space-y-6">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,603 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import {
|
|
||||||
RefreshCw,
|
|
||||||
Search,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
ExternalLink,
|
|
||||||
Download,
|
|
||||||
Star,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { MediaThumbnail } from "./media-thumbnail";
|
|
||||||
import { AdminSurface } from "./admin-surface";
|
|
||||||
import { formatRecordTime } from "@/lib/admin/record-time";
|
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
|
||||||
|
|
||||||
export type MediaRecord = {
|
|
||||||
id: string;
|
|
||||||
recordType: "image" | "video";
|
|
||||||
userName: string | null;
|
|
||||||
userEmail: string | null;
|
|
||||||
userDepartment: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
modelId: string | null;
|
|
||||||
chatId: string | null;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
originalUrl: string;
|
|
||||||
videoUrl?: string | null;
|
|
||||||
prompt: string | null;
|
|
||||||
imageSize: string | null;
|
|
||||||
isFeatured: boolean;
|
|
||||||
featuredCategory: string | null;
|
|
||||||
featuredAt?: string | null;
|
|
||||||
};
|
|
||||||
type MediaResponse = {
|
|
||||||
images: MediaRecord[];
|
|
||||||
pagination: {
|
|
||||||
total: number;
|
|
||||||
totalPages: number;
|
|
||||||
totalImages: number;
|
|
||||||
totalVideos: number;
|
|
||||||
page: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
export async function adminFetcher(url: string) {
|
|
||||||
const response = await fetch(url);
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok) throw new Error(data.error || data.message || "加载失败,请重试");
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
const selectClass =
|
|
||||||
"h-10 rounded-md border bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
|
||||||
function recentDate() {
|
|
||||||
const d = new Date();
|
|
||||||
d.setDate(d.getDate() - 6);
|
|
||||||
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MediaRecordsPanel({ categories }: { categories: string[] }) {
|
|
||||||
const [view, setView] = useState("assets");
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [filters, setFilters] = useState({
|
|
||||||
search: "",
|
|
||||||
type: "all",
|
|
||||||
startDate: recentDate(),
|
|
||||||
endDate: "",
|
|
||||||
model: "",
|
|
||||||
featured: "false",
|
|
||||||
status: "all",
|
|
||||||
});
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [selected, setSelected] = useState<MediaRecord | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [originalFailed, setOriginalFailed] = useState(false);
|
|
||||||
const update = (changes: Partial<typeof filters>) => {
|
|
||||||
setFilters((f) => ({ ...f, ...changes }));
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setFilters((f) => (f.search === search ? f : { ...f, search }));
|
|
||||||
setPage(1);
|
|
||||||
}, 350);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [search]);
|
|
||||||
const { data: options } = useSWR<{
|
|
||||||
models: Array<{ id: string; modelId: string; displayName: string; providerName: string }>;
|
|
||||||
}>("/api/admin/ai-models?view=options", adminFetcher, { revalidateOnFocus: false });
|
|
||||||
const modelNames = new Map<string, string>();
|
|
||||||
for (const model of options?.models || [])
|
|
||||||
if (!modelNames.has(model.modelId)) modelNames.set(model.modelId, model.displayName);
|
|
||||||
const recordModelNames = new Map(modelNames);
|
|
||||||
for (const model of options?.models || []) recordModelNames.set(model.id, model.displayName);
|
|
||||||
const openDetails = (item: MediaRecord) => {
|
|
||||||
setSelected(item);
|
|
||||||
setOriginalFailed(false);
|
|
||||||
};
|
|
||||||
const query = new URLSearchParams({ ...filters, page: String(page), pageSize: "40" });
|
|
||||||
const { data, error, isLoading, isValidating, mutate } = useSWR<MediaResponse>(
|
|
||||||
view === "assets" ? `/api/admin/images?${query}` : null,
|
|
||||||
adminFetcher,
|
|
||||||
{ keepPreviousData: true, revalidateOnFocus: false },
|
|
||||||
);
|
|
||||||
const tasks = useSWR<{
|
|
||||||
tasks: Array<{
|
|
||||||
id: string;
|
|
||||||
userName: string;
|
|
||||||
status: string;
|
|
||||||
createdAt: string;
|
|
||||||
error: string | null;
|
|
||||||
model: string | null;
|
|
||||||
elapsedMs: number | null;
|
|
||||||
}>;
|
|
||||||
total: number;
|
|
||||||
}>(view === "tasks" ? `/api/admin/media-tasks?${query}` : null, adminFetcher, {
|
|
||||||
keepPreviousData: true,
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
});
|
|
||||||
const busy = view === "assets" ? isValidating : tasks.isValidating;
|
|
||||||
const failure = view === "assets" ? error : tasks.error;
|
|
||||||
const loading = view === "assets" ? isLoading : tasks.isLoading;
|
|
||||||
const total = view === "assets" ? data?.pagination.total : tasks.data?.total;
|
|
||||||
const pages = Math.max(1, Math.ceil((total || 0) / 40));
|
|
||||||
async function save(changes: Partial<MediaRecord>) {
|
|
||||||
if (!selected || saving) return;
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/images", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ imageId: selected.id, ...changes }),
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error("保存失败,请重试");
|
|
||||||
setSelected({ ...selected, ...changes });
|
|
||||||
toast.success("已保存");
|
|
||||||
await mutate();
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<AdminSurface>
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">生成记录</h2>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
查看图片与视频产物,追踪画布任务。时间统一按北京时间展示。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`mr-2 size-4 ${busy ? "motion-safe:animate-spin" : ""}`} />
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2" role="tablist" aria-label="记录视图">
|
|
||||||
{[
|
|
||||||
["assets", "产物记录"],
|
|
||||||
["tasks", "画布任务"],
|
|
||||||
].map(([key, label]) => (
|
|
||||||
<Button
|
|
||||||
key={key}
|
|
||||||
role="tab"
|
|
||||||
aria-selected={view === key}
|
|
||||||
variant={view === key ? "default" : "ghost"}
|
|
||||||
onClick={() => {
|
|
||||||
setView(key);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-end gap-3 rounded-xl border bg-background p-4">
|
|
||||||
<label className="min-w-48 flex-1 text-xs font-medium">
|
|
||||||
用户
|
|
||||||
<div className="relative mt-2">
|
|
||||||
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
className="h-10 pl-9"
|
|
||||||
placeholder="搜索姓名或邮箱"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
{view === "assets" ? (
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
类型
|
|
||||||
<select
|
|
||||||
aria-label="产物类型"
|
|
||||||
className={`${selectClass} mt-2 block`}
|
|
||||||
value={filters.type}
|
|
||||||
onChange={(e) => update({ type: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="all">图片与视频</option>
|
|
||||||
<option value="image">只看图片</option>
|
|
||||||
<option value="video">只看视频</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
) : (
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
任务状态
|
|
||||||
<select
|
|
||||||
aria-label="任务状态"
|
|
||||||
className={`${selectClass} mt-2 block`}
|
|
||||||
value={filters.status}
|
|
||||||
onChange={(e) => update({ status: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="all">全部状态</option>
|
|
||||||
<option value="failed">失败</option>
|
|
||||||
<option value="running">运行中</option>
|
|
||||||
<option value="pending">排队中</option>
|
|
||||||
<option value="succeeded">成功</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
开始日期
|
|
||||||
<Input
|
|
||||||
aria-label="开始日期"
|
|
||||||
className="mt-2 h-10 w-40"
|
|
||||||
type="date"
|
|
||||||
value={filters.startDate}
|
|
||||||
onChange={(e) => update({ startDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
结束日期
|
|
||||||
<Input
|
|
||||||
aria-label="结束日期"
|
|
||||||
className="mt-2 h-10 w-40"
|
|
||||||
type="date"
|
|
||||||
value={filters.endDate}
|
|
||||||
onChange={(e) => update({ endDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setSearch("");
|
|
||||||
update({
|
|
||||||
search: "",
|
|
||||||
startDate: "",
|
|
||||||
endDate: "",
|
|
||||||
model: "",
|
|
||||||
featured: "false",
|
|
||||||
type: "all",
|
|
||||||
status: "all",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
全部时间 / 重置
|
|
||||||
</Button>
|
|
||||||
{view === "assets" && (
|
|
||||||
<div className="flex w-full flex-wrap items-center gap-3">
|
|
||||||
<select
|
|
||||||
aria-label="记录模型"
|
|
||||||
className={`${selectClass} max-w-full sm:max-w-96`}
|
|
||||||
value={filters.model}
|
|
||||||
onChange={(e) => update({ model: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="">全部模型</option>
|
|
||||||
{Array.from(modelNames, ([id, name]) => (
|
|
||||||
<option key={id} value={id}>
|
|
||||||
{name} · {id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={filters.featured === "true"}
|
|
||||||
onChange={(e) => update({ featured: String(e.target.checked) })}
|
|
||||||
/>
|
|
||||||
只看精选
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-live="polite"
|
|
||||||
className="flex items-center justify-between text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{total === undefined ? "正在读取记录…" : `共 ${total.toLocaleString()} 条记录`}
|
|
||||||
{view === "assets" && data
|
|
||||||
? ` · 图片 ${data.pagination.totalImages.toLocaleString()} · 视频 ${data.pagination.totalVideos.toLocaleString()}`
|
|
||||||
: ""}
|
|
||||||
</span>
|
|
||||||
{busy && !loading && <span>正在更新…</span>}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
第 {page} / {pages} 页 · 每页 40 条
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={busy || page === 1}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={busy || page >= pages}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{failure ? (
|
|
||||||
<div role="alert" className="rounded-xl border border-destructive/30 p-6 text-sm">
|
|
||||||
<p>{failure.message}</p>
|
|
||||||
<Button
|
|
||||||
className="mt-3"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
|
|
||||||
>
|
|
||||||
重试加载
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : loading ? (
|
|
||||||
<div role="status" className="space-y-3 rounded-xl border p-6">
|
|
||||||
{[0, 1, 2, 3].map((x) => (
|
|
||||||
<div key={x} className="h-16 rounded-lg bg-muted motion-safe:animate-pulse" />
|
|
||||||
))}
|
|
||||||
<span className="sr-only">正在加载记录</span>
|
|
||||||
</div>
|
|
||||||
) : total === 0 ? (
|
|
||||||
<div className="rounded-xl border border-dashed p-12 text-center text-sm text-muted-foreground">
|
|
||||||
没有符合条件的记录,请调整筛选条件。
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto rounded-xl border bg-background" aria-busy={busy}>
|
|
||||||
{view === "assets" ? (
|
|
||||||
<table className="w-full min-w-[720px] text-left text-sm">
|
|
||||||
<thead className="bg-muted/50 text-xs text-muted-foreground">
|
|
||||||
<tr>
|
|
||||||
{["产物", "用户", "生成时间", "模型 / 尺寸", "精选", ""].map((v, i) => (
|
|
||||||
<th key={i} className="px-4 py-3 font-medium">
|
|
||||||
{v}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{data?.images.map((item) => (
|
|
||||||
<tr key={item.id} className="hover:bg-muted/30">
|
|
||||||
<td className="p-4">
|
|
||||||
<div>
|
|
||||||
<MediaThumbnail
|
|
||||||
url={item.thumbnailUrl}
|
|
||||||
video={item.recordType === "video"}
|
|
||||||
onOpen={() => openDetails(item)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="font-medium">{item.userName || item.userEmail}</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{item.userDepartment || "未设置部门"}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="whitespace-nowrap px-4 py-3 tabular-nums">
|
|
||||||
{formatRecordTime(item.createdAt)}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-64 px-4 py-3">
|
|
||||||
<div className="truncate" title={item.modelId || ""}>
|
|
||||||
{recordModelNames.get(item.modelId || "") || item.modelId || "未记录模型"}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{item.recordType === "video" ? "视频" : "图片"} ·{" "}
|
|
||||||
{item.imageSize || "未知尺寸"}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{item.isFeatured ? (
|
|
||||||
<span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300">
|
|
||||||
<Star className="size-3" />
|
|
||||||
{item.featuredCategory || "精选"}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => openDetails(item)}>
|
|
||||||
详情
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
) : (
|
|
||||||
<table className="w-full min-w-[720px] text-left text-sm">
|
|
||||||
<thead className="bg-muted/50">
|
|
||||||
<tr>
|
|
||||||
{["用户 / 时间", "状态", "模型", "耗时", "失败原因 / 任务编号"].map((x) => (
|
|
||||||
<th key={x} className="px-4 py-3 font-medium">
|
|
||||||
{x}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{tasks.data?.tasks.map((task) => (
|
|
||||||
<tr key={task.id}>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
{task.userName}
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{formatRecordTime(task.createdAt)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
{{ failed: "失败", running: "运行中", succeeded: "成功", pending: "排队中" }[
|
|
||||||
task.status
|
|
||||||
] || task.status}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-48 break-words px-4 py-4">{task.model || "自动选择"}</td>
|
|
||||||
<td className="px-4 py-4 tabular-nums">
|
|
||||||
{task.elapsedMs === null ? "—" : `${Math.round(task.elapsedMs / 1000)} 秒`}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-xl break-words px-4 py-4">
|
|
||||||
<p className={task.error ? "text-destructive" : "text-muted-foreground"}>
|
|
||||||
{task.error || "—"}
|
|
||||||
</p>
|
|
||||||
<code className="mt-2 block text-xs text-muted-foreground">{task.id}</code>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
每页 40 条 · 第 {page} / {pages} 页
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" disabled={busy || page === 1} onClick={() => setPage(1)}>
|
|
||||||
首页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
aria-label="上一页"
|
|
||||||
variant="outline"
|
|
||||||
disabled={busy || page === 1}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
aria-label="下一页"
|
|
||||||
variant="outline"
|
|
||||||
disabled={busy || page >= pages}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" disabled={busy || page >= pages} onClick={() => setPage(pages)}>
|
|
||||||
末页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Dialog
|
|
||||||
open={!!selected}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setSelected(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto bg-background">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{selected?.recordType === "video" ? "视频详情" : "图片详情"}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
{selected && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex min-h-40 justify-center rounded-xl bg-muted">
|
|
||||||
{selected.recordType === "video" ? (
|
|
||||||
<video
|
|
||||||
src={selected.videoUrl || selected.originalUrl}
|
|
||||||
controls
|
|
||||||
preload="none"
|
|
||||||
className="max-h-[45vh] w-full"
|
|
||||||
/>
|
|
||||||
) : originalFailed ? (
|
|
||||||
<div role="alert" className="p-8 text-sm">
|
|
||||||
原图无法加载,源文件可能已过期或存储不可达。
|
|
||||||
<Button
|
|
||||||
className="ml-3"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setOriginalFailed(false)}
|
|
||||||
>
|
|
||||||
重试
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={selected.originalUrl}
|
|
||||||
alt="生成原图"
|
|
||||||
className="max-h-[45vh] object-contain"
|
|
||||||
onError={() => setOriginalFailed(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<dl className="grid gap-4 text-sm sm:grid-cols-2">
|
|
||||||
{[
|
|
||||||
["用户", selected.userName || selected.userEmail],
|
|
||||||
["部门", selected.userDepartment],
|
|
||||||
["生成时间", formatRecordTime(selected.createdAt)],
|
|
||||||
["尺寸", selected.imageSize],
|
|
||||||
["模型", recordModelNames.get(selected.modelId || "") || selected.modelId],
|
|
||||||
["精选时间", selected.featuredAt ? formatRecordTime(selected.featuredAt) : "—"],
|
|
||||||
].map(([k, v]) => (
|
|
||||||
<div key={k}>
|
|
||||||
<dt className="text-xs text-muted-foreground">{k}</dt>
|
|
||||||
<dd className="mt-1 break-words">{v || "未记录"}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium">提示词</h3>
|
|
||||||
<p className="mt-2 whitespace-pre-wrap break-words text-sm text-muted-foreground">
|
|
||||||
{selected.prompt || "未记录提示词"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<a href={selected.originalUrl} target="_blank" rel="noreferrer">
|
|
||||||
<ExternalLink className="mr-2 size-4" />
|
|
||||||
打开原文件
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
{selected.recordType === "image" && (
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<a
|
|
||||||
href={buildImageProxyUrl(selected.originalUrl, { delivery: "original" })}
|
|
||||||
download
|
|
||||||
>
|
|
||||||
<Download className="mr-2 size-4" />
|
|
||||||
下载原图
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{selected.recordType === "image" && (
|
|
||||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border p-4">
|
|
||||||
<select
|
|
||||||
aria-label="精选分类"
|
|
||||||
className={selectClass}
|
|
||||||
value={selected.featuredCategory || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelected({ ...selected, featuredCategory: e.target.value || null })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">未分类</option>
|
|
||||||
{[...new Set([...categories, selected.featuredCategory].filter(Boolean))].map(
|
|
||||||
(c) => (
|
|
||||||
<option key={c} value={c!}>
|
|
||||||
{c}
|
|
||||||
</option>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={saving}
|
|
||||||
onClick={() => save({ featuredCategory: selected.featuredCategory })}
|
|
||||||
>
|
|
||||||
保存分类
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={saving}
|
|
||||||
onClick={() => save({ isFeatured: !selected.isFeatured })}
|
|
||||||
>
|
|
||||||
<Star className="mr-2 size-4" />
|
|
||||||
{selected.isFeatured ? "取消精选" : "设为精选"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</AdminSurface>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { ImageOff, Video, RotateCw } from "lucide-react";
|
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
|
||||||
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
|
|
||||||
|
|
||||||
import { queuePreview } from "@/lib/admin/preview-queue";
|
|
||||||
|
|
||||||
export function MediaThumbnail({
|
|
||||||
url,
|
|
||||||
video = false,
|
|
||||||
onOpen,
|
|
||||||
}: {
|
|
||||||
url: string;
|
|
||||||
video?: boolean;
|
|
||||||
onOpen?: () => void;
|
|
||||||
}) {
|
|
||||||
const container = useRef<HTMLDivElement>(null);
|
|
||||||
const [state, setState] = useState<"waiting" | "loading" | "ready" | "error">("waiting");
|
|
||||||
const [attempt, setAttempt] = useState(0);
|
|
||||||
const [source, setSource] = useState("");
|
|
||||||
const [failure, setFailure] = useState("");
|
|
||||||
const retired = isRetiredStorageUrl(url);
|
|
||||||
useEffect(() => {
|
|
||||||
setState("waiting");
|
|
||||||
setSource("");
|
|
||||||
setFailure("");
|
|
||||||
if (video || retired || !url || !container.current) return;
|
|
||||||
let dispose: (() => void) | undefined;
|
|
||||||
let abort = () => {};
|
|
||||||
let objectUrl = "";
|
|
||||||
let alive = true;
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (!entry.isIntersecting || dispose) return;
|
|
||||||
observer.disconnect();
|
|
||||||
dispose = queuePreview((release) => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 48000);
|
|
||||||
setState("loading");
|
|
||||||
// Fetch is abortable, so leaving a page also stops in-flight previews.
|
|
||||||
const parsed = new URL(url, window.location.origin);
|
|
||||||
const target =
|
|
||||||
parsed.hostname === "scqnycdn.jiansun.vip" && !parsed.search
|
|
||||||
? `${url}?imageView2/2/w/192/q/72/format/webp`
|
|
||||||
: buildImageProxyUrl(url, {
|
|
||||||
width: 192,
|
|
||||||
quality: 72,
|
|
||||||
format: "webp",
|
|
||||||
delivery: "proxy",
|
|
||||||
});
|
|
||||||
fetch(target, { signal: controller.signal })
|
|
||||||
.then(async (response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
const detail = await response.json().catch(() => null);
|
|
||||||
throw new Error(`${detail?.error || "预览请求失败"}(HTTP ${response.status})`);
|
|
||||||
}
|
|
||||||
const blob = await response.blob();
|
|
||||||
if (!blob.type.startsWith("image/")) throw new Error("Not an image");
|
|
||||||
if (alive) {
|
|
||||||
objectUrl = URL.createObjectURL(blob);
|
|
||||||
setSource(objectUrl);
|
|
||||||
setState("ready");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (alive) {
|
|
||||||
setFailure(
|
|
||||||
error.name === "AbortError" ? "预览加载超时" : error.message || "预览加载失败",
|
|
||||||
);
|
|
||||||
setState("error");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
release();
|
|
||||||
});
|
|
||||||
abort = () => {
|
|
||||||
controller.abort();
|
|
||||||
clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ rootMargin: "80px" },
|
|
||||||
);
|
|
||||||
observer.observe(container.current);
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
observer.disconnect();
|
|
||||||
abort();
|
|
||||||
dispose?.();
|
|
||||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
||||||
};
|
|
||||||
}, [url, video, retired, attempt]);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={container}
|
|
||||||
className="relative flex h-16 w-20 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted"
|
|
||||||
data-preview-state={state}
|
|
||||||
>
|
|
||||||
{video ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="查看视频详情"
|
|
||||||
className="flex h-full w-full items-center justify-center"
|
|
||||||
onClick={onOpen}
|
|
||||||
>
|
|
||||||
<Video className="size-5 text-muted-foreground" aria-label="视频,点击查看后播放" />
|
|
||||||
</button>
|
|
||||||
) : retired ? (
|
|
||||||
<span className="text-center text-xs text-muted-foreground">
|
|
||||||
旧存储
|
|
||||||
<br />
|
|
||||||
已停用
|
|
||||||
</span>
|
|
||||||
) : source && state === "ready" ? (
|
|
||||||
<button type="button" aria-label="查看图片详情" className="h-full w-full" onClick={onOpen}>
|
|
||||||
<img
|
|
||||||
src={source}
|
|
||||||
alt="生成结果预览"
|
|
||||||
className="h-full w-full object-contain"
|
|
||||||
onError={() => {
|
|
||||||
setFailure("图片内容无法解码");
|
|
||||||
setState("error");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
) : state === "error" ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex h-full w-full flex-col items-center justify-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
title={`${failure},点击重试`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setAttempt((x) => x + 1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ImageOff className="size-4" />
|
|
||||||
加载失败
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<RotateCw
|
|
||||||
className={`size-4 text-muted-foreground ${state === "loading" ? "motion-safe:animate-spin" : ""}`}
|
|
||||||
aria-label={state === "loading" ? "正在加载预览" : "等待进入可见区域"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,191 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useState } from "react";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { adminFetcher } from "./media-records-panel";
|
|
||||||
import { formatRecordTime } from "@/lib/admin/record-time";
|
|
||||||
type Event = {
|
|
||||||
id: string;
|
|
||||||
modelDisplayName: string;
|
|
||||||
modelRuntimeId: string;
|
|
||||||
providerName: string;
|
|
||||||
eventType: string;
|
|
||||||
createdAt: string;
|
|
||||||
errorMessage: string;
|
|
||||||
elapsedMs: number | null;
|
|
||||||
consecutiveFailures: number;
|
|
||||||
autoDisabled: boolean;
|
|
||||||
};
|
|
||||||
export function ModelHealthPanel({
|
|
||||||
providers,
|
|
||||||
models,
|
|
||||||
initialModelId = "",
|
|
||||||
}: {
|
|
||||||
initialModelId?: string;
|
|
||||||
providers: Array<{ id: string; name: string }>;
|
|
||||||
models: Array<{ id: string; displayName: string; providerId: string }>;
|
|
||||||
}) {
|
|
||||||
const [filters, setFilters] = useState({
|
|
||||||
providerId: "",
|
|
||||||
modelId: initialModelId,
|
|
||||||
eventType: "",
|
|
||||||
startDate: "",
|
|
||||||
endDate: "",
|
|
||||||
});
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const query = new URLSearchParams({ ...filters, page: String(page), limit: "20" });
|
|
||||||
const { data, error, isLoading, isValidating, mutate } = useSWR<{
|
|
||||||
events: Event[];
|
|
||||||
hasMore: boolean;
|
|
||||||
}>(`/api/admin/ai-model-health?${query}`, adminFetcher, {
|
|
||||||
keepPreviousData: true,
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
});
|
|
||||||
const update = (change: Partial<typeof filters>) => {
|
|
||||||
setFilters((f) => ({ ...f, ...change }));
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold">运行健康</h3>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
按上游和模型追踪故障,查看完整原因与发生时间。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{[
|
|
||||||
["providerId", "全部上游", providers.map((p) => [p.id, p.name])],
|
|
||||||
[
|
|
||||||
"modelId",
|
|
||||||
"全部模型",
|
|
||||||
models
|
|
||||||
.filter((m) => !filters.providerId || m.providerId === filters.providerId)
|
|
||||||
.map((m) => [m.id, m.displayName]),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"eventType",
|
|
||||||
"全部错误",
|
|
||||||
[
|
|
||||||
["quota", "额度 / 余额"],
|
|
||||||
["timeout", "超时"],
|
|
||||||
["unavailable", "不可用"],
|
|
||||||
["invalid_model", "模型不可用"],
|
|
||||||
["download_error", "结果下载失败"],
|
|
||||||
["error", "其他错误"],
|
|
||||||
["success", "成功"],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
].map(([key, label, options]) => (
|
|
||||||
<select
|
|
||||||
key={key as string}
|
|
||||||
aria-label={label as string}
|
|
||||||
className="h-10 max-w-64 rounded-md border bg-background px-3 text-sm"
|
|
||||||
value={filters[key as keyof typeof filters]}
|
|
||||||
onChange={(e) =>
|
|
||||||
update({
|
|
||||||
[key as string]: e.target.value,
|
|
||||||
...(key === "providerId" ? { modelId: "" } : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">{label as string}</option>
|
|
||||||
{(options as string[][]).map(([value, text]) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{text}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
))}
|
|
||||||
<Input
|
|
||||||
aria-label="故障开始日期"
|
|
||||||
className="h-10 w-40"
|
|
||||||
type="date"
|
|
||||||
value={filters.startDate}
|
|
||||||
onChange={(e) => update({ startDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
aria-label="故障结束日期"
|
|
||||||
className="h-10 w-40"
|
|
||||||
type="date"
|
|
||||||
value={filters.endDate}
|
|
||||||
onChange={(e) => update({ endDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Button variant="outline" disabled={isValidating} onClick={() => mutate()}>
|
|
||||||
刷新日志
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{error ? (
|
|
||||||
<p role="alert" className="p-6 text-sm text-destructive">
|
|
||||||
日志加载失败,请点击刷新重试。
|
|
||||||
</p>
|
|
||||||
) : isLoading ? (
|
|
||||||
<p role="status" className="p-6 text-sm">
|
|
||||||
正在加载故障日志…
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y rounded-xl border">
|
|
||||||
{data?.events.map((event) => (
|
|
||||||
<div key={event.id} className="p-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
|
||||||
<strong className="font-semibold">
|
|
||||||
{event.modelDisplayName || event.modelRuntimeId || "已删除模型"}
|
|
||||||
</strong>
|
|
||||||
<span className="text-muted-foreground">{event.providerName || "未知上游"}</span>
|
|
||||||
<span className="rounded bg-muted px-2 py-1 text-xs">
|
|
||||||
{{
|
|
||||||
quota: "额度 / 余额",
|
|
||||||
timeout: "超时",
|
|
||||||
unavailable: "不可用",
|
|
||||||
error: "服务错误",
|
|
||||||
invalid_model: "模型不可用",
|
|
||||||
download_error: "结果下载失败",
|
|
||||||
success: "成功",
|
|
||||||
}[event.eventType] || event.eventType}
|
|
||||||
</span>
|
|
||||||
<time className="ml-auto text-xs tabular-nums text-muted-foreground">
|
|
||||||
{formatRecordTime(event.createdAt)}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 whitespace-pre-wrap break-words text-sm">
|
|
||||||
{event.errorMessage || "未记录详细原因"}
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
|
|
||||||
<span>连续失败 {event.consecutiveFailures} 次</span>
|
|
||||||
{event.elapsedMs !== null && (
|
|
||||||
<span>耗时 {Math.round(event.elapsedMs / 1000)} 秒</span>
|
|
||||||
)}
|
|
||||||
{event.autoDisabled && <span>已自动停用</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!data?.events.length && (
|
|
||||||
<p className="p-12 text-center text-sm text-muted-foreground">该范围内没有故障记录。</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
第 {page} 页{isValidating && " · 正在更新…"}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={page === 1 || isValidating}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={!data?.hasMore || isValidating}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,326 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import type { AiModel } from "@/lib/db/schema";
|
|
||||||
import {
|
|
||||||
is3DModelDescriptor,
|
|
||||||
isVideoModelDescriptor,
|
|
||||||
isImageGenerationModelDescriptor,
|
|
||||||
} from "@/lib/ai/model-classification";
|
|
||||||
|
|
||||||
type Model = AiModel & { isAgentModel?: boolean; isMultimodal?: boolean };
|
|
||||||
type Provider = { id: string; name: string; type: string; isEnabled: boolean };
|
|
||||||
const selectClass = "h-10 rounded-md border bg-background px-3 text-sm";
|
|
||||||
export function ModelManagementList({
|
|
||||||
models,
|
|
||||||
providers,
|
|
||||||
error,
|
|
||||||
loading,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onRestore,
|
|
||||||
onRefresh,
|
|
||||||
onHealth,
|
|
||||||
}: {
|
|
||||||
models: Model[];
|
|
||||||
providers: Provider[];
|
|
||||||
error?: Error;
|
|
||||||
loading: boolean;
|
|
||||||
onEdit: (model: Model) => Promise<void>;
|
|
||||||
onDelete: (id: string) => void;
|
|
||||||
onRestore: (model: Model) => void;
|
|
||||||
onRefresh: () => void;
|
|
||||||
onHealth?: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [provider, setProvider] = useState("");
|
|
||||||
const [status, setStatus] = useState("enabled");
|
|
||||||
const [kind, setKind] = useState("");
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [opening, setOpening] = useState<string | null>(null);
|
|
||||||
const rows = useMemo(
|
|
||||||
() =>
|
|
||||||
models
|
|
||||||
.map((model) => {
|
|
||||||
const upstream = providers.find((p) => p.id === model.providerId);
|
|
||||||
const descriptor = { ...model, providerType: upstream?.type };
|
|
||||||
const type = is3DModelDescriptor(descriptor)
|
|
||||||
? "3d"
|
|
||||||
: isVideoModelDescriptor(descriptor)
|
|
||||||
? "video"
|
|
||||||
: isImageGenerationModelDescriptor(descriptor)
|
|
||||||
? "image"
|
|
||||||
: "chat";
|
|
||||||
return { model, upstream, type };
|
|
||||||
})
|
|
||||||
.filter(({ model, upstream, type }) => {
|
|
||||||
const text =
|
|
||||||
`${model.displayName} ${model.modelId} ${upstream?.name || ""}`.toLowerCase();
|
|
||||||
return (
|
|
||||||
text.includes(search.trim().toLowerCase()) &&
|
|
||||||
(!provider || model.providerId === provider) &&
|
|
||||||
(!kind || kind === type) &&
|
|
||||||
(status === "all" ||
|
|
||||||
(status === "enabled" && model.isEnabled) ||
|
|
||||||
(status === "disabled" && !model.isEnabled) ||
|
|
||||||
(status === "unhealthy" && model.healthStatus !== "healthy") ||
|
|
||||||
(status === "visible" && !model.isHidden))
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
[models, providers, search, provider, status, kind],
|
|
||||||
);
|
|
||||||
const pages = Math.max(1, Math.ceil(rows.length / 25));
|
|
||||||
const currentPage = Math.min(page, pages);
|
|
||||||
const change = (fn: () => void) => {
|
|
||||||
fn();
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<div className="relative min-w-56 flex-1">
|
|
||||||
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
className="h-10 pl-9"
|
|
||||||
aria-label="搜索模型"
|
|
||||||
placeholder="搜索模型名称、ID 或上游"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => change(() => setSearch(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
aria-label="模型上游"
|
|
||||||
className={selectClass}
|
|
||||||
value={provider}
|
|
||||||
onChange={(e) => change(() => setProvider(e.target.value))}
|
|
||||||
>
|
|
||||||
<option value="">全部上游</option>
|
|
||||||
{providers.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
aria-label="模型类型"
|
|
||||||
className={selectClass}
|
|
||||||
value={kind}
|
|
||||||
onChange={(e) => change(() => setKind(e.target.value))}
|
|
||||||
>
|
|
||||||
<option value="">全部类型</option>
|
|
||||||
<option value="image">生图</option>
|
|
||||||
<option value="video">视频</option>
|
|
||||||
<option value="chat">对话</option>
|
|
||||||
<option value="3d">3D / CAD</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
aria-label="模型状态"
|
|
||||||
className={selectClass}
|
|
||||||
value={status}
|
|
||||||
onChange={(e) => change(() => setStatus(e.target.value))}
|
|
||||||
>
|
|
||||||
<option value="enabled">已启用</option>
|
|
||||||
<option value="all">全部模型</option>
|
|
||||||
<option value="disabled">已停用</option>
|
|
||||||
<option value="unhealthy">运行异常</option>
|
|
||||||
<option value="visible">前台可见</option>
|
|
||||||
</select>
|
|
||||||
<Button variant="outline" onClick={onRefresh} aria-label="刷新模型">
|
|
||||||
<RefreshCw className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{loading
|
|
||||||
? "正在加载模型…"
|
|
||||||
: `筛选结果 ${rows.length} 个 · 已启用 ${models.filter((m) => m.isEnabled).length} 个 · 全部 ${models.length} 个`}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
第 {currentPage} / {pages} 页
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
onClick={() => setPage(currentPage - 1)}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={currentPage === pages}
|
|
||||||
onClick={() => setPage(currentPage + 1)}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{error ? (
|
|
||||||
<div role="alert" className="rounded-xl border p-6 text-sm">
|
|
||||||
模型加载失败。
|
|
||||||
<Button variant="outline" onClick={onRefresh}>
|
|
||||||
重试
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : loading ? (
|
|
||||||
<div role="status" className="h-48 rounded-xl bg-muted motion-safe:animate-pulse" />
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto rounded-xl border">
|
|
||||||
<table className="w-full min-w-[850px] text-left text-sm">
|
|
||||||
<thead className="bg-muted/50 text-xs text-muted-foreground">
|
|
||||||
<tr>
|
|
||||||
{["模型", "上游 / 类型", "启用", "前台展示", "运行健康", "操作"].map((x) => (
|
|
||||||
<th key={x} className="px-4 py-3 font-medium">
|
|
||||||
{x}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{rows
|
|
||||||
.slice((currentPage - 1) * 25, currentPage * 25)
|
|
||||||
.map(({ model, upstream, type }) => (
|
|
||||||
<tr key={model.id} className="hover:bg-muted/30">
|
|
||||||
<td className="max-w-64 px-4 py-4">
|
|
||||||
<div className="font-medium">{model.displayName}</div>
|
|
||||||
<div
|
|
||||||
className="mt-1 truncate text-xs text-muted-foreground"
|
|
||||||
title={model.modelId}
|
|
||||||
>
|
|
||||||
{model.modelId}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
||||||
{model.isDefault && <span>默认模型</span>}
|
|
||||||
{model.isDefaultTextEditModel && <span>文字编辑默认</span>}
|
|
||||||
{model.isAgentModel && <span>Agent</span>}
|
|
||||||
{model.isMultimodal && <span>多模态</span>}
|
|
||||||
{model.isSubModel && <span>子模型</span>}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
{upstream?.name || "未知上游"}
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{{ image: "生图", video: "视频", chat: "对话", "3d": "3D / CAD" }[type]}
|
|
||||||
{upstream && !upstream.isEnabled ? " · 上游已停用" : ""}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="whitespace-nowrap px-4 py-4">
|
|
||||||
<span
|
|
||||||
className={`rounded-md px-2 py-1 text-xs ${model.isEnabled ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200" : "bg-muted text-muted-foreground"}`}
|
|
||||||
>
|
|
||||||
{model.isEnabled ? "已启用" : "已停用"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
{model.isHidden || model.isSubModel ? "隐藏" : "可见"}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-72 px-4 py-4">
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
model.healthStatus !== "healthy"
|
|
||||||
? "text-amber-700 dark:text-amber-300"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{model.healthStatus === "auto_disabled"
|
|
||||||
? "自动停用"
|
|
||||||
: model.healthStatus === "degraded"
|
|
||||||
? "观察中"
|
|
||||||
: "正常"}
|
|
||||||
</span>
|
|
||||||
{model.healthConsecutiveFailures > 0 && (
|
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
|
||||||
连续失败 {model.healthConsecutiveFailures} 次
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{model.healthLastError && (
|
|
||||||
<details className="mt-2 text-xs">
|
|
||||||
<summary className="cursor-pointer text-muted-foreground">
|
|
||||||
查看最近原因
|
|
||||||
</summary>
|
|
||||||
<p className="mt-2 whitespace-pre-wrap break-words">
|
|
||||||
{model.healthLastError}
|
|
||||||
</p>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mt-2 block text-xs underline underline-offset-4"
|
|
||||||
onClick={() => onHealth?.(model.id)}
|
|
||||||
>
|
|
||||||
故障记录
|
|
||||||
</button>
|
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
|
||||||
{model.excludeFromHealthBan && "不自动封禁 "}
|
|
||||||
{model.excludeFromFailover && "不参与迁移"}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={opening !== null}
|
|
||||||
onClick={async () => {
|
|
||||||
setOpening(model.id);
|
|
||||||
try {
|
|
||||||
await onEdit(model);
|
|
||||||
} finally {
|
|
||||||
setOpening(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opening === model.id ? "加载中…" : "编辑"}
|
|
||||||
</Button>
|
|
||||||
{model.healthStatus === "auto_disabled" && (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onRestore(model)}>
|
|
||||||
恢复
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive"
|
|
||||||
onClick={() => onDelete(model.id)}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{rows.length === 0 && (
|
|
||||||
<p className="p-12 text-center text-sm text-muted-foreground">没有符合条件的模型。</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
每页 25 个 · 第 {currentPage} / {pages} 页
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
aria-label="上一页模型"
|
|
||||||
variant="outline"
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
onClick={() => setPage(currentPage - 1)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
aria-label="下一页模型"
|
|
||||||
variant="outline"
|
|
||||||
disabled={currentPage === pages}
|
|
||||||
onClick={() => setPage(currentPage + 1)}
|
|
||||||
>
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
@ -576,11 +575,31 @@ export function AgentGenerationWorkspace({
|
|||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filename = buildDownloadFilename(raw);
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
const response = await fetch(raw);
|
||||||
toast.success("原图已开始下载");
|
if (!response.ok) {
|
||||||
} catch (error) {
|
throw new Error(`download failed: ${response.status}`);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
|
} catch {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = raw;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
toast.message("已打开原图,可右键另存为");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { 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 {
|
||||||
@ -4171,7 +4169,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
? "agent-anchor-materials"
|
? "agent-anchor-materials"
|
||||||
: "agent-anchor-generate";
|
: "agent-anchor-generate";
|
||||||
|
|
||||||
const [finishedSceneRequestId, setFinishedSceneRequestId] = useState<string | null>(null);
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
setMessages,
|
setMessages,
|
||||||
@ -4192,11 +4189,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
void refreshHistory();
|
void refreshHistory();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFinish({ messages: finishedMessages }) {
|
onFinish() { void refreshHistory(); },
|
||||||
const lastUser = finishedMessages.filter((message) => message.role === "user").at(-1);
|
|
||||||
setFinishedSceneRequestId(lastUser?.id || null);
|
|
||||||
void refreshHistory();
|
|
||||||
},
|
|
||||||
experimental_throttle: 100,
|
experimental_throttle: 100,
|
||||||
generateId: generateUUID,
|
generateId: generateUUID,
|
||||||
transport: new DefaultChatTransport({
|
transport: new DefaultChatTransport({
|
||||||
@ -5489,17 +5482,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
setActiveResultModeType(currentResultModeType);
|
setActiveResultModeType(currentResultModeType);
|
||||||
}, [currentResultModeType]);
|
}, [currentResultModeType]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pendingGeneration?.requestMessageId || pendingGeneration.modeType !== "scene_generation" || transportIsGenerating) return;
|
|
||||||
const result = getSceneRunResult(messages, pendingGeneration.requestMessageId);
|
|
||||||
const persisted = getSceneRunResult(historyMessages, pendingGeneration.requestMessageId);
|
|
||||||
if (!result || (finishedSceneRequestId !== pendingGeneration.requestMessageId && !persisted?.count)) return;
|
|
||||||
setPendingGeneration(null);
|
|
||||||
if (result.count < pendingGeneration.expectedCount) {
|
|
||||||
toast.warning(`本次生成已结束,收到 ${result.count}/${pendingGeneration.expectedCount} 张图片。缺少的图片未继续生成,可调整张数后重新提交。`);
|
|
||||||
}
|
|
||||||
}, [pendingGeneration, transportIsGenerating, finishedSceneRequestId, messages, historyMessages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
|
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
|
||||||
// The backend has settled every requested card; an open SSE connection is no longer work.
|
// The backend has settled every requested card; an open SSE connection is no longer work.
|
||||||
@ -6794,11 +6776,31 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filename = buildDownloadFilename(raw);
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
const response = await fetch(raw);
|
||||||
toast.success("原图已开始下载");
|
if (!response.ok) {
|
||||||
} catch (error) {
|
throw new Error(`download failed: ${response.status}`);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
|
} catch {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = raw;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
toast.message("已打开原图,可右键另存为");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {
|
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
@ -726,11 +725,30 @@ export function AgentGenerationWorkspaceNew({
|
|||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const filename = buildDownloadFilename(raw);
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
const response = await fetch(raw);
|
||||||
toast.success("原图已开始下载");
|
if (!response.ok) {
|
||||||
} catch (error) {
|
throw new Error(`download failed: ${response.status}`);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
|
} catch {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = raw;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
toast.message("已打开原图,可右键另存为");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -66,7 +66,6 @@ import {
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
|
||||||
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
fetchUploadWithRetry,
|
fetchUploadWithRetry,
|
||||||
@ -374,9 +373,7 @@ function MarkdownResponseFallback({
|
|||||||
}: Pick<ResponseProps, "children" | "className">) {
|
}: Pick<ResponseProps, "children" | "className">) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className)}>
|
<div className={cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className)}>
|
||||||
{typeof children === "string"
|
{children}
|
||||||
? getGatewayErrorPageMessage(children) || children
|
|
||||||
: children}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
"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,5 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@ -232,8 +231,6 @@ import {
|
|||||||
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||||||
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
||||||
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capability";
|
|
||||||
import { useCanvasRemoteOperation } from "@/components/canvas/CanvasRemoteOperationContext";
|
|
||||||
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
|
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
|
||||||
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
|
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
|
||||||
import { cn, fetcher } from "@/lib/utils";
|
import { cn, fetcher } from "@/lib/utils";
|
||||||
@ -2080,7 +2077,7 @@ export function ImageNode({
|
|||||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||||
const projectId = useCanvasStore((state) => state.projectId);
|
const projectId = useCanvasStore((state) => state.projectId);
|
||||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||||||
const runCanvasRemoteOperation = useCanvasRemoteOperation();
|
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
|
||||||
const addPendingChatAttachment = useCanvasStore(
|
const addPendingChatAttachment = useCanvasStore(
|
||||||
(state) => state.addPendingChatAttachment
|
(state) => state.addPendingChatAttachment
|
||||||
);
|
);
|
||||||
@ -6710,7 +6707,7 @@ export function ImageNode({
|
|||||||
const executeSharedToolbarCapability = async (
|
const executeSharedToolbarCapability = async (
|
||||||
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
|
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
|
||||||
) => {
|
) => {
|
||||||
if (!projectId || !runCanvasRemoteOperation) {
|
if (!projectId) {
|
||||||
toast.error("画布还在加载,请稍后再试");
|
toast.error("画布还在加载,请稍后再试");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -6729,7 +6726,21 @@ export function ImageNode({
|
|||||||
projectId,
|
projectId,
|
||||||
source: "toolbar",
|
source: "toolbar",
|
||||||
});
|
});
|
||||||
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
|
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(command),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || payload?.success !== true) {
|
||||||
|
throw new Error(payload?.error || `${capability.title}失败`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectResult = await canvasService.getProject(projectId);
|
||||||
|
if (!projectResult.success || !projectResult.data?.canvasData) {
|
||||||
|
throw new Error(projectResult.error || "结果已生成,但画布同步失败");
|
||||||
|
}
|
||||||
|
loadCanvas(projectResult.data.canvasData);
|
||||||
toast.success(`${capability.title}完成`);
|
toast.success(`${capability.title}完成`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
@ -8428,11 +8439,7 @@ export function ImageNode({
|
|||||||
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
||||||
{data.src ? (
|
{data.src ? (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
{isRetiredStorageUrl(data.src) ? (
|
{!shouldRequestDisplayImage ? (
|
||||||
<div role="status" className="flex h-full w-full items-center justify-center bg-muted p-4 text-center text-sm text-muted-foreground">
|
|
||||||
{RETIRED_STORAGE_MESSAGE}
|
|
||||||
</div>
|
|
||||||
) : !shouldRequestDisplayImage ? (
|
|
||||||
<ImageNodeDeferredLoadState />
|
<ImageNodeDeferredLoadState />
|
||||||
) : isCompareMode && hasCompareSource ? (
|
) : isCompareMode && hasCompareSource ? (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -18,7 +18,6 @@ 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,
|
||||||
@ -1029,10 +1028,7 @@ export const Response = ({
|
|||||||
|
|
||||||
const rawText =
|
const rawText =
|
||||||
typeof props.children === "string"
|
typeof props.children === "string"
|
||||||
? normalizeMarkdownContent(stableStreamingMarkdown(
|
? normalizeMarkdownContent(stableStreamingMarkdown(props.children, isMarkdownStreaming))
|
||||||
getGatewayErrorPageMessage(props.children) || props.children,
|
|
||||||
isMarkdownStreaming
|
|
||||||
))
|
|
||||||
: null;
|
: null;
|
||||||
const shouldShowPdfDownload =
|
const shouldShowPdfDownload =
|
||||||
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
|
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, type ReactNode } from "react";
|
|
||||||
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
|
|
||||||
|
|
||||||
type HomeImageProps = {
|
|
||||||
sources: Array<string | null | undefined>;
|
|
||||||
alt: string;
|
|
||||||
className?: string;
|
|
||||||
imageClassName?: string;
|
|
||||||
fallback?: ReactNode;
|
|
||||||
loading?: "lazy" | "eager";
|
|
||||||
onUnavailable?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function imageCandidates(source: string) {
|
|
||||||
// These two domains are documented aliases for the same gongxiangpan store.
|
|
||||||
// Do not rewrite other buckets, providers, signed URLs or user-supplied hosts.
|
|
||||||
try {
|
|
||||||
const url = new URL(source);
|
|
||||||
if (url.origin === "https://s3.yydd.ai" &&
|
|
||||||
url.pathname.startsWith("/gongxiangpan/") && !url.search && !url.hash) {
|
|
||||||
return [`https://s3.jiansun.vip${url.pathname}`, source];
|
|
||||||
}
|
|
||||||
} catch { /* Relative site assets need no host rewrite. */ }
|
|
||||||
return [source];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keep failed image elements out of view, trying each distinct source once. */
|
|
||||||
export function HomeImage(props: HomeImageProps) {
|
|
||||||
const sources = [...new Set(props.sources
|
|
||||||
.filter((url): url is string => Boolean(url?.trim()) && !isRetiredStorageUrl(url))
|
|
||||||
.flatMap(imageCandidates))];
|
|
||||||
return <HomeImageAttempt key={JSON.stringify(sources)} {...props} sources={sources} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HomeImageAttempt({
|
|
||||||
sources, alt, className, imageClassName, fallback, loading = "lazy", onUnavailable,
|
|
||||||
}: Omit<HomeImageProps, "sources"> & { sources: string[] }) {
|
|
||||||
const [index, setIndex] = useState(0);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
const source = sources[index];
|
|
||||||
return (
|
|
||||||
<span className={`relative block overflow-hidden ${className || ""}`}>
|
|
||||||
{!loaded && (
|
|
||||||
<span className={fallback ? "block" : "block aspect-[4/5] rounded-[12px] bg-[#e8e8e8] dark:bg-[#1b1c20]"}>
|
|
||||||
{fallback}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{source ? (
|
|
||||||
<img
|
|
||||||
key={source}
|
|
||||||
src={source}
|
|
||||||
alt={alt}
|
|
||||||
loading={loading}
|
|
||||||
decoding="async"
|
|
||||||
className={`${imageClassName || ""} ${loaded ? "" : "absolute inset-0 opacity-0"}`}
|
|
||||||
onLoad={() => setLoaded(true)}
|
|
||||||
onError={() => {
|
|
||||||
setLoaded(false);
|
|
||||||
setIndex(index + 1);
|
|
||||||
if (index + 1 >= sources.length) onUnavailable?.();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||||
import { Gem, Heart, Loader2, PlayCircle } from "lucide-react";
|
import { Gem, Heart, Loader2, PlayCircle } from "lucide-react";
|
||||||
import { useState, type Ref } from "react";
|
import type { Ref } from "react";
|
||||||
import { HomeImage } from "./home-image";
|
|
||||||
import type { FeaturedImage } from "./types";
|
import type { FeaturedImage } from "./types";
|
||||||
|
|
||||||
type LovartFeaturedGalleryProps = {
|
type LovartFeaturedGalleryProps = {
|
||||||
@ -31,10 +30,6 @@ function getFeaturedPreviewUrl(item: FeaturedImage) {
|
|||||||
return `${source}?imageView2/2/w/900/q/78`;
|
return `${source}?imageView2/2/w/900/q/78`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageIdentity(item: FeaturedImage) {
|
|
||||||
return JSON.stringify([item.id, item.thumbnailUrl, item.originalUrl]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LovartFeaturedGallery({
|
export function LovartFeaturedGallery({
|
||||||
onOpenProjects,
|
onOpenProjects,
|
||||||
onOpenMaterials,
|
onOpenMaterials,
|
||||||
@ -47,13 +42,6 @@ export function LovartFeaturedGallery({
|
|||||||
isLoadingMoreFeatured,
|
isLoadingMoreFeatured,
|
||||||
setActiveCategory,
|
setActiveCategory,
|
||||||
}: LovartFeaturedGalleryProps) {
|
}: LovartFeaturedGalleryProps) {
|
||||||
const [unavailable, setUnavailable] = useState<Set<string>>(() => new Set());
|
|
||||||
const [retryVersion, setRetryVersion] = useState(0);
|
|
||||||
const uniqueItems = [...new Map(featuredItems.map((item) => [item.id, item])).values()];
|
|
||||||
const visibleItems = uniqueItems.filter((item) =>
|
|
||||||
Boolean(item.thumbnailUrl || item.originalUrl),
|
|
||||||
);
|
|
||||||
const unavailableCount = uniqueItems.filter(item => unavailable.has(imageIdentity(item))).length;
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={featuredSectionRef}
|
ref={featuredSectionRef}
|
||||||
@ -100,48 +88,44 @@ export function LovartFeaturedGallery({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{unavailableCount > 0 && (
|
{featuredItems.length === 0 ? (
|
||||||
<div role="status" className="mt-4 flex items-center gap-3 text-xs text-black/50 dark:text-white/50">
|
|
||||||
<span>{unavailableCount} 张灵感图片加载失败,请重试。</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shrink-0 underline underline-offset-4 hover:text-black dark:hover:text-white"
|
|
||||||
onClick={() => {
|
|
||||||
setUnavailable(new Set());
|
|
||||||
setRetryVersion((version) => version + 1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
重试加载
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{visibleItems.length === 0 ? (
|
|
||||||
<div className="mt-[22px] rounded-[10px] border border-dashed border-black/15 bg-[#f7f7f7] p-10 text-center text-[14px] text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
|
<div className="mt-[22px] rounded-[10px] border border-dashed border-black/15 bg-[#f7f7f7] p-10 text-center text-[14px] text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
|
||||||
{isLoadingFeatured
|
{isLoadingFeatured
|
||||||
? "灵感内容加载中..."
|
? "灵感内容加载中..."
|
||||||
: unavailableCount > 0 ? "图片暂时无法加载,请稍后重试。" : "当前没有可展示的灵感数据。"}
|
: "当前没有可展示的灵感数据。"}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4">
|
<div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4">
|
||||||
{visibleItems.map((item) => (
|
{featuredItems.map((item, index) => (
|
||||||
<button
|
<button
|
||||||
key={`${imageIdentity(item)}:${retryVersion}`}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={unavailable.has(imageIdentity(item))}
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open(item.originalUrl || item.thumbnailUrl, "_blank")
|
window.open(item.originalUrl || item.thumbnailUrl, "_blank")
|
||||||
}
|
}
|
||||||
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
|
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
|
||||||
>
|
>
|
||||||
{unavailable.has(imageIdentity(item)) ? (
|
<img
|
||||||
<div className="flex aspect-[4/5] items-center justify-center rounded-[12px] bg-muted p-4 text-sm text-muted-foreground">图片加载失败</div>
|
src={getFeaturedPreviewUrl(item)}
|
||||||
) : <HomeImage
|
|
||||||
sources={[getFeaturedPreviewUrl(item), item.thumbnailUrl, item.originalUrl]}
|
|
||||||
alt={item.prompt || "灵感图"}
|
alt={item.prompt || "灵感图"}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onUnavailable={() => setUnavailable((previous) => new Set(previous).add(imageIdentity(item)))}
|
decoding="async"
|
||||||
imageClassName="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
|
onError={(event) => {
|
||||||
/>}
|
const image = event.currentTarget;
|
||||||
|
const fallbackUrl = item.originalUrl || item.thumbnailUrl;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!fallbackUrl ||
|
||||||
|
image.dataset.fallbackApplied === "true"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
image.dataset.fallbackApplied = "true";
|
||||||
|
image.src = fallbackUrl;
|
||||||
|
}}
|
||||||
|
className="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
|
||||||
|
/>
|
||||||
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
|
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
|
||||||
<div className="flex min-w-0 items-center gap-[6px] pr-2">
|
<div className="flex min-w-0 items-center gap-[6px] pr-2">
|
||||||
<span className="flex h-[16px] w-[16px] shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#d8d8d8] text-[8px] font-medium text-white">
|
<span className="flex h-[16px] w-[16px] shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#d8d8d8] text-[8px] font-medium text-white">
|
||||||
@ -154,11 +138,11 @@ export function LovartFeaturedGallery({
|
|||||||
<div className="inline-flex shrink-0 items-center gap-[7px] text-[11px] leading-none">
|
<div className="inline-flex shrink-0 items-center gap-[7px] text-[11px] leading-none">
|
||||||
<span className="inline-flex items-center gap-[4px]">
|
<span className="inline-flex items-center gap-[4px]">
|
||||||
<PlayCircle className="h-[11px] w-[11px]" strokeWidth={2} />
|
<PlayCircle className="h-[11px] w-[11px]" strokeWidth={2} />
|
||||||
{(1200 + featuredItems.indexOf(item) * 37).toLocaleString()}
|
{(1200 + index * 37).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-[4px]">
|
<span className="inline-flex items-center gap-[4px]">
|
||||||
<Heart className="h-[11px] w-[11px]" strokeWidth={2} />
|
<Heart className="h-[11px] w-[11px]" strokeWidth={2} />
|
||||||
{(20 + (featuredItems.indexOf(item) % 9) * 11).toLocaleString()}
|
{(20 + (index % 9) * 11).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<Gem className="h-[11px] w-[11px]" strokeWidth={2} />
|
<Gem className="h-[11px] w-[11px]" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config";
|
import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config";
|
||||||
import type { NavLink, TutorialVideo } from "./types";
|
import type { NavLink, TutorialVideo } from "./types";
|
||||||
import { HomeImage } from "./home-image";
|
|
||||||
|
|
||||||
type SidebarItem = {
|
type SidebarItem = {
|
||||||
iconUrl?: string | null;
|
iconUrl?: string | null;
|
||||||
@ -184,18 +183,17 @@ export function LovartSidebar({
|
|||||||
onClick={onHome}
|
onClick={onHome}
|
||||||
title={siteName}
|
title={siteName}
|
||||||
>
|
>
|
||||||
<HomeImage
|
{siteLogo ? (
|
||||||
sources={[siteLogo]}
|
<img
|
||||||
|
src={siteLogo}
|
||||||
alt={siteName}
|
alt={siteName}
|
||||||
loading="eager"
|
|
||||||
className="h-7 w-7 rounded-full object-cover"
|
className="h-7 w-7 rounded-full object-cover"
|
||||||
imageClassName="h-7 w-7 rounded-full object-cover"
|
/>
|
||||||
fallback={
|
) : (
|
||||||
<span aria-hidden="true" className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-[12px] font-semibold text-white dark:bg-white dark:text-black">
|
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-[12px] font-semibold text-white dark:bg-white dark:text-black">
|
||||||
{siteName.slice(0, 1).toUpperCase()}
|
{siteName.slice(0, 1).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
}
|
)}
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">{siteName}</TooltipContent>
|
<TooltipContent side="right">{siteName}</TooltipContent>
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
"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 } from "@/lib/image-delivery";
|
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
const ImageAnnotator = dynamic(
|
const ImageAnnotator = dynamic(
|
||||||
() => import("./image-annotator").then((module) => module.ImageAnnotator),
|
() => import("./image-annotator").then((module) => module.ImageAnnotator),
|
||||||
@ -24,6 +22,25 @@ const AngleEditDialog = dynamic(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function triggerBrowserDownload(
|
||||||
|
url: string,
|
||||||
|
filename?: string,
|
||||||
|
options?: { openInNewTab?: boolean }
|
||||||
|
) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
if (filename) {
|
||||||
|
link.download = filename;
|
||||||
|
}
|
||||||
|
if (options?.openInNewTab) {
|
||||||
|
link.target = "_blank";
|
||||||
|
}
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
export function ImagePreview({
|
export function ImagePreview({
|
||||||
src,
|
src,
|
||||||
alt,
|
alt,
|
||||||
@ -220,12 +237,24 @@ export function ImagePreview({
|
|||||||
toast.error("下载失败,请重试");
|
toast.error("下载失败,请重试");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const filenameBase = `generated-image-${Date.now()}`;
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url: safeOriginalUrl });
|
const response = await fetch(
|
||||||
toast.success("原图已开始下载");
|
buildImageProxyUrl(safeOriginalUrl, { delivery: "original" })
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`下载失败(${response.status})`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const extension = getImageDownloadExtension(blob.type);
|
||||||
|
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
triggerBrowserDownload(url, filename);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
toast.success("下载成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("下载失败:", error);
|
console.error("下载失败:", error);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -277,10 +306,6 @@ export function ImagePreview({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isRetiredStorageUrl(src) || isRetiredStorageUrl(original)) {
|
|
||||||
return <div role="status" className="my-2 rounded-lg border bg-muted p-4 text-sm text-muted-foreground">{RETIRED_STORAGE_MESSAGE}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 缩略图(显示压缩后的缩略图)*/}
|
{/* 缩略图(显示压缩后的缩略图)*/}
|
||||||
@ -307,18 +332,9 @@ export function ImagePreview({
|
|||||||
}}
|
}}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onLoad={(event) => {
|
|
||||||
event.currentTarget.style.aspectRatio = "";
|
|
||||||
event.currentTarget.parentElement?.style.removeProperty("width");
|
|
||||||
event.currentTarget.alt = "Generated Image";
|
|
||||||
}}
|
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (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,7 +7,6 @@ import type { Vote } from "@/lib/db/schema";
|
|||||||
import type { ChatMessage } from "@/lib/types";
|
import type { ChatMessage } from "@/lib/types";
|
||||||
import { cn, sanitizeText } from "@/lib/utils";
|
import { cn, sanitizeText } from "@/lib/utils";
|
||||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||||
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
|
||||||
import { useDataStream } from "./data-stream-provider";
|
import { useDataStream } from "./data-stream-provider";
|
||||||
import { DocumentToolResult } from "./document";
|
import { DocumentToolResult } from "./document";
|
||||||
import { DocumentPreview } from "./document-preview";
|
import { DocumentPreview } from "./document-preview";
|
||||||
@ -522,7 +521,7 @@ const PurePreviewMessage = ({
|
|||||||
const displayModelName = extractedModelName || inferredFallbackModelName;
|
const displayModelName = extractedModelName || inferredFallbackModelName;
|
||||||
const filteredText =
|
const filteredText =
|
||||||
message.role === "assistant"
|
message.role === "assistant"
|
||||||
? getGatewayErrorPageMessage(contentWithoutBadge) || appendImageDoneSummary(contentWithoutBadge)
|
? appendImageDoneSummary(contentWithoutBadge)
|
||||||
: contentWithoutBadge;
|
: contentWithoutBadge;
|
||||||
const useAssistantTypewriter =
|
const useAssistantTypewriter =
|
||||||
message.role === "assistant" &&
|
message.role === "assistant" &&
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import { Button } from "./ui/button";
|
|||||||
import { X, Download, Target, Paintbrush } from "lucide-react";
|
import { X, Download, Target, Paintbrush } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ImageAnnotator } from "./image-annotator";
|
import { ImageAnnotator } from "./image-annotator";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
export const PreviewAttachment = ({
|
export const PreviewAttachment = ({
|
||||||
attachment,
|
attachment,
|
||||||
@ -74,11 +73,18 @@ export const PreviewAttachment = ({
|
|||||||
// 下载图片函数
|
// 下载图片函数
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url, name: name ?? "image" });
|
const response = await fetch(url);
|
||||||
toast.success("原图已开始下载");
|
const blob = await response.blob();
|
||||||
|
const downloadUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = downloadUrl;
|
||||||
|
link.download = name ?? `image-${Date.now()}.jpg`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(downloadUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("下载失败:", error);
|
console.error("下载失败:", error);
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@ -2074,9 +2073,18 @@ export function SceneFusionView({
|
|||||||
|
|
||||||
const downloadImage = async (url: string, filename: string) => {
|
const downloadImage = async (url: string, filename: string) => {
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({ url, name: filename });
|
const response = await fetch(url);
|
||||||
} catch (error) {
|
const blob = await response.blob();
|
||||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
|
||||||
|
} catch {
|
||||||
|
window.open(url, "_blank");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
# 旧存储停用与七牛上传核查
|
|
||||||
|
|
||||||
## s3.1080p.hk
|
|
||||||
|
|
||||||
该存储已永久停用,文件不可恢复。它与 2026 年 10 月 14 日迁入七牛的阿里云 OSS 资源不是同一套存储。
|
|
||||||
|
|
||||||
- 首页精选只展示原文件仍在有效存储上的记录,统计和分页按同一条件计算。
|
|
||||||
- 历史生成记录、聊天、画布节点保留。旧图片显示永久失效说明,不再发起下载、归档或上传到旧桶。
|
|
||||||
- 单图下载和代理返回 HTTP 410 / `storage_retired`;批量下载将对应文件记为失败,不重试旧源。
|
|
||||||
- 其他存储的临时加载失败仍展示错误卡片和重试入口。
|
|
||||||
- 黑色 Logo 为 `public/brand/logo-black.png`;白色版使用 SVG 色彩滤镜保留同一 PNG 的透明度和轮廓。两者随应用发布,不依赖对象存储。
|
|
||||||
|
|
||||||
一次性配置整理:在生产运行目录执行 `node scripts/storage/retire-1080p-storage.cjs` 预览,增加 `--apply` 停用旧配置、取消失效图片的精选标记并更新 Logo。执行前将原字段保存至 `/var/lib/ai-chatbot/retired-storage-20261103/`。该操作不删除图片记录或对象文件。
|
|
||||||
|
|
||||||
2026-11-03 核查:旧源生成记录 67,267 条;精选 65 条中 59 条来自旧源;旧源图片归档任务为 0。默认上传存储为七牛 `gongxiangpan-sc`。
|
|
||||||
|
|
||||||
## 2026-10-14 上传峰值
|
|
||||||
|
|
||||||
统计时区 UTC+8,GB 按十进制计算。数据来自七牛只读上传统计、对象清单和生产机迁移记录。
|
|
||||||
|
|
||||||
| 范围 | 对象数 | 字节数 | GB |
|
|
||||||
| --- | ---: | ---: | ---: |
|
|
||||||
| 七牛当天上传流量 | — | 825,113,404,030 | 825.11 |
|
|
||||||
| 当天写入且仍存在的 `legacy-oss/demo-sparkai/` | 409,821 | 812,788,883,061 | 812.79 |
|
|
||||||
| 当天写入且仍存在的 `legacy-s3/gongxiangpan/` | 539 | 865,360,590 | 0.87 |
|
|
||||||
| 当天写入且仍存在的其他目录 | 3,382 | 9,735,597,543 | 9.74 |
|
|
||||||
| 上传流量与当天现存对象大小差额 | — | 1,723,562,836 | 1.72 |
|
|
||||||
|
|
||||||
813.65 GB 位于本项目历史迁移目录。主要写入发生在 19:00–21:00。源分别为 `demo-sparkai.oss-cn-shanghai.aliyuncs.com` 和 `s3.jiansun.vip/gongxiangpan`,符合仓库迁移脚本的目标路径。
|
|
||||||
|
|
||||||
生产机 `/var/lib/ai-chatbot/aliyun-qiniu-20261014/verified.jsonl` 有 349,784 个去重后的目标对象、629,920,510,316 字节(629.92 GB)的逐对象验证记录。当前迁移目录比该回执多 60,576 个对象、183.73 GB;目录归属能够确认,现有回执没有覆盖这些对象。
|
|
||||||
|
|
||||||
桶由多个业务共用。其他目录中 `uploads/` 为 7.97 GB,其余包含 `dabi/`、`exports/`、`accio-ghost/` 和 `shared-drive/`,不能全部归为画布新生成资源。上传统计包含传输量,对象清单反映现存对象,不足以单独解释最后 1.72 GB 的差额。本次未删除或改动七牛对象。
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
import { sql, type SQL } from "drizzle-orm";
|
|
||||||
|
|
||||||
export function parseMediaQuery(params: URLSearchParams) {
|
|
||||||
const integer = (key: string, fallback: number, max: number) => {
|
|
||||||
const value = Number(params.get(key));
|
|
||||||
return Number.isInteger(value) && value > 0 ? Math.min(value, max) : fallback;
|
|
||||||
};
|
|
||||||
const date = (key: string) => {
|
|
||||||
const value = params.get(key) || "";
|
|
||||||
return /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
page: integer("page", 1, 100000),
|
|
||||||
pageSize: integer("pageSize", 40, 100),
|
|
||||||
search: (params.get("search") || "").trim().slice(0, 128),
|
|
||||||
type: ["image", "video"].includes(params.get("type") || "") ? params.get("type")! : "all",
|
|
||||||
model: params.get("model") || "",
|
|
||||||
featured: params.get("featured") === "true",
|
|
||||||
startDate: date("startDate"),
|
|
||||||
endDate: date("endDate"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mediaRecordsQuery(params: ReturnType<typeof parseMediaQuery>) {
|
|
||||||
const conditions: SQL[] = [sql`true`];
|
|
||||||
if (params.search)
|
|
||||||
conditions.push(
|
|
||||||
sql`exists (select 1 from "User" u where u.id=m."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`,
|
|
||||||
);
|
|
||||||
if (params.startDate) conditions.push(sql`m."createdAt" >= ${params.startDate}::date`);
|
|
||||||
if (params.endDate)
|
|
||||||
conditions.push(sql`m."createdAt" < ${params.endDate}::date + interval '1 day'`);
|
|
||||||
if (params.model) conditions.push(sql`(m."modelId" = ${params.model} or m."modelId" in (select id::text from "AiModel" where "modelId"=${params.model}))`);
|
|
||||||
const where = sql.join(conditions, sql` and `);
|
|
||||||
const videoFilters: SQL[] = [sql`true`];
|
|
||||||
if (params.startDate)
|
|
||||||
videoFilters.push(sql`v."createdAt">=${params.startDate}::date - interval '8 hours'`);
|
|
||||||
if (params.endDate)
|
|
||||||
videoFilters.push(sql`v."createdAt"<${params.endDate}::date+interval '16 hours'`);
|
|
||||||
if (params.model) videoFilters.push(sql`(v."video_metadata"->>'model'=${params.model} or v."video_metadata"->>'model' in (select id::text from "AiModel" where "modelId"=${params.model}))`);
|
|
||||||
if (params.search)
|
|
||||||
videoFilters.push(
|
|
||||||
sql`exists (select 1 from "User" u where u.id=c."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`,
|
|
||||||
);
|
|
||||||
return sql`
|
|
||||||
with video_candidates as materialized (
|
|
||||||
select v.id, v."chatId", v."createdAt" + interval '8 hours' as "createdAt", v."video_metadata", c."userId",
|
|
||||||
coalesce(nullif(v."permanent_video_url", ''), nullif(v."temporary_video_url", ''),
|
|
||||||
(regexp_match(t.body, '<video[^>]+src="([^"]+)"', 'i'))[1],
|
|
||||||
(regexp_match(t.body, '(https?://[^[:space:]"<>]+\\.mp4[^[:space:]"<>)]*)', 'i'))[1]) as url
|
|
||||||
from "Message_v2" v join "Chat" c on c.id=v."chatId"
|
|
||||||
cross join lateral (select case when jsonb_typeof(v.parts::jsonb)='array'
|
|
||||||
then (select string_agg(p->>'text', E'\\n') from jsonb_array_elements(v.parts::jsonb) p)
|
|
||||||
else v.parts::jsonb #>> '{}' end as body) t
|
|
||||||
where ${params.type !== "image" && !params.featured} and v.role='assistant'
|
|
||||||
and ${sql.join(videoFilters, sql` and `)}
|
|
||||||
and (v."permanent_video_url" is not null or v."temporary_video_url" is not null or v.parts::text ilike '%<video%' or v.parts::text ilike '%.mp4%')
|
|
||||||
), media as not materialized (
|
|
||||||
select g.id, 'image'::text as kind, g."createdAt", g."userId", g."modelId", null::text as url
|
|
||||||
from "GeneratedImage" g where ${params.type !== "video"} and (${!params.featured} or g."isFeatured")
|
|
||||||
union all
|
|
||||||
select id, 'video', "createdAt", "userId", "video_metadata"->>'model', url
|
|
||||||
from video_candidates where url is not null
|
|
||||||
), filtered as not materialized (select * from media m where ${where}),
|
|
||||||
totals as (select count(*)::int as total, count(*) filter(where kind='image')::int as images, count(*) filter(where kind='video')::int as videos from filtered),
|
|
||||||
page_rows as (
|
|
||||||
select * from filtered order by "createdAt" desc, kind, id desc
|
|
||||||
limit ${params.pageSize} offset ${(params.page - 1) * params.pageSize}
|
|
||||||
)
|
|
||||||
select t.total, t.images as "totalImages", t.videos as "totalVideos",
|
|
||||||
coalesce((select jsonb_agg(jsonb_build_object(
|
|
||||||
'id', case when p.kind='video' then 'video-'||p.id::text else p.id::text end,
|
|
||||||
'recordType',p.kind,'userId',p."userId",'userName',u.name,'userEmail',u.email,'userDepartment',u.department,
|
|
||||||
'createdAt',p."createdAt",'modelId',p."modelId",'chatId',coalesce(g."chatId",v."chatId"),
|
|
||||||
'thumbnailUrl',coalesce(p.url,g."thumbnailUrl"),'originalUrl',coalesce(p.url,g."originalUrl"),'videoUrl',p.url,
|
|
||||||
'prompt',coalesce(g.prompt,v."video_metadata"->>'prompt'),'imageSize',coalesce(g."imageSize",v."video_metadata"->>'resolution'),
|
|
||||||
'isFeatured',coalesce(g."isFeatured",false),'featuredCategory',g."featuredCategory",'featuredAt',g."featuredAt" + interval '8 hours'
|
|
||||||
) order by p."createdAt" desc,p.kind,p.id desc)
|
|
||||||
from page_rows p left join "User" u on u.id=p."userId"
|
|
||||||
left join "GeneratedImage" g on p.kind='image' and g.id=p.id
|
|
||||||
left join "Message_v2" v on p.kind='video' and v.id=p.id),'[]'::jsonb) as records
|
|
||||||
from totals t`;
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
// One queue for the records screen: visible previews get four network slots.
|
|
||||||
const waiting: Array<() => void> = [];
|
|
||||||
let active = 0;
|
|
||||||
function pump() {
|
|
||||||
while (active < 4 && waiting.length) waiting.shift()?.();
|
|
||||||
}
|
|
||||||
export function queuePreview(start: (release: () => void) => void) {
|
|
||||||
let released = false;
|
|
||||||
let started = false;
|
|
||||||
const release = () => {
|
|
||||||
if (released) return;
|
|
||||||
released = true;
|
|
||||||
if (started) active--;
|
|
||||||
else {
|
|
||||||
const index = waiting.indexOf(run);
|
|
||||||
if (index >= 0) waiting.splice(index, 1);
|
|
||||||
}
|
|
||||||
pump();
|
|
||||||
};
|
|
||||||
const run = () => {
|
|
||||||
started = true;
|
|
||||||
active++;
|
|
||||||
start(release);
|
|
||||||
};
|
|
||||||
waiting.push(run);
|
|
||||||
pump();
|
|
||||||
// Active fetches release their slot in finally, after abort has settled.
|
|
||||||
return () => {
|
|
||||||
if (!started) release();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
export function formatRecordTime(value: string) {
|
|
||||||
// The admin API normalizes each storage convention to Beijing wall-clock time.
|
|
||||||
return value
|
|
||||||
.replace("T", " ")
|
|
||||||
.replace(/(?:\.\d+)?Z$/, "")
|
|
||||||
.slice(0, 16);
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
|
||||||
import { messageText } from "./replacement-run-recovery";
|
|
||||||
|
|
||||||
type Message = { id: string; role: string; parts?: unknown[] };
|
|
||||||
|
|
||||||
export function getSceneRunResult(messages: Message[], requestMessageId: string) {
|
|
||||||
const index = messages.findIndex((message) => message.id === requestMessageId && message.role === "user");
|
|
||||||
if (index < 0) return null;
|
|
||||||
const replies = messages.slice(index + 1);
|
|
||||||
// A later user turn belongs to another run.
|
|
||||||
if (replies.some((message) => message.role === "user")) return null;
|
|
||||||
const urls = new Set(replies.filter((message) => message.role === "assistant")
|
|
||||||
.flatMap((message) => extractImageUrlsFromText(messageText(message))));
|
|
||||||
return { count: urls.size };
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
|
|
||||||
|
|
||||||
import { createOpenAI } from "@ai-sdk/openai";
|
import { createOpenAI } from "@ai-sdk/openai";
|
||||||
import {
|
import {
|
||||||
@ -419,7 +418,7 @@ export async function getDynamicModel(
|
|||||||
): Promise<LanguageModel | null> {
|
): Promise<LanguageModel | null> {
|
||||||
try {
|
try {
|
||||||
const model = await getAiModelById({ id: modelId });
|
const model = await getAiModelById({ id: modelId });
|
||||||
if (!model || !model.isEnabled || (model.supportsImageGen && isImageModelBalanceBlocked(model))) {
|
if (!model || !model.isEnabled) {
|
||||||
logger.warn("model unavailable", { modelId });
|
logger.warn("model unavailable", { modelId });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
* - qwen-image-edit-plus 系列:支持 1-6 张输出,自定义分辨率,提示词优化
|
* - qwen-image-edit-plus 系列:支持 1-6 张输出,自定义分辨率,提示词优化
|
||||||
* - qwen-image-edit:仅支持 1 张输出
|
* - qwen-image-edit:仅支持 1 张输出
|
||||||
*
|
*
|
||||||
* API 文档:https://help.aliyun.com/en/model-studio/qwen-image-generation-and-editing-api-reference
|
* API 文档:https://help.aliyun.com/zh/model-studio/developer-reference/qwen-vl-plus
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
@ -46,8 +46,6 @@ interface AlibabaImageResponse {
|
|||||||
output?: {
|
output?: {
|
||||||
task_id?: string;
|
task_id?: string;
|
||||||
task_status?: string;
|
task_status?: string;
|
||||||
code?: string;
|
|
||||||
message?: string;
|
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
finish_reason: string;
|
finish_reason: string;
|
||||||
message: {
|
message: {
|
||||||
@ -124,11 +122,6 @@ 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);
|
||||||
@ -175,7 +168,7 @@ function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number | null> {
|
async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number | null> {
|
||||||
if (!/^(?:https?:\/\/|data:image\/)/i.test(imageUrl)) return null;
|
if (!/^https?:\/\//i.test(imageUrl)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
||||||
@ -193,7 +186,7 @@ async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number |
|
|||||||
async function inferImageDimensionsFromImageUrl(
|
async function inferImageDimensionsFromImageUrl(
|
||||||
imageUrl: string,
|
imageUrl: string,
|
||||||
): Promise<{ width: number; height: number; aspectRatio: number } | null> {
|
): Promise<{ width: number; height: number; aspectRatio: number } | null> {
|
||||||
if (!/^(?:https?:\/\/|data:image\/)/i.test(imageUrl)) return null;
|
if (!/^https?:\/\//i.test(imageUrl)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
||||||
@ -309,14 +302,6 @@ async function prepareAlibabaInputImageUrl(
|
|||||||
maxSide: ALIBABA_MAX_INPUT_SIDE,
|
maxSide: ALIBABA_MAX_INPUT_SIDE,
|
||||||
modelId,
|
modelId,
|
||||||
});
|
});
|
||||||
if (isAlibabaQwenImage3ModelId(modelId)) {
|
|
||||||
if (prepared.buffer.length > maxInputBytes) {
|
|
||||||
throw new Error("输入图片超过模型大小限制,请先压缩图片后重试");
|
|
||||||
}
|
|
||||||
const format = prepared.transformed ? prepared.extension : metadata.format;
|
|
||||||
const mime = format === "jpg" || format === "jpeg" ? "image/jpeg" : `image/${format}`;
|
|
||||||
return `data:${mime};base64,${prepared.buffer.toString("base64")}`;
|
|
||||||
}
|
|
||||||
if (!prepared.transformed) {
|
if (!prepared.transformed) {
|
||||||
return normalizedUrl;
|
return normalizedUrl;
|
||||||
}
|
}
|
||||||
@ -580,10 +565,8 @@ async function sendAlibabaImageAsyncRequest(params: {
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
|
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
|
||||||
const reason = task.output?.message || task.message || `阿里图片任务${taskStatus || "失败"}`;
|
|
||||||
const code = task.output?.code || task.code;
|
|
||||||
throw new AlibabaImageTerminalTaskError(
|
throw new AlibabaImageTerminalTaskError(
|
||||||
`${reason}${code ? `(${code})` : ""}(task_id: ${taskId})`,
|
task.message || `阿里图片任务${taskStatus || "失败"}(task_id: ${taskId})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lastPollError = null;
|
lastPollError = null;
|
||||||
@ -786,15 +769,16 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
|
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
|
||||||
const preparedImageUrls = await Promise.all(
|
const preparedImageUrls: string[] = [];
|
||||||
selectedImageUrls.map((imageUrl) =>
|
for (const imageUrl of selectedImageUrls) {
|
||||||
prepareAlibabaInputImageUrl(
|
preparedImageUrls.push(
|
||||||
|
await prepareAlibabaInputImageUrl(
|
||||||
imageUrl,
|
imageUrl,
|
||||||
activeModelId,
|
activeModelId,
|
||||||
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
|
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。
|
// qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。
|
||||||
if (
|
if (
|
||||||
|
|||||||
@ -48,7 +48,6 @@ import {
|
|||||||
VIP98K_REFERENCE_STAGING_KEY_PREFIX,
|
VIP98K_REFERENCE_STAGING_KEY_PREFIX,
|
||||||
} from "@/lib/ai/vip98k-reference-staging";
|
} from "@/lib/ai/vip98k-reference-staging";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
|
||||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||||
import {
|
import {
|
||||||
IMAGE_UPLOAD_MAX_BYTES,
|
IMAGE_UPLOAD_MAX_BYTES,
|
||||||
@ -1024,77 +1023,6 @@ function getConfiguredPollSeconds(config: Vip98kModelRuntimeConfig, key: string,
|
|||||||
return Math.min(Math.max(value, 1), 3600);
|
return Math.min(Math.max(value, 1), 3600);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSynchronousImagePath(path: string) {
|
|
||||||
return /^\/v1\/images\/(?:generations|edits)\/?$/.test(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function recoverImageSubmission(params: {
|
|
||||||
apiKey: string;
|
|
||||||
baseURL: string;
|
|
||||||
modelId: string;
|
|
||||||
path: string;
|
|
||||||
idempotencyKey: string;
|
|
||||||
pollIntervalMs: number;
|
|
||||||
maxPollMs: number;
|
|
||||||
}) {
|
|
||||||
if (!isSynchronousImagePath(params.path)) return null;
|
|
||||||
const startedAt = Date.now();
|
|
||||||
const deadline = startedAt + params.maxPollMs;
|
|
||||||
logger.info("recovering original image request", { modelId: params.modelId });
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
const lookup = await fetch(`${params.baseURL}/api/external/requests/by-idempotency-key`, {
|
|
||||||
cache: "no-store",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${params.apiKey}`,
|
|
||||||
"Idempotency-Key": params.idempotencyKey,
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(Math.min(60_000, deadline - Date.now())),
|
|
||||||
});
|
|
||||||
if ([401, 403, 404, 410].includes(lookup.status)) return null;
|
|
||||||
if (lookup.ok) {
|
|
||||||
const state = toRecord(await parseJsonResponse(lookup));
|
|
||||||
if (!state || !["processing", "succeeded"].includes(String(state.status))) return null;
|
|
||||||
if (state.status === "succeeded") {
|
|
||||||
if (state.result_available !== true || !state.request_id) return null;
|
|
||||||
const result = await fetch(
|
|
||||||
`${params.baseURL}/api/external/requests/${encodeURIComponent(String(state.request_id))}/result`,
|
|
||||||
{
|
|
||||||
cache: "no-store",
|
|
||||||
headers: { Authorization: `Bearer ${params.apiKey}` },
|
|
||||||
signal: AbortSignal.timeout(Math.max(1, Math.min(60_000, deadline - Date.now()))),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if ([401, 403, 404, 410].includes(result.status)) return null;
|
|
||||||
if (result.ok) {
|
|
||||||
const payload = await parseJsonResponse(result);
|
|
||||||
const outputs = collectOutputUrls(payload);
|
|
||||||
if (!outputs.length) {
|
|
||||||
throw new ProviderTaskUncertainError("原生图请求已完成,但未取回图片结果,请勿重复生成");
|
|
||||||
}
|
|
||||||
logger.info("original image request recovered", {
|
|
||||||
modelId: params.modelId,
|
|
||||||
providerRequestId: state.request_id,
|
|
||||||
outputCount: outputs.length,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
});
|
|
||||||
return { payload, taskId: extractTaskId(payload), resultPath: extractResultPath(payload), outputs };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ProviderTaskUncertainError) throw error;
|
|
||||||
}
|
|
||||||
await delay(Math.max(0, Math.min(params.pollIntervalMs, deadline - Date.now())));
|
|
||||||
}
|
|
||||||
logger.warn("original image request recovery timed out", {
|
|
||||||
modelId: params.modelId,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitTask(params: {
|
async function submitTask(params: {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseURL?: string | null;
|
baseURL?: string | null;
|
||||||
@ -1107,8 +1035,6 @@ async function submitTask(params: {
|
|||||||
submissionTimeoutMs: number;
|
submissionTimeoutMs: number;
|
||||||
maxSubmissionAttempts: number;
|
maxSubmissionAttempts: number;
|
||||||
submissionRetryBaseDelayMs: number;
|
submissionRetryBaseDelayMs: number;
|
||||||
pollIntervalMs: number;
|
|
||||||
maxPollMs: number;
|
|
||||||
}) {
|
}) {
|
||||||
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
|
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
|
||||||
const path = String(params.path || "/api/external/task/generate").trim();
|
const path = String(params.path || "/api/external/task/generate").trim();
|
||||||
@ -1172,9 +1098,7 @@ async function submitTask(params: {
|
|||||||
signal: AbortSignal.timeout(params.submissionTimeoutMs),
|
signal: AbortSignal.timeout(params.submissionTimeoutMs),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const recovered = await recoverImageSubmission({ ...params, baseURL, path, idempotencyKey });
|
if (attempt >= maxAttempts) {
|
||||||
if (recovered) return recovered;
|
|
||||||
if (isSynchronousImagePath(path) || attempt >= maxAttempts) {
|
|
||||||
logger.error("provider submission transport failed", {
|
logger.error("provider submission transport failed", {
|
||||||
modelId: params.modelId,
|
modelId: params.modelId,
|
||||||
path,
|
path,
|
||||||
@ -1220,14 +1144,6 @@ async function submitTask(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if ([502, 504, 524].includes(response.status)) {
|
|
||||||
const recovered = await recoverImageSubmission({ ...params, baseURL, path, idempotencyKey });
|
|
||||||
if (recovered) return recovered;
|
|
||||||
if (isSynchronousImagePath(path)) {
|
|
||||||
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${getUserFacingImageGenerationErrorMessage({ message: extractErrorMessage(payload), status: response.status })} (HTTP ${response.status})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage = extractErrorMessage(payload);
|
const errorMessage = extractErrorMessage(payload);
|
||||||
const stagedMediaRetryDelayMs = getVip98kStagedMediaRetryDelay({
|
const stagedMediaRetryDelayMs = getVip98kStagedMediaRetryDelay({
|
||||||
status: response.status,
|
status: response.status,
|
||||||
@ -1272,9 +1188,9 @@ async function submitTask(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ((response.status === 409 && /idempotency\s+key.*(?:still\s+in\s+progress|processing)/i.test(errorMessage))
|
if ((response.status === 409 && /idempotency\s+key.*(?:still\s+in\s+progress|processing)/i.test(errorMessage))
|
||||||
|| [502, 504, 524].includes(response.status)
|
|| [502, 504].includes(response.status)
|
||||||
|| /pending\s+safe\s+reconciliation|request\s+outcome\s+(?:is\s+)?unknown|final\s+outcome\s+could\s+not\s+be\s+confirmed/i.test(errorMessage))
|
|| /pending\s+safe\s+reconciliation|request\s+outcome\s+(?:is\s+)?unknown|final\s+outcome\s+could\s+not\s+be\s+confirmed/i.test(errorMessage))
|
||||||
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${getUserFacingImageGenerationErrorMessage({ message: errorMessage, status: response.status })} (HTTP ${response.status})`);
|
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${errorMessage} (HTTP ${response.status})`);
|
||||||
throw new Error(`${errorMessage} (HTTP ${response.status})`);
|
throw new Error(`${errorMessage} (HTTP ${response.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1345,7 +1261,6 @@ export async function submitVip98kExternalTask(params: {
|
|||||||
const configuredInvocations = getVip98kInvocations(taskParams.modelConfig);
|
const configuredInvocations = getVip98kInvocations(taskParams.modelConfig);
|
||||||
const invocation = selectVip98kTaskInvocation({
|
const invocation = selectVip98kTaskInvocation({
|
||||||
modelId: taskParams.modelId,
|
modelId: taskParams.modelId,
|
||||||
kind: taskParams.kind,
|
|
||||||
configuredInvocation: configuredInvocations[0] || null,
|
configuredInvocation: configuredInvocations[0] || null,
|
||||||
configuredInvocations,
|
configuredInvocations,
|
||||||
imageCount: imageUrls.length,
|
imageCount: imageUrls.length,
|
||||||
@ -1394,12 +1309,6 @@ export async function submitVip98kExternalTask(params: {
|
|||||||
),
|
),
|
||||||
maxSubmissionAttempts: params.durable ? 1 : runtimeConfig.maxSubmissionAttempts,
|
maxSubmissionAttempts: params.durable ? 1 : runtimeConfig.maxSubmissionAttempts,
|
||||||
submissionRetryBaseDelayMs: runtimeConfig.submissionRetryBaseDelayMs,
|
submissionRetryBaseDelayMs: runtimeConfig.submissionRetryBaseDelayMs,
|
||||||
pollIntervalMs: getConfiguredPollSeconds(
|
|
||||||
taskParams.modelConfig, "pollIntervalSeconds", DEFAULT_POLL_INTERVAL_SECONDS,
|
|
||||||
) * 1000,
|
|
||||||
maxPollMs: getConfiguredPollSeconds(
|
|
||||||
taskParams.modelConfig, "maxPollSeconds", DEFAULT_MAX_POLL_SECONDS,
|
|
||||||
) * 1000,
|
|
||||||
});
|
});
|
||||||
const resultPath = submitted.resultPath || invocation?.resultPath || "";
|
const resultPath = submitted.resultPath || invocation?.resultPath || "";
|
||||||
return {
|
return {
|
||||||
@ -1529,7 +1438,7 @@ function normalizeDuration(value: unknown) {
|
|||||||
return Math.round(numeric);
|
return Math.round(numeric);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) {
|
function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) {
|
||||||
const videoParams = params.videoParams || {};
|
const videoParams = params.videoParams || {};
|
||||||
const imageParams = params.imageParams || {};
|
const imageParams = params.imageParams || {};
|
||||||
const promptTitle = params.prompt.split(/\s+/).slice(0, 8).join(" ").slice(0, 80);
|
const promptTitle = params.prompt.split(/\s+/).slice(0, 8).join(" ").slice(0, 80);
|
||||||
@ -1539,9 +1448,6 @@ export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvoca
|
|||||||
body.displayName = promptTitle || params.modelId;
|
body.displayName = promptTitle || params.modelId;
|
||||||
}
|
}
|
||||||
if ("prompt" in body || !invocation) body.prompt = params.prompt;
|
if ("prompt" in body || !invocation) body.prompt = params.prompt;
|
||||||
if (params.kind === "image" && invocationSupportsBodyField(invocation, "n")) {
|
|
||||||
body.n = imageParams.imageCount ?? imageParams.n ?? 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const aspectRatio =
|
const aspectRatio =
|
||||||
normalizeRatio(videoParams.ratio) ||
|
normalizeRatio(videoParams.ratio) ||
|
||||||
@ -1791,7 +1697,7 @@ async function runTask(params: Vip98kTaskParams) {
|
|||||||
}
|
}
|
||||||
const imageSize = String(params.imageParams?.imageSize || "2K").trim() || "2K";
|
const imageSize = String(params.imageParams?.imageSize || "2K").trim() || "2K";
|
||||||
const archiveStartedAt = Date.now();
|
const archiveStartedAt = Date.now();
|
||||||
const archived = await Promise.all(outputUrls.map(archiveOutputImage));
|
const archived = await Promise.all(outputUrls.slice(0, 1).map(archiveOutputImage));
|
||||||
logger.info("result archive completed", {
|
logger.info("result archive completed", {
|
||||||
modelId: params.modelId,
|
modelId: params.modelId,
|
||||||
outputCount: archived.length,
|
outputCount: archived.length,
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
import { isProviderBalanceError } from "@/lib/request-error";
|
|
||||||
|
|
||||||
type BalanceState = {
|
|
||||||
healthLastEventType?: unknown;
|
|
||||||
healthLastError?: unknown;
|
|
||||||
healthAutoDisabledReason?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function isImageModelBalanceBlocked(model: BalanceState | null | undefined) {
|
|
||||||
return Boolean(
|
|
||||||
model &&
|
|
||||||
((model.healthLastEventType === "quota" && isProviderBalanceError(model.healthLastError)) ||
|
|
||||||
isProviderBalanceError(model.healthAutoDisabledReason)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveConfiguredImageBalanceFallback<
|
|
||||||
T extends {
|
|
||||||
model: BalanceState & {
|
|
||||||
id: string;
|
|
||||||
modelId: string;
|
|
||||||
isEnabled: boolean;
|
|
||||||
supportsImageGen: boolean;
|
|
||||||
imageGenConfig?: unknown;
|
|
||||||
};
|
|
||||||
provider: { isEnabled?: boolean };
|
|
||||||
},
|
|
||||||
>(
|
|
||||||
requested: BalanceState & { fallbackModelId1?: string | null; fallbackModelId2?: string | null },
|
|
||||||
entries: T[],
|
|
||||||
input: {
|
|
||||||
imageSize?: unknown;
|
|
||||||
aspectRatio?: unknown;
|
|
||||||
referenceImageCount?: number;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (!isImageModelBalanceBlocked(requested)) return undefined;
|
|
||||||
for (const ref of [requested.fallbackModelId1, requested.fallbackModelId2]) {
|
|
||||||
if (!ref) continue;
|
|
||||||
const entry = entries.find(({ model }) => model.id === ref || model.modelId === ref);
|
|
||||||
if (
|
|
||||||
!entry ||
|
|
||||||
!entry.model.isEnabled ||
|
|
||||||
entry.provider.isEnabled === false ||
|
|
||||||
!entry.model.supportsImageGen ||
|
|
||||||
isImageModelBalanceBlocked(entry.model)
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
const config = entry.model.imageGenConfig as Record<string, unknown> | null;
|
|
||||||
const size = String(input.imageSize || "").toUpperCase();
|
|
||||||
const ratio = String(input.aspectRatio || "");
|
|
||||||
if (
|
|
||||||
size &&
|
|
||||||
Array.isArray(config?.supportedImageSizes) &&
|
|
||||||
!config.supportedImageSizes.includes(size)
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
if (
|
|
||||||
ratio &&
|
|
||||||
ratio !== "auto" &&
|
|
||||||
Array.isArray(config?.supportedAspectRatios) &&
|
|
||||||
!config.supportedAspectRatios.includes(ratio)
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
if (
|
|
||||||
input.referenceImageCount &&
|
|
||||||
typeof config?.maxReferenceImages === "number" &&
|
|
||||||
input.referenceImageCount > config.maxReferenceImages
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
18
lib/ai/runtime/image-fallback-balance-cooldown.ts
Normal file
18
lib/ai/runtime/image-fallback-balance-cooldown.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { isProviderBalanceError } from "@/lib/request-error";
|
||||||
|
|
||||||
|
const IMAGE_FAILOVER_BALANCE_COOLDOWN_MS = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export function isImageFallbackModelBalanceCoolingDown(
|
||||||
|
model: {
|
||||||
|
healthLastEventType?: unknown;
|
||||||
|
healthLastError?: unknown;
|
||||||
|
healthLastFailureAt?: unknown;
|
||||||
|
} | null | undefined,
|
||||||
|
now = Date.now(),
|
||||||
|
) {
|
||||||
|
if (model?.healthLastEventType !== "quota" || !isProviderBalanceError(model.healthLastError)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const failedAt = new Date(String(model.healthLastFailureAt || "")).getTime();
|
||||||
|
return Number.isFinite(failedAt) && now - failedAt < IMAGE_FAILOVER_BALANCE_COOLDOWN_MS && now >= failedAt;
|
||||||
|
}
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { ProviderTaskUncertainError } from "@/lib/video-canvas/video-task-lifecycle";
|
|
||||||
import { isProviderAuthenticationFailure, isCredentialFailureBan } from "./authentication-failover";
|
import { isProviderAuthenticationFailure, isCredentialFailureBan } from "./authentication-failover";
|
||||||
|
|
||||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||||
@ -13,7 +12,7 @@ import {
|
|||||||
isMediaInputFailure,
|
isMediaInputFailure,
|
||||||
isNonProviderRequestStatus,
|
isNonProviderRequestStatus,
|
||||||
} from "@/lib/ai/runtime/request-failure-classification";
|
} from "@/lib/ai/runtime/request-failure-classification";
|
||||||
import { isGatewayResponseTimeout, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
import { isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
||||||
|
|
||||||
export type AiModelHealthEventType =
|
export type AiModelHealthEventType =
|
||||||
| "success"
|
| "success"
|
||||||
@ -344,7 +343,6 @@ 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 =
|
||||||
@ -501,7 +499,6 @@ export async function reviveAiModelsFromCooldown(params?: { modelId?: string })
|
|||||||
|
|
||||||
const dueModels = candidates.filter((model) => {
|
const dueModels = candidates.filter((model) => {
|
||||||
if (isCredentialFailureBan(model.healthAutoDisabledReason)) return false;
|
if (isCredentialFailureBan(model.healthAutoDisabledReason)) return false;
|
||||||
if (isProviderBalanceError(model.healthAutoDisabledReason)) return false;
|
|
||||||
const cooldownStartedAt =
|
const cooldownStartedAt =
|
||||||
model.healthAutoDisabledAt ||
|
model.healthAutoDisabledAt ||
|
||||||
model.healthLastFailureAt ||
|
model.healthLastFailureAt ||
|
||||||
@ -571,7 +568,6 @@ export async function recordAiModelHealthSuccess(params: {
|
|||||||
healthStatus: aiModel.healthStatus,
|
healthStatus: aiModel.healthStatus,
|
||||||
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
|
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
|
||||||
healthObservationStartedAt: aiModel.healthObservationStartedAt,
|
healthObservationStartedAt: aiModel.healthObservationStartedAt,
|
||||||
healthAutoDisabledReason: aiModel.healthAutoDisabledReason,
|
|
||||||
})
|
})
|
||||||
.from(aiModel)
|
.from(aiModel)
|
||||||
.where(eq(aiModel.id, modelId))
|
.where(eq(aiModel.id, modelId))
|
||||||
@ -579,9 +575,6 @@ export async function recordAiModelHealthSuccess(params: {
|
|||||||
|
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|
||||||
// A previously accepted task can finish after the account runs out of credit.
|
|
||||||
if (isProviderBalanceError(existing.healthAutoDisabledReason)) return;
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// A request that started before the circuit opened can still prove the
|
// A request that started before the circuit opened can still prove the
|
||||||
// provider is reachable. Treat that success as the circuit's half-open
|
// provider is reachable. Treat that success as the circuit's half-open
|
||||||
@ -669,7 +662,6 @@ export async function recordAiModelHealthFailure(params: {
|
|||||||
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
|
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
|
||||||
healthObservationStartedAt: aiModel.healthObservationStartedAt,
|
healthObservationStartedAt: aiModel.healthObservationStartedAt,
|
||||||
healthAutoDisabledAt: aiModel.healthAutoDisabledAt,
|
healthAutoDisabledAt: aiModel.healthAutoDisabledAt,
|
||||||
healthAutoDisabledReason: aiModel.healthAutoDisabledReason,
|
|
||||||
})
|
})
|
||||||
.from(aiModel)
|
.from(aiModel)
|
||||||
.where(eq(aiModel.id, modelId))
|
.where(eq(aiModel.id, modelId))
|
||||||
@ -686,15 +678,13 @@ export async function recordAiModelHealthFailure(params: {
|
|||||||
: existing.healthConsecutiveFailures || 0;
|
: existing.healthConsecutiveFailures || 0;
|
||||||
const shouldAutoDisable =
|
const shouldAutoDisable =
|
||||||
classification.counted &&
|
classification.counted &&
|
||||||
(classification.eventType === "quota" ||
|
!existing.excludeFromFailover &&
|
||||||
(!existing.excludeFromFailover &&
|
|
||||||
!existing.excludeFromHealthBan &&
|
!existing.excludeFromHealthBan &&
|
||||||
(classification.reason === "provider_authentication_failed" ||
|
(classification.reason === "provider_authentication_failed" ||
|
||||||
(isObservation && existing.isEnabled) ||
|
(isObservation && existing.isEnabled) ||
|
||||||
(existing.healthStatus !== "auto_disabled" &&
|
(existing.healthStatus !== "auto_disabled" &&
|
||||||
nextConsecutiveFailures >= AUTO_DISABLE_FAILURE_THRESHOLD))));
|
nextConsecutiveFailures >= AUTO_DISABLE_FAILURE_THRESHOLD));
|
||||||
const balanceDisabled = isProviderBalanceError(existing.healthAutoDisabledReason);
|
const nextStatus = shouldAutoDisable
|
||||||
const nextStatus = shouldAutoDisable || balanceDisabled
|
|
||||||
? "auto_disabled"
|
? "auto_disabled"
|
||||||
: classification.counted
|
: classification.counted
|
||||||
? "degraded"
|
? "degraded"
|
||||||
@ -747,7 +737,7 @@ export async function recordAiModelHealthFailure(params: {
|
|||||||
healthUpdatedAt: now,
|
healthUpdatedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
if (shouldAutoDisable && !balanceDisabled) {
|
if (shouldAutoDisable) {
|
||||||
updateData.healthAutoDisabledAt = now;
|
updateData.healthAutoDisabledAt = now;
|
||||||
updateData.healthAutoDisabledReason = autoDisabledReason || errorMessage;
|
updateData.healthAutoDisabledReason = autoDisabledReason || errorMessage;
|
||||||
} else if (existing.healthStatus !== "auto_disabled" && !isObservation) {
|
} else if (existing.healthStatus !== "auto_disabled" && !isObservation) {
|
||||||
|
|||||||
@ -238,10 +238,7 @@ export function bindVip98kImages(
|
|||||||
imageUrls: string[],
|
imageUrls: string[],
|
||||||
) {
|
) {
|
||||||
const bindingKeys: string[] = [];
|
const bindingKeys: string[] = [];
|
||||||
if (imageUrls.length === 0) {
|
if (imageUrls.length === 0) return bindingKeys;
|
||||||
for (const key of [...IMAGE_ARRAY_INPUT_KEYS, ...IMAGE_SINGLE_INPUT_KEYS, "video_clips"]) delete body[key];
|
|
||||||
return bindingKeys;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of IMAGE_ARRAY_INPUT_KEYS) {
|
for (const key of IMAGE_ARRAY_INPUT_KEYS) {
|
||||||
if (!(key in body)) continue;
|
if (!(key in body)) continue;
|
||||||
@ -308,7 +305,6 @@ function scoreReferenceInvocation(invocation: Vip98kTaskInvocation) {
|
|||||||
|
|
||||||
export function selectVip98kTaskInvocation(params: {
|
export function selectVip98kTaskInvocation(params: {
|
||||||
modelId: string;
|
modelId: string;
|
||||||
kind?: string;
|
|
||||||
configuredInvocation: Vip98kTaskInvocation | null;
|
configuredInvocation: Vip98kTaskInvocation | null;
|
||||||
configuredInvocations?: Vip98kTaskInvocation[];
|
configuredInvocations?: Vip98kTaskInvocation[];
|
||||||
imageCount?: number;
|
imageCount?: number;
|
||||||
@ -338,17 +334,6 @@ export function selectVip98kTaskInvocation(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.kind === "image" && !params.imageCount) {
|
|
||||||
const textImageTask = candidates.find(candidate => {
|
|
||||||
const properties = candidate.inputSchema?.properties as Record<string, unknown> | undefined;
|
|
||||||
const required = candidate.inputSchema?.required as string[] | undefined;
|
|
||||||
return candidate.kind === "task" && candidate.path === "/extensions/v1/tasks" &&
|
|
||||||
invocationTargetsModel(candidate, params.modelId) && properties?.resolution && properties?.aspect_ratio &&
|
|
||||||
!required?.some(key => [...IMAGE_ARRAY_INPUT_KEYS, ...IMAGE_SINGLE_INPUT_KEYS, "video_clips"].includes(key));
|
|
||||||
});
|
|
||||||
if (textImageTask) return resolveVip98kTaskInvocation(params.modelId, textImageTask);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolveVip98kTaskInvocation(
|
return resolveVip98kTaskInvocation(
|
||||||
params.modelId,
|
params.modelId,
|
||||||
params.configuredInvocation || candidates[0] || null,
|
params.configuredInvocation || candidates[0] || null,
|
||||||
|
|||||||
@ -14,16 +14,13 @@ import { getCanvasCapability } from "@/lib/canvas/capabilities/registry";
|
|||||||
import { enqueueCanvasCapabilityExecution } from "@/lib/canvas/capabilities/task-queue";
|
import { enqueueCanvasCapabilityExecution } from "@/lib/canvas/capabilities/task-queue";
|
||||||
import {
|
import {
|
||||||
createOrRecoverCanvasCapabilityTask,
|
createOrRecoverCanvasCapabilityTask,
|
||||||
getCanvasCapabilityTask,
|
MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS,
|
||||||
heartbeatCanvasCapabilityTask,
|
|
||||||
markCanvasCapabilityTaskFailed,
|
markCanvasCapabilityTaskFailed,
|
||||||
markCanvasCapabilityTaskRunning,
|
markCanvasCapabilityTaskRunning,
|
||||||
markCanvasCapabilityTaskSucceeded,
|
markCanvasCapabilityTaskSucceeded,
|
||||||
} from "@/lib/canvas/capabilities/task-store";
|
} from "@/lib/canvas/capabilities/task-store";
|
||||||
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
|
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
|
||||||
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
|
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
|
||||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
|
||||||
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
|
||||||
|
|
||||||
export type CanvasCapabilityRuntimeResult = {
|
export type CanvasCapabilityRuntimeResult = {
|
||||||
clientCommand?: CanvasCapabilityClientCommand;
|
clientCommand?: CanvasCapabilityClientCommand;
|
||||||
@ -61,11 +58,9 @@ function serializeTask(task: {
|
|||||||
status: string;
|
status: string;
|
||||||
version: number;
|
version: number;
|
||||||
}) {
|
}) {
|
||||||
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
|
|
||||||
return {
|
return {
|
||||||
attempts: task.attempts,
|
attempts: task.attempts,
|
||||||
error: failure ? formatRequestFailure(failure, task.id) : task.error,
|
error: task.error,
|
||||||
failure,
|
|
||||||
id: task.id,
|
id: task.id,
|
||||||
requestId: task.requestId,
|
requestId: task.requestId,
|
||||||
result: task.result,
|
result: task.result,
|
||||||
@ -74,13 +69,6 @@ function serializeTask(task: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function storedTaskResult(task: Parameters<typeof serializeTask>[0], mode: "headless" | "interactive"): CanvasCapabilityRuntimeResult {
|
|
||||||
const result = task.status === "succeeded" && task.result && typeof task.result === "object"
|
|
||||||
? task.result as Omit<CanvasCapabilityRuntimeResult, "task">
|
|
||||||
: { mode };
|
|
||||||
return { ...result, task: serializeTask(task) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMediaOperationPlan(params: {
|
function buildMediaOperationPlan(params: {
|
||||||
command: CanvasCapabilityClientCommand;
|
command: CanvasCapabilityClientCommand;
|
||||||
mediaResults: CanvasCapabilityMediaResult[];
|
mediaResults: CanvasCapabilityMediaResult[];
|
||||||
@ -223,7 +211,11 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (task.status === "succeeded") {
|
if (task.status === "succeeded") {
|
||||||
return storedTaskResult(task, "headless");
|
const storedResult =
|
||||||
|
task.result && typeof task.result === "object"
|
||||||
|
? (task.result as Omit<CanvasCapabilityRuntimeResult, "task">)
|
||||||
|
: ({ mode: "headless" } as const);
|
||||||
|
return { ...storedResult, task: serializeTask(task) };
|
||||||
}
|
}
|
||||||
if (task.status === "running") {
|
if (task.status === "running") {
|
||||||
return {
|
return {
|
||||||
@ -233,8 +225,15 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
task: serializeTask(task),
|
task: serializeTask(task),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (task.status === "failed") {
|
if (
|
||||||
return { mode: "headless", task: serializeTask(task) };
|
task.status === "failed" &&
|
||||||
|
task.attempts >= MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
|
||||||
|
) {
|
||||||
|
throw new CanvasCapabilityRuntimeError(
|
||||||
|
task.error || "画布能力任务超过最大重试次数",
|
||||||
|
409,
|
||||||
|
"CANVAS_CAPABILITY_RETRY_EXHAUSTED"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldRunHeadlessly = shouldExecuteCanvasCapabilityHeadlessly({
|
const shouldRunHeadlessly = shouldExecuteCanvasCapabilityHeadlessly({
|
||||||
@ -242,9 +241,7 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
command,
|
command,
|
||||||
});
|
});
|
||||||
if (!shouldRunHeadlessly) {
|
if (!shouldRunHeadlessly) {
|
||||||
const claimed = await markCanvasCapabilityTaskRunning(task.id);
|
task = await markCanvasCapabilityTaskRunning(task.id);
|
||||||
if (!claimed) return storedTaskResult((await getCanvasCapabilityTask({ requestId: command.requestId, userId: params.userId, projectId: params.projectId })) || task, "interactive");
|
|
||||||
task = claimed;
|
|
||||||
const result = {
|
const result = {
|
||||||
mode: "interactive" as const,
|
mode: "interactive" as const,
|
||||||
clientCommand: command,
|
clientCommand: command,
|
||||||
@ -253,18 +250,10 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
return { ...result, task: serializeTask(task) };
|
return { ...result, task: serializeTask(task) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
return enqueueCanvasCapabilityExecution({
|
||||||
void heartbeatCanvasCapabilityTask(task.id).catch((error) => {
|
|
||||||
console.warn("[Canvas Capability] Task heartbeat failed:", error);
|
|
||||||
});
|
|
||||||
}, 30_000);
|
|
||||||
try {
|
|
||||||
return await trackRuntimeWork(() => enqueueCanvasCapabilityExecution({
|
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
execute: async () => {
|
execute: async () => {
|
||||||
const claimed = await markCanvasCapabilityTaskRunning(task.id);
|
task = await markCanvasCapabilityTaskRunning(task.id);
|
||||||
if (!claimed) return storedTaskResult((await getCanvasCapabilityTask({ requestId: command.requestId, userId: params.userId, projectId: params.projectId })) || task, "headless");
|
|
||||||
task = claimed;
|
|
||||||
try {
|
try {
|
||||||
const projectRevision = context.project.updatedAt.toISOString();
|
const projectRevision = context.project.updatedAt.toISOString();
|
||||||
const mutationPlan = buildMutationOperationPlan({
|
const mutationPlan = buildMutationOperationPlan({
|
||||||
@ -310,8 +299,5 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
});
|
||||||
} finally {
|
|
||||||
clearInterval(heartbeat);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { and, eq, inArray, lt, sql } from "drizzle-orm";
|
import { and, eq, lt, sql } from "drizzle-orm";
|
||||||
|
|
||||||
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { canvasCapabilityTask } from "@/lib/db/schema";
|
import { canvasCapabilityTask } from "@/lib/db/schema";
|
||||||
|
|
||||||
const STALE_RUNNING_TASK_MS = 15 * 60 * 1000;
|
const STALE_RUNNING_TASK_MS = 15 * 60 * 1000;
|
||||||
const INTERRUPTED_TASK_MESSAGE = "任务执行中断,结果尚未确认。请先查看画布和历史记录,或联系管理员核对原任务,避免重复处理。";
|
export const MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS = 3;
|
||||||
|
|
||||||
export type CanvasCapabilityTaskResult = Record<string, unknown>;
|
export type CanvasCapabilityTaskResult = Record<string, unknown>;
|
||||||
|
|
||||||
@ -25,25 +25,7 @@ export async function getCanvasCapabilityTask(params: {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!task) return null;
|
return task || null;
|
||||||
const staleBefore = new Date(Date.now() - STALE_RUNNING_TASK_MS);
|
|
||||||
if (["pending", "running"].includes(task.status) && task.updatedAt < staleBefore) {
|
|
||||||
const [interrupted] = await db.update(canvasCapabilityTask).set({
|
|
||||||
status: "failed",
|
|
||||||
error: INTERRUPTED_TASK_MESSAGE,
|
|
||||||
completedAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}).where(and(
|
|
||||||
eq(canvasCapabilityTask.id, task.id),
|
|
||||||
inArray(canvasCapabilityTask.status, ["pending", "running"]),
|
|
||||||
lt(canvasCapabilityTask.updatedAt, staleBefore),
|
|
||||||
)).returning();
|
|
||||||
if (interrupted) return interrupted;
|
|
||||||
const [latest] = await db.select().from(canvasCapabilityTask)
|
|
||||||
.where(eq(canvasCapabilityTask.id, task.id)).limit(1);
|
|
||||||
return latest || task;
|
|
||||||
}
|
|
||||||
return task;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createOrRecoverCanvasCapabilityTask(params: {
|
export async function createOrRecoverCanvasCapabilityTask(params: {
|
||||||
@ -60,7 +42,6 @@ export async function createOrRecoverCanvasCapabilityTask(params: {
|
|||||||
capabilityId: params.command.capabilityId,
|
capabilityId: params.command.capabilityId,
|
||||||
source: params.command.source,
|
source: params.command.source,
|
||||||
request: params.command,
|
request: params.command,
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
})
|
||||||
.onConflictDoNothing({ target: canvasCapabilityTask.requestId });
|
.onConflictDoNothing({ target: canvasCapabilityTask.requestId });
|
||||||
|
|
||||||
@ -69,17 +50,57 @@ export async function createOrRecoverCanvasCapabilityTask(params: {
|
|||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
};
|
};
|
||||||
const task = await getCanvasCapabilityTask(taskLookup);
|
let task = await getCanvasCapabilityTask(taskLookup);
|
||||||
if (!task) {
|
if (!task) {
|
||||||
throw new Error("无法创建画布能力任务");
|
throw new Error("无法创建画布能力任务");
|
||||||
}
|
}
|
||||||
|
|
||||||
return task;
|
const staleBefore = new Date(Date.now() - STALE_RUNNING_TASK_MS);
|
||||||
}
|
if (
|
||||||
|
task.status === "failed" &&
|
||||||
|
task.attempts < MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
|
||||||
|
) {
|
||||||
|
await db
|
||||||
|
.update(canvasCapabilityTask)
|
||||||
|
.set({
|
||||||
|
status: "pending",
|
||||||
|
error: null,
|
||||||
|
completedAt: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(canvasCapabilityTask.id, task.id));
|
||||||
|
} else if (
|
||||||
|
task.status === "running" &&
|
||||||
|
task.updatedAt < staleBefore &&
|
||||||
|
task.attempts < MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
|
||||||
|
) {
|
||||||
|
await db
|
||||||
|
.update(canvasCapabilityTask)
|
||||||
|
.set({
|
||||||
|
status: "pending",
|
||||||
|
error: "恢复超时的执行中任务",
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canvasCapabilityTask.id, task.id),
|
||||||
|
lt(canvasCapabilityTask.updatedAt, staleBefore)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (task.status === "running" && task.updatedAt < staleBefore) {
|
||||||
|
await db
|
||||||
|
.update(canvasCapabilityTask)
|
||||||
|
.set({
|
||||||
|
status: "failed",
|
||||||
|
error: "画布能力任务超过最大重试次数",
|
||||||
|
completedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(canvasCapabilityTask.id, task.id));
|
||||||
|
}
|
||||||
|
|
||||||
export async function heartbeatCanvasCapabilityTask(taskId: string) {
|
task = (await getCanvasCapabilityTask(taskLookup)) || task;
|
||||||
await db.update(canvasCapabilityTask).set({ updatedAt: new Date() })
|
return task;
|
||||||
.where(and(eq(canvasCapabilityTask.id, taskId), inArray(canvasCapabilityTask.status, ["pending", "running"])));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markCanvasCapabilityTaskRunning(taskId: string) {
|
export async function markCanvasCapabilityTaskRunning(taskId: string) {
|
||||||
@ -94,10 +115,11 @@ export async function markCanvasCapabilityTaskRunning(taskId: string) {
|
|||||||
error: null,
|
error: null,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.where(and(eq(canvasCapabilityTask.id, taskId), eq(canvasCapabilityTask.status, "pending")))
|
.where(eq(canvasCapabilityTask.id, taskId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return task || null;
|
if (!task) throw new Error("画布能力任务不存在");
|
||||||
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markCanvasCapabilityTaskSucceeded(
|
export async function markCanvasCapabilityTaskSucceeded(
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
export type DownloadFailure = {
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
recommendation: string;
|
|
||||||
status: number;
|
|
||||||
retryable: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getDownloadFailure(error: unknown): DownloadFailure {
|
|
||||||
const text = error && typeof error === "object" && "message" in error
|
|
||||||
? `${"name" in error ? error.name : "Error"}: ${error.message}` : String(error || "");
|
|
||||||
const failure = (code: string, message: string, recommendation: string, status: number, retryable = false) =>
|
|
||||||
({ code, message, recommendation, status, retryable });
|
|
||||||
if (/旧存储已停用|storage_retired/.test(text)) return failure("STORAGE_RETIRED", "原图所在的旧存储已停用。", "请重新上传原图;重试下载无法恢复该文件。", 410);
|
|
||||||
if (/HTTP (?:404|410)|SOURCE_NOT_FOUND/i.test(text)) return failure("SOURCE_NOT_FOUND", "原图链接已失效或源文件已被删除。", "请重新上传原图,或联系管理员核对归档副本;无需重新生成图片。", 404);
|
|
||||||
if (/HTTP (?:401|403)|SOURCE_FORBIDDEN/i.test(text)) return failure("SOURCE_FORBIDDEN", "图片源站拒绝访问原图。", "请联系管理员检查原图访问权限或链接有效期,并提供下载编号。", 403);
|
|
||||||
if (/queue.*busy|正在准备的原图较多|DOWNLOAD_BUSY/i.test(text)) return failure("DOWNLOAD_BUSY", "正在准备的下载较多。", "请等待当前下载完成后再试,避免同时重复点击。", 429, true);
|
|
||||||
if (/TimeoutError|AbortError|timeout|timed out|deadline|超时|HTTP (?:408|504|524)/i.test(text)) return failure("SOURCE_TIMEOUT", "读取原图或准备下载文件超时。", "请稍后再次下载;持续失败时联系管理员并提供下载编号,已归档的原图无需重新生成。", 504, true);
|
|
||||||
if (/fetch failed|failed to fetch|ECONN|ENOTFOUND|EAI_AGAIN|network|HTTP (?:429|5\d\d)/i.test(text)) return failure("SOURCE_UNAVAILABLE", "暂时无法连接图片源站。", "请稍后重试下载;持续失败时联系管理员检查取图线路,并提供下载编号。", 503, true);
|
|
||||||
if (/not.*image|error document|不是图片|无法识别图片|empty response|image is empty|Invalid prepared original/i.test(text)) return failure("INVALID_IMAGE", "源地址没有返回可用的图片文件。", "请重新上传可打开的原图;若原图正常,请联系管理员并提供下载编号。", 422);
|
|
||||||
if (/sharp|pngload|jpegload|unsupported image|无法解析图片|convert|转换/i.test(text)) return failure("IMAGE_CONVERSION_FAILED", "原图无法转换为下载所需的 PNG 文件。", "请保留原图并联系管理员提供下载编号,或重新上传 PNG/JPEG 图片。", 422);
|
|
||||||
if (/upload|存储桶|保存下载|STORAGE_WRITE_FAILED/i.test(text)) return failure("DOWNLOAD_STORAGE_FAILED", "下载文件保存失败,已归档的原图仍然保留。", "请稍后重试;持续失败时联系管理员检查存储,并提供下载编号。", 503, true);
|
|
||||||
if (/未登录/.test(text)) return failure("LOGIN_REQUIRED", "登录状态已失效。", "请重新登录后下载,当前图片不会因此删除。", 401);
|
|
||||||
if (/无法确认原图/.test(text)) return failure("ORIGINAL_IMAGE_UNCONFIRMED", "图片链接包含处理参数,无法确认原图。", "请选择未裁剪、未缩放的原图链接,或重新上传原图。", 400);
|
|
||||||
if (/图片地址无效/.test(text)) return failure("INVALID_IMAGE_URL", "图片地址无效。", "请刷新画布后重新选择图片;仍然失败时请重新上传原图。", 400);
|
|
||||||
return failure("DOWNLOAD_FAILED", "下载准备未能完成。", "请联系管理员并提供下载编号,以便核对原图、转换和存储的具体错误;无需重新生成。", 500, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDownloadFailure(failure: DownloadFailure, requestId?: string) {
|
|
||||||
return `${failure.message}${failure.recommendation}${requestId ? `\n下载编号:${requestId}` : ""}`;
|
|
||||||
}
|
|
||||||
@ -1,10 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { cacheImageBytes, readImageBytes, type ImageBytes } from "@/lib/storage/image-byte-cache";
|
import { cacheImageBytes, readImageBytes, type ImageBytes } from "@/lib/storage/image-byte-cache";
|
||||||
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "./download-error";
|
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
|
||||||
|
|
||||||
const logger = createLogger("canvas.download");
|
|
||||||
|
|
||||||
export type DownloadPreparationInput = {
|
export type DownloadPreparationInput = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -13,18 +9,17 @@ export type DownloadPreparationInput = {
|
|||||||
cookie: string;
|
cookie: string;
|
||||||
};
|
};
|
||||||
type Result =
|
type Result =
|
||||||
| { status: "preparing"; requestId: string }
|
| { status: "preparing" }
|
||||||
| { status: "ready"; asset: ImageBytes }
|
| { status: "ready"; asset: ImageBytes }
|
||||||
| { status: "failed"; error: string; failure: DownloadFailure; requestId: string };
|
| { status: "failed"; error: string };
|
||||||
type Job = {
|
type Job = {
|
||||||
requestId: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
input: DownloadPreparationInput;
|
input: DownloadPreparationInput;
|
||||||
status: "queued" | "running" | "ready" | "failed";
|
status: "queued" | "running" | "ready" | "failed";
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
asset?: ImageBytes;
|
asset?: ImageBytes;
|
||||||
failure?: DownloadFailure;
|
|
||||||
};
|
};
|
||||||
|
const FAILURE_MESSAGE = "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
|
||||||
const MAX_MEMORY_BYTES = 128 * 1024 * 1024;
|
const MAX_MEMORY_BYTES = 128 * 1024 * 1024;
|
||||||
|
|
||||||
// A download is independent of node autosave and of the archive worker's batch.
|
// A download is independent of node autosave and of the archive worker's batch.
|
||||||
@ -74,7 +69,7 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
reject(new Error("Download preparation deadline"));
|
reject(new Error("Download preparation deadline"));
|
||||||
}, deps.timeoutMs || 150_000);
|
}, deps.timeoutMs || 90_000);
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
if (asset.contentType !== "image/png" || !asset.buffer.length || asset.buffer.length > MAX_MEMORY_BYTES) {
|
if (asset.contentType !== "image/png" || !asset.buffer.length || asset.buffer.length > MAX_MEMORY_BYTES) {
|
||||||
@ -86,10 +81,8 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
const stored = await deps.read(key).catch(() => null);
|
const stored = await deps.read(key).catch(() => null);
|
||||||
if (stored?.contentType === "image/png" && stored.buffer.length) job.asset = undefined;
|
if (stored?.contentType === "image/png" && stored.buffer.length) job.asset = undefined;
|
||||||
job.status = "ready";
|
job.status = "ready";
|
||||||
} catch (error) {
|
} catch {
|
||||||
job.status = "failed";
|
job.status = "failed";
|
||||||
job.failure = getDownloadFailure(error);
|
|
||||||
logger.warn("preparation_failed", error, { requestId: job.requestId, code: job.failure.code });
|
|
||||||
} finally {
|
} finally {
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
// Do not retain authentication data after a preparation finishes.
|
// Do not retain authentication data after a preparation finishes.
|
||||||
@ -112,11 +105,8 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
prune();
|
prune();
|
||||||
const current = jobs.get(key);
|
const current = jobs.get(key);
|
||||||
if (current?.status === "ready" && current.asset) return { status: "ready", asset: current.asset };
|
if (current?.status === "ready" && current.asset) return { status: "ready", asset: current.asset };
|
||||||
if (current?.status === "failed") {
|
if (current?.status === "failed") return { status: "failed", error: FAILURE_MESSAGE };
|
||||||
const failure = current.failure || getDownloadFailure(null);
|
if (current && current.status !== "ready") return { status: "preparing" };
|
||||||
return { status: "failed", error: formatDownloadFailure(failure, current.requestId), failure, requestId: current.requestId };
|
|
||||||
}
|
|
||||||
if (current && current.status !== "ready") return { status: "preparing", requestId: current.requestId };
|
|
||||||
if (jobs.size >= 64) {
|
if (jobs.size >= 64) {
|
||||||
for (const [oldKey, job] of jobs) {
|
for (const [oldKey, job] of jobs) {
|
||||||
if (job.status === "ready" || job.status === "failed") jobs.delete(oldKey);
|
if (job.status === "ready" || job.status === "failed") jobs.delete(oldKey);
|
||||||
@ -125,14 +115,11 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
}
|
}
|
||||||
if (jobs.size >= 64 || [...jobs.values()].filter(job => job.userId === input.userId &&
|
if (jobs.size >= 64 || [...jobs.values()].filter(job => job.userId === input.userId &&
|
||||||
(job.status === "queued" || job.status === "running")).length >= 4) {
|
(job.status === "queued" || job.status === "running")).length >= 4) {
|
||||||
const requestId = randomUUID();
|
return { status: "failed", error: "正在准备的原图较多,请等待当前下载完成后再试" };
|
||||||
const failure = getDownloadFailure("DOWNLOAD_BUSY");
|
|
||||||
return { status: "failed", error: formatDownloadFailure(failure, requestId), failure, requestId };
|
|
||||||
}
|
}
|
||||||
const requestId = randomUUID();
|
jobs.set(key, { userId: input.userId, input, status: "queued", updatedAt: now() });
|
||||||
jobs.set(key, { requestId, userId: input.userId, input, status: "queued", updatedAt: now() });
|
|
||||||
deps.schedule(drain);
|
deps.schedule(drain);
|
||||||
return { status: "preparing", requestId };
|
return { status: "preparing" };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { generateCanvasImage, type CanvasGenerateImageRequest } from "@/lib/canv
|
|||||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
import { getImageToolCreditCost, type ImageToolCreditFeature } from "@/lib/points/config";
|
import { getImageToolCreditCost, type ImageToolCreditFeature } from "@/lib/points/config";
|
||||||
import { getUserFacingImageGenerationErrorMessage, getRequestFailureDetails } from "@/lib/request-error";
|
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
||||||
|
|
||||||
interface GenerateImageRequest extends CanvasGenerateImageRequest {
|
interface GenerateImageRequest extends CanvasGenerateImageRequest {
|
||||||
async?: boolean;
|
async?: boolean;
|
||||||
@ -121,7 +121,7 @@ export async function executeCanvasImageResponse(body: GenerateImageRequest, use
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Canvas Generate Image] Error:", error);
|
console.error("[Canvas Generate Image] Error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: getUserFacingImageGenerationErrorMessage(error), failure: getRequestFailureDetails(error),
|
{ success: false, error: getUserFacingImageGenerationErrorMessage(error),
|
||||||
...(error instanceof ProviderTaskUncertainError ? { code: "PROVIDER_TASK_UNCERTAIN" } : {}) },
|
...(error instanceof ProviderTaskUncertainError ? { code: "PROVIDER_TASK_UNCERTAIN" } : {}) },
|
||||||
{ status: error instanceof ProviderTaskUncertainError ? 503 : 500 },
|
{ status: error instanceof ProviderTaskUncertainError ? 503 : 500 },
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { agentImageCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-sto
|
|||||||
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
|
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
|
||||||
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
|
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
|
||||||
import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
||||||
import { getAiModelById, getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||||
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
||||||
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
|
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
|
||||||
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
|
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
|
||||||
@ -43,8 +43,7 @@ import {
|
|||||||
shouldFailoverAiModelError,
|
shouldFailoverAiModelError,
|
||||||
} from "@/lib/ai/runtime/model-health";
|
} from "@/lib/ai/runtime/model-health";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
|
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
|
||||||
import { isProviderBalanceError } from "@/lib/request-error";
|
|
||||||
|
|
||||||
export interface CanvasLocalEditCompositeRegion {
|
export interface CanvasLocalEditCompositeRegion {
|
||||||
x: number;
|
x: number;
|
||||||
@ -199,7 +198,6 @@ export interface CanvasGenerateImageRequest {
|
|||||||
agentImageMaxCreditCost?: number;
|
agentImageMaxCreditCost?: number;
|
||||||
internalFailoverTriedModelRefs?: string[];
|
internalFailoverTriedModelRefs?: string[];
|
||||||
internalFailoverTriedProviderTypes?: string[];
|
internalFailoverTriedProviderTypes?: string[];
|
||||||
internalBalanceBlockedProviderIds?: string[];
|
|
||||||
onQueueStatusChange?: (
|
onQueueStatusChange?: (
|
||||||
status: CanvasImageModelQueueStatus & {
|
status: CanvasImageModelQueueStatus & {
|
||||||
modelName?: string | null;
|
modelName?: string | null;
|
||||||
@ -4514,7 +4512,7 @@ function findCanvasFailoverModel(params: {
|
|||||||
!item.model.isEnabled ||
|
!item.model.isEnabled ||
|
||||||
item.model.healthStatus === "auto_disabled" ||
|
item.model.healthStatus === "auto_disabled" ||
|
||||||
item.model.isSubModel ||
|
item.model.isSubModel ||
|
||||||
isImageModelBalanceBlocked(item.model) ||
|
isImageFallbackModelBalanceCoolingDown(item.model) ||
|
||||||
(item.model.excludeFromFailover && !isSameNameScaleAdjustFallback)
|
(item.model.excludeFromFailover && !isSameNameScaleAdjustFallback)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@ -4906,20 +4904,7 @@ export async function generateCanvasImage(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const allModels = await getEnabledAiModelsWithProvider();
|
const allModels = await getEnabledAiModelsWithProvider();
|
||||||
const balanceBlockedProviderIds = new Set(request.internalBalanceBlockedProviderIds || []);
|
let imageModels = allModels.filter((m) => m.model.supportsImageGen);
|
||||||
let imageModels = allModels.filter((m) => m.model.supportsImageGen && !isImageModelBalanceBlocked(m.model) && !balanceBlockedProviderIds.has(m.provider.id));
|
|
||||||
const requestedModel = allModels.find(entry => entry.model.id === model || entry.model.modelId === model)?.model ||
|
|
||||||
(/^[0-9a-f-]{36}$/i.test(String(model || "")) ? await getAiModelById({ id: model! }) : undefined);
|
|
||||||
let effectiveModel = model;
|
|
||||||
if (requestedModel?.supportsImageGen && isImageModelBalanceBlocked(requestedModel)) {
|
|
||||||
const replacement = request.allowFailover !== false && request.agentImageMode !== "pinned"
|
|
||||||
? resolveConfiguredImageBalanceFallback(requestedModel, imageModels, {
|
|
||||||
imageSize: request.imageSize, aspectRatio: request.aspectRatio,
|
|
||||||
referenceImageCount: new Set([...(referenceImages || []), referenceImage].filter(Boolean)).size,
|
|
||||||
}) : undefined;
|
|
||||||
if (!replacement) throw new Error("当前生图线路余额不足,已暂停新请求;请切换可用模型,已有任务和图片会保留。");
|
|
||||||
effectiveModel = replacement.model.id;
|
|
||||||
}
|
|
||||||
if (request.agentImageMode === "auto") {
|
if (request.agentImageMode === "auto") {
|
||||||
const states = await Promise.all(imageModels.map(async entry => ({ entry, paused: await agentImageCircuitStore.isPaused(entry) })));
|
const states = await Promise.all(imageModels.map(async entry => ({ entry, paused: await agentImageCircuitStore.isPaused(entry) })));
|
||||||
imageModels = states.filter(item => !item.paused && item.entry.model.healthStatus !== "auto_disabled" &&
|
imageModels = states.filter(item => !item.paused && item.entry.model.healthStatus !== "auto_disabled" &&
|
||||||
@ -4931,8 +4916,8 @@ export async function generateCanvasImage(
|
|||||||
throw new Error("暂无可用的图片生成模型");
|
throw new Error("暂无可用的图片生成模型");
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedModel = effectiveModel
|
let selectedModel = model
|
||||||
? imageModels.find((m) => m.model.modelId === effectiveModel || m.model.id === effectiveModel)
|
? imageModels.find((m) => m.model.modelId === model || m.model.id === model)
|
||||||
: imageModels.find((m) => m.model.isDefault) || imageModels[0];
|
: imageModels.find((m) => m.model.isDefault) || imageModels[0];
|
||||||
|
|
||||||
if (!selectedModel) {
|
if (!selectedModel) {
|
||||||
@ -5669,7 +5654,7 @@ export async function generateCanvasImage(
|
|||||||
? request.internalRetryVariantIndex + 1
|
? request.internalRetryVariantIndex + 1
|
||||||
: 0;
|
: 0;
|
||||||
const retryVariant = retryVariants[nextRetryVariantIndex];
|
const retryVariant = retryVariants[nextRetryVariantIndex];
|
||||||
if (retryVariant && !request.agentImageMode && !isProviderBalanceError(caughtError)) {
|
if (retryVariant && !request.agentImageMode) {
|
||||||
const {
|
const {
|
||||||
debugMeta: retryVariantDebugMeta,
|
debugMeta: retryVariantDebugMeta,
|
||||||
label: retryVariantLabel,
|
label: retryVariantLabel,
|
||||||
@ -5725,8 +5710,7 @@ export async function generateCanvasImage(
|
|||||||
} else if (request.agentImageMode) {
|
} else if (request.agentImageMode) {
|
||||||
const failure = agentImageFailurePolicy(caughtError);
|
const failure = agentImageFailurePolicy(caughtError);
|
||||||
if (failure.pause) await agentImageCircuitStore.fail(selectedModel, failure.pause);
|
if (failure.pause) await agentImageCircuitStore.fail(selectedModel, failure.pause);
|
||||||
}
|
} else {
|
||||||
if (!scaleAdjustRejected && (!request.agentImageMode || isProviderBalanceError(caughtError))) {
|
|
||||||
await recordAiModelHealthFailure({
|
await recordAiModelHealthFailure({
|
||||||
model: selectedModel.model,
|
model: selectedModel.model,
|
||||||
provider,
|
provider,
|
||||||
@ -5772,18 +5756,17 @@ export async function generateCanvasImage(
|
|||||||
...(Array.isArray(request.internalFailoverTriedProviderTypes)
|
...(Array.isArray(request.internalFailoverTriedProviderTypes)
|
||||||
? request.internalFailoverTriedProviderTypes
|
? request.internalFailoverTriedProviderTypes
|
||||||
: []),
|
: []),
|
||||||
...(request.agentImageMode !== "auto" && !isProviderBalanceError(caughtError) && shouldFailover &&
|
...(request.agentImageMode !== "auto" && shouldFailover &&
|
||||||
failureClassification.reason !== "non_provider_failure" &&
|
failureClassification.reason !== "non_provider_failure" &&
|
||||||
failureClassification.reason !== "user_cancelled"
|
failureClassification.reason !== "user_cancelled"
|
||||||
? [provider.type]
|
? [provider.type]
|
||||||
: []),
|
: []),
|
||||||
].filter(Boolean))
|
].filter(Boolean))
|
||||||
);
|
);
|
||||||
if (isProviderBalanceError(caughtError)) balanceBlockedProviderIds.add(provider.id);
|
|
||||||
const fallbackModel = allowFailover && shouldFailover
|
const fallbackModel = allowFailover && shouldFailover
|
||||||
? findCanvasFailoverModel({
|
? findCanvasFailoverModel({
|
||||||
selectedModel,
|
selectedModel,
|
||||||
imageModels: imageModels.filter(entry => !balanceBlockedProviderIds.has(entry.provider.id)),
|
imageModels,
|
||||||
failedModelRefs,
|
failedModelRefs,
|
||||||
failedProviderTypes,
|
failedProviderTypes,
|
||||||
preferLowCostSameNameProviders: Boolean(
|
preferLowCostSameNameProviders: Boolean(
|
||||||
@ -5814,7 +5797,6 @@ export async function generateCanvasImage(
|
|||||||
model: fallbackModel.model.id,
|
model: fallbackModel.model.id,
|
||||||
internalFailoverTriedModelRefs: failedModelRefs,
|
internalFailoverTriedModelRefs: failedModelRefs,
|
||||||
internalFailoverTriedProviderTypes: failedProviderTypes,
|
internalFailoverTriedProviderTypes: failedProviderTypes,
|
||||||
internalBalanceBlockedProviderIds: [...balanceBlockedProviderIds],
|
|
||||||
debugMeta: {
|
debugMeta: {
|
||||||
...debugMeta,
|
...debugMeta,
|
||||||
failoverFromModelId: selectedModel.model.modelId,
|
failoverFromModelId: selectedModel.model.modelId,
|
||||||
|
|||||||
@ -38,7 +38,6 @@ import {
|
|||||||
} from "@/lib/agents/detail-replacement-batch";
|
} from "@/lib/agents/detail-replacement-batch";
|
||||||
import { executeDetailReplacementBatch } from "@/lib/agents/detail-replacement-batch-runner";
|
import { executeDetailReplacementBatch } from "@/lib/agents/detail-replacement-batch-runner";
|
||||||
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
||||||
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
|
||||||
|
|
||||||
type CanvasImageTaskStatus =
|
type CanvasImageTaskStatus =
|
||||||
| "waiting"
|
| "waiting"
|
||||||
@ -329,7 +328,7 @@ async function markStaleDatabaseRunningTasksFailed() {
|
|||||||
.update(canvasImageTask)
|
.update(canvasImageTask)
|
||||||
.set({
|
.set({
|
||||||
status: "failed",
|
status: "failed",
|
||||||
error: "图片任务执行中断或等待超时,结果尚未确认。请先查看原任务和历史记录,联系管理员核对,避免重复生成。",
|
error: "图片生成任务已超时,请重新发起生成",
|
||||||
completedAt,
|
completedAt,
|
||||||
updatedAt: completedAt,
|
updatedAt: completedAt,
|
||||||
})
|
})
|
||||||
@ -384,7 +383,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const runningTimeoutMs = getCanvasImageRunningTimeoutMs(request);
|
const runningTimeoutMs = getCanvasImageRunningTimeoutMs(request);
|
||||||
const execution: Promise<CanvasImageTaskResult> = trackRuntimeWork<CanvasImageTaskResult>(() => isStyleProTaskRequest(request)
|
const execution: Promise<CanvasImageTaskResult> = isStyleProTaskRequest(request)
|
||||||
? executeStyleProTask({
|
? executeStyleProTask({
|
||||||
request,
|
request,
|
||||||
userId: String(task.userId || entry.userId),
|
userId: String(task.userId || entry.userId),
|
||||||
@ -413,7 +412,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
|||||||
ownerKey,
|
ownerKey,
|
||||||
request: imageRequest,
|
request: imageRequest,
|
||||||
});
|
});
|
||||||
})());
|
})();
|
||||||
const heartbeat = isDetailReplacementBatchRequest(request)
|
const heartbeat = isDetailReplacementBatchRequest(request)
|
||||||
? setInterval(() => {
|
? setInterval(() => {
|
||||||
void db.update(canvasImageTask).set({ updatedAt: new Date() })
|
void db.update(canvasImageTask).set({ updatedAt: new Date() })
|
||||||
@ -427,7 +426,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
|||||||
try {
|
try {
|
||||||
result = await withCanvasImageTaskExecutionTimeout(
|
result = await withCanvasImageTaskExecutionTimeout(
|
||||||
execution,
|
execution,
|
||||||
Math.max(runningTimeoutMs, LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS),
|
runningTimeoutMs,
|
||||||
entry.taskId
|
entry.taskId
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@ -752,7 +751,7 @@ export async function getCanvasImageTaskForUser(params: {
|
|||||||
|
|
||||||
if (shouldMarkStale) {
|
if (shouldMarkStale) {
|
||||||
const staleBefore = new Date(now - runningTimeoutMs);
|
const staleBefore = new Date(now - runningTimeoutMs);
|
||||||
const timeoutMessage = "图片任务执行中断或等待超时,结果尚未确认。请先查看原任务和历史记录,联系管理员核对,避免重复生成。";
|
const timeoutMessage = "图片生成任务已超时,请重新发起生成";
|
||||||
const completedAt = new Date();
|
const completedAt = new Date();
|
||||||
const [timedOutTask] = await db
|
const [timedOutTask] = await db
|
||||||
.update(canvasImageTask)
|
.update(canvasImageTask)
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
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;
|
||||||
@ -32,7 +30,6 @@ 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";
|
||||||
}
|
}
|
||||||
@ -299,9 +296,6 @@ 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;
|
||||||
@ -332,7 +326,6 @@ 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
|
||||||
@ -347,13 +340,11 @@ export async function fetchCanvasDownloadAsset(params: {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
params.signal?.throwIfAborted();
|
params.signal?.throwIfAborted();
|
||||||
const message = normalizeDownloadError(error);
|
lastError = 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,6 +1,5 @@
|
|||||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
|
||||||
|
|
||||||
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
|
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
|
||||||
|
|
||||||
@ -27,8 +26,6 @@ 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,
|
||||||
@ -36,8 +33,6 @@ 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);
|
||||||
@ -45,14 +40,11 @@ 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} 秒后重试`
|
||||||
@ -62,10 +54,10 @@ export function getCanvasImageDownloadErrorMessage(error: unknown) {
|
|||||||
return "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
|
return "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
|
||||||
}
|
}
|
||||||
if (error.code === "preparation_timeout") {
|
if (error.code === "preparation_timeout") {
|
||||||
return `原图仍在后台准备中,请稍后再次点击下载${error.requestId ? `\n下载编号:${error.requestId}` : ""}`;
|
return "原图仍在后台准备中,请稍后再次点击下载";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return formatDownloadFailure(getDownloadFailure(error));
|
return error instanceof Error ? error.message : "图片下载失败,请稍后重试";
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadCanvasImageParams = {
|
type DownloadCanvasImageParams = {
|
||||||
@ -79,8 +71,6 @@ 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;
|
||||||
@ -176,63 +166,6 @@ 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();
|
||||||
@ -324,7 +257,6 @@ export async function downloadCanvasImageAsPng({
|
|||||||
let preparationPolls = 0;
|
let preparationPolls = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
try {
|
|
||||||
response = await fetch("/api/canvas/download-image", {
|
response = await fetch("/api/canvas/download-image", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@ -337,11 +269,6 @@ export async function downloadCanvasImageAsPng({
|
|||||||
}),
|
}),
|
||||||
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;
|
||||||
@ -351,7 +278,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, requestId: preparing.requestId });
|
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202 });
|
||||||
}
|
}
|
||||||
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));
|
||||||
@ -372,8 +299,6 @@ export async function downloadCanvasImageAsPng({
|
|||||||
code: "rate_limited",
|
code: "rate_limited",
|
||||||
retryAfterSeconds,
|
retryAfterSeconds,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
failure: errorPayload?.failure,
|
|
||||||
requestId: errorPayload?.requestId,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -396,18 +321,22 @@ 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") throw new Error("下载链接无效");
|
if (result.delivery !== "cdn" || typeof result.downloadUrl !== "string"
|
||||||
const downloaded = triggerCdnDownload(result, signal);
|
|| new URL(result.downloadUrl).protocol !== "https:") throw new Error("下载链接无效");
|
||||||
|
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 downloaded;
|
return { filename: result.filename, bytes: result.bytes || 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename =
|
const filename =
|
||||||
|
|||||||
@ -1,20 +1,11 @@
|
|||||||
/** 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();
|
||||||
const queue = {
|
return {
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
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,6 +1,4 @@
|
|||||||
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 {
|
||||||
@ -4556,7 +4554,7 @@ export async function getEnabledAiModelsWithProvider(
|
|||||||
return 3;
|
return 3;
|
||||||
};
|
};
|
||||||
|
|
||||||
return models.filter(({ model }) => !model.supportsImageGen || !isImageModelBalanceBlocked(model)).sort((left, right) => {
|
return [...models].sort((left, right) => {
|
||||||
const byHealth =
|
const byHealth =
|
||||||
healthPriority(left.model.healthStatus) -
|
healthPriority(left.model.healthStatus) -
|
||||||
healthPriority(right.model.healthStatus);
|
healthPriority(right.model.healthStatus);
|
||||||
@ -4746,9 +4744,9 @@ export async function getSiteSettings() {
|
|||||||
? videoCanvasEnabledSetting.value !== false
|
? videoCanvasEnabledSetting.value !== false
|
||||||
: videoCanvasSidebarEnabledSetting?.value !== false;
|
: videoCanvasSidebarEnabledSetting?.value !== false;
|
||||||
|
|
||||||
return resolveSiteBranding({ ...(settings || defaultSettings), videoCanvasEnabled });
|
return { ...(settings || defaultSettings), videoCanvasEnabled };
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return resolveSiteBranding(defaultSettings);
|
return defaultSettings;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,8 +20,6 @@ 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] || "";
|
||||||
}
|
}
|
||||||
@ -185,8 +183,8 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
|
|||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The same-origin endpoint can resolve a native CDN download without relying
|
// An original download must go through the same-origin byte-preserving
|
||||||
// on cross-origin fetch, while retaining a byte-preserving proxy fallback.
|
// proxy even for trusted storage hosts: a direct fetch may fail CORS.
|
||||||
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,6 +1,5 @@
|
|||||||
import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation";
|
import { preparePendingImageDownloads } 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 {
|
||||||
@ -27,12 +26,10 @@ 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;
|
||||||
@ -46,7 +43,6 @@ 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,
|
||||||
@ -110,6 +106,7 @@ export async function runImageArchiveJob() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const downloadTasks = await preparePendingImageDownloads();
|
||||||
const stats = await getImageArchiveTaskStats();
|
const stats = await getImageArchiveTaskStats();
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@ -125,8 +122,7 @@ export async function runImageArchiveJob() {
|
|||||||
failed: replaceFailed,
|
failed: replaceFailed,
|
||||||
},
|
},
|
||||||
stats,
|
stats,
|
||||||
downloadTasks: 0,
|
downloadTasks,
|
||||||
pendingDownloads: await hasPendingImageDownloads(),
|
pendingDownloads: await hasPendingImageDownloads(),
|
||||||
};
|
};
|
||||||
}, { durable: true });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
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,20 +175,6 @@ 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));
|
||||||
@ -213,80 +199,17 @@ 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 = "图片生成失败,请稍后重试",
|
||||||
) {
|
) {
|
||||||
return formatRequestFailure(getRequestFailureDetails(error, fallbackMessage));
|
if (isProviderBalanceError(error)) {
|
||||||
|
return "图片服务商线路余额或额度不足,请切换模型或联系管理员;与您的积分余额无关。";
|
||||||
|
}
|
||||||
|
if (isRateLimitError(error)) {
|
||||||
|
return "图片服务请求过于频繁,请稍后重试或切换模型。";
|
||||||
|
}
|
||||||
|
return getRequestErrorMessage(error) || fallbackMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFriendlyRateLimitMessage(
|
export function getFriendlyRateLimitMessage(
|
||||||
@ -304,12 +227,13 @@ 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;
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
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,7 +6,6 @@ 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() {
|
||||||
@ -35,12 +34,10 @@ 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,21 +10,17 @@ 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 (!task) return { task, direct: null, canPrepareDirect: false };
|
if (asset?.status !== "ready" || !asset.url || asset.contentType !== "image/png") return { task, direct: null };
|
||||||
const config = await getDefaultStorageConfig();
|
const config = await getDefaultStorageConfig();
|
||||||
if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null, canPrepareDirect: false };
|
if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null };
|
||||||
const ready = asset?.status === "ready" && asset.url && asset.contentType === "image/png";
|
const url = new URL(asset.url);
|
||||||
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, canPrepareDirect: false };
|
|| !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null };
|
||||||
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 }, canPrepareDirect: true };
|
return { task, direct: { delivery: "cdn", downloadUrl: url.toString(), filename, bytes: asset.bytes || 0 } };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { isRetiredStorageUrl } from "./retired-storage";
|
|
||||||
|
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
@ -39,7 +38,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) || isRetiredStorageUrl(sourceUrl)) return null;
|
if (!isGeneratedImageSource(sourceUrl)) return null;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const archiveAfterAt = params.archiveAfterAt || now;
|
const archiveAfterAt = params.archiveAfterAt || now;
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
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";
|
||||||
@ -171,7 +170,6 @@ 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,5 +1,4 @@
|
|||||||
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";
|
||||||
@ -81,8 +80,7 @@ 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; signal?: AbortSignal } = {}) {
|
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string } = {}) {
|
||||||
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);
|
||||||
@ -92,9 +90,7 @@ 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: options.signal
|
signal: AbortSignal.timeout(options.timeoutMs || 60_000),
|
||||||
? 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,21 +2,14 @@ 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, hasPendingImageDownloads } from "./image-archive-db";
|
import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks } 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,
|
||||||
@ -24,22 +17,15 @@ 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 (error) {
|
} catch {
|
||||||
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);
|
||||||
const failure = phase === "store"
|
await saveImageDownloadAsset(id, { status: attempts >= 12 ? "abandoned" : "failed", attempts, retryAt: new Date(Date.now() + 10 * 60_000).toISOString() });
|
||||||
? 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() {
|
||||||
@ -50,45 +36,3 @@ 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,5 +1,4 @@
|
|||||||
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";
|
||||||
@ -17,8 +16,6 @@ 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() &&
|
||||||
@ -83,7 +80,7 @@ export async function enqueueStorageMigrationTask({
|
|||||||
migrateAfterAt: Date;
|
migrateAfterAt: Date;
|
||||||
cleanupAfterAt: Date;
|
cleanupAfterAt: Date;
|
||||||
}) {
|
}) {
|
||||||
if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl)) return;
|
if (!sourceUrl || !sourcePath || !sourceStorageConfigId) return;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(storageMigrationTask)
|
.insert(storageMigrationTask)
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
/** 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,5 +1,4 @@
|
|||||||
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";
|
||||||
@ -150,9 +149,6 @@ 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 =
|
||||||
@ -341,8 +337,6 @@ 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 ||
|
||||||
@ -370,7 +364,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 && !isRetiredStorageUrl(endpoint) && !isRetiredStorageUrl(process.env.S3_PUBLIC_URL) && bucket && accessKeyId && secretAccessKey) {
|
if (endpoint && bucket && accessKeyId && secretAccessKey) {
|
||||||
return {
|
return {
|
||||||
endpoint,
|
endpoint,
|
||||||
region: process.env.S3_REGION?.trim() || "auto",
|
region: process.env.S3_REGION?.trim() || "auto",
|
||||||
|
|||||||
@ -132,7 +132,6 @@
|
|||||||
"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,22 +3,10 @@ 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/,
|
||||||
@ -51,7 +39,6 @@ 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,9 +234,6 @@ 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
|
||||||
@ -463,9 +460,6 @@ 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'}
|
||||||
@ -6973,8 +6967,6 @@ 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
|
||||||
|
|||||||
14
proxy.ts
14
proxy.ts
@ -43,7 +43,6 @@ 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;
|
||||||
@ -669,19 +668,6 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} 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)) {
|
} else if (isHighFrequencyReadApi(pathname, request.method)) {
|
||||||
const pollingRateLimit = checkRateLimit(request, rateLimiters.uiPolling);
|
const pollingRateLimit = checkRateLimit(request, rateLimiters.uiPolling);
|
||||||
if (!pollingRateLimit.allowed) {
|
if (!pollingRateLimit.allowed) {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.1 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 11 KiB |
@ -4,6 +4,7 @@
|
|||||||
<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; }
|
||||||
@ -13,8 +14,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="particle-container"></div>
|
<div id="particle-container"></div>
|
||||||
<script type="module">
|
<script>
|
||||||
import * as THREE from "/three/0.160.0/three.module.min.js";
|
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
effect: "scatter",
|
effect: "scatter",
|
||||||
effectMode: 1,
|
effectMode: 1,
|
||||||
|
|||||||
@ -1,21 +0,0 @@
|
|||||||
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
6
public/three/0.160.0/three.module.min.js
vendored
File diff suppressed because one or more lines are too long
@ -1,10 +0,0 @@
|
|||||||
-- 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%'
|
|
||||||
);
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
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,7 +7,6 @@ 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;
|
||||||
@ -27,38 +26,12 @@ async function fetchAgentProjectApi<T>(
|
|||||||
...init,
|
...init,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
return 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}:请求超时`;
|
||||||
@ -98,52 +71,27 @@ export const agentProjectService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async createProject(
|
async createProject(payload: {
|
||||||
payload: CreateProjectPayload,
|
title?: string;
|
||||||
options: { creationKey?: string } = {}
|
description?: string | null;
|
||||||
): Promise<ApiResponse<AgentGenerationProject>> {
|
thumbnail?: string | null;
|
||||||
const key = `agent-project:create:${options.creationKey || JSON.stringify(payload)}`;
|
projectState?: AgentGenerationProjectPersistedState;
|
||||||
let pending = pendingCreations.get(key);
|
}): Promise<ApiResponse<AgentGenerationProject>> {
|
||||||
if (!pending) {
|
|
||||||
pending = { id: pendingCreationStorage(key) || crypto.randomUUID() };
|
|
||||||
pendingCreations.set(key, pending);
|
|
||||||
pendingCreationStorage(key, pending.id);
|
|
||||||
}
|
|
||||||
if (pending.promise) return pending.promise;
|
|
||||||
const current = pending;
|
|
||||||
const clearPending = () => {
|
|
||||||
pendingCreations.delete(key);
|
|
||||||
pendingCreationStorage(key, null);
|
|
||||||
};
|
|
||||||
current.promise = (async () => {
|
|
||||||
try {
|
try {
|
||||||
const result = await fetchAgentProjectApi<AgentGenerationProject>(
|
return await fetchAgentProjectApi<AgentGenerationProject>(
|
||||||
"/api/agent-projects",
|
"/api/agent-projects",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ...payload, id: current.id }),
|
body: JSON.stringify(payload),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
if (result.success || (result.status && result.status < 500)) {
|
} catch (error) {
|
||||||
clearPending();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 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: "创建结果尚未确认,请重试核对或打开历史项目;重试不会重复创建。",
|
error: getAgentProjectRequestErrorMessage(error, "创建生图项目失败"),
|
||||||
};
|
};
|
||||||
})().finally(() => { current.promise = undefined; });
|
}
|
||||||
return current.promise;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateProject(
|
async updateProject(
|
||||||
|
|||||||
@ -248,17 +248,6 @@ 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: '更新画布失败' };
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
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.filter(entry => entry.model.isEnabled), getAiModelById: async ({ id }: { id: string }) => models.find(entry => entry.model.id === id)?.model },
|
"@/lib/db/queries": { getEnabledAiModelsWithProvider: async () => models },
|
||||||
"@/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() {} },
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
import { defineConfig } from '@playwright/test';
|
|
||||||
export default defineConfig({ testDir: './routes', testMatch: /retired-storage\.test\.ts/, workers: 1, reporter: 'list' });
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
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?.();
|
|
||||||
});
|
|
||||||
@ -1,229 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -5,33 +5,6 @@ import { ImageSubmissionRejectedError, agentImageGenerationError } from "@/lib/a
|
|||||||
import * as generationErrors from "@/lib/ai/runtime/image-generation-error";
|
import * as generationErrors from "@/lib/ai/runtime/image-generation-error";
|
||||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||||
|
|
||||||
test("a balance rejection skips other models on the same account but permits another account of the same provider type", async () => {
|
|
||||||
const models = ["primary", "same-account", "backup"].map(id => ({
|
|
||||||
model: { id, modelId: id, displayName: id, isEnabled: true, supportsImageGen: true, supportsVision: true, imageGenConfig: { maxImages: 1 }, capabilities: { kind: "image" } },
|
|
||||||
provider: { id: id === "backup" ? "funded-account" : "exhausted-account", type: "openai-image", apiKey: "synthetic", isEnabled: true },
|
|
||||||
}));
|
|
||||||
const h = harness({ models, generate: id => {
|
|
||||||
if (id !== "backup") throw new Error("余额不足,请充值 (HTTP 429)");
|
|
||||||
return "";
|
|
||||||
} });
|
|
||||||
expect((await h.generate(h.request)).modelRef).toBe("backup");
|
|
||||||
expect(h.calls.map(call => call.id)).toEqual(["primary", "backup"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an old disabled balance selection uses its configured compatible replacement without calling the exhausted account", async () => {
|
|
||||||
const oldId = "11111111-1111-4111-8111-111111111111";
|
|
||||||
const models = [
|
|
||||||
{ model: { id: oldId, modelId: "old", displayName: "old", isEnabled: false, supportsImageGen: true, healthLastEventType: "quota", healthLastError: "insufficient balance", fallbackModelId1: "backup" }, provider: { id: "exhausted-account", type: "openai-image", isEnabled: true } },
|
|
||||||
{ model: { id: "backup", modelId: "backup", displayName: "backup", isEnabled: true, supportsImageGen: true, supportsVision: true, imageGenConfig: { maxImages: 1, maxReferenceImages: 8, supportedImageSizes: ["2K"], supportedAspectRatios: ["3:4"] } }, provider: { id: "funded-account", type: "openai-image", apiKey: "synthetic", isEnabled: true } },
|
|
||||||
];
|
|
||||||
const h = harness({ models });
|
|
||||||
expect((await h.generate({ ...h.request, model: oldId })).modelRef).toBe("backup");
|
|
||||||
expect(h.calls.map(call => call.id)).toEqual(["backup"]);
|
|
||||||
await expect(h.generate({ ...h.request, model: oldId, agentImageMode: "pinned" })).rejects.toThrow("余额不足");
|
|
||||||
await expect(h.generate({ ...h.request, model: oldId, referenceImages: Array.from({ length: 9 }, (_, i) => `https://fixture.invalid/${i}.png`) })).rejects.toThrow("余额不足");
|
|
||||||
expect(h.calls).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
test("automatic image generation switches within the same provider and skips a paused model on the next task", async () => {
|
test("automatic image generation switches within the same provider and skips a paused model on the next task", async () => {
|
||||||
const h = harness({ generate: id => { if (id === "primary") throw Object.assign(new Error("channel unavailable"), { status: 503 }); return ""; } });
|
const h = harness({ generate: id => { if (id === "primary") throw Object.assign(new Error("channel unavailable"), { status: 503 }); return ""; } });
|
||||||
|
|||||||
@ -1,65 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
|
||||||
|
|
||||||
function harness(status = "running") {
|
|
||||||
let now = 2_000_000;
|
|
||||||
const row: any = { id: "task-a", requestId: "request-a", userId: "user-a", projectId: "project-a", status, attempts: status === "pending" ? 0 : 1, updatedAt: new Date(now - 16 * 60_000), error: null };
|
|
||||||
const matches = (predicate: any): boolean => !predicate || (predicate[0] === "and" ? predicate.slice(1).every(matches)
|
|
||||||
: predicate[0] === "eq" ? row[predicate[1]] === predicate[2]
|
|
||||||
: predicate[0] === "lt" ? row[predicate[1]] < predicate[2]
|
|
||||||
: predicate[2].includes(row[predicate[1]]));
|
|
||||||
const db = {
|
|
||||||
select: () => ({ from: () => ({ where: (p: any) => ({ limit: async () => matches(p) ? [{ ...row }] : [] }) }) }),
|
|
||||||
insert: () => ({ values: () => ({ onConflictDoNothing: async () => undefined }) }),
|
|
||||||
update: () => ({ set: (values: any) => ({ where: (p: any) => {
|
|
||||||
const apply = () => {
|
|
||||||
if (!matches(p)) return [];
|
|
||||||
for (const [key, value] of Object.entries(values)) row[key] = key === "attempts" ? row.attempts + 1 : value;
|
|
||||||
return [{ ...row }];
|
|
||||||
};
|
|
||||||
const result = apply();
|
|
||||||
return Object.assign(Promise.resolve(result), { returning: async () => result });
|
|
||||||
} }) }),
|
|
||||||
};
|
|
||||||
const columns = Object.fromEntries(["id", "requestId", "userId", "projectId", "status", "updatedAt", "attempts"].map(k => [k, k]));
|
|
||||||
const store = serverLoader({
|
|
||||||
"@/lib/db": { db }, "@/lib/db/schema": { canvasCapabilityTask: columns },
|
|
||||||
"drizzle-orm": { eq: (...v: any[]) => ["eq", ...v], lt: (...v: any[]) => ["lt", ...v], inArray: (...v: any[]) => ["in", ...v], and: (...v: any[]) => ["and", ...v], sql: () => "increment" },
|
|
||||||
Date: class extends Date { constructor(value?: any) { super(value ?? now); } static now() { return now; } },
|
|
||||||
})("lib/canvas/capabilities/task-store.ts");
|
|
||||||
const lookup = { requestId: "request-a", userId: "user-a", projectId: "project-a" };
|
|
||||||
return { row, store, lookup, advance: (ms: number) => { now += ms; } };
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a stale task stops reporting running without authorizing another execution", async () => {
|
|
||||||
const h = harness();
|
|
||||||
const task = await h.store.getCanvasCapabilityTask(h.lookup);
|
|
||||||
expect(task.status).toBe("failed");
|
|
||||||
expect(task.error).toContain("结果尚未确认");
|
|
||||||
const repeated = await h.store.createOrRecoverCanvasCapabilityTask({ ...h.lookup, command: { requestId: "request-a" } });
|
|
||||||
expect(repeated.status).toBe("failed");
|
|
||||||
expect(repeated.attempts).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("heartbeats keep a legitimate long-running task active beyond fifteen minutes", async () => {
|
|
||||||
const h = harness();
|
|
||||||
await h.store.heartbeatCanvasCapabilityTask("task-a");
|
|
||||||
h.advance(14 * 60_000);
|
|
||||||
await h.store.heartbeatCanvasCapabilityTask("task-a");
|
|
||||||
h.advance(14 * 60_000);
|
|
||||||
expect((await h.store.getCanvasCapabilityTask(h.lookup)).status).toBe("running");
|
|
||||||
expect(h.row.attempts).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("only the pending task can be claimed and a duplicate claim cannot run again", async () => {
|
|
||||||
const h = harness("pending");
|
|
||||||
expect(await h.store.markCanvasCapabilityTaskRunning("task-a")).toMatchObject({ status: "running", attempts: 1 });
|
|
||||||
expect(await h.store.markCanvasCapabilityTaskRunning("task-a")).toBeNull();
|
|
||||||
expect(h.row.attempts).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("another user cannot inspect or change a stale task", async () => {
|
|
||||||
const h = harness();
|
|
||||||
expect(await h.store.getCanvasCapabilityTask({ ...h.lookup, userId: "user-b" })).toBeNull();
|
|
||||||
expect(h.row.status).toBe("running");
|
|
||||||
});
|
|
||||||
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