feat(ai): add VIP98K provider foundation on 48 baseline
This commit is contained in:
parent
30b7ae168f
commit
311d4a0ec3
@ -91,6 +91,7 @@ export type AdminAiProvider = {
|
||||
| "bananaimage"
|
||||
| "visionary"
|
||||
| "atlascloud"
|
||||
| "vip98k"
|
||||
| "stepfun-image"
|
||||
| "midjourney"
|
||||
| "fal-video"
|
||||
|
||||
@ -636,6 +636,7 @@ type AiProvider = {
|
||||
| "bananaimage"
|
||||
| "visionary"
|
||||
| "atlascloud"
|
||||
| "vip98k"
|
||||
| "stepfun-image"
|
||||
| "midjourney"
|
||||
| "fal-video"
|
||||
@ -665,6 +666,9 @@ type ImageGenConfig = {
|
||||
supportsSeed?: boolean; // 是否支持随机种子
|
||||
supportedAspectRatios?: string[]; // 支持的图片比例
|
||||
supportedImageSizes?: string[]; // 支持的分辨率/K 数
|
||||
maxReferenceImages?: number; // 最大参考图数量
|
||||
maxVideoReferences?: number; // 最大参考视频数量
|
||||
supportsQuality?: boolean; // 是否支持质量档位
|
||||
creditCostPerImage?: number; // 每张图片积分消耗
|
||||
creditCostMultiplierBySize?: Record<string, number>; // 各图片尺寸积分倍率
|
||||
creditDiscountRate?: number; // 积分折扣,按“折”配置,如 5 = 5折
|
||||
@ -674,6 +678,7 @@ type ImageGenConfig = {
|
||||
creditCostPerVideoByResolution?: Record<string, number>; // 各分辨率积分
|
||||
enableLoadingPlaceholder?: boolean; // 是否启用加载占位图
|
||||
loadingColor?: string; // 占位图主题色,如 "purple", "blue", "green"
|
||||
vip98k?: Record<string, unknown>; // VIP98K 目录元数据
|
||||
};
|
||||
|
||||
function createDefaultImageGenConfig(): ImageGenConfig {
|
||||
@ -1598,6 +1603,7 @@ const PROVIDER_TYPES = [
|
||||
{ value: "bananaimage", label: "BananaImage 生图" },
|
||||
{ value: "visionary", label: "Visionary 生图" },
|
||||
{ value: "atlascloud", label: "Atlas Cloud 图片 / 视频 / 3D 模型" },
|
||||
{ value: "vip98k", label: "VIP98K 混合模型服务" },
|
||||
{ value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
|
||||
{ value: "bltcy-video", label: "柏拉图统一视频" },
|
||||
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
|
||||
@ -1635,6 +1641,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
||||
bananaimage: "https://bananaimage.aigenmedia.art",
|
||||
visionary: "https://api.visionary.beer",
|
||||
atlascloud: "https://api.atlascloud.ai",
|
||||
vip98k: "https://api.vip98k.com",
|
||||
"stepfun-image": "https://api.stepfun.com",
|
||||
"bltcy-video": "https://api.bltcy.ai",
|
||||
"grsai-gpt-image": "https://grsai.dakka.com.cn",
|
||||
@ -4117,6 +4124,7 @@ export function AdminSettingsClient() {
|
||||
| "bananaimage"
|
||||
| "visionary"
|
||||
| "atlascloud"
|
||||
| "vip98k"
|
||||
| "stepfun-image"
|
||||
| "midjourney"
|
||||
| "fal-video"
|
||||
@ -6363,6 +6371,7 @@ export function AdminSettingsClient() {
|
||||
const isBananaImageProvider = providerForm.type === "bananaimage";
|
||||
const isVisionaryProvider = providerForm.type === "visionary";
|
||||
const isAtlasCloudProvider = providerForm.type === "atlascloud";
|
||||
const isVip98kProvider = providerForm.type === "vip98k";
|
||||
const isBltcyVideoProvider = providerForm.type === "bltcy-video";
|
||||
const isLemonProvider =
|
||||
providerForm.type === "openai" &&
|
||||
@ -6538,6 +6547,17 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
}
|
||||
|
||||
if (isNewProvider && isVip98kProvider && providerId) {
|
||||
try {
|
||||
await handleQuickAddVip98kModels(providerId, { skipConfirm: true });
|
||||
} catch (modelError) {
|
||||
console.error("[Auto Sync VIP98K Models] Error:", modelError);
|
||||
toast.warning(
|
||||
'VIP98K 模型同步失败,请手动点击"同步 VIP98K 全部模型"按钮'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isNewProvider && isBltcyVideoProvider && providerId) {
|
||||
try {
|
||||
await handleQuickAddBltcyVideoModels(providerId, {
|
||||
@ -7626,6 +7646,45 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickAddVip98kModels = async (
|
||||
providerId: string,
|
||||
options?: { skipConfirm?: boolean }
|
||||
) => {
|
||||
if (
|
||||
!options?.skipConfirm &&
|
||||
!confirm(
|
||||
"将从 VIP98K 模型目录同步全部模型,并按图片、视频、3D 世界、文本和工具分类。图片模型会配置比例、1K/2K/4K 和多参考图能力。\n\n继续吗?"
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toast.info("正在读取 VIP98K 模型目录...");
|
||||
const response = await fetch("/api/admin/vip98k-models", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerId }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "同步失败");
|
||||
|
||||
const totalSynced =
|
||||
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
|
||||
toast.success(
|
||||
`已同步 ${totalSynced} 个 VIP98K 模型(目录共 ${Number(data.counts?.total || totalSynced)} 个)`
|
||||
);
|
||||
mutateModels();
|
||||
if (data.errors?.length) {
|
||||
toast.warning(`${data.errors.length} 个 VIP98K 模型同步失败`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[Quick Add VIP98K Models] Error:", error);
|
||||
toast.error(error.message || "同步 VIP98K 模型失败");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseFalImageProviderPreset = () => {
|
||||
setProviderForm({
|
||||
id: "",
|
||||
@ -15944,6 +16003,13 @@ export function AdminSettingsClient() {
|
||||
并按每个模型自己的 Schema 配置多图数量、比例及 1K/2K/4K 档位。
|
||||
</p>
|
||||
)}
|
||||
{providerForm.type === "vip98k" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
VIP98K 使用 Bearer API Key 认证,Base URL 只填写
|
||||
https://api.vip98k.com。保存后会自动同步图片、视频、3D、
|
||||
多模态和工具模型。
|
||||
</p>
|
||||
)}
|
||||
{providerForm.type === "bfl-image" &&
|
||||
isBltcyBflBaseUrl(providerForm.baseUrl) && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
@ -16101,6 +16167,17 @@ export function AdminSettingsClient() {
|
||||
同步 Atlas Cloud 模型
|
||||
</Button>
|
||||
)}
|
||||
{provider.type === "vip98k" && (
|
||||
<Button
|
||||
className="mt-2"
|
||||
onClick={() => handleQuickAddVip98kModels(provider.id)}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
<Sparkles className="mr-1 h-4 w-4" />
|
||||
同步 VIP98K 全部模型
|
||||
</Button>
|
||||
)}
|
||||
{provider.type === "bltcy-video" && (
|
||||
<Button
|
||||
className="mt-2"
|
||||
|
||||
@ -18,6 +18,7 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"vip98k",
|
||||
"stepfun-image",
|
||||
"runway",
|
||||
"krea",
|
||||
@ -249,6 +250,7 @@ export function getImageFailoverConvertProvider(providerType?: string | null) {
|
||||
case "bananaimage":
|
||||
case "visionary":
|
||||
case "atlascloud":
|
||||
case "vip98k":
|
||||
case "stepfun-image":
|
||||
case "midjourney":
|
||||
case "runway":
|
||||
|
||||
@ -527,6 +527,7 @@ export async function convertMessagesWithImages(
|
||||
normalizedProviderType === "bananaimage" ||
|
||||
normalizedProviderType === "visionary" ||
|
||||
normalizedProviderType === "atlascloud" ||
|
||||
normalizedProviderType === "vip98k" ||
|
||||
normalizedProviderType === "stepfun-image" ||
|
||||
normalizedProviderType === "midjourney" ||
|
||||
normalizedProviderType === "runway" ||
|
||||
|
||||
@ -191,6 +191,7 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
|
||||
normalized === "bananaimage" ||
|
||||
normalized === "visionary" ||
|
||||
normalized === "atlascloud" ||
|
||||
normalized === "vip98k" ||
|
||||
normalized === "stepfun-image" ||
|
||||
normalized === "midjourney" ||
|
||||
normalized === "runway" ||
|
||||
|
||||
@ -9222,6 +9222,7 @@ export async function POST(request: Request) {
|
||||
bananaimage: 7,
|
||||
visionary: 7,
|
||||
atlascloud: 7,
|
||||
vip98k: 7,
|
||||
"stepfun-image": 7,
|
||||
"grsai-gpt-image": 8,
|
||||
runway: 9,
|
||||
@ -9616,6 +9617,7 @@ export async function POST(request: Request) {
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"vip98k",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"runway",
|
||||
@ -9844,6 +9846,8 @@ export async function POST(request: Request) {
|
||||
? "visionary"
|
||||
: (languageModel as any).provider === "atlascloud"
|
||||
? "atlascloud"
|
||||
: (languageModel as any).provider === "vip98k"
|
||||
? "vip98k"
|
||||
: (languageModel as any).provider === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: (languageModel as any).provider === "midjourney"
|
||||
@ -9950,6 +9954,7 @@ export async function POST(request: Request) {
|
||||
bananaimage: 7,
|
||||
visionary: 7,
|
||||
atlascloud: 7,
|
||||
vip98k: 7,
|
||||
"stepfun-image": 7,
|
||||
runway: 8,
|
||||
krea: 9,
|
||||
@ -9999,6 +10004,8 @@ export async function POST(request: Request) {
|
||||
? "visionary"
|
||||
: candidate.provider.type === "atlascloud"
|
||||
? "atlascloud"
|
||||
: candidate.provider.type === "vip98k"
|
||||
? "vip98k"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: candidate.provider.type === "midjourney"
|
||||
@ -10388,6 +10395,7 @@ export async function POST(request: Request) {
|
||||
bananaimage: 6,
|
||||
visionary: 6,
|
||||
atlascloud: 6,
|
||||
vip98k: 6,
|
||||
"stepfun-image": 6,
|
||||
runway: 7,
|
||||
krea: 8,
|
||||
@ -10438,6 +10446,8 @@ export async function POST(request: Request) {
|
||||
? "visionary"
|
||||
: candidate.provider.type === "atlascloud"
|
||||
? "atlascloud"
|
||||
: candidate.provider.type === "vip98k"
|
||||
? "vip98k"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: candidate.provider.type === "midjourney"
|
||||
@ -10651,7 +10661,7 @@ export async function POST(request: Request) {
|
||||
|
||||
// ========== 千问/FAL/RUXA 视频生成独立处理 ==========
|
||||
if (
|
||||
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video"].includes((languageModel as any).provider) &&
|
||||
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video", "vip98k"].includes((languageModel as any).provider) &&
|
||||
typeof (languageModel as any).doGenerate === "function"
|
||||
) {
|
||||
const currentVideoProvider = (languageModel as any).provider as
|
||||
@ -10660,7 +10670,8 @@ export async function POST(request: Request) {
|
||||
| "ruxa-video"
|
||||
| "runway"
|
||||
| "krea"
|
||||
| "bltcy-video";
|
||||
| "bltcy-video"
|
||||
| "vip98k";
|
||||
chatApiLogger.info(`[Chat API] Using ${currentVideoProvider} video generation`);
|
||||
|
||||
const videoGenerationStream = createUIMessageStream({
|
||||
@ -11316,6 +11327,7 @@ export async function POST(request: Request) {
|
||||
bananaimage: 7,
|
||||
visionary: 7,
|
||||
atlascloud: 7,
|
||||
vip98k: 7,
|
||||
"stepfun-image": 7,
|
||||
runway: 8,
|
||||
krea: 9,
|
||||
@ -11368,6 +11380,8 @@ export async function POST(request: Request) {
|
||||
? "visionary"
|
||||
: candidate.provider.type === "atlascloud"
|
||||
? "atlascloud"
|
||||
: candidate.provider.type === "vip98k"
|
||||
? "vip98k"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: candidate.provider.type === "midjourney"
|
||||
|
||||
@ -33,6 +33,11 @@ const imageGenConfigSchema = z.object({
|
||||
supportsNegativePrompt: z.boolean().optional(),
|
||||
supportsWatermark: z.boolean().optional(),
|
||||
supportsSeed: z.boolean().optional(),
|
||||
supportsQuality: z.boolean().optional(),
|
||||
supportedAspectRatios: z.array(z.string().min(1).max(32)).max(32).optional(),
|
||||
supportedImageSizes: z.array(z.string().min(1).max(32)).max(32).optional(),
|
||||
maxReferenceImages: z.number().min(0).max(64).optional(),
|
||||
maxVideoReferences: z.number().min(0).max(16).optional(),
|
||||
ruxaTaskInputMode: z
|
||||
.enum(["size-image", "resolution-based"])
|
||||
.optional(),
|
||||
@ -45,6 +50,7 @@ const imageGenConfigSchema = z.object({
|
||||
creditCostPerVideoByResolution: z.record(z.string(), z.number().min(0).max(100000)).optional(),
|
||||
enableLoadingPlaceholder: z.boolean().optional(),
|
||||
loadingColor: z.string().optional(),
|
||||
vip98k: z.record(z.string(), z.unknown()).optional(),
|
||||
}).optional();
|
||||
|
||||
const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional();
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
} from "@/lib/db/queries";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { BLTCY_VIDEO_BASE_URL, isBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models";
|
||||
import { VIP98K_BASE_URL } from "@/lib/ai/vip98k-models";
|
||||
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
|
||||
|
||||
const providerTypes = [
|
||||
@ -35,6 +36,7 @@ const providerTypes = [
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"vip98k",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"fal-video",
|
||||
@ -79,6 +81,7 @@ const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
|
||||
const BANANAIMAGE_BASE_URL = "https://bananaimage.aigenmedia.art";
|
||||
const VISIONARY_BASE_URL = "https://api.visionary.beer";
|
||||
const ATLASCLOUD_BASE_URL = "https://api.atlascloud.ai";
|
||||
const VIP98K_PROVIDER_BASE_URL = VIP98K_BASE_URL;
|
||||
const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
|
||||
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
||||
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
||||
@ -249,6 +252,8 @@ export async function POST(request: Request) {
|
||||
? data.baseUrl || VISIONARY_BASE_URL
|
||||
: data.type === "atlascloud"
|
||||
? data.baseUrl || ATLASCLOUD_BASE_URL
|
||||
: data.type === "vip98k"
|
||||
? data.baseUrl || VIP98K_PROVIDER_BASE_URL
|
||||
: data.type === "stepfun-image"
|
||||
? data.baseUrl || STEPFUN_IMAGE_BASE_URL
|
||||
: data.type === "midjourney"
|
||||
@ -311,6 +316,12 @@ export async function PUT(request: Request) {
|
||||
...data,
|
||||
apiKey: normalizedApiKey,
|
||||
type: normalizeProviderTypeForStorage(data.type),
|
||||
baseUrl:
|
||||
data.baseUrl !== undefined
|
||||
? data.baseUrl
|
||||
: data.type === "vip98k"
|
||||
? VIP98K_PROVIDER_BASE_URL
|
||||
: undefined,
|
||||
updatedBy: session.user.id,
|
||||
});
|
||||
|
||||
|
||||
185
app/api/admin/vip98k-models/route.ts
Normal file
185
app/api/admin/vip98k-models/route.ts
Normal file
@ -0,0 +1,185 @@
|
||||
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;
|
||||
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 presets = requestedModelIds
|
||||
? catalogPresets.filter((preset) => requestedModelIds.has(preset.modelId))
|
||||
: catalogPresets;
|
||||
|
||||
const addedModels: AiModel[] = [];
|
||||
const updatedModels: AiModel[] = [];
|
||||
const errors: Array<{ modelId: string; error: string }> = [];
|
||||
|
||||
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: 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,
|
||||
addedModels,
|
||||
updatedModels,
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@ import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
|
||||
import { createVisionary } from "@/lib/ai/providers/visionary";
|
||||
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
|
||||
import { createVip98k } from "@/lib/ai/providers/vip98k";
|
||||
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
|
||||
import { createMidjourney } from "@/lib/ai/providers/midjourney";
|
||||
import { createRunway } from "@/lib/ai/providers/runway";
|
||||
@ -57,6 +58,7 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"vip98k",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"runway",
|
||||
@ -127,6 +129,8 @@ function createTextEditProvider(
|
||||
return createVisionary({ baseURL, apiKey });
|
||||
case "atlascloud":
|
||||
return createAtlasCloud({ baseURL, apiKey });
|
||||
case "vip98k":
|
||||
return createVip98k({ baseURL, apiKey });
|
||||
case "stepfun-image":
|
||||
return createStepfunImage({ baseURL, apiKey });
|
||||
case "midjourney":
|
||||
@ -493,10 +497,17 @@ async function generateTextEditImageWithFailover(params: {
|
||||
imageCount: 1,
|
||||
},
|
||||
};
|
||||
if (selected.provider.type === "atlascloud") {
|
||||
if (selected.provider.type === "atlascloud" || selected.provider.type === "vip98k") {
|
||||
generationOptions.imageGenConfig = selected.model.imageGenConfig || null;
|
||||
}
|
||||
const result = await provider.chat(runtimeModelId).doGenerate(generationOptions);
|
||||
const modelInstance =
|
||||
selected.provider.type === "vip98k"
|
||||
? (provider as any).chat(runtimeModelId, {
|
||||
capabilities: selected.model.capabilities || null,
|
||||
imageGenConfig: selected.model.imageGenConfig || null,
|
||||
})
|
||||
: provider.chat(runtimeModelId);
|
||||
const result = await modelInstance.doGenerate(generationOptions);
|
||||
|
||||
const resultText = String(result?.text || "").trim();
|
||||
const finishReason = String(result?.finishReason || "").toLowerCase();
|
||||
|
||||
@ -28,6 +28,7 @@ import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
|
||||
import { createVisionary } from "@/lib/ai/providers/visionary";
|
||||
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
|
||||
import { createVip98k } from "@/lib/ai/providers/vip98k";
|
||||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||
import {
|
||||
canProcessSceneFusionProviderType,
|
||||
@ -1565,6 +1566,18 @@ async function processSceneWithProvider(
|
||||
imageGenConfig,
|
||||
);
|
||||
|
||||
case "vip98k":
|
||||
return processWithVip98k(
|
||||
productImageUrl,
|
||||
sceneImageUrl,
|
||||
prompt,
|
||||
provider,
|
||||
runtimeModelId || modelId,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
imageGenConfig,
|
||||
);
|
||||
|
||||
case "gemini":
|
||||
case "gemini-image":
|
||||
return processWithGemini(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
|
||||
@ -1933,6 +1946,45 @@ async function processWithAtlasCloud(
|
||||
return { thumbnail, original, prompt };
|
||||
}
|
||||
|
||||
async function processWithVip98k(
|
||||
productImageUrl: string,
|
||||
sceneImageUrl: string,
|
||||
prompt: string,
|
||||
provider: { baseUrl: string; apiKey: string },
|
||||
modelId: string,
|
||||
aspectRatio?: string,
|
||||
imageSize?: string,
|
||||
imageGenConfig?: Record<string, any> | null
|
||||
): Promise<{ thumbnail: string; original: string; prompt: string }> {
|
||||
logger.info("provider_processing", { providerType: "vip98k", modelId });
|
||||
|
||||
const vip98kProvider = createVip98k({
|
||||
baseURL: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
});
|
||||
const result = await vip98kProvider.chat(modelId, imageGenConfig || null).doGenerate({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "image_url", image_url: { url: productImageUrl } },
|
||||
{ type: "image_url", image_url: { url: sceneImageUrl } },
|
||||
{ type: "text", text: prompt },
|
||||
],
|
||||
},
|
||||
],
|
||||
imageParams: {
|
||||
aspectRatio: aspectRatio || "auto",
|
||||
imageSize: imageSize || "2K",
|
||||
imageCount: 1,
|
||||
},
|
||||
imageGenConfig,
|
||||
});
|
||||
|
||||
const { thumbnail, original } = parseImageFromMarkdown(result.text);
|
||||
return { thumbnail, original, prompt };
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用既梦/火山模型处理
|
||||
*/
|
||||
|
||||
@ -29,6 +29,7 @@ const PROVIDER_MODEL_LABEL: Record<string, string> = {
|
||||
bananaimage: "BananaImage 图片",
|
||||
visionary: "Visionary 图片",
|
||||
atlascloud: "Atlas Cloud 图片",
|
||||
vip98k: "VIP98K 图片",
|
||||
"stepfun-image": "StepFun 图片",
|
||||
midjourney: "Midjourney",
|
||||
"volcano-vision": "即梦 4.5",
|
||||
|
||||
@ -177,6 +177,7 @@ export function ImageParamsBar({
|
||||
providerType === "bananaimage" ||
|
||||
providerType === "visionary" ||
|
||||
providerType === "atlascloud" ||
|
||||
providerType === "vip98k" ||
|
||||
providerType === "stepfun-image" ||
|
||||
providerType === "midjourney"
|
||||
) {
|
||||
|
||||
@ -27,6 +27,7 @@ import { createOpenAIImage } from "./providers/openai-image";
|
||||
import { createBananaImage } from "./providers/bananaimage";
|
||||
import { createVisionary } from "./providers/visionary";
|
||||
import { createAtlasCloud } from "./providers/atlascloud";
|
||||
import { createVip98k } from "./providers/vip98k";
|
||||
import { createStepfunImage } from "./providers/stepfun-image";
|
||||
import { createMidjourney } from "./providers/midjourney";
|
||||
import { createFalVideo } from "./providers/fal-video";
|
||||
@ -324,6 +325,15 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
|
||||
return atlasCloudProvider as any;
|
||||
}
|
||||
|
||||
case "vip98k": {
|
||||
const vip98kProvider = createVip98k({
|
||||
baseURL: resolvedBaseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
});
|
||||
logProviderCreated("vip98k", resolvedBaseUrl);
|
||||
return vip98kProvider as any;
|
||||
}
|
||||
|
||||
case "stepfun-image": {
|
||||
const stepfunImageProvider = createStepfunImage({
|
||||
baseURL: resolvedBaseUrl,
|
||||
@ -501,6 +511,12 @@ export async function getDynamicModel(
|
||||
} else if (provider.type === "atlascloud") {
|
||||
baseModel = providerInstance.chat(resolvedModelId, model.imageGenConfig || null);
|
||||
logChatModelResult("atlascloud", baseModel);
|
||||
} else if (provider.type === "vip98k") {
|
||||
baseModel = providerInstance.chat(resolvedModelId, {
|
||||
capabilities: model.capabilities || null,
|
||||
imageGenConfig: model.imageGenConfig || null,
|
||||
});
|
||||
logChatModelResult("vip98k", baseModel);
|
||||
} else if (provider.type === "stepfun-image") {
|
||||
baseModel = providerInstance.chat(resolvedModelId);
|
||||
logChatModelResult("stepfun-image", baseModel);
|
||||
|
||||
@ -76,6 +76,8 @@ const THREE_D_MODE_KEYS = new Set([
|
||||
"mesh-generation",
|
||||
"mesh_generation",
|
||||
"cad",
|
||||
"world-generation",
|
||||
"world_generation",
|
||||
]);
|
||||
|
||||
const THREE_D_KIND_KEYS = new Set([
|
||||
@ -86,6 +88,11 @@ const THREE_D_KIND_KEYS = new Set([
|
||||
"mesh-generation",
|
||||
"mesh_generation",
|
||||
"cad",
|
||||
"world",
|
||||
"world-generation",
|
||||
"world_generation",
|
||||
"3d-world",
|
||||
"3d_world",
|
||||
]);
|
||||
|
||||
const TEXT_TO_IMAGE_MODE_KEYS = new Set([
|
||||
|
||||
@ -9,6 +9,7 @@ import { isAiModelMultimodal } from "@/lib/db/ai-model-flags";
|
||||
import {
|
||||
isImageGenerationModelDescriptor,
|
||||
modelSupportsTextToImageDescriptor,
|
||||
is3DModelDescriptor,
|
||||
isVideoModelDescriptor,
|
||||
} from "@/lib/ai/model-classification";
|
||||
import { isKnownReferenceImageModel } from "@/lib/ai/reference-image-capability";
|
||||
@ -51,6 +52,7 @@ const CHAT_PROVIDER_TYPES = [
|
||||
"gemini",
|
||||
"huanke-gemini",
|
||||
"volcano-multimodal",
|
||||
"vip98k",
|
||||
] as const;
|
||||
|
||||
const IMAGE_ONLY_PROVIDER_TYPES = new Set([
|
||||
@ -91,6 +93,25 @@ function isConversationalCandidate(
|
||||
const providerType = String(item.provider.type || "");
|
||||
if (!item.model.isEnabled || item.model.isSubModel) return false;
|
||||
if (isVideoProviderType(providerType, item.model)) return false;
|
||||
if (
|
||||
is3DModelDescriptor({
|
||||
providerType,
|
||||
modelId: item.model.modelId,
|
||||
capabilities: item.model.capabilities,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
isImageGenerationModelDescriptor({
|
||||
providerType,
|
||||
modelId: item.model.modelId,
|
||||
capabilities: item.model.capabilities,
|
||||
supportsImageGen: item.model.supportsImageGen,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const requireVision = Boolean(options?.requireVision);
|
||||
const matchesPreferredProvider = CHAT_PROVIDER_TYPES.includes(
|
||||
@ -175,7 +196,7 @@ const INTENT_MODEL_MAP: Record<
|
||||
> = {
|
||||
// 图片编辑:优先 alibaba-image(灵魂4.5,支持提示词扩展)
|
||||
image_edit: {
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "vip98k", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini"],
|
||||
requireVision: true,
|
||||
requireImageGen: true,
|
||||
description: "图片编辑",
|
||||
@ -190,20 +211,20 @@ const INTENT_MODEL_MAP: Record<
|
||||
|
||||
// 图片生成:优先灵魂4.5
|
||||
image_gen: {
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini", "volcano-vision"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "vip98k", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini", "volcano-vision"],
|
||||
requireImageGen: true,
|
||||
description: "图片生成",
|
||||
},
|
||||
|
||||
// 视频生成
|
||||
video_gen: {
|
||||
providerTypes: ["doubao-video", "volcano-video", "qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "custom"],
|
||||
providerTypes: ["doubao-video", "volcano-video", "qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "vip98k", "custom"],
|
||||
description: "视频生成",
|
||||
},
|
||||
|
||||
// 普通对话:优先千问3
|
||||
chat: {
|
||||
providerTypes: ["alibaba", "zhipu", "openai", "custom", "local", "gemini", "huanke-gemini", "volcano-multimodal"],
|
||||
providerTypes: ["alibaba", "zhipu", "openai", "custom", "local", "gemini", "huanke-gemini", "volcano-multimodal", "vip98k"],
|
||||
description: "对话",
|
||||
},
|
||||
};
|
||||
@ -218,25 +239,25 @@ const SUB_INTENT_OVERRIDES: Record<
|
||||
> = {
|
||||
// 抠图:alibaba-image 效果最好
|
||||
remove_background: {
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea", "gemini"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "vip98k", "stepfun-image", "runway", "krea", "gemini"],
|
||||
reason: "抠图首选灵魂4.5",
|
||||
},
|
||||
|
||||
// 换背景:alibaba-image 支持场景生成
|
||||
replace_background: {
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea", "gemini"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "vip98k", "stepfun-image", "runway", "krea", "gemini"],
|
||||
reason: "换背景首选灵魂4.5",
|
||||
},
|
||||
|
||||
// 图片增强
|
||||
enhance: {
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea", "gemini", "volcano-vision"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "vip98k", "stepfun-image", "runway", "krea", "gemini", "volcano-vision"],
|
||||
reason: "增强首选灵魂4.5",
|
||||
},
|
||||
|
||||
// 风格迁移:gemini 可能更有创意
|
||||
style_transfer: {
|
||||
providerTypes: ["gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea"],
|
||||
providerTypes: ["gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "vip98k", "stepfun-image", "runway", "krea"],
|
||||
reason: "风格迁移优选Gemini",
|
||||
},
|
||||
};
|
||||
|
||||
@ -20,6 +20,7 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
|
||||
bananaimage: "https://bananaimage.aigenmedia.art",
|
||||
visionary: "https://api.visionary.beer",
|
||||
atlascloud: "https://api.atlascloud.ai",
|
||||
vip98k: "https://api.vip98k.com",
|
||||
"stepfun-image": "https://api.stepfun.com",
|
||||
"bfl-image": "https://api.bfl.ai",
|
||||
midjourney: "https://your-midjourney-api.example.com",
|
||||
|
||||
674
lib/ai/providers/vip98k.ts
Normal file
674
lib/ai/providers/vip98k.ts
Normal file
@ -0,0 +1,674 @@
|
||||
import "server-only";
|
||||
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import sharp from "sharp";
|
||||
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||
import { VIP98K_BASE_URL, normalizeVip98kBaseUrl } from "@/lib/ai/vip98k-models";
|
||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
interface Vip98kConfig {
|
||||
apiKey: string;
|
||||
baseURL?: string | null;
|
||||
}
|
||||
|
||||
interface Vip98kTaskParams {
|
||||
apiKey: string;
|
||||
baseURL?: string | null;
|
||||
modelId: string;
|
||||
kind: string;
|
||||
prompt: string;
|
||||
imageUrls: string[];
|
||||
videoUrls: string[];
|
||||
videoParams?: Record<string, unknown>;
|
||||
imageParams?: Record<string, unknown>;
|
||||
modelConfig: Vip98kModelRuntimeConfig;
|
||||
}
|
||||
|
||||
type Vip98kModelRuntimeConfig = {
|
||||
capabilities?: Record<string, unknown> | null;
|
||||
imageGenConfig?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type UploadedImage = {
|
||||
thumbnail: string;
|
||||
original: string;
|
||||
};
|
||||
|
||||
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_POLL_INTERVAL_SECONDS = 8;
|
||||
const DEFAULT_MAX_POLL_SECONDS = 600;
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeOpenAIBaseUrl(baseURL?: string | null) {
|
||||
const normalized = normalizeVip98kBaseUrl(baseURL || VIP98K_BASE_URL);
|
||||
return /\/v1$/i.test(normalized) ? normalized : `${normalized}/v1`;
|
||||
}
|
||||
|
||||
function parseRuntimeConfig(value?: unknown): Vip98kModelRuntimeConfig {
|
||||
const record = toRecord(value);
|
||||
if (!record) return {};
|
||||
if ("capabilities" in record || "imageGenConfig" in record) {
|
||||
return {
|
||||
capabilities: toRecord(record.capabilities),
|
||||
imageGenConfig: toRecord(record.imageGenConfig),
|
||||
};
|
||||
}
|
||||
return {
|
||||
capabilities: null,
|
||||
imageGenConfig: record,
|
||||
};
|
||||
}
|
||||
|
||||
function getStringField(source: unknown, key: string) {
|
||||
const record = toRecord(source);
|
||||
return String(record?.[key] || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getVip98kMetadata(config: Vip98kModelRuntimeConfig) {
|
||||
const capabilitiesVip98k = toRecord(toRecord(config.capabilities)?.vip98k);
|
||||
const imageConfigVip98k = toRecord(toRecord(config.imageGenConfig)?.vip98k);
|
||||
return capabilitiesVip98k || imageConfigVip98k || {};
|
||||
}
|
||||
|
||||
function inferKind(modelId: string, config: Vip98kModelRuntimeConfig) {
|
||||
const capabilities = config.capabilities;
|
||||
const metadata = getVip98kMetadata(config);
|
||||
const configuredKind =
|
||||
getStringField(capabilities, "kind") ||
|
||||
getStringField(capabilities, "modelType") ||
|
||||
getStringField(capabilities, "mediaType") ||
|
||||
getStringField(metadata, "modelType");
|
||||
if (configuredKind.includes("world")) return "world";
|
||||
if (configuredKind.includes("video")) return "video";
|
||||
if (configuredKind.includes("image-tool")) return "image-tool";
|
||||
if (configuredKind.includes("image")) return "image";
|
||||
if (configuredKind.includes("audio")) return "audio";
|
||||
if (configuredKind.includes("document")) return "document-tool";
|
||||
if (configuredKind.includes("chat") || configuredKind.includes("model")) return "chat";
|
||||
|
||||
const normalizedModelId = modelId.trim().toLowerCase();
|
||||
if (normalizedModelId === "world-generation") return "world";
|
||||
if (/^veo[-_.]/.test(normalizedModelId) || normalizedModelId.startsWith("veo-")) {
|
||||
return "video";
|
||||
}
|
||||
if (
|
||||
normalizedModelId.startsWith("gpt-image") ||
|
||||
normalizedModelId.startsWith("nano-banana")
|
||||
) {
|
||||
return "image";
|
||||
}
|
||||
return "chat";
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response: Response) {
|
||||
const text = await response.text();
|
||||
if (!text.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: unknown, fallback = "VIP98K request failed") {
|
||||
if (typeof payload === "string" && payload.trim()) return payload.trim();
|
||||
const record = toRecord(payload);
|
||||
const data = toRecord(record?.data);
|
||||
const error = toRecord(record?.error);
|
||||
const value =
|
||||
data?.message ||
|
||||
data?.errorMessage ||
|
||||
data?.error ||
|
||||
error?.message ||
|
||||
record?.message ||
|
||||
record?.error ||
|
||||
fallback;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function unwrapData(payload: unknown) {
|
||||
const record = toRecord(payload);
|
||||
return record?.data && typeof record.data === "object" ? record.data : payload;
|
||||
}
|
||||
|
||||
function extractTaskId(payload: unknown) {
|
||||
const data = unwrapData(payload);
|
||||
const record = toRecord(data);
|
||||
return String(
|
||||
record?.taskId ||
|
||||
record?.task_id ||
|
||||
record?.id ||
|
||||
record?.requestId ||
|
||||
record?.request_id ||
|
||||
"",
|
||||
).trim();
|
||||
}
|
||||
|
||||
function extractResultPath(payload: unknown) {
|
||||
const data = unwrapData(payload);
|
||||
const record = toRecord(data);
|
||||
return String(record?.resultPath || record?.result_path || "").trim();
|
||||
}
|
||||
|
||||
function extractStatus(payload: unknown) {
|
||||
const data = unwrapData(payload);
|
||||
const record = toRecord(data);
|
||||
return String(record?.status || record?.state || record?.taskStatus || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function extractDone(payload: unknown) {
|
||||
const data = unwrapData(payload);
|
||||
const record = toRecord(data);
|
||||
if (typeof record?.done === "boolean") return record.done;
|
||||
if (typeof record?.isDone === "boolean") return record.isDone;
|
||||
if (typeof record?.finished === "boolean") return record.finished;
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectHttpUrls(value: unknown, urls = new Set<string>()) {
|
||||
if (!value) return urls;
|
||||
if (typeof value === "string") {
|
||||
const rawUrlMatches = value.match(/https?:\/\/[^\s"'<>()[\]{}]+/g) || [];
|
||||
for (const match of rawUrlMatches) {
|
||||
urls.add(match.replace(/[),.;]+$/g, ""));
|
||||
}
|
||||
if (/^data:image\/[a-zA-Z0-9.+-]+;base64,/i.test(value)) urls.add(value);
|
||||
return urls;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => collectHttpUrls(item, urls));
|
||||
return urls;
|
||||
}
|
||||
if (typeof value !== "object") return urls;
|
||||
Object.values(value as Record<string, unknown>).forEach((nested) =>
|
||||
collectHttpUrls(nested, urls),
|
||||
);
|
||||
return urls;
|
||||
}
|
||||
|
||||
function collectOutputUrls(payload: unknown) {
|
||||
const data = unwrapData(payload);
|
||||
const candidateRecord = toRecord(data);
|
||||
const preferred = [
|
||||
candidateRecord?.result,
|
||||
candidateRecord?.results,
|
||||
candidateRecord?.output,
|
||||
candidateRecord?.outputs,
|
||||
candidateRecord?.url,
|
||||
candidateRecord?.urls,
|
||||
candidateRecord?.imageUrl,
|
||||
candidateRecord?.image_url,
|
||||
candidateRecord?.videoUrl,
|
||||
candidateRecord?.video_url,
|
||||
candidateRecord?.assetUrl,
|
||||
candidateRecord?.asset_url,
|
||||
candidateRecord?.files,
|
||||
candidateRecord?.assets,
|
||||
data,
|
||||
];
|
||||
const urls = new Set<string>();
|
||||
preferred.forEach((candidate) => collectHttpUrls(candidate, urls));
|
||||
return Array.from(urls);
|
||||
}
|
||||
|
||||
function parseDataUrl(value: string) {
|
||||
const match = value.match(/^data:([^;,]+);base64,(.+)$/s);
|
||||
if (!match) throw new Error("VIP98K returned invalid Base64 media");
|
||||
return {
|
||||
mimeType: match[1] || "image/png",
|
||||
buffer: Buffer.from(match[2] || "", "base64"),
|
||||
};
|
||||
}
|
||||
|
||||
function detectExtension(mimeType: string) {
|
||||
if (/jpe?g/i.test(mimeType)) return "jpg";
|
||||
if (/webp/i.test(mimeType)) return "webp";
|
||||
if (/gif/i.test(mimeType)) return "gif";
|
||||
if (/mp4/i.test(mimeType)) return "mp4";
|
||||
if (/webm/i.test(mimeType)) return "webm";
|
||||
if (/quicktime/i.test(mimeType)) return "mov";
|
||||
return "png";
|
||||
}
|
||||
|
||||
function toArrayBuffer(buffer: Buffer) {
|
||||
return buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength,
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
async function uploadBuffer(buffer: Buffer, mimeType: string) {
|
||||
const extension = detectExtension(mimeType);
|
||||
const thumbnailBuffer = await sharp(buffer, { failOn: "none" })
|
||||
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 82 })
|
||||
.toBuffer()
|
||||
.catch(() => buffer);
|
||||
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const [original, thumbnail] = await Promise.all([
|
||||
uploadFile(`vip98k-orig-${suffix}.${extension}`, toArrayBuffer(buffer)),
|
||||
uploadFile(`vip98k-thumb-${suffix}.jpg`, toArrayBuffer(thumbnailBuffer)),
|
||||
]);
|
||||
return { original: original.url, thumbnail: thumbnail.url };
|
||||
}
|
||||
|
||||
async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
|
||||
if (imageUrl.startsWith("data:")) {
|
||||
const parsed = parseDataUrl(imageUrl);
|
||||
return uploadBuffer(parsed.buffer, parsed.mimeType);
|
||||
}
|
||||
const deferred = await createDeferredUploadedImage(imageUrl, {
|
||||
providerType: "vip98k",
|
||||
});
|
||||
if (deferred) return deferred;
|
||||
const response = await fetch(imageUrl, {
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
headers: { "User-Agent": "yedir-ai-vip98k/1.0" },
|
||||
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`VIP98K result image download failed (HTTP ${response.status})`);
|
||||
}
|
||||
return uploadBuffer(
|
||||
Buffer.from(await response.arrayBuffer()),
|
||||
response.headers.get("content-type") || "image/png",
|
||||
);
|
||||
}
|
||||
|
||||
async function ensurePublicMediaUrl(mediaUrl: string) {
|
||||
const normalized = String(mediaUrl || "").trim();
|
||||
if (/^https?:\/\//i.test(normalized)) return normalized;
|
||||
if (!normalized.startsWith("data:")) return normalized;
|
||||
const parsed = parseDataUrl(normalized);
|
||||
const extension = detectExtension(parsed.mimeType);
|
||||
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const uploaded = await uploadFile(
|
||||
`vip98k-ref-${suffix}.${extension}`,
|
||||
toArrayBuffer(parsed.buffer),
|
||||
);
|
||||
return uploaded.url;
|
||||
}
|
||||
|
||||
function extractPromptAndMedia(messages: Array<{ role: string; content?: any; parts?: any[] }>) {
|
||||
const message = messages.filter((item) => item.role === "user").pop();
|
||||
if (!message) throw new Error("No user message found");
|
||||
|
||||
const texts: string[] = [];
|
||||
const imageUrls: string[] = [];
|
||||
const videoUrls: string[] = [];
|
||||
|
||||
const pushText = (value: unknown) => {
|
||||
if (typeof value === "string" && value.trim()) texts.push(value.trim());
|
||||
};
|
||||
|
||||
const pushMedia = (value: unknown, mediaType?: unknown) => {
|
||||
const url = String(
|
||||
typeof value === "string"
|
||||
? value
|
||||
: (value as any)?.url ||
|
||||
(value as any)?.imageUrl ||
|
||||
(value as any)?.fileUrl ||
|
||||
(value as any)?.src ||
|
||||
"",
|
||||
).trim();
|
||||
if (!url || !/^(?:https?:\/\/|data:)/i.test(url)) return;
|
||||
const normalizedMediaType = String(mediaType || "").toLowerCase();
|
||||
const isVideo =
|
||||
normalizedMediaType.startsWith("video/") ||
|
||||
url.startsWith("data:video/") ||
|
||||
/\.(?:mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(url);
|
||||
const target = isVideo ? videoUrls : imageUrls;
|
||||
if (!target.includes(url)) target.push(url);
|
||||
};
|
||||
|
||||
const visitPart = (part: any) => {
|
||||
if (!part) return;
|
||||
if (typeof part === "string") return pushText(part);
|
||||
if (part.type === "text" || typeof part.text === "string") pushText(part.text);
|
||||
if (part.type === "image_url") pushMedia(part.image_url, "image/jpeg");
|
||||
if (part.type === "image") pushMedia(part.image || part.url, "image/jpeg");
|
||||
if (part.type === "file") {
|
||||
pushMedia(
|
||||
part.url || part.file || part.data,
|
||||
part.mediaType || part.contentType || part.mimeType,
|
||||
);
|
||||
}
|
||||
if (part.inlineData?.data) {
|
||||
pushMedia(
|
||||
`data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}`,
|
||||
part.inlineData.mimeType,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof message.content === "string") pushText(message.content);
|
||||
else if (Array.isArray(message.content)) message.content.forEach(visitPart);
|
||||
if (Array.isArray(message.parts)) message.parts.forEach(visitPart);
|
||||
|
||||
const prompt = texts.join("\n").trim() || "Generate media";
|
||||
return { prompt, imageUrls, videoUrls };
|
||||
}
|
||||
|
||||
function getConfiguredPollSeconds(config: Vip98kModelRuntimeConfig, key: string, fallback: number) {
|
||||
const metadata = getVip98kMetadata(config);
|
||||
const value = Number(metadata[key]);
|
||||
if (!Number.isFinite(value) || value <= 0) return fallback;
|
||||
return Math.min(Math.max(value, 1), 3600);
|
||||
}
|
||||
|
||||
async function submitTask(params: {
|
||||
apiKey: string;
|
||||
baseURL?: string | null;
|
||||
body: Record<string, unknown>;
|
||||
}) {
|
||||
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
|
||||
const response = await fetch(`${baseURL}/api/external/task/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params.body),
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
const payload = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`);
|
||||
}
|
||||
return {
|
||||
payload,
|
||||
taskId: extractTaskId(payload),
|
||||
resultPath: extractResultPath(payload),
|
||||
outputs: collectOutputUrls(payload),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForTask(params: {
|
||||
apiKey: string;
|
||||
baseURL?: string | null;
|
||||
taskId: string;
|
||||
resultPath?: string;
|
||||
modelConfig: Vip98kModelRuntimeConfig;
|
||||
}) {
|
||||
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
|
||||
const intervalMs =
|
||||
getConfiguredPollSeconds(
|
||||
params.modelConfig,
|
||||
"pollIntervalSeconds",
|
||||
DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
) * 1000;
|
||||
const timeoutMs =
|
||||
getConfiguredPollSeconds(params.modelConfig, "maxPollSeconds", DEFAULT_MAX_POLL_SECONDS) *
|
||||
1000;
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
await delay(intervalMs);
|
||||
const resultUrl = params.resultPath
|
||||
? params.resultPath.startsWith("http")
|
||||
? params.resultPath
|
||||
: `${baseURL}${params.resultPath.startsWith("/") ? "" : "/"}${params.resultPath}`
|
||||
: `${baseURL}/api/external/task/result?taskId=${encodeURIComponent(params.taskId)}`;
|
||||
const response = await fetch(resultUrl, {
|
||||
cache: "no-store",
|
||||
headers: { Authorization: `Bearer ${params.apiKey}` },
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
const payload = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
const status = extractStatus(payload);
|
||||
const done = extractDone(payload);
|
||||
const outputs = collectOutputUrls(payload);
|
||||
if (
|
||||
["succeeded", "success", "completed", "done", "finished"].includes(status) ||
|
||||
(done && outputs.length > 0)
|
||||
) {
|
||||
return { payload, outputs };
|
||||
}
|
||||
if (["failed", "error", "timeout", "canceled", "cancelled"].includes(status)) {
|
||||
throw new Error(extractErrorMessage(payload, "VIP98K task failed"));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("VIP98K task timed out; it may still be running");
|
||||
}
|
||||
|
||||
function normalizeRatio(value: unknown) {
|
||||
const normalized = String(value || "").trim();
|
||||
return normalized && normalized !== "auto" ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeDuration(value: unknown) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return undefined;
|
||||
return Math.round(numeric);
|
||||
}
|
||||
|
||||
function buildTaskBody(params: Vip98kTaskParams) {
|
||||
const videoParams = params.videoParams || {};
|
||||
const imageParams = params.imageParams || {};
|
||||
const promptTitle = params.prompt.split(/\s+/).slice(0, 8).join(" ").slice(0, 80);
|
||||
const body: Record<string, unknown> = {
|
||||
model: params.modelId,
|
||||
displayName: promptTitle || params.modelId,
|
||||
prompt: params.prompt,
|
||||
};
|
||||
|
||||
const aspectRatio =
|
||||
normalizeRatio(videoParams.ratio) ||
|
||||
normalizeRatio(videoParams.aspectRatio) ||
|
||||
normalizeRatio(imageParams.aspectRatio);
|
||||
const duration = normalizeDuration(videoParams.duration);
|
||||
const resolution = String(videoParams.resolution || imageParams.imageSize || "").trim();
|
||||
const negativePrompt = String(videoParams.negativePrompt || imageParams.negativePrompt || "").trim();
|
||||
|
||||
if (aspectRatio) {
|
||||
body.ratio = aspectRatio;
|
||||
body.aspectRatio = aspectRatio;
|
||||
body.aspect_ratio = aspectRatio;
|
||||
}
|
||||
if (duration) body.duration = duration;
|
||||
if (resolution) {
|
||||
body.resolution = resolution;
|
||||
body.imageSize = resolution;
|
||||
body.image_size = resolution;
|
||||
}
|
||||
if (negativePrompt) {
|
||||
body.negativePrompt = negativePrompt;
|
||||
body.negative_prompt = negativePrompt;
|
||||
}
|
||||
if (typeof videoParams.generateAudio === "boolean") {
|
||||
body.generateAudio = videoParams.generateAudio;
|
||||
body.generate_audio = videoParams.generateAudio;
|
||||
}
|
||||
|
||||
if (params.imageUrls.length > 0) {
|
||||
body.images = params.imageUrls;
|
||||
body.imageUrls = params.imageUrls;
|
||||
body.referenceImages = params.imageUrls;
|
||||
body.reference_images = params.imageUrls;
|
||||
body.image = params.imageUrls[0];
|
||||
body.imageUrl = params.imageUrls[0];
|
||||
body.image_url = params.imageUrls[0];
|
||||
body.firstFrameImage = params.imageUrls[0];
|
||||
body.first_frame_image = params.imageUrls[0];
|
||||
body.firstFrameImageUrl = params.imageUrls[0];
|
||||
body.first_frame_image_url = params.imageUrls[0];
|
||||
if (params.imageUrls[1]) {
|
||||
body.lastFrameImage = params.imageUrls[1];
|
||||
body.last_frame_image = params.imageUrls[1];
|
||||
body.endImageUrl = params.imageUrls[1];
|
||||
body.end_image_url = params.imageUrls[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (params.videoUrls.length > 0) {
|
||||
body.videos = params.videoUrls;
|
||||
body.videoUrls = params.videoUrls;
|
||||
body.referenceVideos = params.videoUrls;
|
||||
}
|
||||
|
||||
if (params.kind === "world") {
|
||||
body.mode = "world-generation";
|
||||
} else if (params.kind === "video") {
|
||||
body.mode = params.imageUrls.length > 0 ? "image-to-video" : "text-to-video";
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function formatImageMarkdown(images: UploadedImage[], imageSize: string) {
|
||||
return images
|
||||
.map((image) => ``)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function formatVideoMarkdown(videoUrl: string) {
|
||||
return `<video src="${videoUrl}" controls style="max-width: 100%; border-radius: 8px; margin-bottom: 12px;"></video>\n\n[下载视频](${videoUrl})`;
|
||||
}
|
||||
|
||||
function formatWorldMarkdown(urls: string[]) {
|
||||
if (urls.length === 0) return "3D 世界生成成功,但未返回可访问的结果地址";
|
||||
return urls.map((url, index) => `[查看 3D 世界 ${index + 1}](${url})`).join("\n\n");
|
||||
}
|
||||
|
||||
async function runTask(params: Vip98kTaskParams) {
|
||||
const body = buildTaskBody(params);
|
||||
const submitted = await submitTask({
|
||||
apiKey: params.apiKey,
|
||||
baseURL: params.baseURL,
|
||||
body,
|
||||
});
|
||||
|
||||
const outputUrls = submitted.outputs.length
|
||||
? submitted.outputs
|
||||
: await waitForTask({
|
||||
apiKey: params.apiKey,
|
||||
baseURL: params.baseURL,
|
||||
taskId: submitted.taskId,
|
||||
resultPath: submitted.resultPath,
|
||||
modelConfig: params.modelConfig,
|
||||
}).then((result) => result.outputs);
|
||||
|
||||
if (params.kind === "video") {
|
||||
const videoUrl =
|
||||
outputUrls.find((url) => /\.(mp4|webm|mov|m4v)(?:[?#].*)?$/i.test(url)) ||
|
||||
outputUrls[0];
|
||||
if (!videoUrl) throw new Error("VIP98K task completed but returned no video URL");
|
||||
return formatVideoMarkdown(videoUrl);
|
||||
}
|
||||
|
||||
if (params.kind === "image" || params.kind === "image-tool") {
|
||||
if (outputUrls.length === 0) {
|
||||
throw new Error("VIP98K task completed but returned no image URL");
|
||||
}
|
||||
const imageSize = String(params.imageParams?.imageSize || "2K").trim() || "2K";
|
||||
const archived = await Promise.all(outputUrls.slice(0, 1).map(archiveOutputImage));
|
||||
return formatImageMarkdown(archived, imageSize);
|
||||
}
|
||||
|
||||
if (params.kind === "world") {
|
||||
return formatWorldMarkdown(outputUrls);
|
||||
}
|
||||
|
||||
return outputUrls.length ? outputUrls.join("\n") : "VIP98K task completed";
|
||||
}
|
||||
|
||||
export function createVip98k({ apiKey, baseURL }: Vip98kConfig) {
|
||||
const rootBaseURL = normalizeVip98kBaseUrl(baseURL);
|
||||
const openAIBaseURL = normalizeOpenAIBaseUrl(rootBaseURL);
|
||||
const openAITextProvider = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: openAIBaseURL,
|
||||
});
|
||||
const openAIImageProvider = createOpenAIImage({
|
||||
apiKey,
|
||||
baseURL: openAIBaseURL,
|
||||
});
|
||||
|
||||
return {
|
||||
provider: "vip98k",
|
||||
chat: (modelId: string, defaultModelConfig?: unknown): any => {
|
||||
const modelConfig = parseRuntimeConfig(defaultModelConfig);
|
||||
const kind = inferKind(modelId, modelConfig);
|
||||
|
||||
if (kind === "image") {
|
||||
return openAIImageProvider.chat(modelId) as any;
|
||||
}
|
||||
|
||||
if (kind === "chat") {
|
||||
return openAITextProvider.chat(modelId) as any;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "vip98k",
|
||||
doGenerate: async (options: {
|
||||
messages: Array<{ role: string; content?: any; parts?: any[] }>;
|
||||
imageParams?: Record<string, unknown>;
|
||||
videoParams?: Record<string, unknown>;
|
||||
imageGenConfig?: unknown;
|
||||
overrideModelId?: string;
|
||||
}) => {
|
||||
const runtimeConfig =
|
||||
options.imageGenConfig !== undefined
|
||||
? parseRuntimeConfig({
|
||||
capabilities: modelConfig.capabilities,
|
||||
imageGenConfig: options.imageGenConfig,
|
||||
})
|
||||
: modelConfig;
|
||||
const activeModelId = String(options.overrideModelId || modelId || "").trim();
|
||||
if (!activeModelId) throw new Error("VIP98K model ID is not configured");
|
||||
const activeKind = inferKind(activeModelId, runtimeConfig);
|
||||
const { prompt, imageUrls, videoUrls } = extractPromptAndMedia(options.messages);
|
||||
const [publicImageUrls, publicVideoUrls] = await Promise.all([
|
||||
Promise.all(imageUrls.map(ensurePublicMediaUrl)),
|
||||
Promise.all(videoUrls.map(ensurePublicMediaUrl)),
|
||||
]);
|
||||
const text = await runTask({
|
||||
apiKey,
|
||||
baseURL: rootBaseURL,
|
||||
modelId: activeModelId,
|
||||
kind: activeKind,
|
||||
prompt,
|
||||
imageUrls: publicImageUrls,
|
||||
videoUrls: publicVideoUrls,
|
||||
videoParams: options.videoParams,
|
||||
imageParams: options.imageParams,
|
||||
modelConfig: runtimeConfig,
|
||||
});
|
||||
return {
|
||||
text,
|
||||
finishReason: "stop" as const,
|
||||
usage: {
|
||||
promptTokens: 0,
|
||||
completionTokens: text.length,
|
||||
},
|
||||
};
|
||||
},
|
||||
doStream: async function* (options: {
|
||||
messages: Array<{ role: string; content?: any; parts?: any[] }>;
|
||||
imageParams?: Record<string, unknown>;
|
||||
videoParams?: Record<string, unknown>;
|
||||
imageGenConfig?: unknown;
|
||||
overrideModelId?: string;
|
||||
}) {
|
||||
const result = await this.doGenerate(options);
|
||||
yield { type: "text-delta", textDelta: result.text };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -7,6 +7,8 @@ import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import { doGenerateStream } from "@/lib/ai/providers/volcano-video";
|
||||
import { executeAtlasCloudVideoGeneration } from "@/lib/ai/providers/atlascloud";
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import { isVideoModelDescriptor } from "@/lib/ai/model-classification";
|
||||
import {
|
||||
getEnabledAiModelsWithProvider,
|
||||
getSubModelsByParentModelId
|
||||
@ -55,6 +57,16 @@ function inferSupportedModesFromModelId(modelId: string): VideoGenerationMode[]
|
||||
return ["text-to-video", "first-frame", "first-last-frame", "reference-images"];
|
||||
}
|
||||
|
||||
function extractVideoUrlFromGeneratedText(value: unknown) {
|
||||
const text = String(value || "");
|
||||
const srcMatch = text.match(/<video[^>]+src=["'](https?:\/\/[^"']+)["']/i);
|
||||
if (srcMatch?.[1]) return srcMatch[1];
|
||||
const markdownMatch = text.match(/\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/i);
|
||||
if (markdownMatch?.[1]) return markdownMatch[1];
|
||||
const directMatch = text.match(/https?:\/\/[^\s<>"']+/i);
|
||||
return directMatch?.[0]?.replace(/[),.;]+$/, "") || "";
|
||||
}
|
||||
|
||||
function getSupportedModes(caps: Partial<VideoModelCapabilities> | undefined, modelId: string): VideoGenerationMode[] {
|
||||
const modes = (caps?.supportedModes || []) as VideoGenerationMode[];
|
||||
if (Array.isArray(modes) && modes.length > 0) {
|
||||
@ -120,11 +132,24 @@ function isAtlasCloudVideoModel(item: any) {
|
||||
);
|
||||
}
|
||||
|
||||
function isVip98kVideoModel(item: any) {
|
||||
return (
|
||||
item?.provider?.type === "vip98k" &&
|
||||
isVideoModelDescriptor({
|
||||
providerType: item.provider.type,
|
||||
modelId: item.model?.modelId,
|
||||
capabilities: item.model?.capabilities,
|
||||
supportsImageGen: item.model?.supportsImageGen,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isDirectExecutableVideoModel(item: any) {
|
||||
return (
|
||||
isVolcanoVideoModel(item) ||
|
||||
isAgnesVideoModel(item) ||
|
||||
isAtlasCloudVideoModel(item)
|
||||
isAtlasCloudVideoModel(item) ||
|
||||
isVip98kVideoModel(item)
|
||||
);
|
||||
}
|
||||
|
||||
@ -540,6 +565,103 @@ async function executeAgnesVideoGeneration(params: {
|
||||
);
|
||||
}
|
||||
|
||||
async function resolvePreferredVip98kVideoModel(
|
||||
preferredModelRef: string | undefined,
|
||||
mode: VideoGenerationMode,
|
||||
params: Partial<VideoGenerationOptions>,
|
||||
) {
|
||||
const normalizedRef = String(preferredModelRef || "").trim();
|
||||
if (!normalizedRef) return null;
|
||||
|
||||
const models = await getEnabledAiModelsWithProvider();
|
||||
const matched = models.find(
|
||||
(item) =>
|
||||
isVip98kVideoModel(item) &&
|
||||
item.model.isEnabled &&
|
||||
!item.model.isSubModel &&
|
||||
(item.model.id === normalizedRef ||
|
||||
item.model.modelId === normalizedRef ||
|
||||
item.model.displayName === normalizedRef),
|
||||
);
|
||||
if (!matched) return null;
|
||||
|
||||
const capabilities = withConfiguredVideoCapabilities(
|
||||
matched.model.capabilities as VideoModelCapabilities | undefined,
|
||||
(matched.model as any).imageGenConfig,
|
||||
);
|
||||
const supportedModes = getSupportedModes(capabilities, matched.model.modelId);
|
||||
if (!supportedModes.includes(mode)) {
|
||||
throw new Error(`模型 ${matched.model.displayName} 不支持 ${mode} 模式`);
|
||||
}
|
||||
const resolvedCapabilities = {
|
||||
...capabilities,
|
||||
supportedModes,
|
||||
} as VideoModelCapabilities;
|
||||
validateParamsAgainstCapabilities(params, resolvedCapabilities);
|
||||
return { model: matched, capabilities: resolvedCapabilities };
|
||||
}
|
||||
|
||||
async function executeVip98kVideoGeneration(params: {
|
||||
request: ExecuteDirectVideoGenerationParams;
|
||||
prompt: string;
|
||||
selected: { model: any; capabilities: VideoModelCapabilities };
|
||||
generationParams: Partial<VideoGenerationOptions>;
|
||||
}): Promise<ExecuteDirectVideoGenerationResult> {
|
||||
const selected = params.selected.model;
|
||||
const languageModel = await getLanguageModelById(selected.model.id);
|
||||
if (!languageModel || typeof (languageModel as any).doGenerate !== "function") {
|
||||
throw new Error("所选 VIP98K 视频模型初始化失败");
|
||||
}
|
||||
|
||||
await params.request.onProgress?.(
|
||||
`正在调用 ${selected.model.displayName || selected.model.modelId} 生成视频...`,
|
||||
);
|
||||
const imageParts = (params.request.images || []).map((url) => ({
|
||||
type: "image_url" as const,
|
||||
image_url: { url },
|
||||
}));
|
||||
const result = await (languageModel as any).doGenerate({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: params.prompt }, ...imageParts],
|
||||
},
|
||||
],
|
||||
overrideModelId: selected.model.modelId,
|
||||
videoParams: {
|
||||
mode: params.request.mode,
|
||||
resolution: params.generationParams.resolution || "720p",
|
||||
ratio: params.generationParams.ratio || "16:9",
|
||||
duration: params.generationParams.duration || 8,
|
||||
watermark: params.generationParams.watermark,
|
||||
cameraFixed: params.generationParams.cameraFixed,
|
||||
seed: params.generationParams.seed,
|
||||
returnLastFrame: params.generationParams.returnLastFrame,
|
||||
},
|
||||
});
|
||||
if (result?.finishReason === "error") {
|
||||
throw new Error(String(result?.text || "VIP98K 视频生成失败"));
|
||||
}
|
||||
const videoUrl = extractVideoUrlFromGeneratedText(result?.text);
|
||||
if (!videoUrl) throw new Error("VIP98K 视频模型未返回可用视频地址");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
videoUrl,
|
||||
metadata: {
|
||||
provider: "vip98k",
|
||||
temporaryVideoUrl: videoUrl,
|
||||
mode: params.request.mode,
|
||||
resolution: params.generationParams.resolution,
|
||||
ratio: params.generationParams.ratio,
|
||||
duration: params.generationParams.duration,
|
||||
},
|
||||
modelDisplayName: String(selected.model.displayName || selected.model.modelId),
|
||||
modelId: String(selected.model.modelId || selected.model.id),
|
||||
modelRef: String(selected.model.id || selected.model.modelId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeDirectVideoGeneration(
|
||||
params: ExecuteDirectVideoGenerationParams,
|
||||
): Promise<ExecuteDirectVideoGenerationResult> {
|
||||
@ -589,6 +711,20 @@ export async function executeDirectVideoGeneration(
|
||||
referenceImages: params.images,
|
||||
} satisfies Partial<VideoGenerationOptions>;
|
||||
|
||||
const preferredVip98kModel = await resolvePreferredVip98kVideoModel(
|
||||
params.preferredModelRef,
|
||||
params.mode,
|
||||
generationParams,
|
||||
);
|
||||
if (preferredVip98kModel) {
|
||||
return executeVip98kVideoGeneration({
|
||||
request: params,
|
||||
prompt,
|
||||
selected: preferredVip98kModel,
|
||||
generationParams,
|
||||
});
|
||||
}
|
||||
|
||||
let selectedModelConfig: { model: any; capabilities: VideoModelCapabilities };
|
||||
try {
|
||||
selectedModelConfig = await getCompatibleModel(
|
||||
|
||||
626
lib/ai/vip98k-models.ts
Normal file
626
lib/ai/vip98k-models.ts
Normal file
@ -0,0 +1,626 @@
|
||||
import "server-only";
|
||||
|
||||
export const VIP98K_BASE_URL = "https://api.vip98k.com";
|
||||
|
||||
export type Vip98kCatalogModelType =
|
||||
| "image"
|
||||
| "video"
|
||||
| "image_tool"
|
||||
| "document_tool"
|
||||
| "model"
|
||||
| "audio"
|
||||
| "world"
|
||||
| string;
|
||||
|
||||
export interface Vip98kCatalogModel {
|
||||
modelId?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
type?: Vip98kCatalogModelType;
|
||||
modelType?: Vip98kCatalogModelType;
|
||||
model_type?: Vip98kCatalogModelType;
|
||||
category?: Vip98kCatalogModelType;
|
||||
capabilities?: unknown;
|
||||
inputModes?: unknown;
|
||||
input_modes?: unknown;
|
||||
outputModes?: unknown;
|
||||
output_modes?: unknown;
|
||||
supportedResolutions?: unknown;
|
||||
supported_resolutions?: unknown;
|
||||
resolutions?: unknown;
|
||||
durations?: unknown;
|
||||
durationOptions?: unknown;
|
||||
duration_options?: unknown;
|
||||
defaultDuration?: unknown;
|
||||
default_duration?: unknown;
|
||||
supportsAsync?: unknown;
|
||||
supports_async?: unknown;
|
||||
pollIntervalSeconds?: unknown;
|
||||
poll_interval_seconds?: unknown;
|
||||
maxPollSeconds?: unknown;
|
||||
max_poll_seconds?: unknown;
|
||||
resultTTLSeconds?: unknown;
|
||||
result_ttl_seconds?: unknown;
|
||||
billingMode?: string;
|
||||
billing_mode?: string;
|
||||
pricing?: unknown;
|
||||
price?: unknown;
|
||||
status?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Vip98kModelPreset {
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
type: Vip98kCatalogModelType;
|
||||
supportsVision: boolean;
|
||||
supportsImageGen: boolean;
|
||||
isHidden: boolean;
|
||||
capabilities: Record<string, unknown>;
|
||||
defaultParams: Record<string, unknown>;
|
||||
imageGenConfig: Record<string, unknown> | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
const DEFAULT_ASPECT_RATIOS = [
|
||||
"auto",
|
||||
"1:1",
|
||||
"4:3",
|
||||
"3:4",
|
||||
"16:9",
|
||||
"9:16",
|
||||
"3:2",
|
||||
"2:3",
|
||||
"4:5",
|
||||
"5:4",
|
||||
] as const;
|
||||
|
||||
const DEFAULT_VIDEO_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4"] as const;
|
||||
const DEFAULT_IMAGE_SIZES = ["1K", "2K", "4K"] as const;
|
||||
|
||||
const TYPE_SORT_ORDER: Record<string, number> = {
|
||||
image: 100,
|
||||
video: 200,
|
||||
world: 300,
|
||||
model: 400,
|
||||
image_tool: 800,
|
||||
audio: 900,
|
||||
document_tool: 950,
|
||||
};
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function parseNumberArray(value: unknown): number[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.map((item) => Number(item))
|
||||
.filter((item) => Number.isFinite(item) && item > 0),
|
||||
),
|
||||
).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function parseNumber(value: unknown): number | undefined {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : undefined;
|
||||
}
|
||||
|
||||
function normalizeCatalogType(model: Vip98kCatalogModel): Vip98kCatalogModelType {
|
||||
return String(
|
||||
model.type || model.modelType || model.model_type || model.category || "model",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeImageSize(value: string): string | null {
|
||||
const normalized = value.trim().toUpperCase();
|
||||
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
|
||||
return normalized;
|
||||
}
|
||||
const kMatch = normalized.match(/^(\d+(?:\.\d+)?)\s*K$/);
|
||||
if (kMatch?.[1]) return `${Number(kMatch[1]).toString()}K`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeVideoResolution(value: string): string | null {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "720p" || normalized === "1080p" || normalized === "4k") {
|
||||
return normalized;
|
||||
}
|
||||
if (normalized === "720" || normalized === "720P".toLowerCase()) return "720p";
|
||||
if (normalized === "1080" || normalized === "1080P".toLowerCase()) return "1080p";
|
||||
if (normalized === "2160p" || normalized === "2160" || normalized === "4K".toLowerCase()) {
|
||||
return "4k";
|
||||
}
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function getSupportedResolutions(model: Vip98kCatalogModel) {
|
||||
return parseStringArray(
|
||||
model.supportedResolutions || model.supported_resolutions || model.resolutions,
|
||||
);
|
||||
}
|
||||
|
||||
function getSupportedImageSizes(model: Vip98kCatalogModel) {
|
||||
const sizes = getSupportedResolutions(model)
|
||||
.map(normalizeImageSize)
|
||||
.filter((value): value is string => Boolean(value));
|
||||
return sizes.length ? sizes : [...DEFAULT_IMAGE_SIZES];
|
||||
}
|
||||
|
||||
function getSupportedVideoResolutions(model: Vip98kCatalogModel) {
|
||||
const resolutions = getSupportedResolutions(model)
|
||||
.map(normalizeVideoResolution)
|
||||
.filter((value): value is string => Boolean(value));
|
||||
return resolutions.length ? resolutions : ["720p"];
|
||||
}
|
||||
|
||||
function getDurations(model: Vip98kCatalogModel) {
|
||||
return parseNumberArray(
|
||||
model.durations || model.durationOptions || model.duration_options,
|
||||
);
|
||||
}
|
||||
|
||||
function getCapabilities(model: Vip98kCatalogModel) {
|
||||
return parseStringArray(model.capabilities).map((item) =>
|
||||
item.trim().toLowerCase().replace(/_/g, "-"),
|
||||
);
|
||||
}
|
||||
|
||||
function getInputModes(model: Vip98kCatalogModel) {
|
||||
return parseStringArray(model.inputModes || model.input_modes).map((item) =>
|
||||
item.trim().toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function getOutputModes(model: Vip98kCatalogModel) {
|
||||
return parseStringArray(model.outputModes || model.output_modes).map((item) =>
|
||||
item.trim().toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function getDisplayName(model: Vip98kCatalogModel, modelId: string) {
|
||||
return String(
|
||||
model.displayName || model.display_name || model.name || modelId,
|
||||
).trim();
|
||||
}
|
||||
|
||||
function getModelId(model: Vip98kCatalogModel) {
|
||||
return String(model.modelId || model.id || model.name || "").trim();
|
||||
}
|
||||
|
||||
function getEffectivePrice(model: Vip98kCatalogModel) {
|
||||
const pricing = toRecord(model.pricing);
|
||||
return parseNumber(
|
||||
model.price ||
|
||||
pricing?.effectivePrice ||
|
||||
pricing?.effective_price ||
|
||||
pricing?.price ||
|
||||
pricing?.requestPrice ||
|
||||
pricing?.request_price,
|
||||
);
|
||||
}
|
||||
|
||||
function getBillingMode(model: Vip98kCatalogModel) {
|
||||
return String(model.billingMode || model.billing_mode || "per_request").trim();
|
||||
}
|
||||
|
||||
function getVip98kMetadata(model: Vip98kCatalogModel, type: string) {
|
||||
return {
|
||||
modelType: type,
|
||||
taskApi: "external-task",
|
||||
supportsAsync: Boolean(model.supportsAsync ?? model.supports_async ?? true),
|
||||
pollIntervalSeconds: parseNumber(
|
||||
model.pollIntervalSeconds || model.poll_interval_seconds,
|
||||
) || 8,
|
||||
maxPollSeconds: parseNumber(model.maxPollSeconds || model.max_poll_seconds) || 600,
|
||||
resultTTLSeconds: parseNumber(
|
||||
model.resultTTLSeconds || model.result_ttl_seconds,
|
||||
),
|
||||
billingMode: getBillingMode(model),
|
||||
effectivePrice: getEffectivePrice(model),
|
||||
pricing: model.pricing || null,
|
||||
status: model.status || null,
|
||||
};
|
||||
}
|
||||
|
||||
function createImagePreset(
|
||||
model: Vip98kCatalogModel,
|
||||
index: number,
|
||||
): Vip98kModelPreset {
|
||||
const modelId = getModelId(model);
|
||||
const displayName = getDisplayName(model, modelId);
|
||||
const capabilities = getCapabilities(model);
|
||||
const inputModes = getInputModes(model);
|
||||
const outputModes = getOutputModes(model);
|
||||
const supportedImageSizes = getSupportedImageSizes(model);
|
||||
const supportsReferenceImages =
|
||||
capabilities.includes("image-to-image") ||
|
||||
inputModes.includes("url") ||
|
||||
inputModes.includes("file");
|
||||
const supportedModes = [
|
||||
...(capabilities.includes("text-to-image") || capabilities.length === 0
|
||||
? ["text-to-image"]
|
||||
: []),
|
||||
...(supportsReferenceImages ? ["image-to-image", "reference-images"] : []),
|
||||
];
|
||||
const maxReferenceImages = supportsReferenceImages ? 8 : 0;
|
||||
const defaultImageSize =
|
||||
supportedImageSizes.find((size) => size === "2K") ||
|
||||
supportedImageSizes[0] ||
|
||||
"1K";
|
||||
|
||||
return {
|
||||
modelId,
|
||||
displayName,
|
||||
description: model.description || `VIP98K image model (${modelId})`,
|
||||
type: "image",
|
||||
supportsVision: supportsReferenceImages,
|
||||
supportsImageGen: true,
|
||||
isHidden: false,
|
||||
capabilities: {
|
||||
provider: "vip98k",
|
||||
kind: "image",
|
||||
mediaType: "image",
|
||||
modelType: "image",
|
||||
outputType: "image",
|
||||
supportedModes,
|
||||
inputModes,
|
||||
outputModes,
|
||||
supportedImageSizes,
|
||||
supportedAspectRatios: [...DEFAULT_ASPECT_RATIOS],
|
||||
supportsMultiImageReference: supportsReferenceImages,
|
||||
maxReferenceImages,
|
||||
vip98k: getVip98kMetadata(model, "image"),
|
||||
},
|
||||
defaultParams: {
|
||||
provider: "vip98k",
|
||||
kind: "image",
|
||||
endpoint: modelId,
|
||||
apiFormat: "openai-images-compatible",
|
||||
taskApi: "openai-images",
|
||||
supportedModes,
|
||||
imageSize: defaultImageSize,
|
||||
aspectRatio: "auto",
|
||||
imageCount: 1,
|
||||
},
|
||||
imageGenConfig: {
|
||||
maxImages: 1,
|
||||
maxReferenceImages,
|
||||
supportsSize: true,
|
||||
supports1K: supportedImageSizes.includes("1K"),
|
||||
supports2K: supportedImageSizes.includes("2K"),
|
||||
supports4K: supportedImageSizes.includes("4K"),
|
||||
supportedImageSizes,
|
||||
supportedAspectRatios: [...DEFAULT_ASPECT_RATIOS],
|
||||
supportsPromptExtend: false,
|
||||
supportsNegativePrompt: false,
|
||||
supportsWatermark: false,
|
||||
supportsSeed: false,
|
||||
creditCostPerImage: 0,
|
||||
creditCostMultiplierBySize: {
|
||||
"1K": 1,
|
||||
"2K": 1,
|
||||
"4K": 1,
|
||||
},
|
||||
enableLoadingPlaceholder: true,
|
||||
loadingColor: "yellow",
|
||||
vip98k: getVip98kMetadata(model, "image"),
|
||||
},
|
||||
sortOrder: TYPE_SORT_ORDER.image + index,
|
||||
};
|
||||
}
|
||||
|
||||
function createVideoPreset(
|
||||
model: Vip98kCatalogModel,
|
||||
index: number,
|
||||
): Vip98kModelPreset {
|
||||
const modelId = getModelId(model);
|
||||
const displayName = getDisplayName(model, modelId);
|
||||
const capabilities = getCapabilities(model);
|
||||
const inputModes = getInputModes(model);
|
||||
const outputModes = getOutputModes(model);
|
||||
const supportedResolutions = getSupportedVideoResolutions(model);
|
||||
const durations = getDurations(model);
|
||||
const defaultDuration =
|
||||
parseNumber(model.defaultDuration || model.default_duration) ||
|
||||
durations[durations.length - 1] ||
|
||||
8;
|
||||
const supportsReferenceImages =
|
||||
capabilities.includes("image-to-video") ||
|
||||
inputModes.includes("url") ||
|
||||
inputModes.includes("file");
|
||||
const supportedModes = [
|
||||
...(capabilities.includes("text-to-video") || capabilities.length === 0
|
||||
? ["text-to-video"]
|
||||
: []),
|
||||
...(supportsReferenceImages ? ["image-to-video", "first-frame"] : []),
|
||||
];
|
||||
|
||||
return {
|
||||
modelId,
|
||||
displayName,
|
||||
description: model.description || `VIP98K video model (${modelId})`,
|
||||
type: "video",
|
||||
supportsVision: supportsReferenceImages,
|
||||
supportsImageGen: false,
|
||||
isHidden: false,
|
||||
capabilities: {
|
||||
provider: "vip98k",
|
||||
kind: "video",
|
||||
mediaType: "video",
|
||||
modelType: "video",
|
||||
outputType: "video",
|
||||
supportedModes,
|
||||
inputModes,
|
||||
outputModes,
|
||||
supportedResolutions,
|
||||
supportedRatios: [...DEFAULT_VIDEO_RATIOS],
|
||||
durationOptions: durations,
|
||||
durationRange: durations.length
|
||||
? { min: durations[0], max: durations[durations.length - 1] }
|
||||
: { min: defaultDuration, max: defaultDuration },
|
||||
defaultDuration,
|
||||
supportWatermark: false,
|
||||
supportCameraFixed: false,
|
||||
maxReferenceImages: supportsReferenceImages ? 4 : 0,
|
||||
vip98k: getVip98kMetadata(model, "video"),
|
||||
},
|
||||
defaultParams: {
|
||||
provider: "vip98k",
|
||||
kind: "video",
|
||||
endpoint: modelId,
|
||||
taskApi: "external-task",
|
||||
supportedModes,
|
||||
duration: defaultDuration,
|
||||
resolution: supportedResolutions[0] || "720p",
|
||||
ratio: "16:9",
|
||||
},
|
||||
imageGenConfig: {
|
||||
creditCostPerVideo: 0,
|
||||
videoDurationOptions: durations,
|
||||
creditCostPerVideoByDuration: Object.fromEntries(
|
||||
durations.map((duration) => [String(duration), 0]),
|
||||
),
|
||||
creditCostPerVideoByResolution: Object.fromEntries(
|
||||
supportedResolutions.map((resolution) => [resolution, 0]),
|
||||
),
|
||||
enableLoadingPlaceholder: true,
|
||||
loadingColor: "blue",
|
||||
vip98k: getVip98kMetadata(model, "video"),
|
||||
},
|
||||
sortOrder: TYPE_SORT_ORDER.video + index,
|
||||
};
|
||||
}
|
||||
|
||||
function createWorldPreset(
|
||||
model: Vip98kCatalogModel,
|
||||
index: number,
|
||||
): Vip98kModelPreset {
|
||||
const modelId = getModelId(model);
|
||||
const displayName = getDisplayName(model, modelId);
|
||||
return {
|
||||
modelId,
|
||||
displayName,
|
||||
description: model.description || `VIP98K 3D world model (${modelId})`,
|
||||
type: "world",
|
||||
supportsVision: false,
|
||||
supportsImageGen: false,
|
||||
isHidden: false,
|
||||
capabilities: {
|
||||
provider: "vip98k",
|
||||
kind: "world",
|
||||
mediaType: "world",
|
||||
modelType: "world-generation",
|
||||
outputType: "3d",
|
||||
supportedModes: ["world-generation", "text-to-3d"],
|
||||
vip98k: getVip98kMetadata(model, "world"),
|
||||
},
|
||||
defaultParams: {
|
||||
provider: "vip98k",
|
||||
kind: "world",
|
||||
endpoint: modelId,
|
||||
taskApi: "external-task",
|
||||
},
|
||||
imageGenConfig: null,
|
||||
sortOrder: TYPE_SORT_ORDER.world + index,
|
||||
};
|
||||
}
|
||||
|
||||
function createChatPreset(
|
||||
model: Vip98kCatalogModel,
|
||||
index: number,
|
||||
): Vip98kModelPreset {
|
||||
const modelId = getModelId(model);
|
||||
const displayName = getDisplayName(model, modelId);
|
||||
return {
|
||||
modelId,
|
||||
displayName,
|
||||
description: model.description || `VIP98K text model (${modelId})`,
|
||||
type: "model",
|
||||
supportsVision: false,
|
||||
supportsImageGen: false,
|
||||
isHidden: false,
|
||||
capabilities: {
|
||||
provider: "vip98k",
|
||||
kind: "chat",
|
||||
mediaType: "text",
|
||||
modelType: "model",
|
||||
outputType: "text",
|
||||
apiFormat: "openai-compatible",
|
||||
vip98k: getVip98kMetadata(model, "model"),
|
||||
},
|
||||
defaultParams: {
|
||||
provider: "vip98k",
|
||||
kind: "chat",
|
||||
endpoint: modelId,
|
||||
apiFormat: "openai-compatible",
|
||||
},
|
||||
imageGenConfig: null,
|
||||
sortOrder: TYPE_SORT_ORDER.model + index,
|
||||
};
|
||||
}
|
||||
|
||||
function createToolPreset(
|
||||
model: Vip98kCatalogModel,
|
||||
type: string,
|
||||
index: number,
|
||||
): Vip98kModelPreset {
|
||||
const modelId = getModelId(model);
|
||||
const displayName = getDisplayName(model, modelId);
|
||||
const inputModes = getInputModes(model);
|
||||
const outputModes = getOutputModes(model);
|
||||
const isImageTool = type === "image_tool";
|
||||
|
||||
return {
|
||||
modelId,
|
||||
displayName,
|
||||
description: model.description || `VIP98K ${type} model (${modelId})`,
|
||||
type,
|
||||
supportsVision: isImageTool,
|
||||
supportsImageGen: false,
|
||||
isHidden: true,
|
||||
capabilities: {
|
||||
provider: "vip98k",
|
||||
kind: type.replace(/_/g, "-"),
|
||||
mediaType: isImageTool ? "image" : type === "audio" ? "audio" : "document",
|
||||
modelType: type,
|
||||
outputType: outputModes[0] || (isImageTool ? "image" : "text"),
|
||||
inputModes,
|
||||
outputModes,
|
||||
supportedModes: getCapabilities(model),
|
||||
vip98k: getVip98kMetadata(model, type),
|
||||
},
|
||||
defaultParams: {
|
||||
provider: "vip98k",
|
||||
kind: type.replace(/_/g, "-"),
|
||||
endpoint: modelId,
|
||||
taskApi: type === "audio" ? "openai-compatible" : "external-task",
|
||||
},
|
||||
imageGenConfig: null,
|
||||
sortOrder: (TYPE_SORT_ORDER[type] || 990) + index,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVip98kModelPresets(
|
||||
models: Vip98kCatalogModel[],
|
||||
): Vip98kModelPreset[] {
|
||||
return models
|
||||
.map((model, index) => {
|
||||
const modelId = getModelId(model);
|
||||
if (!modelId) return null;
|
||||
const type = normalizeCatalogType(model);
|
||||
if (type === "image") return createImagePreset(model, index);
|
||||
if (type === "video") return createVideoPreset(model, index);
|
||||
if (type === "world") return createWorldPreset(model, index);
|
||||
if (type === "model") return createChatPreset(model, index);
|
||||
return createToolPreset(model, type, index);
|
||||
})
|
||||
.filter((item): item is Vip98kModelPreset => Boolean(item))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.displayName.localeCompare(b.displayName));
|
||||
}
|
||||
|
||||
export function normalizeVip98kBaseUrl(baseURL?: string | null) {
|
||||
const normalized = String(baseURL || VIP98K_BASE_URL).trim().replace(/\/+$/, "");
|
||||
if (!normalized) return VIP98K_BASE_URL;
|
||||
if (/\/v1$/i.test(normalized)) return normalized.replace(/\/v1$/i, "");
|
||||
if (/\/api\/external\/task$/i.test(normalized)) {
|
||||
return normalized.replace(/\/api\/external\/task$/i, "");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function extractCatalogModels(payload: unknown): Vip98kCatalogModel[] {
|
||||
const record = toRecord(payload);
|
||||
const data = record?.data;
|
||||
const dataRecord = toRecord(data);
|
||||
const candidates = [
|
||||
dataRecord?.models,
|
||||
dataRecord?.items,
|
||||
dataRecord?.list,
|
||||
data,
|
||||
record?.models,
|
||||
record?.items,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (Array.isArray(candidate)) return candidate as Vip98kCatalogModel[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response: Response) {
|
||||
const text = await response.text();
|
||||
if (!text.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: unknown, fallback = "VIP98K model catalog request failed") {
|
||||
if (typeof payload === "string" && payload.trim()) return payload.trim();
|
||||
const record = toRecord(payload);
|
||||
const data = toRecord(record?.data);
|
||||
const error = toRecord(record?.error);
|
||||
const value =
|
||||
data?.message ||
|
||||
data?.error ||
|
||||
error?.message ||
|
||||
record?.message ||
|
||||
record?.error ||
|
||||
fallback;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
export async function fetchVip98kCatalog(params: {
|
||||
baseURL?: string | null;
|
||||
apiKey: string;
|
||||
}): Promise<Vip98kCatalogModel[]> {
|
||||
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
|
||||
const response = await fetch(`${baseURL}/api/external/task/models`, {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
const payload = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`);
|
||||
}
|
||||
const models = extractCatalogModels(payload);
|
||||
if (models.length === 0) {
|
||||
throw new Error("VIP98K model catalog is empty or has an unsupported response shape");
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function fetchVip98kModelPresets(params: {
|
||||
baseURL?: string | null;
|
||||
apiKey: string;
|
||||
}) {
|
||||
const models = await fetchVip98kCatalog(params);
|
||||
return buildVip98kModelPresets(models);
|
||||
}
|
||||
@ -13,6 +13,7 @@ import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
|
||||
import { createVisionary } from "@/lib/ai/providers/visionary";
|
||||
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
|
||||
import { createVip98k } from "@/lib/ai/providers/vip98k";
|
||||
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
|
||||
import { createMidjourney } from "@/lib/ai/providers/midjourney";
|
||||
import { createRunway } from "@/lib/ai/providers/runway";
|
||||
@ -300,6 +301,7 @@ const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"vip98k",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"runway",
|
||||
@ -4155,6 +4157,7 @@ function getProviderBatchLimit(providerType: string) {
|
||||
case "bananaimage":
|
||||
case "visionary":
|
||||
case "atlascloud":
|
||||
case "vip98k":
|
||||
case "stepfun-image":
|
||||
case "midjourney":
|
||||
case "runway":
|
||||
@ -5341,6 +5344,29 @@ export async function generateCanvasImage(
|
||||
});
|
||||
return result.text || "";
|
||||
};
|
||||
} else if (provider.type === "vip98k") {
|
||||
const vip98kProvider = createVip98k({
|
||||
apiKey: provider.apiKey,
|
||||
baseURL: provider.baseUrl,
|
||||
});
|
||||
const runtimeModelId = resolveModelRuntimeId(selectedModel.model, {
|
||||
providerType: provider.type,
|
||||
});
|
||||
const modelInstance = vip98kProvider.chat(runtimeModelId, {
|
||||
capabilities: selectedModel.model.capabilities || null,
|
||||
imageGenConfig: selectedModel.model.imageGenConfig || null,
|
||||
});
|
||||
executeGenerateBatch = async (batchImageCount) => {
|
||||
const result = await modelInstance.doGenerate({
|
||||
messages,
|
||||
imageParams: {
|
||||
...imageParams,
|
||||
imageCount: batchImageCount,
|
||||
},
|
||||
imageGenConfig: selectedModel.model.imageGenConfig || null,
|
||||
});
|
||||
return result.text || "";
|
||||
};
|
||||
} else if (provider.type === "stepfun-image") {
|
||||
const stepfunProvider = createStepfunImage({
|
||||
apiKey: provider.apiKey,
|
||||
@ -5417,7 +5443,7 @@ export async function generateCanvasImage(
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/BananaImage/Visionary/Atlas Cloud/StepFun/Midjourney/Runway/Krea 图片模型`
|
||||
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/BananaImage/Visionary/Atlas Cloud/VIP98K/StepFun/Midjourney/Runway/Krea 图片模型`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -36,6 +36,7 @@ const providerBaseUrlDefaults = [
|
||||
{ type: "bananaimage", baseUrl: "https://bananaimage.aigenmedia.art" },
|
||||
{ type: "visionary", baseUrl: "https://api.visionary.beer" },
|
||||
{ type: "atlascloud", baseUrl: "https://api.atlascloud.ai" },
|
||||
{ type: "vip98k", baseUrl: "https://api.vip98k.com" },
|
||||
{ type: "stepfun-image", baseUrl: "https://api.stepfun.com" },
|
||||
{ type: "midjourney", baseUrl: "https://your-midjourney-api.example.com" },
|
||||
{ type: "fal-video", baseUrl: "https://fal.run" },
|
||||
@ -153,7 +154,7 @@ async function runMigrationStatements(db: ReturnType<typeof drizzle>) {
|
||||
ADD CONSTRAINT "AiProvider_type_check"
|
||||
CHECK (type IN (
|
||||
'alibaba', 'openai', 'custom', 'local', 'gemini',
|
||||
'grsai-gpt-image', 'grsai-video', 'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'snapgen-image', 'openai-image', 'bananaimage', 'visionary', 'atlascloud', 'stepfun-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea'
|
||||
'grsai-gpt-image', 'grsai-video', 'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'snapgen-image', 'openai-image', 'bananaimage', 'visionary', 'atlascloud', 'vip98k', 'stepfun-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea'
|
||||
));
|
||||
`);
|
||||
await db.execute(sql`
|
||||
|
||||
41
lib/db/migrations/0097_add_vip98k_provider_type.sql
Normal file
41
lib/db/migrations/0097_add_vip98k_provider_type.sql
Normal file
@ -0,0 +1,41 @@
|
||||
ALTER TABLE "AiProvider"
|
||||
DROP CONSTRAINT IF EXISTS "AiProvider_type_check";
|
||||
|
||||
ALTER TABLE "AiProvider"
|
||||
ADD CONSTRAINT "AiProvider_type_check"
|
||||
CHECK (
|
||||
"type" IN (
|
||||
'alibaba',
|
||||
'openai',
|
||||
'custom',
|
||||
'local',
|
||||
'gemini',
|
||||
'grsai-gpt-image',
|
||||
'grsai-video',
|
||||
'huanke-gemini',
|
||||
'qianwen-video',
|
||||
'doubao-video',
|
||||
'ruxa-video',
|
||||
'volcano-vision',
|
||||
'volcano-video',
|
||||
'alibaba-image',
|
||||
'bfl-image',
|
||||
'ruxa-image',
|
||||
'fal-image',
|
||||
'snapgen-image',
|
||||
'openai-image',
|
||||
'bananaimage',
|
||||
'visionary',
|
||||
'atlascloud',
|
||||
'vip98k',
|
||||
'stepfun-image',
|
||||
'midjourney',
|
||||
'fal-video',
|
||||
'zhipu',
|
||||
'tripo-3d',
|
||||
'worldlabs-3d',
|
||||
'kittycad-cad',
|
||||
'runway',
|
||||
'krea'
|
||||
)
|
||||
);
|
||||
@ -582,6 +582,13 @@
|
||||
"when": 1786060800000,
|
||||
"tag": "0096_add_user_access_keys",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 81,
|
||||
"version": "7",
|
||||
"when": 1786176000000,
|
||||
"tag": "0097_add_vip98k_provider_type",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -3786,7 +3786,7 @@ export async function createAiProvider({
|
||||
updatedBy,
|
||||
}: {
|
||||
name: string;
|
||||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "bananaimage" | "visionary" | "atlascloud" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
|
||||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "bananaimage" | "visionary" | "atlascloud" | "vip98k" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
isEnabled?: boolean;
|
||||
@ -3824,7 +3824,7 @@ export async function updateAiProvider({
|
||||
}: {
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "bananaimage" | "visionary" | "atlascloud" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
|
||||
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "bananaimage" | "visionary" | "atlascloud" | "vip98k" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
isEnabled?: boolean;
|
||||
@ -3854,7 +3854,10 @@ export async function updateAiProvider({
|
||||
|
||||
export async function deleteAiProvider({ id }: { id: string }) {
|
||||
try {
|
||||
return await db.delete(aiProvider).where(eq(aiProvider.id, id)).returning();
|
||||
return await db.transaction(async (tx) => {
|
||||
await tx.delete(aiModel).where(eq(aiModel.providerId, id));
|
||||
return tx.delete(aiProvider).where(eq(aiProvider.id, id)).returning();
|
||||
});
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
|
||||
@ -309,7 +309,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
|
||||
export const aiProvider = pgTable("AiProvider", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
|
||||
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "grsai-gpt-image", "grsai-video", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "krea", "bfl-image"] })
|
||||
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "grsai-gpt-image", "grsai-video", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "vip98k", "stepfun-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "krea", "bfl-image"] })
|
||||
.notNull()
|
||||
.default("custom"), // 提供商类型
|
||||
baseUrl: text("baseUrl").notNull(), // API 基础 URL
|
||||
|
||||
@ -353,6 +353,7 @@ function supportsImageEditForReferenceCount(
|
||||
providerType === "bananaimage" ||
|
||||
providerType === "visionary" ||
|
||||
providerType === "atlascloud" ||
|
||||
providerType === "vip98k" ||
|
||||
providerType === "grsai-gpt-image" ||
|
||||
providerType === "ruxa-image" ||
|
||||
providerType === "snapgen-image";
|
||||
|
||||
@ -80,6 +80,7 @@ export function canProcessSceneFusionProviderType(providerType: string): boolean
|
||||
case "bananaimage":
|
||||
case "visionary":
|
||||
case "atlascloud":
|
||||
case "vip98k":
|
||||
case "gemini":
|
||||
case "volcano-vision":
|
||||
return true;
|
||||
@ -137,6 +138,7 @@ function getSupportedAspectRatiosByProvider(
|
||||
case "bananaimage":
|
||||
case "visionary":
|
||||
case "atlascloud":
|
||||
case "vip98k":
|
||||
case "gemini":
|
||||
default:
|
||||
return [...ALL_ASPECT_RATIO_VALUES];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user