ai-chatbot/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx
Your Name 64cb485dfa
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: refine agent workflow flow
2026-03-17 18:23:32 +08:00

445 lines
18 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 Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import {
ArrowLeft,
Image as ImageIcon,
Loader2,
Save,
Sparkles,
Upload,
} from "lucide-react";
import {
defaultAgentWorkflowUiConfig,
normalizeAgentWorkflowUiConfig,
} from "@/lib/agents/workflow-ui-config";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import type { AgentWorkflowUiConfig } from "@/types/agent";
const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json());
export function AgentWorkflowSettingsClient() {
const { data, isLoading, mutate } = useSWR<{
agentWorkflowUiConfig?: AgentWorkflowUiConfig;
}>("/api/site-settings", fetcher);
const [config, setConfig] = useState<AgentWorkflowUiConfig>(defaultAgentWorkflowUiConfig);
const [isSaving, setIsSaving] = useState(false);
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
useEffect(() => {
if (!data) return;
setConfig(normalizeAgentWorkflowUiConfig(data.agentWorkflowUiConfig));
}, [data]);
const sortedSteps = useMemo(
() => [...config.steps].sort((left, right) => left.sortOrder - right.sortOrder),
[config.steps]
);
const updateStep = (
stepKey: string,
updater: (step: AgentWorkflowUiConfig["steps"][number]) => AgentWorkflowUiConfig["steps"][number]
) => {
setConfig((prev) => ({
...prev,
steps: prev.steps.map((step) => (step.key === stepKey ? updater(step) : step)),
}));
};
const updateOption = (
stepKey: string,
optionKey: string,
updater: (
option: AgentWorkflowUiConfig["steps"][number]["options"][number]
) => AgentWorkflowUiConfig["steps"][number]["options"][number]
) => {
updateStep(stepKey, (step) => ({
...step,
options: step.options.map((option) =>
option.key === optionKey ? updater(option) : option
),
}));
};
const handleUploadPreview = async (stepKey: string, optionKey: string, file: File) => {
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
return;
}
const refKey = `${stepKey}:${optionKey}`;
try {
setUploadingKey(refKey);
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result?.url) {
throw new Error(result?.error || "上传失败");
}
updateOption(stepKey, optionKey, (option) => ({
...option,
previewImageUrl: result.url,
}));
toast.success("预览图上传成功");
} catch (error) {
toast.error(error instanceof Error ? error.message : "预览图上传失败");
} finally {
setUploadingKey(null);
}
};
const handleSave = async () => {
try {
setIsSaving(true);
const response = await fetch("/api/site-settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agentWorkflowUiConfig: config,
}),
});
if (!response.ok) {
throw new Error("保存失败");
}
toast.success("智能体工作流配置已保存");
await mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setIsSaving(false);
}
};
return (
<div className="container mx-auto space-y-6 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4" />
智能体配置中心
</div>
<h1 className="text-2xl font-semibold">智能体工作流配置</h1>
<p className="text-sm text-muted-foreground">
单独维护智能体页面左侧每个环节的功能按钮名称、说明、Prompt 片段和预览图。
</p>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/settings">
<ArrowLeft className="mr-2 h-4 w-4" />
返回设置
</Link>
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
保存配置
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>功能说明</CardTitle>
<CardDescription>
这里配置的是智能体工作流页面的功能卡片。网站设置页只保留入口,不再承载这些详细配置。
</CardDescription>
</CardHeader>
</Card>
{isLoading ? (
<Card>
<CardContent className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</CardContent>
</Card>
) : (
<div className="space-y-5">
<Card>
<CardHeader>
<CardTitle>基础下拉配置</CardTitle>
<CardDescription>
工作流左侧“品牌 / 店铺”采用后台配置的候选列表,前台支持输入关键字匹配下拉选择。
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>品牌列表</Label>
<Textarea
value={(config.inputOptions?.brands || []).join("\n")}
onChange={(event) =>
setConfig((prev) => ({
...prev,
inputOptions: {
brands: event.target.value
.split("\n")
.map((item) => item.trim())
.filter(Boolean),
stores: prev.inputOptions?.stores || [],
},
}))
}
rows={10}
placeholder={"例如:\n宜家\n顾家家居\n林氏家居"}
/>
</div>
<div className="space-y-2">
<Label>店铺列表</Label>
<Textarea
value={(config.inputOptions?.stores || []).join("\n")}
onChange={(event) =>
setConfig((prev) => ({
...prev,
inputOptions: {
brands: prev.inputOptions?.brands || [],
stores: event.target.value
.split("\n")
.map((item) => item.trim())
.filter(Boolean),
},
}))
}
rows={10}
placeholder={"例如:\n天猫旗舰店\n京东自营店\n抖音官方店"}
/>
</div>
</CardContent>
</Card>
{sortedSteps.map((step) => (
<Card key={step.key}>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-3">
<div className="space-y-2">
<Label>步骤标题</Label>
<Input
value={step.title}
onChange={(event) =>
updateStep(step.key, (current) => ({
...current,
title: event.target.value,
}))
}
placeholder="步骤标题"
/>
</div>
<div className="space-y-2">
<Label>步骤说明</Label>
<Textarea
value={step.description || ""}
onChange={(event) =>
updateStep(step.key, (current) => ({
...current,
description: event.target.value,
}))
}
rows={2}
placeholder="步骤说明"
/>
</div>
</div>
<div className="flex items-center gap-2 pt-8">
<Label htmlFor={`workflow-step-${step.key}`}>启用</Label>
<Switch
id={`workflow-step-${step.key}`}
checked={step.enabled}
onCheckedChange={(checked) =>
updateStep(step.key, (current) => ({
...current,
enabled: checked,
}))
}
/>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{step.options
.slice()
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((option) => {
const refKey = `${step.key}:${option.key}`;
const isUploading = uploadingKey === refKey;
return (
<div key={option.key} className="rounded-xl border bg-muted/20 p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-medium">{option.key}</div>
<div className="mt-1 text-xs text-muted-foreground">
配置按钮名称与预览图
</div>
</div>
<div className="flex items-center gap-2">
<Label
htmlFor={`workflow-option-${step.key}-${option.key}`}
className="text-xs text-muted-foreground"
>
启用
</Label>
<Switch
id={`workflow-option-${step.key}-${option.key}`}
checked={option.enabled}
onCheckedChange={(checked) =>
updateOption(step.key, option.key, (current) => ({
...current,
enabled: checked,
}))
}
/>
</div>
</div>
<div className="grid grid-cols-[148px_minmax(0,1fr)] gap-4">
<div className="space-y-3">
<div className="flex h-[120px] items-center justify-center overflow-hidden rounded-xl border bg-background">
{option.previewImageUrl ? (
<img
src={option.previewImageUrl}
alt={option.buttonName}
className="h-full w-full object-cover"
/>
) : (
<div className="px-3 text-center text-xs text-muted-foreground">
<ImageIcon className="mx-auto mb-2 h-5 w-5 opacity-60" />
暂无预览图
</div>
)}
</div>
<input
ref={(element) => {
fileInputRefs.current[refKey] = element;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
void handleUploadPreview(step.key, option.key, file);
event.target.value = "";
}}
/>
<div className="grid grid-cols-1 gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={isUploading}
onClick={() => fileInputRefs.current[refKey]?.click()}
>
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传图片
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
updateOption(step.key, option.key, (current) => ({
...current,
previewImageUrl: "",
}))
}
>
清空图片
</Button>
</div>
</div>
<div className="space-y-3">
<div className="space-y-2">
<Label>按钮名称</Label>
<Input
value={option.buttonName}
onChange={(event) =>
updateOption(step.key, option.key, (current) => ({
...current,
buttonName: event.target.value,
}))
}
placeholder="按钮名称"
/>
</div>
<div className="space-y-2">
<Label>说明文案</Label>
<Textarea
value={option.description || ""}
onChange={(event) =>
updateOption(step.key, option.key, (current) => ({
...current,
description: event.target.value,
}))
}
rows={2}
placeholder="说明文案"
/>
</div>
<div className="space-y-2">
<Label>预览图链接</Label>
<Input
value={option.previewImageUrl || ""}
onChange={(event) =>
updateOption(step.key, option.key, (current) => ({
...current,
previewImageUrl: event.target.value,
}))
}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label>Prompt 片段</Label>
<Textarea
value={option.promptFragment || ""}
onChange={(event) =>
updateOption(step.key, option.key, (current) => ({
...current,
promptFragment: event.target.value,
}))
}
rows={3}
placeholder="用于执行时注入的提示片段"
/>
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}