Compare commits
20 Commits
codex/sku-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 00cef71be7 | |||
| 525e105876 | |||
| 5371ae2617 | |||
| af8189908d | |||
| 6053271352 | |||
| 6b7b24a86e | |||
| c431a574c8 | |||
| 1daf08a964 | |||
| 348cf4cf5d | |||
| a43a51adfc | |||
| 6ba5d9606d | |||
| 02b9575a47 | |||
| 349735601c | |||
| e837788d7a | |||
|
|
dfab95a654 | ||
| c35f5fc5a8 | |||
|
|
e6c6e19e5a | ||
| bb9d02a053 | |||
|
|
d9b06cf0b3 | ||
| 086c58fcc3 |
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,7 @@ import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-c
|
|||||||
import type { ChatMessage } from "@/lib/types";
|
import type { ChatMessage } from "@/lib/types";
|
||||||
import { convertMessagesWithImages } from "./image-media";
|
import { convertMessagesWithImages } from "./image-media";
|
||||||
import { isProviderBalanceError } from "@/lib/request-error";
|
import { isProviderBalanceError } from "@/lib/request-error";
|
||||||
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
|
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
|
||||||
|
|
||||||
const chatApiLogger = createLogger("chat.imageFailover");
|
const chatApiLogger = createLogger("chat.imageFailover");
|
||||||
|
|
||||||
@ -326,7 +326,7 @@ export function buildImageFallbackCandidates(
|
|||||||
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
|
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(item.model)) {
|
if (isImageFallbackModelCoolingDown(item.model.id) || isImageModelBalanceBlocked(item.model)) {
|
||||||
const cooldown = imageFailoverModelCooldown.get(item.model.id);
|
const cooldown = imageFailoverModelCooldown.get(item.model.id);
|
||||||
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
||||||
return false;
|
return false;
|
||||||
@ -488,7 +488,7 @@ export function getImageCrossProviderCandidates(
|
|||||||
}
|
}
|
||||||
if (m.model.id === options.currentModelId) return false;
|
if (m.model.id === options.currentModelId) return false;
|
||||||
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
|
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
|
||||||
if (isImageFallbackModelCoolingDown(m.model.id) || isImageFallbackModelBalanceCoolingDown(m.model)) {
|
if (isImageFallbackModelCoolingDown(m.model.id) || isImageModelBalanceBlocked(m.model)) {
|
||||||
const cooldown = imageFailoverModelCooldown.get(m.model.id);
|
const cooldown = imageFailoverModelCooldown.get(m.model.id);
|
||||||
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -336,6 +336,7 @@ import {
|
|||||||
} from "./_lib/image-media";
|
} from "./_lib/image-media";
|
||||||
import {
|
import {
|
||||||
buildImageFallbackCandidates,
|
buildImageFallbackCandidates,
|
||||||
|
countChatMessageImageInputs,
|
||||||
generateImageWithFailoverCandidate,
|
generateImageWithFailoverCandidate,
|
||||||
getImageCrossProviderCandidates,
|
getImageCrossProviderCandidates,
|
||||||
IMAGE_FAILOVER_PROVIDER_ALLOWLIST,
|
IMAGE_FAILOVER_PROVIDER_ALLOWLIST,
|
||||||
@ -344,6 +345,8 @@ import {
|
|||||||
setImageFallbackModelCooldown,
|
setImageFallbackModelCooldown,
|
||||||
shouldCooldownImageFallbackModel,
|
shouldCooldownImageFallbackModel,
|
||||||
} from "./_lib/image-failover";
|
} from "./_lib/image-failover";
|
||||||
|
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
|
||||||
|
import { isProviderBalanceError } from "@/lib/request-error";
|
||||||
import {
|
import {
|
||||||
buildAgentSceneWorkflowPrompt,
|
buildAgentSceneWorkflowPrompt,
|
||||||
buildAgentSlotPrompt,
|
buildAgentSlotPrompt,
|
||||||
@ -8769,6 +8772,19 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (/^[0-9a-f-]{36}$/i.test(finalModelId)) {
|
||||||
|
const requested = await getAiModelById({ id: finalModelId });
|
||||||
|
if (requested?.supportsImageGen && isImageModelBalanceBlocked(requested)) {
|
||||||
|
const replacement = resolveConfiguredImageBalanceFallback(requested, await getEnabledAiModelsWithProvider(), {
|
||||||
|
imageSize: effectiveImageGenerationParams.imageSize,
|
||||||
|
aspectRatio: effectiveImageGenerationParams.aspectRatio,
|
||||||
|
referenceImageCount: countChatMessageImageInputs(uiMessagesForModel.filter(item => item.role === "user").slice(-1)),
|
||||||
|
});
|
||||||
|
if (!replacement) return new ChatSDKError("bad_request:api", "当前生图线路余额不足,已暂停新请求;请切换可用模型,已有任务和图片会保留。").toResponse();
|
||||||
|
finalModelId = replacement.model.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否是 UUID 格式(数据库中的模型 ID)
|
// 检查是否是 UUID 格式(数据库中的模型 ID)
|
||||||
const isUUID =
|
const isUUID =
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||||
@ -10002,7 +10018,10 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
let fallbackSuccess = false;
|
let fallbackSuccess = false;
|
||||||
const triedFallbackModelIds = new Set<string>();
|
const triedFallbackModelIds = new Set<string>();
|
||||||
|
const balanceBlockedProviders = new Set<string>();
|
||||||
|
if (isProviderBalanceError(primaryError) && currentModelEntry?.provider.id) balanceBlockedProviders.add(currentModelEntry.provider.id);
|
||||||
for (const candidate of fallbackCandidates) {
|
for (const candidate of fallbackCandidates) {
|
||||||
|
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
|
||||||
try {
|
try {
|
||||||
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||||
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
|
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||||
@ -10025,6 +10044,7 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.info(`[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!`);
|
chatApiLogger.info(`[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!`);
|
||||||
break;
|
break;
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
|
if (isProviderBalanceError(fallbackError)) balanceBlockedProviders.add(candidate.provider.id);
|
||||||
const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError);
|
const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError);
|
||||||
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
|
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
|
||||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||||
@ -10073,9 +10093,10 @@ export async function POST(request: Request) {
|
|||||||
currentModelKey: actualModelId,
|
currentModelKey: actualModelId,
|
||||||
currentProviderId: alibabaModelConfig?.providerId,
|
currentProviderId: alibabaModelConfig?.providerId,
|
||||||
providerPriority,
|
providerPriority,
|
||||||
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")));
|
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")) && !balanceBlockedProviders.has(candidate.provider.id));
|
||||||
|
|
||||||
for (const candidate of crossProviderCandidates) {
|
for (const candidate of crossProviderCandidates) {
|
||||||
|
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
|
||||||
try {
|
try {
|
||||||
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||||
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
|
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||||
@ -10157,6 +10178,7 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.info(`[Chat API] ✅ Cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`);
|
chatApiLogger.info(`[Chat API] ✅ Cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||||
break;
|
break;
|
||||||
} catch (crossError) {
|
} catch (crossError) {
|
||||||
|
if (isProviderBalanceError(crossError)) balanceBlockedProviders.add(candidate.provider.id);
|
||||||
const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
|
const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
|
||||||
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
|
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
|
||||||
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
|
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@ -316,7 +317,12 @@ export default function GalleryPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex flex-wrap justify-center gap-6 px-4">
|
<div className="flex flex-wrap justify-center gap-6 px-4">
|
||||||
{images.map((img) => (
|
{images.map((img) => isRetiredStorageUrl(img.url) ? (
|
||||||
|
<div key={img.id} className="flex h-[270px] w-[200px] shrink-0 flex-col justify-center rounded-lg border bg-muted p-4 text-center text-sm text-muted-foreground">
|
||||||
|
<p>{RETIRED_STORAGE_MESSAGE}</p>
|
||||||
|
<p className="mt-3 line-clamp-3 text-xs">{img.alt}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
key={img.id}
|
key={img.id}
|
||||||
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
|
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
|
||||||
@ -325,7 +331,7 @@ export default function GalleryPage() {
|
|||||||
>
|
>
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<Image
|
<Image
|
||||||
src={img.thumbnailUrl || img.url}
|
src={isRetiredStorageUrl(img.thumbnailUrl) ? img.url : img.thumbnailUrl || img.url}
|
||||||
alt={img.alt}
|
alt={img.alt}
|
||||||
width={200}
|
width={200}
|
||||||
height={270}
|
height={270}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
import { ChatSDKError } from "@/lib/errors";
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { aiModelHealthEvent } from "@/lib/db/schema";
|
||||||
|
import { and, desc, eq, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
listAiModelHealthEvents,
|
|
||||||
restoreAiModelHealth,
|
restoreAiModelHealth,
|
||||||
} from "@/lib/ai/runtime/model-health";
|
} from "@/lib/ai/runtime/model-health";
|
||||||
|
|
||||||
@ -15,14 +17,30 @@ export async function GET(request: Request) {
|
|||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const modelId = searchParams.get("modelId") || undefined;
|
const modelId = searchParams.get("modelId");
|
||||||
const limit = Number(searchParams.get("limit") || 80);
|
const providerId = searchParams.get("providerId");
|
||||||
const events = await listAiModelHealthEvents({
|
const rawLimit = Number(searchParams.get("limit") || 20);
|
||||||
modelId,
|
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(200, Math.floor(rawLimit))) : 20;
|
||||||
limit: Number.isFinite(limit) ? limit : 80,
|
const rawPage = Number(searchParams.get("page") || 1);
|
||||||
});
|
const page = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1;
|
||||||
|
const filters = [];
|
||||||
return Response.json({ events });
|
if (modelId) filters.push(eq(aiModelHealthEvent.modelId, modelId));
|
||||||
|
if (providerId) filters.push(eq(aiModelHealthEvent.providerId, providerId));
|
||||||
|
if (searchParams.get("eventType")) filters.push(eq(aiModelHealthEvent.eventType, searchParams.get("eventType")!));
|
||||||
|
const start = searchParams.get("startDate");
|
||||||
|
const end = searchParams.get("endDate");
|
||||||
|
if (start && /^\d{4}-\d{2}-\d{2}$/.test(start)) filters.push(sql`${aiModelHealthEvent.createdAt}>=${start}::date`);
|
||||||
|
if (end && /^\d{4}-\d{2}-\d{2}$/.test(end)) filters.push(sql`${aiModelHealthEvent.createdAt}<${end}::date+interval '1 day'`);
|
||||||
|
const events = await db.select({
|
||||||
|
id: aiModelHealthEvent.id, modelId: aiModelHealthEvent.modelId, modelDisplayName: aiModelHealthEvent.modelDisplayName,
|
||||||
|
modelRuntimeId: aiModelHealthEvent.modelRuntimeId, providerName: aiModelHealthEvent.providerName,
|
||||||
|
providerId: aiModelHealthEvent.providerId, eventType: aiModelHealthEvent.eventType,
|
||||||
|
errorMessage: aiModelHealthEvent.errorMessage, elapsedMs: aiModelHealthEvent.elapsedMs,
|
||||||
|
consecutiveFailures: aiModelHealthEvent.consecutiveFailures, autoDisabled: aiModelHealthEvent.autoDisabled,
|
||||||
|
counted: aiModelHealthEvent.counted, createdAt: aiModelHealthEvent.createdAt,
|
||||||
|
}).from(aiModelHealthEvent).where(and(...filters)).orderBy(desc(aiModelHealthEvent.createdAt), desc(aiModelHealthEvent.id))
|
||||||
|
.limit(limit + 1).offset((page - 1) * limit);
|
||||||
|
return Response.json({ events: events.slice(0, limit), hasMore: events.length > limit, page });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ChatSDKError) {
|
if (error instanceof ChatSDKError) {
|
||||||
return error.toResponse();
|
return error.toResponse();
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { aiModel, aiProvider } from "@/lib/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
import {
|
import {
|
||||||
getAllAiModels,
|
|
||||||
getAiModelById,
|
getAiModelById,
|
||||||
getAiModelsByProviderId,
|
|
||||||
createAiModel,
|
createAiModel,
|
||||||
updateAiModel,
|
updateAiModel,
|
||||||
deleteAiModel,
|
deleteAiModel,
|
||||||
@ -154,8 +155,14 @@ export async function GET(request: Request) {
|
|||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
const providerId = searchParams.get("providerId");
|
const providerId = searchParams.get("providerId");
|
||||||
|
|
||||||
|
if (searchParams.get("view") === "options") {
|
||||||
|
const models = await db.select({ id: aiModel.id, modelId: aiModel.modelId, displayName: aiModel.displayName,
|
||||||
|
providerName: aiProvider.name }).from(aiModel).leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id));
|
||||||
|
return Response.json({ models: sortModelsForDisplay(models) });
|
||||||
|
}
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
const model = await getAiModelById({ id });
|
const model = await db.query.aiModel.findFirst({ where: eq(aiModel.id, id) });
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return new ChatSDKError("bad_request:api", "Model not found").toResponse();
|
return new ChatSDKError("bad_request:api", "Model not found").toResponse();
|
||||||
}
|
}
|
||||||
@ -163,12 +170,19 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (providerId) {
|
if (providerId) {
|
||||||
const models = await getAiModelsByProviderId({ providerId });
|
const models = await db.select().from(aiModel).where(eq(aiModel.providerId, providerId));
|
||||||
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = await getAllAiModels();
|
const models = await db.select().from(aiModel);
|
||||||
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
const normalized = models.map(normalizeModel);
|
||||||
|
const output = searchParams.get("view") === "summary" ? normalized.map(model => {
|
||||||
|
const { capabilities, defaultParams, imageGenConfig, description, ...summary } = model;
|
||||||
|
const fields = ["kind", "mediaType", "modelType", "outputType", "supportedModes"];
|
||||||
|
return { ...summary, healthLastError: summary.healthLastError?.slice(0, 1200),
|
||||||
|
capabilities: Object.fromEntries(fields.filter(key => capabilities?.[key] !== undefined).map(key => [key, capabilities[key]])) };
|
||||||
|
}) : normalized;
|
||||||
|
return Response.json({ models: sortModelsForDisplay(output) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ChatSDKError) {
|
if (error instanceof ChatSDKError) {
|
||||||
return error.toResponse();
|
return error.toResponse();
|
||||||
|
|||||||
@ -1,237 +1,31 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { chat, generatedImage, message, user } from "@/lib/db/schema";
|
import { generatedImage, user } from "@/lib/db/schema";
|
||||||
import { eq, desc, sql, and, gte, lte, like, or, isNotNull } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
function extractVideoUrlFromParts(parts: unknown): string | null {
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
const text = Array.isArray(parts)
|
import { mediaRecordsQuery, parseMediaQuery } from "@/lib/admin/media-records";
|
||||||
? parts
|
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
||||||
.map((part) => (part && typeof part === "object" && "text" in part ? String((part as any).text || "") : ""))
|
|
||||||
.join("\n")
|
|
||||||
: typeof parts === "string"
|
|
||||||
? parts
|
|
||||||
: "";
|
|
||||||
|
|
||||||
if (!text) return null;
|
|
||||||
|
|
||||||
const htmlMatch = text.match(/<video[^>]+src="([^"]+)"/i);
|
|
||||||
if (htmlMatch?.[1]) return htmlMatch[1];
|
|
||||||
|
|
||||||
const markdownMatch = text.match(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/i);
|
|
||||||
if (markdownMatch?.[1]) return markdownMatch[1];
|
|
||||||
|
|
||||||
const plainMatch = text.match(/(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/i);
|
|
||||||
return plainMatch?.[1] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await requireAdmin();
|
||||||
|
const operator = await db.query.user.findFirst({ where: eq(user.id, session.user.id) });
|
||||||
if (!session?.user?.id) {
|
if (operator?.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
const query = parseMediaQuery(request.nextUrl.searchParams);
|
||||||
|
if (query.startDate && query.endDate && query.startDate > query.endDate) {
|
||||||
|
return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
const [result] = await db.execute(mediaRecordsQuery(query));
|
||||||
// 检查是否为管理员
|
const images = await resolveArchivedImageUrls(result.records);
|
||||||
const currentUser = await db.query.user.findFirst({
|
const total = Number(result.total);
|
||||||
where: eq(user.id, session.user.id),
|
return NextResponse.json({ images, pagination: { page: query.page, pageSize: query.pageSize, total,
|
||||||
});
|
totalPages: Math.ceil(total / query.pageSize), totalImages: Number(result.totalImages), totalVideos: Number(result.totalVideos) } });
|
||||||
|
|
||||||
if (currentUser?.role !== "admin") {
|
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取查询参数
|
|
||||||
const searchParams = request.nextUrl.searchParams;
|
|
||||||
const page = Number.parseInt(searchParams.get("page") || "1");
|
|
||||||
const pageSize = Number.parseInt(searchParams.get("pageSize") || "200");
|
|
||||||
const offset = (page - 1) * pageSize;
|
|
||||||
const search = searchParams.get("search") || "";
|
|
||||||
const startDate = searchParams.get("startDate");
|
|
||||||
const endDate = searchParams.get("endDate");
|
|
||||||
|
|
||||||
// 构建查询条件
|
|
||||||
const imageConditions = [];
|
|
||||||
const videoConditions = [
|
|
||||||
eq(message.role, "assistant"),
|
|
||||||
or(
|
|
||||||
isNotNull(message.temporaryVideoUrl),
|
|
||||||
isNotNull(message.permanentVideoUrl),
|
|
||||||
sql`${message.parts}::text ILIKE '%<video%'`,
|
|
||||||
sql`${message.parts}::text ILIKE '%.mp4%'`
|
|
||||||
),
|
|
||||||
];
|
|
||||||
if (search) {
|
|
||||||
// 搜索用户名或邮箱
|
|
||||||
const matchingUsers = await db
|
|
||||||
.select({ id: user.id })
|
|
||||||
.from(user)
|
|
||||||
.where(or(
|
|
||||||
like(user.name, `%${search}%`),
|
|
||||||
like(user.email, `%${search}%`)
|
|
||||||
));
|
|
||||||
|
|
||||||
if (matchingUsers.length > 0) {
|
|
||||||
const matchedUserIds = matchingUsers.map((u) => u.id);
|
|
||||||
imageConditions.push(
|
|
||||||
sql`${generatedImage.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
|
|
||||||
);
|
|
||||||
videoConditions.push(
|
|
||||||
sql`${chat.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 没有匹配的用户,返回空结果
|
|
||||||
return NextResponse.json({
|
|
||||||
images: [],
|
|
||||||
pagination: { page, pageSize, total: 0, totalPages: 0 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startDate) {
|
|
||||||
const start = new Date(startDate);
|
|
||||||
imageConditions.push(gte(generatedImage.createdAt, start));
|
|
||||||
videoConditions.push(gte(message.createdAt, start));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate) {
|
|
||||||
const end = new Date(endDate);
|
|
||||||
end.setHours(23, 59, 59, 999);
|
|
||||||
imageConditions.push(lte(generatedImage.createdAt, end));
|
|
||||||
videoConditions.push(lte(message.createdAt, end));
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageWhereClause = imageConditions.length > 0 ? and(...imageConditions) : undefined;
|
|
||||||
const videoWhereClause = videoConditions.length > 0 ? and(...videoConditions) : undefined;
|
|
||||||
|
|
||||||
// 查询图片总数
|
|
||||||
const imageCountResult = await db
|
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
|
||||||
.from(generatedImage)
|
|
||||||
.where(imageWhereClause);
|
|
||||||
|
|
||||||
// 查询视频总数
|
|
||||||
const videoCountResult = await db
|
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
|
||||||
.from(message)
|
|
||||||
.innerJoin(chat, eq(message.chatId, chat.id))
|
|
||||||
.where(videoWhereClause);
|
|
||||||
|
|
||||||
const totalImages = imageCountResult[0]?.count || 0;
|
|
||||||
const totalVideos = videoCountResult[0]?.count || 0;
|
|
||||||
const total = totalImages + totalVideos;
|
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
|
||||||
|
|
||||||
const fetchLimit = Math.max(page * pageSize, pageSize);
|
|
||||||
|
|
||||||
// 查询图片列表(包含用户信息)
|
|
||||||
const imageRows = await db
|
|
||||||
.select({
|
|
||||||
id: generatedImage.id,
|
|
||||||
userId: generatedImage.userId,
|
|
||||||
userName: user.name,
|
|
||||||
userEmail: user.email,
|
|
||||||
userDepartment: user.department,
|
|
||||||
thumbnailUrl: generatedImage.thumbnailUrl,
|
|
||||||
originalUrl: generatedImage.originalUrl,
|
|
||||||
imageSize: generatedImage.imageSize,
|
|
||||||
prompt: generatedImage.prompt,
|
|
||||||
modelId: generatedImage.modelId,
|
|
||||||
isFeatured: generatedImage.isFeatured,
|
|
||||||
featuredCategory: generatedImage.featuredCategory,
|
|
||||||
featuredAt: generatedImage.featuredAt,
|
|
||||||
createdAt: generatedImage.createdAt,
|
|
||||||
chatId: generatedImage.chatId,
|
|
||||||
recordType: sql<"image">`'image'`,
|
|
||||||
videoUrl: sql<string | null>`null`,
|
|
||||||
})
|
|
||||||
.from(generatedImage)
|
|
||||||
.leftJoin(user, eq(generatedImage.userId, user.id))
|
|
||||||
.where(imageWhereClause)
|
|
||||||
.orderBy(desc(generatedImage.createdAt))
|
|
||||||
.limit(fetchLimit);
|
|
||||||
|
|
||||||
// 查询视频列表(来自消息表)
|
|
||||||
const videoRows = await db
|
|
||||||
.select({
|
|
||||||
id: message.id,
|
|
||||||
userId: chat.userId,
|
|
||||||
userName: user.name,
|
|
||||||
userEmail: user.email,
|
|
||||||
userDepartment: user.department,
|
|
||||||
temporaryVideoUrl: message.temporaryVideoUrl,
|
|
||||||
permanentVideoUrl: message.permanentVideoUrl,
|
|
||||||
parts: message.parts,
|
|
||||||
videoMetadata: message.videoMetadata,
|
|
||||||
createdAt: message.createdAt,
|
|
||||||
chatId: message.chatId,
|
|
||||||
})
|
|
||||||
.from(message)
|
|
||||||
.innerJoin(chat, eq(message.chatId, chat.id))
|
|
||||||
.leftJoin(user, eq(chat.userId, user.id))
|
|
||||||
.where(videoWhereClause)
|
|
||||||
.orderBy(desc(message.createdAt))
|
|
||||||
.limit(fetchLimit);
|
|
||||||
|
|
||||||
const normalizedVideos = videoRows
|
|
||||||
.map((item) => {
|
|
||||||
const metadata = (item.videoMetadata as Record<string, any> | null) || {};
|
|
||||||
const videoUrl =
|
|
||||||
item.permanentVideoUrl ||
|
|
||||||
item.temporaryVideoUrl ||
|
|
||||||
extractVideoUrlFromParts(item.parts);
|
|
||||||
|
|
||||||
if (!videoUrl) return null;
|
|
||||||
|
|
||||||
const resolution =
|
|
||||||
typeof metadata.resolution === "string" && metadata.resolution.trim()
|
|
||||||
? metadata.resolution.trim()
|
|
||||||
: null;
|
|
||||||
const prompt =
|
|
||||||
typeof metadata.prompt === "string" && metadata.prompt.trim()
|
|
||||||
? metadata.prompt.trim()
|
|
||||||
: null;
|
|
||||||
const modelId =
|
|
||||||
typeof metadata.model === "string" && metadata.model.trim()
|
|
||||||
? metadata.model.trim()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `video-${item.id}`,
|
|
||||||
sourceMessageId: item.id,
|
|
||||||
userId: item.userId,
|
|
||||||
userName: item.userName,
|
|
||||||
userEmail: item.userEmail,
|
|
||||||
userDepartment: item.userDepartment,
|
|
||||||
thumbnailUrl: videoUrl,
|
|
||||||
originalUrl: videoUrl,
|
|
||||||
videoUrl,
|
|
||||||
imageSize: resolution,
|
|
||||||
prompt,
|
|
||||||
modelId,
|
|
||||||
isFeatured: false,
|
|
||||||
featuredCategory: null,
|
|
||||||
featuredAt: null,
|
|
||||||
createdAt: item.createdAt,
|
|
||||||
chatId: item.chatId,
|
|
||||||
recordType: "video" as const,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((item): item is NonNullable<typeof item> => Boolean(item));
|
|
||||||
|
|
||||||
const merged = [...imageRows, ...normalizedVideos].sort(
|
|
||||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
||||||
);
|
|
||||||
const images = merged.slice(offset, offset + pageSize);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
images,
|
|
||||||
pagination: { page, pageSize, total, totalPages, totalImages, totalVideos }
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Admin Images API] Error:", error);
|
if (error instanceof ChatSDKError) return error.toResponse();
|
||||||
return NextResponse.json({ error: "Failed to fetch images" }, { status: 500 });
|
console.error("[Admin Images API] GET failed", error);
|
||||||
|
return NextResponse.json({ error: "记录加载失败,请重试" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
app/api/admin/media-tasks/route.ts
Normal file
31
app/api/admin/media-tasks/route.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
|
import { parseMediaQuery } from "@/lib/admin/media-records";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const q = parseMediaQuery(request.nextUrl.searchParams);
|
||||||
|
const status = request.nextUrl.searchParams.get("status") || "all";
|
||||||
|
const filters = [sql`true`];
|
||||||
|
if (q.search)
|
||||||
|
filters.push(sql`(u.name ilike ${`%${q.search}%`} or u.email ilike ${`%${q.search}%`})`);
|
||||||
|
if (["pending", "running", "failed", "succeeded"].includes(status))
|
||||||
|
filters.push(sql`t.status=${status}`);
|
||||||
|
if (q.startDate) filters.push(sql`t."createdAt">=${q.startDate}::date - interval '8 hours'`);
|
||||||
|
if (q.endDate) filters.push(sql`t."createdAt"<${q.endDate}::date+interval '16 hours'`);
|
||||||
|
const [result] = await db.execute(sql`with filtered as not materialized (
|
||||||
|
select t.id,u.name as "userName",t.status,t."createdAt"+interval '8 hours' as "createdAt",t.error,coalesce(t.request->>'modelId',t.request->>'model') as model,
|
||||||
|
extract(epoch from (t."completedAt"-t."startedAt"))*1000 as "elapsedMs"
|
||||||
|
from "CanvasImageTask" t left join "User" u on u.id=t."userId" where ${sql.join(filters, sql` and `)}
|
||||||
|
) select (select count(*)::int from filtered) as total,
|
||||||
|
coalesce((select jsonb_agg(p) from (select * from filtered order by "createdAt" desc,id desc limit ${q.pageSize} offset ${(q.page - 1) * q.pageSize}) p),'[]'::jsonb) as tasks`);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) return error.toResponse();
|
||||||
|
return NextResponse.json({ error: "任务记录加载失败,请重试" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -106,6 +106,22 @@ export async function POST(request: Request) {
|
|||||||
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
|
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
|
||||||
: catalogPresets;
|
: catalogPresets;
|
||||||
|
|
||||||
|
if (body?.dryRun === true) {
|
||||||
|
const catalogIds = new Set(catalogPresets.map(preset => preset.modelId));
|
||||||
|
const fields = ["displayName", "description", "supportsVision", "supportsImageGen", "isHidden", "sortOrder", "capabilities", "defaultParams", "imageGenConfig"] as const;
|
||||||
|
const added = presets.filter(preset => !existingModelIds.has(preset.modelId)).map(preset => ({ modelId: preset.modelId, displayName: preset.displayName }));
|
||||||
|
const updated = presets.flatMap(preset => {
|
||||||
|
const existing = existingModels.find(model => model.modelId === preset.modelId);
|
||||||
|
if (!existing) return [];
|
||||||
|
const changedFields: string[] = fields.filter(field => preset[field] !== undefined && JSON.stringify(existing[field]) !== JSON.stringify(preset[field]));
|
||||||
|
if (existing.avatarUrl) changedFields.push("avatarUrl");
|
||||||
|
if (requestedModelIds !== null && !existing.isEnabled) changedFields.push("isEnabled");
|
||||||
|
return changedFields.length ? [{ modelId: preset.modelId, displayName: existing.displayName, changedFields }] : [];
|
||||||
|
});
|
||||||
|
const disabled = requestedModelIds === null ? existingModels.filter(model => !catalogIds.has(model.modelId) && (model.isEnabled || !model.isHidden)).map(model => ({ modelId: model.modelId, displayName: model.displayName })) : [];
|
||||||
|
return NextResponse.json({ preview: { added, updated, disabled }, total: catalogPresets.length });
|
||||||
|
}
|
||||||
|
|
||||||
const addedModels: AiModel[] = [];
|
const addedModels: AiModel[] = [];
|
||||||
const updatedModels: AiModel[] = [];
|
const updatedModels: AiModel[] = [];
|
||||||
const disabledModels: AiModel[] = [];
|
const disabledModels: AiModel[] = [];
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
|
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
|
||||||
|
|
||||||
@ -12,7 +13,6 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
|||||||
"tripo3d.ai",
|
"tripo3d.ai",
|
||||||
"tripo3d.com",
|
"tripo3d.com",
|
||||||
"worldlabs.ai",
|
"worldlabs.ai",
|
||||||
"s3.1080p.hk",
|
|
||||||
"img.yydd.ai",
|
"img.yydd.ai",
|
||||||
"scqny.jiansun.vip",
|
"scqny.jiansun.vip",
|
||||||
"blob.vercel-storage.com",
|
"blob.vercel-storage.com",
|
||||||
@ -25,7 +25,6 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
|||||||
|
|
||||||
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
|
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
|
||||||
"bbyy.ai",
|
"bbyy.ai",
|
||||||
"s3.1080p.hk",
|
|
||||||
"img.yydd.ai",
|
"img.yydd.ai",
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -165,6 +164,9 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
|
|||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const rawUrl = String(searchParams.get("url") || "").trim();
|
const rawUrl = String(searchParams.get("url") || "").trim();
|
||||||
|
if (isRetiredStorageUrl(rawUrl)) {
|
||||||
|
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
||||||
|
}
|
||||||
if (!rawUrl) {
|
if (!rawUrl) {
|
||||||
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -207,7 +207,11 @@ export async function PUT(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
return NextResponse.json({ success: true, data: saved });
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: saved,
|
||||||
|
previousCanvasRevision: existingProject?.updatedAt.toISOString() ?? null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("更新/创建画布失败:", error);
|
console.error("更新/创建画布失败:", error);
|
||||||
|
|||||||
@ -1,4 +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 { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
|
||||||
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";
|
||||||
@ -83,7 +84,11 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
const requestOrigin = getRequestOrigin(request);
|
const requestOrigin = getRequestOrigin(request);
|
||||||
const urls = resolveDownloadUrls(body, requestOrigin);
|
const candidates = resolveDownloadUrls(body, requestOrigin);
|
||||||
|
if (candidates.length && candidates.every(isRetiredStorageUrl)) {
|
||||||
|
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
||||||
|
}
|
||||||
|
const urls = candidates.filter(url => !isRetiredStorageUrl(url));
|
||||||
const requestedUrls = [...urls];
|
const requestedUrls = [...urls];
|
||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage, user, aiModel } from "@/lib/db/schema";
|
import { generatedImage, user, aiModel } from "@/lib/db/schema";
|
||||||
import { eq, desc, sql } from "drizzle-orm";
|
import { and, eq, desc, sql } from "drizzle-orm";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_URL_PATTERN } from "@/lib/storage/retired-storage";
|
||||||
import { cachedPublicJson } from "@/lib/public-api/performance";
|
import { cachedPublicJson } from "@/lib/public-api/performance";
|
||||||
|
|
||||||
const FEATURED_TIMEOUT_MS = 1800;
|
const FEATURED_TIMEOUT_MS = 1800;
|
||||||
@ -57,13 +58,17 @@ export async function GET(request: NextRequest) {
|
|||||||
userName: user.name,
|
userName: user.name,
|
||||||
userEmail: user.email,
|
userEmail: user.email,
|
||||||
userDepartment: user.department,
|
userDepartment: user.department,
|
||||||
modelName: aiModel.displayName,
|
modelName: sql<string | null>`(select ${aiModel.displayName} from ${aiModel}
|
||||||
|
where ${aiModel.modelId} = ${generatedImage.modelId}
|
||||||
|
order by ${aiModel.isEnabled} desc, ${aiModel.createdAt} desc, ${aiModel.id} asc limit 1)`,
|
||||||
totalCount: sql<number>`count(*) over()::int`,
|
totalCount: sql<number>`count(*) over()::int`,
|
||||||
})
|
})
|
||||||
.from(generatedImage)
|
.from(generatedImage)
|
||||||
.leftJoin(user, eq(generatedImage.userId, user.id))
|
.leftJoin(user, eq(generatedImage.userId, user.id))
|
||||||
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
|
.where(and(
|
||||||
.where(eq(generatedImage.isFeatured, true))
|
eq(generatedImage.isFeatured, true),
|
||||||
|
sql`${generatedImage.originalUrl} !~* ${RETIRED_STORAGE_URL_PATTERN}`,
|
||||||
|
))
|
||||||
.orderBy(desc(generatedImage.featuredAt))
|
.orderBy(desc(generatedImage.featuredAt))
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
@ -74,7 +79,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return {
|
return {
|
||||||
images: featuredImages.map((img) => ({
|
images: featuredImages.map((img) => ({
|
||||||
id: img.id,
|
id: img.id,
|
||||||
thumbnailUrl: img.thumbnailUrl,
|
thumbnailUrl: isRetiredStorageUrl(img.thumbnailUrl) ? img.originalUrl : img.thumbnailUrl,
|
||||||
originalUrl: img.originalUrl,
|
originalUrl: img.originalUrl,
|
||||||
imageSize: img.imageSize,
|
imageSize: img.imageSize,
|
||||||
prompt: img.prompt,
|
prompt: img.prompt,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
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";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@ -9,6 +10,7 @@ const MIN_WIDTH = 64;
|
|||||||
const MIN_QUALITY = 40;
|
const MIN_QUALITY = 40;
|
||||||
const MAX_QUALITY = 100;
|
const MAX_QUALITY = 100;
|
||||||
const FETCH_TIMEOUT_MS = 15000;
|
const FETCH_TIMEOUT_MS = 15000;
|
||||||
|
const SMALL_PREVIEW_FETCH_TIMEOUT_MS = 45000;
|
||||||
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
||||||
const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256;
|
const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256;
|
||||||
|
|
||||||
@ -266,6 +268,9 @@ 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 rawUrl = (searchParams.get("url") || "").trim();
|
const rawUrl = (searchParams.get("url") || "").trim();
|
||||||
|
if (isRetiredStorageUrl(rawUrl)) {
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
@ -339,17 +344,28 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchSignal = AbortSignal.any([
|
||||||
|
request.signal,
|
||||||
|
AbortSignal.timeout(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 });
|
||||||
|
const timedOut = (error instanceof Error && error.name === "TimeoutError") || fetchSignal.reason?.name === "TimeoutError";
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: timedOut ? "读取源图片超时,请重试" : "源图片传输中断,请重试" },
|
||||||
|
{ status: timedOut ? 504 : 502 },
|
||||||
|
);
|
||||||
|
};
|
||||||
let upstreamResponse: Response;
|
let upstreamResponse: Response;
|
||||||
try {
|
try {
|
||||||
upstreamResponse = await fetch(target.toString(), {
|
upstreamResponse = await fetch(target.toString(), {
|
||||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
signal: fetchSignal,
|
||||||
headers: {
|
headers: {
|
||||||
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ImageProxy] Upstream fetch failed:", error);
|
return upstreamFailure(error);
|
||||||
return NextResponse.json({ error: "Failed to fetch upstream image" }, { status: 502 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!upstreamResponse.ok) {
|
if (!upstreamResponse.ok) {
|
||||||
@ -364,7 +380,12 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
let originalBuffer: Buffer;
|
||||||
|
try {
|
||||||
|
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||||
|
} catch (error) {
|
||||||
|
return upstreamFailure(error);
|
||||||
|
}
|
||||||
if (original) {
|
if (original) {
|
||||||
return buildImageResponse(originalBuffer, {
|
return buildImageResponse(originalBuffer, {
|
||||||
contentType,
|
contentType,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { resolveSiteBranding } from "@/lib/site-branding";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@ -663,12 +664,12 @@ async function readScopedSiteSettings(keys: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
||||||
return {
|
return resolveSiteBranding({
|
||||||
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
||||||
siteLogo: settings?.siteLogo || null,
|
siteLogo: String(settings?.siteLogo || ""),
|
||||||
siteLogoDark: settings?.siteLogoDark || null,
|
siteLogoDark: String(settings?.siteLogoDark || ""),
|
||||||
favicon: settings?.favicon || null,
|
favicon: String(settings?.favicon || ""),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getScopedPublicSiteSettings(
|
async function getScopedPublicSiteSettings(
|
||||||
@ -1541,7 +1542,7 @@ export async function GET(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
...baseSettings,
|
...resolveSiteBranding(baseSettings),
|
||||||
lovartPromoText:
|
lovartPromoText:
|
||||||
baseSettings.lovartPromoText ||
|
baseSettings.lovartPromoText ||
|
||||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
||||||
|
import { CanvasRemoteOperationContext } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@ -3946,20 +3947,18 @@ export default function CanvasEditorLegacyPage({
|
|||||||
loadCanvas(normalized.canvasData);
|
loadCanvas(normalized.canvasData);
|
||||||
setProjectTitle(nextTitle);
|
setProjectTitle(nextTitle);
|
||||||
|
|
||||||
const [saveResult, updateResult] = await Promise.all([
|
const saveResult = await canvasService.saveCanvasState(
|
||||||
canvasService.saveCanvasState(
|
|
||||||
id,
|
id,
|
||||||
normalized.canvasData,
|
normalized.canvasData,
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
),
|
);
|
||||||
canvasService.updateProject(
|
const updateResult = await canvasService.updateProject(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
title: nextTitle,
|
title: nextTitle,
|
||||||
},
|
},
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
),
|
);
|
||||||
]);
|
|
||||||
|
|
||||||
if (!saveResult.success) {
|
if (!saveResult.success) {
|
||||||
throw new Error(saveResult.error || "导入后保存失败");
|
throw new Error(saveResult.error || "导入后保存失败");
|
||||||
@ -5080,9 +5079,10 @@ export default function CanvasEditorLegacyPage({
|
|||||||
if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue();
|
if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue();
|
||||||
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const canvasPersistenceScope = useRef(id);
|
const canvasPersistenceScope = useRef(id);
|
||||||
|
const canvasRemoteOperationActive = useRef(false);
|
||||||
canvasPersistenceScope.current = id;
|
canvasPersistenceScope.current = id;
|
||||||
const persistCanvasState = useCallback(
|
const persistCanvasStateNow = useCallback(
|
||||||
(reason: "autosave" | "delete" | "beforeunload") => canvasSaveQueue.current!.enqueue(async () => {
|
async (reason: "autosave" | "delete" | "beforeunload") => {
|
||||||
if (canvasPersistenceScope.current !== id) return false;
|
if (canvasPersistenceScope.current !== id) return false;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@ -5122,10 +5122,37 @@ export default function CanvasEditorLegacyPage({
|
|||||||
} finally {
|
} finally {
|
||||||
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
[activeCanvasWorkspaceType, buildPersistedCanvasData, id]
|
[activeCanvasWorkspaceType, buildPersistedCanvasData, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const persistCanvasState = useCallback(
|
||||||
|
(reason: "autosave" | "delete" | "beforeunload") =>
|
||||||
|
canvasSaveQueue.current!.enqueue(() => persistCanvasStateNow(reason)),
|
||||||
|
[persistCanvasStateNow]
|
||||||
|
);
|
||||||
|
|
||||||
|
const runCanvasRemoteOperation = useCallback(
|
||||||
|
(operation: () => Promise<void>) => {
|
||||||
|
if (canvasAutosaveTimer.current) {
|
||||||
|
clearTimeout(canvasAutosaveTimer.current);
|
||||||
|
canvasAutosaveTimer.current = null;
|
||||||
|
}
|
||||||
|
return canvasSaveQueue.current!.enqueueOperation(
|
||||||
|
() => persistCanvasStateNow("autosave"),
|
||||||
|
async () => {
|
||||||
|
canvasRemoteOperationActive.current = true;
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
} finally {
|
||||||
|
canvasRemoteOperationActive.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[persistCanvasStateNow]
|
||||||
|
);
|
||||||
|
|
||||||
const persistCanvasBeforeAgent = useCallback(() => {
|
const persistCanvasBeforeAgent = useCallback(() => {
|
||||||
// Flush the pending local edit once instead of racing its scheduled autosave.
|
// Flush the pending local edit once instead of racing its scheduled autosave.
|
||||||
if (canvasAutosaveTimer.current) {
|
if (canvasAutosaveTimer.current) {
|
||||||
@ -5168,6 +5195,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const refreshIfExternallyUpdated = async () => {
|
const refreshIfExternallyUpdated = async () => {
|
||||||
if (
|
if (
|
||||||
cancelled ||
|
cancelled ||
|
||||||
|
canvasRemoteOperationActive.current ||
|
||||||
document.visibilityState !== "visible" ||
|
document.visibilityState !== "visible" ||
|
||||||
isSaving ||
|
isSaving ||
|
||||||
externalCanvasConflictRef.current ||
|
externalCanvasConflictRef.current ||
|
||||||
@ -5197,7 +5225,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await canvasService.getProject(id);
|
const result = await canvasService.getProject(id);
|
||||||
if (cancelled || !result.success || !result.data) return;
|
if (cancelled || canvasRemoteOperationActive.current || !result.success || !result.data) return;
|
||||||
useCanvasStore.getState().markAgentOperationRefresh();
|
useCanvasStore.getState().markAgentOperationRefresh();
|
||||||
hydrateCanvasProject(result.data);
|
hydrateCanvasProject(result.data);
|
||||||
lastLocalCanvasMutationAtRef.current = Date.now();
|
lastLocalCanvasMutationAtRef.current = Date.now();
|
||||||
@ -5320,11 +5348,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (autoThumbnailSyncedRef.current !== autoTitleKey) {
|
if (autoThumbnailSyncedRef.current !== autoTitleKey) {
|
||||||
await canvasService.updateProject(
|
await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
|
||||||
id,
|
id,
|
||||||
{ thumbnail: imageSrc },
|
{ thumbnail: imageSrc },
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
);
|
));
|
||||||
autoThumbnailSyncedRef.current = autoTitleKey;
|
autoThumbnailSyncedRef.current = autoTitleKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5340,11 +5368,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.title) {
|
if (data.title) {
|
||||||
setProjectTitle(data.title);
|
setProjectTitle(data.title);
|
||||||
await canvasService.updateProject(
|
await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
|
||||||
id,
|
id,
|
||||||
{ title: data.title },
|
{ title: data.title },
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
);
|
));
|
||||||
toast.success(`${workspaceLabel}已自动命名为:${data.title}`);
|
toast.success(`${workspaceLabel}已自动命名为:${data.title}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -5395,11 +5423,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await canvasService.updateProject(
|
const result = await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
|
||||||
id,
|
id,
|
||||||
{ title: editTitle.trim() },
|
{ title: editTitle.trim() },
|
||||||
activeCanvasWorkspaceType
|
activeCanvasWorkspaceType
|
||||||
);
|
));
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setProjectTitle(editTitle.trim());
|
setProjectTitle(editTitle.trim());
|
||||||
} else {
|
} else {
|
||||||
@ -10281,6 +10309,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<CanvasRemoteOperationContext.Provider value={runCanvasRemoteOperation}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div
|
<div
|
||||||
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
|
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
|
||||||
@ -12079,5 +12108,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
{projectLoadingBadge}
|
{projectLoadingBadge}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</CanvasRemoteOperationContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
30
components/admin/admin-surface.tsx
Normal file
30
components/admin/admin-surface.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
|
|
||||||
|
export function AdminSurface({ children }: { children: ReactNode }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||||
|
let context: { revert: () => void } | undefined;
|
||||||
|
let disposed = false;
|
||||||
|
import("gsap").then(({ gsap }) => {
|
||||||
|
if (disposed) return;
|
||||||
|
context = gsap.context(() => {
|
||||||
|
gsap.fromTo(
|
||||||
|
ref.current,
|
||||||
|
{ y: 6, opacity: 0.85 },
|
||||||
|
{ y: 0, opacity: 1, duration: 0.25, ease: "power3.out", clearProps: "transform,opacity" },
|
||||||
|
);
|
||||||
|
}, ref);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
context?.revert();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="space-y-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
603
components/admin/media-records-panel.tsx
Normal file
603
components/admin/media-records-panel.tsx
Normal file
@ -0,0 +1,603 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ExternalLink,
|
||||||
|
Download,
|
||||||
|
Star,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { MediaThumbnail } from "./media-thumbnail";
|
||||||
|
import { AdminSurface } from "./admin-surface";
|
||||||
|
import { formatRecordTime } from "@/lib/admin/record-time";
|
||||||
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
|
|
||||||
|
export type MediaRecord = {
|
||||||
|
id: string;
|
||||||
|
recordType: "image" | "video";
|
||||||
|
userName: string | null;
|
||||||
|
userEmail: string | null;
|
||||||
|
userDepartment: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
modelId: string | null;
|
||||||
|
chatId: string | null;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
originalUrl: string;
|
||||||
|
videoUrl?: string | null;
|
||||||
|
prompt: string | null;
|
||||||
|
imageSize: string | null;
|
||||||
|
isFeatured: boolean;
|
||||||
|
featuredCategory: string | null;
|
||||||
|
featuredAt?: string | null;
|
||||||
|
};
|
||||||
|
type MediaResponse = {
|
||||||
|
images: MediaRecord[];
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalImages: number;
|
||||||
|
totalVideos: number;
|
||||||
|
page: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export async function adminFetcher(url: string) {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error || data.message || "加载失败,请重试");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
const selectClass =
|
||||||
|
"h-10 rounded-md border bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
||||||
|
function recentDate() {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - 6);
|
||||||
|
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaRecordsPanel({ categories }: { categories: string[] }) {
|
||||||
|
const [view, setView] = useState("assets");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
search: "",
|
||||||
|
type: "all",
|
||||||
|
startDate: recentDate(),
|
||||||
|
endDate: "",
|
||||||
|
model: "",
|
||||||
|
featured: "false",
|
||||||
|
status: "all",
|
||||||
|
});
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [selected, setSelected] = useState<MediaRecord | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [originalFailed, setOriginalFailed] = useState(false);
|
||||||
|
const update = (changes: Partial<typeof filters>) => {
|
||||||
|
setFilters((f) => ({ ...f, ...changes }));
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setFilters((f) => (f.search === search ? f : { ...f, search }));
|
||||||
|
setPage(1);
|
||||||
|
}, 350);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search]);
|
||||||
|
const { data: options } = useSWR<{
|
||||||
|
models: Array<{ id: string; modelId: string; displayName: string; providerName: string }>;
|
||||||
|
}>("/api/admin/ai-models?view=options", adminFetcher, { revalidateOnFocus: false });
|
||||||
|
const modelNames = new Map<string, string>();
|
||||||
|
for (const model of options?.models || [])
|
||||||
|
if (!modelNames.has(model.modelId)) modelNames.set(model.modelId, model.displayName);
|
||||||
|
const recordModelNames = new Map(modelNames);
|
||||||
|
for (const model of options?.models || []) recordModelNames.set(model.id, model.displayName);
|
||||||
|
const openDetails = (item: MediaRecord) => {
|
||||||
|
setSelected(item);
|
||||||
|
setOriginalFailed(false);
|
||||||
|
};
|
||||||
|
const query = new URLSearchParams({ ...filters, page: String(page), pageSize: "40" });
|
||||||
|
const { data, error, isLoading, isValidating, mutate } = useSWR<MediaResponse>(
|
||||||
|
view === "assets" ? `/api/admin/images?${query}` : null,
|
||||||
|
adminFetcher,
|
||||||
|
{ keepPreviousData: true, revalidateOnFocus: false },
|
||||||
|
);
|
||||||
|
const tasks = useSWR<{
|
||||||
|
tasks: Array<{
|
||||||
|
id: string;
|
||||||
|
userName: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
error: string | null;
|
||||||
|
model: string | null;
|
||||||
|
elapsedMs: number | null;
|
||||||
|
}>;
|
||||||
|
total: number;
|
||||||
|
}>(view === "tasks" ? `/api/admin/media-tasks?${query}` : null, adminFetcher, {
|
||||||
|
keepPreviousData: true,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
const busy = view === "assets" ? isValidating : tasks.isValidating;
|
||||||
|
const failure = view === "assets" ? error : tasks.error;
|
||||||
|
const loading = view === "assets" ? isLoading : tasks.isLoading;
|
||||||
|
const total = view === "assets" ? data?.pagination.total : tasks.data?.total;
|
||||||
|
const pages = Math.max(1, Math.ceil((total || 0) / 40));
|
||||||
|
async function save(changes: Partial<MediaRecord>) {
|
||||||
|
if (!selected || saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/admin/images", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ imageId: selected.id, ...changes }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("保存失败,请重试");
|
||||||
|
setSelected({ ...selected, ...changes });
|
||||||
|
toast.success("已保存");
|
||||||
|
await mutate();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<AdminSurface>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">生成记录</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
查看图片与视频产物,追踪画布任务。时间统一按北京时间展示。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`mr-2 size-4 ${busy ? "motion-safe:animate-spin" : ""}`} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2" role="tablist" aria-label="记录视图">
|
||||||
|
{[
|
||||||
|
["assets", "产物记录"],
|
||||||
|
["tasks", "画布任务"],
|
||||||
|
].map(([key, label]) => (
|
||||||
|
<Button
|
||||||
|
key={key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={view === key}
|
||||||
|
variant={view === key ? "default" : "ghost"}
|
||||||
|
onClick={() => {
|
||||||
|
setView(key);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-end gap-3 rounded-xl border bg-background p-4">
|
||||||
|
<label className="min-w-48 flex-1 text-xs font-medium">
|
||||||
|
用户
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="h-10 pl-9"
|
||||||
|
placeholder="搜索姓名或邮箱"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{view === "assets" ? (
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
类型
|
||||||
|
<select
|
||||||
|
aria-label="产物类型"
|
||||||
|
className={`${selectClass} mt-2 block`}
|
||||||
|
value={filters.type}
|
||||||
|
onChange={(e) => update({ type: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="all">图片与视频</option>
|
||||||
|
<option value="image">只看图片</option>
|
||||||
|
<option value="video">只看视频</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
任务状态
|
||||||
|
<select
|
||||||
|
aria-label="任务状态"
|
||||||
|
className={`${selectClass} mt-2 block`}
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(e) => update({ status: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="all">全部状态</option>
|
||||||
|
<option value="failed">失败</option>
|
||||||
|
<option value="running">运行中</option>
|
||||||
|
<option value="pending">排队中</option>
|
||||||
|
<option value="succeeded">成功</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
开始日期
|
||||||
|
<Input
|
||||||
|
aria-label="开始日期"
|
||||||
|
className="mt-2 h-10 w-40"
|
||||||
|
type="date"
|
||||||
|
value={filters.startDate}
|
||||||
|
onChange={(e) => update({ startDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
结束日期
|
||||||
|
<Input
|
||||||
|
aria-label="结束日期"
|
||||||
|
className="mt-2 h-10 w-40"
|
||||||
|
type="date"
|
||||||
|
value={filters.endDate}
|
||||||
|
onChange={(e) => update({ endDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setSearch("");
|
||||||
|
update({
|
||||||
|
search: "",
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
model: "",
|
||||||
|
featured: "false",
|
||||||
|
type: "all",
|
||||||
|
status: "all",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
全部时间 / 重置
|
||||||
|
</Button>
|
||||||
|
{view === "assets" && (
|
||||||
|
<div className="flex w-full flex-wrap items-center gap-3">
|
||||||
|
<select
|
||||||
|
aria-label="记录模型"
|
||||||
|
className={`${selectClass} max-w-full sm:max-w-96`}
|
||||||
|
value={filters.model}
|
||||||
|
onChange={(e) => update({ model: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">全部模型</option>
|
||||||
|
{Array.from(modelNames, ([id, name]) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{name} · {id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.featured === "true"}
|
||||||
|
onChange={(e) => update({ featured: String(e.target.checked) })}
|
||||||
|
/>
|
||||||
|
只看精选
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="flex items-center justify-between text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{total === undefined ? "正在读取记录…" : `共 ${total.toLocaleString()} 条记录`}
|
||||||
|
{view === "assets" && data
|
||||||
|
? ` · 图片 ${data.pagination.totalImages.toLocaleString()} · 视频 ${data.pagination.totalVideos.toLocaleString()}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
{busy && !loading && <span>正在更新…</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
第 {page} / {pages} 页 · 每页 40 条
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy || page === 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy || page >= pages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{failure ? (
|
||||||
|
<div role="alert" className="rounded-xl border border-destructive/30 p-6 text-sm">
|
||||||
|
<p>{failure.message}</p>
|
||||||
|
<Button
|
||||||
|
className="mt-3"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
|
||||||
|
>
|
||||||
|
重试加载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div role="status" className="space-y-3 rounded-xl border p-6">
|
||||||
|
{[0, 1, 2, 3].map((x) => (
|
||||||
|
<div key={x} className="h-16 rounded-lg bg-muted motion-safe:animate-pulse" />
|
||||||
|
))}
|
||||||
|
<span className="sr-only">正在加载记录</span>
|
||||||
|
</div>
|
||||||
|
) : total === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed p-12 text-center text-sm text-muted-foreground">
|
||||||
|
没有符合条件的记录,请调整筛选条件。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-xl border bg-background" aria-busy={busy}>
|
||||||
|
{view === "assets" ? (
|
||||||
|
<table className="w-full min-w-[720px] text-left text-sm">
|
||||||
|
<thead className="bg-muted/50 text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
{["产物", "用户", "生成时间", "模型 / 尺寸", "精选", ""].map((v, i) => (
|
||||||
|
<th key={i} className="px-4 py-3 font-medium">
|
||||||
|
{v}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{data?.images.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-muted/30">
|
||||||
|
<td className="p-4">
|
||||||
|
<div>
|
||||||
|
<MediaThumbnail
|
||||||
|
url={item.thumbnailUrl}
|
||||||
|
video={item.recordType === "video"}
|
||||||
|
onOpen={() => openDetails(item)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium">{item.userName || item.userEmail}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{item.userDepartment || "未设置部门"}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 tabular-nums">
|
||||||
|
{formatRecordTime(item.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="max-w-64 px-4 py-3">
|
||||||
|
<div className="truncate" title={item.modelId || ""}>
|
||||||
|
{recordModelNames.get(item.modelId || "") || item.modelId || "未记录模型"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{item.recordType === "video" ? "视频" : "图片"} ·{" "}
|
||||||
|
{item.imageSize || "未知尺寸"}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{item.isFeatured ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300">
|
||||||
|
<Star className="size-3" />
|
||||||
|
{item.featuredCategory || "精选"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openDetails(item)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<table className="w-full min-w-[720px] text-left text-sm">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
{["用户 / 时间", "状态", "模型", "耗时", "失败原因 / 任务编号"].map((x) => (
|
||||||
|
<th key={x} className="px-4 py-3 font-medium">
|
||||||
|
{x}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{tasks.data?.tasks.map((task) => (
|
||||||
|
<tr key={task.id}>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
{task.userName}
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{formatRecordTime(task.createdAt)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
{{ failed: "失败", running: "运行中", succeeded: "成功", pending: "排队中" }[
|
||||||
|
task.status
|
||||||
|
] || task.status}
|
||||||
|
</td>
|
||||||
|
<td className="max-w-48 break-words px-4 py-4">{task.model || "自动选择"}</td>
|
||||||
|
<td className="px-4 py-4 tabular-nums">
|
||||||
|
{task.elapsedMs === null ? "—" : `${Math.round(task.elapsedMs / 1000)} 秒`}
|
||||||
|
</td>
|
||||||
|
<td className="max-w-xl break-words px-4 py-4">
|
||||||
|
<p className={task.error ? "text-destructive" : "text-muted-foreground"}>
|
||||||
|
{task.error || "—"}
|
||||||
|
</p>
|
||||||
|
<code className="mt-2 block text-xs text-muted-foreground">{task.id}</code>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
每页 40 条 · 第 {page} / {pages} 页
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" disabled={busy || page === 1} onClick={() => setPage(1)}>
|
||||||
|
首页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
aria-label="上一页"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy || page === 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
aria-label="下一页"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy || page >= pages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" disabled={busy || page >= pages} onClick={() => setPage(pages)}>
|
||||||
|
末页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog
|
||||||
|
open={!!selected}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setSelected(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto bg-background">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{selected?.recordType === "video" ? "视频详情" : "图片详情"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{selected && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex min-h-40 justify-center rounded-xl bg-muted">
|
||||||
|
{selected.recordType === "video" ? (
|
||||||
|
<video
|
||||||
|
src={selected.videoUrl || selected.originalUrl}
|
||||||
|
controls
|
||||||
|
preload="none"
|
||||||
|
className="max-h-[45vh] w-full"
|
||||||
|
/>
|
||||||
|
) : originalFailed ? (
|
||||||
|
<div role="alert" className="p-8 text-sm">
|
||||||
|
原图无法加载,源文件可能已过期或存储不可达。
|
||||||
|
<Button
|
||||||
|
className="ml-3"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOriginalFailed(false)}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={selected.originalUrl}
|
||||||
|
alt="生成原图"
|
||||||
|
className="max-h-[45vh] object-contain"
|
||||||
|
onError={() => setOriginalFailed(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<dl className="grid gap-4 text-sm sm:grid-cols-2">
|
||||||
|
{[
|
||||||
|
["用户", selected.userName || selected.userEmail],
|
||||||
|
["部门", selected.userDepartment],
|
||||||
|
["生成时间", formatRecordTime(selected.createdAt)],
|
||||||
|
["尺寸", selected.imageSize],
|
||||||
|
["模型", recordModelNames.get(selected.modelId || "") || selected.modelId],
|
||||||
|
["精选时间", selected.featuredAt ? formatRecordTime(selected.featuredAt) : "—"],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k}>
|
||||||
|
<dt className="text-xs text-muted-foreground">{k}</dt>
|
||||||
|
<dd className="mt-1 break-words">{v || "未记录"}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">提示词</h3>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap break-words text-sm text-muted-foreground">
|
||||||
|
{selected.prompt || "未记录提示词"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href={selected.originalUrl} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink className="mr-2 size-4" />
|
||||||
|
打开原文件
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
{selected.recordType === "image" && (
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a
|
||||||
|
href={buildImageProxyUrl(selected.originalUrl, { delivery: "original" })}
|
||||||
|
download
|
||||||
|
>
|
||||||
|
<Download className="mr-2 size-4" />
|
||||||
|
下载原图
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selected.recordType === "image" && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 rounded-xl border p-4">
|
||||||
|
<select
|
||||||
|
aria-label="精选分类"
|
||||||
|
className={selectClass}
|
||||||
|
value={selected.featuredCategory || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelected({ ...selected, featuredCategory: e.target.value || null })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">未分类</option>
|
||||||
|
{[...new Set([...categories, selected.featuredCategory].filter(Boolean))].map(
|
||||||
|
(c) => (
|
||||||
|
<option key={c} value={c!}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => save({ featuredCategory: selected.featuredCategory })}
|
||||||
|
>
|
||||||
|
保存分类
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => save({ isFeatured: !selected.isFeatured })}
|
||||||
|
>
|
||||||
|
<Star className="mr-2 size-4" />
|
||||||
|
{selected.isFeatured ? "取消精选" : "设为精选"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</AdminSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
components/admin/media-thumbnail.tsx
Normal file
150
components/admin/media-thumbnail.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ImageOff, Video, RotateCw } from "lucide-react";
|
||||||
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
|
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
|
||||||
|
|
||||||
|
import { queuePreview } from "@/lib/admin/preview-queue";
|
||||||
|
|
||||||
|
export function MediaThumbnail({
|
||||||
|
url,
|
||||||
|
video = false,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
video?: boolean;
|
||||||
|
onOpen?: () => void;
|
||||||
|
}) {
|
||||||
|
const container = useRef<HTMLDivElement>(null);
|
||||||
|
const [state, setState] = useState<"waiting" | "loading" | "ready" | "error">("waiting");
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const [source, setSource] = useState("");
|
||||||
|
const [failure, setFailure] = useState("");
|
||||||
|
const retired = isRetiredStorageUrl(url);
|
||||||
|
useEffect(() => {
|
||||||
|
setState("waiting");
|
||||||
|
setSource("");
|
||||||
|
setFailure("");
|
||||||
|
if (video || retired || !url || !container.current) return;
|
||||||
|
let dispose: (() => void) | undefined;
|
||||||
|
let abort = () => {};
|
||||||
|
let objectUrl = "";
|
||||||
|
let alive = true;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (!entry.isIntersecting || dispose) return;
|
||||||
|
observer.disconnect();
|
||||||
|
dispose = queuePreview((release) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 48000);
|
||||||
|
setState("loading");
|
||||||
|
// Fetch is abortable, so leaving a page also stops in-flight previews.
|
||||||
|
const parsed = new URL(url, window.location.origin);
|
||||||
|
const target =
|
||||||
|
parsed.hostname === "scqnycdn.jiansun.vip" && !parsed.search
|
||||||
|
? `${url}?imageView2/2/w/192/q/72/format/webp`
|
||||||
|
: buildImageProxyUrl(url, {
|
||||||
|
width: 192,
|
||||||
|
quality: 72,
|
||||||
|
format: "webp",
|
||||||
|
delivery: "proxy",
|
||||||
|
});
|
||||||
|
fetch(target, { signal: controller.signal })
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.json().catch(() => null);
|
||||||
|
throw new Error(`${detail?.error || "预览请求失败"}(HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
if (!blob.type.startsWith("image/")) throw new Error("Not an image");
|
||||||
|
if (alive) {
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
setSource(objectUrl);
|
||||||
|
setState("ready");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (alive) {
|
||||||
|
setFailure(
|
||||||
|
error.name === "AbortError" ? "预览加载超时" : error.message || "预览加载失败",
|
||||||
|
);
|
||||||
|
setState("error");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
release();
|
||||||
|
});
|
||||||
|
abort = () => {
|
||||||
|
controller.abort();
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ rootMargin: "80px" },
|
||||||
|
);
|
||||||
|
observer.observe(container.current);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
observer.disconnect();
|
||||||
|
abort();
|
||||||
|
dispose?.();
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}, [url, video, retired, attempt]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={container}
|
||||||
|
className="relative flex h-16 w-20 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted"
|
||||||
|
data-preview-state={state}
|
||||||
|
>
|
||||||
|
{video ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="查看视频详情"
|
||||||
|
className="flex h-full w-full items-center justify-center"
|
||||||
|
onClick={onOpen}
|
||||||
|
>
|
||||||
|
<Video className="size-5 text-muted-foreground" aria-label="视频,点击查看后播放" />
|
||||||
|
</button>
|
||||||
|
) : retired ? (
|
||||||
|
<span className="text-center text-xs text-muted-foreground">
|
||||||
|
旧存储
|
||||||
|
<br />
|
||||||
|
已停用
|
||||||
|
</span>
|
||||||
|
) : source && state === "ready" ? (
|
||||||
|
<button type="button" aria-label="查看图片详情" className="h-full w-full" onClick={onOpen}>
|
||||||
|
<img
|
||||||
|
src={source}
|
||||||
|
alt="生成结果预览"
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
onError={() => {
|
||||||
|
setFailure("图片内容无法解码");
|
||||||
|
setState("error");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : state === "error" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-full w-full flex-col items-center justify-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
title={`${failure},点击重试`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setAttempt((x) => x + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ImageOff className="size-4" />
|
||||||
|
加载失败
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<RotateCw
|
||||||
|
className={`size-4 text-muted-foreground ${state === "loading" ? "motion-safe:animate-spin" : ""}`}
|
||||||
|
aria-label={state === "loading" ? "正在加载预览" : "等待进入可见区域"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
components/admin/model-health-panel.tsx
Normal file
191
components/admin/model-health-panel.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { adminFetcher } from "./media-records-panel";
|
||||||
|
import { formatRecordTime } from "@/lib/admin/record-time";
|
||||||
|
type Event = {
|
||||||
|
id: string;
|
||||||
|
modelDisplayName: string;
|
||||||
|
modelRuntimeId: string;
|
||||||
|
providerName: string;
|
||||||
|
eventType: string;
|
||||||
|
createdAt: string;
|
||||||
|
errorMessage: string;
|
||||||
|
elapsedMs: number | null;
|
||||||
|
consecutiveFailures: number;
|
||||||
|
autoDisabled: boolean;
|
||||||
|
};
|
||||||
|
export function ModelHealthPanel({
|
||||||
|
providers,
|
||||||
|
models,
|
||||||
|
initialModelId = "",
|
||||||
|
}: {
|
||||||
|
initialModelId?: string;
|
||||||
|
providers: Array<{ id: string; name: string }>;
|
||||||
|
models: Array<{ id: string; displayName: string; providerId: string }>;
|
||||||
|
}) {
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
providerId: "",
|
||||||
|
modelId: initialModelId,
|
||||||
|
eventType: "",
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
});
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const query = new URLSearchParams({ ...filters, page: String(page), limit: "20" });
|
||||||
|
const { data, error, isLoading, isValidating, mutate } = useSWR<{
|
||||||
|
events: Event[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}>(`/api/admin/ai-model-health?${query}`, adminFetcher, {
|
||||||
|
keepPreviousData: true,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
const update = (change: Partial<typeof filters>) => {
|
||||||
|
setFilters((f) => ({ ...f, ...change }));
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">运行健康</h3>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
按上游和模型追踪故障,查看完整原因与发生时间。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{[
|
||||||
|
["providerId", "全部上游", providers.map((p) => [p.id, p.name])],
|
||||||
|
[
|
||||||
|
"modelId",
|
||||||
|
"全部模型",
|
||||||
|
models
|
||||||
|
.filter((m) => !filters.providerId || m.providerId === filters.providerId)
|
||||||
|
.map((m) => [m.id, m.displayName]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"eventType",
|
||||||
|
"全部错误",
|
||||||
|
[
|
||||||
|
["quota", "额度 / 余额"],
|
||||||
|
["timeout", "超时"],
|
||||||
|
["unavailable", "不可用"],
|
||||||
|
["invalid_model", "模型不可用"],
|
||||||
|
["download_error", "结果下载失败"],
|
||||||
|
["error", "其他错误"],
|
||||||
|
["success", "成功"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
].map(([key, label, options]) => (
|
||||||
|
<select
|
||||||
|
key={key as string}
|
||||||
|
aria-label={label as string}
|
||||||
|
className="h-10 max-w-64 rounded-md border bg-background px-3 text-sm"
|
||||||
|
value={filters[key as keyof typeof filters]}
|
||||||
|
onChange={(e) =>
|
||||||
|
update({
|
||||||
|
[key as string]: e.target.value,
|
||||||
|
...(key === "providerId" ? { modelId: "" } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">{label as string}</option>
|
||||||
|
{(options as string[][]).map(([value, text]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
))}
|
||||||
|
<Input
|
||||||
|
aria-label="故障开始日期"
|
||||||
|
className="h-10 w-40"
|
||||||
|
type="date"
|
||||||
|
value={filters.startDate}
|
||||||
|
onChange={(e) => update({ startDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
aria-label="故障结束日期"
|
||||||
|
className="h-10 w-40"
|
||||||
|
type="date"
|
||||||
|
value={filters.endDate}
|
||||||
|
onChange={(e) => update({ endDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" disabled={isValidating} onClick={() => mutate()}>
|
||||||
|
刷新日志
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="p-6 text-sm text-destructive">
|
||||||
|
日志加载失败,请点击刷新重试。
|
||||||
|
</p>
|
||||||
|
) : isLoading ? (
|
||||||
|
<p role="status" className="p-6 text-sm">
|
||||||
|
正在加载故障日志…
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y rounded-xl border">
|
||||||
|
{data?.events.map((event) => (
|
||||||
|
<div key={event.id} className="p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||||
|
<strong className="font-semibold">
|
||||||
|
{event.modelDisplayName || event.modelRuntimeId || "已删除模型"}
|
||||||
|
</strong>
|
||||||
|
<span className="text-muted-foreground">{event.providerName || "未知上游"}</span>
|
||||||
|
<span className="rounded bg-muted px-2 py-1 text-xs">
|
||||||
|
{{
|
||||||
|
quota: "额度 / 余额",
|
||||||
|
timeout: "超时",
|
||||||
|
unavailable: "不可用",
|
||||||
|
error: "服务错误",
|
||||||
|
invalid_model: "模型不可用",
|
||||||
|
download_error: "结果下载失败",
|
||||||
|
success: "成功",
|
||||||
|
}[event.eventType] || event.eventType}
|
||||||
|
</span>
|
||||||
|
<time className="ml-auto text-xs tabular-nums text-muted-foreground">
|
||||||
|
{formatRecordTime(event.createdAt)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 whitespace-pre-wrap break-words text-sm">
|
||||||
|
{event.errorMessage || "未记录详细原因"}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
|
||||||
|
<span>连续失败 {event.consecutiveFailures} 次</span>
|
||||||
|
{event.elapsedMs !== null && (
|
||||||
|
<span>耗时 {Math.round(event.elapsedMs / 1000)} 秒</span>
|
||||||
|
)}
|
||||||
|
{event.autoDisabled && <span>已自动停用</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!data?.events.length && (
|
||||||
|
<p className="p-12 text-center text-sm text-muted-foreground">该范围内没有故障记录。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
第 {page} 页{isValidating && " · 正在更新…"}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={page === 1 || isValidating}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!data?.hasMore || isValidating}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
326
components/admin/model-management-list.tsx
Normal file
326
components/admin/model-management-list.tsx
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import type { AiModel } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
is3DModelDescriptor,
|
||||||
|
isVideoModelDescriptor,
|
||||||
|
isImageGenerationModelDescriptor,
|
||||||
|
} from "@/lib/ai/model-classification";
|
||||||
|
|
||||||
|
type Model = AiModel & { isAgentModel?: boolean; isMultimodal?: boolean };
|
||||||
|
type Provider = { id: string; name: string; type: string; isEnabled: boolean };
|
||||||
|
const selectClass = "h-10 rounded-md border bg-background px-3 text-sm";
|
||||||
|
export function ModelManagementList({
|
||||||
|
models,
|
||||||
|
providers,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onRestore,
|
||||||
|
onRefresh,
|
||||||
|
onHealth,
|
||||||
|
}: {
|
||||||
|
models: Model[];
|
||||||
|
providers: Provider[];
|
||||||
|
error?: Error;
|
||||||
|
loading: boolean;
|
||||||
|
onEdit: (model: Model) => Promise<void>;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onRestore: (model: Model) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onHealth?: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [provider, setProvider] = useState("");
|
||||||
|
const [status, setStatus] = useState("enabled");
|
||||||
|
const [kind, setKind] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [opening, setOpening] = useState<string | null>(null);
|
||||||
|
const rows = useMemo(
|
||||||
|
() =>
|
||||||
|
models
|
||||||
|
.map((model) => {
|
||||||
|
const upstream = providers.find((p) => p.id === model.providerId);
|
||||||
|
const descriptor = { ...model, providerType: upstream?.type };
|
||||||
|
const type = is3DModelDescriptor(descriptor)
|
||||||
|
? "3d"
|
||||||
|
: isVideoModelDescriptor(descriptor)
|
||||||
|
? "video"
|
||||||
|
: isImageGenerationModelDescriptor(descriptor)
|
||||||
|
? "image"
|
||||||
|
: "chat";
|
||||||
|
return { model, upstream, type };
|
||||||
|
})
|
||||||
|
.filter(({ model, upstream, type }) => {
|
||||||
|
const text =
|
||||||
|
`${model.displayName} ${model.modelId} ${upstream?.name || ""}`.toLowerCase();
|
||||||
|
return (
|
||||||
|
text.includes(search.trim().toLowerCase()) &&
|
||||||
|
(!provider || model.providerId === provider) &&
|
||||||
|
(!kind || kind === type) &&
|
||||||
|
(status === "all" ||
|
||||||
|
(status === "enabled" && model.isEnabled) ||
|
||||||
|
(status === "disabled" && !model.isEnabled) ||
|
||||||
|
(status === "unhealthy" && model.healthStatus !== "healthy") ||
|
||||||
|
(status === "visible" && !model.isHidden))
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[models, providers, search, provider, status, kind],
|
||||||
|
);
|
||||||
|
const pages = Math.max(1, Math.ceil(rows.length / 25));
|
||||||
|
const currentPage = Math.min(page, pages);
|
||||||
|
const change = (fn: () => void) => {
|
||||||
|
fn();
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<div className="relative min-w-56 flex-1">
|
||||||
|
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="h-10 pl-9"
|
||||||
|
aria-label="搜索模型"
|
||||||
|
placeholder="搜索模型名称、ID 或上游"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => change(() => setSearch(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
aria-label="模型上游"
|
||||||
|
className={selectClass}
|
||||||
|
value={provider}
|
||||||
|
onChange={(e) => change(() => setProvider(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">全部上游</option>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label="模型类型"
|
||||||
|
className={selectClass}
|
||||||
|
value={kind}
|
||||||
|
onChange={(e) => change(() => setKind(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">全部类型</option>
|
||||||
|
<option value="image">生图</option>
|
||||||
|
<option value="video">视频</option>
|
||||||
|
<option value="chat">对话</option>
|
||||||
|
<option value="3d">3D / CAD</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label="模型状态"
|
||||||
|
className={selectClass}
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => change(() => setStatus(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="enabled">已启用</option>
|
||||||
|
<option value="all">全部模型</option>
|
||||||
|
<option value="disabled">已停用</option>
|
||||||
|
<option value="unhealthy">运行异常</option>
|
||||||
|
<option value="visible">前台可见</option>
|
||||||
|
</select>
|
||||||
|
<Button variant="outline" onClick={onRefresh} aria-label="刷新模型">
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{loading
|
||||||
|
? "正在加载模型…"
|
||||||
|
: `筛选结果 ${rows.length} 个 · 已启用 ${models.filter((m) => m.isEnabled).length} 个 · 全部 ${models.length} 个`}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
第 {currentPage} / {pages} 页
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => setPage(currentPage - 1)}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage === pages}
|
||||||
|
onClick={() => setPage(currentPage + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<div role="alert" className="rounded-xl border p-6 text-sm">
|
||||||
|
模型加载失败。
|
||||||
|
<Button variant="outline" onClick={onRefresh}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div role="status" className="h-48 rounded-xl bg-muted motion-safe:animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full min-w-[850px] text-left text-sm">
|
||||||
|
<thead className="bg-muted/50 text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
{["模型", "上游 / 类型", "启用", "前台展示", "运行健康", "操作"].map((x) => (
|
||||||
|
<th key={x} className="px-4 py-3 font-medium">
|
||||||
|
{x}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{rows
|
||||||
|
.slice((currentPage - 1) * 25, currentPage * 25)
|
||||||
|
.map(({ model, upstream, type }) => (
|
||||||
|
<tr key={model.id} className="hover:bg-muted/30">
|
||||||
|
<td className="max-w-64 px-4 py-4">
|
||||||
|
<div className="font-medium">{model.displayName}</div>
|
||||||
|
<div
|
||||||
|
className="mt-1 truncate text-xs text-muted-foreground"
|
||||||
|
title={model.modelId}
|
||||||
|
>
|
||||||
|
{model.modelId}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||||
|
{model.isDefault && <span>默认模型</span>}
|
||||||
|
{model.isDefaultTextEditModel && <span>文字编辑默认</span>}
|
||||||
|
{model.isAgentModel && <span>Agent</span>}
|
||||||
|
{model.isMultimodal && <span>多模态</span>}
|
||||||
|
{model.isSubModel && <span>子模型</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
{upstream?.name || "未知上游"}
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{{ image: "生图", video: "视频", chat: "对话", "3d": "3D / CAD" }[type]}
|
||||||
|
{upstream && !upstream.isEnabled ? " · 上游已停用" : ""}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-4">
|
||||||
|
<span
|
||||||
|
className={`rounded-md px-2 py-1 text-xs ${model.isEnabled ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200" : "bg-muted text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
{model.isEnabled ? "已启用" : "已停用"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
{model.isHidden || model.isSubModel ? "隐藏" : "可见"}
|
||||||
|
</td>
|
||||||
|
<td className="max-w-72 px-4 py-4">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
model.healthStatus !== "healthy"
|
||||||
|
? "text-amber-700 dark:text-amber-300"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{model.healthStatus === "auto_disabled"
|
||||||
|
? "自动停用"
|
||||||
|
: model.healthStatus === "degraded"
|
||||||
|
? "观察中"
|
||||||
|
: "正常"}
|
||||||
|
</span>
|
||||||
|
{model.healthConsecutiveFailures > 0 && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
连续失败 {model.healthConsecutiveFailures} 次
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{model.healthLastError && (
|
||||||
|
<details className="mt-2 text-xs">
|
||||||
|
<summary className="cursor-pointer text-muted-foreground">
|
||||||
|
查看最近原因
|
||||||
|
</summary>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap break-words">
|
||||||
|
{model.healthLastError}
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-2 block text-xs underline underline-offset-4"
|
||||||
|
onClick={() => onHealth?.(model.id)}
|
||||||
|
>
|
||||||
|
故障记录
|
||||||
|
</button>
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{model.excludeFromHealthBan && "不自动封禁 "}
|
||||||
|
{model.excludeFromFailover && "不参与迁移"}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={opening !== null}
|
||||||
|
onClick={async () => {
|
||||||
|
setOpening(model.id);
|
||||||
|
try {
|
||||||
|
await onEdit(model);
|
||||||
|
} finally {
|
||||||
|
setOpening(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opening === model.id ? "加载中…" : "编辑"}
|
||||||
|
</Button>
|
||||||
|
{model.healthStatus === "auto_disabled" && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onRestore(model)}>
|
||||||
|
恢复
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => onDelete(model.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<p className="p-12 text-center text-sm text-muted-foreground">没有符合条件的模型。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
每页 25 个 · 第 {currentPage} / {pages} 页
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
aria-label="上一页模型"
|
||||||
|
variant="outline"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => setPage(currentPage - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
aria-label="下一页模型"
|
||||||
|
variant="outline"
|
||||||
|
disabled={currentPage === pages}
|
||||||
|
onClick={() => setPage(currentPage + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
||||||
|
import { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
|
||||||
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
|
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
import {
|
import {
|
||||||
@ -4169,6 +4170,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
? "agent-anchor-materials"
|
? "agent-anchor-materials"
|
||||||
: "agent-anchor-generate";
|
: "agent-anchor-generate";
|
||||||
|
|
||||||
|
const [finishedSceneRequestId, setFinishedSceneRequestId] = useState<string | null>(null);
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
setMessages,
|
setMessages,
|
||||||
@ -4189,7 +4191,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
void refreshHistory();
|
void refreshHistory();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFinish() { void refreshHistory(); },
|
onFinish({ messages: finishedMessages }) {
|
||||||
|
const lastUser = finishedMessages.filter((message) => message.role === "user").at(-1);
|
||||||
|
setFinishedSceneRequestId(lastUser?.id || null);
|
||||||
|
void refreshHistory();
|
||||||
|
},
|
||||||
experimental_throttle: 100,
|
experimental_throttle: 100,
|
||||||
generateId: generateUUID,
|
generateId: generateUUID,
|
||||||
transport: new DefaultChatTransport({
|
transport: new DefaultChatTransport({
|
||||||
@ -5482,6 +5488,17 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
setActiveResultModeType(currentResultModeType);
|
setActiveResultModeType(currentResultModeType);
|
||||||
}, [currentResultModeType]);
|
}, [currentResultModeType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingGeneration?.requestMessageId || pendingGeneration.modeType !== "scene_generation" || transportIsGenerating) return;
|
||||||
|
const result = getSceneRunResult(messages, pendingGeneration.requestMessageId);
|
||||||
|
const persisted = getSceneRunResult(historyMessages, pendingGeneration.requestMessageId);
|
||||||
|
if (!result || (finishedSceneRequestId !== pendingGeneration.requestMessageId && !persisted?.count)) return;
|
||||||
|
setPendingGeneration(null);
|
||||||
|
if (result.count < pendingGeneration.expectedCount) {
|
||||||
|
toast.warning(`本次生成已结束,收到 ${result.count}/${pendingGeneration.expectedCount} 张图片。缺少的图片未继续生成,可调整张数后重新提交。`);
|
||||||
|
}
|
||||||
|
}, [pendingGeneration, transportIsGenerating, finishedSceneRequestId, messages, historyMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
|
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
|
||||||
// The backend has settled every requested card; an open SSE connection is no longer work.
|
// The backend has settled every requested card; an open SSE connection is no longer work.
|
||||||
|
|||||||
11
components/canvas/CanvasRemoteOperationContext.tsx
Normal file
11
components/canvas/CanvasRemoteOperationContext.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export const CanvasRemoteOperationContext = createContext<
|
||||||
|
((operation: () => Promise<void>) => Promise<void>) | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
export function useCanvasRemoteOperation() {
|
||||||
|
return useContext(CanvasRemoteOperationContext);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@ -231,6 +232,8 @@ import {
|
|||||||
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||||||
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
||||||
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
|
import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capability";
|
||||||
|
import { useCanvasRemoteOperation } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||||
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
|
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
|
||||||
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
|
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
|
||||||
import { cn, fetcher } from "@/lib/utils";
|
import { cn, fetcher } from "@/lib/utils";
|
||||||
@ -2077,7 +2080,7 @@ export function ImageNode({
|
|||||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||||
const projectId = useCanvasStore((state) => state.projectId);
|
const projectId = useCanvasStore((state) => state.projectId);
|
||||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||||||
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
|
const runCanvasRemoteOperation = useCanvasRemoteOperation();
|
||||||
const addPendingChatAttachment = useCanvasStore(
|
const addPendingChatAttachment = useCanvasStore(
|
||||||
(state) => state.addPendingChatAttachment
|
(state) => state.addPendingChatAttachment
|
||||||
);
|
);
|
||||||
@ -6707,7 +6710,7 @@ export function ImageNode({
|
|||||||
const executeSharedToolbarCapability = async (
|
const executeSharedToolbarCapability = async (
|
||||||
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
|
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
|
||||||
) => {
|
) => {
|
||||||
if (!projectId) {
|
if (!projectId || !runCanvasRemoteOperation) {
|
||||||
toast.error("画布还在加载,请稍后再试");
|
toast.error("画布还在加载,请稍后再试");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -6726,21 +6729,7 @@ export function ImageNode({
|
|||||||
projectId,
|
projectId,
|
||||||
source: "toolbar",
|
source: "toolbar",
|
||||||
});
|
});
|
||||||
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
|
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(command),
|
|
||||||
});
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok || payload?.success !== true) {
|
|
||||||
throw new Error(payload?.error || `${capability.title}失败`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectResult = await canvasService.getProject(projectId);
|
|
||||||
if (!projectResult.success || !projectResult.data?.canvasData) {
|
|
||||||
throw new Error(projectResult.error || "结果已生成,但画布同步失败");
|
|
||||||
}
|
|
||||||
loadCanvas(projectResult.data.canvasData);
|
|
||||||
toast.success(`${capability.title}完成`);
|
toast.success(`${capability.title}完成`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
@ -8439,7 +8428,11 @@ export function ImageNode({
|
|||||||
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
||||||
{data.src ? (
|
{data.src ? (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
{!shouldRequestDisplayImage ? (
|
{isRetiredStorageUrl(data.src) ? (
|
||||||
|
<div role="status" className="flex h-full w-full items-center justify-center bg-muted p-4 text-center text-sm text-muted-foreground">
|
||||||
|
{RETIRED_STORAGE_MESSAGE}
|
||||||
|
</div>
|
||||||
|
) : !shouldRequestDisplayImage ? (
|
||||||
<ImageNodeDeferredLoadState />
|
<ImageNodeDeferredLoadState />
|
||||||
) : isCompareMode && hasCompareSource ? (
|
) : isCompareMode && hasCompareSource ? (
|
||||||
<div
|
<div
|
||||||
|
|||||||
68
components/home/lovart/home-image.tsx
Normal file
68
components/home/lovart/home-image.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
|
||||||
|
|
||||||
|
type HomeImageProps = {
|
||||||
|
sources: Array<string | null | undefined>;
|
||||||
|
alt: string;
|
||||||
|
className?: string;
|
||||||
|
imageClassName?: string;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
loading?: "lazy" | "eager";
|
||||||
|
onUnavailable?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function imageCandidates(source: string) {
|
||||||
|
// These two domains are documented aliases for the same gongxiangpan store.
|
||||||
|
// Do not rewrite other buckets, providers, signed URLs or user-supplied hosts.
|
||||||
|
try {
|
||||||
|
const url = new URL(source);
|
||||||
|
if (url.origin === "https://s3.yydd.ai" &&
|
||||||
|
url.pathname.startsWith("/gongxiangpan/") && !url.search && !url.hash) {
|
||||||
|
return [`https://s3.jiansun.vip${url.pathname}`, source];
|
||||||
|
}
|
||||||
|
} catch { /* Relative site assets need no host rewrite. */ }
|
||||||
|
return [source];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep failed image elements out of view, trying each distinct source once. */
|
||||||
|
export function HomeImage(props: HomeImageProps) {
|
||||||
|
const sources = [...new Set(props.sources
|
||||||
|
.filter((url): url is string => Boolean(url?.trim()) && !isRetiredStorageUrl(url))
|
||||||
|
.flatMap(imageCandidates))];
|
||||||
|
return <HomeImageAttempt key={JSON.stringify(sources)} {...props} sources={sources} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeImageAttempt({
|
||||||
|
sources, alt, className, imageClassName, fallback, loading = "lazy", onUnavailable,
|
||||||
|
}: Omit<HomeImageProps, "sources"> & { sources: string[] }) {
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const source = sources[index];
|
||||||
|
return (
|
||||||
|
<span className={`relative block overflow-hidden ${className || ""}`}>
|
||||||
|
{!loaded && (
|
||||||
|
<span className={fallback ? "block" : "block aspect-[4/5] rounded-[12px] bg-[#e8e8e8] dark:bg-[#1b1c20]"}>
|
||||||
|
{fallback}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{source ? (
|
||||||
|
<img
|
||||||
|
key={source}
|
||||||
|
src={source}
|
||||||
|
alt={alt}
|
||||||
|
loading={loading}
|
||||||
|
decoding="async"
|
||||||
|
className={`${imageClassName || ""} ${loaded ? "" : "absolute inset-0 opacity-0"}`}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
onError={() => {
|
||||||
|
setLoaded(false);
|
||||||
|
setIndex(index + 1);
|
||||||
|
if (index + 1 >= sources.length) onUnavailable?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||||
import { Gem, Heart, Loader2, PlayCircle } from "lucide-react";
|
import { Gem, Heart, Loader2, PlayCircle } from "lucide-react";
|
||||||
import type { Ref } from "react";
|
import { useState, type Ref } from "react";
|
||||||
|
import { HomeImage } from "./home-image";
|
||||||
import type { FeaturedImage } from "./types";
|
import type { FeaturedImage } from "./types";
|
||||||
|
|
||||||
type LovartFeaturedGalleryProps = {
|
type LovartFeaturedGalleryProps = {
|
||||||
@ -30,6 +31,10 @@ function getFeaturedPreviewUrl(item: FeaturedImage) {
|
|||||||
return `${source}?imageView2/2/w/900/q/78`;
|
return `${source}?imageView2/2/w/900/q/78`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function imageIdentity(item: FeaturedImage) {
|
||||||
|
return JSON.stringify([item.id, item.thumbnailUrl, item.originalUrl]);
|
||||||
|
}
|
||||||
|
|
||||||
export function LovartFeaturedGallery({
|
export function LovartFeaturedGallery({
|
||||||
onOpenProjects,
|
onOpenProjects,
|
||||||
onOpenMaterials,
|
onOpenMaterials,
|
||||||
@ -42,6 +47,13 @@ export function LovartFeaturedGallery({
|
|||||||
isLoadingMoreFeatured,
|
isLoadingMoreFeatured,
|
||||||
setActiveCategory,
|
setActiveCategory,
|
||||||
}: LovartFeaturedGalleryProps) {
|
}: LovartFeaturedGalleryProps) {
|
||||||
|
const [unavailable, setUnavailable] = useState<Set<string>>(() => new Set());
|
||||||
|
const [retryVersion, setRetryVersion] = useState(0);
|
||||||
|
const uniqueItems = [...new Map(featuredItems.map((item) => [item.id, item])).values()];
|
||||||
|
const visibleItems = uniqueItems.filter((item) =>
|
||||||
|
Boolean(item.thumbnailUrl || item.originalUrl),
|
||||||
|
);
|
||||||
|
const unavailableCount = uniqueItems.filter(item => unavailable.has(imageIdentity(item))).length;
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={featuredSectionRef}
|
ref={featuredSectionRef}
|
||||||
@ -88,44 +100,48 @@ export function LovartFeaturedGallery({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{featuredItems.length === 0 ? (
|
{unavailableCount > 0 && (
|
||||||
|
<div role="status" className="mt-4 flex items-center gap-3 text-xs text-black/50 dark:text-white/50">
|
||||||
|
<span>{unavailableCount} 张灵感图片加载失败,请重试。</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 underline underline-offset-4 hover:text-black dark:hover:text-white"
|
||||||
|
onClick={() => {
|
||||||
|
setUnavailable(new Set());
|
||||||
|
setRetryVersion((version) => version + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重试加载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visibleItems.length === 0 ? (
|
||||||
<div className="mt-[22px] rounded-[10px] border border-dashed border-black/15 bg-[#f7f7f7] p-10 text-center text-[14px] text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
|
<div className="mt-[22px] rounded-[10px] border border-dashed border-black/15 bg-[#f7f7f7] p-10 text-center text-[14px] text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
|
||||||
{isLoadingFeatured
|
{isLoadingFeatured
|
||||||
? "灵感内容加载中..."
|
? "灵感内容加载中..."
|
||||||
: "当前没有可展示的灵感数据。"}
|
: unavailableCount > 0 ? "图片暂时无法加载,请稍后重试。" : "当前没有可展示的灵感数据。"}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4">
|
<div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4">
|
||||||
{featuredItems.map((item, index) => (
|
{visibleItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={`${imageIdentity(item)}:${retryVersion}`}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={unavailable.has(imageIdentity(item))}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open(item.originalUrl || item.thumbnailUrl, "_blank")
|
window.open(item.originalUrl || item.thumbnailUrl, "_blank")
|
||||||
}
|
}
|
||||||
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
|
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
|
||||||
>
|
>
|
||||||
<img
|
{unavailable.has(imageIdentity(item)) ? (
|
||||||
src={getFeaturedPreviewUrl(item)}
|
<div className="flex aspect-[4/5] items-center justify-center rounded-[12px] bg-muted p-4 text-sm text-muted-foreground">图片加载失败</div>
|
||||||
|
) : <HomeImage
|
||||||
|
sources={[getFeaturedPreviewUrl(item), item.thumbnailUrl, item.originalUrl]}
|
||||||
alt={item.prompt || "灵感图"}
|
alt={item.prompt || "灵感图"}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
onUnavailable={() => setUnavailable((previous) => new Set(previous).add(imageIdentity(item)))}
|
||||||
onError={(event) => {
|
imageClassName="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
|
||||||
const image = event.currentTarget;
|
/>}
|
||||||
const fallbackUrl = item.originalUrl || item.thumbnailUrl;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!fallbackUrl ||
|
|
||||||
image.dataset.fallbackApplied === "true"
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
image.dataset.fallbackApplied = "true";
|
|
||||||
image.src = fallbackUrl;
|
|
||||||
}}
|
|
||||||
className="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
|
|
||||||
/>
|
|
||||||
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
|
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
|
||||||
<div className="flex min-w-0 items-center gap-[6px] pr-2">
|
<div className="flex min-w-0 items-center gap-[6px] pr-2">
|
||||||
<span className="flex h-[16px] w-[16px] shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#d8d8d8] text-[8px] font-medium text-white">
|
<span className="flex h-[16px] w-[16px] shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#d8d8d8] text-[8px] font-medium text-white">
|
||||||
@ -138,11 +154,11 @@ export function LovartFeaturedGallery({
|
|||||||
<div className="inline-flex shrink-0 items-center gap-[7px] text-[11px] leading-none">
|
<div className="inline-flex shrink-0 items-center gap-[7px] text-[11px] leading-none">
|
||||||
<span className="inline-flex items-center gap-[4px]">
|
<span className="inline-flex items-center gap-[4px]">
|
||||||
<PlayCircle className="h-[11px] w-[11px]" strokeWidth={2} />
|
<PlayCircle className="h-[11px] w-[11px]" strokeWidth={2} />
|
||||||
{(1200 + index * 37).toLocaleString()}
|
{(1200 + featuredItems.indexOf(item) * 37).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-[4px]">
|
<span className="inline-flex items-center gap-[4px]">
|
||||||
<Heart className="h-[11px] w-[11px]" strokeWidth={2} />
|
<Heart className="h-[11px] w-[11px]" strokeWidth={2} />
|
||||||
{(20 + (index % 9) * 11).toLocaleString()}
|
{(20 + (featuredItems.indexOf(item) % 9) * 11).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<Gem className="h-[11px] w-[11px]" strokeWidth={2} />
|
<Gem className="h-[11px] w-[11px]" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config";
|
import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config";
|
||||||
import type { NavLink, TutorialVideo } from "./types";
|
import type { NavLink, TutorialVideo } from "./types";
|
||||||
|
import { HomeImage } from "./home-image";
|
||||||
|
|
||||||
type SidebarItem = {
|
type SidebarItem = {
|
||||||
iconUrl?: string | null;
|
iconUrl?: string | null;
|
||||||
@ -183,17 +184,18 @@ export function LovartSidebar({
|
|||||||
onClick={onHome}
|
onClick={onHome}
|
||||||
title={siteName}
|
title={siteName}
|
||||||
>
|
>
|
||||||
{siteLogo ? (
|
<HomeImage
|
||||||
<img
|
sources={[siteLogo]}
|
||||||
src={siteLogo}
|
|
||||||
alt={siteName}
|
alt={siteName}
|
||||||
|
loading="eager"
|
||||||
className="h-7 w-7 rounded-full object-cover"
|
className="h-7 w-7 rounded-full object-cover"
|
||||||
/>
|
imageClassName="h-7 w-7 rounded-full object-cover"
|
||||||
) : (
|
fallback={
|
||||||
<span 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 aria-hidden="true" className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-[12px] font-semibold text-white dark:bg-white dark:text-black">
|
||||||
{siteName.slice(0, 1).toUpperCase()}
|
{siteName.slice(0, 1).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">{siteName}</TooltipContent>
|
<TooltipContent side="right">{siteName}</TooltipContent>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"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";
|
||||||
@ -306,6 +307,10 @@ export function ImagePreview({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isRetiredStorageUrl(src) || isRetiredStorageUrl(original)) {
|
||||||
|
return <div role="status" className="my-2 rounded-lg border bg-muted p-4 text-sm text-muted-foreground">{RETIRED_STORAGE_MESSAGE}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 缩略图(显示压缩后的缩略图)*/}
|
{/* 缩略图(显示压缩后的缩略图)*/}
|
||||||
|
|||||||
33
docs/retired-storage.md
Normal file
33
docs/retired-storage.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# 旧存储停用与七牛上传核查
|
||||||
|
|
||||||
|
## s3.1080p.hk
|
||||||
|
|
||||||
|
该存储已永久停用,文件不可恢复。它与 2026 年 10 月 14 日迁入七牛的阿里云 OSS 资源不是同一套存储。
|
||||||
|
|
||||||
|
- 首页精选只展示原文件仍在有效存储上的记录,统计和分页按同一条件计算。
|
||||||
|
- 历史生成记录、聊天、画布节点保留。旧图片显示永久失效说明,不再发起下载、归档或上传到旧桶。
|
||||||
|
- 单图下载和代理返回 HTTP 410 / `storage_retired`;批量下载将对应文件记为失败,不重试旧源。
|
||||||
|
- 其他存储的临时加载失败仍展示错误卡片和重试入口。
|
||||||
|
- 黑色 Logo 为 `public/brand/logo-black.png`;白色版使用 SVG 色彩滤镜保留同一 PNG 的透明度和轮廓。两者随应用发布,不依赖对象存储。
|
||||||
|
|
||||||
|
一次性配置整理:在生产运行目录执行 `node scripts/storage/retire-1080p-storage.cjs` 预览,增加 `--apply` 停用旧配置、取消失效图片的精选标记并更新 Logo。执行前将原字段保存至 `/var/lib/ai-chatbot/retired-storage-20261103/`。该操作不删除图片记录或对象文件。
|
||||||
|
|
||||||
|
2026-11-03 核查:旧源生成记录 67,267 条;精选 65 条中 59 条来自旧源;旧源图片归档任务为 0。默认上传存储为七牛 `gongxiangpan-sc`。
|
||||||
|
|
||||||
|
## 2026-10-14 上传峰值
|
||||||
|
|
||||||
|
统计时区 UTC+8,GB 按十进制计算。数据来自七牛只读上传统计、对象清单和生产机迁移记录。
|
||||||
|
|
||||||
|
| 范围 | 对象数 | 字节数 | GB |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| 七牛当天上传流量 | — | 825,113,404,030 | 825.11 |
|
||||||
|
| 当天写入且仍存在的 `legacy-oss/demo-sparkai/` | 409,821 | 812,788,883,061 | 812.79 |
|
||||||
|
| 当天写入且仍存在的 `legacy-s3/gongxiangpan/` | 539 | 865,360,590 | 0.87 |
|
||||||
|
| 当天写入且仍存在的其他目录 | 3,382 | 9,735,597,543 | 9.74 |
|
||||||
|
| 上传流量与当天现存对象大小差额 | — | 1,723,562,836 | 1.72 |
|
||||||
|
|
||||||
|
813.65 GB 位于本项目历史迁移目录。主要写入发生在 19:00–21:00。源分别为 `demo-sparkai.oss-cn-shanghai.aliyuncs.com` 和 `s3.jiansun.vip/gongxiangpan`,符合仓库迁移脚本的目标路径。
|
||||||
|
|
||||||
|
生产机 `/var/lib/ai-chatbot/aliyun-qiniu-20261014/verified.jsonl` 有 349,784 个去重后的目标对象、629,920,510,316 字节(629.92 GB)的逐对象验证记录。当前迁移目录比该回执多 60,576 个对象、183.73 GB;目录归属能够确认,现有回执没有覆盖这些对象。
|
||||||
|
|
||||||
|
桶由多个业务共用。其他目录中 `uploads/` 为 7.97 GB,其余包含 `dabi/`、`exports/`、`accio-ghost/` 和 `shared-drive/`,不能全部归为画布新生成资源。上传统计包含传输量,对象清单反映现存对象,不足以单独解释最后 1.72 GB 的差额。本次未删除或改动七牛对象。
|
||||||
83
lib/admin/media-records.ts
Normal file
83
lib/admin/media-records.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { sql, type SQL } from "drizzle-orm";
|
||||||
|
|
||||||
|
export function parseMediaQuery(params: URLSearchParams) {
|
||||||
|
const integer = (key: string, fallback: number, max: number) => {
|
||||||
|
const value = Number(params.get(key));
|
||||||
|
return Number.isInteger(value) && value > 0 ? Math.min(value, max) : fallback;
|
||||||
|
};
|
||||||
|
const date = (key: string) => {
|
||||||
|
const value = params.get(key) || "";
|
||||||
|
return /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
page: integer("page", 1, 100000),
|
||||||
|
pageSize: integer("pageSize", 40, 100),
|
||||||
|
search: (params.get("search") || "").trim().slice(0, 128),
|
||||||
|
type: ["image", "video"].includes(params.get("type") || "") ? params.get("type")! : "all",
|
||||||
|
model: params.get("model") || "",
|
||||||
|
featured: params.get("featured") === "true",
|
||||||
|
startDate: date("startDate"),
|
||||||
|
endDate: date("endDate"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaRecordsQuery(params: ReturnType<typeof parseMediaQuery>) {
|
||||||
|
const conditions: SQL[] = [sql`true`];
|
||||||
|
if (params.search)
|
||||||
|
conditions.push(
|
||||||
|
sql`exists (select 1 from "User" u where u.id=m."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`,
|
||||||
|
);
|
||||||
|
if (params.startDate) conditions.push(sql`m."createdAt" >= ${params.startDate}::date`);
|
||||||
|
if (params.endDate)
|
||||||
|
conditions.push(sql`m."createdAt" < ${params.endDate}::date + interval '1 day'`);
|
||||||
|
if (params.model) conditions.push(sql`(m."modelId" = ${params.model} or m."modelId" in (select id::text from "AiModel" where "modelId"=${params.model}))`);
|
||||||
|
const where = sql.join(conditions, sql` and `);
|
||||||
|
const videoFilters: SQL[] = [sql`true`];
|
||||||
|
if (params.startDate)
|
||||||
|
videoFilters.push(sql`v."createdAt">=${params.startDate}::date - interval '8 hours'`);
|
||||||
|
if (params.endDate)
|
||||||
|
videoFilters.push(sql`v."createdAt"<${params.endDate}::date+interval '16 hours'`);
|
||||||
|
if (params.model) videoFilters.push(sql`(v."video_metadata"->>'model'=${params.model} or v."video_metadata"->>'model' in (select id::text from "AiModel" where "modelId"=${params.model}))`);
|
||||||
|
if (params.search)
|
||||||
|
videoFilters.push(
|
||||||
|
sql`exists (select 1 from "User" u where u.id=c."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`,
|
||||||
|
);
|
||||||
|
return sql`
|
||||||
|
with video_candidates as materialized (
|
||||||
|
select v.id, v."chatId", v."createdAt" + interval '8 hours' as "createdAt", v."video_metadata", c."userId",
|
||||||
|
coalesce(nullif(v."permanent_video_url", ''), nullif(v."temporary_video_url", ''),
|
||||||
|
(regexp_match(t.body, '<video[^>]+src="([^"]+)"', 'i'))[1],
|
||||||
|
(regexp_match(t.body, '(https?://[^[:space:]"<>]+\\.mp4[^[:space:]"<>)]*)', 'i'))[1]) as url
|
||||||
|
from "Message_v2" v join "Chat" c on c.id=v."chatId"
|
||||||
|
cross join lateral (select case when jsonb_typeof(v.parts::jsonb)='array'
|
||||||
|
then (select string_agg(p->>'text', E'\\n') from jsonb_array_elements(v.parts::jsonb) p)
|
||||||
|
else v.parts::jsonb #>> '{}' end as body) t
|
||||||
|
where ${params.type !== "image" && !params.featured} and v.role='assistant'
|
||||||
|
and ${sql.join(videoFilters, sql` and `)}
|
||||||
|
and (v."permanent_video_url" is not null or v."temporary_video_url" is not null or v.parts::text ilike '%<video%' or v.parts::text ilike '%.mp4%')
|
||||||
|
), media as not materialized (
|
||||||
|
select g.id, 'image'::text as kind, g."createdAt", g."userId", g."modelId", null::text as url
|
||||||
|
from "GeneratedImage" g where ${params.type !== "video"} and (${!params.featured} or g."isFeatured")
|
||||||
|
union all
|
||||||
|
select id, 'video', "createdAt", "userId", "video_metadata"->>'model', url
|
||||||
|
from video_candidates where url is not null
|
||||||
|
), filtered as not materialized (select * from media m where ${where}),
|
||||||
|
totals as (select count(*)::int as total, count(*) filter(where kind='image')::int as images, count(*) filter(where kind='video')::int as videos from filtered),
|
||||||
|
page_rows as (
|
||||||
|
select * from filtered order by "createdAt" desc, kind, id desc
|
||||||
|
limit ${params.pageSize} offset ${(params.page - 1) * params.pageSize}
|
||||||
|
)
|
||||||
|
select t.total, t.images as "totalImages", t.videos as "totalVideos",
|
||||||
|
coalesce((select jsonb_agg(jsonb_build_object(
|
||||||
|
'id', case when p.kind='video' then 'video-'||p.id::text else p.id::text end,
|
||||||
|
'recordType',p.kind,'userId',p."userId",'userName',u.name,'userEmail',u.email,'userDepartment',u.department,
|
||||||
|
'createdAt',p."createdAt",'modelId',p."modelId",'chatId',coalesce(g."chatId",v."chatId"),
|
||||||
|
'thumbnailUrl',coalesce(p.url,g."thumbnailUrl"),'originalUrl',coalesce(p.url,g."originalUrl"),'videoUrl',p.url,
|
||||||
|
'prompt',coalesce(g.prompt,v."video_metadata"->>'prompt'),'imageSize',coalesce(g."imageSize",v."video_metadata"->>'resolution'),
|
||||||
|
'isFeatured',coalesce(g."isFeatured",false),'featuredCategory',g."featuredCategory",'featuredAt',g."featuredAt" + interval '8 hours'
|
||||||
|
) order by p."createdAt" desc,p.kind,p.id desc)
|
||||||
|
from page_rows p left join "User" u on u.id=p."userId"
|
||||||
|
left join "GeneratedImage" g on p.kind='image' and g.id=p.id
|
||||||
|
left join "Message_v2" v on p.kind='video' and v.id=p.id),'[]'::jsonb) as records
|
||||||
|
from totals t`;
|
||||||
|
}
|
||||||
31
lib/admin/preview-queue.ts
Normal file
31
lib/admin/preview-queue.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// One queue for the records screen: visible previews get four network slots.
|
||||||
|
const waiting: Array<() => void> = [];
|
||||||
|
let active = 0;
|
||||||
|
function pump() {
|
||||||
|
while (active < 4 && waiting.length) waiting.shift()?.();
|
||||||
|
}
|
||||||
|
export function queuePreview(start: (release: () => void) => void) {
|
||||||
|
let released = false;
|
||||||
|
let started = false;
|
||||||
|
const release = () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
if (started) active--;
|
||||||
|
else {
|
||||||
|
const index = waiting.indexOf(run);
|
||||||
|
if (index >= 0) waiting.splice(index, 1);
|
||||||
|
}
|
||||||
|
pump();
|
||||||
|
};
|
||||||
|
const run = () => {
|
||||||
|
started = true;
|
||||||
|
active++;
|
||||||
|
start(release);
|
||||||
|
};
|
||||||
|
waiting.push(run);
|
||||||
|
pump();
|
||||||
|
// Active fetches release their slot in finally, after abort has settled.
|
||||||
|
return () => {
|
||||||
|
if (!started) release();
|
||||||
|
};
|
||||||
|
}
|
||||||
7
lib/admin/record-time.ts
Normal file
7
lib/admin/record-time.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export function formatRecordTime(value: string) {
|
||||||
|
// The admin API normalizes each storage convention to Beijing wall-clock time.
|
||||||
|
return value
|
||||||
|
.replace("T", " ")
|
||||||
|
.replace(/(?:\.\d+)?Z$/, "")
|
||||||
|
.slice(0, 16);
|
||||||
|
}
|
||||||
15
lib/agents/scene-run-recovery.ts
Normal file
15
lib/agents/scene-run-recovery.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||||
|
import { messageText } from "./replacement-run-recovery";
|
||||||
|
|
||||||
|
type Message = { id: string; role: string; parts?: unknown[] };
|
||||||
|
|
||||||
|
export function getSceneRunResult(messages: Message[], requestMessageId: string) {
|
||||||
|
const index = messages.findIndex((message) => message.id === requestMessageId && message.role === "user");
|
||||||
|
if (index < 0) return null;
|
||||||
|
const replies = messages.slice(index + 1);
|
||||||
|
// A later user turn belongs to another run.
|
||||||
|
if (replies.some((message) => message.role === "user")) return null;
|
||||||
|
const urls = new Set(replies.filter((message) => message.role === "assistant")
|
||||||
|
.flatMap((message) => extractImageUrlsFromText(messageText(message))));
|
||||||
|
return { count: urls.size };
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
|
||||||
|
|
||||||
import { createOpenAI } from "@ai-sdk/openai";
|
import { createOpenAI } from "@ai-sdk/openai";
|
||||||
import {
|
import {
|
||||||
@ -418,7 +419,7 @@ export async function getDynamicModel(
|
|||||||
): Promise<LanguageModel | null> {
|
): Promise<LanguageModel | null> {
|
||||||
try {
|
try {
|
||||||
const model = await getAiModelById({ id: modelId });
|
const model = await getAiModelById({ id: modelId });
|
||||||
if (!model || !model.isEnabled) {
|
if (!model || !model.isEnabled || (model.supportsImageGen && isImageModelBalanceBlocked(model))) {
|
||||||
logger.warn("model unavailable", { modelId });
|
logger.warn("model unavailable", { modelId });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -769,16 +769,15 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
|
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
|
||||||
const preparedImageUrls: string[] = [];
|
const preparedImageUrls = await Promise.all(
|
||||||
for (const imageUrl of selectedImageUrls) {
|
selectedImageUrls.map((imageUrl) =>
|
||||||
preparedImageUrls.push(
|
prepareAlibabaInputImageUrl(
|
||||||
await prepareAlibabaInputImageUrl(
|
|
||||||
imageUrl,
|
imageUrl,
|
||||||
activeModelId,
|
activeModelId,
|
||||||
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
|
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。
|
// qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。
|
||||||
if (
|
if (
|
||||||
|
|||||||
@ -1261,6 +1261,7 @@ export async function submitVip98kExternalTask(params: {
|
|||||||
const configuredInvocations = getVip98kInvocations(taskParams.modelConfig);
|
const configuredInvocations = getVip98kInvocations(taskParams.modelConfig);
|
||||||
const invocation = selectVip98kTaskInvocation({
|
const invocation = selectVip98kTaskInvocation({
|
||||||
modelId: taskParams.modelId,
|
modelId: taskParams.modelId,
|
||||||
|
kind: taskParams.kind,
|
||||||
configuredInvocation: configuredInvocations[0] || null,
|
configuredInvocation: configuredInvocations[0] || null,
|
||||||
configuredInvocations,
|
configuredInvocations,
|
||||||
imageCount: imageUrls.length,
|
imageCount: imageUrls.length,
|
||||||
@ -1438,7 +1439,7 @@ function normalizeDuration(value: unknown) {
|
|||||||
return Math.round(numeric);
|
return Math.round(numeric);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) {
|
export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) {
|
||||||
const videoParams = params.videoParams || {};
|
const videoParams = params.videoParams || {};
|
||||||
const imageParams = params.imageParams || {};
|
const imageParams = params.imageParams || {};
|
||||||
const promptTitle = params.prompt.split(/\s+/).slice(0, 8).join(" ").slice(0, 80);
|
const promptTitle = params.prompt.split(/\s+/).slice(0, 8).join(" ").slice(0, 80);
|
||||||
@ -1448,6 +1449,9 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
|
|||||||
body.displayName = promptTitle || params.modelId;
|
body.displayName = promptTitle || params.modelId;
|
||||||
}
|
}
|
||||||
if ("prompt" in body || !invocation) body.prompt = params.prompt;
|
if ("prompt" in body || !invocation) body.prompt = params.prompt;
|
||||||
|
if (params.kind === "image" && invocationSupportsBodyField(invocation, "n")) {
|
||||||
|
body.n = imageParams.imageCount ?? imageParams.n ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
const aspectRatio =
|
const aspectRatio =
|
||||||
normalizeRatio(videoParams.ratio) ||
|
normalizeRatio(videoParams.ratio) ||
|
||||||
@ -1697,7 +1701,7 @@ async function runTask(params: Vip98kTaskParams) {
|
|||||||
}
|
}
|
||||||
const imageSize = String(params.imageParams?.imageSize || "2K").trim() || "2K";
|
const imageSize = String(params.imageParams?.imageSize || "2K").trim() || "2K";
|
||||||
const archiveStartedAt = Date.now();
|
const archiveStartedAt = Date.now();
|
||||||
const archived = await Promise.all(outputUrls.slice(0, 1).map(archiveOutputImage));
|
const archived = await Promise.all(outputUrls.map(archiveOutputImage));
|
||||||
logger.info("result archive completed", {
|
logger.info("result archive completed", {
|
||||||
modelId: params.modelId,
|
modelId: params.modelId,
|
||||||
outputCount: archived.length,
|
outputCount: archived.length,
|
||||||
|
|||||||
74
lib/ai/runtime/image-balance.ts
Normal file
74
lib/ai/runtime/image-balance.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { isProviderBalanceError } from "@/lib/request-error";
|
||||||
|
|
||||||
|
type BalanceState = {
|
||||||
|
healthLastEventType?: unknown;
|
||||||
|
healthLastError?: unknown;
|
||||||
|
healthAutoDisabledReason?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isImageModelBalanceBlocked(model: BalanceState | null | undefined) {
|
||||||
|
return Boolean(
|
||||||
|
model &&
|
||||||
|
((model.healthLastEventType === "quota" && isProviderBalanceError(model.healthLastError)) ||
|
||||||
|
isProviderBalanceError(model.healthAutoDisabledReason)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfiguredImageBalanceFallback<
|
||||||
|
T extends {
|
||||||
|
model: BalanceState & {
|
||||||
|
id: string;
|
||||||
|
modelId: string;
|
||||||
|
isEnabled: boolean;
|
||||||
|
supportsImageGen: boolean;
|
||||||
|
imageGenConfig?: unknown;
|
||||||
|
};
|
||||||
|
provider: { isEnabled?: boolean };
|
||||||
|
},
|
||||||
|
>(
|
||||||
|
requested: BalanceState & { fallbackModelId1?: string | null; fallbackModelId2?: string | null },
|
||||||
|
entries: T[],
|
||||||
|
input: {
|
||||||
|
imageSize?: unknown;
|
||||||
|
aspectRatio?: unknown;
|
||||||
|
referenceImageCount?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!isImageModelBalanceBlocked(requested)) return undefined;
|
||||||
|
for (const ref of [requested.fallbackModelId1, requested.fallbackModelId2]) {
|
||||||
|
if (!ref) continue;
|
||||||
|
const entry = entries.find(({ model }) => model.id === ref || model.modelId === ref);
|
||||||
|
if (
|
||||||
|
!entry ||
|
||||||
|
!entry.model.isEnabled ||
|
||||||
|
entry.provider.isEnabled === false ||
|
||||||
|
!entry.model.supportsImageGen ||
|
||||||
|
isImageModelBalanceBlocked(entry.model)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const config = entry.model.imageGenConfig as Record<string, unknown> | null;
|
||||||
|
const size = String(input.imageSize || "").toUpperCase();
|
||||||
|
const ratio = String(input.aspectRatio || "");
|
||||||
|
if (
|
||||||
|
size &&
|
||||||
|
Array.isArray(config?.supportedImageSizes) &&
|
||||||
|
!config.supportedImageSizes.includes(size)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
if (
|
||||||
|
ratio &&
|
||||||
|
ratio !== "auto" &&
|
||||||
|
Array.isArray(config?.supportedAspectRatios) &&
|
||||||
|
!config.supportedAspectRatios.includes(ratio)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
if (
|
||||||
|
input.referenceImageCount &&
|
||||||
|
typeof config?.maxReferenceImages === "number" &&
|
||||||
|
input.referenceImageCount > config.maxReferenceImages
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import { isProviderBalanceError } from "@/lib/request-error";
|
|
||||||
|
|
||||||
const IMAGE_FAILOVER_BALANCE_COOLDOWN_MS = 2 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
export function isImageFallbackModelBalanceCoolingDown(
|
|
||||||
model: {
|
|
||||||
healthLastEventType?: unknown;
|
|
||||||
healthLastError?: unknown;
|
|
||||||
healthLastFailureAt?: unknown;
|
|
||||||
} | null | undefined,
|
|
||||||
now = Date.now(),
|
|
||||||
) {
|
|
||||||
if (model?.healthLastEventType !== "quota" || !isProviderBalanceError(model.healthLastError)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const failedAt = new Date(String(model.healthLastFailureAt || "")).getTime();
|
|
||||||
return Number.isFinite(failedAt) && now - failedAt < IMAGE_FAILOVER_BALANCE_COOLDOWN_MS && now >= failedAt;
|
|
||||||
}
|
|
||||||
@ -499,6 +499,7 @@ export async function reviveAiModelsFromCooldown(params?: { modelId?: string })
|
|||||||
|
|
||||||
const dueModels = candidates.filter((model) => {
|
const dueModels = candidates.filter((model) => {
|
||||||
if (isCredentialFailureBan(model.healthAutoDisabledReason)) return false;
|
if (isCredentialFailureBan(model.healthAutoDisabledReason)) return false;
|
||||||
|
if (isProviderBalanceError(model.healthAutoDisabledReason)) return false;
|
||||||
const cooldownStartedAt =
|
const cooldownStartedAt =
|
||||||
model.healthAutoDisabledAt ||
|
model.healthAutoDisabledAt ||
|
||||||
model.healthLastFailureAt ||
|
model.healthLastFailureAt ||
|
||||||
@ -568,6 +569,7 @@ export async function recordAiModelHealthSuccess(params: {
|
|||||||
healthStatus: aiModel.healthStatus,
|
healthStatus: aiModel.healthStatus,
|
||||||
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
|
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
|
||||||
healthObservationStartedAt: aiModel.healthObservationStartedAt,
|
healthObservationStartedAt: aiModel.healthObservationStartedAt,
|
||||||
|
healthAutoDisabledReason: aiModel.healthAutoDisabledReason,
|
||||||
})
|
})
|
||||||
.from(aiModel)
|
.from(aiModel)
|
||||||
.where(eq(aiModel.id, modelId))
|
.where(eq(aiModel.id, modelId))
|
||||||
@ -575,6 +577,9 @@ export async function recordAiModelHealthSuccess(params: {
|
|||||||
|
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|
||||||
|
// A previously accepted task can finish after the account runs out of credit.
|
||||||
|
if (isProviderBalanceError(existing.healthAutoDisabledReason)) return;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// A request that started before the circuit opened can still prove the
|
// A request that started before the circuit opened can still prove the
|
||||||
// provider is reachable. Treat that success as the circuit's half-open
|
// provider is reachable. Treat that success as the circuit's half-open
|
||||||
@ -662,6 +667,7 @@ export async function recordAiModelHealthFailure(params: {
|
|||||||
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
|
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
|
||||||
healthObservationStartedAt: aiModel.healthObservationStartedAt,
|
healthObservationStartedAt: aiModel.healthObservationStartedAt,
|
||||||
healthAutoDisabledAt: aiModel.healthAutoDisabledAt,
|
healthAutoDisabledAt: aiModel.healthAutoDisabledAt,
|
||||||
|
healthAutoDisabledReason: aiModel.healthAutoDisabledReason,
|
||||||
})
|
})
|
||||||
.from(aiModel)
|
.from(aiModel)
|
||||||
.where(eq(aiModel.id, modelId))
|
.where(eq(aiModel.id, modelId))
|
||||||
@ -678,13 +684,15 @@ export async function recordAiModelHealthFailure(params: {
|
|||||||
: existing.healthConsecutiveFailures || 0;
|
: existing.healthConsecutiveFailures || 0;
|
||||||
const shouldAutoDisable =
|
const shouldAutoDisable =
|
||||||
classification.counted &&
|
classification.counted &&
|
||||||
!existing.excludeFromFailover &&
|
(classification.eventType === "quota" ||
|
||||||
|
(!existing.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 nextStatus = shouldAutoDisable
|
const balanceDisabled = isProviderBalanceError(existing.healthAutoDisabledReason);
|
||||||
|
const nextStatus = shouldAutoDisable || balanceDisabled
|
||||||
? "auto_disabled"
|
? "auto_disabled"
|
||||||
: classification.counted
|
: classification.counted
|
||||||
? "degraded"
|
? "degraded"
|
||||||
@ -737,7 +745,7 @@ export async function recordAiModelHealthFailure(params: {
|
|||||||
healthUpdatedAt: now,
|
healthUpdatedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
if (shouldAutoDisable) {
|
if (shouldAutoDisable && !balanceDisabled) {
|
||||||
updateData.healthAutoDisabledAt = now;
|
updateData.healthAutoDisabledAt = now;
|
||||||
updateData.healthAutoDisabledReason = autoDisabledReason || errorMessage;
|
updateData.healthAutoDisabledReason = autoDisabledReason || errorMessage;
|
||||||
} else if (existing.healthStatus !== "auto_disabled" && !isObservation) {
|
} else if (existing.healthStatus !== "auto_disabled" && !isObservation) {
|
||||||
|
|||||||
@ -238,7 +238,10 @@ export function bindVip98kImages(
|
|||||||
imageUrls: string[],
|
imageUrls: string[],
|
||||||
) {
|
) {
|
||||||
const bindingKeys: string[] = [];
|
const bindingKeys: string[] = [];
|
||||||
if (imageUrls.length === 0) return bindingKeys;
|
if (imageUrls.length === 0) {
|
||||||
|
for (const key of [...IMAGE_ARRAY_INPUT_KEYS, ...IMAGE_SINGLE_INPUT_KEYS, "video_clips"]) delete body[key];
|
||||||
|
return bindingKeys;
|
||||||
|
}
|
||||||
|
|
||||||
for (const key of IMAGE_ARRAY_INPUT_KEYS) {
|
for (const key of IMAGE_ARRAY_INPUT_KEYS) {
|
||||||
if (!(key in body)) continue;
|
if (!(key in body)) continue;
|
||||||
@ -305,6 +308,7 @@ function scoreReferenceInvocation(invocation: Vip98kTaskInvocation) {
|
|||||||
|
|
||||||
export function selectVip98kTaskInvocation(params: {
|
export function selectVip98kTaskInvocation(params: {
|
||||||
modelId: string;
|
modelId: string;
|
||||||
|
kind?: string;
|
||||||
configuredInvocation: Vip98kTaskInvocation | null;
|
configuredInvocation: Vip98kTaskInvocation | null;
|
||||||
configuredInvocations?: Vip98kTaskInvocation[];
|
configuredInvocations?: Vip98kTaskInvocation[];
|
||||||
imageCount?: number;
|
imageCount?: number;
|
||||||
@ -334,6 +338,17 @@ export function selectVip98kTaskInvocation(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.kind === "image" && !params.imageCount) {
|
||||||
|
const textImageTask = candidates.find(candidate => {
|
||||||
|
const properties = candidate.inputSchema?.properties as Record<string, unknown> | undefined;
|
||||||
|
const required = candidate.inputSchema?.required as string[] | undefined;
|
||||||
|
return candidate.kind === "task" && candidate.path === "/extensions/v1/tasks" &&
|
||||||
|
invocationTargetsModel(candidate, params.modelId) && properties?.resolution && properties?.aspect_ratio &&
|
||||||
|
!required?.some(key => [...IMAGE_ARRAY_INPUT_KEYS, ...IMAGE_SINGLE_INPUT_KEYS, "video_clips"].includes(key));
|
||||||
|
});
|
||||||
|
if (textImageTask) return resolveVip98kTaskInvocation(params.modelId, textImageTask);
|
||||||
|
}
|
||||||
|
|
||||||
return resolveVip98kTaskInvocation(
|
return resolveVip98kTaskInvocation(
|
||||||
params.modelId,
|
params.modelId,
|
||||||
params.configuredInvocation || candidates[0] || null,
|
params.configuredInvocation || candidates[0] || null,
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { agentImageCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-sto
|
|||||||
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
|
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
|
||||||
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
|
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
|
||||||
import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
||||||
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
import { getAiModelById, getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||||
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
||||||
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
|
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
|
||||||
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
|
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
|
||||||
@ -43,7 +43,8 @@ import {
|
|||||||
shouldFailoverAiModelError,
|
shouldFailoverAiModelError,
|
||||||
} from "@/lib/ai/runtime/model-health";
|
} from "@/lib/ai/runtime/model-health";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
|
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
|
||||||
|
import { isProviderBalanceError } from "@/lib/request-error";
|
||||||
|
|
||||||
export interface CanvasLocalEditCompositeRegion {
|
export interface CanvasLocalEditCompositeRegion {
|
||||||
x: number;
|
x: number;
|
||||||
@ -198,6 +199,7 @@ export interface CanvasGenerateImageRequest {
|
|||||||
agentImageMaxCreditCost?: number;
|
agentImageMaxCreditCost?: number;
|
||||||
internalFailoverTriedModelRefs?: string[];
|
internalFailoverTriedModelRefs?: string[];
|
||||||
internalFailoverTriedProviderTypes?: string[];
|
internalFailoverTriedProviderTypes?: string[];
|
||||||
|
internalBalanceBlockedProviderIds?: string[];
|
||||||
onQueueStatusChange?: (
|
onQueueStatusChange?: (
|
||||||
status: CanvasImageModelQueueStatus & {
|
status: CanvasImageModelQueueStatus & {
|
||||||
modelName?: string | null;
|
modelName?: string | null;
|
||||||
@ -4512,7 +4514,7 @@ function findCanvasFailoverModel(params: {
|
|||||||
!item.model.isEnabled ||
|
!item.model.isEnabled ||
|
||||||
item.model.healthStatus === "auto_disabled" ||
|
item.model.healthStatus === "auto_disabled" ||
|
||||||
item.model.isSubModel ||
|
item.model.isSubModel ||
|
||||||
isImageFallbackModelBalanceCoolingDown(item.model) ||
|
isImageModelBalanceBlocked(item.model) ||
|
||||||
(item.model.excludeFromFailover && !isSameNameScaleAdjustFallback)
|
(item.model.excludeFromFailover && !isSameNameScaleAdjustFallback)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@ -4904,7 +4906,20 @@ export async function generateCanvasImage(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const allModels = await getEnabledAiModelsWithProvider();
|
const allModels = await getEnabledAiModelsWithProvider();
|
||||||
let imageModels = allModels.filter((m) => m.model.supportsImageGen);
|
const balanceBlockedProviderIds = new Set(request.internalBalanceBlockedProviderIds || []);
|
||||||
|
let imageModels = allModels.filter((m) => m.model.supportsImageGen && !isImageModelBalanceBlocked(m.model) && !balanceBlockedProviderIds.has(m.provider.id));
|
||||||
|
const requestedModel = allModels.find(entry => entry.model.id === model || entry.model.modelId === model)?.model ||
|
||||||
|
(/^[0-9a-f-]{36}$/i.test(String(model || "")) ? await getAiModelById({ id: model! }) : undefined);
|
||||||
|
let effectiveModel = model;
|
||||||
|
if (requestedModel?.supportsImageGen && isImageModelBalanceBlocked(requestedModel)) {
|
||||||
|
const replacement = request.allowFailover !== false && request.agentImageMode !== "pinned"
|
||||||
|
? resolveConfiguredImageBalanceFallback(requestedModel, imageModels, {
|
||||||
|
imageSize: request.imageSize, aspectRatio: request.aspectRatio,
|
||||||
|
referenceImageCount: new Set([...(referenceImages || []), referenceImage].filter(Boolean)).size,
|
||||||
|
}) : undefined;
|
||||||
|
if (!replacement) throw new Error("当前生图线路余额不足,已暂停新请求;请切换可用模型,已有任务和图片会保留。");
|
||||||
|
effectiveModel = replacement.model.id;
|
||||||
|
}
|
||||||
if (request.agentImageMode === "auto") {
|
if (request.agentImageMode === "auto") {
|
||||||
const states = await Promise.all(imageModels.map(async entry => ({ entry, paused: await agentImageCircuitStore.isPaused(entry) })));
|
const states = await Promise.all(imageModels.map(async entry => ({ entry, paused: await agentImageCircuitStore.isPaused(entry) })));
|
||||||
imageModels = states.filter(item => !item.paused && item.entry.model.healthStatus !== "auto_disabled" &&
|
imageModels = states.filter(item => !item.paused && item.entry.model.healthStatus !== "auto_disabled" &&
|
||||||
@ -4916,8 +4931,8 @@ export async function generateCanvasImage(
|
|||||||
throw new Error("暂无可用的图片生成模型");
|
throw new Error("暂无可用的图片生成模型");
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedModel = model
|
let selectedModel = effectiveModel
|
||||||
? imageModels.find((m) => m.model.modelId === model || m.model.id === model)
|
? imageModels.find((m) => m.model.modelId === effectiveModel || m.model.id === effectiveModel)
|
||||||
: imageModels.find((m) => m.model.isDefault) || imageModels[0];
|
: imageModels.find((m) => m.model.isDefault) || imageModels[0];
|
||||||
|
|
||||||
if (!selectedModel) {
|
if (!selectedModel) {
|
||||||
@ -5654,7 +5669,7 @@ export async function generateCanvasImage(
|
|||||||
? request.internalRetryVariantIndex + 1
|
? request.internalRetryVariantIndex + 1
|
||||||
: 0;
|
: 0;
|
||||||
const retryVariant = retryVariants[nextRetryVariantIndex];
|
const retryVariant = retryVariants[nextRetryVariantIndex];
|
||||||
if (retryVariant && !request.agentImageMode) {
|
if (retryVariant && !request.agentImageMode && !isProviderBalanceError(caughtError)) {
|
||||||
const {
|
const {
|
||||||
debugMeta: retryVariantDebugMeta,
|
debugMeta: retryVariantDebugMeta,
|
||||||
label: retryVariantLabel,
|
label: retryVariantLabel,
|
||||||
@ -5710,7 +5725,8 @@ export async function generateCanvasImage(
|
|||||||
} else if (request.agentImageMode) {
|
} else if (request.agentImageMode) {
|
||||||
const failure = agentImageFailurePolicy(caughtError);
|
const failure = agentImageFailurePolicy(caughtError);
|
||||||
if (failure.pause) await agentImageCircuitStore.fail(selectedModel, failure.pause);
|
if (failure.pause) await agentImageCircuitStore.fail(selectedModel, failure.pause);
|
||||||
} else {
|
}
|
||||||
|
if (!scaleAdjustRejected && (!request.agentImageMode || isProviderBalanceError(caughtError))) {
|
||||||
await recordAiModelHealthFailure({
|
await recordAiModelHealthFailure({
|
||||||
model: selectedModel.model,
|
model: selectedModel.model,
|
||||||
provider,
|
provider,
|
||||||
@ -5756,17 +5772,18 @@ export async function generateCanvasImage(
|
|||||||
...(Array.isArray(request.internalFailoverTriedProviderTypes)
|
...(Array.isArray(request.internalFailoverTriedProviderTypes)
|
||||||
? request.internalFailoverTriedProviderTypes
|
? request.internalFailoverTriedProviderTypes
|
||||||
: []),
|
: []),
|
||||||
...(request.agentImageMode !== "auto" && shouldFailover &&
|
...(request.agentImageMode !== "auto" && !isProviderBalanceError(caughtError) && shouldFailover &&
|
||||||
failureClassification.reason !== "non_provider_failure" &&
|
failureClassification.reason !== "non_provider_failure" &&
|
||||||
failureClassification.reason !== "user_cancelled"
|
failureClassification.reason !== "user_cancelled"
|
||||||
? [provider.type]
|
? [provider.type]
|
||||||
: []),
|
: []),
|
||||||
].filter(Boolean))
|
].filter(Boolean))
|
||||||
);
|
);
|
||||||
|
if (isProviderBalanceError(caughtError)) balanceBlockedProviderIds.add(provider.id);
|
||||||
const fallbackModel = allowFailover && shouldFailover
|
const fallbackModel = allowFailover && shouldFailover
|
||||||
? findCanvasFailoverModel({
|
? findCanvasFailoverModel({
|
||||||
selectedModel,
|
selectedModel,
|
||||||
imageModels,
|
imageModels: imageModels.filter(entry => !balanceBlockedProviderIds.has(entry.provider.id)),
|
||||||
failedModelRefs,
|
failedModelRefs,
|
||||||
failedProviderTypes,
|
failedProviderTypes,
|
||||||
preferLowCostSameNameProviders: Boolean(
|
preferLowCostSameNameProviders: Boolean(
|
||||||
@ -5797,6 +5814,7 @@ export async function generateCanvasImage(
|
|||||||
model: fallbackModel.model.id,
|
model: fallbackModel.model.id,
|
||||||
internalFailoverTriedModelRefs: failedModelRefs,
|
internalFailoverTriedModelRefs: failedModelRefs,
|
||||||
internalFailoverTriedProviderTypes: failedProviderTypes,
|
internalFailoverTriedProviderTypes: failedProviderTypes,
|
||||||
|
internalBalanceBlockedProviderIds: [...balanceBlockedProviderIds],
|
||||||
debugMeta: {
|
debugMeta: {
|
||||||
...debugMeta,
|
...debugMeta,
|
||||||
failoverFromModelId: selectedModel.model.modelId,
|
failoverFromModelId: selectedModel.model.modelId,
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
/** A half-open span containing a complete Markdown image or linked image. */
|
/** A half-open span containing a complete Markdown link or image. */
|
||||||
export type MarkdownImageRange = { start: number; end: number };
|
export type MarkdownImageRange = { start: number; end: number };
|
||||||
|
|
||||||
type Parsed = { kind: "complete"; end: number } | { kind: "pending" } | { kind: "invalid" };
|
type Parsed = { kind: "complete"; end: number } | { kind: "pending" } | { kind: "invalid" };
|
||||||
|
|
||||||
// This scanner recognizes syntax only; rendering and URL safety remain the
|
// This scanner recognizes syntax only; rendering and URL safety remain the
|
||||||
// responsibility of the existing Markdown renderer and image splitter.
|
// responsibility of the existing Markdown renderer and image splitter.
|
||||||
function readDestination(text: string, start: number): Parsed {
|
function readDestination(text: string, start: number, allowWhitespace = false): Parsed {
|
||||||
if (start >= text.length) return { kind: "pending" };
|
if (start >= text.length) return { kind: "pending" };
|
||||||
if (text[start] !== "(") return { kind: "invalid" };
|
if (text[start] !== "(") return { kind: "invalid" };
|
||||||
let depth = 1;
|
let depth = 1;
|
||||||
@ -16,7 +16,7 @@ function readDestination(text: string, start: number): Parsed {
|
|||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (/\s/.test(char)) return { kind: "invalid" };
|
if (char === "\n" || char === "\r" || (!allowWhitespace && /\s/.test(char))) return { kind: "invalid" };
|
||||||
if (char === "(") depth += 1;
|
if (char === "(") depth += 1;
|
||||||
if (char === ")" && --depth === 0) return { kind: "complete", end: i + 1 };
|
if (char === ")" && --depth === 0) return { kind: "complete", end: i + 1 };
|
||||||
}
|
}
|
||||||
@ -47,7 +47,37 @@ function readImage(text: string, start: number, wrapped: boolean): Parsed {
|
|||||||
return { kind: "pending" };
|
return { kind: "pending" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Locate completed image syntax and a potentially incomplete trailing image.
|
function readLink(text: string, start: number): Parsed {
|
||||||
|
let depth = 1;
|
||||||
|
for (let i = start + 1; i < text.length; i += 1) {
|
||||||
|
const char = text[i];
|
||||||
|
// An unfinished bracket on an earlier line must not hide later output.
|
||||||
|
if (char === "\n" || char === "\r") return { kind: "invalid" };
|
||||||
|
if (char === "\\") {
|
||||||
|
if (i + 1 >= text.length) return { kind: "pending" };
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "[") depth += 1;
|
||||||
|
if (char !== "]" || --depth !== 0) continue;
|
||||||
|
if (text[i + 1] === "[") {
|
||||||
|
for (let j = i + 2; j < text.length; j += 1) {
|
||||||
|
if (text[j] === "\n" || text[j] === "\r") return { kind: "invalid" };
|
||||||
|
if (text[j] === "\\") {
|
||||||
|
if (j + 1 >= text.length) return { kind: "pending" };
|
||||||
|
j += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (text[j] === "]") return { kind: "complete", end: j + 1 };
|
||||||
|
}
|
||||||
|
return { kind: "pending" };
|
||||||
|
}
|
||||||
|
return readDestination(text, i + 1, true);
|
||||||
|
}
|
||||||
|
return { kind: "pending" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locate complete Markdown links/images and an incomplete trailing construct.
|
||||||
* Code spans/fences and escaped markers remain normal text. */
|
* Code spans/fences and escaped markers remain normal text. */
|
||||||
export function scanMarkdownImageStreaming(text: string): {
|
export function scanMarkdownImageStreaming(text: string): {
|
||||||
ranges: MarkdownImageRange[];
|
ranges: MarkdownImageRange[];
|
||||||
@ -83,8 +113,9 @@ export function scanMarkdownImageStreaming(text: string): {
|
|||||||
const char = text[cursor];
|
const char = text[cursor];
|
||||||
if (char !== "[" && char !== "!") { cursor += 1; continue; }
|
if (char !== "[" && char !== "!") { cursor += 1; continue; }
|
||||||
const wrapped = char === "[";
|
const wrapped = char === "[";
|
||||||
if (wrapped && cursor + 1 < text.length && text[cursor + 1] !== "!") { cursor += 1; continue; }
|
const parsed = wrapped && text[cursor + 1] !== "!"
|
||||||
const parsed = readImage(text, cursor, wrapped);
|
? readLink(text, cursor)
|
||||||
|
: readImage(text, cursor, wrapped);
|
||||||
if (parsed.kind === "pending") return { ranges, pendingStart: cursor };
|
if (parsed.kind === "pending") return { ranges, pendingStart: cursor };
|
||||||
if (parsed.kind === "complete") { ranges.push({ start: cursor, end: parsed.end }); cursor = parsed.end; }
|
if (parsed.kind === "complete") { ranges.push({ start: cursor, end: parsed.end }); cursor = parsed.end; }
|
||||||
else cursor += 1;
|
else cursor += 1;
|
||||||
@ -92,7 +123,7 @@ export function scanMarkdownImageStreaming(text: string): {
|
|||||||
return { ranges, pendingStart: null };
|
return { ranges, pendingStart: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** During active delivery, withhold only unfinished image syntax at the tail. */
|
/** During active delivery, withhold only unfinished link/image syntax at the tail. */
|
||||||
export function stableStreamingMarkdown(text: string, streaming: boolean): string {
|
export function stableStreamingMarkdown(text: string, streaming: boolean): string {
|
||||||
if (!streaming) return text;
|
if (!streaming) return text;
|
||||||
const { pendingStart } = scanMarkdownImageStreaming(text);
|
const { pendingStart } = scanMarkdownImageStreaming(text);
|
||||||
@ -101,7 +132,7 @@ export function stableStreamingMarkdown(text: string, streaming: boolean): strin
|
|||||||
|
|
||||||
export function advancePastAtomicRanges(length: number, target: number, ranges: MarkdownImageRange[]) {
|
export function advancePastAtomicRanges(length: number, target: number, ranges: MarkdownImageRange[]) {
|
||||||
let position = Math.min(length, target);
|
let position = Math.min(length, target);
|
||||||
// Overlapping table/image ranges can require more than one jump.
|
// Overlapping table/link ranges can require more than one jump.
|
||||||
for (let changed = true; changed;) {
|
for (let changed = true; changed;) {
|
||||||
changed = false;
|
changed = false;
|
||||||
for (const range of ranges) {
|
for (const range of ranges) {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
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";
|
||||||
|
|
||||||
@ -296,6 +297,9 @@ export async function fetchCanvasDownloadAsset(params: {
|
|||||||
retryCount?: number;
|
retryCount?: number;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}): Promise<CanvasDownloadAssetResult> {
|
}): Promise<CanvasDownloadAssetResult> {
|
||||||
|
if (isRetiredStorageUrl(params.file.url)) {
|
||||||
|
return { ok: false, url: params.file.url, name: params.file.name, error: RETIRED_STORAGE_MESSAGE };
|
||||||
|
}
|
||||||
let lastError = "";
|
let lastError = "";
|
||||||
const timeoutMs = params.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS;
|
const timeoutMs = params.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS;
|
||||||
const retryCount = params.retryCount || DEFAULT_FETCH_RETRY_COUNT;
|
const retryCount = params.retryCount || DEFAULT_FETCH_RETRY_COUNT;
|
||||||
|
|||||||
@ -1,11 +1,20 @@
|
|||||||
/** Serialize local saves so each request reads the revision returned by the last. */
|
/** Serialize local saves so each request reads the revision returned by the last. */
|
||||||
export function createCanvasSaveQueue() {
|
export function createCanvasSaveQueue() {
|
||||||
let pending: Promise<unknown> = Promise.resolve();
|
let pending: Promise<unknown> = Promise.resolve();
|
||||||
return {
|
const queue = {
|
||||||
enqueue<T>(save: () => Promise<T>): Promise<T> {
|
enqueue<T>(save: () => Promise<T>): Promise<T> {
|
||||||
const next = pending.then(save, save);
|
const next = pending.then(save, save);
|
||||||
pending = next.catch(() => undefined);
|
pending = next.catch(() => undefined);
|
||||||
return next;
|
return next;
|
||||||
},
|
},
|
||||||
};
|
enqueueOperation(save: () => Promise<boolean>, operation: () => Promise<void>) {
|
||||||
|
return queue.enqueue(async () => {
|
||||||
|
if (!(await save())) {
|
||||||
|
throw new Error("画布尚未保存成功,请确认网络或画布同步状态后重试");
|
||||||
|
}
|
||||||
|
await operation();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return queue;
|
||||||
}
|
}
|
||||||
|
|||||||
40
lib/client/canvas-toolbar-capability.ts
Normal file
40
lib/client/canvas-toolbar-capability.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
|
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
|
||||||
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
|
|
||||||
|
/** 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() });
|
||||||
|
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 || "图片处理失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectResponse = await fetch(`/api/canvas/${projectId}`);
|
||||||
|
const result = await projectResponse.json();
|
||||||
|
if (!projectResponse.ok || !result.success || !result.data?.canvasData) {
|
||||||
|
throw new Error(result.error || "结果已生成,但画布同步失败");
|
||||||
|
}
|
||||||
|
const current = useCanvasStore.getState();
|
||||||
|
if (current.projectId !== projectId) return;
|
||||||
|
const merged = mergeCanvasSnapshots(
|
||||||
|
base,
|
||||||
|
JSON.stringify({ canvasData: current.getCanvasState() }),
|
||||||
|
JSON.stringify({ canvasData: result.data.canvasData }),
|
||||||
|
);
|
||||||
|
if (merged.conflicts.length > 0) {
|
||||||
|
throw new Error("结果已生成,但画布有冲突;请保留本地改动后刷新查看结果");
|
||||||
|
}
|
||||||
|
current.loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
||||||
|
current.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
||||||
|
}
|
||||||
@ -1,4 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
|
||||||
|
import { resolveSiteBranding } from "@/lib/site-branding";
|
||||||
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -4554,7 +4556,7 @@ export async function getEnabledAiModelsWithProvider(
|
|||||||
return 3;
|
return 3;
|
||||||
};
|
};
|
||||||
|
|
||||||
return [...models].sort((left, right) => {
|
return models.filter(({ model }) => !model.supportsImageGen || !isImageModelBalanceBlocked(model)).sort((left, right) => {
|
||||||
const byHealth =
|
const byHealth =
|
||||||
healthPriority(left.model.healthStatus) -
|
healthPriority(left.model.healthStatus) -
|
||||||
healthPriority(right.model.healthStatus);
|
healthPriority(right.model.healthStatus);
|
||||||
@ -4744,9 +4746,9 @@ export async function getSiteSettings() {
|
|||||||
? videoCanvasEnabledSetting.value !== false
|
? videoCanvasEnabledSetting.value !== false
|
||||||
: videoCanvasSidebarEnabledSetting?.value !== false;
|
: videoCanvasSidebarEnabledSetting?.value !== false;
|
||||||
|
|
||||||
return { ...(settings || defaultSettings), videoCanvasEnabled };
|
return resolveSiteBranding({ ...(settings || defaultSettings), videoCanvasEnabled });
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return defaultSettings;
|
return resolveSiteBranding(defaultSettings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
lib/site-branding.ts
Normal file
13
lib/site-branding.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { isRetiredStorageUrl } from '@/lib/storage/retired-storage';
|
||||||
|
|
||||||
|
export const DEFAULT_SITE_LOGO = '/brand/logo-black.png';
|
||||||
|
export const DEFAULT_SITE_LOGO_DARK = '/brand/logo-white.svg';
|
||||||
|
|
||||||
|
export function resolveSiteBranding<T extends { siteLogo?: string | null; siteLogoDark?: string | null; favicon?: string | null }>(settings: T) {
|
||||||
|
return {
|
||||||
|
...settings,
|
||||||
|
siteLogo: !settings.siteLogo || isRetiredStorageUrl(settings.siteLogo) ? DEFAULT_SITE_LOGO : settings.siteLogo,
|
||||||
|
siteLogoDark: !settings.siteLogoDark || isRetiredStorageUrl(settings.siteLogoDark) ? DEFAULT_SITE_LOGO_DARK : settings.siteLogoDark,
|
||||||
|
favicon: !settings.favicon || isRetiredStorageUrl(settings.favicon) ? DEFAULT_SITE_LOGO : settings.favicon,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isRetiredStorageUrl } from "./retired-storage";
|
||||||
|
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
@ -38,7 +39,7 @@ export async function enqueueImageArchiveTask(params: {
|
|||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
}) {
|
}) {
|
||||||
const sourceUrl = String(params.sourceUrl || "").trim();
|
const sourceUrl = String(params.sourceUrl || "").trim();
|
||||||
if (!isGeneratedImageSource(sourceUrl)) return null;
|
if (!isGeneratedImageSource(sourceUrl) || isRetiredStorageUrl(sourceUrl)) return null;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const archiveAfterAt = params.archiveAfterAt || now;
|
const archiveAfterAt = params.archiveAfterAt || now;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { fetchImageBytes, cacheImageBytes } from "@/lib/storage/image-byte-cache";
|
import { fetchImageBytes, cacheImageBytes } from "@/lib/storage/image-byte-cache";
|
||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { isGeneratedImageSource } from "./image-archive-urls";
|
import { isGeneratedImageSource } from "./image-archive-urls";
|
||||||
@ -170,6 +171,7 @@ function toArrayBuffer(buffer: Buffer): ArrayBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadTemporaryImage(params: { sourceUrl: string; retries?: number }) {
|
export async function downloadTemporaryImage(params: { sourceUrl: string; retries?: number }) {
|
||||||
|
if (isRetiredStorageUrl(params.sourceUrl)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
|
||||||
const retries = Math.max(0, Math.min(5, params.retries ?? 2));
|
const retries = Math.max(0, Math.min(5, params.retries ?? 2));
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { mkdir, readFile, writeFile, rename, readdir, stat, statfs, unlink } from "node:fs/promises";
|
import { mkdir, readFile, writeFile, rename, readdir, stat, statfs, unlink } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
@ -81,6 +82,7 @@ function isMediaResponse(data: ImageBytes) {
|
|||||||
return data.buffer.length > 0;
|
return data.buffer.length > 0;
|
||||||
}
|
}
|
||||||
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string } = {}) {
|
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string } = {}) {
|
||||||
|
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);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isRetiredStorageUrl } from "./retired-storage";
|
||||||
|
|
||||||
import { and, asc, desc, eq, inArray, lte, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, inArray, lte, sql } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@ -16,6 +17,8 @@ function isUsableS3Config(config: StorageConfig | null | undefined): config is S
|
|||||||
config &&
|
config &&
|
||||||
config.type === "s3" &&
|
config.type === "s3" &&
|
||||||
config.isEnabled &&
|
config.isEnabled &&
|
||||||
|
!isRetiredStorageUrl(config.endpoint) &&
|
||||||
|
!isRetiredStorageUrl(config.publicUrl) &&
|
||||||
String(config.endpoint || "").trim() &&
|
String(config.endpoint || "").trim() &&
|
||||||
String(config.bucket || "").trim() &&
|
String(config.bucket || "").trim() &&
|
||||||
String(config.accessKeyId || "").trim() &&
|
String(config.accessKeyId || "").trim() &&
|
||||||
@ -80,7 +83,7 @@ export async function enqueueStorageMigrationTask({
|
|||||||
migrateAfterAt: Date;
|
migrateAfterAt: Date;
|
||||||
cleanupAfterAt: Date;
|
cleanupAfterAt: Date;
|
||||||
}) {
|
}) {
|
||||||
if (!sourceUrl || !sourcePath || !sourceStorageConfigId) return;
|
if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl)) return;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(storageMigrationTask)
|
.insert(storageMigrationTask)
|
||||||
|
|||||||
16
lib/storage/retired-storage.ts
Normal file
16
lib/storage/retired-storage.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/** Permanently decommissioned storage. Historical records remain for reference. */
|
||||||
|
export const RETIRED_STORAGE_URL_PATTERN = '^https?://s3[.]1080p[.]hk([/:?#]|$)';
|
||||||
|
export const RETIRED_STORAGE_MESSAGE = '旧存储已停用,源文件已无法恢复,请重新上传。';
|
||||||
|
|
||||||
|
export function isRetiredStorageUrl(value: string | null | undefined): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
try {
|
||||||
|
const url = new URL(value, 'https://local.invalid');
|
||||||
|
if (url.hostname === 's3.1080p.hk') return true;
|
||||||
|
if (url.pathname === '/api/image-proxy' || url.pathname === '/api/canvas/3d/model-proxy') {
|
||||||
|
const source = url.searchParams.get('url');
|
||||||
|
return Boolean(source && new URL(source).hostname === 's3.1080p.hk');
|
||||||
|
}
|
||||||
|
} catch { /* Relative local assets and non-URL values are not retired storage. */ }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
||||||
|
|
||||||
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||||
import { put } from "@vercel/blob";
|
import { put } from "@vercel/blob";
|
||||||
@ -149,6 +150,9 @@ export function getContentType(filename: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createS3Client(config: S3UploadConfig): S3Client {
|
export function createS3Client(config: S3UploadConfig): S3Client {
|
||||||
|
if (isRetiredStorageUrl(config.endpoint) || isRetiredStorageUrl(config.publicUrl)) {
|
||||||
|
throw new Error(RETIRED_STORAGE_MESSAGE);
|
||||||
|
}
|
||||||
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
|
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
|
||||||
const lowerEndpoint = normalizedEndpoint.toLowerCase();
|
const lowerEndpoint = normalizedEndpoint.toLowerCase();
|
||||||
const isCloudS3 =
|
const isCloudS3 =
|
||||||
@ -337,6 +341,8 @@ export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
|
|||||||
!value ||
|
!value ||
|
||||||
!provider ||
|
!provider ||
|
||||||
!value.isEnabled ||
|
!value.isEnabled ||
|
||||||
|
isRetiredStorageUrl(endpoint) ||
|
||||||
|
isRetiredStorageUrl(String(value.publicUrl || "")) ||
|
||||||
!endpoint ||
|
!endpoint ||
|
||||||
!bucket ||
|
!bucket ||
|
||||||
!accessKeyId ||
|
!accessKeyId ||
|
||||||
@ -364,7 +370,7 @@ function buildEnvS3Config(): S3UploadConfig | null {
|
|||||||
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() ?? "";
|
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() ?? "";
|
||||||
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim() ?? "";
|
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim() ?? "";
|
||||||
|
|
||||||
if (endpoint && bucket && accessKeyId && secretAccessKey) {
|
if (endpoint && !isRetiredStorageUrl(endpoint) && !isRetiredStorageUrl(process.env.S3_PUBLIC_URL) && bucket && accessKeyId && secretAccessKey) {
|
||||||
return {
|
return {
|
||||||
endpoint,
|
endpoint,
|
||||||
region: process.env.S3_REGION?.trim() || "auto",
|
region: process.env.S3_REGION?.trim() || "auto",
|
||||||
|
|||||||
@ -132,6 +132,7 @@
|
|||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
|
"gsap": "3.14.2",
|
||||||
"heic-convert": "^2.1.0",
|
"heic-convert": "^2.1.0",
|
||||||
"immer": "^11.1.3",
|
"immer": "^11.1.3",
|
||||||
"is-hotkey": "^0.2.0",
|
"is-hotkey": "^0.2.0",
|
||||||
|
|||||||
@ -3,10 +3,12 @@ import { defineConfig } from "@playwright/test";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests",
|
testDir: "./tests",
|
||||||
testMatch: [
|
testMatch: [
|
||||||
|
/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/,
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -234,6 +234,9 @@ importers:
|
|||||||
geist:
|
geist:
|
||||||
specifier: ^1.3.1
|
specifier: ^1.3.1
|
||||||
version: 1.3.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))
|
version: 1.3.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))
|
||||||
|
gsap:
|
||||||
|
specifier: 3.14.2
|
||||||
|
version: 3.14.2
|
||||||
heic-convert:
|
heic-convert:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.0
|
version: 2.1.0
|
||||||
@ -460,6 +463,9 @@ importers:
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
gsap@3.14.2:
|
||||||
|
resolution: {integrity: sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==}
|
||||||
|
|
||||||
'@ai-sdk/gateway@1.0.15':
|
'@ai-sdk/gateway@1.0.15':
|
||||||
resolution: {integrity: sha512-xySXoQ29+KbGuGfmDnABx+O6vc7Gj7qugmj1kGpn0rW0rQNn6UKUuvscKMzWyv1Uv05GyC1vqHq8ZhEOLfXscQ==}
|
resolution: {integrity: sha512-xySXoQ29+KbGuGfmDnABx+O6vc7Gj7qugmj1kGpn0rW0rQNn6UKUuvscKMzWyv1Uv05GyC1vqHq8ZhEOLfXscQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -6967,6 +6973,8 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
|
gsap@3.14.2: {}
|
||||||
|
|
||||||
'@ai-sdk/gateway@1.0.15(zod@3.25.76)':
|
'@ai-sdk/gateway@1.0.15(zod@3.25.76)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/provider': 2.0.0
|
'@ai-sdk/provider': 2.0.0
|
||||||
|
|||||||
BIN
public/brand/logo-black.png
Normal file
BIN
public/brand/logo-black.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
1
public/brand/logo-white.svg
Normal file
1
public/brand/logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
10
scripts/sql/admin-media-record-indexes.sql
Normal file
10
scripts/sql/admin-media-record-indexes.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
-- Run outside a transaction. Concurrent creation keeps normal inserts available.
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS "GeneratedImage_created_id_idx"
|
||||||
|
ON public."GeneratedImage" ("createdAt" DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS "Message_v2_video_created_id_idx"
|
||||||
|
ON public."Message_v2" ("createdAt" DESC, id DESC)
|
||||||
|
WHERE role = 'assistant' AND (
|
||||||
|
permanent_video_url IS NOT NULL OR temporary_video_url IS NOT NULL
|
||||||
|
OR parts::text ILIKE '%<video%' OR parts::text ILIKE '%.mp4%'
|
||||||
|
);
|
||||||
29
scripts/storage/retire-1080p-storage.cjs
Normal file
29
scripts/storage/retire-1080p-storage.cjs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
require('node:module').createRequire(require.resolve('next/package.json'))('@next/env').loadEnvConfig(process.cwd());
|
||||||
|
const sql = require('postgres')(process.env.POSTGRES_URL, { max: 1, prepare: false });
|
||||||
|
const pattern = '^https?://s3[.]1080p[.]hk([/:?#]|$)';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const apply = process.argv.includes('--apply');
|
||||||
|
const result = await sql.begin(async tx => {
|
||||||
|
const storage = await tx`select id,"isEnabled","isDefault" from "StorageConfig" where endpoint ~* ${pattern} or "publicUrl" ~* ${pattern}`;
|
||||||
|
const featured = await tx`select id,"isFeatured" from "GeneratedImage" where "isFeatured" and "originalUrl" ~* ${pattern}`;
|
||||||
|
const branding = await tx`select id,"siteLogo","siteLogoDark",favicon from "SiteSettings" where "siteLogo" ~* ${pattern} or "siteLogoDark" ~* ${pattern} or favicon ~* ${pattern}`;
|
||||||
|
if (!apply) return { apply, storage: storage.length, featured: featured.length, branding: branding.length };
|
||||||
|
const directory = process.env.STORAGE_RETIREMENT_BACKUP_DIR || '/var/lib/ai-chatbot/retired-storage-20261103';
|
||||||
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||||
|
const backup = path.join(directory, `before-${Date.now()}.json`);
|
||||||
|
fs.writeFileSync(backup, JSON.stringify({ storage, featured, branding }, null, 2), { flag: 'wx', mode: 0o600 });
|
||||||
|
const disabled = await tx`update "StorageConfig" set "isEnabled"=false,"isDefault"=false,"updatedAt"=now() where (endpoint ~* ${pattern} or "publicUrl" ~* ${pattern}) and ("isEnabled" or "isDefault") returning id`;
|
||||||
|
const unfeatured = await tx`update "GeneratedImage" set "isFeatured"=false where "isFeatured" and "originalUrl" ~* ${pattern} returning id`;
|
||||||
|
const branded = await tx`update "SiteSettings" set
|
||||||
|
"siteLogo"=case when "siteLogo" ~* ${pattern} then '/brand/logo-black.png' else "siteLogo" end,
|
||||||
|
"siteLogoDark"=case when "siteLogoDark" ~* ${pattern} then '/brand/logo-white.svg' else "siteLogoDark" end,
|
||||||
|
favicon=case when favicon ~* ${pattern} then '/brand/logo-black.png' else favicon end
|
||||||
|
where "siteLogo" ~* ${pattern} or "siteLogoDark" ~* ${pattern} or favicon ~* ${pattern} returning id`;
|
||||||
|
return { apply, backup, disabled: disabled.length, unfeatured: unfeatured.length, branded: branded.length };
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(result));
|
||||||
|
}
|
||||||
|
main().catch(error => { console.error(error.message); process.exitCode = 1; }).finally(() => sql.end());
|
||||||
@ -248,6 +248,17 @@ export const canvasService = {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
if (data?.success && data?.previousCanvasRevision && data?.data?.updatedAt) {
|
||||||
|
const current = useCanvasStore.getState();
|
||||||
|
// Metadata-only writes advance the same revision used by canvas saves.
|
||||||
|
// Adopt it only when no unseen canvas revision lies between them.
|
||||||
|
if (
|
||||||
|
current.projectId === id &&
|
||||||
|
current.canvasRevision === data.previousCanvasRevision
|
||||||
|
) {
|
||||||
|
current.setCanvasRevision(String(data.data.updatedAt), String(data.data.updatedAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: '更新画布失败' };
|
return { success: false, error: '更新画布失败' };
|
||||||
|
|||||||
2
tests/canvas-toolbar.config.ts
Normal file
2
tests/canvas-toolbar.config.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
export default defineConfig({ testDir: "./routes", testMatch: /canvas-toolbar-capability\.test\.ts|canvas-capability-runtime\.test\.ts/, workers: 1, reporter: "list" });
|
||||||
@ -6,7 +6,7 @@ export function agentImageProviderHarness(options: { generate?: (id: string, cou
|
|||||||
const models = options.models || ["primary", "backup"].map((id, i) => ({ model: { id, modelId: id, displayName: id, isEnabled: true, supportsImageGen: true, supportsVision: true, isDefault: !i, imageGenConfig: { maxImages: 12 }, capabilities: { kind: "image" } }, provider: { id: "same-provider", type: "openai-image", apiKey: "synthetic", isEnabled: true } }));
|
const models = options.models || ["primary", "backup"].map((id, i) => ({ model: { id, modelId: id, displayName: id, isEnabled: true, supportsImageGen: true, supportsVision: true, isDefault: !i, imageGenConfig: { maxImages: 12 }, capabilities: { kind: "image" } }, provider: { id: "same-provider", type: "openai-image", apiKey: "synthetic", isEnabled: true } }));
|
||||||
const mocks: Record<string, any> = {
|
const mocks: Record<string, any> = {
|
||||||
"@/lib/ai/runtime/image-generation-error": generationErrors,
|
"@/lib/ai/runtime/image-generation-error": generationErrors,
|
||||||
"@/lib/db/queries": { getEnabledAiModelsWithProvider: async () => models },
|
"@/lib/db/queries": { getEnabledAiModelsWithProvider: async () => models.filter(entry => entry.model.isEnabled), getAiModelById: async ({ id }: { id: string }) => models.find(entry => entry.model.id === id)?.model },
|
||||||
"@/lib/ai/rmbg/service": {},
|
"@/lib/ai/rmbg/service": {},
|
||||||
"@/lib/net/url-access": { filterReachableRemoteUrls: async (urls: string[]) => ({ kept: urls, dropped: [] }) },
|
"@/lib/net/url-access": { filterReachableRemoteUrls: async (urls: string[]) => ({ kept: urls, dropped: [] }) },
|
||||||
"@/lib/canvas/image-concurrency": { acquireCanvasImageModelSlot: async () => ({ waitMs: 0, maxConcurrent: 999, release: async () => {} }), normalizeCanvasImageModelKey: (v: string) => v, recordCanvasImageModelExecutionDuration() {} },
|
"@/lib/canvas/image-concurrency": { acquireCanvasImageModelSlot: async () => ({ waitMs: 0, maxConcurrent: 999, release: async () => {} }), normalizeCanvasImageModelKey: (v: string) => v, recordCanvasImageModelExecutionDuration() {} },
|
||||||
|
|||||||
@ -63,3 +63,42 @@ test("coalesces overlapping typewriter table and image ranges without looping",
|
|||||||
assert.equal(advancePastAtomicRanges(50, 36, ranges), 40);
|
assert.equal(advancePastAtomicRanges(50, 36, ranges), 40);
|
||||||
assert.equal(advancePastAtomicRanges(50, 50, ranges), 50);
|
assert.equal(advancePastAtomicRanges(50, 50, ranges), 50);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("SKU result links are atomic across every typewriter cursor and live fragment", () => {
|
||||||
|
const links = [
|
||||||
|
"[beike-sku-template2-batch.json](https://scqny.jiansun.vip/ps-skill-api/results/job_test/795c72cb-batch.json)",
|
||||||
|
"[beike-sku-template2-report.json](https://scqny.jiansun.vip/ps-skill-api/results/job_test/af588253-report.json)",
|
||||||
|
];
|
||||||
|
const text = `生成图\n${wrapped}\n可编辑文件\n- ${links[0]}\n- ${links[1]}\n完成`;
|
||||||
|
const ranges = scanMarkdownImageStreaming(text).ranges;
|
||||||
|
assert.equal(ranges.length, 3);
|
||||||
|
for (const link of links) {
|
||||||
|
const start = text.indexOf(link);
|
||||||
|
for (let n = 1; n < link.length; n += 1) {
|
||||||
|
const prefix = text.slice(0, start + n);
|
||||||
|
assert.equal(stableStreamingMarkdown(prefix, true), text.slice(0, start), `link prefix ${n}`);
|
||||||
|
}
|
||||||
|
assert.equal(stableStreamingMarkdown(text.slice(0, start + link.length), true), text.slice(0, start + link.length));
|
||||||
|
}
|
||||||
|
let cursor = 0;
|
||||||
|
while (cursor < text.length) {
|
||||||
|
cursor = advancePastAtomicRanges(text.length, cursor + 1, ranges);
|
||||||
|
assert.ok(ranges.every((range) => cursor <= range.start || cursor >= range.end), `split at ${cursor}`);
|
||||||
|
}
|
||||||
|
assert.equal(cursor, text.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ordinary brackets, code, escaped markers and unfinished final links remain available", () => {
|
||||||
|
for (const value of ["普通文字", "[状态] 已完成", "[引用][reference]", "\\[转义](https://example.com)", "`[code](https://example.com`", "~~~md\n[example](https://example.com\n~~~\n"]) {
|
||||||
|
assert.equal(stableStreamingMarkdown(value, true), value);
|
||||||
|
assert.equal(stableStreamingMarkdown(value, false), value);
|
||||||
|
}
|
||||||
|
for (const partial of ["[状态", "[文件](https://example.com/", "[文件]"]) {
|
||||||
|
assert.equal(stableStreamingMarkdown(partial, false), partial);
|
||||||
|
assert.equal(stableStreamingMarkdown(partial + "\n普通文字", true), partial + "\n普通文字");
|
||||||
|
}
|
||||||
|
const titleLink = '[文档](https://example.com/a "标题")';
|
||||||
|
assert.deepEqual(scanMarkdownImageStreaming(titleLink).ranges, [{ start: 0, end: titleLink.length }]);
|
||||||
|
const unsafeLink = "[危险链接](javascript:alert(1))";
|
||||||
|
assert.equal(stableStreamingMarkdown(unsafeLink, false), unsafeLink);
|
||||||
|
});
|
||||||
|
|||||||
2
tests/retired-storage.config.ts
Normal file
2
tests/retired-storage.config.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
export default defineConfig({ testDir: './routes', testMatch: /retired-storage\.test\.ts/, workers: 1, reporter: 'list' });
|
||||||
45
tests/routes/admin-media-loading.test.ts
Normal file
45
tests/routes/admin-media-loading.test.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { queuePreview } from "../../lib/admin/preview-queue";
|
||||||
|
import { formatRecordTime } from "../../lib/admin/record-time";
|
||||||
|
import { parseMediaQuery } from "../../lib/admin/media-records";
|
||||||
|
|
||||||
|
test("pagination rejects invalid values and bounds a requested page size", () => {
|
||||||
|
expect(parseMediaQuery(new URLSearchParams("page=-1&pageSize=200"))).toMatchObject({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
parseMediaQuery(new URLSearchParams("page=2&pageSize=40&search=%E9%99%88%E9%BE%99")),
|
||||||
|
).toMatchObject({ page: 2, pageSize: 40, search: "陈龙" });
|
||||||
|
expect(parseMediaQuery(new URLSearchParams("page=NaN&pageSize=0"))).toMatchObject({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalized Beijing record times do not receive a second timezone offset", () => {
|
||||||
|
expect(formatRecordTime("2026-11-03T23:55:00.123Z")).toBe("2026-11-03 23:55");
|
||||||
|
expect(formatRecordTime("2026-11-03T17:12:00.123456")).toBe("2026-11-03 17:12");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaving a page cancels waiting previews and retains active slots until fetch settles", () => {
|
||||||
|
const started: number[] = [];
|
||||||
|
const releases: Array<() => void> = [];
|
||||||
|
const cancel = Array.from({ length: 7 }, (_, index) =>
|
||||||
|
queuePreview((release) => {
|
||||||
|
started.push(index);
|
||||||
|
releases[index] = release;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(started).toEqual([0, 1, 2, 3]);
|
||||||
|
cancel[0]();
|
||||||
|
cancel[4]();
|
||||||
|
expect(started).toEqual([0, 1, 2, 3]);
|
||||||
|
releases[0]();
|
||||||
|
expect(started).toEqual([0, 1, 2, 3, 5]);
|
||||||
|
releases[0]();
|
||||||
|
expect(started).toHaveLength(5);
|
||||||
|
releases[1]();
|
||||||
|
expect(started).toEqual([0, 1, 2, 3, 5, 6]);
|
||||||
|
for (const release of releases) release?.();
|
||||||
|
});
|
||||||
@ -5,6 +5,33 @@ import { ImageSubmissionRejectedError, agentImageGenerationError } from "@/lib/a
|
|||||||
import * as generationErrors from "@/lib/ai/runtime/image-generation-error";
|
import * as generationErrors from "@/lib/ai/runtime/image-generation-error";
|
||||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||||
|
|
||||||
|
test("a balance rejection skips other models on the same account but permits another account of the same provider type", async () => {
|
||||||
|
const models = ["primary", "same-account", "backup"].map(id => ({
|
||||||
|
model: { id, modelId: id, displayName: id, isEnabled: true, supportsImageGen: true, supportsVision: true, imageGenConfig: { maxImages: 1 }, capabilities: { kind: "image" } },
|
||||||
|
provider: { id: id === "backup" ? "funded-account" : "exhausted-account", type: "openai-image", apiKey: "synthetic", isEnabled: true },
|
||||||
|
}));
|
||||||
|
const h = harness({ models, generate: id => {
|
||||||
|
if (id !== "backup") throw new Error("余额不足,请充值 (HTTP 429)");
|
||||||
|
return "";
|
||||||
|
} });
|
||||||
|
expect((await h.generate(h.request)).modelRef).toBe("backup");
|
||||||
|
expect(h.calls.map(call => call.id)).toEqual(["primary", "backup"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an old disabled balance selection uses its configured compatible replacement without calling the exhausted account", async () => {
|
||||||
|
const oldId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const models = [
|
||||||
|
{ model: { id: oldId, modelId: "old", displayName: "old", isEnabled: false, supportsImageGen: true, healthLastEventType: "quota", healthLastError: "insufficient balance", fallbackModelId1: "backup" }, provider: { id: "exhausted-account", type: "openai-image", isEnabled: true } },
|
||||||
|
{ model: { id: "backup", modelId: "backup", displayName: "backup", isEnabled: true, supportsImageGen: true, supportsVision: true, imageGenConfig: { maxImages: 1, maxReferenceImages: 8, supportedImageSizes: ["2K"], supportedAspectRatios: ["3:4"] } }, provider: { id: "funded-account", type: "openai-image", apiKey: "synthetic", isEnabled: true } },
|
||||||
|
];
|
||||||
|
const h = harness({ models });
|
||||||
|
expect((await h.generate({ ...h.request, model: oldId })).modelRef).toBe("backup");
|
||||||
|
expect(h.calls.map(call => call.id)).toEqual(["backup"]);
|
||||||
|
await expect(h.generate({ ...h.request, model: oldId, agentImageMode: "pinned" })).rejects.toThrow("余额不足");
|
||||||
|
await expect(h.generate({ ...h.request, model: oldId, referenceImages: Array.from({ length: 9 }, (_, i) => `https://fixture.invalid/${i}.png`) })).rejects.toThrow("余额不足");
|
||||||
|
expect(h.calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
test("automatic image generation switches within the same provider and skips a paused model on the next task", async () => {
|
test("automatic image generation switches within the same provider and skips a paused model on the next task", async () => {
|
||||||
const h = harness({ generate: id => { if (id === "primary") throw Object.assign(new Error("channel unavailable"), { status: 503 }); return ""; } });
|
const h = harness({ generate: id => { if (id === "primary") throw Object.assign(new Error("channel unavailable"), { status: 503 }); return ""; } });
|
||||||
|
|||||||
116
tests/routes/canvas-toolbar-capability.test.ts
Normal file
116
tests/routes/canvas-toolbar-capability.test.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
||||||
|
import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capability";
|
||||||
|
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
|
import { canvasService } from "@/services/canvas";
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const node = (id: string) => ({ id, type: "image", position: { x: 0, y: 0 },
|
||||||
|
size: { width: 100, height: 100 }, data: { src: `https://example.com/${id}.png` } });
|
||||||
|
const command = () => createCanvasCapabilityClientCommand({
|
||||||
|
projectId: "project-a", capabilityId: "image.remove_watermark", nodeIds: ["source"], source: "toolbar",
|
||||||
|
});
|
||||||
|
test.beforeEach(() => {
|
||||||
|
useCanvasStore.getState().clearCanvas();
|
||||||
|
useCanvasStore.getState().setProject("project-a");
|
||||||
|
useCanvasStore.getState().loadCanvas({ nodes: [node("source") as any], edges: [] });
|
||||||
|
useCanvasStore.getState().setCanvasRevision("r1", "r1");
|
||||||
|
});
|
||||||
|
test.afterEach(() => { globalThis.fetch = originalFetch; });
|
||||||
|
|
||||||
|
test("waits for the selected image to be saved before submitting and keeps subsequent saves behind the result", async () => {
|
||||||
|
const queue = createCanvasSaveQueue();
|
||||||
|
const events: string[] = [];
|
||||||
|
let finishSave!: () => void;
|
||||||
|
const pendingSave = new Promise<void>(resolve => { finishSave = resolve; });
|
||||||
|
const pending = queue.enqueueOperation(async () => {
|
||||||
|
events.push("saving"); await pendingSave; events.push("saved"); return true;
|
||||||
|
}, async () => { events.push("submitted"); });
|
||||||
|
const autosave = queue.enqueue(async () => { events.push("autosaved"); });
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(events).toEqual(["saving"]);
|
||||||
|
finishSave();
|
||||||
|
await Promise.all([pending, autosave]);
|
||||||
|
expect(events).toEqual(["saving", "saved", "submitted", "autosaved"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed save does not submit a paid image task or block later saves", async () => {
|
||||||
|
const queue = createCanvasSaveQueue();
|
||||||
|
let submissions = 0;
|
||||||
|
await expect(queue.enqueueOperation(async () => false, async () => { submissions++; })).rejects.toThrow("尚未保存成功");
|
||||||
|
expect(submissions).toBe(0);
|
||||||
|
expect(await queue.enqueue(async () => "saved")).toBe("saved");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merges the result with edits made while processing and advances the save revision", async () => {
|
||||||
|
globalThis.fetch = (async (url) => {
|
||||||
|
if (String(url).endsWith("/capabilities")) {
|
||||||
|
useCanvasStore.getState().moveNode("source", { x: 50, y: 80 });
|
||||||
|
return Response.json({ success: true });
|
||||||
|
}
|
||||||
|
return Response.json({ success: true, data: {
|
||||||
|
updatedAt: "r2", canvasData: { nodes: [node("source"), node("result")], edges: [] },
|
||||||
|
} });
|
||||||
|
}) as typeof fetch;
|
||||||
|
await executeCanvasToolbarCapability(command());
|
||||||
|
const state = useCanvasStore.getState();
|
||||||
|
expect(state.nodes.map(n => n.id)).toEqual(["source", "result"]);
|
||||||
|
expect(state.nodes[0].position).toEqual({ x: 50, y: 80 });
|
||||||
|
expect(state.canvasRevision).toBe("r2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a provider failure is shown without reloading or changing the canvas", async () => {
|
||||||
|
globalThis.fetch = (async () => Response.json({ success: false, error: "去水印服务繁忙" }, { status: 503 })) as typeof fetch;
|
||||||
|
await expect(executeCanvasToolbarCapability(command())).rejects.toThrow("去水印服务繁忙");
|
||||||
|
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source"]);
|
||||||
|
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching projects during processing does not load the old result into the new project", async () => {
|
||||||
|
globalThis.fetch = (async (url) => {
|
||||||
|
if (String(url).endsWith("/capabilities")) {
|
||||||
|
useCanvasStore.getState().setProject("project-b");
|
||||||
|
useCanvasStore.getState().loadCanvas({ nodes: [node("other") as any], edges: [] });
|
||||||
|
return Response.json({ success: true });
|
||||||
|
}
|
||||||
|
return Response.json({ success: true, data: { updatedAt: "r2", canvasData: { nodes: [node("result")], edges: [] } } });
|
||||||
|
}) as typeof fetch;
|
||||||
|
await executeCanvasToolbarCapability(command());
|
||||||
|
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["other"]);
|
||||||
|
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cover updates advance the local revision before the next queued save", async () => {
|
||||||
|
const queue = createCanvasSaveQueue();
|
||||||
|
globalThis.fetch = (async () => Response.json({
|
||||||
|
success: true, previousCanvasRevision: "r1", data: { updatedAt: "r2" },
|
||||||
|
})) as typeof fetch;
|
||||||
|
const cover = queue.enqueue(() => canvasService.updateProject("project-a", { thumbnail: "https://example.com/source.png" }));
|
||||||
|
const nextSave = queue.enqueue(async () => useCanvasStore.getState().canvasRevision);
|
||||||
|
await cover;
|
||||||
|
expect(await nextSave).toBe("r2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("metadata changes never adopt an unseen remote canvas revision", async () => {
|
||||||
|
globalThis.fetch = (async () => Response.json({
|
||||||
|
success: true, previousCanvasRevision: "external-edit", data: { updatedAt: "r3" },
|
||||||
|
})) as typeof fetch;
|
||||||
|
await canvasService.updateProject("project-a", { title: "Renamed" });
|
||||||
|
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a result merge conflict retains both the local edit and its original revision", async () => {
|
||||||
|
globalThis.fetch = (async (url) => {
|
||||||
|
if (String(url).endsWith("/capabilities")) {
|
||||||
|
useCanvasStore.getState().moveNode("source", { x: 50, y: 80 });
|
||||||
|
return Response.json({ success: true });
|
||||||
|
}
|
||||||
|
return Response.json({ success: true, data: { updatedAt: "r2", canvasData: {
|
||||||
|
nodes: [{ ...node("source"), position: { x: 200, y: 80 } }, node("result")], edges: [],
|
||||||
|
} } });
|
||||||
|
}) as typeof fetch;
|
||||||
|
await expect(executeCanvasToolbarCapability(command())).rejects.toThrow("画布有冲突");
|
||||||
|
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
|
||||||
|
expect(useCanvasStore.getState().nodes[0].position).toEqual({ x: 50, y: 80 });
|
||||||
|
});
|
||||||
@ -5,6 +5,36 @@ import { GET } from "@/app/api/image-proxy/route";
|
|||||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||||
|
|
||||||
test.describe("Qiniu image delivery", () => {
|
test.describe("Qiniu image delivery", () => {
|
||||||
|
test("reports a timeout while reading image bytes as a retryable response", async () => {
|
||||||
|
const previousFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async () => new Response(new ReadableStream({
|
||||||
|
start(controller) { controller.error(new DOMException("source stalled", "TimeoutError")); },
|
||||||
|
}), { headers: { "Content-Type": "image/png" } });
|
||||||
|
try {
|
||||||
|
const response = await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fslow.png&proxy=1&w=192"));
|
||||||
|
expect(response.status).toBe(504);
|
||||||
|
expect(await response.json()).toMatchObject({ error: "读取源图片超时,请重试" });
|
||||||
|
} finally { globalThis.fetch = previousFetch; }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops the upstream image body when the preview request is cancelled", async () => {
|
||||||
|
const previousFetch = globalThis.fetch;
|
||||||
|
const controller = new AbortController();
|
||||||
|
let upstreamSignal: AbortSignal | undefined;
|
||||||
|
globalThis.fetch = async (_url, init) => {
|
||||||
|
upstreamSignal = init?.signal as AbortSignal;
|
||||||
|
return new Response(new ReadableStream({ start(stream) {
|
||||||
|
upstreamSignal!.addEventListener("abort", () => stream.error(upstreamSignal!.reason));
|
||||||
|
queueMicrotask(() => controller.abort());
|
||||||
|
} }), { headers: { "Content-Type": "image/png" } });
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const response = await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fcancel.png&proxy=1&w=192", { signal: controller.signal }));
|
||||||
|
expect(upstreamSignal?.aborted).toBe(true);
|
||||||
|
expect(response.status).toBe(499);
|
||||||
|
} finally { globalThis.fetch = previousFetch; }
|
||||||
|
});
|
||||||
|
|
||||||
test("uses Qiniu image processing for direct canvas previews", () => {
|
test("uses Qiniu image processing for direct canvas previews", () => {
|
||||||
expect(
|
expect(
|
||||||
buildImageProxyUrl("https://yybb.top/uploads/result.png", {
|
buildImageProxyUrl("https://yybb.top/uploads/result.png", {
|
||||||
|
|||||||
@ -5,7 +5,8 @@ import {
|
|||||||
isProviderBalanceError,
|
isProviderBalanceError,
|
||||||
isRateLimitError,
|
isRateLimitError,
|
||||||
} from "@/lib/request-error";
|
} from "@/lib/request-error";
|
||||||
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
|
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
|
||||||
|
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||||
|
|
||||||
test("a bare HTTP 429 is rate limiting, not insufficient balance", () => {
|
test("a bare HTTP 429 is rate limiting, not insufficient balance", () => {
|
||||||
const error = Object.assign(new Error("Too many requests (HTTP 429)"), { status: 429 });
|
const error = Object.assign(new Error("Too many requests (HTTP 429)"), { status: 429 });
|
||||||
@ -34,14 +35,66 @@ test("nested billing details and generic quota limits are kept distinct", () =>
|
|||||||
expect(isProviderBalanceError(new Error("该模型额度不足(HTTP 429)"))).toBe(true);
|
expect(isProviderBalanceError(new Error("该模型额度不足(HTTP 429)"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a recent confirmed provider balance failure skips only fallback selection", () => {
|
test("confirmed balance failures remain isolated until explicitly restored", () => {
|
||||||
const now = Date.now();
|
|
||||||
const model = {
|
const model = {
|
||||||
healthLastEventType: "quota",
|
healthLastEventType: "quota",
|
||||||
healthLastError: "余额不足(HTTP 429)",
|
healthLastError: "余额不足(HTTP 429)",
|
||||||
healthLastFailureAt: new Date(now - 30_000),
|
|
||||||
};
|
};
|
||||||
expect(isImageFallbackModelBalanceCoolingDown(model, now)).toBe(true);
|
expect(isImageModelBalanceBlocked(model)).toBe(true);
|
||||||
expect(isImageFallbackModelBalanceCoolingDown({ ...model, healthLastFailureAt: new Date(now - 3 * 60 * 60 * 1000) }, now)).toBe(false);
|
expect(isImageModelBalanceBlocked({ healthLastEventType: "error", healthAutoDisabledReason: "provider_balance_or_billing_quota: 余额不足" })).toBe(true);
|
||||||
expect(isImageFallbackModelBalanceCoolingDown({ ...model, healthLastEventType: "success" }, now)).toBe(false);
|
expect(isImageModelBalanceBlocked({ ...model, healthLastEventType: "success", healthLastError: null })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("old selections use only configured, enabled replacements that preserve the requested inputs", () => {
|
||||||
|
const requested = { healthLastEventType: "quota", healthLastError: "余额不足", fallbackModelId1: "verified" };
|
||||||
|
const verified = { model: { id: "verified", modelId: "same-family", isEnabled: true, supportsImageGen: true,
|
||||||
|
imageGenConfig: { supportedImageSizes: ["2K"], supportedAspectRatios: ["1:1"], maxReferenceImages: 8 } }, provider: { isEnabled: true } };
|
||||||
|
const input = { imageSize: "2K", aspectRatio: "1:1", referenceImageCount: 2 };
|
||||||
|
expect(resolveConfiguredImageBalanceFallback(requested, [verified], input)).toBe(verified);
|
||||||
|
expect(resolveConfiguredImageBalanceFallback(requested, [verified], { ...input, referenceImageCount: 9 })).toBeUndefined();
|
||||||
|
expect(resolveConfiguredImageBalanceFallback(requested, [verified], { ...input, imageSize: "4K" })).toBeUndefined();
|
||||||
|
expect(resolveConfiguredImageBalanceFallback(requested, [verified], { ...input, aspectRatio: "9:16" })).toBeUndefined();
|
||||||
|
expect(resolveConfiguredImageBalanceFallback(requested, [{ ...verified, provider: { isEnabled: false } }], input)).toBeUndefined();
|
||||||
|
expect(resolveConfiguredImageBalanceFallback({ ...requested, fallbackModelId1: null }, [verified], input)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
function healthHarness() {
|
||||||
|
let model: any = { id: "test", modelId: "test", isEnabled: true, excludeFromFailover: true,
|
||||||
|
excludeFromHealthBan: true, healthStatus: "healthy", healthConsecutiveFailures: 0 };
|
||||||
|
const events: any[] = [];
|
||||||
|
const query = (rows: () => any[]) => ({ from() { return this; }, where() { return this; },
|
||||||
|
limit: async () => rows(), then: (resolve: any) => Promise.resolve(rows()).then(resolve), returning: async () => rows() });
|
||||||
|
const db = {
|
||||||
|
select: () => query(() => [model]),
|
||||||
|
insert: () => ({ values: async (value: any) => { events.push(value); } }),
|
||||||
|
update: () => ({ set: (value: any) => { model = { ...model, ...value }; return query(() => [model]); } }),
|
||||||
|
};
|
||||||
|
const health = serverLoader({ "@/lib/db": { db } })("lib/ai/runtime/model-health.ts");
|
||||||
|
return { health, events, get model() { return model; }, age() { model.healthAutoDisabledAt = new Date(Date.now() - 4 * 60 * 60_000); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("quota pauses an exempt model, survives cooldown and late completion, and can be restored", async () => {
|
||||||
|
const h = healthHarness();
|
||||||
|
await h.health.recordAiModelHealthFailure({ model: h.model, error: new Error("余额不足(HTTP 429)") });
|
||||||
|
expect(h.model.isEnabled).toBe(false);
|
||||||
|
expect(h.model.healthStatus).toBe("auto_disabled");
|
||||||
|
expect(h.events[0].eventType).toBe("quota");
|
||||||
|
await h.health.recordAiModelHealthFailure({ model: h.model, error: new Error("HTTP 503 unavailable") });
|
||||||
|
await h.health.recordAiModelHealthFailure({ model: h.model, error: new Error("Too many requests (HTTP 429)") });
|
||||||
|
expect(h.model.healthStatus).toBe("auto_disabled");
|
||||||
|
expect(isImageModelBalanceBlocked(h.model)).toBe(true);
|
||||||
|
h.age();
|
||||||
|
expect(await h.health.reviveAiModelsFromCooldown()).toEqual([]);
|
||||||
|
await h.health.recordAiModelHealthSuccess({ model: h.model });
|
||||||
|
expect(h.model.isEnabled).toBe(false);
|
||||||
|
await h.health.restoreAiModelHealth(h.model.id);
|
||||||
|
expect(h.model.isEnabled).toBe(true);
|
||||||
|
expect(isImageModelBalanceBlocked(h.model)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ordinary rate limiting does not trigger billing isolation", async () => {
|
||||||
|
const h = healthHarness();
|
||||||
|
await h.health.recordAiModelHealthFailure({ model: h.model, error: new Error("Too many requests (HTTP 429)") });
|
||||||
|
expect(h.model.isEnabled).toBe(true);
|
||||||
|
expect(isImageModelBalanceBlocked(h.model)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
76
tests/routes/retired-storage.test.ts
Normal file
76
tests/routes/retired-storage.test.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { serverLoader } from '../helpers/canvas-download-server-loader';
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from '@/lib/storage/retired-storage';
|
||||||
|
import { resolveSiteBranding, DEFAULT_SITE_LOGO, DEFAULT_SITE_LOGO_DARK } from '@/lib/site-branding';
|
||||||
|
|
||||||
|
const retired = 'https://s3.1080p.hk/jsxiangche/2025/01/image.png';
|
||||||
|
const current = 'https://scqnycdn.jiansun.vip/uploads/current.png';
|
||||||
|
|
||||||
|
test('retired URLs include stored proxy URLs but do not include live storage', () => {
|
||||||
|
for (const url of [retired, retired.replace('https:', 'http:'), '/api/image-proxy?url=' + encodeURIComponent(retired)]) {
|
||||||
|
expect(isRetiredStorageUrl(url)).toBe(true);
|
||||||
|
}
|
||||||
|
for (const url of [current, '/brand/logo-black.png', 'https://s3.jiansun.vip/gongxiangpan/a.png', 'https://example.com/s3.1080p.hk/a.png']) {
|
||||||
|
expect(isRetiredStorageUrl(url)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('original image proxy returns permanent failure without redirecting or fetching', async () => {
|
||||||
|
let fetches = 0;
|
||||||
|
const route = serverLoader({ 'next/server': { NextResponse }, fetch: () => { fetches++; throw Error('Unexpected network'); } })('app/api/image-proxy/route.ts');
|
||||||
|
const response = await route.GET(new NextRequest('https://ai.yydd.ai/api/image-proxy?original=1&url=' + encodeURIComponent(retired), {headers:{'sec-fetch-dest':'image'}}));
|
||||||
|
expect(response.status).toBe(410);
|
||||||
|
expect(await response.json()).toMatchObject({code:'storage_retired',error:RETIRED_STORAGE_MESSAGE});
|
||||||
|
expect(fetches).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('byte cache never fetches retired resources', async () => {
|
||||||
|
let fetches = 0;
|
||||||
|
const module = serverLoader({ fetch: () => { fetches++; throw Error('Unexpected network'); } })('lib/storage/image-byte-cache.ts');
|
||||||
|
await expect(module.fetchImageBytes(retired)).rejects.toThrow('HTTP 410');
|
||||||
|
expect(fetches).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retired sources cannot enter the archive queue', async () => {
|
||||||
|
let inserts = 0;
|
||||||
|
const module = serverLoader({
|
||||||
|
'@/lib/db': {db:{insert:()=>{inserts++;throw Error('Unexpected insert');}}},
|
||||||
|
'@/lib/db/schema': {},
|
||||||
|
})('lib/storage/image-archive-db.ts');
|
||||||
|
expect(await module.enqueueImageArchiveTask({sourceUrl:retired})).toBeNull();
|
||||||
|
expect(inserts).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single downloads reject retired sources before async preparation, while live candidates remain usable', async () => {
|
||||||
|
const fetched: string[] = [];
|
||||||
|
const route = serverLoader({
|
||||||
|
'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
|
||||||
|
'next/server':{NextResponse,after:()=>{}},
|
||||||
|
'@/lib/storage/canvas-cdn-download':{},
|
||||||
|
'@/lib/storage/image-download-preparation':{},
|
||||||
|
'@/lib/canvas/photoshop-safe-download':{
|
||||||
|
fetchCanvasDownloadAsset:async({file}:any)=>{fetched.push(file.url);return {ok:true,bytes:Buffer.from('original'),filename:'original.png',contentType:'image/png'};},
|
||||||
|
sanitizeCanvasDownloadFileName:()=> 'original', buildAttachmentContentDisposition:()=> 'attachment',
|
||||||
|
},
|
||||||
|
})('app/api/canvas/download-image/route.ts');
|
||||||
|
const request = (body:unknown) => new NextRequest('https://ai.yydd.ai/api/canvas/download-image',{method:'POST',body:JSON.stringify(body)});
|
||||||
|
const failure = await route.POST(request({url:retired,preparation:'async',preferDirect:true}));
|
||||||
|
expect(failure.status).toBe(410);
|
||||||
|
expect(fetched).toEqual([]);
|
||||||
|
const success = await route.POST(request({urls:[retired,current]}));
|
||||||
|
expect(success.status).toBe(200);
|
||||||
|
expect(fetched).toEqual([current]);
|
||||||
|
expect(await success.text()).toBe('original');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ZIP asset preparation reports permanent failure without retrying the source', async () => {
|
||||||
|
const module = serverLoader({})( 'lib/canvas/photoshop-safe-download.ts');
|
||||||
|
const result = await module.fetchCanvasDownloadAsset({file:{url:retired},index:0,requestOrigin:'https://ai.yydd.ai',cookie:''});
|
||||||
|
expect(result).toMatchObject({ok:false,error:RETIRED_STORAGE_MESSAGE});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('branding replaces unavailable storage with theme variants and preserves custom live branding', () => {
|
||||||
|
expect(resolveSiteBranding({siteLogo:retired,siteLogoDark:retired,favicon:retired})).toEqual({siteLogo:DEFAULT_SITE_LOGO,siteLogoDark:DEFAULT_SITE_LOGO_DARK,favicon:DEFAULT_SITE_LOGO});
|
||||||
|
expect(resolveSiteBranding({siteLogo:current,siteLogoDark:current}).siteLogo).toBe(current);
|
||||||
|
});
|
||||||
@ -1,5 +1,10 @@
|
|||||||
import { mergeGenerationHistory, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
import { mergeGenerationHistory, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { runInNewContext } from "node:vm";
|
||||||
|
import ts from "typescript";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import {
|
import {
|
||||||
isVip98kReferenceSizeAllowed,
|
isVip98kReferenceSizeAllowed,
|
||||||
@ -34,6 +39,9 @@ import {
|
|||||||
resolveVip98kSubmissionTimeoutMs,
|
resolveVip98kSubmissionTimeoutMs,
|
||||||
} from "@/lib/ai/vip98k-runtime-config";
|
} from "@/lib/ai/vip98k-runtime-config";
|
||||||
import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-models";
|
import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-models";
|
||||||
|
import { buildTaskBody } from "@/lib/ai/providers/vip98k";
|
||||||
|
import { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
|
||||||
|
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||||
|
|
||||||
const textToImageInvocation: Vip98kTaskInvocation = {
|
const textToImageInvocation: Vip98kTaskInvocation = {
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
@ -46,6 +54,84 @@ const textToImageInvocation: Vip98kTaskInvocation = {
|
|||||||
"/api/external/tools/result?tool_id=google%2Fnano-banana-2%2Ftext-to-image&taskId={task_id}",
|
"/api/external/tools/result?tool_id=google%2Fnano-banana-2%2Ftext-to-image&taskId={task_id}",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
test("text-to-image uses the advertised task contract with native size and ratio, without example reference images", () => {
|
||||||
|
const configured: Vip98kTaskInvocation = { ...textToImageInvocation, path: "/v1/images/generations", requestExample: { model: "nano-banana-pro", prompt: "sample" } };
|
||||||
|
const task: Vip98kTaskInvocation = { ...configured, kind: "task", path: "/extensions/v1/tasks",
|
||||||
|
requestExample: { model: "nano-banana-pro", prompt: "sample", file_urls: ["https://provider.test/logo.png"] },
|
||||||
|
inputSchema: { required: ["model"], properties: { resolution: { type: "string" }, aspect_ratio: { type: "string" }, file_urls: { type: "array" } } },
|
||||||
|
};
|
||||||
|
const invocation = selectVip98kTaskInvocation({ modelId: "nano-banana-pro", kind: "image", configuredInvocation: configured, configuredInvocations: [configured, task], imageCount: 0 });
|
||||||
|
expect(invocation?.path).toBe("/extensions/v1/tasks");
|
||||||
|
const result = buildTaskBody({ apiKey: "test", modelId: "nano-banana-pro", kind: "image", prompt: "a blue mug", imageUrls: [], videoUrls: [], modelConfig: {}, imageParams: { imageSize: "2K", aspectRatio: "3:4" } }, invocation);
|
||||||
|
expect(result.body).toMatchObject({ prompt: "a blue mug", resolution: "2K", aspect_ratio: "3:4" });
|
||||||
|
expect(result.body).not.toHaveProperty("file_urls");
|
||||||
|
expect(result.outputConstraintFallbacks).toEqual([]);
|
||||||
|
expect(selectVip98kTaskInvocation({ modelId: "nano-banana-pro", kind: "image", configuredInvocation: configured, configuredInvocations: [configured, { ...task, inputSchema: { ...task.inputSchema, required: ["model", "file_urls"] } }], imageCount: 0 })?.path).toBe("/v1/images/generations");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forwards six requested outputs independently of reference count", () => {
|
||||||
|
const invocation = selectVip98kTaskInvocation({ modelId: "gpt-image-2.5", configuredInvocation: null, imageCount: 2 });
|
||||||
|
const { body } = buildTaskBody({
|
||||||
|
apiKey: "test", modelId: "gpt-image-2.5", kind: "image", prompt: "Style the desk",
|
||||||
|
imageUrls: ["https://assets.test/product.png", "https://assets.test/model.png"],
|
||||||
|
videoUrls: [], modelConfig: {}, imageParams: { imageCount: 6, imageSize: "2K", aspectRatio: "1:1" },
|
||||||
|
}, invocation);
|
||||||
|
expect(body.n).toBe(6);
|
||||||
|
expect(body.image).toHaveLength(2);
|
||||||
|
expect(body.size).toBe("2048x2048");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not inject output count into a task contract without n", () => {
|
||||||
|
const { body } = buildTaskBody({
|
||||||
|
apiKey: "test", modelId: "nano-banana-2.1", kind: "image", prompt: "Desk",
|
||||||
|
imageUrls: [], videoUrls: [], modelConfig: {}, imageParams: { imageCount: 6 },
|
||||||
|
}, textToImageInvocation);
|
||||||
|
expect(body).not.toHaveProperty("n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scene completion counts the current run and preserves partial results", () => {
|
||||||
|
const user = { id: "run", role: "user", parts: [] };
|
||||||
|
const result = (count: number) => ({ id: "result", role: "assistant", parts: [{ type: "text", text:
|
||||||
|
Array.from({ length: count }, (_, i) => ``).join("\n\n") }] });
|
||||||
|
expect(getSceneRunResult([user, result(6)], "run")).toEqual({ count: 6 });
|
||||||
|
expect(getSceneRunResult([user, result(1)], "run")).toEqual({ count: 1 });
|
||||||
|
expect(getSceneRunResult([user, result(0)], "run")).toEqual({ count: 0 });
|
||||||
|
expect(getSceneRunResult([result(6), user], "run")).toEqual({ count: 0 });
|
||||||
|
expect(getSceneRunResult([user, result(6), { ...user, id: "next" }], "run")).toBeNull();
|
||||||
|
expect(getSceneRunResult([user, result(6)], "other")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provider generation retains all six outputs and submits only once", async () => {
|
||||||
|
const filename = resolve("lib/ai/providers/vip98k.ts");
|
||||||
|
const localRequire = createRequire(filename);
|
||||||
|
const module = { exports: {} as any };
|
||||||
|
const archived: string[] = [];
|
||||||
|
const requests: any[] = [];
|
||||||
|
const code = ts.transpileModule(readFileSync(filename, "utf8"), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
|
||||||
|
}).outputText;
|
||||||
|
runInNewContext(code, {
|
||||||
|
module, exports: module.exports, Buffer, URL, AbortSignal, setTimeout, clearTimeout, process, console,
|
||||||
|
require(id: string) {
|
||||||
|
if (id === "@/lib/db/queries") return { getSystemSetting: async () => null };
|
||||||
|
if (id === "@/lib/storage/image-archive") return {
|
||||||
|
createDeferredUploadedImage: async (url: string) => { archived.push(url); return { original: url, thumbnail: url }; },
|
||||||
|
};
|
||||||
|
return localRequire(id.startsWith("@/") ? resolve(id.slice(2)) : id);
|
||||||
|
},
|
||||||
|
fetch: async (_url: string, init: RequestInit) => {
|
||||||
|
requests.push(JSON.parse(String(init.body)));
|
||||||
|
return Response.json({ data: Array.from({ length: 6 }, (_, i) => ({ url: `https://assets.test/result-${i}.png` })) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const model = module.exports.createVip98k({ apiKey: "test", baseURL: "https://provider.test" }).chat("gpt-image-2.5");
|
||||||
|
const result = await model.doGenerate({ messages: [{ role: "user", content: "Style a desk" }], imageParams: { imageCount: 6 } });
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0].n).toBe(6);
|
||||||
|
expect(archived).toHaveLength(6);
|
||||||
|
expect(extractImageUrlsFromText(result.text)).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
test.describe("VIP98K reference-image routing", () => {
|
test.describe("VIP98K reference-image routing", () => {
|
||||||
test("keeps the provider-selected relay route header", () => {
|
test("keeps the provider-selected relay route header", () => {
|
||||||
const [preset] = buildVip98kModelPresets([
|
const [preset] = buildVip98kModelPresets([
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user