ai-chatbot/components/agents/AgentPresetDialog.tsx
Your Name 4f5d999ade
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
style: align agent preset dialog with dark workflow
2026-03-18 15:33:57 +08:00

743 lines
27 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, useRef, useState } from "react";
import useSWR from "swr";
import { Loader2, Plus, Trash2, Upload } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn, fetcher } from "@/lib/utils";
import { imageGenerationService } from "@/services/canvas";
import { agentService } from "@/services/agents";
import type {
AgentCreatePayload,
AgentPresetType,
AgentPreset,
AgentPresetSlot,
AgentUpdatePayload,
} from "@/types/agent";
type DialogSlotState = AgentPresetSlot & {
localReferenceAssets: Array<{
assetId: string;
url: string;
name?: string | null;
}>;
};
interface AgentPresetDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
initialPreset?: AgentPreset | null;
onSaved?: (preset: AgentPreset) => void;
entityLabel?: string;
theme?: "light" | "dark";
}
export function AgentPresetDialog({
open,
onOpenChange,
initialPreset,
onSaved,
entityLabel = "智能体",
theme = "light",
}: AgentPresetDialogProps) {
const { data: modelsData } = useSWR<{
models?: Array<{
id: string;
displayName: string;
supportsImageGen?: boolean;
providerType?: string;
isMultimodal?: boolean;
}>;
}>("/api/models", fetcher);
const [name, setName] = useState("");
const [type, setType] = useState<"image" | "video" | "text">("image");
const [visibility, setVisibility] = useState<"public" | "private">("private");
const [defaultModelId, setDefaultModelId] = useState("");
const [description, setDescription] = useState("");
const [avatarAssetId, setAvatarAssetId] = useState<string>("");
const [avatarUrl, setAvatarUrl] = useState<string>("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [uploadingSlotIndex, setUploadingSlotIndex] = useState<number | null>(null);
const [avatarUploading, setAvatarUploading] = useState(false);
const [slots, setSlots] = useState<DialogSlotState[]>([
{
slotIndex: 0,
title: "第1张",
promptTemplate: "",
requireUpload: true,
uploadLabel: "第1张",
defaultParams: null,
referenceAssetIds: [],
localReferenceAssets: [],
},
]);
const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const isDark = theme === "dark";
const entityNameLabel = `${entityLabel}名称`;
const dialogTitle = initialPreset ? `编辑${entityLabel}` : `新建${entityLabel}`;
const dialogDescription = `配置${entityLabel}名称、预览图、模型和槽位提示词。`;
const submitLabel = initialPreset ? `保存${entityLabel}` : `创建${entityLabel}`;
const videoProviderTypes = useMemo(
() => new Set(["volcano-video", "doubao-video", "qianwen-video", "fal-video", "ruxa-video"]),
[]
);
const filteredModels = useMemo(() => {
const models = modelsData?.models || [];
if (type === "video") {
return models.filter((model) => videoProviderTypes.has(String(model.providerType || "")));
}
if (type === "text") {
return models.filter(
(model) =>
model.isMultimodal === true &&
!videoProviderTypes.has(String(model.providerType || ""))
);
}
return models.filter(
(model) =>
model.supportsImageGen && !videoProviderTypes.has(String(model.providerType || ""))
);
}, [modelsData?.models, type, videoProviderTypes]);
useEffect(() => {
if (!open) return;
if (filteredModels.length === 0) {
setDefaultModelId("");
return;
}
if (!filteredModels.some((model) => model.id === defaultModelId)) {
setDefaultModelId(filteredModels[0]?.id || "");
}
}, [defaultModelId, filteredModels, open]);
useEffect(() => {
if (!open) return;
setSlots((current) =>
current.map((slot, index) => ({
...slot,
slotIndex: index,
title: slot.title || `第${index + 1}张`,
uploadLabel: slot.uploadLabel || `第${index + 1}张`,
}))
);
}, [open]);
const resetState = () => {
setName("");
setType("image");
setVisibility("private");
setDefaultModelId("");
setDescription("");
setAvatarAssetId("");
setAvatarUrl("");
setSlots([
{
slotIndex: 0,
title: "第1张",
promptTemplate: "",
requireUpload: true,
uploadLabel: "第1张",
defaultParams: null,
referenceAssetIds: [],
localReferenceAssets: [],
},
]);
setUploadingSlotIndex(null);
};
useEffect(() => {
if (!open) return;
if (!initialPreset) {
resetState();
return;
}
setName(initialPreset.name || "");
setType(initialPreset.type);
setVisibility(initialPreset.visibility);
setDefaultModelId(initialPreset.defaultModelId || "");
setDescription(initialPreset.description || "");
setAvatarAssetId(initialPreset.avatarAssetId || "");
setAvatarUrl(initialPreset.avatarUrl || "");
setSlots(
(initialPreset.slots || []).map((slot, index) => ({
slotIndex: index,
title: slot.title || `第${index + 1}张`,
promptTemplate: slot.promptTemplate || "",
requireUpload: slot.requireUpload !== false,
uploadLabel: slot.uploadLabel || `第${index + 1}张`,
defaultParams: slot.defaultParams || null,
referenceAssetIds: slot.referenceAssetIds || [],
localReferenceAssets: (slot.referenceAssets || []).map((asset) => ({
assetId: asset.assetId,
url: asset.thumbnailUrl || asset.previewUrl || asset.originalUrl,
name: asset.displayName,
})),
}))
);
}, [initialPreset, open]);
const handleSlotChange = (slotIndex: number, updates: Partial<DialogSlotState>) => {
setSlots((current) =>
current.map((slot, index) =>
index === slotIndex ? { ...slot, ...updates, slotIndex: index } : slot
)
);
};
const handleAddSlot = () => {
setSlots((current) => [
...current,
{
slotIndex: current.length,
title: `第${current.length + 1}张`,
promptTemplate: "",
requireUpload: true,
uploadLabel: `第${current.length + 1}张`,
defaultParams: null,
referenceAssetIds: [],
localReferenceAssets: [],
},
]);
};
const handleRemoveSlot = (slotIndex: number) => {
setSlots((current) =>
current
.filter((_, index) => index !== slotIndex)
.map((slot, index) => ({
...slot,
slotIndex: index,
title: slot.title || `第${index + 1}张`,
uploadLabel: slot.uploadLabel || `第${index + 1}张`,
}))
);
};
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setAvatarUploading(true);
try {
const result = await imageGenerationService.uploadImage(file);
if (!result.success || !result.data?.assetId || !result.data.url) {
throw new Error(result.error || "预览图上传失败");
}
setAvatarAssetId(result.data.assetId);
setAvatarUrl(result.data.url);
toast.success("预览图上传成功");
} catch (error) {
toast.error(error instanceof Error ? error.message : "预览图上传失败");
} finally {
setAvatarUploading(false);
event.target.value = "";
}
};
const handleUploadReferenceImages = async (
slotIndex: number,
event: React.ChangeEvent<HTMLInputElement>
) => {
const files = Array.from(event.target.files || []);
if (files.length === 0) return;
setUploadingSlotIndex(slotIndex);
try {
const uploadedAssets: Array<{ assetId: string; url: string; name?: string | null }> = [];
for (const file of files) {
const result = await imageGenerationService.uploadImage(file);
if (!result.success || !result.data?.assetId || !result.data.url) {
throw new Error(result.error || "参考图上传失败");
}
uploadedAssets.push({
assetId: result.data.assetId,
url: result.data.url,
name: file.name,
});
}
setSlots((current) =>
current.map((slot, index) => {
if (index !== slotIndex) return slot;
const nextAssets = [...slot.localReferenceAssets, ...uploadedAssets];
return {
...slot,
referenceAssetIds: nextAssets.map((item) => item.assetId),
localReferenceAssets: nextAssets,
};
})
);
toast.success(
uploadedAssets.length === 1
? "参考图上传成功"
: `已上传 ${uploadedAssets.length} 张参考图`
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "参考图上传失败");
} finally {
setUploadingSlotIndex(null);
event.target.value = "";
}
};
const handleCreate = async () => {
if (!name.trim()) {
toast.error(`请输入${entityNameLabel}`);
return;
}
if (!defaultModelId) {
toast.error("请选择对应模型");
return;
}
if (slots.some((slot) => !slot.promptTemplate.trim())) {
toast.error("每个槽位都需要填写提示词");
return;
}
setIsSubmitting(true);
try {
const basePayload: AgentCreatePayload = {
name: name.trim(),
type,
visibility,
description: description.trim() || undefined,
defaultModelId,
slots: slots.map((slot, index) => ({
slotIndex: index,
title: slot.title || null,
promptTemplate: slot.promptTemplate.trim(),
requireUpload: slot.requireUpload !== false,
uploadLabel: slot.uploadLabel || null,
defaultParams: slot.defaultParams || undefined,
referenceAssetIds: slot.referenceAssetIds || [],
})),
avatarAssetId: avatarAssetId || undefined,
};
const result = initialPreset
? await agentService.updatePreset(initialPreset.id, {
...basePayload,
secondaryType: null,
categoryPath: null,
} satisfies AgentUpdatePayload)
: await agentService.createPreset(basePayload);
if (!result.success || !result.data) {
throw new Error(result.error || (initialPreset ? `更新${entityLabel}失败` : `创建${entityLabel}失败`));
}
toast.success(initialPreset ? `${entityLabel}更新成功` : `${entityLabel}创建成功`);
onSaved?.(result.data);
onOpenChange(false);
resetState();
} catch (error) {
toast.error(error instanceof Error ? error.message : `创建${entityLabel}失败`);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) resetState();
}}
>
<DialogContent
className={cn(
"max-w-4xl opacity-100 shadow-2xl",
isDark
? [
"border border-white/12 bg-[#0b0d0f] text-white shadow-[0_32px_80px_rgba(0,0,0,0.52)]",
"[&>button]:rounded-full [&>button]:border [&>button]:border-white/10 [&>button]:bg-white/[0.03]",
"[&>button]:text-white/58 [&>button]:opacity-100 [&>button:hover]:bg-white/[0.08] [&>button:hover]:text-white",
]
: "border-slate-200 bg-white dark:border-slate-800 dark:bg-zinc-950"
)}
>
<DialogHeader>
<DialogTitle className={cn(isDark ? "text-white" : "")}>{dialogTitle}</DialogTitle>
<DialogDescription className={cn(isDark ? "text-white/48" : "")}>
{dialogDescription}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[72vh] pr-3">
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>预览图</div>
<div className="flex items-start gap-4">
{avatarUrl ? (
<img
src={avatarUrl}
alt={`${entityLabel}预览图`}
className={cn(
"h-24 w-24 rounded-2xl border object-cover shadow-sm",
isDark ? "border-white/10" : ""
)}
/>
) : (
<div
className={cn(
"flex h-24 w-24 items-center justify-center rounded-2xl border text-sm shadow-sm",
isDark
? "border-white/10 bg-black/30 text-white/35"
: "bg-muted text-muted-foreground"
)}
>
未上传
</div>
)}
<label className="inline-flex">
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void handleAvatarUpload(event);
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className={cn(
"h-11 px-5 text-base",
isDark
? "border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
: ""
)}
onClick={() => avatarInputRef.current?.click()}
disabled={avatarUploading}
>
{avatarUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传预览图
</Button>
</label>
</div>
</div>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>
{entityNameLabel}
</div>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
className={cn(
isDark
? "border-white/10 bg-black/30 text-white placeholder:text-white/22 focus-visible:ring-emerald-400/35"
: ""
)}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>可见性</div>
<Select
value={visibility}
onValueChange={(value: "public" | "private") => setVisibility(value)}
>
<SelectTrigger
className={cn(
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent className={cn(isDark ? "border-white/10 bg-zinc-950 text-white" : "")}>
<SelectItem value="private" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>私有</SelectItem>
<SelectItem value="public" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>公开</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>类型</div>
<Select
value={type}
onValueChange={(value: AgentPresetType) => {
setType(value);
setDefaultModelId("");
}}
>
<SelectTrigger
className={cn(
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent className={cn(isDark ? "border-white/10 bg-zinc-950 text-white" : "")}>
<SelectItem value="image" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>生图</SelectItem>
<SelectItem value="video" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>视频</SelectItem>
<SelectItem value="text" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>生文</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>对应模型</div>
<Select value={defaultModelId} onValueChange={setDefaultModelId}>
<SelectTrigger
className={cn(
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent className={cn(isDark ? "border-white/10 bg-zinc-950 text-white" : "")}>
{filteredModels.map((model) => (
<SelectItem
key={model.id}
value={model.id}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>描述</div>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={3}
className={cn(
isDark
? "border-white/10 bg-black/30 text-white placeholder:text-white/22 focus-visible:ring-emerald-400/35"
: ""
)}
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>槽位配置</div>
<div className={cn("text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
当前共 {slots.length} 个槽位,使用时固定上传 3 张素材图:产品图1、产品图2、模特图
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddSlot}
className={cn(
isDark
? "border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
: ""
)}
>
<Plus className="mr-1 h-4 w-4" />
新增槽位
</Button>
</div>
<div className="space-y-3">
{slots.map((slot, index) => (
<div
key={`slot-${index}`}
className={cn(
"rounded-2xl border p-3",
isDark ? "border-white/10 bg-black/20" : ""
)}
>
<div className="mb-3 flex items-center justify-between">
<div className={cn("font-medium", isDark ? "text-white" : "")}>槽位 {index + 1}</div>
{slots.length > 1 ? (
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"h-8 w-8",
isDark ? "text-white/60 hover:bg-white/8 hover:text-white" : ""
)}
onClick={() => handleRemoveSlot(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
) : null}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>槽位名称</div>
<Input
value={slot.title || ""}
onChange={(event) =>
handleSlotChange(index, { title: event.target.value })
}
className={cn(
isDark
? "border-white/10 bg-black/30 text-white placeholder:text-white/22 focus-visible:ring-emerald-400/35"
: ""
)}
/>
</div>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>上传提示语</div>
<Input
value={slot.uploadLabel || ""}
onChange={(event) =>
handleSlotChange(index, { uploadLabel: event.target.value })
}
className={cn(
isDark
? "border-white/10 bg-black/30 text-white placeholder:text-white/22 focus-visible:ring-emerald-400/35"
: ""
)}
/>
</div>
</div>
<div className="mt-3 space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>提示词</div>
<Textarea
rows={4}
value={slot.promptTemplate}
onChange={(event) =>
handleSlotChange(index, { promptTemplate: event.target.value })
}
className={cn(
isDark
? "border-white/10 bg-black/30 text-white placeholder:text-white/22 focus-visible:ring-emerald-400/35"
: ""
)}
/>
</div>
<div className="mt-3">
<input
ref={(element) => {
fileInputRefs.current[index] = element;
}}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
void handleUploadReferenceImages(index, event);
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRefs.current[index]?.click()}
disabled={uploadingSlotIndex === index}
className={cn(
isDark
? "border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
: ""
)}
>
{uploadingSlotIndex === index ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-1 h-4 w-4" />
)}
上传参考图
</Button>
{slot.localReferenceAssets.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{slot.localReferenceAssets.map((asset, assetIndex) => (
<div key={`${asset.assetId}-${assetIndex}`} className="relative">
<img
src={asset.url}
alt={asset.name || `参考图 ${assetIndex + 1}`}
className={cn(
"h-16 w-16 rounded-lg border object-cover",
isDark ? "border-white/10" : ""
)}
/>
</div>
))}
</div>
) : (
<div className={cn("mt-2 text-xs", isDark ? "text-white/40" : "text-muted-foreground")}>
未上传参考图
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className={cn(
isDark
? "border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
: ""
)}
>
取消
</Button>
<Button
onClick={handleCreate}
disabled={isSubmitting}
className={cn(
isDark
? "bg-gradient-to-r from-emerald-500 to-teal-500 text-white hover:from-emerald-400 hover:to-teal-400"
: "bg-black text-white hover:bg-black/90 dark:bg-white dark:text-black dark:hover:bg-white/90"
)}
>
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{submitLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}