feat: add runway image and video runtime support
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-20 22:33:09 +08:00
parent f09d8f1d25
commit 947af6d26f
10 changed files with 947 additions and 115 deletions

View File

@ -1562,6 +1562,7 @@ export function AdminSettingsClient() {
"qianwen-video",
"fal-video",
"ruxa-video",
"runway",
].includes(selectedModelProviderType);
// 存储配置状态
@ -9363,12 +9364,8 @@ export function AdminSettingsClient() {
<SelectItem value="auto">不指定首选(完全随机轮换)</SelectItem>
{(modelsData?.models || [])
.filter((m: AiModel) => {
// 过滤出支持图片生成/编辑的模型
const provider = (providersData?.providers || []).find((p: AiProvider) => p.id === m.providerId);
if (!provider) return false;
// 支持阿里生图、Gemini、既梦等图片模型
const imageProviderTypes = ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "midjourney", "gemini-image", "gemini", "volcano-vision"];
return imageProviderTypes.includes(provider.type);
return Boolean(provider && m.supportsImageGen);
})
.map((m: AiModel) => {
const provider = (providersData?.providers || []).find((p: AiProvider) => p.id === m.providerId);
@ -9382,8 +9379,7 @@ export function AdminSettingsClient() {
{/* 如果没有找到生图模型,显示提示 */}
{(modelsData?.models || []).filter((m: AiModel) => {
const provider = (providersData?.providers || []).find((p: AiProvider) => p.id === m.providerId);
const imageProviderTypes = ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "midjourney", "gemini-image", "gemini", "volcano-vision"];
return provider && imageProviderTypes.includes(provider.type);
return Boolean(provider && m.supportsImageGen);
}).length === 0 && (
<SelectItem value="no-model" disabled>
暂无可用的生图模型,请先配置生图供应商

View File

@ -98,6 +98,10 @@ import {
isGridConfirmationReply,
type PendingGridConfirmation,
} from "@/lib/ai/conversation-action-resolver";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import {
bootstrapConversationStateProtocol,
composeConversationStateProtocol,
@ -457,6 +461,7 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
normalized === "fal-image" ||
normalized === "openai-image" ||
normalized === "midjourney" ||
normalized === "runway" ||
normalized === "volcano-vision" ||
normalized === "gemini"
) {
@ -583,21 +588,53 @@ function startStreamKeepalive(writer: StreamWriter, label: string, intervalMs =
type VideoModeType = "text-to-video" | "first-frame" | "first-last-frame" | "reference-images";
const VIDEO_PROVIDER_TYPES = new Set([
"volcano-video",
"doubao-video",
"qianwen-video",
"fal-video",
"ruxa-video",
]);
function isVideoProviderType(
providerType?: string | null,
model?: {
modelId?: string | null;
capabilities?: unknown;
supportsImageGen?: boolean | null;
}
): boolean {
return isVideoModelDescriptor({
providerType,
modelId: model?.modelId,
capabilities: model?.capabilities,
supportsImageGen: model?.supportsImageGen,
});
}
function isVideoProviderType(providerType?: string | null): boolean {
return Boolean(providerType && VIDEO_PROVIDER_TYPES.has(providerType));
function isImageGenerationModelType(
providerType?: string | null,
model?: {
modelId?: string | null;
capabilities?: unknown;
supportsImageGen?: boolean | null;
}
): boolean {
return isImageGenerationModelDescriptor({
providerType,
modelId: model?.modelId,
capabilities: model?.capabilities,
supportsImageGen: model?.supportsImageGen,
});
}
function inferSupportedVideoModesByModelId(modelId: string): VideoModeType[] {
const normalized = modelId.toLowerCase();
if (
normalized === "gen4.5" ||
normalized === "gen4_turbo" ||
normalized === "gen4_aleph" ||
normalized === "gen3a_turbo" ||
normalized === "veo3" ||
normalized === "veo3.1" ||
normalized === "veo3.1_fast"
) {
return ["text-to-video", "first-frame"];
}
if (normalized.includes("lite-t2v")) {
return ["text-to-video"];
}
@ -1341,6 +1378,7 @@ const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
"ruxa-image",
"fal-image",
"openai-image",
"runway",
"volcano-vision",
"gemini",
"huanke-gemini",
@ -1980,7 +2018,8 @@ async function convertMessagesWithImages(messages: ChatMessage[], providerType:
providerType === "ruxa-image" ||
providerType === "fal-image" ||
providerType === "openai-image" ||
providerType === "midjourney"
providerType === "midjourney" ||
providerType === "runway"
) {
const convertedMessages = [];
@ -3156,6 +3195,7 @@ export async function POST(request: Request) {
let requestedModelProviderType: string | undefined;
let requestedModelIsFixedVisual = false;
let requestedModelIsConversationMultimodal = false;
let requestedModelConfig: Awaited<ReturnType<typeof getAiModelById>> | null = null;
if (!shouldRunAgentConversation && requestedModelId !== "auto") {
const selectedModelIsUUID =
@ -3183,33 +3223,21 @@ export async function POST(request: Request) {
selectedModelProviderType = selectedProvider?.type as string | undefined;
}
requestedModelConfig = selectedModelConfig;
requestedModelProviderType = selectedModelProviderType;
if (selectedModelConfig) {
const fixedImageProviderTypes = new Set([
"gemini",
"huanke-gemini",
"alibaba-image",
"ruxa-image",
"fal-image",
"openai-image",
"midjourney",
"volcano-vision",
]);
const isFixedVisualModel =
!selectedModelConfig.isSubModel &&
(selectedModelConfig.supportsImageGen ||
(selectedModelProviderType
? isVideoProviderType(selectedModelProviderType)
: false) ||
(selectedModelProviderType
? fixedImageProviderTypes.has(selectedModelProviderType)
: false));
(
isImageGenerationModelType(selectedModelProviderType, selectedModelConfig) ||
isVideoProviderType(selectedModelProviderType, selectedModelConfig)
);
const isConversationMultimodalModel =
!selectedModelConfig.isSubModel &&
isAiModelMultimodal(selectedModelConfig) &&
!selectedModelConfig.supportsImageGen &&
!isVideoProviderType(selectedModelProviderType);
!isImageGenerationModelType(selectedModelProviderType, selectedModelConfig) &&
!isVideoProviderType(selectedModelProviderType, selectedModelConfig);
requestedModelIsFixedVisual = isFixedVisualModel;
requestedModelIsConversationMultimodal = isConversationMultimodalModel;
@ -3331,7 +3359,8 @@ export async function POST(request: Request) {
}
const allowAgentVideoGeneration =
Boolean(requestBody.videoParams) || isVideoProviderType(requestedModelProviderType);
Boolean(requestBody.videoParams) ||
isVideoProviderType(requestedModelProviderType, requestedModelConfig || undefined);
let smartAutoWorkflowDecision:
| {
action?: string;
@ -3985,7 +4014,9 @@ export async function POST(request: Request) {
Boolean(smartAutoExecution?.shouldStayConversational);
if (autoGraphResult.route && (!shouldKeepSelectedModel || shouldForceConversationalRouteForVisualModel)) {
const routeIsVideo = isVideoProviderType(autoGraphResult.route.providerType);
const routeIsVideo = isVideoProviderType(autoGraphResult.route.providerType, {
modelId: autoGraphResult.route.modelId,
});
if (routeIsVideo && !allowAgentVideoGeneration) {
const fallbackIntent: "image_edit" | "image_gen" = hasImage ? "image_edit" : "image_gen";
smartAutoDetectedIntent = fallbackIntent;
@ -4021,7 +4052,7 @@ export async function POST(request: Request) {
subIntent: autoGraphResult.analysis?.subIntent,
hasImage,
});
if (!isVideoProviderType(fallbackRoute.providerType)) {
if (!isVideoProviderType(fallbackRoute.providerType, { modelId: fallbackRoute.modelId })) {
finalModelId = fallbackRoute.id;
console.log(
`[Smart Auto Mode] ✅ Fallback routed to: ${fallbackRoute.displayName} (${fallbackRoute.reason})`,
@ -4281,7 +4312,7 @@ export async function POST(request: Request) {
(m) =>
m.model.supportsImageGen &&
!m.model.isSubModel &&
!isVideoProviderType(m.provider?.type),
!isVideoProviderType(m.provider?.type, m.model),
);
if (isAutoTimeout) {
smartAutoFriendlyMessage = buildAutoModeFallbackMessage({
@ -4309,12 +4340,15 @@ export async function POST(request: Request) {
}
if (!bestModel) {
bestModel = allModels.find(
(m) => m.model.isDefault && !m.model.isSubModel && !isVideoProviderType(m.provider?.type),
(m) =>
m.model.isDefault &&
!m.model.isSubModel &&
!isVideoProviderType(m.provider?.type, m.model),
);
}
if (!bestModel && allModels.length > 0) {
bestModel = allModels.find(
(m) => !m.model.isSubModel && !isVideoProviderType(m.provider?.type),
(m) => !m.model.isSubModel && !isVideoProviderType(m.provider?.type, m.model),
) || allModels.find((m) => !m.model.isSubModel) || allModels[0];
}
@ -4335,9 +4369,10 @@ export async function POST(request: Request) {
const finalModelIsUuid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(finalModelId);
let finalProviderType: string | undefined;
let finalModelConfig: Awaited<ReturnType<typeof getAiModelById>> | null = null;
if (finalModelIsUuid) {
const finalModelConfig = await getAiModelById({ id: finalModelId }).catch(() => null);
finalModelConfig = await getAiModelById({ id: finalModelId }).catch(() => null);
if (finalModelConfig) {
finalProviderType = (
await getAiProviderById({ id: finalModelConfig.providerId }).catch(() => null)
@ -4345,19 +4380,19 @@ export async function POST(request: Request) {
}
}
if (isVideoProviderType(finalProviderType)) {
if (isVideoProviderType(finalProviderType, finalModelConfig || undefined)) {
const allModels = await getEnabledAiModelsWithProvider(false);
const fallbackModel =
allModels.find(
(m) =>
!m.model.isSubModel &&
m.model.supportsImageGen &&
!isVideoProviderType(m.provider?.type),
!isVideoProviderType(m.provider?.type, m.model),
) ||
allModels.find(
(m) =>
!m.model.isSubModel &&
!isVideoProviderType(m.provider?.type),
!isVideoProviderType(m.provider?.type, m.model),
);
if (fallbackModel) {
@ -7364,22 +7399,31 @@ export async function POST(request: Request) {
}
// ========== 图片生成配额检查 ==========
const isImageGenerationModel =
(languageModel as any).provider === "gemini-image" ||
(languageModel as any).provider === "volcano-vision" ||
(languageModel as any).provider === "alibaba-image" ||
(languageModel as any).provider === "ruxa-image" ||
(languageModel as any).provider === "fal-image" ||
(languageModel as any).provider === "openai-image" ||
(languageModel as any).provider === "midjourney" ||
(languageModel as any).provider === "huanke-gemini";
const isUuidModelId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
finalModelId
);
const currentModelConfig = isUuidModelId
? await getAiModelById({ id: finalModelId }).catch(() => undefined)
: undefined;
const runtimeProviderType = String(
resolvedProviderType || (languageModel as any).provider || ""
);
const normalizedRuntimeProviderType =
runtimeProviderType === "gemini-image"
? "gemini"
: runtimeProviderType === "huanke-gemini"
? "huanke-gemini"
: runtimeProviderType;
const isImageGenerationModel = isImageGenerationModelType(
normalizedRuntimeProviderType,
currentModelConfig
? {
modelId: currentModelConfig.modelId,
capabilities: currentModelConfig.capabilities,
supportsImageGen: currentModelConfig.supportsImageGen,
}
: undefined
);
const modelCreditCostPerImage = Number(
(currentModelConfig as any)?.imageGenConfig?.creditCostPerImage ?? 0
);
@ -7438,13 +7482,16 @@ export async function POST(request: Request) {
}
}
const isVideoGenerationModel = [
"qianwen-video",
"fal-video",
"ruxa-video",
"volcano-video",
"doubao-video",
].includes(String(resolvedProviderType || (languageModel as any).provider || ""));
const isVideoGenerationModel = isVideoProviderType(
normalizedRuntimeProviderType,
currentModelConfig
? {
modelId: currentModelConfig.modelId,
capabilities: currentModelConfig.capabilities,
supportsImageGen: currentModelConfig.supportsImageGen,
}
: undefined
);
if (isVideoGenerationModel) {
const pointsConfig = await getPointsSystemConfig();
@ -7925,7 +7972,14 @@ export async function POST(request: Request) {
// ========== Gemini 处理结束 ==========
// ========== 阿里云千问图片生成独立处理 ==========
const isAlibabaLikeImageProvider = ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "midjourney"].includes((languageModel as any).provider);
const isAlibabaLikeImageProvider = [
"alibaba-image",
"ruxa-image",
"fal-image",
"openai-image",
"midjourney",
"runway",
].includes((languageModel as any).provider);
if (isAlibabaLikeImageProvider && typeof (languageModel as any).doGenerate === "function") {
console.log("[Chat API] Using image generation provider:", (languageModel as any).provider);
console.log("[Chat API] Image params:", effectiveImageParams);
@ -8013,6 +8067,8 @@ export async function POST(request: Request) {
? "openai-image"
: (languageModel as any).provider === "midjourney"
? "midjourney"
: (languageModel as any).provider === "runway"
? "runway"
: "alibaba-image";
const modelMessages = await convertMessagesWithImages(messagesForAlibaba, providerForConvert);
@ -8085,8 +8141,9 @@ export async function POST(request: Request) {
"ruxa-image": 2,
"fal-image": 3,
"openai-image": 4,
gemini: 5,
"huanke-gemini": 6,
runway: 5,
gemini: 6,
"huanke-gemini": 7,
};
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
currentModelId: finalModelId,
@ -8115,6 +8172,8 @@ export async function POST(request: Request) {
? "openai-image"
: candidate.provider.type === "midjourney"
? "midjourney"
: candidate.provider.type === "runway"
? "runway"
: candidate.provider.type === "gemini"
? "gemini"
: candidate.provider.type === "huanke-gemini"
@ -8473,9 +8532,10 @@ export async function POST(request: Request) {
"ruxa-image": 1,
"fal-image": 2,
"openai-image": 3,
gemini: 4,
"huanke-gemini": 5,
"volcano-vision": 6,
runway: 4,
gemini: 5,
"huanke-gemini": 6,
"volcano-vision": 7,
};
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
currentModelId: finalModelId,
@ -8504,6 +8564,8 @@ export async function POST(request: Request) {
? "openai-image"
: candidate.provider.type === "midjourney"
? "midjourney"
: candidate.provider.type === "runway"
? "runway"
: candidate.provider.type === "gemini"
? "gemini"
: candidate.provider.type === "huanke-gemini"
@ -8710,13 +8772,14 @@ export async function POST(request: Request) {
// ========== 千问/FAL/RUXA 视频生成独立处理 ==========
if (
["qianwen-video", "fal-video", "ruxa-video"].includes((languageModel as any).provider) &&
["qianwen-video", "fal-video", "ruxa-video", "runway"].includes((languageModel as any).provider) &&
typeof (languageModel as any).doGenerate === "function"
) {
const currentVideoProvider = (languageModel as any).provider as
| "qianwen-video"
| "fal-video"
| "ruxa-video";
| "ruxa-video"
| "runway";
console.log(`[Chat API] Using ${currentVideoProvider} video generation`);
const videoGenerationStream = createUIMessageStream({
@ -9286,8 +9349,9 @@ export async function POST(request: Request) {
"ruxa-image": 2,
"fal-image": 3,
"openai-image": 4,
gemini: 5,
"huanke-gemini": 6,
runway: 5,
gemini: 6,
"huanke-gemini": 7,
};
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
currentModelId: finalModelId,
@ -9321,6 +9385,8 @@ export async function POST(request: Request) {
? "openai-image"
: candidate.provider.type === "midjourney"
? "midjourney"
: candidate.provider.type === "runway"
? "runway"
: candidate.provider.type === "gemini"
? "gemini"
: candidate.provider.type === "huanke-gemini"

View File

@ -14,14 +14,7 @@ import {
getSystemSetting,
} from "@/lib/db/queries";
import { isAiModelAgentAuto } from "@/lib/db/ai-model-flags";
const VIDEO_PROVIDER_TYPES = new Set([
"volcano-video",
"doubao-video",
"qianwen-video",
"fal-video",
"ruxa-video",
]);
import { isVideoModelDescriptor } from "@/lib/ai/model-classification";
function isNanoBananaProModel(model: { displayName?: string | null; modelId?: string | null }) {
const displayName = String(model.displayName || "").toLowerCase();
@ -109,7 +102,12 @@ async function NewChatPage() {
const preferredImageModels = visibleModels.filter(
({ model, provider }) =>
model.supportsImageGen &&
!VIDEO_PROVIDER_TYPES.has(provider.type)
!isVideoModelDescriptor({
providerType: provider.type,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
})
);
const nanoBananaModel = preferredImageModels.find(({ model }) =>
isNanoBananaProModel(model)

View File

@ -24,6 +24,7 @@ import { createOpenAIImage } from "./providers/openai-image";
import { createMidjourney } from "./providers/midjourney";
import { createFalVideo } from "./providers/fal-video";
import { createRuxaVideo } from "./providers/ruxa-video";
import { createRunway } from "./providers/runway";
import { createVolcanoMultimodal } from "./providers/volcano-multimodal";
import { getProviderDefaultBaseUrl } from "./provider-defaults";
@ -227,6 +228,15 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
return midjourneyProvider as any;
}
case "runway": {
const runwayProvider = createRunway({
baseURL: resolvedBaseUrl,
apiKey: provider.apiKey,
});
console.log(`[Provider] Created Runway provider: ${resolvedBaseUrl}`);
return runwayProvider as any;
}
case "custom":
default: {
if (isVolcanoMultimodalBaseUrl(resolvedBaseUrl)) {
@ -353,6 +363,10 @@ export async function getDynamicModel(
console.log("[getDynamicModel] Getting Midjourney chat model..."); // 调试日志
baseModel = providerInstance.chat(resolvedModelId);
console.log("[getDynamicModel] Midjourney model retrieved:", baseModel ? "success" : "failed"); // 调试日志
} else if (provider.type === "runway") {
console.log("[getDynamicModel] Getting Runway chat model..."); // 调试日志
baseModel = providerInstance.chat(resolvedModelId);
console.log("[getDynamicModel] Runway model retrieved:", baseModel ? "success" : "failed"); // 调试日志
} else if (provider.type === "huanke-gemini") {
// 环科 Gemini 原生格式使用 chat() 方法
console.log("[getDynamicModel] Getting Huanke Gemini chat model..."); // 调试日志

View File

@ -0,0 +1,114 @@
const STATIC_VIDEO_PROVIDER_TYPES = new Set([
"doubao-video",
"volcano-video",
"qianwen-video",
"fal-video",
"ruxa-video",
]);
const STATIC_IMAGE_PROVIDER_TYPES = new Set([
"gemini",
"huanke-gemini",
"alibaba-image",
"ruxa-image",
"fal-image",
"openai-image",
"midjourney",
"volcano-vision",
]);
const RUNWAY_IMAGE_MODEL_IDS = new Set([
"gen4_image",
"gen4_image_turbo",
"gemini_2.5_flash",
]);
const RUNWAY_VIDEO_MODEL_PREFIXES = [
"gen4.5",
"gen4_turbo",
"gen4_aleph",
"gen3a_turbo",
"veo3.1_fast",
"veo3.1",
"veo3",
"act_two",
"upscale_v1",
];
const VIDEO_MODE_KEYS = new Set([
"text-to-video",
"first-frame",
"first-last-frame",
"reference-images",
]);
function normalizeText(value?: string | null) {
return String(value || "").trim().toLowerCase();
}
function hasVideoModes(capabilities: unknown) {
if (!capabilities || typeof capabilities !== "object") return false;
const supportedModes = (capabilities as { supportedModes?: unknown }).supportedModes;
if (!Array.isArray(supportedModes)) return false;
return supportedModes.some((mode) => VIDEO_MODE_KEYS.has(String(mode || "").trim()));
}
export function inferRunwayModelKind(modelId?: string | null): "image" | "video" | null {
const normalized = normalizeText(modelId);
if (!normalized) return null;
if (RUNWAY_IMAGE_MODEL_IDS.has(normalized)) return "image";
if (RUNWAY_VIDEO_MODEL_PREFIXES.some((prefix) => normalized === prefix || normalized.startsWith(`${prefix}_`))) {
return "video";
}
return null;
}
export function isStaticVideoProviderType(providerType?: string | null): boolean {
return STATIC_VIDEO_PROVIDER_TYPES.has(normalizeText(providerType));
}
export function isVideoModelDescriptor(params: {
providerType?: string | null;
modelId?: string | null;
capabilities?: unknown;
supportsImageGen?: boolean | null;
}): boolean {
const providerType = normalizeText(params.providerType);
if (isStaticVideoProviderType(providerType)) {
return true;
}
if (hasVideoModes(params.capabilities)) {
return true;
}
if (providerType === "runway") {
return inferRunwayModelKind(params.modelId) === "video";
}
return false;
}
export function isImageGenerationModelDescriptor(params: {
providerType?: string | null;
modelId?: string | null;
capabilities?: unknown;
supportsImageGen?: boolean | null;
}): boolean {
const providerType = normalizeText(params.providerType);
if (params.supportsImageGen === true) {
return true;
}
if (providerType === "runway") {
return inferRunwayModelKind(params.modelId) === "image";
}
if (isVideoModelDescriptor(params)) {
return false;
}
return STATIC_IMAGE_PROVIDER_TYPES.has(providerType);
}

View File

@ -6,6 +6,10 @@
*/
import { isAiModelMultimodal } from "@/lib/db/ai-model-flags";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
type EnabledModelWithProvider = Awaited<
ReturnType<typeof import("@/lib/db/queries")["getEnabledAiModelsWithProvider"]>
@ -42,14 +46,6 @@ const CHAT_PROVIDER_TYPES = [
"volcano-multimodal",
] as const;
const VIDEO_PROVIDER_TYPES = new Set([
"doubao-video",
"volcano-video",
"qianwen-video",
"fal-video",
"ruxa-video",
]);
const IMAGE_ONLY_PROVIDER_TYPES = new Set([
"alibaba-image",
"ruxa-image",
@ -59,8 +55,20 @@ const IMAGE_ONLY_PROVIDER_TYPES = new Set([
"gemini-image",
]);
function isVideoProviderType(providerType: string) {
return VIDEO_PROVIDER_TYPES.has(providerType);
function isVideoProviderType(
providerType: string,
model?: {
modelId?: string | null;
capabilities?: unknown;
supportsImageGen?: boolean | null;
}
) {
return isVideoModelDescriptor({
providerType,
modelId: model?.modelId,
capabilities: model?.capabilities,
supportsImageGen: model?.supportsImageGen,
});
}
function isConversationalCandidate(
@ -69,7 +77,7 @@ function isConversationalCandidate(
) {
const providerType = String(item.provider.type || "");
if (!item.model.isEnabled || item.model.isSubModel) return false;
if (isVideoProviderType(providerType)) return false;
if (isVideoProviderType(providerType, item.model)) return false;
const requireVision = Boolean(options?.requireVision);
const matchesPreferredProvider = CHAT_PROVIDER_TYPES.includes(
@ -108,13 +116,27 @@ function matchesIntentRequirements(
if (params.intent === "vision") {
return (
!isVideoProviderType(String(item.provider.type || "")) &&
!isVideoProviderType(String(item.provider.type || ""), item.model) &&
hasVisionCapability(item.model)
);
}
if (params.intent === "video_gen") {
return isVideoProviderType(String(item.provider.type || ""), item.model);
}
if (params.requireVision && !hasVisionCapability(item.model)) return false;
if (params.requireImageGen && !item.model.supportsImageGen) return false;
if (
params.requireImageGen &&
!isImageGenerationModelDescriptor({
providerType: item.provider.type,
modelId: item.model.modelId,
capabilities: item.model.capabilities,
supportsImageGen: item.model.supportsImageGen,
})
) {
return false;
}
return true;
}
@ -132,7 +154,7 @@ const INTENT_MODEL_MAP: Record<
> = {
// 图片编辑:优先 alibaba-image(灵魂4.5,支持提示词扩展)
image_edit: {
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "gemini", "huanke-gemini"],
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini", "huanke-gemini"],
requireVision: true,
requireImageGen: true,
description: "图片编辑",
@ -147,14 +169,14 @@ const INTENT_MODEL_MAP: Record<
// 图片生成:优先灵魂4.5
image_gen: {
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "gemini", "huanke-gemini", "volcano-vision"],
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini", "huanke-gemini", "volcano-vision"],
requireImageGen: true,
description: "图片生成",
},
// 视频生成
video_gen: {
providerTypes: ["doubao-video", "volcano-video", "qianwen-video", "fal-video", "ruxa-video"],
providerTypes: ["doubao-video", "volcano-video", "qianwen-video", "fal-video", "ruxa-video", "runway"],
description: "视频生成",
},
@ -175,25 +197,25 @@ const SUB_INTENT_OVERRIDES: Record<
> = {
// 抠图:alibaba-image 效果最好
remove_background: {
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "gemini"],
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini"],
reason: "抠图首选灵魂4.5",
},
// 换背景:alibaba-image 支持场景生成
replace_background: {
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "gemini"],
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini"],
reason: "换背景首选灵魂4.5",
},
// 图片增强
enhance: {
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "gemini", "volcano-vision"],
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini", "volcano-vision"],
reason: "增强首选灵魂4.5",
},
// 风格迁移:gemini 可能更有创意
style_transfer: {
providerTypes: ["gemini", "alibaba-image", "ruxa-image", "fal-image", "openai-image"],
providerTypes: ["gemini", "alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway"],
reason: "风格迁移优选Gemini",
},
};
@ -321,7 +343,7 @@ export async function routeToModel(params: {
return (
!item.model.isSubModel &&
item.model.isEnabled &&
!isVideoProviderType(String(item.provider.type || "")) &&
!isVideoProviderType(String(item.provider.type || ""), item.model) &&
(item.model.supportsImageGen || hasVisionCapability(item.model))
);
});
@ -345,7 +367,7 @@ export async function routeToModel(params: {
(m) =>
!m.model.isSubModel &&
m.model.isEnabled &&
!isVideoProviderType(String(m.provider.type || "")) &&
!isVideoProviderType(String(m.provider.type || ""), m.model) &&
!m.model.supportsImageGen,
)
: undefined) ||

581
lib/ai/providers/runway.ts Normal file
View File

@ -0,0 +1,581 @@
import "server-only";
import sharp from "sharp";
import { uploadFile } from "@/lib/storage/service";
import { inferRunwayModelKind } from "@/lib/ai/model-classification";
interface RunwayConfig {
apiKey: string;
baseURL: string;
}
interface RunwayImageParams {
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
seed?: number;
}
interface RunwayVideoParams {
mode?: "text-to-video" | "first-frame" | "first-last-frame" | "reference-images";
ratio?: string;
duration?: number;
seed?: number;
}
interface UploadedImage {
thumbnail: string;
original: string;
}
const RUNWAY_VERSION = "2024-11-06";
const DEFAULT_BASE_URL = "https://api.dev.runwayml.com";
const TASK_POLL_INTERVAL_MS = 5000;
const TASK_TIMEOUT_MS = 15 * 60 * 1000;
const IMAGE_RESOLUTION_MAP: Record<string, Record<string, string>> = {
"1K": {
"21:9": "1584:672",
"16:9": "1360:768",
"9:16": "768:1360",
"4:3": "1104:832",
"3:4": "832:1104",
"1:1": "960:960",
},
"2K": {
"21:9": "1584:672",
"16:9": "1360:768",
"9:16": "768:1360",
"4:3": "1104:832",
"3:4": "832:1104",
"1:1": "960:960",
},
"4K": {
"21:9": "1584:672",
"16:9": "1360:768",
"9:16": "768:1360",
"4:3": "1104:832",
"3:4": "832:1104",
"1:1": "960:960",
},
};
function normalizeBaseURL(baseURL: string) {
const normalized = String(baseURL || "").trim().replace(/\/+$/, "");
return normalized || DEFAULT_BASE_URL;
}
function joinEndpoint(baseURL: string, path: string) {
const cleanPath = path.startsWith("/") ? path : `/${path}`;
return `${normalizeBaseURL(baseURL)}${cleanPath}`;
}
function buildHeaders(apiKey: string) {
return {
Authorization: `Bearer ${String(apiKey || "").trim()}`,
"Content-Type": "application/json",
"X-Runway-Version": RUNWAY_VERSION,
};
}
function normalizeText(value: string) {
return String(value || "").replace(/\u00a0/g, " ").trim().normalize("NFKC");
}
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
const normalized = String(imageSize || "").trim().toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
return normalized;
}
return "2K";
}
function normalizeImageResolution(aspectRatio?: string, imageSize?: string) {
const normalizedRatio = String(aspectRatio || "").trim() || "16:9";
const normalizedSize = normalizeImageSizeLabel(imageSize);
return (
IMAGE_RESOLUTION_MAP[normalizedSize]?.[normalizedRatio] ||
IMAGE_RESOLUTION_MAP[normalizedSize]?.["16:9"] ||
"1360:768"
);
}
function normalizeVideoRatio(ratio?: string) {
const normalized = String(ratio || "").trim();
if (["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(normalized)) {
return normalized;
}
return "16:9";
}
function normalizeVideoDuration(duration?: number) {
if (!Number.isFinite(duration)) return 5;
const normalized = Math.round(Number(duration));
return normalized >= 8 ? 10 : 5;
}
function normalizeSeed(seed?: number) {
if (!Number.isFinite(seed)) return undefined;
return Math.max(0, Math.min(4294967295, Math.round(Number(seed))));
}
function detectExtensionFromContentType(contentType?: string | null) {
const normalized = String(contentType || "").toLowerCase();
if (normalized.includes("jpeg") || normalized.includes("jpg")) return "jpg";
if (normalized.includes("webp")) return "webp";
if (normalized.includes("gif")) return "gif";
if (normalized.includes("png")) return "png";
if (normalized.includes("mp4")) return "mp4";
if (normalized.includes("quicktime")) return "mov";
if (normalized.includes("webm")) return "webm";
return "bin";
}
function extractDataUriParts(dataUri: string) {
const match = dataUri.match(/^data:([^;]+);base64,(.+)$/);
if (!match) {
throw new Error("不支持的 Data URI 格式");
}
return {
mimeType: match[1],
buffer: Buffer.from(match[2], "base64"),
};
}
function generateFilename(prefix: string, extension: string) {
const timestamp = Date.now();
const random = Math.random().toString(36).slice(2, 8);
return `${prefix}-${timestamp}-${random}.${extension}`;
}
async function uploadGeneratedBuffer(
buffer: Buffer,
extension: string,
mimeType: string
): Promise<UploadedImage> {
const thumbnailBuffer = await sharp(buffer)
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
const originalFilename = generateFilename("runway-image", extension);
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
const originalUploaded = await uploadFile(originalFilename, originalArrayBuffer);
const thumbnailFilename = generateFilename("runway-thumb", "jpg");
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
) as ArrayBuffer;
const thumbnailUploaded = await uploadFile(thumbnailFilename, thumbnailArrayBuffer);
console.log("[Runway] Uploaded image to storage:", {
original: originalUploaded.url,
thumbnail: thumbnailUploaded.url,
mimeType,
});
return {
thumbnail: thumbnailUploaded.url,
original: originalUploaded.url,
};
}
async function uploadDataUriAsset(dataUri: string, prefix: "runway-input" | "runway-video") {
const { mimeType, buffer } = extractDataUriParts(dataUri);
const extension = detectExtensionFromContentType(mimeType);
const filename = generateFilename(prefix, extension);
const arrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
const uploaded = await uploadFile(filename, arrayBuffer);
return uploaded.url;
}
async function normalizeAssetUrl(url?: string | null) {
const normalized = String(url || "").trim();
if (!normalized) return "";
if (/^https:\/\//i.test(normalized) || /^runway:\/\//i.test(normalized)) {
return normalized;
}
if (/^data:(image|video)\//i.test(normalized)) {
return uploadDataUriAsset(normalized, /^data:video\//i.test(normalized) ? "runway-video" : "runway-input");
}
return normalized;
}
function collectHttpUrls(payload: unknown): string[] {
const urls = new Set<string>();
const visit = (value: unknown) => {
if (typeof value === "string") {
const normalized = value.trim();
if (/^https?:\/\//i.test(normalized)) {
urls.add(normalized);
}
return;
}
if (Array.isArray(value)) {
value.forEach(visit);
return;
}
if (value && typeof value === "object") {
Object.values(value as Record<string, unknown>).forEach(visit);
}
};
visit(payload);
return Array.from(urls);
}
async function downloadRemoteAsset(url: string) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 120000);
try {
const response = await fetch(url, {
cache: "no-store",
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`下载 Runway 输出失败: ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
return {
buffer,
contentType: response.headers.get("content-type"),
};
} finally {
clearTimeout(timeoutId);
}
}
async function uploadVideoToStorage(remoteVideoUrl: string) {
try {
const { buffer, contentType } = await downloadRemoteAsset(remoteVideoUrl);
const extension = detectExtensionFromContentType(contentType || "video/mp4");
const filename = generateFilename("runway-video", extension === "bin" ? "mp4" : extension);
const arrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
const uploaded = await uploadFile(filename, arrayBuffer);
return uploaded.url;
} catch (error) {
console.warn("[Runway] Failed to archive video output, using temporary URL:", error);
return remoteVideoUrl;
}
}
function formatImageMarkdown(images: UploadedImage[], imageSize: string) {
return images
.map((image) => `![thumb|||${image.original}|||${imageSize}](${image.thumbnail})`)
.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 extractPromptAndAssets(messages: Array<{ role: string; content: any }>) {
const lastUserMessage = messages.filter((message) => message.role === "user").pop();
if (!lastUserMessage) {
throw new Error("未找到用户输入内容");
}
let prompt = "";
const imageUrls: string[] = [];
const pushImageUrl = (value?: string) => {
if (!value) return;
const normalized = String(value || "").trim();
if (!normalized) return;
if (!imageUrls.includes(normalized)) {
imageUrls.push(normalized);
}
};
if (typeof lastUserMessage.content === "string") {
prompt = normalizeText(lastUserMessage.content);
} else if (Array.isArray(lastUserMessage.content)) {
for (const part of lastUserMessage.content) {
if (part?.type === "text" && typeof part.text === "string") {
prompt = `${prompt} ${part.text}`.trim();
} else if (part?.type === "image_url" && part.image_url?.url) {
pushImageUrl(part.image_url.url);
} else if (part?.type === "file") {
pushImageUrl(part.url || part.data);
} else if (part?.type === "image") {
pushImageUrl(part.image || part.url);
}
}
}
return {
prompt: normalizeText(prompt) || "生成内容",
imageUrls,
};
}
async function createTask(baseURL: string, apiKey: string, path: string, body: Record<string, any>) {
const response = await fetch(joinEndpoint(baseURL, path), {
method: "POST",
headers: buildHeaders(apiKey),
body: JSON.stringify(body),
});
const rawText = await response.text();
let payload: any = null;
try {
payload = rawText ? JSON.parse(rawText) : null;
} catch {
payload = rawText;
}
if (!response.ok) {
const message =
typeof payload?.error === "string"
? payload.error
: typeof payload?.message === "string"
? payload.message
: rawText || "Runway 请求失败";
throw new Error(`${message} (HTTP ${response.status})`);
}
const taskId = String(payload?.id || "").trim();
if (!taskId) {
throw new Error("Runway 未返回任务 ID");
}
return taskId;
}
async function waitForTaskOutput(baseURL: string, apiKey: string, taskId: string) {
const deadline = Date.now() + TASK_TIMEOUT_MS;
while (Date.now() < deadline) {
const response = await fetch(joinEndpoint(baseURL, `/v1/tasks/${taskId}`), {
method: "GET",
headers: buildHeaders(apiKey),
cache: "no-store",
});
const rawText = await response.text();
let payload: any = null;
try {
payload = rawText ? JSON.parse(rawText) : null;
} catch {
payload = rawText;
}
if (!response.ok) {
const message =
typeof payload?.error === "string"
? payload.error
: typeof payload?.message === "string"
? payload.message
: rawText || "Runway 轮询失败";
throw new Error(`${message} (HTTP ${response.status})`);
}
const status = String(payload?.status || "").trim().toUpperCase();
if (status === "SUCCEEDED") {
return payload;
}
if (status === "FAILED" || status === "CANCELLED") {
throw new Error(
String(payload?.failure || payload?.error || payload?.message || "Runway 任务失败")
);
}
await new Promise((resolve) => setTimeout(resolve, TASK_POLL_INTERVAL_MS));
}
throw new Error("Runway 任务超时,请稍后重试");
}
async function uploadRunwayImages(taskOutput: unknown) {
const outputUrls = collectHttpUrls(taskOutput);
const uploadedImages: UploadedImage[] = [];
for (const url of outputUrls) {
try {
const { buffer, contentType } = await downloadRemoteAsset(url);
if (!String(contentType || "").startsWith("image/")) {
continue;
}
const extension = detectExtensionFromContentType(contentType);
uploadedImages.push(
await uploadGeneratedBuffer(
buffer,
extension === "bin" ? "png" : extension,
contentType || "image/png"
)
);
} catch (error) {
console.warn("[Runway] Failed to archive image output:", error);
}
}
if (uploadedImages.length === 0) {
throw new Error("Runway 未返回可用图片");
}
return uploadedImages;
}
async function resolveRunwayVideoUrl(taskOutput: unknown) {
const outputUrls = collectHttpUrls(taskOutput);
if (outputUrls.length === 0) {
throw new Error("Runway 未返回可用视频地址");
}
for (const url of outputUrls) {
if (/(\.mp4|\.mov|\.webm)(\?|$)/i.test(url) || /video/i.test(url)) {
return uploadVideoToStorage(url);
}
}
return uploadVideoToStorage(outputUrls[0]!);
}
function getRunwayVideoEndpoint(modelId: string, hasPromptImage: boolean) {
const normalized = normalizeText(modelId);
if (hasPromptImage) return "/v1/image_to_video";
if (normalized === "gen4.5") return "/v1/image_to_video";
return "/v1/text_to_video";
}
function buildReferenceImages(imageUrls: string[]) {
return imageUrls.slice(0, 3).map((uri, index) => ({
uri,
tag: `ref_${index + 1}`.replace(/[^a-z0-9_]/g, "_"),
}));
}
export function createRunway({ apiKey, baseURL }: RunwayConfig) {
return {
provider: "runway",
chat: (modelId: string) => ({
provider: "runway",
doGenerate: async (options: {
messages: Array<{ role: string; content: any }>;
imageParams?: RunwayImageParams;
videoParams?: RunwayVideoParams;
overrideModelId?: string;
}) => {
try {
const activeModelId = normalizeText(options.overrideModelId || modelId);
const { prompt, imageUrls } = extractPromptAndAssets(options.messages);
const normalizedImageUrls = (
await Promise.all(imageUrls.map((url) => normalizeAssetUrl(url)))
).filter(Boolean);
const modelKind =
inferRunwayModelKind(activeModelId) ||
(options.videoParams ? "video" : options.imageParams ? "image" : null) ||
"video";
if (modelKind === "image") {
const imageSize = normalizeImageSizeLabel(options.imageParams?.imageSize);
const taskId = await createTask(baseURL, apiKey, "/v1/text_to_image", {
model: activeModelId,
promptText: prompt,
ratio: normalizeImageResolution(options.imageParams?.aspectRatio, imageSize),
...(normalizedImageUrls.length > 0
? { referenceImages: buildReferenceImages(normalizedImageUrls) }
: {}),
...(normalizeSeed(options.imageParams?.seed) !== undefined
? { seed: normalizeSeed(options.imageParams?.seed) }
: {}),
});
const task = await waitForTaskOutput(baseURL, apiKey, taskId);
const uploadedImages = await uploadRunwayImages(task?.output ?? task);
const text = formatImageMarkdown(uploadedImages, imageSize);
return {
text,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: text.length,
},
};
}
const videoMode = options.videoParams?.mode || (normalizedImageUrls[0] ? "first-frame" : "text-to-video");
if (
(videoMode === "first-frame" || videoMode === "first-last-frame" || videoMode === "reference-images") &&
!normalizedImageUrls[0]
) {
throw new Error("Runway 图生视频至少需要 1 张参考图");
}
const endpoint = getRunwayVideoEndpoint(activeModelId, Boolean(normalizedImageUrls[0]));
const taskId = await createTask(baseURL, apiKey, endpoint, {
model: activeModelId,
promptText: prompt,
ratio: normalizeVideoRatio(options.videoParams?.ratio),
duration: normalizeVideoDuration(options.videoParams?.duration),
...(normalizedImageUrls[0] ? { promptImage: normalizedImageUrls[0] } : {}),
...(normalizeSeed(options.videoParams?.seed) !== undefined
? { seed: normalizeSeed(options.videoParams?.seed) }
: {}),
});
const task = await waitForTaskOutput(baseURL, apiKey, taskId);
const videoUrl = await resolveRunwayVideoUrl(task?.output ?? task);
const text = formatVideoMarkdown(videoUrl);
return {
text,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: text.length,
},
};
} catch (error) {
console.error("[Runway] Generation failed:", error);
return {
text:
error instanceof Error
? `${
(inferRunwayModelKind(options.overrideModelId || modelId) ||
(options.videoParams ? "video" : options.imageParams ? "image" : "video")) === "image"
? "图片"
: "视频"
}生成失败: ${error.message}`
: "Runway 生成失败,请稍后重试",
finishReason: "error" as const,
usage: {
promptTokens: 0,
completionTokens: 0,
},
};
}
},
doStream: async function* (options: {
messages: Array<{ role: string; content: any }>;
imageParams?: RunwayImageParams;
videoParams?: RunwayVideoParams;
overrideModelId?: string;
}) {
const result = await this.doGenerate(options);
yield {
type: "text-delta",
textDelta: result.text,
};
},
modelId,
specificationVersion: "v1" as const,
defaultObjectGenerationMode: "json" as const,
}),
};
}

View File

@ -193,6 +193,33 @@ function createProviderDefaultCapability(providerType: string): VideoToolCapabil
};
}
if (providerType === "runway") {
return {
schemaVersion: "v1",
source: "provider-default",
providerType,
modeOptions: normalizeModeOptions(["text-to-video", "first-frame"]),
ratioOptions: [...STANDARD_RATIOS],
durationOptions: [5, 10],
resolutionOptions: [],
ui: {
showResolution: false,
showFps: false,
showCameraMove: false,
showGenerateAudio: false,
showReturnLastFrame: false,
showSeed: true,
},
defaults: {
mode: "text-to-video",
ratio: "16:9",
duration: 5,
generateAudio: false,
fps: 24,
},
};
}
return {
schemaVersion: "v1",
source: "provider-default",

View File

@ -7,6 +7,7 @@ import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
import { createFalImage } from "@/lib/ai/providers/fal-image";
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
import { createMidjourney } from "@/lib/ai/providers/midjourney";
import { createRunway } from "@/lib/ai/providers/runway";
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
export interface CanvasGenerateImageRequest {
@ -39,6 +40,7 @@ const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
"fal-image",
"openai-image",
"midjourney",
"runway",
]);
function parseImagesFromMarkdown(markdown: string): ParsedImage[] {
@ -76,6 +78,7 @@ function getProviderBatchLimit(providerType: string) {
case "ruxa-image":
case "openai-image":
case "midjourney":
case "runway":
return 1;
case "gemini":
return 2;
@ -151,7 +154,7 @@ export async function generateCanvasImage(
}
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(fallbackProvider.type)) {
throw new Error(
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/阿里/RUXA/FAL/OpenAI 图片模型。"
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/阿里/RUXA/FAL/OpenAI/Runway 图片模型。"
);
}
if (fallbackModel.model.id !== selectedModel.model.id) {
@ -311,9 +314,22 @@ export async function generateCanvasImage(
});
return result.text || "";
};
} else if (provider.type === "runway") {
const runwayProvider = createRunway({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = runwayProvider.chat(selectedModel.model.modelId);
executeGenerateBatch = async (_batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams,
});
return result.text || "";
};
} else {
throw new Error(
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/阿里/RUXA/FAL/OpenAI/Midjourney 图片模型`
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/阿里/RUXA/FAL/OpenAI/Midjourney/Runway 图片模型`
);
}

View File

@ -91,6 +91,7 @@ import {
} from "../points/config";
import { inferOriginalImageUrl } from "../image-url";
import { getAgentRequiredUploadCount } from "../agents/preset-mode-config";
import { isVideoModelDescriptor } from "../ai/model-classification";
// Optionally, if not using email/pass login, you can
// use the Drizzle adapter for Auth.js / NextAuth
@ -1755,14 +1756,6 @@ type AgentPresetKind = "image" | "video" | "text";
type AgentPresetScope = "all" | AgentPresetVisibility;
type AgentCategoryScope = "all" | "public" | "private" | "global";
type AgentPresetSort = "latest" | "hottest" | "most_used";
const AGENT_VIDEO_PROVIDER_TYPES = new Set([
"volcano-video",
"doubao-video",
"qianwen-video",
"fal-video",
"ruxa-video",
]);
function normalizeAgentCategoryName(value: string) {
return String(value || "").trim().replace(/\s+/g, " ");
}
@ -1804,7 +1797,12 @@ async function validateAgentDefaultModel(params: {
throw new ChatSDKError("bad_request:database", "Invalid default model");
}
const isVideoModel = AGENT_VIDEO_PROVIDER_TYPES.has(String(model.providerType || ""));
const isVideoModel = isVideoModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
if (params.presetType === "video") {
if (!isVideoModel) {