diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 3da45bb46..ac1a7fbdb 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -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(null); + const [modelAvatarUploading, setModelAvatarUploading] = useState(false); // 🆕 火山引擎视频多模型表单(动态从API加载) const [volcanoVideoModels, setVolcanoVideoModels] = useState => + 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 })} /> +
+ +
+
+ {modelForm.avatarUrl ? ( + 模型头像预览 + ) : ( +
+ 400x400 +
+ )} +
+
+ setModelForm({ ...modelForm, avatarUrl: e.target.value })} + placeholder="头像 URL(推荐 400x400)" + /> + + { + const file = event.target.files?.[0]; + if (file) { + await handleModelAvatarUpload(file); + } + event.target.value = ""; + }} + /> +
+
+

上传后会自动裁切并压缩为 400x400 PNG。

+