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