新增FAL视频服务商并优化模型选择弹窗与头像配置
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-14 20:53:47 +08:00
parent ed9a03b1c7
commit 10b44ddfcd
21 changed files with 1028 additions and 278 deletions

View File

@ -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,

View File

@ -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 }) => {

View File

@ -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(),

View File

@ -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,

View File

@ -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(),

View File

@ -22,6 +22,7 @@ const providerTypes = [
"alibaba-image",
"ruxa-image",
"fal-image",
"fal-video",
"zhipu",
] as const;
type ProviderType = (typeof providerTypes)[number];

View File

@ -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>

View File

@ -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);

View File

@ -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>

View File

@ -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";

View File

@ -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>
))}

View File

@ -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>
);
}

View File

@ -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,
};
}

View File

@ -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..."); // 调试日志

View File

@ -52,7 +52,7 @@ const INTENT_MODEL_MAP: Record<
// 视频生成
video_gen: {
providerTypes: ["volcano-video", "qianwen-video"],
providerTypes: ["volcano-video", "qianwen-video", "fal-video"],
description: "视频生成",
},

View 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,
}),
};
}

View File

@ -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();

View File

@ -0,0 +1,2 @@
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "avatarUrl" text;

View File

@ -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
}
]
}

View File

@ -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)

View File

@ -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), // 是否支持图片生成