ai-chatbot/app/(admin)/settings/video-canvas/video-canvas-settings-client.tsx
Your Name 432d315bc6
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: align canvas billing and 3d task routing
2026-04-27 16:56:40 +08:00

398 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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