ai-chatbot/components/agents/AgentPresetDialog.tsx
Your Name 4b820f8ff4
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
Add canvas agent mode and management
2026-03-10 19:56:25 +08:00

633 lines
22 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 { fetcher } from "@/lib/utils";
import { imageGenerationService } from "@/services/canvas";
import { agentService } from "@/services/agents";
import type { AgentCategory, AgentPreset, AgentPresetSlot } from "@/types/agent";
type DialogSlotState = AgentPresetSlot & {
localReferenceAssets: Array<{
assetId: string;
url: string;
name?: string | null;
}>;
};
interface AgentPresetDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
categories: AgentCategory[];
initialPreset?: AgentPreset | null;
onSaved?: (preset: AgentPreset) => void;
}
const NEW_CATEGORY_VALUE = "__new__";
export function AgentPresetDialog({
open,
onOpenChange,
categories,
initialPreset,
onSaved,
}: AgentPresetDialogProps) {
const { data: modelsData } = useSWR<{
models?: Array<{
id: string;
displayName: string;
supportsImageGen?: boolean;
providerType?: string;
}>;
}>("/api/models", fetcher);
const [name, setName] = useState("");
const [type, setType] = useState<"image" | "video">("image");
const [visibility, setVisibility] = useState<"public" | "private">("private");
const [categoryMode, setCategoryMode] = useState<string>("");
const [newCategoryName, setNewCategoryName] = useState("");
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 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 || "")));
}
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");
setCategoryMode("");
setNewCategoryName("");
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);
setCategoryMode(initialPreset.category?.id || initialPreset.categoryId || "");
setNewCategoryName("");
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("请输入智能体名称");
return;
}
if (!defaultModelId) {
toast.error("请选择对应模型");
return;
}
if (visibility === "public" && !categoryMode && !newCategoryName.trim()) {
toast.error("公开智能体必须选择分类");
return;
}
if (slots.some((slot) => !slot.promptTemplate.trim())) {
toast.error("每个槽位都需要填写提示词");
return;
}
setIsSubmitting(true);
try {
const payload = {
name: name.trim(),
type,
visibility,
categoryId:
categoryMode && categoryMode !== NEW_CATEGORY_VALUE ? categoryMode : undefined,
newCategoryName:
categoryMode === NEW_CATEGORY_VALUE ? newCategoryName.trim() : undefined,
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,
} as const;
const result = initialPreset
? await agentService.updatePreset(initialPreset.id, payload)
: await agentService.createPreset(payload);
if (!result.success || !result.data) {
throw new Error(result.error || (initialPreset ? "更新智能体失败" : "创建智能体失败"));
}
toast.success(initialPreset ? "智能体更新成功" : "智能体创建成功");
onSaved?.(result.data);
onOpenChange(false);
resetState();
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建智能体失败");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) resetState();
}}
>
<DialogContent className="max-w-3xl bg-background">
<DialogHeader>
<DialogTitle>{initialPreset ? "编辑智能体" : "新建智能体"}</DialogTitle>
<DialogDescription>配置模型、槽位提示词和参考图。</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[70vh] 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="text-sm font-medium">头像</div>
<div className="flex items-center gap-3">
{avatarUrl ? (
<img
src={avatarUrl}
alt="智能体头像"
className="h-14 w-14 rounded-2xl border object-cover"
/>
) : (
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border bg-muted text-xs 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"
onClick={() => avatarInputRef.current?.click()}
disabled={avatarUploading}
>
{avatarUploading ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-1 h-4 w-4" />
)}
上传头像
</Button>
</label>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">智能体名称</div>
<Input value={name} onChange={(event) => setName(event.target.value)} />
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="space-y-2">
<div className="text-sm font-medium">可见性</div>
<Select
value={visibility}
onValueChange={(value: "public" | "private") => setVisibility(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="private">私有</SelectItem>
<SelectItem value="public">公开</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">类型</div>
<Select
value={type}
onValueChange={(value: "image" | "video") => {
setType(value);
setDefaultModelId("");
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="image">生图</SelectItem>
<SelectItem value="video">视频</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">分类</div>
<Select value={categoryMode} onValueChange={setCategoryMode}>
<SelectTrigger>
<SelectValue placeholder="选择分类" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
<SelectItem value={NEW_CATEGORY_VALUE}>新建分类</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">对应模型</div>
<Select value={defaultModelId} onValueChange={setDefaultModelId}>
<SelectTrigger>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{filteredModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{categoryMode === NEW_CATEGORY_VALUE ? (
<div className="space-y-2">
<div className="text-sm font-medium">新分类名称</div>
<Input
value={newCategoryName}
onChange={(event) => setNewCategoryName(event.target.value)}
/>
</div>
) : null}
<div className="space-y-2">
<div className="text-sm font-medium">描述</div>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={3}
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">槽位配置</div>
<div className="text-xs text-muted-foreground">
当前共 {slots.length} 个槽位,使用时固定上传 3 张素材图:产品图1、产品图2、模特图
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={handleAddSlot}>
<Plus className="mr-1 h-4 w-4" />
新增槽位
</Button>
</div>
<div className="space-y-3">
{slots.map((slot, index) => (
<div key={`slot-${index}`} className="rounded-2xl border p-3">
<div className="mb-3 flex items-center justify-between">
<div className="font-medium">槽位 {index + 1}</div>
{slots.length > 1 ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
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="text-sm font-medium">槽位名称</div>
<Input
value={slot.title || ""}
onChange={(event) =>
handleSlotChange(index, { title: event.target.value })
}
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">上传提示语</div>
<Input
value={slot.uploadLabel || ""}
onChange={(event) =>
handleSlotChange(index, { uploadLabel: event.target.value })
}
/>
</div>
</div>
<div className="mt-3 space-y-2">
<div className="text-sm font-medium">提示词</div>
<Textarea
rows={4}
value={slot.promptTemplate}
onChange={(event) =>
handleSlotChange(index, { promptTemplate: event.target.value })
}
/>
</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}
>
{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="h-16 w-16 rounded-lg border object-cover"
/>
</div>
))}
</div>
) : (
<div className="mt-2 text-xs text-muted-foreground">未上传参考图</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleCreate} disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{initialPreset ? "保存修改" : "创建智能体"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}