新增FAL视频服务商并优化模型选择弹窗与头像配置
This commit is contained in:
parent
ed9a03b1c7
commit
10b44ddfcd
@ -121,7 +121,7 @@ type ToolConfig = {
|
||||
type AiProvider = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "zhipu";
|
||||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu";
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
isEnabled: boolean;
|
||||
@ -146,6 +146,7 @@ type AiModel = {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
description?: string;
|
||||
supportsVision: boolean;
|
||||
supportsImageGen: boolean;
|
||||
@ -329,6 +330,7 @@ const PROVIDER_TYPES = [
|
||||
{ value: "qianwen-video", label: "千问视频生成" },
|
||||
{ value: "volcano-vision", label: "火山引擎视觉" },
|
||||
{ value: "volcano-video", label: "火山引擎视频" },
|
||||
{ value: "fal-video", label: "FAL 视频生成" },
|
||||
];
|
||||
|
||||
export function AdminSettingsClient() {
|
||||
@ -866,7 +868,7 @@ export function AdminSettingsClient() {
|
||||
const [providerForm, setProviderForm] = useState({
|
||||
id: "",
|
||||
name: "",
|
||||
type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "zhipu",
|
||||
type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu",
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
isEnabled: true,
|
||||
@ -880,6 +882,7 @@ export function AdminSettingsClient() {
|
||||
providerId: "",
|
||||
modelId: "",
|
||||
displayName: "",
|
||||
avatarUrl: "",
|
||||
description: "",
|
||||
supportsVision: false,
|
||||
supportsImageGen: false,
|
||||
@ -904,6 +907,7 @@ export function AdminSettingsClient() {
|
||||
});
|
||||
const [showModelDialog, setShowModelDialog] = useState(false);
|
||||
const [editingModel, setEditingModel] = useState<string | null>(null);
|
||||
const [modelAvatarUploading, setModelAvatarUploading] = useState(false);
|
||||
|
||||
// 🆕 火山引擎视频多模型表单(动态从API加载)
|
||||
const [volcanoVideoModels, setVolcanoVideoModels] = useState<Array<{
|
||||
@ -1713,6 +1717,7 @@ export function AdminSettingsClient() {
|
||||
providerId: "",
|
||||
modelId: "",
|
||||
displayName: "",
|
||||
avatarUrl: "",
|
||||
description: "",
|
||||
supportsVision: false,
|
||||
supportsImageGen: false,
|
||||
@ -1737,6 +1742,93 @@ export function AdminSettingsClient() {
|
||||
setEditingModel(null);
|
||||
};
|
||||
|
||||
const resizeImageToSquare = (file: File, size = 400): Promise<File> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
image.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
reject(new Error("无法处理图片"));
|
||||
return;
|
||||
}
|
||||
|
||||
const minSide = Math.min(image.width, image.height);
|
||||
const sx = (image.width - minSide) / 2;
|
||||
const sy = (image.height - minSide) / 2;
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = "high";
|
||||
context.drawImage(image, sx, sy, minSide, minSide, 0, 0, size, size);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
if (!blob) {
|
||||
reject(new Error("头像压缩失败"));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(
|
||||
new File([blob], `model-avatar-${Date.now()}.png`, {
|
||||
type: "image/png",
|
||||
})
|
||||
);
|
||||
},
|
||||
"image/png",
|
||||
0.95
|
||||
);
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
reject(new Error("图片读取失败"));
|
||||
};
|
||||
|
||||
image.src = objectUrl;
|
||||
});
|
||||
|
||||
const handleModelAvatarUpload = async (file: File) => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("请上传图片文件");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setModelAvatarUploading(true);
|
||||
const resizedFile = await resizeImageToSquare(file, 400);
|
||||
const formData = new FormData();
|
||||
formData.append("file", resizedFile);
|
||||
|
||||
const response = await fetch("/api/files/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result?.url) {
|
||||
throw new Error(result?.error || "上传失败");
|
||||
}
|
||||
|
||||
setModelForm((prev) => ({ ...prev, avatarUrl: result.url }));
|
||||
toast.success("模型头像上传成功");
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "模型头像上传失败");
|
||||
} finally {
|
||||
setModelAvatarUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveModel = async () => {
|
||||
try {
|
||||
// 🆕 检查是否是火山引擎视频Provider
|
||||
@ -2754,6 +2846,7 @@ export function AdminSettingsClient() {
|
||||
"gemini": "bg-purple-500",
|
||||
"volcano-vision": "bg-orange-500",
|
||||
"volcano-video": "bg-red-500",
|
||||
"fal-video": "bg-indigo-500",
|
||||
"alibaba": "bg-blue-500",
|
||||
"alibaba-image": "bg-amber-500",
|
||||
"ruxa-image": "bg-emerald-500",
|
||||
@ -5091,6 +5184,8 @@ export function AdminSettingsClient() {
|
||||
? "https://api.ruxa.ai"
|
||||
: !prev.baseUrl && value === "fal-image"
|
||||
? "https://fal.run"
|
||||
: !prev.baseUrl && value === "fal-video"
|
||||
? "https://fal.run"
|
||||
: !prev.baseUrl && value === "zhipu"
|
||||
? "https://open.bigmodel.cn/api/paas/v4"
|
||||
: prev.baseUrl,
|
||||
@ -5115,6 +5210,8 @@ export function AdminSettingsClient() {
|
||||
? "https://api.ruxa.ai"
|
||||
: providerForm.type === "fal-image"
|
||||
? "https://fal.run"
|
||||
: providerForm.type === "fal-video"
|
||||
? "https://fal.run"
|
||||
: providerForm.type === "zhipu"
|
||||
? "https://open.bigmodel.cn/api/paas/v4"
|
||||
: "https://api.openai.com/v1"
|
||||
@ -5311,6 +5408,57 @@ export function AdminSettingsClient() {
|
||||
onChange={(e) => setModelForm({ ...modelForm, displayName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>模型头像(400x400)</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-12 w-12 overflow-hidden rounded-full border bg-muted">
|
||||
{modelForm.avatarUrl ? (
|
||||
<img
|
||||
src={modelForm.avatarUrl}
|
||||
alt="模型头像预览"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-[10px] text-muted-foreground">
|
||||
400x400
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<Input
|
||||
value={modelForm.avatarUrl}
|
||||
onChange={(e) => setModelForm({ ...modelForm, avatarUrl: e.target.value })}
|
||||
placeholder="头像 URL(推荐 400x400)"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={modelAvatarUploading}
|
||||
onClick={() => document.getElementById("model-avatar-upload")?.click()}
|
||||
>
|
||||
{modelAvatarUploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<input
|
||||
id="model-avatar-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
await handleModelAvatarUpload(file);
|
||||
}
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">上传后会自动裁切并压缩为 400x400 PNG。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Textarea
|
||||
@ -5539,14 +5687,25 @@ export function AdminSettingsClient() {
|
||||
const provider = providersData?.providers?.find((p) => p.id === model.providerId);
|
||||
return (
|
||||
<div key={model.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
{model.displayName}
|
||||
{model.isDefault && <span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded">默认</span>}
|
||||
{model.isAgentModel && <span className="text-xs bg-violet-100 text-violet-800 px-2 py-0.5 rounded">Agent</span>}
|
||||
<div className="flex flex-1 items-center gap-3">
|
||||
<div className="h-10 w-10 overflow-hidden rounded-full border bg-muted">
|
||||
{model.avatarUrl ? (
|
||||
<img src={model.avatarUrl} alt={model.displayName} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
|
||||
无图
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
ID: {model.modelId} | 提供商: {provider?.name ?? "未知"}
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
<span className="truncate">{model.displayName}</span>
|
||||
{model.isDefault && <span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded">默认</span>}
|
||||
{model.isAgentModel && <span className="text-xs bg-violet-100 text-violet-800 px-2 py-0.5 rounded">Agent</span>}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
ID: {model.modelId} | 提供商: {provider?.name ?? "未知"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -5556,6 +5715,7 @@ export function AdminSettingsClient() {
|
||||
providerId: model.providerId,
|
||||
modelId: model.modelId,
|
||||
displayName: model.displayName,
|
||||
avatarUrl: (model as any).avatarUrl || "",
|
||||
description: model.description || "",
|
||||
supportsVision: model.supportsVision,
|
||||
supportsImageGen: model.supportsImageGen,
|
||||
|
||||
@ -2784,26 +2784,33 @@ export async function POST(request: Request) {
|
||||
}
|
||||
// ========== 火山引擎视觉处理结束 ==========
|
||||
|
||||
// ========== 千问视频生成独立处理 ==========
|
||||
if ((languageModel as any).provider === "qianwen-video" && typeof (languageModel as any).doGenerate === "function") {
|
||||
console.log("[Chat API] Using Qianwen video generation");
|
||||
|
||||
const { scheduleVideoUrlReplacement } = await import("@/lib/ai/providers/qianwen-video");
|
||||
// ========== 千问/FAL 视频生成独立处理 ==========
|
||||
if (
|
||||
["qianwen-video", "fal-video"].includes((languageModel as any).provider) &&
|
||||
typeof (languageModel as any).doGenerate === "function"
|
||||
) {
|
||||
const currentVideoProvider = (languageModel as any).provider as "qianwen-video" | "fal-video";
|
||||
console.log(`[Chat API] Using ${currentVideoProvider} video generation`);
|
||||
const needsAsyncReplacement = currentVideoProvider === "qianwen-video";
|
||||
const scheduleVideoUrlReplacement = needsAsyncReplacement
|
||||
? (await import("@/lib/ai/providers/qianwen-video")).scheduleVideoUrlReplacement
|
||||
: null;
|
||||
|
||||
const qianwenVideoStream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
try {
|
||||
// 千问视频直接使用原始 URL,不转换为 base64
|
||||
const modelMessages = convertToModelMessages(uiMessagesForModel);
|
||||
console.log("[Chat API] Qianwen video messages prepared, count:", modelMessages.length);
|
||||
console.log(`[Chat API] ${currentVideoProvider} messages prepared, count:`, modelMessages.length);
|
||||
|
||||
// 调用 doGenerate 获取视频
|
||||
const result = await (languageModel as any).doGenerate({
|
||||
messages: modelMessages,
|
||||
videoParams: requestBody.videoParams,
|
||||
});
|
||||
|
||||
const videoMarkdown = result.text || "视频生成失败";
|
||||
console.log("[Chat API] Qianwen video generated, length:", videoMarkdown.length);
|
||||
console.log(`[Chat API] ${currentVideoProvider} video generated, length:`, videoMarkdown.length);
|
||||
|
||||
// 提取临时视频 URL 和提示词(用于后续替换)
|
||||
const videoUrlMatch = videoMarkdown.match(/<video\s+src="([^"]+)"/);
|
||||
@ -2844,18 +2851,18 @@ export async function POST(request: Request) {
|
||||
},
|
||||
],
|
||||
});
|
||||
console.log("[Chat API] ✅ Qianwen video message saved successfully!");
|
||||
console.log(`[Chat API] ✅ ${currentVideoProvider} video message saved successfully!`);
|
||||
|
||||
// 启动异步视频替换任务(5分钟后执行)
|
||||
if (tempVideoUrl) {
|
||||
if (needsAsyncReplacement && tempVideoUrl && scheduleVideoUrlReplacement) {
|
||||
console.log("[Chat API] 🕐 Scheduling video URL replacement in 5 minutes...");
|
||||
scheduleVideoUrlReplacement(id, messageId, tempVideoUrl, prompt);
|
||||
}
|
||||
} catch (saveError) {
|
||||
console.error("[Chat API] ❌ Failed to save Qianwen video message:", saveError);
|
||||
console.error(`[Chat API] ❌ Failed to save ${currentVideoProvider} video message:`, saveError);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Chat API] Qianwen video generation error:", error);
|
||||
console.error(`[Chat API] ${currentVideoProvider} video generation error:`, error);
|
||||
const errorMessageId = generateUUID();
|
||||
writer.write({
|
||||
type: "data-appendMessage",
|
||||
@ -2872,7 +2879,7 @@ export async function POST(request: Request) {
|
||||
|
||||
return new Response(qianwenVideoStream.pipeThrough(new JsonToSseTransformStream()));
|
||||
}
|
||||
// ========== 千问视频处理结束 ==========
|
||||
// ========== 千问/FAL 视频处理结束 ==========
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer: dataStream }) => {
|
||||
|
||||
@ -20,6 +20,7 @@ const videoParamsSchema = z.object({
|
||||
resolution: z.enum(["480p", "720p", "1080p"]).optional(),
|
||||
ratio: z.string().optional(),
|
||||
duration: z.number().optional(),
|
||||
generateAudio: z.boolean().optional(),
|
||||
watermark: z.boolean().optional(),
|
||||
cameraFixed: z.boolean().optional(),
|
||||
cameraMove: z.string().optional(),
|
||||
|
||||
@ -24,6 +24,7 @@ export async function GET() {
|
||||
id: model.id,
|
||||
modelId: model.modelId,
|
||||
displayName: model.displayName,
|
||||
avatarUrl: model.avatarUrl,
|
||||
description: model.description,
|
||||
supportsVision: model.supportsVision,
|
||||
supportsImageGen: model.supportsImageGen,
|
||||
|
||||
@ -29,6 +29,7 @@ const createModelSchema = z.object({
|
||||
providerId: z.string().uuid(),
|
||||
modelId: z.string().min(1).max(128),
|
||||
displayName: z.string().min(1).max(128),
|
||||
avatarUrl: z.string().url().max(1000).optional().or(z.literal("")),
|
||||
description: z.string().optional(),
|
||||
supportsVision: z.boolean().optional(),
|
||||
supportsImageGen: z.boolean().optional(),
|
||||
@ -46,6 +47,7 @@ const updateModelSchema = z.object({
|
||||
providerId: z.string().uuid().optional(),
|
||||
modelId: z.string().min(1).max(128).optional(),
|
||||
displayName: z.string().min(1).max(128).optional(),
|
||||
avatarUrl: z.string().url().max(1000).optional().or(z.literal("")),
|
||||
description: z.string().optional(),
|
||||
supportsVision: z.boolean().optional(),
|
||||
supportsImageGen: z.boolean().optional(),
|
||||
|
||||
@ -22,6 +22,7 @@ const providerTypes = [
|
||||
"alibaba-image",
|
||||
"ruxa-image",
|
||||
"fal-image",
|
||||
"fal-video",
|
||||
"zhipu",
|
||||
] as const;
|
||||
type ProviderType = (typeof providerTypes)[number];
|
||||
|
||||
@ -5,7 +5,7 @@ import { DefaultChatTransport } from "ai";
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import useSWR from "swr";
|
||||
import { Send, Loader2, ImagePlus, Paperclip, X, Sparkles, User, Bot, Plus, History, LayoutGrid, Maximize2, RefreshCw, Zap, Globe, Box, ArrowUp } from "lucide-react";
|
||||
import { Send, Loader2, ImagePlus, Paperclip, X, User, Bot, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@ -15,7 +15,6 @@ import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
@ -708,6 +707,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
id: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string | null;
|
||||
providerType: string;
|
||||
supportsVision?: boolean;
|
||||
supportsImageGen?: boolean;
|
||||
@ -731,6 +731,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
id: "auto",
|
||||
modelId: "auto",
|
||||
displayName: "Agent模式",
|
||||
avatarUrl: null,
|
||||
providerType: "auto",
|
||||
supportsImageGen: true,
|
||||
};
|
||||
@ -2060,42 +2061,56 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
{/* Agent Selector (Model) - 包含Agent模式选项 */}
|
||||
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
|
||||
<SelectTrigger className={cn(
|
||||
"h-8 text-xs transition-colors rounded-full px-3 gap-1 w-auto flex-shrink-0",
|
||||
"h-8 text-xs transition-colors rounded-full px-2 gap-1.5 w-auto flex-shrink-0",
|
||||
selectedModelId === "auto"
|
||||
? "bg-gradient-to-r from-purple-50 to-blue-50 text-purple-600 border-purple-100 hover:from-purple-100 hover:to-blue-100"
|
||||
: "bg-blue-50 text-blue-600 border-blue-100 hover:bg-blue-100 hover:border-blue-200"
|
||||
)}>
|
||||
{selectedModelId === "auto" ? (
|
||||
<Zap className="h-3 w-3" />
|
||||
) : (
|
||||
<Sparkles className="h-3 w-3" />
|
||||
)}
|
||||
<span className="truncate max-w-[60px]">
|
||||
<span className="h-5 w-5 overflow-hidden rounded-full border border-blue-200 bg-white">
|
||||
{allModels.find((m) => m.id === selectedModelId)?.avatarUrl ? (
|
||||
<img
|
||||
src={allModels.find((m) => m.id === selectedModelId)?.avatarUrl || ""}
|
||||
alt={allModels.find((m) => m.id === selectedModelId)?.displayName || "模型头像"}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-full w-full items-center justify-center text-[10px] font-medium text-blue-500">
|
||||
{(allModels.find((m) => m.id === selectedModelId)?.displayName || "A").slice(0, 1)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate max-w-[66px]">
|
||||
{allModels.find(m => m.id === selectedModelId)?.displayName || "Agent模式"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* Agent模式选项 */}
|
||||
<SelectItem value="auto" className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-3 w-3 text-purple-500" />
|
||||
<span>Agent模式</span>
|
||||
<span className="text-[10px] text-muted-foreground">先对话再生图</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{/* 分隔线 */}
|
||||
{imageModels.length > 0 && (
|
||||
<div className="my-1 border-t border-border" />
|
||||
)}
|
||||
{/* 生图模型列表 */}
|
||||
{imageModels.map(model => (
|
||||
<SelectItem key={model.id} value={model.id} className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-3 w-3 text-primary" />
|
||||
{model.displayName}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectContent className="w-[320px] max-h-[420px] overflow-hidden rounded-[22px] border border-[#cfd4dc] bg-[#f3f4f6] p-2 shadow-[0_10px_30px_rgba(15,23,42,0.15)]">
|
||||
<div className="max-h-[390px] space-y-1 overflow-y-auto pr-1">
|
||||
{allModels.map((model) => (
|
||||
<SelectItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
className="h-12 rounded-2xl px-3 text-[#2f3640] [&>span]:hidden focus:bg-[#dfe2e6] focus:text-[#1f2937] data-[state=checked]:bg-[#dfe2e6] data-[state=checked]:text-[#1f2937]"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="h-7 w-7 overflow-hidden rounded-full border border-[#d4d9df] bg-white">
|
||||
{model.avatarUrl ? (
|
||||
<img src={model.avatarUrl} alt={model.displayName} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="flex h-full w-full items-center justify-center text-[10px] font-medium text-[#6b7280]">
|
||||
{model.id === "auto" ? "A" : model.displayName.slice(0, 1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate text-[15px] font-medium leading-none">{model.displayName}</span>
|
||||
</div>
|
||||
{selectedModelId === model.id ? (
|
||||
<span className="text-base font-semibold text-[#1f2937]">✓</span>
|
||||
) : null}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@ -65,6 +65,51 @@ function normalizeImageParamsForProvider(
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeVideoParamsForProvider(
|
||||
params: VideoGenerationParams | null | undefined,
|
||||
providerType?: string
|
||||
): VideoGenerationParams {
|
||||
const isFalVideo = providerType === "fal-video";
|
||||
const base: VideoGenerationParams = {
|
||||
mode: isFalVideo ? "first-frame" : "text-to-video",
|
||||
resolution: "720p",
|
||||
ratio: "16:9",
|
||||
duration: 5,
|
||||
generateAudio: true,
|
||||
watermark: false,
|
||||
cameraFixed: false,
|
||||
cameraMove: "none",
|
||||
fps: 24,
|
||||
};
|
||||
|
||||
const merged = { ...base, ...(params || {}) };
|
||||
|
||||
if (isFalVideo) {
|
||||
if (merged.mode !== "first-frame" && merged.mode !== "first-last-frame") {
|
||||
merged.mode = "first-frame";
|
||||
}
|
||||
|
||||
if (!["16:9", "9:16", "1:1"].includes(merged.ratio)) {
|
||||
merged.ratio = "16:9";
|
||||
}
|
||||
|
||||
merged.duration = Math.max(3, Math.min(15, Math.round(Number(merged.duration) || 5)));
|
||||
merged.generateAudio = merged.generateAudio ?? true;
|
||||
return merged;
|
||||
}
|
||||
|
||||
if (!["text-to-video", "first-frame", "first-last-frame", "reference-images"].includes(merged.mode)) {
|
||||
merged.mode = "text-to-video";
|
||||
}
|
||||
|
||||
if (!["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(merged.ratio)) {
|
||||
merged.ratio = "16:9";
|
||||
}
|
||||
|
||||
merged.duration = merged.duration >= 8 ? 10 : 5;
|
||||
return merged;
|
||||
}
|
||||
|
||||
function sortMessagesByCreatedAt(messages: ChatMessage[]): ChatMessage[] {
|
||||
return [...messages].sort((a, b) => {
|
||||
const timeA = (a as any).createdAt ? new Date((a as any).createdAt).getTime() : 0;
|
||||
@ -284,7 +329,10 @@ export function Chat({
|
||||
}>("/api/models", fetcher);
|
||||
|
||||
const currentModel = modelsData?.models?.find(m => m.id === currentModelId);
|
||||
const isVideoModel = currentModel?.providerType === "volcano-video";
|
||||
const isVideoModel =
|
||||
currentModel?.providerType === "volcano-video" ||
|
||||
currentModel?.providerType === "qianwen-video" ||
|
||||
currentModel?.providerType === "fal-video";
|
||||
const isImageModel = currentModel?.supportsImageGen === true;
|
||||
|
||||
// 🔍 调试日志
|
||||
@ -319,25 +367,19 @@ export function Chat({
|
||||
|
||||
// 🆕 当切换到视频模型时,自动初始化 videoParams
|
||||
useEffect(() => {
|
||||
if (isVideoModel && !videoParams) {
|
||||
const defaultParams: VideoGenerationParams = {
|
||||
mode: "text-to-video",
|
||||
resolution: "720p",
|
||||
ratio: "16:9",
|
||||
duration: 5,
|
||||
watermark: false,
|
||||
cameraFixed: false,
|
||||
cameraMove: "none",
|
||||
fps: 24,
|
||||
};
|
||||
setVideoParams(defaultParams);
|
||||
console.log("[Chat] 视频模型已选择,初始化默认参数:", defaultParams);
|
||||
} else if (!isVideoModel && videoParams) {
|
||||
// 切换到非视频模型时清空 videoParams
|
||||
if (!isVideoModel) {
|
||||
if (!videoParams) return;
|
||||
setVideoParams(null);
|
||||
console.log("[Chat] 已切换到非视频模型,清空视频参数");
|
||||
return;
|
||||
}
|
||||
}, [isVideoModel, videoParams]);
|
||||
|
||||
const normalized = normalizeVideoParamsForProvider(videoParams, currentModel?.providerType);
|
||||
if (!videoParams || JSON.stringify(videoParams) !== JSON.stringify(normalized)) {
|
||||
setVideoParams(normalized);
|
||||
console.log("[Chat] 视频参数已按当前模型规范化:", normalized);
|
||||
}
|
||||
}, [isVideoModel, videoParams, currentModel?.providerType]);
|
||||
|
||||
// 🆕 当切换到图片模型时,强制更新 imageParams(确保组件重新渲染)
|
||||
useEffect(() => {
|
||||
@ -654,6 +696,7 @@ export function Chat({
|
||||
else if (model?.providerType === "volcano-vision") modelType = "volcano-vision";
|
||||
else if (model?.providerType === "volcano-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 === "alibaba-image" ||
|
||||
model?.providerType === "ruxa-image" ||
|
||||
@ -1584,6 +1627,7 @@ export function Chat({
|
||||
isVideoModel={isVideoModel}
|
||||
videoModelName={currentModel?.displayName}
|
||||
videoParams={videoParams}
|
||||
videoProviderType={currentModel?.providerType}
|
||||
onVideoParamsChange={(params) => {
|
||||
setVideoParams(params);
|
||||
console.log("[Chat] Video params updated:", params);
|
||||
|
||||
@ -90,6 +90,7 @@ type ModelOption = {
|
||||
id: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string | null;
|
||||
providerType: string;
|
||||
supportsImageGen?: boolean;
|
||||
};
|
||||
@ -1315,20 +1316,54 @@ export function LovartHome({
|
||||
</label>
|
||||
|
||||
<Select value={selectedModelId || "auto"} onValueChange={setSelectedModelId}>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[110px] rounded-full border border-blue-100 bg-blue-50 px-3 text-xs text-blue-600">
|
||||
<span className="truncate max-w-[100px]">
|
||||
{currentComposerModel?.displayName || "Agent模式"}
|
||||
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full border border-blue-100 bg-blue-50 px-2 text-xs text-blue-600">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="h-5 w-5 overflow-hidden rounded-full border border-blue-200 bg-white">
|
||||
{currentComposerModel?.avatarUrl ? (
|
||||
<img
|
||||
src={currentComposerModel.avatarUrl}
|
||||
alt={currentComposerModel.displayName || "模型头像"}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-full w-full items-center justify-center text-[10px] font-medium text-blue-500">
|
||||
{(currentComposerModel?.displayName || "A").slice(0, 1)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="max-w-[95px] truncate">
|
||||
{currentComposerModel?.displayName || "Agent模式"}
|
||||
</span>
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto" className="text-xs">
|
||||
Agent模式
|
||||
</SelectItem>
|
||||
{imageModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id} className="text-xs">
|
||||
{model.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectContent className="w-[320px] max-h-[420px] overflow-hidden rounded-[22px] border border-[#cfd4dc] bg-[#f3f4f6] p-2 shadow-[0_10px_30px_rgba(15,23,42,0.15)]">
|
||||
<div className="max-h-[390px] space-y-1 overflow-y-auto pr-1">
|
||||
{[{ id: "auto", displayName: "Agent模式", avatarUrl: null }, ...imageModels].map((model) => (
|
||||
<SelectItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
className="h-12 rounded-2xl px-3 text-[#2f3640] [&>span]:hidden focus:bg-[#dfe2e6] focus:text-[#1f2937] data-[state=checked]:bg-[#dfe2e6] data-[state=checked]:text-[#1f2937]"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="h-7 w-7 overflow-hidden rounded-full border border-[#d4d9df] bg-white">
|
||||
{model.avatarUrl ? (
|
||||
<img src={model.avatarUrl} alt={model.displayName} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="flex h-full w-full items-center justify-center text-[10px] font-medium text-[#6b7280]">
|
||||
{model.id === "auto" ? "A" : model.displayName.slice(0, 1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate text-[15px] font-medium leading-none">{model.displayName}</span>
|
||||
</div>
|
||||
{(selectedModelId || "auto") === model.id ? (
|
||||
<span className="text-base font-semibold text-[#1f2937]">✓</span>
|
||||
) : null}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
@ -108,7 +108,9 @@ function PureMessages({
|
||||
) ?? false;
|
||||
|
||||
const isQianwenVideoModel = modelsData?.models?.some(
|
||||
(m: DynamicModel) => m.id === selectedModelId && m.providerType === "qianwen-video"
|
||||
(m: DynamicModel) =>
|
||||
m.id === selectedModelId &&
|
||||
(m.providerType === "qianwen-video" || m.providerType === "fal-video")
|
||||
) ?? false;
|
||||
|
||||
const isVolcanoVisionModel = modelsData?.models?.some(
|
||||
@ -593,7 +595,11 @@ function PureMessages({
|
||||
const showGemini = modelType === "gemini" || (!modelType && isGeminiModel);
|
||||
const showVolcano = modelType === "volcano-vision" || (!modelType && isVolcanoVisionModel);
|
||||
const showAlibabaImage = modelType === "alibaba-image" || (!modelType && isAlibabaImageModel);
|
||||
const showVideo = modelType === "volcano-video" || modelType === "qianwen-video" || (!modelType && (isQianwenVideoModel || isVideoModel));
|
||||
const showVideo =
|
||||
modelType === "volcano-video" ||
|
||||
modelType === "qianwen-video" ||
|
||||
modelType === "fal-video" ||
|
||||
(!modelType && (isQianwenVideoModel || isVideoModel));
|
||||
|
||||
const isAutoMode = selectedModelId === "auto";
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
type DynamicModel = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string | null;
|
||||
description: string | null;
|
||||
supportsVision: boolean;
|
||||
isDefault: boolean;
|
||||
@ -82,6 +83,7 @@ function PureMultimodalInput({
|
||||
isVideoModel,
|
||||
videoModelName,
|
||||
videoParams,
|
||||
videoProviderType,
|
||||
onVideoParamsChange,
|
||||
isImageModel,
|
||||
imageModelName,
|
||||
@ -107,6 +109,7 @@ function PureMultimodalInput({
|
||||
isVideoModel?: boolean;
|
||||
videoModelName?: string;
|
||||
videoParams?: VideoGenerationParams | null;
|
||||
videoProviderType?: string;
|
||||
onVideoParamsChange?: (params: VideoGenerationParams) => void;
|
||||
isImageModel?: boolean;
|
||||
imageModelName?: string;
|
||||
@ -421,19 +424,28 @@ function PureMultimodalInput({
|
||||
if (!isVideoModel || !videoParams) {
|
||||
return { max: Number.POSITIVE_INFINITY, label: "" as const };
|
||||
}
|
||||
if (videoProviderType === "fal-video") {
|
||||
switch (videoParams.mode) {
|
||||
case "first-last-frame":
|
||||
return { max: 2, label: "FAL 首尾帧模式需要 2 张图(首帧 + 尾帧)" as const };
|
||||
case "first-frame":
|
||||
default:
|
||||
return { max: 1, label: "FAL 图生视频模式需要 1 张起始图" as const };
|
||||
}
|
||||
}
|
||||
switch (videoParams.mode) {
|
||||
case "text-to-video":
|
||||
return { max: 0, label: "文生视频模式不支持图片" as const };
|
||||
case "first-frame":
|
||||
return { max: 4, label: "智能多帧模式最多 4 张图" as const };
|
||||
return { max: 1, label: "主体参考模式仅支持 1 张图" as const };
|
||||
case "first-last-frame":
|
||||
return { max: 2, label: "首尾帧模式仅支持 2 张图" as const };
|
||||
case "reference-images":
|
||||
return { max: 1, label: "主体参考模式仅支持 1 张图" as const };
|
||||
return { max: 4, label: "智能多帧模式最多 4 张图" as const };
|
||||
default:
|
||||
return { max: Number.POSITIVE_INFINITY, label: "" as const };
|
||||
}
|
||||
}, [isVideoModel, videoParams]);
|
||||
}, [isVideoModel, videoParams, videoProviderType]);
|
||||
|
||||
const enforceVideoImageLimit = useCallback(
|
||||
(incomingCount: number) => {
|
||||
@ -887,6 +899,7 @@ function PureMultimodalInput({
|
||||
initialParams={videoParams || undefined}
|
||||
onParamsChange={onVideoParamsChange}
|
||||
inline={true}
|
||||
providerType={videoProviderType}
|
||||
/>
|
||||
)}
|
||||
{/* 🆕 图片参数栏(内嵌在模型选择器后面,同一行) */}
|
||||
@ -944,6 +957,9 @@ export const MultimodalInput = memo(
|
||||
if (!equal(prevProps.videoParams, nextProps.videoParams)) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.videoProviderType !== nextProps.videoProviderType) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.isImageModel !== nextProps.isImageModel) {
|
||||
return false;
|
||||
}
|
||||
@ -1044,6 +1060,7 @@ function PureModelSelectorCompact({
|
||||
const autoModel: DynamicModel = {
|
||||
id: "auto",
|
||||
displayName: "Agent模式",
|
||||
avatarUrl: null,
|
||||
description: "根据您的意图自动选择最合适的模型",
|
||||
supportsVision: true,
|
||||
isDefault: false,
|
||||
@ -1128,8 +1145,8 @@ function PureModelSelectorCompact({
|
||||
|
||||
return (
|
||||
<PromptInputModelSelect
|
||||
onValueChange={(modelName) => {
|
||||
const model = displayModels.find((m) => m.displayName === modelName);
|
||||
onValueChange={(modelId) => {
|
||||
const model = displayModels.find((m) => m.id === modelId);
|
||||
if (model) {
|
||||
// 🆕 如果选择的是"自动"模式,直接传递 "auto" 给后端
|
||||
// 后端会使用智能分析器进行意图识别和模型选择
|
||||
@ -1150,7 +1167,7 @@ function PureModelSelectorCompact({
|
||||
});
|
||||
}
|
||||
}}
|
||||
value={selectedModel?.name}
|
||||
value={selectedModel?.id}
|
||||
>
|
||||
<Trigger asChild>
|
||||
<Button className="h-8 px-2" variant="ghost">
|
||||
@ -1161,19 +1178,41 @@ function PureModelSelectorCompact({
|
||||
<ChevronDownIcon size={16} />
|
||||
</Button>
|
||||
</Trigger>
|
||||
<PromptInputModelSelectContent className="min-w-[260px] p-0">
|
||||
<div className="flex flex-col gap-px">
|
||||
<PromptInputModelSelectContent className="w-[320px] max-h-[420px] overflow-hidden rounded-[22px] border border-[#cfd4dc] bg-[#f3f4f6] p-2 shadow-[0_10px_30px_rgba(15,23,42,0.15)]">
|
||||
<div className="max-h-[390px] space-y-1 overflow-y-auto pr-1">
|
||||
{displayModels.map((model) => (
|
||||
<SelectItem
|
||||
key={model.id}
|
||||
value={model.displayName}
|
||||
className={model.isSubModel ? "pl-6" : ""}
|
||||
value={model.id}
|
||||
className={cn(
|
||||
"h-12 rounded-2xl px-3 text-[#2f3640] [&>span]:hidden",
|
||||
"focus:bg-[#dfe2e6] focus:text-[#1f2937]",
|
||||
"data-[state=checked]:bg-[#dfe2e6] data-[state=checked]:text-[#1f2937]",
|
||||
model.isSubModel ? "pl-8" : ""
|
||||
)}
|
||||
>
|
||||
<div className="truncate font-medium text-xs">
|
||||
{model.isSubModel && "└ "}{model.displayName}
|
||||
</div>
|
||||
<div className="mt-px truncate text-[10px] text-muted-foreground leading-tight">
|
||||
{model.description ?? model.providerName}
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="h-7 w-7 overflow-hidden rounded-full border border-[#d4d9df] bg-white">
|
||||
{model.avatarUrl ? (
|
||||
<img
|
||||
src={model.avatarUrl}
|
||||
alt={model.displayName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-[10px] font-medium text-[#6b7280]">
|
||||
{model.id === "auto" ? "A" : model.displayName.slice(0, 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-[15px] font-medium leading-none">
|
||||
{model.isSubModel ? `└ ${model.displayName}` : model.displayName}
|
||||
</div>
|
||||
</div>
|
||||
{selectedModel?.id === model.id ? (
|
||||
<span className="text-base font-semibold text-[#1f2937]">✓</span>
|
||||
) : null}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -63,6 +63,7 @@ export interface VideoGenerationParams {
|
||||
resolution: "480p" | "720p" | "1080p";
|
||||
ratio: "16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9" | "adaptive";
|
||||
duration: number;
|
||||
generateAudio?: boolean;
|
||||
watermark: boolean;
|
||||
cameraFixed: boolean;
|
||||
cameraMove?: CameraMove; // 新增:运镜模式
|
||||
@ -76,6 +77,7 @@ interface VideoParamsBarProps {
|
||||
initialParams?: Partial<VideoGenerationParams>;
|
||||
onParamsChange?: (params: VideoGenerationParams) => void;
|
||||
inline?: boolean; // 内联模式:只显示参数按钮,不显示外层容器
|
||||
providerType?: string;
|
||||
}
|
||||
|
||||
const MODE_LABELS: Record<VideoGenerationMode, string> = {
|
||||
@ -124,26 +126,98 @@ const FPS_OPTIONS = [
|
||||
{ value: 60, label: "60fps" },
|
||||
] as const;
|
||||
|
||||
const STANDARD_MODE_OPTIONS: Array<{ value: VideoGenerationMode; label: string }> = [
|
||||
{ value: "text-to-video", label: "文生视频" },
|
||||
{ value: "first-last-frame", label: "首尾帧" },
|
||||
{ value: "reference-images", label: "智能多帧" },
|
||||
{ value: "first-frame", label: "主体参考" },
|
||||
];
|
||||
|
||||
const FAL_MODE_OPTIONS: Array<{ value: VideoGenerationMode; label: string }> = [
|
||||
{ value: "first-frame", label: "图生视频" },
|
||||
{ value: "first-last-frame", label: "首尾帧" },
|
||||
];
|
||||
|
||||
const FAL_RATIO_OPTIONS = [
|
||||
{ value: "16:9", label: "16:9" },
|
||||
{ value: "9:16", label: "9:16" },
|
||||
{ value: "1:1", label: "1:1" },
|
||||
] as const;
|
||||
|
||||
const STANDARD_DURATION_OPTIONS = [5, 10];
|
||||
const FAL_DURATION_OPTIONS = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
|
||||
const STANDARD_RATIO_SET = new Set(["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"]);
|
||||
const FAL_RATIO_SET = new Set(["16:9", "9:16", "1:1"]);
|
||||
|
||||
function normalizeVideoParamsByProvider(
|
||||
source: Partial<VideoGenerationParams> | undefined,
|
||||
isFalVideo: boolean
|
||||
): VideoGenerationParams {
|
||||
const base: VideoGenerationParams = {
|
||||
mode: isFalVideo ? "first-frame" : "text-to-video",
|
||||
resolution: "720p",
|
||||
ratio: "16:9",
|
||||
duration: 5,
|
||||
generateAudio: true,
|
||||
watermark: false,
|
||||
cameraFixed: false,
|
||||
cameraMove: "none",
|
||||
fps: 24,
|
||||
};
|
||||
const merged = { ...base, ...source };
|
||||
|
||||
if (isFalVideo) {
|
||||
if (merged.mode !== "first-frame" && merged.mode !== "first-last-frame") {
|
||||
merged.mode = "first-frame";
|
||||
}
|
||||
merged.ratio = FAL_RATIO_SET.has(merged.ratio) ? merged.ratio : "16:9";
|
||||
merged.duration = Math.max(3, Math.min(15, Math.round(Number(merged.duration) || 5)));
|
||||
merged.generateAudio = merged.generateAudio ?? true;
|
||||
return merged;
|
||||
}
|
||||
|
||||
if (
|
||||
merged.mode !== "text-to-video" &&
|
||||
merged.mode !== "first-last-frame" &&
|
||||
merged.mode !== "reference-images" &&
|
||||
merged.mode !== "first-frame"
|
||||
) {
|
||||
merged.mode = "text-to-video";
|
||||
}
|
||||
|
||||
merged.ratio = STANDARD_RATIO_SET.has(merged.ratio) ? merged.ratio : "16:9";
|
||||
merged.duration = merged.duration >= 8 ? 10 : 5;
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function VideoParamsBar({
|
||||
modelName = "视频 3.0",
|
||||
initialParams,
|
||||
onParamsChange,
|
||||
inline = false,
|
||||
providerType = "volcano-video",
|
||||
}: VideoParamsBarProps) {
|
||||
const [params, setParams] = useState<VideoGenerationParams>({
|
||||
mode: "text-to-video",
|
||||
resolution: "720p",
|
||||
ratio: "16:9",
|
||||
duration: 5,
|
||||
watermark: false, // 默认关闭水印
|
||||
cameraFixed: false,
|
||||
cameraMove: "none",
|
||||
fps: 24,
|
||||
...initialParams,
|
||||
});
|
||||
const isFalVideo = providerType === "fal-video";
|
||||
const modeOptions = isFalVideo ? FAL_MODE_OPTIONS : STANDARD_MODE_OPTIONS;
|
||||
const ratioOptions = isFalVideo ? FAL_RATIO_OPTIONS : RATIO_OPTIONS;
|
||||
const durationOptions = isFalVideo ? FAL_DURATION_OPTIONS : STANDARD_DURATION_OPTIONS;
|
||||
|
||||
const [params, setParams] = useState<VideoGenerationParams>(() =>
|
||||
normalizeVideoParamsByProvider(initialParams, isFalVideo)
|
||||
);
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = normalizeVideoParamsByProvider(initialParams, isFalVideo);
|
||||
setParams((prev) => {
|
||||
if (JSON.stringify(prev) === JSON.stringify(normalized)) {
|
||||
return prev;
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
}, [initialParams, isFalVideo]);
|
||||
|
||||
// 获取当前运镜模式的标签
|
||||
const getCurrentCameraLabel = () => {
|
||||
if (params.cameraMove) {
|
||||
@ -155,6 +229,16 @@ export function VideoParamsBar({
|
||||
|
||||
// 🆕 根据模式获取图片上传配置(根据火山引擎官方文档)
|
||||
const getImageUploadConfig = () => {
|
||||
if (isFalVideo) {
|
||||
switch (params.mode) {
|
||||
case "first-last-frame":
|
||||
return { min: 2, max: 2, label: "首尾帧(2 张)" };
|
||||
case "first-frame":
|
||||
default:
|
||||
return { min: 1, max: 1, label: "图生视频(1 张起始图)" };
|
||||
}
|
||||
}
|
||||
|
||||
switch (params.mode) {
|
||||
case "text-to-video":
|
||||
return { min: 0, max: 0, label: "文生视频(无需图片)" };
|
||||
@ -189,28 +273,28 @@ const getImageUploadConfig = () => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 px-2 gap-1 focus-visible:ring-0 focus-visible:ring-offset-0">
|
||||
<Maximize2 size={14} />
|
||||
<span className="font-medium text-xs">{MODE_LABELS[params.mode]}</span>
|
||||
<span className="font-medium text-xs">
|
||||
{modeOptions.find((option) => option.value === params.mode)?.label || MODE_LABELS[params.mode]}
|
||||
</span>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-36">
|
||||
<DropdownMenuItem onClick={() => updateParam("mode", "text-to-video")}>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.mode === "text-to-video" ? "opacity-100" : "opacity-0")} />
|
||||
文生视频
|
||||
<DropdownMenuContent align="start" className="w-36">
|
||||
{modeOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => updateParam("mode", option.value)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
params.mode === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => updateParam("mode", "first-last-frame")}>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.mode === "first-last-frame" ? "opacity-100" : "opacity-0")} />
|
||||
首尾帧
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => updateParam("mode", "reference-images")}>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.mode === "reference-images" ? "opacity-100" : "opacity-0")} />
|
||||
智能多帧
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => updateParam("mode", "first-frame")}>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.mode === "first-frame" ? "opacity-100" : "opacity-0")} />
|
||||
主体参考
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 宽高比 */}
|
||||
@ -223,7 +307,7 @@ const getImageUploadConfig = () => {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-48">
|
||||
<div className="p-1.5 grid grid-cols-3 gap-1">
|
||||
{RATIO_OPTIONS.map((option) => (
|
||||
{ratioOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={params.ratio === option.value ? "secondary" : "ghost"}
|
||||
@ -238,22 +322,23 @@ const getImageUploadConfig = () => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 分辨率 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0">
|
||||
<span className="font-medium text-xs">{params.resolution}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[100px]">
|
||||
{RESOLUTION_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.value} onClick={() => updateParam("resolution", option.value)}>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.resolution === option.value ? "opacity-100" : "opacity-0")} />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!isFalVideo && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0">
|
||||
<span className="font-medium text-xs">{params.resolution}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[100px]">
|
||||
{RESOLUTION_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.value} onClick={() => updateParam("resolution", option.value)}>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.resolution === option.value ? "opacity-100" : "opacity-0")} />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* 时长 */}
|
||||
<DropdownMenu>
|
||||
@ -264,7 +349,7 @@ const getImageUploadConfig = () => {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[80px]">
|
||||
{[5, 10].map((duration) => (
|
||||
{durationOptions.map((duration) => (
|
||||
<DropdownMenuItem key={duration} onClick={() => updateParam("duration", duration)}>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.duration === duration ? "opacity-100" : "opacity-0")} />
|
||||
{duration}s
|
||||
@ -273,46 +358,57 @@ const getImageUploadConfig = () => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 帧率 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0">
|
||||
<span className="font-medium text-xs">{params.fps || 24}fps</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[90px]">
|
||||
{FPS_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.value} onClick={() => updateParam("fps", option.value as VideoGenerationParams["fps"])}>
|
||||
<Check className={cn("mr-2 h-4 w-4", (params.fps || 24) === option.value ? "opacity-100" : "opacity-0")} />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{isFalVideo ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 px-2 gap-1 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
onClick={() => updateParam("generateAudio", !(params.generateAudio ?? true))}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span className="font-medium text-xs">{params.generateAudio === false ? "静音" : "带音频"}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0">
|
||||
<span className="font-medium text-xs">{params.fps || 24}fps</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[90px]">
|
||||
{FPS_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.value} onClick={() => updateParam("fps", option.value as VideoGenerationParams["fps"])}>
|
||||
<Check className={cn("mr-2 h-4 w-4", (params.fps || 24) === option.value ? "opacity-100" : "opacity-0")} />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 运镜(改为下拉菜单,和其他按钮一致) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 px-2 gap-1 focus-visible:ring-0 focus-visible:ring-offset-0">
|
||||
<Camera size={14} />
|
||||
<span className="font-medium text-xs">{getCurrentCameraLabel()}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[100px]">
|
||||
{CAMERA_MOVE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
updateParam("cameraMove", option.value);
|
||||
updateParam("cameraFixed", option.value === "static");
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.cameraMove === option.value ? "opacity-100" : "opacity-0")} />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 px-2 gap-1 focus-visible:ring-0 focus-visible:ring-offset-0">
|
||||
<Camera size={14} />
|
||||
<span className="font-medium text-xs">{getCurrentCameraLabel()}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[100px]">
|
||||
{CAMERA_MOVE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
updateParam("cameraMove", option.value);
|
||||
updateParam("cameraFixed", option.value === "static");
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.cameraMove === option.value ? "opacity-100" : "opacity-0")} />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 高级设置 */}
|
||||
<Dialog>
|
||||
@ -333,6 +429,15 @@ const getImageUploadConfig = () => {
|
||||
onCheckedChange={(checked) => updateParam("returnLastFrame", checked)}
|
||||
/>
|
||||
</div>
|
||||
{isFalVideo && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">生成音频</Label>
|
||||
<Switch
|
||||
checked={params.generateAudio ?? true}
|
||||
onCheckedChange={(checked) => updateParam("generateAudio", checked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">随机种子</Label>
|
||||
<Input
|
||||
@ -355,7 +460,7 @@ const getImageUploadConfig = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 px-4 border-t bg-muted/20">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
视频参数:{MODE_LABELS[params.mode]} | {params.ratio} | {params.resolution} | {params.duration}s
|
||||
视频参数:{modeOptions.find((option) => option.value === params.mode)?.label || MODE_LABELS[params.mode]} | {params.ratio} | {params.duration}s
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -432,27 +537,17 @@ const getImageUploadConfig = () => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-1 px-2 h-8 focus-visible:ring-0 focus-visible:ring-offset-0">
|
||||
<Maximize2 size={14} />
|
||||
{MODE_LABELS[params.mode]}
|
||||
{modeOptions.find((option) => option.value === params.mode)?.label || MODE_LABELS[params.mode]}
|
||||
<ChevronDown size={12} className="opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-40">
|
||||
<DropdownMenuItem onClick={() => updateParam("mode", "text-to-video")}>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.mode === "text-to-video" ? "opacity-100" : "opacity-0")} />
|
||||
文生视频
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => updateParam("mode", "first-last-frame")}>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.mode === "first-last-frame" ? "opacity-100" : "opacity-0")} />
|
||||
首尾帧
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => updateParam("mode", "first-frame")}>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.mode === "first-frame" ? "opacity-100" : "opacity-0")} />
|
||||
智能多帧
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => updateParam("mode", "reference-images")}>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.mode === "reference-images" ? "opacity-100" : "opacity-0")} />
|
||||
主体参考
|
||||
</DropdownMenuItem>
|
||||
{modeOptions.map((option) => (
|
||||
<DropdownMenuItem key={option.value} onClick={() => updateParam("mode", option.value)}>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.mode === option.value ? "opacity-100" : "opacity-0")} />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@ -468,7 +563,7 @@ const getImageUploadConfig = () => {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-64">
|
||||
<div className="p-2 grid grid-cols-3 gap-1">
|
||||
{RATIO_OPTIONS.map((option) => (
|
||||
{ratioOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={params.ratio === option.value ? "secondary" : "ghost"}
|
||||
@ -483,22 +578,23 @@ const getImageUploadConfig = () => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 分辨率 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-1 px-2 h-8 focus-visible:ring-0 focus-visible:ring-offset-0">
|
||||
{params.resolution}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[100px]">
|
||||
{RESOLUTION_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.value} onClick={() => updateParam("resolution", option.value)}>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.resolution === option.value ? "opacity-100" : "opacity-0")} />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!isFalVideo && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-1 px-2 h-8 focus-visible:ring-0 focus-visible:ring-offset-0">
|
||||
{params.resolution}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[100px]">
|
||||
{RESOLUTION_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.value} onClick={() => updateParam("resolution", option.value)}>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.resolution === option.value ? "opacity-100" : "opacity-0")} />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* 时长 */}
|
||||
<DropdownMenu>
|
||||
@ -509,7 +605,7 @@ const getImageUploadConfig = () => {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[80px]">
|
||||
{[5, 10].map((duration) => (
|
||||
{durationOptions.map((duration) => (
|
||||
<DropdownMenuItem key={duration} onClick={() => updateParam("duration", duration)}>
|
||||
<Check className={cn("mr-2 h-4 w-4", params.duration === duration ? "opacity-100" : "opacity-0")} />
|
||||
{duration}s
|
||||
@ -518,56 +614,68 @@ const getImageUploadConfig = () => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 帧率 (新增) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-1 px-2 h-8 focus-visible:ring-0 focus-visible:ring-offset-0">
|
||||
{params.fps || 24}fps
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[100px]">
|
||||
{FPS_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => updateParam("fps", option.value as VideoGenerationParams["fps"])}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
(params.fps || 24) === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{isFalVideo ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1 px-2 h-8 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
onClick={() => updateParam("generateAudio", !(params.generateAudio ?? true))}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
{params.generateAudio === false ? "静音" : "带音频"}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-1 px-2 h-8 focus-visible:ring-0 focus-visible:ring-offset-0">
|
||||
{params.fps || 24}fps
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[100px]">
|
||||
{FPS_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => updateParam("fps", option.value as VideoGenerationParams["fps"])}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
(params.fps || 24) === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 运镜(改为下拉菜单,与官方一致) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-1 px-2 h-8 focus-visible:ring-0 focus-visible:ring-offset-0">
|
||||
<Camera size={14} />
|
||||
{getCurrentCameraLabel()}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[140px]">
|
||||
{CAMERA_MOVE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
updateParam("cameraMove", option.value);
|
||||
updateParam("cameraFixed", option.value === "static");
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", params.cameraMove === option.value ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-1 px-2 h-8 focus-visible:ring-0 focus-visible:ring-offset-0">
|
||||
<Camera size={14} />
|
||||
{getCurrentCameraLabel()}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[140px]">
|
||||
{CAMERA_MOVE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
updateParam("cameraMove", option.value);
|
||||
updateParam("cameraFixed", option.value === "static");
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", params.cameraMove === option.value ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 高级选项(隐藏水印,默认关闭) */}
|
||||
@ -590,6 +698,16 @@ const getImageUploadConfig = () => {
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
{isFalVideo && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">生成音频</Label>
|
||||
<Switch
|
||||
checked={params.generateAudio ?? true}
|
||||
onCheckedChange={(checked) => updateParam("generateAudio", checked)}
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">随机种子</Label>
|
||||
<Input
|
||||
@ -609,4 +727,3 @@ const getImageUploadConfig = () => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ import { useEffect, useState, useCallback } from "react";
|
||||
export interface PendingGeneration {
|
||||
chatId: string;
|
||||
modelId: string;
|
||||
modelType: "gemini" | "volcano-vision" | "volcano-video" | "qianwen-video" | "alibaba-image" | "other";
|
||||
modelType: "gemini" | "volcano-vision" | "volcano-video" | "qianwen-video" | "fal-video" | "alibaba-image" | "other";
|
||||
timestamp: number;
|
||||
userMessageId?: string;
|
||||
}
|
||||
@ -110,4 +110,3 @@ export function usePendingGeneration(chatId: string) {
|
||||
completeGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ import { createVolcanoVision } from "./providers/volcano-vision";
|
||||
import { createAlibabaImage } from "./providers/alibaba-image";
|
||||
import { createRuxaImage } from "./providers/ruxa-image";
|
||||
import { createFalImage } from "./providers/fal-image";
|
||||
import { createFalVideo } from "./providers/fal-video";
|
||||
|
||||
// 缓存已创建的 provider 实例
|
||||
const providerCache = new Map<string, ReturnType<typeof createOpenAI>>();
|
||||
@ -123,6 +124,16 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
|
||||
return qianwenVideoProvider as any;
|
||||
}
|
||||
|
||||
case "fal-video": {
|
||||
// FAL 视频生成服务(Kling O3 等)
|
||||
const falVideoProvider = createFalVideo({
|
||||
baseURL: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
});
|
||||
console.log(`[Provider] Created Fal Video generation provider: ${provider.baseUrl}`);
|
||||
return falVideoProvider as any;
|
||||
}
|
||||
|
||||
case "volcano-vision": {
|
||||
// 火山引擎视觉图片生成服务(Seedream)
|
||||
// 支持文生图、图生图、多图融合等功能
|
||||
@ -237,6 +248,11 @@ export async function getDynamicModel(
|
||||
console.log("[getDynamicModel] Getting Qianwen Video chat model..."); // 调试日志
|
||||
baseModel = providerInstance.chat(model.modelId);
|
||||
console.log("[getDynamicModel] Qianwen Video model retrieved:", baseModel ? "success" : "failed"); // 调试日志
|
||||
} else if (provider.type === "fal-video") {
|
||||
// FAL 视频生成使用 chat() 方法
|
||||
console.log("[getDynamicModel] Getting Fal Video chat model..."); // 调试日志
|
||||
baseModel = providerInstance.chat(model.modelId);
|
||||
console.log("[getDynamicModel] Fal Video model retrieved:", baseModel ? "success" : "failed"); // 调试日志
|
||||
} else if (provider.type === "volcano-vision") {
|
||||
// 火山引擎视觉生成使用 chat() 方法
|
||||
console.log("[getDynamicModel] Getting Volcano Vision chat model..."); // 调试日志
|
||||
|
||||
@ -52,7 +52,7 @@ const INTENT_MODEL_MAP: Record<
|
||||
|
||||
// 视频生成
|
||||
video_gen: {
|
||||
providerTypes: ["volcano-video", "qianwen-video"],
|
||||
providerTypes: ["volcano-video", "qianwen-video", "fal-video"],
|
||||
description: "视频生成",
|
||||
},
|
||||
|
||||
|
||||
291
lib/ai/providers/fal-video.ts
Normal file
291
lib/ai/providers/fal-video.ts
Normal file
@ -0,0 +1,291 @@
|
||||
import "server-only";
|
||||
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
interface FalVideoConfig {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
}
|
||||
|
||||
interface FalVideoParams {
|
||||
mode?: "text-to-video" | "first-frame" | "first-last-frame" | "reference-images";
|
||||
ratio?: string;
|
||||
duration?: number;
|
||||
generateAudio?: boolean;
|
||||
negativePrompt?: string;
|
||||
cfgScale?: number;
|
||||
shotType?: "customize" | "intelligent";
|
||||
}
|
||||
|
||||
const DEFAULT_DURATION = 5;
|
||||
const DEFAULT_RATIO = "16:9";
|
||||
const DEFAULT_SHOT_TYPE = "customize";
|
||||
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function normalizeFalCredentials(apiKey: string) {
|
||||
return apiKey.trim().replace(/^bearer\s+/i, "").replace(/^key\s+/i, "");
|
||||
}
|
||||
|
||||
function normalizeDuration(value?: number) {
|
||||
if (!Number.isFinite(value)) return DEFAULT_DURATION;
|
||||
return Math.max(3, Math.min(15, Math.round(value as number)));
|
||||
}
|
||||
|
||||
function normalizeRatio(value?: string) {
|
||||
if (!value) return DEFAULT_RATIO;
|
||||
const ratio = value.trim();
|
||||
if (["16:9", "9:16", "1:1"].includes(ratio)) return ratio;
|
||||
return DEFAULT_RATIO;
|
||||
}
|
||||
|
||||
function normalizeShotType(value?: string) {
|
||||
if (value === "intelligent") return "intelligent";
|
||||
return DEFAULT_SHOT_TYPE;
|
||||
}
|
||||
|
||||
function extractPromptAndImages(messages: Array<{ role: string; content: any }>) {
|
||||
const lastUserMessage = messages.filter((message) => message.role === "user").pop();
|
||||
if (!lastUserMessage) {
|
||||
throw new Error("No user message found");
|
||||
}
|
||||
|
||||
let prompt = "";
|
||||
const imageUrls: string[] = [];
|
||||
|
||||
const pushImageUrl = (url?: string) => {
|
||||
if (!url) return;
|
||||
const normalized = String(url).trim();
|
||||
|
||||
if (normalized.startsWith("data:")) {
|
||||
throw new Error("FAL 视频模型不支持 Base64 图片,请先上传图片后再生成视频");
|
||||
}
|
||||
|
||||
if (!normalized.startsWith("http")) return;
|
||||
if (!imageUrls.includes(normalized)) {
|
||||
imageUrls.push(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof lastUserMessage.content === "string") {
|
||||
prompt = lastUserMessage.content.trim();
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!prompt) {
|
||||
prompt = "生成视频";
|
||||
}
|
||||
|
||||
return { prompt, imageUrls };
|
||||
}
|
||||
|
||||
function extractVideoUrl(payload: any): string | undefined {
|
||||
if (!payload) return undefined;
|
||||
|
||||
if (typeof payload === "string" && payload.startsWith("http")) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (typeof payload !== "object") return undefined;
|
||||
|
||||
const direct =
|
||||
payload?.video?.url ||
|
||||
payload?.output?.video?.url ||
|
||||
payload?.data?.video?.url ||
|
||||
payload?.video_url ||
|
||||
payload?.output?.video_url ||
|
||||
payload?.data?.video_url;
|
||||
|
||||
if (typeof direct === "string" && direct.startsWith("http")) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
const nested = extractVideoUrl(item);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const value of Object.values(payload)) {
|
||||
const nested = extractVideoUrl(value);
|
||||
if (nested) return nested;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function downloadAndUploadVideo(videoUrl: string): Promise<string> {
|
||||
try {
|
||||
const response = await fetch(videoUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download video: ${response.status}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const filename = `fal-video-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`;
|
||||
const uploadResult = await uploadFile(filename, buffer as any);
|
||||
return uploadResult.url;
|
||||
} catch (error) {
|
||||
console.error("[Fal Video] Failed to upload video to storage, fallback original URL", error);
|
||||
return videoUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function formatVideoResponse(videoUrl: string): string {
|
||||
return `<video src="${videoUrl}" controls style="max-width: 100%; border-radius: 8px; margin-bottom: 12px;"></video>\n\n[下载视频](${videoUrl})`;
|
||||
}
|
||||
|
||||
function buildFalInput(params: {
|
||||
prompt: string;
|
||||
imageUrls: string[];
|
||||
videoParams?: FalVideoParams;
|
||||
}) {
|
||||
const { prompt, imageUrls, videoParams } = params;
|
||||
const mode = videoParams?.mode || "first-frame";
|
||||
|
||||
const input: Record<string, any> = {
|
||||
prompt,
|
||||
duration: String(normalizeDuration(videoParams?.duration)),
|
||||
aspect_ratio: normalizeRatio(videoParams?.ratio),
|
||||
shot_type: normalizeShotType(videoParams?.shotType),
|
||||
};
|
||||
|
||||
if (typeof videoParams?.generateAudio === "boolean") {
|
||||
input.generate_audio = videoParams.generateAudio;
|
||||
}
|
||||
|
||||
if (videoParams?.negativePrompt?.trim()) {
|
||||
input.negative_prompt = videoParams.negativePrompt.trim();
|
||||
}
|
||||
|
||||
if (typeof videoParams?.cfgScale === "number" && Number.isFinite(videoParams.cfgScale)) {
|
||||
input.cfg_scale = videoParams.cfgScale;
|
||||
}
|
||||
|
||||
if (mode === "first-last-frame") {
|
||||
if (!imageUrls[0] || !imageUrls[1]) {
|
||||
throw new Error("请至少上传 2 张图片(首帧 + 尾帧)");
|
||||
}
|
||||
input.image_url = imageUrls[0];
|
||||
input.end_image_url = imageUrls[1];
|
||||
return input;
|
||||
}
|
||||
|
||||
if (!imageUrls[0]) {
|
||||
throw new Error("FAL O3 图生视频需要至少 1 张起始图片");
|
||||
}
|
||||
|
||||
input.image_url = imageUrls[0];
|
||||
if (imageUrls[1]) {
|
||||
input.end_image_url = imageUrls[1];
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
export function createFalVideo({ apiKey, baseURL }: FalVideoConfig) {
|
||||
return {
|
||||
provider: "fal-video",
|
||||
chat: (modelId: string) => ({
|
||||
provider: "fal-video",
|
||||
doGenerate: async (options: {
|
||||
messages: Array<{ role: string; content: any }>;
|
||||
videoParams?: FalVideoParams;
|
||||
overrideModelId?: string;
|
||||
}) => {
|
||||
try {
|
||||
const activeModelId = options.overrideModelId || modelId;
|
||||
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
|
||||
const input = buildFalInput({
|
||||
prompt,
|
||||
imageUrls,
|
||||
videoParams: options.videoParams,
|
||||
});
|
||||
|
||||
console.log("[Fal Video] Submitting task", {
|
||||
model: activeModelId,
|
||||
baseURL,
|
||||
duration: input.duration,
|
||||
aspect_ratio: input.aspect_ratio,
|
||||
hasEndFrame: Boolean(input.end_image_url),
|
||||
});
|
||||
|
||||
const fal = await import("@fal-ai/serverless-client");
|
||||
fal.config({
|
||||
credentials: normalizeFalCredentials(apiKey),
|
||||
});
|
||||
|
||||
const result: any = await fal.subscribe(activeModelId, {
|
||||
input,
|
||||
logs: true,
|
||||
timeout: DEFAULT_TIMEOUT_MS,
|
||||
onQueueUpdate: (update: any) => {
|
||||
if (update?.status === "IN_PROGRESS" && Array.isArray(update.logs)) {
|
||||
for (const log of update.logs) {
|
||||
if (log?.message) {
|
||||
console.log(`[Fal Video][${activeModelId}]`, log.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const remoteVideoUrl = extractVideoUrl(result) || extractVideoUrl(result?.data);
|
||||
if (!remoteVideoUrl) {
|
||||
throw new Error("FAL 未返回视频地址");
|
||||
}
|
||||
|
||||
const hostedVideoUrl = await downloadAndUploadVideo(remoteVideoUrl);
|
||||
const text = formatVideoResponse(hostedVideoUrl);
|
||||
|
||||
return {
|
||||
text,
|
||||
finishReason: "stop" as const,
|
||||
usage: {
|
||||
promptTokens: 0,
|
||||
completionTokens: text.length,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Fal Video] Generation failed", error);
|
||||
const errorText =
|
||||
error instanceof Error ? `视频生成失败: ${error.message}` : "视频生成失败,请稍后重试";
|
||||
return {
|
||||
text: errorText,
|
||||
finishReason: "error" as const,
|
||||
usage: {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
doStream: async function* (options: {
|
||||
messages: Array<{ role: string; content: any }>;
|
||||
videoParams?: FalVideoParams;
|
||||
overrideModelId?: string;
|
||||
}) {
|
||||
const result = await this.doGenerate(options);
|
||||
yield {
|
||||
type: "text-delta",
|
||||
textDelta: result.text,
|
||||
};
|
||||
},
|
||||
modelId,
|
||||
specificationVersion: "v1" as const,
|
||||
defaultObjectGenerationMode: "json" as const,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -67,7 +67,7 @@ const runMigrate = async () => {
|
||||
ADD CONSTRAINT "AiProvider_type_check"
|
||||
CHECK (type IN (
|
||||
'alibaba', 'openai', 'custom', 'local', 'gemini',
|
||||
'huanke-gemini', 'qianwen-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'ruxa-image', 'fal-image', 'zhipu'
|
||||
'huanke-gemini', 'qianwen-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'ruxa-image', 'fal-image', 'fal-video', 'zhipu'
|
||||
));
|
||||
`);
|
||||
const end = Date.now();
|
||||
|
||||
2
lib/db/migrations/0037_add_ai_model_avatar.sql
Normal file
2
lib/db/migrations/0037_add_ai_model_avatar.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "AiModel"
|
||||
ADD COLUMN IF NOT EXISTS "avatarUrl" text;
|
||||
@ -162,6 +162,13 @@
|
||||
"when": 1771209600000,
|
||||
"tag": "0036_add_payment_orders",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1771317600000,
|
||||
"tag": "0037_add_ai_model_avatar",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1088,7 +1088,7 @@ export async function createAiProvider({
|
||||
updatedBy,
|
||||
}: {
|
||||
name: string;
|
||||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "zhipu";
|
||||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu";
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
isEnabled?: boolean;
|
||||
@ -1126,7 +1126,7 @@ export async function updateAiProvider({
|
||||
}: {
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "zhipu";
|
||||
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu";
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
isEnabled?: boolean;
|
||||
@ -1265,6 +1265,7 @@ export async function createAiModel({
|
||||
providerId,
|
||||
modelId,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
description,
|
||||
supportsVision,
|
||||
supportsImageGen,
|
||||
@ -1279,6 +1280,7 @@ export async function createAiModel({
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
description?: string;
|
||||
supportsVision?: boolean;
|
||||
supportsImageGen?: boolean;
|
||||
@ -1305,6 +1307,7 @@ export async function createAiModel({
|
||||
providerId,
|
||||
modelId,
|
||||
displayName,
|
||||
avatarUrl: avatarUrl || null,
|
||||
description,
|
||||
supportsVision: supportsVision ?? false,
|
||||
supportsImageGen: supportsImageGen ?? false,
|
||||
@ -1333,6 +1336,7 @@ export async function updateAiModel({
|
||||
providerId,
|
||||
modelId,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
description,
|
||||
supportsVision,
|
||||
supportsImageGen,
|
||||
@ -1348,6 +1352,7 @@ export async function updateAiModel({
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
description?: string;
|
||||
supportsVision?: boolean;
|
||||
supportsImageGen?: boolean;
|
||||
@ -1372,6 +1377,7 @@ export async function updateAiModel({
|
||||
if (providerId !== undefined) updateData.providerId = providerId;
|
||||
if (modelId !== undefined) updateData.modelId = modelId;
|
||||
if (displayName !== undefined) updateData.displayName = displayName;
|
||||
if (avatarUrl !== undefined) updateData.avatarUrl = avatarUrl || null;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (supportsVision !== undefined) updateData.supportsVision = supportsVision;
|
||||
if (supportsImageGen !== undefined)
|
||||
|
||||
@ -258,7 +258,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", "huanke-gemini", "qianwen-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "zhipu"] })
|
||||
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "huanke-gemini", "qianwen-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "fal-video", "zhipu"] })
|
||||
.notNull()
|
||||
.default("custom"), // 提供商类型
|
||||
baseUrl: text("baseUrl").notNull(), // API 基础 URL
|
||||
@ -279,6 +279,7 @@ export const aiModel = pgTable("AiModel", {
|
||||
.references(() => aiProvider.id, { onDelete: "cascade" }),
|
||||
modelId: varchar("modelId", { length: 128 }).notNull(), // 模型 ID,如 "qwen-turbo"
|
||||
displayName: varchar("displayName", { length: 128 }).notNull(), // 显示名称
|
||||
avatarUrl: text("avatarUrl"), // 模型头像(建议 400x400)
|
||||
description: text("description"), // 模型描述
|
||||
supportsVision: boolean("supportsVision").notNull().default(false), // 是否支持图片输入
|
||||
supportsImageGen: boolean("supportsImageGen").notNull().default(false), // 是否支持图片生成
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user