feat(canvas): 添加视频模型配置和生成功能
- 在系统配置中新增 defaultVideoModel 字段用于设置默认视频模型 - 更新应用配置模态框以支持视频模型的选择和验证 - 修改画布节点逻辑以支持视频类型的生成配置 - 实现视频节点的生成和上传功能 - 更新模型选择器组件以支持视频模型选择 - 在管理后台设置页面添加视频模型配置选项 - 优化模型选择器的层级和交互体验 - 添加对 DeepSeek 和 GLM 模型图标的支持
This commit is contained in:
@@ -30,6 +30,7 @@ const emptySettings: AdminSettings = {
|
||||
availableModels: [],
|
||||
defaultModel: "",
|
||||
defaultImageModel: "",
|
||||
defaultVideoModel: "",
|
||||
defaultTextModel: "",
|
||||
systemPrompt: "",
|
||||
allowCustomChannel: true,
|
||||
@@ -315,17 +316,22 @@ export default function AdminSettingsPage() {
|
||||
<Select mode="tags" tokenSeparators={[",", "\n"]} options={modelOptions.map((item) => ({ label: item, value: item }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name={["public", "modelChannel", "defaultModel"]} label="默认模型">
|
||||
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name={["public", "modelChannel", "defaultImageModel"]} label="默认图片模型">
|
||||
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name={["public", "modelChannel", "defaultVideoModel"]} label="默认视频模型">
|
||||
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name={["public", "modelChannel", "defaultTextModel"]} label="默认文本模型">
|
||||
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -1819,7 +1819,7 @@ function InfiniteCanvasPage() {
|
||||
size: savedImageMetadata.size || effectiveConfig.size,
|
||||
count: "1",
|
||||
}
|
||||
: { ...buildGenerationConfig(effectiveConfig, sourceNode, node.type === CanvasNodeType.Text ? "text" : "image"), count: "1" };
|
||||
: { ...buildGenerationConfig(effectiveConfig, sourceNode, node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : "image"), count: "1" };
|
||||
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
|
||||
openConfigDialog(true);
|
||||
return;
|
||||
@@ -1855,6 +1855,11 @@ function InfiniteCanvasPage() {
|
||||
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: answer || streamed, prompt, status: NODE_STATUS_SUCCESS } } : item)));
|
||||
return;
|
||||
}
|
||||
if (node.type === CanvasNodeType.Video) {
|
||||
const video = await uploadMediaFile(await requestVideoGeneration(generationConfig, prompt), "video");
|
||||
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, ...videoMetadata(video), prompt, model: generationConfig.model, size: generationConfig.size } } : item)));
|
||||
return;
|
||||
}
|
||||
|
||||
const image = useReferenceImages ? await requestEdit(generationConfig, prompt, retryReferenceImages).then((items) => items[0]) : await requestGeneration(generationConfig, prompt).then((items) => items[0]);
|
||||
const uploadedImage = await uploadImage(image.dataUrl);
|
||||
|
||||
@@ -461,7 +461,7 @@ function VideoNodeContent({ node, theme }: NodeContentRendererProps) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
|
||||
<Video className="size-7 opacity-35" />
|
||||
<span className="text-sm">视频节点</span>
|
||||
<span className="text-sm">视频节点(未开发完,请勿使用)</span>
|
||||
</div>
|
||||
);
|
||||
return <video src={node.metadata.content} controls className="h-full w-full rounded-[18px] bg-black object-contain" data-canvas-no-zoom />;
|
||||
|
||||
@@ -26,7 +26,7 @@ export function AppConfigModal() {
|
||||
const finishConfig = () => {
|
||||
setConfigDialogOpen(false);
|
||||
if (effectiveMode === "local" && (!config.baseUrl.trim() || !config.apiKey.trim())) return;
|
||||
if (!modelConfig.imageModel.trim() || !modelConfig.textModel.trim()) return;
|
||||
if (!modelConfig.imageModel.trim() || !modelConfig.videoModel.trim() || !modelConfig.textModel.trim()) return;
|
||||
if (!allowCustomChannel && config.channelMode !== "remote") updateConfig("channelMode", "remote");
|
||||
message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存");
|
||||
clearPromptContinue();
|
||||
@@ -43,6 +43,7 @@ export function AppConfigModal() {
|
||||
const models = await fetchImageModels(config);
|
||||
updateConfig("models", models);
|
||||
if (models.length && !models.includes(config.imageModel)) updateConfig("imageModel", models[0]);
|
||||
if (models.length && !models.includes(config.videoModel)) updateConfig("videoModel", models[0]);
|
||||
if (models.length && !models.includes(config.textModel)) updateConfig("textModel", models[0]);
|
||||
message.success("模型列表已更新");
|
||||
} catch (error) {
|
||||
@@ -112,10 +113,13 @@ export function AppConfigModal() {
|
||||
<div className="mt-1">由系统后台渠道转发请求,当前可用 {modelChannel?.availableModels.length || 0} 个模型。</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Form.Item label="默认生图模型" className="mb-4">
|
||||
<ModelPicker config={modelConfig} value={modelConfig.imageModel} onChange={(model) => updateConfig("imageModel", model)} fullWidth />
|
||||
</Form.Item>
|
||||
<Form.Item label="默认视频模型" className="mb-4">
|
||||
<ModelPicker config={modelConfig} value={modelConfig.videoModel} onChange={(model) => updateConfig("videoModel", model)} fullWidth />
|
||||
</Form.Item>
|
||||
<Form.Item label="默认文本模型" className="mb-4">
|
||||
<ModelPicker config={modelConfig} value={modelConfig.textModel} onChange={(model) => updateConfig("textModel", model)} fullWidth />
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { Cpu } from "lucide-react";
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
@@ -18,10 +18,19 @@ type ModelPickerProps = {
|
||||
};
|
||||
|
||||
export function ModelPicker({ config, value, onChange, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) {
|
||||
const pickerId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const options = useMemo(() => Array.from(new Set([value, ...config.models].filter(Boolean))), [config.models, value]);
|
||||
const current = value || "";
|
||||
|
||||
useEffect(() => {
|
||||
const closeOtherPicker = (event: Event) => {
|
||||
if ((event as CustomEvent<string>).detail !== pickerId) setOpen(false);
|
||||
};
|
||||
window.addEventListener("model-picker-open", closeOtherPicker);
|
||||
return () => window.removeEventListener("model-picker-open", closeOtherPicker);
|
||||
}, [pickerId]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
open={open}
|
||||
@@ -31,6 +40,7 @@ export function ModelPicker({ config, value, onChange, className, fullWidth = fa
|
||||
onMissingConfig?.();
|
||||
return;
|
||||
}
|
||||
if (nextOpen) window.dispatchEvent(new CustomEvent("model-picker-open", { detail: pickerId }));
|
||||
setOpen(nextOpen);
|
||||
}}
|
||||
onValueChange={onChange}
|
||||
@@ -51,9 +61,11 @@ export function ModelPicker({ config, value, onChange, className, fullWidth = fa
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
data-canvas-no-zoom
|
||||
className="z-50 w-80 max-w-[calc(100vw-24px)] rounded-xl border border-border/70 bg-popover p-1 shadow-xl"
|
||||
className="z-[1200] w-80 max-w-[calc(100vw-24px)] rounded-xl border border-border/70 bg-popover p-1 shadow-xl"
|
||||
position="popper"
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
@@ -93,5 +105,7 @@ function resolveModelIcon(model: string) {
|
||||
if (name.includes("gemini") || name.includes("google")) return "/icons/gemini.svg";
|
||||
if (name.includes("gpt") || name.includes("openai")) return "/icons/openai.svg";
|
||||
if (name.includes("grok") || name.includes("grok")) return "/icons/grok.svg";
|
||||
if (name.includes("deepseek") || name.includes("deepseek")) return "/icons/deepseek.svg";
|
||||
if (name.includes("glm") || name.includes("glm")) return "/icons/glm.svg";
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||
className={cn("relative z-50 max-h-[min(var(--radix-select-content-available-height),18rem)] min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
|
||||
@@ -97,6 +97,7 @@ export type AdminPublicModelChannelSettings = {
|
||||
availableModels: string[];
|
||||
defaultModel: string;
|
||||
defaultImageModel: string;
|
||||
defaultVideoModel: string;
|
||||
defaultTextModel: string;
|
||||
systemPrompt: string;
|
||||
allowCustomChannel: boolean;
|
||||
|
||||
@@ -13,13 +13,14 @@ function aiHeaders(config: AiConfig) {
|
||||
}
|
||||
|
||||
export async function requestVideoGeneration(config: AiConfig, prompt: string) {
|
||||
const created = await axios.post<VideoResponse>(aiApiUrl(config, "/videos"), { model: config.model, prompt, size: config.size || undefined }, { headers: { ...(aiHeaders(config) || {}), "Content-Type": "application/json" } });
|
||||
const model = config.model || config.videoModel;
|
||||
const created = await axios.post<VideoResponse>(aiApiUrl(config, "/videos"), { model, prompt, size: config.size || undefined }, { headers: { ...(aiHeaders(config) || {}), "Content-Type": "application/json" } });
|
||||
for (;;) {
|
||||
const video = await axios.get<VideoResponse>(aiApiUrl(config, `/videos/${created.data.id}`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: config.model } : undefined });
|
||||
const video = await axios.get<VideoResponse>(aiApiUrl(config, `/videos/${created.data.id}`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined });
|
||||
if (video.data.status === "completed") break;
|
||||
if (video.data.status === "failed" || video.data.status === "cancelled") throw new Error(video.data.error?.message || "视频生成失败");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||||
}
|
||||
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${created.data.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: config.model } : undefined, responseType: "blob" });
|
||||
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${created.data.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined, responseType: "blob" });
|
||||
return content.data;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ function resolveEffectiveConfig(config: AiConfig, modelChannel: AdminPublicSetti
|
||||
models,
|
||||
model: models.includes(config.model) ? config.model : modelChannel.defaultModel,
|
||||
imageModel: models.includes(config.imageModel) ? config.imageModel : modelChannel.defaultImageModel || modelChannel.defaultModel,
|
||||
videoModel: models.includes(config.videoModel) ? config.videoModel : "sora-2",
|
||||
videoModel: models.includes(config.videoModel) ? config.videoModel : modelChannel.defaultVideoModel || modelChannel.defaultModel || "sora-2",
|
||||
textModel: models.includes(config.textModel) ? config.textModel : modelChannel.defaultTextModel || modelChannel.defaultModel,
|
||||
systemPrompt: modelChannel.systemPrompt,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user