ai-chatbot/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx
Your Name ea5a189ae0
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add agent admin settings access
2026-03-17 23:16:52 +08:00

938 lines
36 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,
Plus,
Save,
Sparkles,
Trash2,
Upload,
} from "lucide-react";
import {
defaultAgentWorkflowUiConfig,
normalizeAgentWorkflowUiConfig,
} from "@/lib/agents/workflow-ui-config";
import {
defaultAgentWorkflowBrandModelConfig,
normalizeAgentWorkflowBrandModelConfig,
} from "@/lib/agents/workflow-brand-model-config";
import {
defaultAgentWorkflowMaterialApiConfig,
normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-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 {
AgentWorkflowBrandModelConfig,
AgentWorkflowMaterialApiConfig,
AgentWorkflowOptionUiConfig,
AgentWorkflowStepUiConfig,
AgentWorkflowUiConfig,
} from "@/types/agent";
const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json());
function normalizeConfigOrdering(config: AgentWorkflowUiConfig): AgentWorkflowUiConfig {
return {
...config,
steps: config.steps.map((step, stepIndex) => ({
...step,
sortOrder: stepIndex,
options: step.options.map((option, optionIndex) => ({
...option,
sortOrder: optionIndex,
})),
})),
};
}
function createWorkflowSlug(value: string, fallback: string) {
const normalized = value
.trim()
.replace(/\s+/g, "_")
.replace(/[^\w-]/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return normalized || fallback;
}
function ensureUniqueWorkflowKey(baseKey: string, existingKeys: string[]) {
const normalizedBase = createWorkflowSlug(baseKey, "item");
const keySet = new Set(existingKeys);
if (!keySet.has(normalizedBase)) {
return normalizedBase;
}
let suffix = 2;
let candidate = `${normalizedBase}_${suffix}`;
while (keySet.has(candidate)) {
suffix += 1;
candidate = `${normalizedBase}_${suffix}`;
}
return candidate;
}
function createWorkflowOptionDraft(
stepKey: string,
optionIndex: number,
existingOptionKeys: string[]
): AgentWorkflowOptionUiConfig {
const optionKey = ensureUniqueWorkflowKey(
`${stepKey}_option_${optionIndex + 1}`,
existingOptionKeys
);
return {
key: optionKey,
buttonName: `选项${optionIndex + 1}`,
description: "",
previewImageUrl: "",
enabled: true,
sortOrder: optionIndex,
promptFragment: `请按选项${optionIndex + 1}执行。`,
};
}
function createWorkflowStepDraft(
stepIndex: number,
existingStepKeys: string[]
): AgentWorkflowStepUiConfig {
const stepKey = ensureUniqueWorkflowKey(`step_${stepIndex + 1}`, existingStepKeys);
return {
key: stepKey,
title: `新步骤${stepIndex + 1}`,
description: "",
enabled: true,
sortOrder: stepIndex,
options: [createWorkflowOptionDraft(stepKey, 0, [])],
};
}
interface AgentWorkflowSettingsClientProps {
backHref?: string;
backLabel?: string;
}
export function AgentWorkflowSettingsClient({
backHref = "/settings",
backLabel = "返回设置",
}: AgentWorkflowSettingsClientProps = {}) {
const { data, isLoading, mutate } = useSWR<{
agentWorkflowUiConfig?: AgentWorkflowUiConfig;
agentWorkflowMaterialApiConfig?: AgentWorkflowMaterialApiConfig;
agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig;
}>("/api/site-settings", fetcher);
const [config, setConfig] = useState<AgentWorkflowUiConfig>(defaultAgentWorkflowUiConfig);
const [materialApiConfig, setMaterialApiConfig] = useState<AgentWorkflowMaterialApiConfig>(
defaultAgentWorkflowMaterialApiConfig
);
const [brandModelConfig, setBrandModelConfig] = useState<AgentWorkflowBrandModelConfig>(
defaultAgentWorkflowBrandModelConfig
);
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));
setMaterialApiConfig(
normalizeAgentWorkflowMaterialApiConfig(data.agentWorkflowMaterialApiConfig)
);
setBrandModelConfig(
normalizeAgentWorkflowBrandModelConfig(data.agentWorkflowBrandModelConfig)
);
}, [data]);
const configuredBrands = useMemo(
() => config.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
[config.inputOptions?.brands]
);
const brandModelBrands = useMemo(() => {
return Array.from(
new Set([
...configuredBrands,
...brandModelConfig.entries.map((entry) => entry.brandName),
])
).sort((left, right) => left.localeCompare(right, "zh-CN"));
}, [brandModelConfig.entries, configuredBrands]);
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) =>
normalizeConfigOrdering({
...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 handleAddStep = () => {
setConfig((prev) =>
normalizeConfigOrdering({
...prev,
steps: [
...prev.steps,
createWorkflowStepDraft(prev.steps.length, prev.steps.map((step) => step.key)),
],
})
);
};
const handleRemoveStep = (stepKey: string) => {
setConfig((prev) =>
normalizeConfigOrdering({
...prev,
steps: prev.steps.filter((step) => step.key !== stepKey),
})
);
};
const handleAddOption = (stepKey: string) => {
updateStep(stepKey, (step) => ({
...step,
options: [
...step.options,
createWorkflowOptionDraft(step.key, step.options.length, step.options.map((item) => item.key)),
],
}));
};
const handleRemoveOption = (stepKey: string, optionKey: string) => {
updateStep(stepKey, (step) => ({
...step,
options: step.options.filter((option) => option.key !== optionKey),
}));
};
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,
agentWorkflowMaterialApiConfig: materialApiConfig,
agentWorkflowBrandModelConfig: brandModelConfig,
}),
});
if (!response.ok) {
throw new Error("保存失败");
}
toast.success("智能体工作流配置已保存");
await mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setIsSaving(false);
}
};
const updateBrandModelEntry = (
brandName: string,
updater: (
entry: NonNullable<AgentWorkflowBrandModelConfig["entries"][number]>
) => NonNullable<AgentWorkflowBrandModelConfig["entries"][number]>
) => {
setBrandModelConfig((prev) => {
const entryIndex = prev.entries.findIndex((entry) => entry.brandName === brandName);
const currentEntry =
entryIndex >= 0
? prev.entries[entryIndex]
: {
brandName,
images: [],
};
const nextEntry = updater(currentEntry);
const nextEntries =
entryIndex >= 0
? prev.entries.map((entry, index) => (index === entryIndex ? nextEntry : entry))
: [...prev.entries, nextEntry];
return {
entries: nextEntries
.filter((entry) => entry.images.length > 0 || configuredBrands.includes(entry.brandName))
.map((entry) => ({
...entry,
images: entry.images
.slice()
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((image, index) => ({
...image,
sortOrder: index,
})),
})),
};
});
};
const handleUploadBrandModelImages = async (brandName: string, files: FileList | null) => {
const pickedFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
if (pickedFiles.length === 0) {
toast.error("请先选择图片文件");
return;
}
const refKey = `brand-model:${brandName}`;
try {
setUploadingKey(refKey);
const uploadedImages: AgentWorkflowBrandModelConfig["entries"][number]["images"] = [];
for (const file of pickedFiles) {
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 || "上传失败");
}
uploadedImages.push({
id:
(typeof result.assetId === "string" && result.assetId.trim()) ||
`${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
assetId: typeof result.assetId === "string" ? result.assetId : null,
imageUrl: result.url,
displayName: file.name,
mimeType: file.type || null,
sortOrder: 0,
});
}
updateBrandModelEntry(brandName, (entry) => ({
...entry,
images: [
...entry.images,
...uploadedImages.map((image, index) => ({
...image,
sortOrder: entry.images.length + index,
})),
],
}));
toast.success(
uploadedImages.length === 1 ? "模特图上传成功" : `已上传 ${uploadedImages.length} 张模特图`
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "模特图上传失败");
} finally {
setUploadingKey(null);
}
};
const handleRemoveBrandModelImage = (brandName: string, imageId: string) => {
updateBrandModelEntry(brandName, (entry) => ({
...entry,
images: entry.images.filter((image) => image.id !== imageId),
}));
};
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={backHref}>
<ArrowLeft className="mr-2 h-4 w-4" />
{backLabel}
</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>
配置主 SPU / 搭配 SKU 搜图所需的接口地址与 API Key。Key 会按系统设置方式存储到数据库中,不会下发到前台页面。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-xl border bg-muted/20 px-4 py-3">
<div className="space-y-1">
<div className="text-sm font-medium">启用素材搜索</div>
<div className="text-xs text-muted-foreground">
开启后,智能体工作流页面支持搜索并选择白底素材图。
</div>
</div>
<Switch
checked={materialApiConfig.enabled}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({
...prev,
enabled: checked,
}))
}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>接口基地址</Label>
<Input
value={materialApiConfig.baseUrl}
onChange={(event) =>
setMaterialApiConfig((prev) => ({
...prev,
baseUrl: event.target.value,
}))
}
placeholder="https://sc.jiansun.vip/api/external"
/>
</div>
<div className="space-y-2">
<Label>API Key</Label>
<Input
type="password"
value={materialApiConfig.apiKey}
onChange={(event) =>
setMaterialApiConfig((prev) => ({
...prev,
apiKey: event.target.value,
}))
}
placeholder="请输入素材搜索 API Key"
/>
</div>
</div>
<div className="text-xs text-muted-foreground">
当前适配的是白底图搜索接口;前台不会直接暴露 Key,而是由服务端代查并转存图片。
</div>
</CardContent>
</Card>
<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>
<Card>
<CardHeader>
<CardTitle>品牌模特图库</CardTitle>
<CardDescription>
第二步会根据第一步选择的品牌,展示这里配置的品牌模特图供用户单选,并自动带入生图流程的模特图槽位。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{brandModelBrands.length > 0 ? (
brandModelBrands.map((brandName) => {
const refKey = `brand-model:${brandName}`;
const entry =
brandModelConfig.entries.find((item) => item.brandName === brandName) || null;
const images = entry?.images || [];
const isUploading = uploadingKey === refKey;
return (
<div key={brandName} className="rounded-2xl border bg-muted/10 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">{brandName}</div>
<div className="mt-1 text-xs text-muted-foreground">
当前已配置 {images.length} 张模特图
</div>
</div>
<div className="flex items-center gap-2">
<input
ref={(element) => {
fileInputRefs.current[refKey] = element;
}}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
void handleUploadBrandModelImages(brandName, event.target.files);
event.target.value = "";
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRefs.current[refKey]?.click()}
disabled={isUploading}
>
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传模特图
</Button>
</div>
</div>
{images.length > 0 ? (
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-6">
{images.map((image) => (
<div
key={image.id}
className="overflow-hidden rounded-2xl border bg-background"
>
<div className="relative aspect-square overflow-hidden bg-muted/30">
<img
src={image.imageUrl}
alt={image.displayName || `${brandName} 模特图`}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
</div>
<div className="flex items-center justify-between gap-2 px-3 py-2">
<div className="min-w-0 text-[11px] text-muted-foreground">
<div className="truncate">
{image.displayName || `${brandName} 模特图`}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
onClick={() =>
handleRemoveBrandModelImage(brandName, image.id)
}
>
删除
</Button>
</div>
</div>
))}
</div>
) : (
<div className="mt-4 rounded-2xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
当前品牌还没有上传模特图,上传后第二步会自动按品牌展示给用户选择。
</div>
)}
</div>
);
})
) : (
<div className="rounded-2xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
先在上方“品牌列表”里配置品牌,随后即可为每个品牌上传对应的模特图。
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="button" variant="outline" onClick={handleAddStep}>
<Plus className="mr-2 h-4 w-4" />
新增步骤
</Button>
</div>
{sortedSteps.length > 0 ? (
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-1">
<div className="text-xs font-medium text-muted-foreground">
步骤 Key:{step.key}
</div>
<div className="text-xs text-muted-foreground">
新增步骤后系统会自动生成唯一 Key,用于前台保存和读取选择结果。
</div>
</div>
<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 flex-wrap items-center justify-end gap-2 pt-2">
<Button type="button" variant="outline" size="sm" onClick={() => handleAddOption(step.key)}>
<Plus className="mr-1 h-4 w-4" />
新增选项
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => handleRemoveStep(step.key)}
>
<Trash2 className="mr-1 h-4 w-4" />
删除步骤
</Button>
<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>
{step.options.length > 0 ? (
<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">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs text-destructive hover:text-destructive"
onClick={() => handleRemoveOption(step.key, option.key)}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
删除选项
</Button>
<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>
) : (
<div className="rounded-2xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
当前步骤还没有选项,请先新增一个选项。
</div>
)}
</CardContent>
</Card>
))
) : (
<Card>
<CardContent className="rounded-2xl border border-dashed px-4 py-10 text-center text-sm text-muted-foreground">
还没有配置工作流步骤,点击上方“新增步骤”开始搭建第一步页面。
</CardContent>
</Card>
)}
</div>
)}
</div>
);
}