633 lines
22 KiB
TypeScript
633 lines
22 KiB
TypeScript
"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>
|
||
);
|
||
}
|