From 25f88e8d3deaf1045e61dc91663dcde02e6807e5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 5 Mar 2026 11:16:24 +0800 Subject: [PATCH] feat(video): add ruxa-video provider end-to-end --- .../settings/admin-settings-client.tsx | 8 +- app/(chat)/api/chat/route.ts | 17 +- app/(chat)/api/models/route.ts | 1 + app/(chat)/page.tsx | 1 + app/api/admin/ai-providers/route.ts | 4 + components/canvas/CanvasChatPanel.tsx | 22 +- components/chat.tsx | 21 +- components/messages.tsx | 6 +- components/multimodal-input.tsx | 16 +- components/video-params-bar.tsx | 113 ++++-- hooks/use-pending-generation.ts | 2 +- lib/ai/auto-agent-graph.ts | 7 +- lib/ai/dynamic-provider.ts | 16 + lib/ai/model-router.ts | 2 +- lib/ai/provider-defaults.ts | 1 + lib/ai/providers/ruxa-video.ts | 374 ++++++++++++++++++ lib/ai/smart-analyzer.ts | 5 +- lib/db/migrate.ts | 3 +- lib/db/queries.ts | 4 +- lib/db/schema.ts | 2 +- 20 files changed, 567 insertions(+), 58 deletions(-) create mode 100644 lib/ai/providers/ruxa-video.ts diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 9fee83c49..b642ca574 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -146,7 +146,7 @@ type ToolConfig = { type AiProvider = { id: string; name: string; - type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "tripo-3d" | "volcano-multimodal"; + type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "tripo-3d" | "volcano-multimodal"; baseUrl: string; apiKey: string; isEnabled: boolean; @@ -427,6 +427,7 @@ const PROVIDER_TYPES = [ { value: "huanke-gemini", label: "环科Gemini生图" }, // 🆕 环科 Gemini 原生格式 API { value: "qianwen-video", label: "千问视频生成" }, { value: "doubao-video", label: "豆包AI视频" }, + { value: "ruxa-video", label: "RUXA 视频生成" }, { value: "volcano-vision", label: "火山引擎视觉" }, { value: "volcano-video", label: "火山引擎视频" }, { value: "fal-video", label: "FAL 视频生成" }, @@ -446,6 +447,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record = { "huanke-gemini": "https://api.huandutech.com/v1", "qianwen-video": "https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation", "doubao-video": "https://ark.cn-beijing.volces.com/api/v3", + "ruxa-video": "https://api.ruxa.ai", "volcano-vision": "https://ark.cn-beijing.volces.com", "volcano-video": "https://ark.cn-beijing.volces.com/api/v3", }; @@ -1277,7 +1279,7 @@ export function AdminSettingsClient() { const [providerForm, setProviderForm] = useState({ id: "", name: "", - type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "tripo-3d" | "volcano-multimodal", + type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "tripo-3d" | "volcano-multimodal", baseUrl: "", apiKey: "", isEnabled: true, @@ -1366,6 +1368,7 @@ export function AdminSettingsClient() { "doubao-video", "qianwen-video", "fal-video", + "ruxa-video", ].includes(selectedModelProviderType); // 存储配置状态 @@ -3620,6 +3623,7 @@ export function AdminSettingsClient() { "volcano-video": "bg-red-500", "doubao-video": "bg-red-400", "fal-video": "bg-indigo-500", + "ruxa-video": "bg-violet-500", "alibaba": "bg-blue-500", "alibaba-image": "bg-amber-500", "ruxa-image": "bg-emerald-500", diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index fd190b95c..0d73d8b2b 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -257,6 +257,7 @@ const VIDEO_PROVIDER_TYPES = new Set([ "doubao-video", "qianwen-video", "fal-video", + "ruxa-video", ]); function isVideoProviderType(providerType?: string | null): boolean { @@ -2972,6 +2973,7 @@ 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 || "")); @@ -4064,15 +4066,18 @@ export async function POST(request: Request) { } // ========== 火山引擎视觉处理结束 ========== - // ========== 千问/FAL 视频生成独立处理 ========== + // ========== 千问/FAL/RUXA 视频生成独立处理 ========== if ( - ["qianwen-video", "fal-video"].includes((languageModel as any).provider) && + ["qianwen-video", "fal-video", "ruxa-video"].includes((languageModel as any).provider) && typeof (languageModel as any).doGenerate === "function" ) { - const currentVideoProvider = (languageModel as any).provider as "qianwen-video" | "fal-video"; + const currentVideoProvider = (languageModel as any).provider as + | "qianwen-video" + | "fal-video" + | "ruxa-video"; console.log(`[Chat API] Using ${currentVideoProvider} video generation`); - const qianwenVideoStream = createUIMessageStream({ + const videoGenerationStream = createUIMessageStream({ execute: async ({ writer }) => { try { const conversationMessage = smartAutoFriendlyMessage || null; @@ -4212,9 +4217,9 @@ export async function POST(request: Request) { }, }); - return createSseResponse(qianwenVideoStream); + return createSseResponse(videoGenerationStream); } - // ========== 千问/FAL 视频处理结束 ========== + // ========== 千问/FAL/RUXA 视频处理结束 ========== let stopStreamKeepalive: (() => void) | null = null; const stream = createUIMessageStream({ diff --git a/app/(chat)/api/models/route.ts b/app/(chat)/api/models/route.ts index 6c7c51b15..ce9a0babe 100644 --- a/app/(chat)/api/models/route.ts +++ b/app/(chat)/api/models/route.ts @@ -13,6 +13,7 @@ const VIDEO_PROVIDER_TYPES = new Set([ "doubao-video", "qianwen-video", "fal-video", + "ruxa-video", ]); function isNanoBananaProModel(model: { displayName?: string | null; modelId?: string | null }) { diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx index 0c70b742b..dc324994c 100644 --- a/app/(chat)/page.tsx +++ b/app/(chat)/page.tsx @@ -20,6 +20,7 @@ const VIDEO_PROVIDER_TYPES = new Set([ "doubao-video", "qianwen-video", "fal-video", + "ruxa-video", ]); function isNanoBananaProModel(model: { displayName?: string | null; modelId?: string | null }) { diff --git a/app/api/admin/ai-providers/route.ts b/app/api/admin/ai-providers/route.ts index 9c9172b25..9b2c1bf5b 100644 --- a/app/api/admin/ai-providers/route.ts +++ b/app/api/admin/ai-providers/route.ts @@ -18,6 +18,7 @@ const providerTypes = [ "huanke-gemini", "qianwen-video", "doubao-video", + "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", @@ -51,6 +52,7 @@ const updateProviderSchema = z.object({ const ZHIPU_BASE_URL = "https://open.bigmodel.cn/api/paas/v4"; const VOLCANO_MULTIMODAL_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"; const DOUBAO_VIDEO_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"; +const RUXA_VIDEO_BASE_URL = "https://api.ruxa.ai"; const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi"; function normalizeProviderTypeForStorage( @@ -158,6 +160,8 @@ export async function POST(request: Request) { ? data.baseUrl || VOLCANO_MULTIMODAL_BASE_URL : data.type === "doubao-video" ? data.baseUrl || DOUBAO_VIDEO_BASE_URL + : data.type === "ruxa-video" + ? data.baseUrl || RUXA_VIDEO_BASE_URL : data.type === "tripo-3d" ? data.baseUrl || TRIPO_3D_BASE_URL : data.baseUrl, diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index de035b301..3cab66513 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -763,8 +763,9 @@ function normalizeVideoParamsForProvider( providerType?: string ): VideoGenerationParams { const isFalVideo = providerType === "fal-video"; + const isRuxaVideo = providerType === "ruxa-video"; const base: VideoGenerationParams = { - mode: isFalVideo ? "first-frame" : "text-to-video", + mode: isFalVideo || isRuxaVideo ? "first-frame" : "text-to-video", resolution: "720p", ratio: "16:9", duration: 5, @@ -788,6 +789,19 @@ function normalizeVideoParamsForProvider( return merged; } + if (isRuxaVideo) { + merged.mode = "first-frame"; + if (!["16:9", "9:16", "1:1"].includes(merged.ratio)) { + merged.ratio = "16:9"; + } + merged.duration = merged.duration >= 8 ? 10 : 5; + merged.generateAudio = false; + merged.cameraMove = "none"; + merged.cameraFixed = false; + merged.fps = 24; + return merged; + } + if (!["text-to-video", "first-frame", "first-last-frame", "reference-images"].includes(merged.mode)) { merged.mode = "text-to-video"; } @@ -806,6 +820,10 @@ function getVideoImageLimit( return { min: 0, max: 0 }; } const isFalVideo = providerType === "fal-video"; + const isRuxaVideo = providerType === "ruxa-video"; + if (isRuxaVideo) { + return { min: 1, max: 1 }; + } if (isFalVideo) { if (params.mode === "first-last-frame") { return { min: 2, max: 2 }; @@ -2457,7 +2475,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF } }, [canvasSkillTags, selectedSkillTagId]); - const VIDEO_PROVIDER_TYPES = new Set(["volcano-video", "doubao-video", "qianwen-video", "fal-video"]); + const VIDEO_PROVIDER_TYPES = new Set(["volcano-video", "doubao-video", "qianwen-video", "fal-video", "ruxa-video"]); const visualModels = useMemo(() => { const filtered = modelsData?.models?.filter( diff --git a/components/chat.tsx b/components/chat.tsx index 65d369531..9ee7e77e3 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -71,8 +71,9 @@ function normalizeVideoParamsForProvider( providerType?: string ): VideoGenerationParams { const isFalVideo = providerType === "fal-video"; + const isRuxaVideo = providerType === "ruxa-video"; const base: VideoGenerationParams = { - mode: isFalVideo ? "first-frame" : "text-to-video", + mode: isFalVideo || isRuxaVideo ? "first-frame" : "text-to-video", resolution: "720p", ratio: "16:9", duration: 5, @@ -99,6 +100,19 @@ function normalizeVideoParamsForProvider( return merged; } + if (isRuxaVideo) { + merged.mode = "first-frame"; + if (!["16:9", "9:16", "1:1"].includes(merged.ratio)) { + merged.ratio = "16:9"; + } + merged.duration = merged.duration >= 8 ? 10 : 5; + merged.generateAudio = false; + merged.cameraMove = "none"; + merged.cameraFixed = false; + merged.fps = 24; + return merged; + } + if (!["text-to-video", "first-frame", "first-last-frame", "reference-images"].includes(merged.mode)) { merged.mode = "text-to-video"; } @@ -416,7 +430,8 @@ export function Chat({ currentModel?.providerType === "volcano-video" || currentModel?.providerType === "doubao-video" || currentModel?.providerType === "qianwen-video" || - currentModel?.providerType === "fal-video"; + currentModel?.providerType === "fal-video" || + currentModel?.providerType === "ruxa-video"; const isImageModel = currentModel?.supportsImageGen === true; // 🔍 调试日志 @@ -962,6 +977,7 @@ export function Chat({ else if (model?.providerType === "volcano-video" || model?.providerType === "doubao-video") modelType = "volcano-video"; else if (model?.providerType === "qianwen-video") modelType = "qianwen-video"; else if (model?.providerType === "fal-video") modelType = "fal-video"; + else if (model?.providerType === "ruxa-video") modelType = "ruxa-video"; else if ( model?.providerType === "alibaba-image" || model?.providerType === "ruxa-image" || @@ -1793,6 +1809,7 @@ export function Chat({ ): PendingGeneration["modelType"] => { if (provider === "qianwen-video") return "qianwen-video"; if (provider === "fal-video") return "fal-video"; + if (provider === "ruxa-video") return "ruxa-video"; if (provider === "volcano-video" || provider === "doubao-video") return "volcano-video"; return "other"; }; diff --git a/components/messages.tsx b/components/messages.tsx index 9b00a2370..213fa5af9 100644 --- a/components/messages.tsx +++ b/components/messages.tsx @@ -110,7 +110,7 @@ function PureMessages({ const isQianwenVideoModel = modelsData?.models?.some( (m: DynamicModel) => m.id === selectedModelId && - (m.providerType === "qianwen-video" || m.providerType === "fal-video") + (m.providerType === "qianwen-video" || m.providerType === "fal-video" || m.providerType === "ruxa-video") ) ?? false; const isVolcanoVisionModel = modelsData?.models?.some( @@ -139,6 +139,9 @@ function PureMessages({ (m: DynamicModel) => m.id === selectedModelId && (m.providerType === "volcano-video" || + m.providerType === "qianwen-video" || + m.providerType === "fal-video" || + m.providerType === "ruxa-video" || m.providerType === "doubao-video" || m.modelId?.toLowerCase().includes("video") || m.modelId?.toLowerCase().includes("sora") || @@ -608,6 +611,7 @@ function PureMessages({ modelType === "volcano-video" || modelType === "qianwen-video" || modelType === "fal-video" || + modelType === "ruxa-video" || (!modelType && (isQianwenVideoModel || isVideoModel)); const isAutoMode = selectedModelId === "auto"; diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx index 0f9f277fc..e4ee40233 100644 --- a/components/multimodal-input.tsx +++ b/components/multimodal-input.tsx @@ -50,6 +50,7 @@ const VIDEO_PROVIDER_TYPES = new Set([ "doubao-video", "qianwen-video", "fal-video", + "ruxa-video", ]); function isVideoProviderType(providerType?: string | null) { @@ -690,13 +691,14 @@ function PureMultimodalInput({ if (!isVideoModel || !videoParams) { return { max: Number.POSITIVE_INFINITY, label: "" as const }; } - if (videoProviderType === "fal-video") { + if (videoProviderType === "fal-video" || videoProviderType === "ruxa-video") { + const providerLabel = videoProviderType === "ruxa-video" ? "RUXA" : "FAL"; switch (videoParams.mode) { case "first-last-frame": - return { max: 2, label: "FAL 首尾帧模式需要 2 张图(首帧 + 尾帧)" as const }; + return { max: 2, label: `${providerLabel} 首尾帧模式需要 2 张图(首帧 + 尾帧)` as const }; case "first-frame": default: - return { max: 1, label: "FAL 图生视频模式需要 1 张起始图" as const }; + return { max: 1, label: `${providerLabel} 图生视频模式需要 1 张起始图` as const }; } } switch (videoParams.mode) { @@ -1741,7 +1743,13 @@ function PureModelSelectorCompact({ {(() => { const providerType = model.providerType || ""; - if (providerType === "volcano-video" || providerType === "doubao-video" || providerType === "qianwen-video" || providerType === "fal-video") { + if ( + providerType === "volcano-video" || + providerType === "doubao-video" || + providerType === "qianwen-video" || + providerType === "fal-video" || + providerType === "ruxa-video" + ) { return