Compare commits

..

No commits in common. "main" and "codex/sku-stream-links" have entirely different histories.

109 changed files with 1645 additions and 4669 deletions

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-c
import type { ChatMessage } from "@/lib/types"; import type { ChatMessage } from "@/lib/types";
import { convertMessagesWithImages } from "./image-media"; import { convertMessagesWithImages } from "./image-media";
import { isProviderBalanceError } from "@/lib/request-error"; import { isProviderBalanceError } from "@/lib/request-error";
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance"; import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
const chatApiLogger = createLogger("chat.imageFailover"); const chatApiLogger = createLogger("chat.imageFailover");
@ -326,7 +326,7 @@ export function buildImageFallbackCandidates(
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) { if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
return false; return false;
} }
if (isImageFallbackModelCoolingDown(item.model.id) || isImageModelBalanceBlocked(item.model)) { if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(item.model)) {
const cooldown = imageFailoverModelCooldown.get(item.model.id); const cooldown = imageFailoverModelCooldown.get(item.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`); chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false; return false;
@ -488,7 +488,7 @@ export function getImageCrossProviderCandidates(
} }
if (m.model.id === options.currentModelId) return false; if (m.model.id === options.currentModelId) return false;
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false; if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
if (isImageFallbackModelCoolingDown(m.model.id) || isImageModelBalanceBlocked(m.model)) { if (isImageFallbackModelCoolingDown(m.model.id) || isImageFallbackModelBalanceCoolingDown(m.model)) {
const cooldown = imageFailoverModelCooldown.get(m.model.id); const cooldown = imageFailoverModelCooldown.get(m.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`); chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false; return false;

View File

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

View File

@ -8,8 +8,6 @@ import { ArrowLeft, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsR
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import Image from "next/image"; import Image from "next/image";
import { formatBeijingTime } from "@/lib/utils"; import { formatBeijingTime } from "@/lib/utils";
import { toast } from "sonner";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
type FeaturedImage = { type FeaturedImage = {
id: string; id: string;
@ -76,11 +74,18 @@ export default function FeaturedPage() {
const handleDownload = async (url: string, prompt: string) => { const handleDownload = async (url: string, prompt: string) => {
try { try {
await downloadOriginalImage({ url, name: "featured-image" }); const response = await fetch(url);
toast.success("原图已开始下载"); const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = `featured-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch (error) { } catch (error) {
console.error("Download failed:", error); console.error("Download failed:", error);
toast.error(getCanvasImageDownloadErrorMessage(error));
} }
}; };

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -13,7 +12,6 @@ import { requestLongTaskResult } from "@/lib/client/long-task";
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery"; import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url"; import { inferOriginalImageUrl } from "@/lib/image-url";
import { formatBeijingTime } from "@/lib/utils"; import { formatBeijingTime } from "@/lib/utils";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
type GalleryImage = { type GalleryImage = {
id: string; id: string;
@ -60,7 +58,7 @@ function buildGalleryDownloadFilename(url: string, alt: string, contentType: str
} }
} }
return `${safeBase}.${getImageDownloadExtension(contentType) || extension}`; return `${safeBase}-${Date.now()}.${getImageDownloadExtension(contentType) || extension}`;
} }
export default function GalleryPage() { export default function GalleryPage() {
@ -120,11 +118,25 @@ export default function GalleryPage() {
const parsed = new URL(originalUrl, window.location.origin); const parsed = new URL(originalUrl, window.location.origin);
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl); const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
await downloadOriginalImage({ url: source, name: buildGalleryDownloadFilename(originalUrl, alt, "") }); const response = await fetch(
buildImageProxyUrl(source, { delivery: "original" })
);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = buildGalleryDownloadFilename(originalUrl, alt, blob.type);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
toast.success("图片已开始下载"); toast.success("图片已开始下载");
} catch (error) { } catch (error) {
console.error("Download failed:", error); console.error("Download failed:", error);
toast.error(getCanvasImageDownloadErrorMessage(error)); toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
} }
}; };
@ -304,12 +316,7 @@ export default function GalleryPage() {
<> <>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="flex flex-wrap justify-center gap-6 px-4"> <div className="flex flex-wrap justify-center gap-6 px-4">
{images.map((img) => isRetiredStorageUrl(img.url) ? ( {images.map((img) => (
<div key={img.id} className="flex h-[270px] w-[200px] shrink-0 flex-col justify-center rounded-lg border bg-muted p-4 text-center text-sm text-muted-foreground">
<p>{RETIRED_STORAGE_MESSAGE}</p>
<p className="mt-3 line-clamp-3 text-xs">{img.alt}</p>
</div>
) : (
<div <div
key={img.id} key={img.id}
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105" className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
@ -318,7 +325,7 @@ export default function GalleryPage() {
> >
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<Image <Image
src={isRetiredStorageUrl(img.thumbnailUrl) ? img.url : img.thumbnailUrl || img.url} src={img.thumbnailUrl || img.url}
alt={img.alt} alt={img.alt}
width={200} width={200}
height={270} height={270}

View File

@ -1,10 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { requireAdmin } from "@/lib/middleware/admin"; import { requireAdmin } from "@/lib/middleware/admin";
import { ChatSDKError } from "@/lib/errors"; import { ChatSDKError } from "@/lib/errors";
import { db } from "@/lib/db";
import { aiModelHealthEvent } from "@/lib/db/schema";
import { and, desc, eq, sql } from "drizzle-orm";
import { import {
listAiModelHealthEvents,
restoreAiModelHealth, restoreAiModelHealth,
} from "@/lib/ai/runtime/model-health"; } from "@/lib/ai/runtime/model-health";
@ -17,30 +15,14 @@ export async function GET(request: Request) {
await requireAdmin(); await requireAdmin();
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const modelId = searchParams.get("modelId"); const modelId = searchParams.get("modelId") || undefined;
const providerId = searchParams.get("providerId"); const limit = Number(searchParams.get("limit") || 80);
const rawLimit = Number(searchParams.get("limit") || 20); const events = await listAiModelHealthEvents({
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(200, Math.floor(rawLimit))) : 20; modelId,
const rawPage = Number(searchParams.get("page") || 1); limit: Number.isFinite(limit) ? limit : 80,
const page = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1; });
const filters = [];
if (modelId) filters.push(eq(aiModelHealthEvent.modelId, modelId)); return Response.json({ events });
if (providerId) filters.push(eq(aiModelHealthEvent.providerId, providerId));
if (searchParams.get("eventType")) filters.push(eq(aiModelHealthEvent.eventType, searchParams.get("eventType")!));
const start = searchParams.get("startDate");
const end = searchParams.get("endDate");
if (start && /^\d{4}-\d{2}-\d{2}$/.test(start)) filters.push(sql`${aiModelHealthEvent.createdAt}>=${start}::date`);
if (end && /^\d{4}-\d{2}-\d{2}$/.test(end)) filters.push(sql`${aiModelHealthEvent.createdAt}<${end}::date+interval '1 day'`);
const events = await db.select({
id: aiModelHealthEvent.id, modelId: aiModelHealthEvent.modelId, modelDisplayName: aiModelHealthEvent.modelDisplayName,
modelRuntimeId: aiModelHealthEvent.modelRuntimeId, providerName: aiModelHealthEvent.providerName,
providerId: aiModelHealthEvent.providerId, eventType: aiModelHealthEvent.eventType,
errorMessage: aiModelHealthEvent.errorMessage, elapsedMs: aiModelHealthEvent.elapsedMs,
consecutiveFailures: aiModelHealthEvent.consecutiveFailures, autoDisabled: aiModelHealthEvent.autoDisabled,
counted: aiModelHealthEvent.counted, createdAt: aiModelHealthEvent.createdAt,
}).from(aiModelHealthEvent).where(and(...filters)).orderBy(desc(aiModelHealthEvent.createdAt), desc(aiModelHealthEvent.id))
.limit(limit + 1).offset((page - 1) * limit);
return Response.json({ events: events.slice(0, limit), hasMore: events.length > limit, page });
} catch (error) { } catch (error) {
if (error instanceof ChatSDKError) { if (error instanceof ChatSDKError) {
return error.toResponse(); return error.toResponse();

View File

@ -1,10 +1,9 @@
import { z } from "zod"; import { z } from "zod";
import { db } from "@/lib/db";
import { aiModel, aiProvider } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { requireAdmin } from "@/lib/middleware/admin"; import { requireAdmin } from "@/lib/middleware/admin";
import { import {
getAllAiModels,
getAiModelById, getAiModelById,
getAiModelsByProviderId,
createAiModel, createAiModel,
updateAiModel, updateAiModel,
deleteAiModel, deleteAiModel,
@ -155,14 +154,8 @@ export async function GET(request: Request) {
const id = searchParams.get("id"); const id = searchParams.get("id");
const providerId = searchParams.get("providerId"); const providerId = searchParams.get("providerId");
if (searchParams.get("view") === "options") {
const models = await db.select({ id: aiModel.id, modelId: aiModel.modelId, displayName: aiModel.displayName,
providerName: aiProvider.name }).from(aiModel).leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id));
return Response.json({ models: sortModelsForDisplay(models) });
}
if (id) { if (id) {
const model = await db.query.aiModel.findFirst({ where: eq(aiModel.id, id) }); const model = await getAiModelById({ id });
if (!model) { if (!model) {
return new ChatSDKError("bad_request:api", "Model not found").toResponse(); return new ChatSDKError("bad_request:api", "Model not found").toResponse();
} }
@ -170,19 +163,12 @@ export async function GET(request: Request) {
} }
if (providerId) { if (providerId) {
const models = await db.select().from(aiModel).where(eq(aiModel.providerId, providerId)); const models = await getAiModelsByProviderId({ providerId });
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) }); return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
} }
const models = await db.select().from(aiModel); const models = await getAllAiModels();
const normalized = models.map(normalizeModel); return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
const output = searchParams.get("view") === "summary" ? normalized.map(model => {
const { capabilities, defaultParams, imageGenConfig, description, ...summary } = model;
const fields = ["kind", "mediaType", "modelType", "outputType", "supportedModes"];
return { ...summary, healthLastError: summary.healthLastError?.slice(0, 1200),
capabilities: Object.fromEntries(fields.filter(key => capabilities?.[key] !== undefined).map(key => [key, capabilities[key]])) };
}) : normalized;
return Response.json({ models: sortModelsForDisplay(output) });
} catch (error) { } catch (error) {
if (error instanceof ChatSDKError) { if (error instanceof ChatSDKError) {
return error.toResponse(); return error.toResponse();

View File

@ -1,31 +1,237 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage, user } from "@/lib/db/schema"; import { chat, generatedImage, message, user } from "@/lib/db/schema";
import { eq } from "drizzle-orm"; import { eq, desc, sql, and, gte, lte, like, or, isNotNull } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { requireAdmin } from "@/lib/middleware/admin";
import { ChatSDKError } from "@/lib/errors"; function extractVideoUrlFromParts(parts: unknown): string | null {
import { mediaRecordsQuery, parseMediaQuery } from "@/lib/admin/media-records"; const text = Array.isArray(parts)
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db"; ? parts
.map((part) => (part && typeof part === "object" && "text" in part ? String((part as any).text || "") : ""))
.join("\n")
: typeof parts === "string"
? parts
: "";
if (!text) return null;
const htmlMatch = text.match(/<video[^>]+src="([^"]+)"/i);
if (htmlMatch?.[1]) return htmlMatch[1];
const markdownMatch = text.match(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/i);
if (markdownMatch?.[1]) return markdownMatch[1];
const plainMatch = text.match(/(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/i);
return plainMatch?.[1] || null;
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const session = await requireAdmin(); const session = await auth();
const operator = await db.query.user.findFirst({ where: eq(user.id, session.user.id) });
if (operator?.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); if (!session?.user?.id) {
const query = parseMediaQuery(request.nextUrl.searchParams); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (query.startDate && query.endDate && query.startDate > query.endDate) {
return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 });
} }
const [result] = await db.execute(mediaRecordsQuery(query));
const images = await resolveArchivedImageUrls(result.records); // 检查是否为管理员
const total = Number(result.total); const currentUser = await db.query.user.findFirst({
return NextResponse.json({ images, pagination: { page: query.page, pageSize: query.pageSize, total, where: eq(user.id, session.user.id),
totalPages: Math.ceil(total / query.pageSize), totalImages: Number(result.totalImages), totalVideos: Number(result.totalVideos) } }); });
if (currentUser?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// 获取查询参数
const searchParams = request.nextUrl.searchParams;
const page = Number.parseInt(searchParams.get("page") || "1");
const pageSize = Number.parseInt(searchParams.get("pageSize") || "200");
const offset = (page - 1) * pageSize;
const search = searchParams.get("search") || "";
const startDate = searchParams.get("startDate");
const endDate = searchParams.get("endDate");
// 构建查询条件
const imageConditions = [];
const videoConditions = [
eq(message.role, "assistant"),
or(
isNotNull(message.temporaryVideoUrl),
isNotNull(message.permanentVideoUrl),
sql`${message.parts}::text ILIKE '%<video%'`,
sql`${message.parts}::text ILIKE '%.mp4%'`
),
];
if (search) {
// 搜索用户名或邮箱
const matchingUsers = await db
.select({ id: user.id })
.from(user)
.where(or(
like(user.name, `%${search}%`),
like(user.email, `%${search}%`)
));
if (matchingUsers.length > 0) {
const matchedUserIds = matchingUsers.map((u) => u.id);
imageConditions.push(
sql`${generatedImage.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
);
videoConditions.push(
sql`${chat.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
);
} else {
// 没有匹配的用户,返回空结果
return NextResponse.json({
images: [],
pagination: { page, pageSize, total: 0, totalPages: 0 }
});
}
}
if (startDate) {
const start = new Date(startDate);
imageConditions.push(gte(generatedImage.createdAt, start));
videoConditions.push(gte(message.createdAt, start));
}
if (endDate) {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
imageConditions.push(lte(generatedImage.createdAt, end));
videoConditions.push(lte(message.createdAt, end));
}
const imageWhereClause = imageConditions.length > 0 ? and(...imageConditions) : undefined;
const videoWhereClause = videoConditions.length > 0 ? and(...videoConditions) : undefined;
// 查询图片总数
const imageCountResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(generatedImage)
.where(imageWhereClause);
// 查询视频总数
const videoCountResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(message)
.innerJoin(chat, eq(message.chatId, chat.id))
.where(videoWhereClause);
const totalImages = imageCountResult[0]?.count || 0;
const totalVideos = videoCountResult[0]?.count || 0;
const total = totalImages + totalVideos;
const totalPages = Math.ceil(total / pageSize);
const fetchLimit = Math.max(page * pageSize, pageSize);
// 查询图片列表(包含用户信息)
const imageRows = await db
.select({
id: generatedImage.id,
userId: generatedImage.userId,
userName: user.name,
userEmail: user.email,
userDepartment: user.department,
thumbnailUrl: generatedImage.thumbnailUrl,
originalUrl: generatedImage.originalUrl,
imageSize: generatedImage.imageSize,
prompt: generatedImage.prompt,
modelId: generatedImage.modelId,
isFeatured: generatedImage.isFeatured,
featuredCategory: generatedImage.featuredCategory,
featuredAt: generatedImage.featuredAt,
createdAt: generatedImage.createdAt,
chatId: generatedImage.chatId,
recordType: sql<"image">`'image'`,
videoUrl: sql<string | null>`null`,
})
.from(generatedImage)
.leftJoin(user, eq(generatedImage.userId, user.id))
.where(imageWhereClause)
.orderBy(desc(generatedImage.createdAt))
.limit(fetchLimit);
// 查询视频列表(来自消息表)
const videoRows = await db
.select({
id: message.id,
userId: chat.userId,
userName: user.name,
userEmail: user.email,
userDepartment: user.department,
temporaryVideoUrl: message.temporaryVideoUrl,
permanentVideoUrl: message.permanentVideoUrl,
parts: message.parts,
videoMetadata: message.videoMetadata,
createdAt: message.createdAt,
chatId: message.chatId,
})
.from(message)
.innerJoin(chat, eq(message.chatId, chat.id))
.leftJoin(user, eq(chat.userId, user.id))
.where(videoWhereClause)
.orderBy(desc(message.createdAt))
.limit(fetchLimit);
const normalizedVideos = videoRows
.map((item) => {
const metadata = (item.videoMetadata as Record<string, any> | null) || {};
const videoUrl =
item.permanentVideoUrl ||
item.temporaryVideoUrl ||
extractVideoUrlFromParts(item.parts);
if (!videoUrl) return null;
const resolution =
typeof metadata.resolution === "string" && metadata.resolution.trim()
? metadata.resolution.trim()
: null;
const prompt =
typeof metadata.prompt === "string" && metadata.prompt.trim()
? metadata.prompt.trim()
: null;
const modelId =
typeof metadata.model === "string" && metadata.model.trim()
? metadata.model.trim()
: null;
return {
id: `video-${item.id}`,
sourceMessageId: item.id,
userId: item.userId,
userName: item.userName,
userEmail: item.userEmail,
userDepartment: item.userDepartment,
thumbnailUrl: videoUrl,
originalUrl: videoUrl,
videoUrl,
imageSize: resolution,
prompt,
modelId,
isFeatured: false,
featuredCategory: null,
featuredAt: null,
createdAt: item.createdAt,
chatId: item.chatId,
recordType: "video" as const,
};
})
.filter((item): item is NonNullable<typeof item> => Boolean(item));
const merged = [...imageRows, ...normalizedVideos].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
const images = merged.slice(offset, offset + pageSize);
return NextResponse.json({
images,
pagination: { page, pageSize, total, totalPages, totalImages, totalVideos }
});
} catch (error) { } catch (error) {
if (error instanceof ChatSDKError) return error.toResponse(); console.error("[Admin Images API] Error:", error);
console.error("[Admin Images API] GET failed", error); return NextResponse.json({ error: "Failed to fetch images" }, { status: 500 });
return NextResponse.json({ error: "记录加载失败,请重试" }, { status: 500 });
} }
} }

View File

@ -1,31 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { sql } from "drizzle-orm";
import { requireAdmin } from "@/lib/middleware/admin";
import { ChatSDKError } from "@/lib/errors";
import { parseMediaQuery } from "@/lib/admin/media-records";
export async function GET(request: NextRequest) {
try {
await requireAdmin();
const q = parseMediaQuery(request.nextUrl.searchParams);
const status = request.nextUrl.searchParams.get("status") || "all";
const filters = [sql`true`];
if (q.search)
filters.push(sql`(u.name ilike ${`%${q.search}%`} or u.email ilike ${`%${q.search}%`})`);
if (["pending", "running", "failed", "succeeded"].includes(status))
filters.push(sql`t.status=${status}`);
if (q.startDate) filters.push(sql`t."createdAt">=${q.startDate}::date - interval '8 hours'`);
if (q.endDate) filters.push(sql`t."createdAt"<${q.endDate}::date+interval '16 hours'`);
const [result] = await db.execute(sql`with filtered as not materialized (
select t.id,u.name as "userName",t.status,t."createdAt"+interval '8 hours' as "createdAt",t.error,coalesce(t.request->>'modelId',t.request->>'model') as model,
extract(epoch from (t."completedAt"-t."startedAt"))*1000 as "elapsedMs"
from "CanvasImageTask" t left join "User" u on u.id=t."userId" where ${sql.join(filters, sql` and `)}
) select (select count(*)::int from filtered) as total,
coalesce((select jsonb_agg(p) from (select * from filtered order by "createdAt" desc,id desc limit ${q.pageSize} offset ${(q.page - 1) * q.pageSize}) p),'[]'::jsonb) as tasks`);
return NextResponse.json(result);
} catch (error) {
if (error instanceof ChatSDKError) return error.toResponse();
return NextResponse.json({ error: "任务记录加载失败,请重试" }, { status: 500 });
}
}

View File

@ -106,22 +106,6 @@ export async function POST(request: Request) {
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId)) ? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
: catalogPresets; : catalogPresets;
if (body?.dryRun === true) {
const catalogIds = new Set(catalogPresets.map(preset => preset.modelId));
const fields = ["displayName", "description", "supportsVision", "supportsImageGen", "isHidden", "sortOrder", "capabilities", "defaultParams", "imageGenConfig"] as const;
const added = presets.filter(preset => !existingModelIds.has(preset.modelId)).map(preset => ({ modelId: preset.modelId, displayName: preset.displayName }));
const updated = presets.flatMap(preset => {
const existing = existingModels.find(model => model.modelId === preset.modelId);
if (!existing) return [];
const changedFields: string[] = fields.filter(field => preset[field] !== undefined && JSON.stringify(existing[field]) !== JSON.stringify(preset[field]));
if (existing.avatarUrl) changedFields.push("avatarUrl");
if (requestedModelIds !== null && !existing.isEnabled) changedFields.push("isEnabled");
return changedFields.length ? [{ modelId: preset.modelId, displayName: existing.displayName, changedFields }] : [];
});
const disabled = requestedModelIds === null ? existingModels.filter(model => !catalogIds.has(model.modelId) && (model.isEnabled || !model.isHidden)).map(model => ({ modelId: model.modelId, displayName: model.displayName })) : [];
return NextResponse.json({ preview: { added, updated, disabled }, total: catalogPresets.length });
}
const addedModels: AiModel[] = []; const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = []; const updatedModels: AiModel[] = [];
const disabledModels: AiModel[] = []; const disabledModels: AiModel[] = [];

View File

@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { and, desc, eq } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema"; import { canvasProject } from "@/lib/db/schema";
@ -57,20 +56,14 @@ export async function POST(request: NextRequest) {
} }
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const requestedId = body.id === undefined ? undefined : z.string().uuid().safeParse(body.id);
if (requestedId && !requestedId.success) {
return NextResponse.json({ success: false, error: "项目编号无效" }, { status: 400 });
}
const id = requestedId?.data;
const title = String(body.title || "").trim() || "未命名生图项目"; const title = String(body.title || "").trim() || "未命名生图项目";
const description = String(body.description || "").trim() || null; const description = String(body.description || "").trim() || null;
const thumbnail = String(body.thumbnail || "").trim() || null; const thumbnail = String(body.thumbnail || "").trim() || null;
const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState); const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState);
const [insertedProject] = await db const [project] = await db
.insert(canvasProject) .insert(canvasProject)
.values({ .values({
id,
userId: session.user.id, userId: session.user.id,
title, title,
description, description,
@ -85,22 +78,8 @@ export async function POST(request: NextRequest) {
agentProjectState: projectState, agentProjectState: projectState,
} as any, } as any,
}) })
.onConflictDoNothing({ target: canvasProject.id })
.returning(); .returning();
const project = insertedProject || (id ? (await db
.select()
.from(canvasProject)
.where(and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "agent")
))
.limit(1))[0] : undefined);
if (!project) {
return NextResponse.json({ success: false, error: "项目编号不可用,请重试" }, { status: 409 });
}
return NextResponse.json({ success: true, data: mapAgentProjectRow(project) }); return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
} catch (error) { } catch (error) {
console.error("[AgentProjects API] 创建项目失败:", error); console.error("[AgentProjects API] 创建项目失败:", error);

View File

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

View File

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

View File

@ -207,11 +207,7 @@ export async function PUT(
), ),
) )
.returning(); .returning();
return NextResponse.json({ return NextResponse.json({ success: true, data: saved });
success: true,
data: saved,
previousCanvasRevision: existingProject?.updatedAt.toISOString() ?? null,
});
}); });
} catch (error) { } catch (error) {
console.error("更新/创建画布失败:", error); console.error("更新/创建画布失败:", error);

View File

@ -1,9 +1,5 @@
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download"; import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation";
import { randomUUID } from "node:crypto";
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
import { createLogger } from "@/lib/observability/logger";
import { after, NextRequest, NextResponse } from "next/server"; import { after, NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation"; import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
@ -16,22 +12,6 @@ import {
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 120; export const maxDuration = 120;
const logger = createLogger("canvas.download");
function failed(failure: DownloadFailure, requestId: string) {
logger.warn("request_failed", { requestId, code: failure.code, status: failure.status });
return NextResponse.json({ success: false, error: formatDownloadFailure(failure, requestId), failure, requestId }, {
status: failure.status,
headers: { "Cache-Control": "no-store", ...(failure.status === 429 ? { "Retry-After": "3" } : {}) },
});
}
function preparing(requestId: string) {
return NextResponse.json({ delivery: "preparing", retryAfter: 2, requestId,
message: "正在准备原图,完成后将自动开始下载" }, {
status: 202, headers: { "Retry-After": "2", "Cache-Control": "no-store" },
});
}
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) { async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
// A proxy candidate for the same original must not repeat the same slow // A proxy candidate for the same original must not repeat the same slow
@ -40,25 +20,22 @@ async function prepareDownload(input: DownloadPreparationInput, signal: AbortSig
const source = extractImageProxySourceUrl(url) || url; const source = extractImageProxySourceUrl(url) || url;
return inferOriginalImageUrl(source) || source; return inferOriginalImageUrl(source) || source;
}))]; }))];
let lastError: unknown; for (let attempt = 0; attempt < 3; attempt++) {
for (let attempt = 0; attempt < 2; attempt++) {
signal.throwIfAborted(); signal.throwIfAborted();
for (const url of sources) { for (const url of sources) {
signal.throwIfAborted(); signal.throwIfAborted();
const result = await fetchCanvasDownloadAsset({ const result = await fetchCanvasDownloadAsset({
file: { url, name: "original" }, index: 0, file: { url, name: "original" }, index: 0,
requestOrigin: input.requestOrigin, cookie: input.cookie, requestOrigin: input.requestOrigin, cookie: input.cookie,
includeSequencePrefix: false, timeoutMs: 60_000, retryCount: 1, signal, includeSequencePrefix: false, timeoutMs: 12_000, retryCount: 1, signal,
}); });
if (result.ok) return { buffer: result.bytes, contentType: result.contentType }; if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
lastError = result.error;
} }
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset. // Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
// No AI/model request is involved and a completed source fetch is shared. // No AI/model request is involved and a completed source fetch is shared.
if (attempt < 1 && getDownloadFailure(lastError).retryable) await new Promise(resolve => setTimeout(resolve, 1_000)); if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 1_000));
else break;
} }
throw new Error(String(lastError || "Original image sources unavailable")); throw new Error("Original image sources unavailable");
} }
function getRequestOrigin(request: NextRequest): string { function getRequestOrigin(request: NextRequest): string {
@ -98,43 +75,27 @@ function resolveDownloadUrls(body: Record<string, unknown>, origin: string): str
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const requestId = randomUUID();
try { try {
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
return failed(getDownloadFailure("未登录"), requestId); return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
} }
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>; const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const requestOrigin = getRequestOrigin(request); const requestOrigin = getRequestOrigin(request);
const candidates = resolveDownloadUrls(body, requestOrigin); const urls = resolveDownloadUrls(body, requestOrigin);
if (candidates.length && candidates.every(isRetiredStorageUrl)) {
return failed(getDownloadFailure(RETIRED_STORAGE_MESSAGE), requestId);
}
const urls = candidates.filter(url => !isRetiredStorageUrl(url));
const requestedUrls = [...urls]; const requestedUrls = [...urls];
if (urls.length === 0) { if (urls.length === 0) {
return failed(getDownloadFailure("图片地址无效"), requestId); return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
} }
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image"); const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
if (body.preferDirect === true) { if (body.preferDirect === true) {
const { task, direct, canPrepareDirect } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch((error) => { const { task, direct } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch(() => ({ task: null, direct: null }));
logger.warn("cdn_lookup_failed", error, { requestId });
return { task: null, direct: null, canPrepareDirect: false };
});
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } }); if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
if (task && canPrepareDirect && body.preparation === "async") {
const asset = (task.metadata as { downloadAsset?: { status?: string; failure?: DownloadFailure; retryAt?: string } } | null)?.downloadAsset;
if (asset?.status === "abandoned" || (asset?.status === "failed" && Date.parse(asset.retryAt || "") > Date.now())) {
return failed(asset.failure || getDownloadFailure(null), task.id);
}
scheduleImageDownloadPreparation(task.id);
return preparing(task.id);
}
if (task?.targetUrl) { if (task?.targetUrl) {
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl); if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
scheduleImageDownloadPreparation(task.id); after(() => prepareImageArchiveDownload(task.id));
} }
} }
const cookie = request.headers.get("cookie") || ""; const cookie = request.headers.get("cookie") || "";
@ -143,10 +104,13 @@ export async function POST(request: NextRequest) {
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie, userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
}); });
if (result.status === "preparing") { if (result.status === "preparing") {
return preparing(result.requestId); return NextResponse.json({ delivery: "preparing", retryAfter: 3,
message: "正在准备原图,完成后将自动下载" }, {
status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" },
});
} }
if (result.status === "failed") { if (result.status === "failed") {
return failed(result.failure, result.requestId); return NextResponse.json({ success: false, error: result.error }, { status: 502 });
} }
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: { return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
"Content-Type": result.asset.contentType, "Content-Type": result.asset.contentType,
@ -167,14 +131,17 @@ export async function POST(request: NextRequest) {
includeSequencePrefix: false, includeSequencePrefix: false,
}); });
if (result.ok) break; if (result.ok) break;
failures.push(result.error); failures.push(`${url}: ${result.error}`);
} }
if (!result?.ok) { if (!result?.ok) {
if (failures.length > 0) { if (failures.length > 0) {
logger.warn("all_sources_failed", { requestId, failures }); console.warn("[CanvasDownloadImage] All candidates failed:", failures);
} }
return failed(getDownloadFailure(result?.error), requestId); return NextResponse.json(
{ success: false, error: result?.error || "图片源文件暂时不可访问" },
{ status: 502 }
);
} }
return new NextResponse(result.bytes, { return new NextResponse(result.bytes, {
@ -187,7 +154,7 @@ export async function POST(request: NextRequest) {
}, },
}); });
} catch (error) { } catch (error) {
logger.error("download_failed", error, { requestId }); console.error("[CanvasDownloadImage] Failed:", error);
return failed(getDownloadFailure(error), requestId); return NextResponse.json({ success: false, error: "图片下载失败" }, { status: 500 });
} }
} }

View File

@ -2,7 +2,6 @@ import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue"; import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
export const maxDuration = 1200; export const maxDuration = 1200;
@ -24,15 +23,13 @@ export async function GET(request: Request) {
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 }); return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
} }
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
task: { task: {
id: task.id, id: task.id,
status: task.status, status: task.status,
attempts: task.attempts, attempts: task.attempts,
error: failure ? formatRequestFailure(failure, task.id) : null, error: task.error || null,
failure,
result: task.result || null, result: task.result || null,
createdAt: task.createdAt, createdAt: task.createdAt,
startedAt: task.startedAt, startedAt: task.startedAt,

View File

@ -1,8 +1,7 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage, user, aiModel } from "@/lib/db/schema"; import { generatedImage, user, aiModel } from "@/lib/db/schema";
import { and, eq, desc, sql } from "drizzle-orm"; import { eq, desc, sql } from "drizzle-orm";
import { isRetiredStorageUrl, RETIRED_STORAGE_URL_PATTERN } from "@/lib/storage/retired-storage";
import { cachedPublicJson } from "@/lib/public-api/performance"; import { cachedPublicJson } from "@/lib/public-api/performance";
const FEATURED_TIMEOUT_MS = 1800; const FEATURED_TIMEOUT_MS = 1800;
@ -58,17 +57,13 @@ export async function GET(request: NextRequest) {
userName: user.name, userName: user.name,
userEmail: user.email, userEmail: user.email,
userDepartment: user.department, userDepartment: user.department,
modelName: sql<string | null>`(select ${aiModel.displayName} from ${aiModel} modelName: aiModel.displayName,
where ${aiModel.modelId} = ${generatedImage.modelId}
order by ${aiModel.isEnabled} desc, ${aiModel.createdAt} desc, ${aiModel.id} asc limit 1)`,
totalCount: sql<number>`count(*) over()::int`, totalCount: sql<number>`count(*) over()::int`,
}) })
.from(generatedImage) .from(generatedImage)
.leftJoin(user, eq(generatedImage.userId, user.id)) .leftJoin(user, eq(generatedImage.userId, user.id))
.where(and( .leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
eq(generatedImage.isFeatured, true), .where(eq(generatedImage.isFeatured, true))
sql`${generatedImage.originalUrl} !~* ${RETIRED_STORAGE_URL_PATTERN}`,
))
.orderBy(desc(generatedImage.featuredAt)) .orderBy(desc(generatedImage.featuredAt))
.limit(pageSize) .limit(pageSize)
.offset(offset); .offset(offset);
@ -79,7 +74,7 @@ export async function GET(request: NextRequest) {
return { return {
images: featuredImages.map((img) => ({ images: featuredImages.map((img) => ({
id: img.id, id: img.id,
thumbnailUrl: isRetiredStorageUrl(img.thumbnailUrl) ? img.originalUrl : img.thumbnailUrl, thumbnailUrl: img.thumbnailUrl,
originalUrl: img.originalUrl, originalUrl: img.originalUrl,
imageSize: img.imageSize, imageSize: img.imageSize,
prompt: img.prompt, prompt: img.prompt,

View File

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

View File

@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { resolveSiteBranding } from "@/lib/site-branding";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { z } from "zod"; import { z } from "zod";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
@ -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 折优惠",

View File

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

View File

@ -1,30 +0,0 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
export function AdminSurface({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let context: { revert: () => void } | undefined;
let disposed = false;
import("gsap").then(({ gsap }) => {
if (disposed) return;
context = gsap.context(() => {
gsap.fromTo(
ref.current,
{ y: 6, opacity: 0.85 },
{ y: 0, opacity: 1, duration: 0.25, ease: "power3.out", clearProps: "transform,opacity" },
);
}, ref);
});
return () => {
disposed = true;
context?.revert();
};
}, []);
return (
<div ref={ref} className="space-y-6">
{children}
</div>
);
}

View File

@ -1,603 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import useSWR from "swr";
import {
RefreshCw,
Search,
ChevronLeft,
ChevronRight,
ExternalLink,
Download,
Star,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { toast } from "sonner";
import { MediaThumbnail } from "./media-thumbnail";
import { AdminSurface } from "./admin-surface";
import { formatRecordTime } from "@/lib/admin/record-time";
import { buildImageProxyUrl } from "@/lib/image-delivery";
export type MediaRecord = {
id: string;
recordType: "image" | "video";
userName: string | null;
userEmail: string | null;
userDepartment: string | null;
createdAt: string;
modelId: string | null;
chatId: string | null;
thumbnailUrl: string;
originalUrl: string;
videoUrl?: string | null;
prompt: string | null;
imageSize: string | null;
isFeatured: boolean;
featuredCategory: string | null;
featuredAt?: string | null;
};
type MediaResponse = {
images: MediaRecord[];
pagination: {
total: number;
totalPages: number;
totalImages: number;
totalVideos: number;
page: number;
};
};
export async function adminFetcher(url: string) {
const response = await fetch(url);
const data = await response.json();
if (!response.ok) throw new Error(data.error || data.message || "加载失败,请重试");
return data;
}
const selectClass =
"h-10 rounded-md border bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
function recentDate() {
const d = new Date();
d.setDate(d.getDate() - 6);
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(d);
}
export function MediaRecordsPanel({ categories }: { categories: string[] }) {
const [view, setView] = useState("assets");
const [search, setSearch] = useState("");
const [filters, setFilters] = useState({
search: "",
type: "all",
startDate: recentDate(),
endDate: "",
model: "",
featured: "false",
status: "all",
});
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<MediaRecord | null>(null);
const [saving, setSaving] = useState(false);
const [originalFailed, setOriginalFailed] = useState(false);
const update = (changes: Partial<typeof filters>) => {
setFilters((f) => ({ ...f, ...changes }));
setPage(1);
};
useEffect(() => {
const timer = setTimeout(() => {
setFilters((f) => (f.search === search ? f : { ...f, search }));
setPage(1);
}, 350);
return () => clearTimeout(timer);
}, [search]);
const { data: options } = useSWR<{
models: Array<{ id: string; modelId: string; displayName: string; providerName: string }>;
}>("/api/admin/ai-models?view=options", adminFetcher, { revalidateOnFocus: false });
const modelNames = new Map<string, string>();
for (const model of options?.models || [])
if (!modelNames.has(model.modelId)) modelNames.set(model.modelId, model.displayName);
const recordModelNames = new Map(modelNames);
for (const model of options?.models || []) recordModelNames.set(model.id, model.displayName);
const openDetails = (item: MediaRecord) => {
setSelected(item);
setOriginalFailed(false);
};
const query = new URLSearchParams({ ...filters, page: String(page), pageSize: "40" });
const { data, error, isLoading, isValidating, mutate } = useSWR<MediaResponse>(
view === "assets" ? `/api/admin/images?${query}` : null,
adminFetcher,
{ keepPreviousData: true, revalidateOnFocus: false },
);
const tasks = useSWR<{
tasks: Array<{
id: string;
userName: string;
status: string;
createdAt: string;
error: string | null;
model: string | null;
elapsedMs: number | null;
}>;
total: number;
}>(view === "tasks" ? `/api/admin/media-tasks?${query}` : null, adminFetcher, {
keepPreviousData: true,
revalidateOnFocus: false,
});
const busy = view === "assets" ? isValidating : tasks.isValidating;
const failure = view === "assets" ? error : tasks.error;
const loading = view === "assets" ? isLoading : tasks.isLoading;
const total = view === "assets" ? data?.pagination.total : tasks.data?.total;
const pages = Math.max(1, Math.ceil((total || 0) / 40));
async function save(changes: Partial<MediaRecord>) {
if (!selected || saving) return;
setSaving(true);
try {
const response = await fetch("/api/admin/images", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageId: selected.id, ...changes }),
});
if (!response.ok) throw new Error("保存失败,请重试");
setSelected({ ...selected, ...changes });
toast.success("已保存");
await mutate();
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
}
return (
<AdminSurface>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold tracking-tight">生成记录</h2>
<p className="mt-2 text-sm text-muted-foreground">
查看图片与视频产物,追踪画布任务。时间统一按北京时间展示。
</p>
</div>
<Button
variant="outline"
disabled={busy}
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
>
<RefreshCw className={`mr-2 size-4 ${busy ? "motion-safe:animate-spin" : ""}`} />
刷新
</Button>
</div>
<div className="flex gap-2" role="tablist" aria-label="记录视图">
{[
["assets", "产物记录"],
["tasks", "画布任务"],
].map(([key, label]) => (
<Button
key={key}
role="tab"
aria-selected={view === key}
variant={view === key ? "default" : "ghost"}
onClick={() => {
setView(key);
setPage(1);
}}
>
{label}
</Button>
))}
</div>
<div className="flex flex-wrap items-end gap-3 rounded-xl border bg-background p-4">
<label className="min-w-48 flex-1 text-xs font-medium">
用户
<div className="relative mt-2">
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
<Input
className="h-10 pl-9"
placeholder="搜索姓名或邮箱"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</label>
{view === "assets" ? (
<label className="text-xs font-medium">
类型
<select
aria-label="产物类型"
className={`${selectClass} mt-2 block`}
value={filters.type}
onChange={(e) => update({ type: e.target.value })}
>
<option value="all">图片与视频</option>
<option value="image">只看图片</option>
<option value="video">只看视频</option>
</select>
</label>
) : (
<label className="text-xs font-medium">
任务状态
<select
aria-label="任务状态"
className={`${selectClass} mt-2 block`}
value={filters.status}
onChange={(e) => update({ status: e.target.value })}
>
<option value="all">全部状态</option>
<option value="failed">失败</option>
<option value="running">运行中</option>
<option value="pending">排队中</option>
<option value="succeeded">成功</option>
</select>
</label>
)}
<label className="text-xs font-medium">
开始日期
<Input
aria-label="开始日期"
className="mt-2 h-10 w-40"
type="date"
value={filters.startDate}
onChange={(e) => update({ startDate: e.target.value })}
/>
</label>
<label className="text-xs font-medium">
结束日期
<Input
aria-label="结束日期"
className="mt-2 h-10 w-40"
type="date"
value={filters.endDate}
onChange={(e) => update({ endDate: e.target.value })}
/>
</label>
<Button
variant="ghost"
onClick={() => {
setSearch("");
update({
search: "",
startDate: "",
endDate: "",
model: "",
featured: "false",
type: "all",
status: "all",
});
}}
>
全部时间 / 重置
</Button>
{view === "assets" && (
<div className="flex w-full flex-wrap items-center gap-3">
<select
aria-label="记录模型"
className={`${selectClass} max-w-full sm:max-w-96`}
value={filters.model}
onChange={(e) => update({ model: e.target.value })}
>
<option value="">全部模型</option>
{Array.from(modelNames, ([id, name]) => (
<option key={id} value={id}>
{name} · {id}
</option>
))}
</select>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={filters.featured === "true"}
onChange={(e) => update({ featured: String(e.target.checked) })}
/>
只看精选
</label>
</div>
)}
</div>
<div
aria-live="polite"
className="flex items-center justify-between text-sm text-muted-foreground"
>
<span>
{total === undefined ? "正在读取记录…" : `共 ${total.toLocaleString()} 条记录`}
{view === "assets" && data
? ` · 图片 ${data.pagination.totalImages.toLocaleString()} · 视频 ${data.pagination.totalVideos.toLocaleString()}`
: ""}
</span>
{busy && !loading && <span>正在更新…</span>}
</div>
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span>
第 {page} / {pages} 页 · 每页 40 条
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={busy || page === 1}
onClick={() => setPage((p) => p - 1)}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={busy || page >= pages}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
{failure ? (
<div role="alert" className="rounded-xl border border-destructive/30 p-6 text-sm">
<p>{failure.message}</p>
<Button
className="mt-3"
variant="outline"
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
>
重试加载
</Button>
</div>
) : loading ? (
<div role="status" className="space-y-3 rounded-xl border p-6">
{[0, 1, 2, 3].map((x) => (
<div key={x} className="h-16 rounded-lg bg-muted motion-safe:animate-pulse" />
))}
<span className="sr-only">正在加载记录</span>
</div>
) : total === 0 ? (
<div className="rounded-xl border border-dashed p-12 text-center text-sm text-muted-foreground">
没有符合条件的记录,请调整筛选条件。
</div>
) : (
<div className="overflow-x-auto rounded-xl border bg-background" aria-busy={busy}>
{view === "assets" ? (
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="bg-muted/50 text-xs text-muted-foreground">
<tr>
{["产物", "用户", "生成时间", "模型 / 尺寸", "精选", ""].map((v, i) => (
<th key={i} className="px-4 py-3 font-medium">
{v}
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{data?.images.map((item) => (
<tr key={item.id} className="hover:bg-muted/30">
<td className="p-4">
<div>
<MediaThumbnail
url={item.thumbnailUrl}
video={item.recordType === "video"}
onOpen={() => openDetails(item)}
/>
</div>
</td>
<td className="px-4 py-3">
<div className="font-medium">{item.userName || item.userEmail}</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.userDepartment || "未设置部门"}
</div>
</td>
<td className="whitespace-nowrap px-4 py-3 tabular-nums">
{formatRecordTime(item.createdAt)}
</td>
<td className="max-w-64 px-4 py-3">
<div className="truncate" title={item.modelId || ""}>
{recordModelNames.get(item.modelId || "") || item.modelId || "未记录模型"}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.recordType === "video" ? "视频" : "图片"} ·{" "}
{item.imageSize || "未知尺寸"}
</div>
</td>
<td className="px-4 py-3">
{item.isFeatured ? (
<span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300">
<Star className="size-3" />
{item.featuredCategory || "精选"}
</span>
) : (
"—"
)}
</td>
<td className="px-4 py-3">
<Button variant="ghost" size="sm" onClick={() => openDetails(item)}>
详情
</Button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="bg-muted/50">
<tr>
{["用户 / 时间", "状态", "模型", "耗时", "失败原因 / 任务编号"].map((x) => (
<th key={x} className="px-4 py-3 font-medium">
{x}
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{tasks.data?.tasks.map((task) => (
<tr key={task.id}>
<td className="px-4 py-4">
{task.userName}
<div className="mt-1 text-xs text-muted-foreground">
{formatRecordTime(task.createdAt)}
</div>
</td>
<td className="px-4 py-4">
{{ failed: "失败", running: "运行中", succeeded: "成功", pending: "排队中" }[
task.status
] || task.status}
</td>
<td className="max-w-48 break-words px-4 py-4">{task.model || "自动选择"}</td>
<td className="px-4 py-4 tabular-nums">
{task.elapsedMs === null ? "—" : `${Math.round(task.elapsedMs / 1000)} 秒`}
</td>
<td className="max-w-xl break-words px-4 py-4">
<p className={task.error ? "text-destructive" : "text-muted-foreground"}>
{task.error || "—"}
</p>
<code className="mt-2 block text-xs text-muted-foreground">{task.id}</code>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">
每页 40 条 · 第 {page} / {pages} 页
</span>
<div className="flex items-center gap-2">
<Button variant="outline" disabled={busy || page === 1} onClick={() => setPage(1)}>
首页
</Button>
<Button
aria-label="上一页"
variant="outline"
disabled={busy || page === 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="size-4" />
</Button>
<Button
aria-label="下一页"
variant="outline"
disabled={busy || page >= pages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="size-4" />
</Button>
<Button variant="outline" disabled={busy || page >= pages} onClick={() => setPage(pages)}>
末页
</Button>
</div>
</div>
<Dialog
open={!!selected}
onOpenChange={(open) => {
if (!open) setSelected(null);
}}
>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto bg-background">
<DialogHeader>
<DialogTitle>{selected?.recordType === "video" ? "视频详情" : "图片详情"}</DialogTitle>
</DialogHeader>
{selected && (
<div className="space-y-6">
<div className="flex min-h-40 justify-center rounded-xl bg-muted">
{selected.recordType === "video" ? (
<video
src={selected.videoUrl || selected.originalUrl}
controls
preload="none"
className="max-h-[45vh] w-full"
/>
) : originalFailed ? (
<div role="alert" className="p-8 text-sm">
原图无法加载,源文件可能已过期或存储不可达。
<Button
className="ml-3"
variant="outline"
onClick={() => setOriginalFailed(false)}
>
重试
</Button>
</div>
) : (
<img
src={selected.originalUrl}
alt="生成原图"
className="max-h-[45vh] object-contain"
onError={() => setOriginalFailed(true)}
/>
)}
</div>
<dl className="grid gap-4 text-sm sm:grid-cols-2">
{[
["用户", selected.userName || selected.userEmail],
["部门", selected.userDepartment],
["生成时间", formatRecordTime(selected.createdAt)],
["尺寸", selected.imageSize],
["模型", recordModelNames.get(selected.modelId || "") || selected.modelId],
["精选时间", selected.featuredAt ? formatRecordTime(selected.featuredAt) : "—"],
].map(([k, v]) => (
<div key={k}>
<dt className="text-xs text-muted-foreground">{k}</dt>
<dd className="mt-1 break-words">{v || "未记录"}</dd>
</div>
))}
</dl>
<div>
<h3 className="text-sm font-medium">提示词</h3>
<p className="mt-2 whitespace-pre-wrap break-words text-sm text-muted-foreground">
{selected.prompt || "未记录提示词"}
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button variant="outline" asChild>
<a href={selected.originalUrl} target="_blank" rel="noreferrer">
<ExternalLink className="mr-2 size-4" />
打开原文件
</a>
</Button>
{selected.recordType === "image" && (
<Button variant="outline" asChild>
<a
href={buildImageProxyUrl(selected.originalUrl, { delivery: "original" })}
download
>
<Download className="mr-2 size-4" />
下载原图
</a>
</Button>
)}
</div>
{selected.recordType === "image" && (
<div className="flex flex-wrap items-center gap-3 rounded-xl border p-4">
<select
aria-label="精选分类"
className={selectClass}
value={selected.featuredCategory || ""}
onChange={(e) =>
setSelected({ ...selected, featuredCategory: e.target.value || null })
}
>
<option value="">未分类</option>
{[...new Set([...categories, selected.featuredCategory].filter(Boolean))].map(
(c) => (
<option key={c} value={c!}>
{c}
</option>
),
)}
</select>
<Button
variant="outline"
disabled={saving}
onClick={() => save({ featuredCategory: selected.featuredCategory })}
>
保存分类
</Button>
<Button
disabled={saving}
onClick={() => save({ isFeatured: !selected.isFeatured })}
>
<Star className="mr-2 size-4" />
{selected.isFeatured ? "取消精选" : "设为精选"}
</Button>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</AdminSurface>
);
}

View File

@ -1,150 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ImageOff, Video, RotateCw } from "lucide-react";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
import { queuePreview } from "@/lib/admin/preview-queue";
export function MediaThumbnail({
url,
video = false,
onOpen,
}: {
url: string;
video?: boolean;
onOpen?: () => void;
}) {
const container = useRef<HTMLDivElement>(null);
const [state, setState] = useState<"waiting" | "loading" | "ready" | "error">("waiting");
const [attempt, setAttempt] = useState(0);
const [source, setSource] = useState("");
const [failure, setFailure] = useState("");
const retired = isRetiredStorageUrl(url);
useEffect(() => {
setState("waiting");
setSource("");
setFailure("");
if (video || retired || !url || !container.current) return;
let dispose: (() => void) | undefined;
let abort = () => {};
let objectUrl = "";
let alive = true;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting || dispose) return;
observer.disconnect();
dispose = queuePreview((release) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 48000);
setState("loading");
// Fetch is abortable, so leaving a page also stops in-flight previews.
const parsed = new URL(url, window.location.origin);
const target =
parsed.hostname === "scqnycdn.jiansun.vip" && !parsed.search
? `${url}?imageView2/2/w/192/q/72/format/webp`
: buildImageProxyUrl(url, {
width: 192,
quality: 72,
format: "webp",
delivery: "proxy",
});
fetch(target, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) {
const detail = await response.json().catch(() => null);
throw new Error(`${detail?.error || "预览请求失败"}(HTTP ${response.status})`);
}
const blob = await response.blob();
if (!blob.type.startsWith("image/")) throw new Error("Not an image");
if (alive) {
objectUrl = URL.createObjectURL(blob);
setSource(objectUrl);
setState("ready");
}
})
.catch((error) => {
if (alive) {
setFailure(
error.name === "AbortError" ? "预览加载超时" : error.message || "预览加载失败",
);
setState("error");
}
})
.finally(() => {
clearTimeout(timeout);
release();
});
abort = () => {
controller.abort();
clearTimeout(timeout);
};
});
},
{ rootMargin: "80px" },
);
observer.observe(container.current);
return () => {
alive = false;
observer.disconnect();
abort();
dispose?.();
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [url, video, retired, attempt]);
return (
<div
ref={container}
className="relative flex h-16 w-20 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted"
data-preview-state={state}
>
{video ? (
<button
type="button"
aria-label="查看视频详情"
className="flex h-full w-full items-center justify-center"
onClick={onOpen}
>
<Video className="size-5 text-muted-foreground" aria-label="视频,点击查看后播放" />
</button>
) : retired ? (
<span className="text-center text-xs text-muted-foreground">
旧存储
<br />
已停用
</span>
) : source && state === "ready" ? (
<button type="button" aria-label="查看图片详情" className="h-full w-full" onClick={onOpen}>
<img
src={source}
alt="生成结果预览"
className="h-full w-full object-contain"
onError={() => {
setFailure("图片内容无法解码");
setState("error");
}}
/>
</button>
) : state === "error" ? (
<button
type="button"
className="flex h-full w-full flex-col items-center justify-center gap-1 text-xs text-muted-foreground hover:text-foreground"
title={`${failure},点击重试`}
onClick={(e) => {
e.stopPropagation();
setAttempt((x) => x + 1);
}}
>
<ImageOff className="size-4" />
加载失败
</button>
) : (
<RotateCw
className={`size-4 text-muted-foreground ${state === "loading" ? "motion-safe:animate-spin" : ""}`}
aria-label={state === "loading" ? "正在加载预览" : "等待进入可见区域"}
/>
)}
</div>
);
}

View File

@ -1,191 +0,0 @@
"use client";
import { useState } from "react";
import useSWR from "swr";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { adminFetcher } from "./media-records-panel";
import { formatRecordTime } from "@/lib/admin/record-time";
type Event = {
id: string;
modelDisplayName: string;
modelRuntimeId: string;
providerName: string;
eventType: string;
createdAt: string;
errorMessage: string;
elapsedMs: number | null;
consecutiveFailures: number;
autoDisabled: boolean;
};
export function ModelHealthPanel({
providers,
models,
initialModelId = "",
}: {
initialModelId?: string;
providers: Array<{ id: string; name: string }>;
models: Array<{ id: string; displayName: string; providerId: string }>;
}) {
const [filters, setFilters] = useState({
providerId: "",
modelId: initialModelId,
eventType: "",
startDate: "",
endDate: "",
});
const [page, setPage] = useState(1);
const query = new URLSearchParams({ ...filters, page: String(page), limit: "20" });
const { data, error, isLoading, isValidating, mutate } = useSWR<{
events: Event[];
hasMore: boolean;
}>(`/api/admin/ai-model-health?${query}`, adminFetcher, {
keepPreviousData: true,
revalidateOnFocus: false,
});
const update = (change: Partial<typeof filters>) => {
setFilters((f) => ({ ...f, ...change }));
setPage(1);
};
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">运行健康</h3>
<p className="mt-2 text-sm text-muted-foreground">
按上游和模型追踪故障,查看完整原因与发生时间。
</p>
</div>
<div className="flex flex-wrap gap-3">
{[
["providerId", "全部上游", providers.map((p) => [p.id, p.name])],
[
"modelId",
"全部模型",
models
.filter((m) => !filters.providerId || m.providerId === filters.providerId)
.map((m) => [m.id, m.displayName]),
],
[
"eventType",
"全部错误",
[
["quota", "额度 / 余额"],
["timeout", "超时"],
["unavailable", "不可用"],
["invalid_model", "模型不可用"],
["download_error", "结果下载失败"],
["error", "其他错误"],
["success", "成功"],
],
],
].map(([key, label, options]) => (
<select
key={key as string}
aria-label={label as string}
className="h-10 max-w-64 rounded-md border bg-background px-3 text-sm"
value={filters[key as keyof typeof filters]}
onChange={(e) =>
update({
[key as string]: e.target.value,
...(key === "providerId" ? { modelId: "" } : {}),
})
}
>
<option value="">{label as string}</option>
{(options as string[][]).map(([value, text]) => (
<option key={value} value={value}>
{text}
</option>
))}
</select>
))}
<Input
aria-label="故障开始日期"
className="h-10 w-40"
type="date"
value={filters.startDate}
onChange={(e) => update({ startDate: e.target.value })}
/>
<Input
aria-label="故障结束日期"
className="h-10 w-40"
type="date"
value={filters.endDate}
onChange={(e) => update({ endDate: e.target.value })}
/>
<Button variant="outline" disabled={isValidating} onClick={() => mutate()}>
刷新日志
</Button>
</div>
{error ? (
<p role="alert" className="p-6 text-sm text-destructive">
日志加载失败,请点击刷新重试。
</p>
) : isLoading ? (
<p role="status" className="p-6 text-sm">
正在加载故障日志…
</p>
) : (
<div className="divide-y rounded-xl border">
{data?.events.map((event) => (
<div key={event.id} className="p-4">
<div className="flex flex-wrap items-center gap-3 text-sm">
<strong className="font-semibold">
{event.modelDisplayName || event.modelRuntimeId || "已删除模型"}
</strong>
<span className="text-muted-foreground">{event.providerName || "未知上游"}</span>
<span className="rounded bg-muted px-2 py-1 text-xs">
{{
quota: "额度 / 余额",
timeout: "超时",
unavailable: "不可用",
error: "服务错误",
invalid_model: "模型不可用",
download_error: "结果下载失败",
success: "成功",
}[event.eventType] || event.eventType}
</span>
<time className="ml-auto text-xs tabular-nums text-muted-foreground">
{formatRecordTime(event.createdAt)}
</time>
</div>
<p className="mt-3 whitespace-pre-wrap break-words text-sm">
{event.errorMessage || "未记录详细原因"}
</p>
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
<span>连续失败 {event.consecutiveFailures} 次</span>
{event.elapsedMs !== null && (
<span>耗时 {Math.round(event.elapsedMs / 1000)} 秒</span>
)}
{event.autoDisabled && <span>已自动停用</span>}
</div>
</div>
))}
{!data?.events.length && (
<p className="p-12 text-center text-sm text-muted-foreground">该范围内没有故障记录。</p>
)}
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
第 {page} 页{isValidating && " · 正在更新…"}
</span>
<div className="flex gap-2">
<Button
variant="outline"
disabled={page === 1 || isValidating}
onClick={() => setPage((p) => p - 1)}
>
上一页
</Button>
<Button
variant="outline"
disabled={!data?.hasMore || isValidating}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,326 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { AiModel } from "@/lib/db/schema";
import {
is3DModelDescriptor,
isVideoModelDescriptor,
isImageGenerationModelDescriptor,
} from "@/lib/ai/model-classification";
type Model = AiModel & { isAgentModel?: boolean; isMultimodal?: boolean };
type Provider = { id: string; name: string; type: string; isEnabled: boolean };
const selectClass = "h-10 rounded-md border bg-background px-3 text-sm";
export function ModelManagementList({
models,
providers,
error,
loading,
onEdit,
onDelete,
onRestore,
onRefresh,
onHealth,
}: {
models: Model[];
providers: Provider[];
error?: Error;
loading: boolean;
onEdit: (model: Model) => Promise<void>;
onDelete: (id: string) => void;
onRestore: (model: Model) => void;
onRefresh: () => void;
onHealth?: (id: string) => void;
}) {
const [search, setSearch] = useState("");
const [provider, setProvider] = useState("");
const [status, setStatus] = useState("enabled");
const [kind, setKind] = useState("");
const [page, setPage] = useState(1);
const [opening, setOpening] = useState<string | null>(null);
const rows = useMemo(
() =>
models
.map((model) => {
const upstream = providers.find((p) => p.id === model.providerId);
const descriptor = { ...model, providerType: upstream?.type };
const type = is3DModelDescriptor(descriptor)
? "3d"
: isVideoModelDescriptor(descriptor)
? "video"
: isImageGenerationModelDescriptor(descriptor)
? "image"
: "chat";
return { model, upstream, type };
})
.filter(({ model, upstream, type }) => {
const text =
`${model.displayName} ${model.modelId} ${upstream?.name || ""}`.toLowerCase();
return (
text.includes(search.trim().toLowerCase()) &&
(!provider || model.providerId === provider) &&
(!kind || kind === type) &&
(status === "all" ||
(status === "enabled" && model.isEnabled) ||
(status === "disabled" && !model.isEnabled) ||
(status === "unhealthy" && model.healthStatus !== "healthy") ||
(status === "visible" && !model.isHidden))
);
}),
[models, providers, search, provider, status, kind],
);
const pages = Math.max(1, Math.ceil(rows.length / 25));
const currentPage = Math.min(page, pages);
const change = (fn: () => void) => {
fn();
setPage(1);
};
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<div className="relative min-w-56 flex-1">
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
<Input
className="h-10 pl-9"
aria-label="搜索模型"
placeholder="搜索模型名称、ID 或上游"
value={search}
onChange={(e) => change(() => setSearch(e.target.value))}
/>
</div>
<select
aria-label="模型上游"
className={selectClass}
value={provider}
onChange={(e) => change(() => setProvider(e.target.value))}
>
<option value="">全部上游</option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<select
aria-label="模型类型"
className={selectClass}
value={kind}
onChange={(e) => change(() => setKind(e.target.value))}
>
<option value="">全部类型</option>
<option value="image">生图</option>
<option value="video">视频</option>
<option value="chat">对话</option>
<option value="3d">3D / CAD</option>
</select>
<select
aria-label="模型状态"
className={selectClass}
value={status}
onChange={(e) => change(() => setStatus(e.target.value))}
>
<option value="enabled">已启用</option>
<option value="all">全部模型</option>
<option value="disabled">已停用</option>
<option value="unhealthy">运行异常</option>
<option value="visible">前台可见</option>
</select>
<Button variant="outline" onClick={onRefresh} aria-label="刷新模型">
<RefreshCw className="size-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{loading
? "正在加载模型…"
: `筛选结果 ${rows.length} 个 · 已启用 ${models.filter((m) => m.isEnabled).length} 个 · 全部 ${models.length} 个`}
</p>
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>
第 {currentPage} / {pages} 页
</span>
<Button
variant="outline"
size="sm"
disabled={currentPage === 1}
onClick={() => setPage(currentPage - 1)}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage === pages}
onClick={() => setPage(currentPage + 1)}
>
下一页
</Button>
</div>
{error ? (
<div role="alert" className="rounded-xl border p-6 text-sm">
模型加载失败。
<Button variant="outline" onClick={onRefresh}>
重试
</Button>
</div>
) : loading ? (
<div role="status" className="h-48 rounded-xl bg-muted motion-safe:animate-pulse" />
) : (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full min-w-[850px] text-left text-sm">
<thead className="bg-muted/50 text-xs text-muted-foreground">
<tr>
{["模型", "上游 / 类型", "启用", "前台展示", "运行健康", "操作"].map((x) => (
<th key={x} className="px-4 py-3 font-medium">
{x}
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{rows
.slice((currentPage - 1) * 25, currentPage * 25)
.map(({ model, upstream, type }) => (
<tr key={model.id} className="hover:bg-muted/30">
<td className="max-w-64 px-4 py-4">
<div className="font-medium">{model.displayName}</div>
<div
className="mt-1 truncate text-xs text-muted-foreground"
title={model.modelId}
>
{model.modelId}
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
{model.isDefault && <span>默认模型</span>}
{model.isDefaultTextEditModel && <span>文字编辑默认</span>}
{model.isAgentModel && <span>Agent</span>}
{model.isMultimodal && <span>多模态</span>}
{model.isSubModel && <span>子模型</span>}
</div>
</td>
<td className="px-4 py-4">
{upstream?.name || "未知上游"}
<div className="mt-1 text-xs text-muted-foreground">
{{ image: "生图", video: "视频", chat: "对话", "3d": "3D / CAD" }[type]}
{upstream && !upstream.isEnabled ? " · 上游已停用" : ""}
</div>
</td>
<td className="whitespace-nowrap px-4 py-4">
<span
className={`rounded-md px-2 py-1 text-xs ${model.isEnabled ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200" : "bg-muted text-muted-foreground"}`}
>
{model.isEnabled ? "已启用" : "已停用"}
</span>
</td>
<td className="px-4 py-4">
{model.isHidden || model.isSubModel ? "隐藏" : "可见"}
</td>
<td className="max-w-72 px-4 py-4">
<span
className={
model.healthStatus !== "healthy"
? "text-amber-700 dark:text-amber-300"
: "text-muted-foreground"
}
>
{model.healthStatus === "auto_disabled"
? "自动停用"
: model.healthStatus === "degraded"
? "观察中"
: "正常"}
</span>
{model.healthConsecutiveFailures > 0 && (
<span className="ml-2 text-xs text-muted-foreground">
连续失败 {model.healthConsecutiveFailures} 次
</span>
)}
{model.healthLastError && (
<details className="mt-2 text-xs">
<summary className="cursor-pointer text-muted-foreground">
查看最近原因
</summary>
<p className="mt-2 whitespace-pre-wrap break-words">
{model.healthLastError}
</p>
</details>
)}
<button
type="button"
className="mt-2 block text-xs underline underline-offset-4"
onClick={() => onHealth?.(model.id)}
>
故障记录
</button>
<div className="mt-2 text-xs text-muted-foreground">
{model.excludeFromHealthBan && "不自动封禁 "}
{model.excludeFromFailover && "不参与迁移"}
</div>
</td>
<td className="px-4 py-4">
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={opening !== null}
onClick={async () => {
setOpening(model.id);
try {
await onEdit(model);
} finally {
setOpening(null);
}
}}
>
{opening === model.id ? "加载中…" : "编辑"}
</Button>
{model.healthStatus === "auto_disabled" && (
<Button variant="outline" size="sm" onClick={() => onRestore(model)}>
恢复
</Button>
)}
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => onDelete(model.id)}
>
删除
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 && (
<p className="p-12 text-center text-sm text-muted-foreground">没有符合条件的模型。</p>
)}
</div>
)}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
每页 25 个 · 第 {currentPage} / {pages} 页
</span>
<div className="flex gap-2">
<Button
aria-label="上一页模型"
variant="outline"
disabled={currentPage === 1}
onClick={() => setPage(currentPage - 1)}
>
<ChevronLeft className="size-4" />
</Button>
<Button
aria-label="下一页模型"
variant="outline"
disabled={currentPage === pages}
onClick={() => setPage(currentPage + 1)}
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
@ -576,11 +575,31 @@ export function AgentGenerationWorkspace({
toast.error("图片地址无效"); toast.error("图片地址无效");
return; return;
} }
const filename = buildDownloadFilename(raw);
try { try {
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) }); const response = await fetch(raw);
toast.success("原图已开始下载"); if (!response.ok) {
} catch (error) { throw new Error(`download failed: ${response.status}`);
toast.error(getCanvasImageDownloadErrorMessage(error)); }
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
} }
}, []); }, []);

View File

@ -1,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) => {

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
@ -726,11 +725,30 @@ export function AgentGenerationWorkspaceNew({
toast.error("图片地址无效"); toast.error("图片地址无效");
return; return;
} }
const filename = buildDownloadFilename(raw);
try { try {
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) }); const response = await fetch(raw);
toast.success("原图已开始下载"); if (!response.ok) {
} catch (error) { throw new Error(`download failed: ${response.status}`);
toast.error(getCanvasImageDownloadErrorMessage(error)); }
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
} }
}, []); }, []);

View File

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

View File

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

View File

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

View File

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

View File

@ -1,68 +0,0 @@
"use client";
import { useState, type ReactNode } from "react";
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
type HomeImageProps = {
sources: Array<string | null | undefined>;
alt: string;
className?: string;
imageClassName?: string;
fallback?: ReactNode;
loading?: "lazy" | "eager";
onUnavailable?: () => void;
};
function imageCandidates(source: string) {
// These two domains are documented aliases for the same gongxiangpan store.
// Do not rewrite other buckets, providers, signed URLs or user-supplied hosts.
try {
const url = new URL(source);
if (url.origin === "https://s3.yydd.ai" &&
url.pathname.startsWith("/gongxiangpan/") && !url.search && !url.hash) {
return [`https://s3.jiansun.vip${url.pathname}`, source];
}
} catch { /* Relative site assets need no host rewrite. */ }
return [source];
}
/** Keep failed image elements out of view, trying each distinct source once. */
export function HomeImage(props: HomeImageProps) {
const sources = [...new Set(props.sources
.filter((url): url is string => Boolean(url?.trim()) && !isRetiredStorageUrl(url))
.flatMap(imageCandidates))];
return <HomeImageAttempt key={JSON.stringify(sources)} {...props} sources={sources} />;
}
function HomeImageAttempt({
sources, alt, className, imageClassName, fallback, loading = "lazy", onUnavailable,
}: Omit<HomeImageProps, "sources"> & { sources: string[] }) {
const [index, setIndex] = useState(0);
const [loaded, setLoaded] = useState(false);
const source = sources[index];
return (
<span className={`relative block overflow-hidden ${className || ""}`}>
{!loaded && (
<span className={fallback ? "block" : "block aspect-[4/5] rounded-[12px] bg-[#e8e8e8] dark:bg-[#1b1c20]"}>
{fallback}
</span>
)}
{source ? (
<img
key={source}
src={source}
alt={alt}
loading={loading}
decoding="async"
className={`${imageClassName || ""} ${loaded ? "" : "absolute inset-0 opacity-0"}`}
onLoad={() => setLoaded(true)}
onError={() => {
setLoaded(false);
setIndex(index + 1);
if (index + 1 >= sources.length) onUnavailable?.();
}}
/>
) : null}
</span>
);
}

View File

@ -1,7 +1,6 @@
import { ReferenceIcon } from "@/components/lovart/reference-icon"; import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { Gem, Heart, Loader2, PlayCircle } from "lucide-react"; import { Gem, Heart, Loader2, PlayCircle } from "lucide-react";
import { useState, type Ref } from "react"; import type { Ref } from "react";
import { HomeImage } from "./home-image";
import type { FeaturedImage } from "./types"; import type { FeaturedImage } from "./types";
type LovartFeaturedGalleryProps = { type LovartFeaturedGalleryProps = {
@ -31,10 +30,6 @@ function getFeaturedPreviewUrl(item: FeaturedImage) {
return `${source}?imageView2/2/w/900/q/78`; return `${source}?imageView2/2/w/900/q/78`;
} }
function imageIdentity(item: FeaturedImage) {
return JSON.stringify([item.id, item.thumbnailUrl, item.originalUrl]);
}
export function LovartFeaturedGallery({ export function LovartFeaturedGallery({
onOpenProjects, onOpenProjects,
onOpenMaterials, onOpenMaterials,
@ -47,13 +42,6 @@ export function LovartFeaturedGallery({
isLoadingMoreFeatured, isLoadingMoreFeatured,
setActiveCategory, setActiveCategory,
}: LovartFeaturedGalleryProps) { }: LovartFeaturedGalleryProps) {
const [unavailable, setUnavailable] = useState<Set<string>>(() => new Set());
const [retryVersion, setRetryVersion] = useState(0);
const uniqueItems = [...new Map(featuredItems.map((item) => [item.id, item])).values()];
const visibleItems = uniqueItems.filter((item) =>
Boolean(item.thumbnailUrl || item.originalUrl),
);
const unavailableCount = uniqueItems.filter(item => unavailable.has(imageIdentity(item))).length;
return ( return (
<section <section
ref={featuredSectionRef} ref={featuredSectionRef}
@ -100,48 +88,44 @@ export function LovartFeaturedGallery({
))} ))}
</div> </div>
{unavailableCount > 0 && ( {featuredItems.length === 0 ? (
<div role="status" className="mt-4 flex items-center gap-3 text-xs text-black/50 dark:text-white/50">
<span>{unavailableCount} 张灵感图片加载失败,请重试。</span>
<button
type="button"
className="shrink-0 underline underline-offset-4 hover:text-black dark:hover:text-white"
onClick={() => {
setUnavailable(new Set());
setRetryVersion((version) => version + 1);
}}
>
重试加载
</button>
</div>
)}
{visibleItems.length === 0 ? (
<div className="mt-[22px] rounded-[10px] border border-dashed border-black/15 bg-[#f7f7f7] p-10 text-center text-[14px] text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35"> <div className="mt-[22px] rounded-[10px] border border-dashed border-black/15 bg-[#f7f7f7] p-10 text-center text-[14px] text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
{isLoadingFeatured {isLoadingFeatured
? "灵感内容加载中..." ? "灵感内容加载中..."
: unavailableCount > 0 ? "图片暂时无法加载,请稍后重试。" : "当前没有可展示的灵感数据。"} : "当前没有可展示的灵感数据。"}
</div> </div>
) : ( ) : (
<div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4"> <div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4">
{visibleItems.map((item) => ( {featuredItems.map((item, index) => (
<button <button
key={`${imageIdentity(item)}:${retryVersion}`} key={item.id}
type="button" type="button"
disabled={unavailable.has(imageIdentity(item))}
onClick={() => onClick={() =>
window.open(item.originalUrl || item.thumbnailUrl, "_blank") window.open(item.originalUrl || item.thumbnailUrl, "_blank")
} }
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left" className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
> >
{unavailable.has(imageIdentity(item)) ? ( <img
<div className="flex aspect-[4/5] items-center justify-center rounded-[12px] bg-muted p-4 text-sm text-muted-foreground">图片加载失败</div> src={getFeaturedPreviewUrl(item)}
) : <HomeImage
sources={[getFeaturedPreviewUrl(item), item.thumbnailUrl, item.originalUrl]}
alt={item.prompt || "灵感图"} alt={item.prompt || "灵感图"}
loading="lazy" loading="lazy"
onUnavailable={() => setUnavailable((previous) => new Set(previous).add(imageIdentity(item)))} decoding="async"
imageClassName="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]" onError={(event) => {
/>} const image = event.currentTarget;
const fallbackUrl = item.originalUrl || item.thumbnailUrl;
if (
!fallbackUrl ||
image.dataset.fallbackApplied === "true"
) {
return;
}
image.dataset.fallbackApplied = "true";
image.src = fallbackUrl;
}}
className="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
/>
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45"> <div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
<div className="flex min-w-0 items-center gap-[6px] pr-2"> <div className="flex min-w-0 items-center gap-[6px] pr-2">
<span className="flex h-[16px] w-[16px] shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#d8d8d8] text-[8px] font-medium text-white"> <span className="flex h-[16px] w-[16px] shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#d8d8d8] text-[8px] font-medium text-white">
@ -154,11 +138,11 @@ export function LovartFeaturedGallery({
<div className="inline-flex shrink-0 items-center gap-[7px] text-[11px] leading-none"> <div className="inline-flex shrink-0 items-center gap-[7px] text-[11px] leading-none">
<span className="inline-flex items-center gap-[4px]"> <span className="inline-flex items-center gap-[4px]">
<PlayCircle className="h-[11px] w-[11px]" strokeWidth={2} /> <PlayCircle className="h-[11px] w-[11px]" strokeWidth={2} />
{(1200 + featuredItems.indexOf(item) * 37).toLocaleString()} {(1200 + index * 37).toLocaleString()}
</span> </span>
<span className="inline-flex items-center gap-[4px]"> <span className="inline-flex items-center gap-[4px]">
<Heart className="h-[11px] w-[11px]" strokeWidth={2} /> <Heart className="h-[11px] w-[11px]" strokeWidth={2} />
{(20 + (featuredItems.indexOf(item) % 9) * 11).toLocaleString()} {(20 + (index % 9) * 11).toLocaleString()}
</span> </span>
<Gem className="h-[11px] w-[11px]" strokeWidth={2} /> <Gem className="h-[11px] w-[11px]" strokeWidth={2} />
</div> </div>

View File

@ -13,7 +13,6 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config"; import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config";
import type { NavLink, TutorialVideo } from "./types"; import type { NavLink, TutorialVideo } from "./types";
import { HomeImage } from "./home-image";
type SidebarItem = { type SidebarItem = {
iconUrl?: string | null; iconUrl?: string | null;
@ -184,18 +183,17 @@ export function LovartSidebar({
onClick={onHome} onClick={onHome}
title={siteName} title={siteName}
> >
<HomeImage {siteLogo ? (
sources={[siteLogo]} <img
src={siteLogo}
alt={siteName} alt={siteName}
loading="eager"
className="h-7 w-7 rounded-full object-cover" className="h-7 w-7 rounded-full object-cover"
imageClassName="h-7 w-7 rounded-full object-cover" />
fallback={ ) : (
<span aria-hidden="true" className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-[12px] font-semibold text-white dark:bg-white dark:text-black"> <span className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-[12px] font-semibold text-white dark:bg-white dark:text-black">
{siteName.slice(0, 1).toUpperCase()} {siteName.slice(0, 1).toUpperCase()}
</span> </span>
} )}
/>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right">{siteName}</TooltipContent> <TooltipContent side="right">{siteName}</TooltipContent>

View File

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

View File

@ -7,7 +7,6 @@ import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types"; import type { ChatMessage } from "@/lib/types";
import { cn, sanitizeText } from "@/lib/utils"; import { cn, sanitizeText } from "@/lib/utils";
import { extractImageUrlsFromText } from "@/lib/image-url"; import { extractImageUrlsFromText } from "@/lib/image-url";
import { getGatewayErrorPageMessage } from "@/lib/request-error";
import { useDataStream } from "./data-stream-provider"; import { useDataStream } from "./data-stream-provider";
import { DocumentToolResult } from "./document"; import { DocumentToolResult } from "./document";
import { DocumentPreview } from "./document-preview"; import { DocumentPreview } from "./document-preview";
@ -522,7 +521,7 @@ const PurePreviewMessage = ({
const displayModelName = extractedModelName || inferredFallbackModelName; const displayModelName = extractedModelName || inferredFallbackModelName;
const filteredText = const filteredText =
message.role === "assistant" message.role === "assistant"
? getGatewayErrorPageMessage(contentWithoutBadge) || appendImageDoneSummary(contentWithoutBadge) ? appendImageDoneSummary(contentWithoutBadge)
: contentWithoutBadge; : contentWithoutBadge;
const useAssistantTypewriter = const useAssistantTypewriter =
message.role === "assistant" && message.role === "assistant" &&

View File

@ -9,7 +9,6 @@ import { Button } from "./ui/button";
import { X, Download, Target, Paintbrush } from "lucide-react"; import { X, Download, Target, Paintbrush } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ImageAnnotator } from "./image-annotator"; import { ImageAnnotator } from "./image-annotator";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
export const PreviewAttachment = ({ export const PreviewAttachment = ({
attachment, attachment,
@ -74,11 +73,18 @@ export const PreviewAttachment = ({
// 下载图片函数 // 下载图片函数
const handleDownload = async () => { const handleDownload = async () => {
try { try {
await downloadOriginalImage({ url, name: name ?? "image" }); const response = await fetch(url);
toast.success("原图已开始下载"); const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = name ?? `image-${Date.now()}.jpg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
} catch (error) { } catch (error) {
console.error("下载失败:", error); console.error("下载失败:", error);
toast.error(getCanvasImageDownloadErrorMessage(error));
} }
}; };

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { import {
ArrowLeft, ArrowLeft,
@ -2074,9 +2073,18 @@ export function SceneFusionView({
const downloadImage = async (url: string, filename: string) => { const downloadImage = async (url: string, filename: string) => {
try { try {
await downloadOriginalImage({ url, name: filename }); const response = await fetch(url);
} catch (error) { const blob = await response.blob();
toast.error(getCanvasImageDownloadErrorMessage(error)); const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
} catch {
window.open(url, "_blank");
} }
}; };

View File

@ -1,33 +0,0 @@
# 旧存储停用与七牛上传核查
## s3.1080p.hk
该存储已永久停用,文件不可恢复。它与 2026 年 10 月 14 日迁入七牛的阿里云 OSS 资源不是同一套存储。
- 首页精选只展示原文件仍在有效存储上的记录,统计和分页按同一条件计算。
- 历史生成记录、聊天、画布节点保留。旧图片显示永久失效说明,不再发起下载、归档或上传到旧桶。
- 单图下载和代理返回 HTTP 410 / `storage_retired`;批量下载将对应文件记为失败,不重试旧源。
- 其他存储的临时加载失败仍展示错误卡片和重试入口。
- 黑色 Logo 为 `public/brand/logo-black.png`;白色版使用 SVG 色彩滤镜保留同一 PNG 的透明度和轮廓。两者随应用发布,不依赖对象存储。
一次性配置整理:在生产运行目录执行 `node scripts/storage/retire-1080p-storage.cjs` 预览,增加 `--apply` 停用旧配置、取消失效图片的精选标记并更新 Logo。执行前将原字段保存至 `/var/lib/ai-chatbot/retired-storage-20261103/`。该操作不删除图片记录或对象文件。
2026-11-03 核查:旧源生成记录 67,267 条;精选 65 条中 59 条来自旧源;旧源图片归档任务为 0。默认上传存储为七牛 `gongxiangpan-sc`。
## 2026-10-14 上传峰值
统计时区 UTC+8,GB 按十进制计算。数据来自七牛只读上传统计、对象清单和生产机迁移记录。
| 范围 | 对象数 | 字节数 | GB |
| --- | ---: | ---: | ---: |
| 七牛当天上传流量 | — | 825,113,404,030 | 825.11 |
| 当天写入且仍存在的 `legacy-oss/demo-sparkai/` | 409,821 | 812,788,883,061 | 812.79 |
| 当天写入且仍存在的 `legacy-s3/gongxiangpan/` | 539 | 865,360,590 | 0.87 |
| 当天写入且仍存在的其他目录 | 3,382 | 9,735,597,543 | 9.74 |
| 上传流量与当天现存对象大小差额 | — | 1,723,562,836 | 1.72 |
813.65 GB 位于本项目历史迁移目录。主要写入发生在 19:00–21:00。源分别为 `demo-sparkai.oss-cn-shanghai.aliyuncs.com` 和 `s3.jiansun.vip/gongxiangpan`,符合仓库迁移脚本的目标路径。
生产机 `/var/lib/ai-chatbot/aliyun-qiniu-20261014/verified.jsonl` 有 349,784 个去重后的目标对象、629,920,510,316 字节(629.92 GB)的逐对象验证记录。当前迁移目录比该回执多 60,576 个对象、183.73 GB;目录归属能够确认,现有回执没有覆盖这些对象。
桶由多个业务共用。其他目录中 `uploads/` 为 7.97 GB,其余包含 `dabi/`、`exports/`、`accio-ghost/` 和 `shared-drive/`,不能全部归为画布新生成资源。上传统计包含传输量,对象清单反映现存对象,不足以单独解释最后 1.72 GB 的差额。本次未删除或改动七牛对象。

View File

@ -1,83 +0,0 @@
import { sql, type SQL } from "drizzle-orm";
export function parseMediaQuery(params: URLSearchParams) {
const integer = (key: string, fallback: number, max: number) => {
const value = Number(params.get(key));
return Number.isInteger(value) && value > 0 ? Math.min(value, max) : fallback;
};
const date = (key: string) => {
const value = params.get(key) || "";
return /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
};
return {
page: integer("page", 1, 100000),
pageSize: integer("pageSize", 40, 100),
search: (params.get("search") || "").trim().slice(0, 128),
type: ["image", "video"].includes(params.get("type") || "") ? params.get("type")! : "all",
model: params.get("model") || "",
featured: params.get("featured") === "true",
startDate: date("startDate"),
endDate: date("endDate"),
};
}
export function mediaRecordsQuery(params: ReturnType<typeof parseMediaQuery>) {
const conditions: SQL[] = [sql`true`];
if (params.search)
conditions.push(
sql`exists (select 1 from "User" u where u.id=m."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`,
);
if (params.startDate) conditions.push(sql`m."createdAt" >= ${params.startDate}::date`);
if (params.endDate)
conditions.push(sql`m."createdAt" < ${params.endDate}::date + interval '1 day'`);
if (params.model) conditions.push(sql`(m."modelId" = ${params.model} or m."modelId" in (select id::text from "AiModel" where "modelId"=${params.model}))`);
const where = sql.join(conditions, sql` and `);
const videoFilters: SQL[] = [sql`true`];
if (params.startDate)
videoFilters.push(sql`v."createdAt">=${params.startDate}::date - interval '8 hours'`);
if (params.endDate)
videoFilters.push(sql`v."createdAt"<${params.endDate}::date+interval '16 hours'`);
if (params.model) videoFilters.push(sql`(v."video_metadata"->>'model'=${params.model} or v."video_metadata"->>'model' in (select id::text from "AiModel" where "modelId"=${params.model}))`);
if (params.search)
videoFilters.push(
sql`exists (select 1 from "User" u where u.id=c."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`,
);
return sql`
with video_candidates as materialized (
select v.id, v."chatId", v."createdAt" + interval '8 hours' as "createdAt", v."video_metadata", c."userId",
coalesce(nullif(v."permanent_video_url", ''), nullif(v."temporary_video_url", ''),
(regexp_match(t.body, '<video[^>]+src="([^"]+)"', 'i'))[1],
(regexp_match(t.body, '(https?://[^[:space:]"<>]+\\.mp4[^[:space:]"<>)]*)', 'i'))[1]) as url
from "Message_v2" v join "Chat" c on c.id=v."chatId"
cross join lateral (select case when jsonb_typeof(v.parts::jsonb)='array'
then (select string_agg(p->>'text', E'\\n') from jsonb_array_elements(v.parts::jsonb) p)
else v.parts::jsonb #>> '{}' end as body) t
where ${params.type !== "image" && !params.featured} and v.role='assistant'
and ${sql.join(videoFilters, sql` and `)}
and (v."permanent_video_url" is not null or v."temporary_video_url" is not null or v.parts::text ilike '%<video%' or v.parts::text ilike '%.mp4%')
), media as not materialized (
select g.id, 'image'::text as kind, g."createdAt", g."userId", g."modelId", null::text as url
from "GeneratedImage" g where ${params.type !== "video"} and (${!params.featured} or g."isFeatured")
union all
select id, 'video', "createdAt", "userId", "video_metadata"->>'model', url
from video_candidates where url is not null
), filtered as not materialized (select * from media m where ${where}),
totals as (select count(*)::int as total, count(*) filter(where kind='image')::int as images, count(*) filter(where kind='video')::int as videos from filtered),
page_rows as (
select * from filtered order by "createdAt" desc, kind, id desc
limit ${params.pageSize} offset ${(params.page - 1) * params.pageSize}
)
select t.total, t.images as "totalImages", t.videos as "totalVideos",
coalesce((select jsonb_agg(jsonb_build_object(
'id', case when p.kind='video' then 'video-'||p.id::text else p.id::text end,
'recordType',p.kind,'userId',p."userId",'userName',u.name,'userEmail',u.email,'userDepartment',u.department,
'createdAt',p."createdAt",'modelId',p."modelId",'chatId',coalesce(g."chatId",v."chatId"),
'thumbnailUrl',coalesce(p.url,g."thumbnailUrl"),'originalUrl',coalesce(p.url,g."originalUrl"),'videoUrl',p.url,
'prompt',coalesce(g.prompt,v."video_metadata"->>'prompt'),'imageSize',coalesce(g."imageSize",v."video_metadata"->>'resolution'),
'isFeatured',coalesce(g."isFeatured",false),'featuredCategory',g."featuredCategory",'featuredAt',g."featuredAt" + interval '8 hours'
) order by p."createdAt" desc,p.kind,p.id desc)
from page_rows p left join "User" u on u.id=p."userId"
left join "GeneratedImage" g on p.kind='image' and g.id=p.id
left join "Message_v2" v on p.kind='video' and v.id=p.id),'[]'::jsonb) as records
from totals t`;
}

View File

@ -1,31 +0,0 @@
// One queue for the records screen: visible previews get four network slots.
const waiting: Array<() => void> = [];
let active = 0;
function pump() {
while (active < 4 && waiting.length) waiting.shift()?.();
}
export function queuePreview(start: (release: () => void) => void) {
let released = false;
let started = false;
const release = () => {
if (released) return;
released = true;
if (started) active--;
else {
const index = waiting.indexOf(run);
if (index >= 0) waiting.splice(index, 1);
}
pump();
};
const run = () => {
started = true;
active++;
start(release);
};
waiting.push(run);
pump();
// Active fetches release their slot in finally, after abort has settled.
return () => {
if (!started) release();
};
}

View File

@ -1,7 +0,0 @@
export function formatRecordTime(value: string) {
// The admin API normalizes each storage convention to Beijing wall-clock time.
return value
.replace("T", " ")
.replace(/(?:\.\d+)?Z$/, "")
.slice(0, 16);
}

View File

@ -1,15 +0,0 @@
import { extractImageUrlsFromText } from "@/lib/image-url";
import { messageText } from "./replacement-run-recovery";
type Message = { id: string; role: string; parts?: unknown[] };
export function getSceneRunResult(messages: Message[], requestMessageId: string) {
const index = messages.findIndex((message) => message.id === requestMessageId && message.role === "user");
if (index < 0) return null;
const replies = messages.slice(index + 1);
// A later user turn belongs to another run.
if (replies.some((message) => message.role === "user")) return null;
const urls = new Set(replies.filter((message) => message.role === "assistant")
.flatMap((message) => extractImageUrlsFromText(messageText(message))));
return { count: urls.size };
}

View File

@ -1,5 +1,4 @@
import "server-only"; import "server-only";
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
import { createOpenAI } from "@ai-sdk/openai"; import { createOpenAI } from "@ai-sdk/openai";
import { import {
@ -419,7 +418,7 @@ export async function getDynamicModel(
): Promise<LanguageModel | null> { ): Promise<LanguageModel | null> {
try { try {
const model = await getAiModelById({ id: modelId }); const model = await getAiModelById({ id: modelId });
if (!model || !model.isEnabled || (model.supportsImageGen && isImageModelBalanceBlocked(model))) { if (!model || !model.isEnabled) {
logger.warn("model unavailable", { modelId }); logger.warn("model unavailable", { modelId });
return null; return null;
} }

View File

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

View File

@ -48,7 +48,6 @@ import {
VIP98K_REFERENCE_STAGING_KEY_PREFIX, VIP98K_REFERENCE_STAGING_KEY_PREFIX,
} from "@/lib/ai/vip98k-reference-staging"; } from "@/lib/ai/vip98k-reference-staging";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { import {
IMAGE_UPLOAD_MAX_BYTES, IMAGE_UPLOAD_MAX_BYTES,
@ -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,

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@ -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: '更新画布失败' };

View File

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

View File

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

View File

@ -1,2 +0,0 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({ testDir: './routes', testMatch: /retired-storage\.test\.ts/, workers: 1, reporter: 'list' });

View File

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

View File

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

View File

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

View File

@ -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 "![image](https://fixture.invalid/ok.png)";
} });
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 "![image](https://fixture.invalid/ok.png)"; } }); const h = harness({ generate: id => { if (id === "primary") throw Object.assign(new Error("channel unavailable"), { status: 503 }); return "![image](https://fixture.invalid/ok.png)"; } });

View File

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