生成记录改为数据库分页,每页 40 条,默认最近 7 天;可见预览限制为 4 路并发并使用 192px 缩略图,翻页取消旧请求,视频在详情中按需播放。保留精选、分类和原图操作,新增画布任务失败记录,统一时间显示。 模型管理拆分模型、上游和运行健康视图,提供搜索、筛选、分页和完整故障详情;列表使用摘要,编辑按需获取完整配置。后台按当前设置页加载,模型 GET 不再自动修改健康状态,98K 同步先显示变更预览。 验证:600 项现有辅助测试和 3 项新增回归测试通过,TypeScript 和本次 API/组件 lint 通过;浏览器验证请求峰值 4、翻页、搜索、失败重试、详情、模型筛选与 390px 窄屏。只读生产查询:当天页约 29ms,历史视频页 122ms,第 7800 页 739ms。两个并发索引已在生产建成,SQL 随代码保留。 已知检查状态:全仓 lint 存量 1004 项诊断使 check:quality 停在 lint 阶段;日志、品牌、CI、环境、凭据、元数据、测试与制品检查单独通过。
230 lines
8.0 KiB
TypeScript
230 lines
8.0 KiB
TypeScript
import { and, eq } from "drizzle-orm";
|
|
import { NextResponse } from "next/server";
|
|
import {
|
|
fetchVip98kModelPresets,
|
|
type Vip98kModelPreset,
|
|
} from "@/lib/ai/vip98k-models";
|
|
import { db } from "@/lib/db";
|
|
import { getAiProviderById } from "@/lib/db/queries";
|
|
import { aiModel, type AiModel } from "@/lib/db/schema";
|
|
import { requireAdmin } from "@/lib/middleware/admin";
|
|
|
|
export const runtime = "nodejs";
|
|
export const maxDuration = 300;
|
|
|
|
function summarizeByType(presets: Vip98kModelPreset[]) {
|
|
return presets.reduce<Record<string, number>>((acc, preset) => {
|
|
const type = String(preset.type || "model");
|
|
acc[type] = (acc[type] || 0) + 1;
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
export async function GET(request: Request) {
|
|
try {
|
|
await requireAdmin();
|
|
const { searchParams } = new URL(request.url);
|
|
const providerId = String(searchParams.get("providerId") || "").trim();
|
|
if (!providerId) {
|
|
return NextResponse.json(
|
|
{ error: "Provider ID is required to fetch VIP98K catalog" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const provider = await getAiProviderById({ id: providerId });
|
|
if (!provider || provider.type !== "vip98k") {
|
|
return NextResponse.json(
|
|
{ error: "Provider must be a VIP98K provider" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const presets = await fetchVip98kModelPresets({
|
|
baseURL: provider.baseUrl,
|
|
apiKey: provider.apiKey,
|
|
});
|
|
return NextResponse.json({
|
|
presets,
|
|
counts: {
|
|
total: presets.length,
|
|
byType: summarizeByType(presets),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("[VIP98K Models API] GET error:", error);
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to fetch VIP98K models",
|
|
},
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
try {
|
|
await requireAdmin();
|
|
const body = await request.json();
|
|
const providerId = String(body?.providerId || "").trim();
|
|
const requestedModelIds = Array.isArray(body?.modelIds)
|
|
? new Set(
|
|
body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean),
|
|
)
|
|
: null;
|
|
const preserveDeletedModels = body?.preserveDeletedModels !== false;
|
|
if (!providerId) {
|
|
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
|
|
}
|
|
|
|
const provider = await getAiProviderById({ id: providerId });
|
|
if (!provider || provider.type !== "vip98k") {
|
|
return NextResponse.json(
|
|
{ error: "Provider must be a VIP98K provider" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const catalogPresets = await fetchVip98kModelPresets({
|
|
baseURL: provider.baseUrl,
|
|
apiKey: provider.apiKey,
|
|
});
|
|
const existingModels = await db.query.aiModel.findMany({
|
|
where: eq(aiModel.providerId, providerId),
|
|
});
|
|
const existingModelIds = new Set(existingModels.map((model) => model.modelId));
|
|
const preserveExistingCatalog =
|
|
requestedModelIds === null &&
|
|
preserveDeletedModels &&
|
|
existingModels.length > 0;
|
|
const presets = requestedModelIds
|
|
? catalogPresets.filter((preset) => requestedModelIds.has(preset.modelId))
|
|
: preserveExistingCatalog
|
|
? 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[] = [];
|
|
const errors: Array<{ modelId: string; error: string }> = [];
|
|
|
|
if (requestedModelIds === null) {
|
|
const catalogModelIds = new Set(catalogPresets.map((preset) => preset.modelId));
|
|
for (const existing of existingModels) {
|
|
if (catalogModelIds.has(existing.modelId)) continue;
|
|
const [disabled] = await db
|
|
.update(aiModel)
|
|
.set({ isEnabled: false, isHidden: true, updatedAt: new Date() })
|
|
.where(eq(aiModel.id, existing.id))
|
|
.returning();
|
|
if (disabled) disabledModels.push(disabled);
|
|
}
|
|
}
|
|
|
|
for (const preset of presets) {
|
|
try {
|
|
const existing = await db.query.aiModel.findFirst({
|
|
where: and(
|
|
eq(aiModel.providerId, providerId),
|
|
eq(aiModel.modelId, preset.modelId),
|
|
),
|
|
});
|
|
const values = {
|
|
displayName: preset.displayName,
|
|
avatarUrl: null,
|
|
description: preset.description,
|
|
supportsVision: preset.supportsVision,
|
|
supportsImageGen: preset.supportsImageGen,
|
|
isEnabled:
|
|
requestedModelIds !== null ? true : (existing?.isEnabled ?? true),
|
|
isHidden: preset.isHidden,
|
|
sortOrder: preset.sortOrder,
|
|
capabilities: preset.capabilities,
|
|
defaultParams: preset.defaultParams,
|
|
imageGenConfig: preset.imageGenConfig,
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
if (existing) {
|
|
const [updated] = await db
|
|
.update(aiModel)
|
|
.set(values)
|
|
.where(
|
|
and(
|
|
eq(aiModel.providerId, providerId),
|
|
eq(aiModel.modelId, preset.modelId),
|
|
),
|
|
)
|
|
.returning();
|
|
if (updated) updatedModels.push(updated);
|
|
} else {
|
|
const [created] = await db
|
|
.insert(aiModel)
|
|
.values({
|
|
providerId,
|
|
modelId: preset.modelId,
|
|
...values,
|
|
isDefault: false,
|
|
isSubModel: false,
|
|
parentModelId: null,
|
|
})
|
|
.returning();
|
|
if (created) addedModels.push(created);
|
|
}
|
|
} catch (error) {
|
|
console.error(`[VIP98K Models API] Failed to sync ${preset.modelId}:`, error);
|
|
errors.push({
|
|
modelId: preset.modelId,
|
|
error: error instanceof Error ? error.message : "Sync failed",
|
|
});
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
addedCount: addedModels.length,
|
|
updatedCount: updatedModels.length,
|
|
disabledCount: disabledModels.length,
|
|
addedModels,
|
|
updatedModels,
|
|
disabledModelIds: disabledModels.map((model) => model.modelId),
|
|
errors: errors.length > 0 ? errors : undefined,
|
|
counts: {
|
|
requested: presets.length,
|
|
total: catalogPresets.length,
|
|
byType: summarizeByType(catalogPresets),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("[VIP98K Models API] POST error:", error);
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to sync VIP98K models",
|
|
},
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|