398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import useSWR from "swr";
|
||
import { toast } from "sonner";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
isImageGenerationModelDescriptor,
|
||
isVideoModelDescriptor,
|
||
} from "@/lib/ai/model-classification";
|
||
import { fetcher } from "@/lib/utils";
|
||
import {
|
||
getVideoCanvasFunctionBillingCopy,
|
||
type VideoCanvasFunctionConfig,
|
||
type VideoCanvasFunctionDefinition,
|
||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||
defaultVideoCanvasFunctionConfig,
|
||
normalizeVideoCanvasFunctionConfig,
|
||
} from "@/lib/video-canvas/config";
|
||
|
||
type SettingsResponse = {
|
||
setting?: {
|
||
value?: unknown;
|
||
};
|
||
};
|
||
|
||
type AiModelOption = {
|
||
id: string;
|
||
modelId: string;
|
||
displayName: string;
|
||
providerName?: string;
|
||
providerType?: string;
|
||
supportsVision?: boolean;
|
||
supportsImageGen?: boolean;
|
||
isEnabled?: boolean;
|
||
isHidden?: boolean;
|
||
isMultimodal?: boolean;
|
||
capabilities?: unknown;
|
||
};
|
||
|
||
type AiModelsResponse = {
|
||
models?: AiModelOption[];
|
||
};
|
||
|
||
const INHERIT_MODEL_VALUE = "__inherit__";
|
||
|
||
const functionKindLabels: Record<VideoCanvasFunctionDefinition["kind"], string> = {
|
||
audio: "音频",
|
||
image: "图片生成",
|
||
"image-edit": "图片编辑",
|
||
multimodal: "多模态",
|
||
utility: "工具",
|
||
video: "视频生成",
|
||
workflow: "工作流",
|
||
};
|
||
|
||
function getModelOptionValue(model: AiModelOption) {
|
||
return String(model.modelId || model.id || "").trim();
|
||
}
|
||
|
||
function getModelOptionLabel(model: AiModelOption) {
|
||
const name = model.displayName || model.modelId || model.id;
|
||
const id = model.modelId && model.modelId !== name ? ` · ${model.modelId}` : "";
|
||
return `${name}${id}`;
|
||
}
|
||
|
||
function isImageModelOption(model: AiModelOption) {
|
||
return isImageGenerationModelDescriptor({
|
||
providerType: model.providerType,
|
||
modelId: model.modelId,
|
||
capabilities: model.capabilities,
|
||
supportsImageGen: model.supportsImageGen,
|
||
});
|
||
}
|
||
|
||
function isVideoModelOption(model: AiModelOption) {
|
||
return isVideoModelDescriptor({
|
||
providerType: model.providerType,
|
||
modelId: model.modelId,
|
||
capabilities: model.capabilities,
|
||
supportsImageGen: model.supportsImageGen,
|
||
});
|
||
}
|
||
|
||
function filterModelsForFunctionKind(
|
||
models: AiModelOption[],
|
||
kind: VideoCanvasFunctionDefinition["kind"],
|
||
) {
|
||
const enabled = models.filter((model) => model.isEnabled !== false && getModelOptionValue(model));
|
||
let filtered = enabled;
|
||
if (kind === "image" || kind === "image-edit") {
|
||
filtered = enabled.filter(isImageModelOption);
|
||
} else if (kind === "video") {
|
||
filtered = enabled.filter(isVideoModelOption);
|
||
} else if (kind === "multimodal") {
|
||
filtered = enabled.filter(
|
||
(model) => model.isMultimodal || model.supportsVision || (!isImageModelOption(model) && !isVideoModelOption(model)),
|
||
);
|
||
}
|
||
return filtered.length ? filtered : enabled;
|
||
}
|
||
|
||
function ModelSelect({
|
||
allowInherit = false,
|
||
kind,
|
||
models,
|
||
onChange,
|
||
value,
|
||
}: {
|
||
allowInherit?: boolean;
|
||
kind: VideoCanvasFunctionDefinition["kind"];
|
||
models: AiModelOption[];
|
||
onChange: (value: string) => void;
|
||
value: string;
|
||
}) {
|
||
const filteredModels = filterModelsForFunctionKind(models, kind);
|
||
const normalizedValue = value?.trim() || (allowInherit ? INHERIT_MODEL_VALUE : "auto");
|
||
const hasCurrent =
|
||
normalizedValue === "auto" ||
|
||
normalizedValue === INHERIT_MODEL_VALUE ||
|
||
filteredModels.some((model) => getModelOptionValue(model) === normalizedValue);
|
||
|
||
return (
|
||
<Select
|
||
onValueChange={(nextValue) => {
|
||
if (nextValue === INHERIT_MODEL_VALUE) {
|
||
onChange("");
|
||
return;
|
||
}
|
||
onChange(nextValue);
|
||
}}
|
||
value={normalizedValue}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择模型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{allowInherit ? (
|
||
<SelectItem value={INHERIT_MODEL_VALUE}>继承全局默认模型</SelectItem>
|
||
) : (
|
||
<SelectItem value="auto">自动选择可用模型</SelectItem>
|
||
)}
|
||
{!hasCurrent && normalizedValue ? (
|
||
<SelectItem value={normalizedValue}>当前保存值:{normalizedValue}</SelectItem>
|
||
) : null}
|
||
{filteredModels.map((model) => {
|
||
const optionValue = getModelOptionValue(model);
|
||
return (
|
||
<SelectItem key={`${model.id}-${optionValue}`} value={optionValue}>
|
||
{getModelOptionLabel(model)}
|
||
</SelectItem>
|
||
);
|
||
})}
|
||
{filteredModels.length === 0 ? (
|
||
<SelectItem disabled value="__no_models__">
|
||
暂无已启用模型
|
||
</SelectItem>
|
||
) : null}
|
||
</SelectContent>
|
||
</Select>
|
||
);
|
||
}
|
||
|
||
export function VideoCanvasSettingsClient() {
|
||
const { data, isLoading, mutate } = useSWR<SettingsResponse>(
|
||
`/api/admin/settings?key=${VIDEO_CANVAS_FUNCTION_CONFIG_KEY}`,
|
||
fetcher,
|
||
);
|
||
const { data: aiModelsData } = useSWR<AiModelsResponse>("/api/admin/ai-models", fetcher);
|
||
const [config, setConfig] = useState<VideoCanvasFunctionConfig>(defaultVideoCanvasFunctionConfig);
|
||
const [saving, setSaving] = useState(false);
|
||
const modelOptions = useMemo(
|
||
() => (aiModelsData?.models || []).filter((model) => model.isEnabled !== false),
|
||
[aiModelsData?.models],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!data) return;
|
||
setConfig(normalizeVideoCanvasFunctionConfig(data.setting?.value));
|
||
}, [data]);
|
||
|
||
const functions = useMemo(
|
||
() =>
|
||
Object.values(config.functions).sort((a, b) => {
|
||
const kindCompare = a.kind.localeCompare(b.kind);
|
||
return kindCompare || a.label.localeCompare(b.label);
|
||
}),
|
||
[config.functions],
|
||
);
|
||
|
||
const updateRoot = (updates: Partial<VideoCanvasFunctionConfig>) => {
|
||
setConfig((current) => ({ ...current, ...updates }));
|
||
};
|
||
|
||
const updateFunction = (
|
||
key: string,
|
||
updates: Partial<VideoCanvasFunctionDefinition>,
|
||
) => {
|
||
setConfig((current) => ({
|
||
...current,
|
||
functions: {
|
||
...current.functions,
|
||
[key]: {
|
||
...current.functions[key],
|
||
...updates,
|
||
},
|
||
},
|
||
}));
|
||
};
|
||
|
||
const save = async () => {
|
||
setSaving(true);
|
||
try {
|
||
const response = await fetch("/api/admin/settings", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||
value: config,
|
||
}),
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
throw new Error(payload?.message || payload?.error || "保存失败");
|
||
}
|
||
toast.success("视频画布功能配置已保存");
|
||
await mutate();
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const resetToDefault = () => {
|
||
setConfig(defaultVideoCanvasFunctionConfig);
|
||
toast.info("已恢复默认配置,保存后生效");
|
||
};
|
||
|
||
return (
|
||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
|
||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-semibold tracking-tight">视频画布功能设置</h1>
|
||
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
|
||
这里集中配置复刻画布里的工具能力、预设提示词和默认模型。积分与扣费统一跟随全站模型/功能配置,不再在视频画布里单独维护一套价格。
|
||
</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button disabled={saving || isLoading} onClick={resetToDefault} type="button" variant="outline">
|
||
恢复默认
|
||
</Button>
|
||
<Button disabled={saving || isLoading} onClick={save} type="button">
|
||
{saving ? "保存中..." : "保存配置"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<Card>
|
||
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||
<div>
|
||
<CardTitle className="text-xl">全局模型与开关</CardTitle>
|
||
<CardDescription>
|
||
默认模型从后台已启用模型中选择;保持自动时由各接口按当前可用模型选择。
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<Label htmlFor="video-canvas-enabled">启用视频画布功能串联</Label>
|
||
<Switch
|
||
checked={config.enabled}
|
||
id="video-canvas-enabled"
|
||
onCheckedChange={(checked) => updateRoot({ enabled: checked })}
|
||
/>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="grid gap-4 md:grid-cols-3">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="defaultImageModel">默认图片/编辑模型</Label>
|
||
<ModelSelect
|
||
kind="image-edit"
|
||
models={modelOptions}
|
||
onChange={(value) => updateRoot({ defaultImageModel: value || "auto" })}
|
||
value={config.defaultImageModel}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="defaultVideoModel">默认视频模型</Label>
|
||
<ModelSelect
|
||
kind="video"
|
||
models={modelOptions}
|
||
onChange={(value) => updateRoot({ defaultVideoModel: value || "auto" })}
|
||
value={config.defaultVideoModel}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="defaultMultimodalModel">默认多模态模型</Label>
|
||
<ModelSelect
|
||
kind="multimodal"
|
||
models={modelOptions}
|
||
onChange={(value) => updateRoot({ defaultMultimodalModel: value || "auto" })}
|
||
value={config.defaultMultimodalModel}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<section className="space-y-4">
|
||
<div>
|
||
<h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2>
|
||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||
视频画布运行时会通过 /api/video-canvas/config 读取这里保存的提示词。比如“多机位九宫格”的分镜规则、黑色分割线要求和镜头说明,都由管理员在这里维护,前端不再写死覆盖。
|
||
</p>
|
||
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
|
||
计费说明只展示来源,不在这里改价;需要调整积分请回到全站积分设置、模型设置或对应功能设置统一修改。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid gap-4">
|
||
{functions.map((item) => (
|
||
<Card className={!item.enabled ? "opacity-70" : undefined} key={item.key}>
|
||
<CardHeader className="space-y-4">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||
<div>
|
||
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
|
||
<span>{item.label}</span>
|
||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-normal text-muted-foreground">
|
||
{functionKindLabels[item.kind]}
|
||
</span>
|
||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-normal text-muted-foreground">
|
||
{item.route}
|
||
</span>
|
||
</CardTitle>
|
||
<CardDescription className="mt-2">{item.description}</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<Label htmlFor={`enabled-${item.key}`}>启用</Label>
|
||
<Switch
|
||
checked={item.enabled}
|
||
id={`enabled-${item.key}`}
|
||
onCheckedChange={(checked) => updateFunction(item.key, { enabled: checked })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-4 md:grid-cols-1">
|
||
<div className="space-y-2">
|
||
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
|
||
<ModelSelect
|
||
allowInherit
|
||
kind={item.kind}
|
||
models={modelOptions}
|
||
onChange={(value) => updateFunction(item.key, { model: value })}
|
||
value={item.model || ""}
|
||
/>
|
||
</div>
|
||
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-3 py-2">
|
||
<div className="text-xs font-medium text-foreground">
|
||
积分来源:{getVideoCanvasFunctionBillingCopy(item).shortLabel}
|
||
</div>
|
||
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
||
{getVideoCanvasFunctionBillingCopy(item).description}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Label htmlFor={`prompt-${item.key}`}>管理员可编辑的预设提示词 / 后端规则</Label>
|
||
<Textarea
|
||
className="mt-2 min-h-28"
|
||
id={`prompt-${item.key}`}
|
||
onChange={(event) => updateFunction(item.key, { prompt: event.target.value })}
|
||
value={item.prompt}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|