feat: add agent workflow mode
This commit is contained in:
parent
31f8c004b2
commit
176fc94293
@ -95,11 +95,16 @@ import {
|
||||
normalizeCanvasSkillTags,
|
||||
type CanvasSkillTag,
|
||||
} from "@/lib/canvas-skill-tags";
|
||||
import {
|
||||
defaultAgentWorkflowUiConfig,
|
||||
normalizeAgentWorkflowUiConfig,
|
||||
} from "@/lib/agents/workflow-ui-config";
|
||||
import {
|
||||
CANVAS_SKILL_ICON_OPTIONS,
|
||||
getCanvasSkillIconComponent,
|
||||
} from "@/components/canvas/canvas-skill-icons";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import type { AgentWorkflowUiConfig } from "@/types/agent";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
const DEFAULT_BFL_REALTIME_MODEL = "flux-2-klein-4b";
|
||||
@ -241,6 +246,7 @@ type SiteSettings = {
|
||||
video: string[];
|
||||
text: string[];
|
||||
};
|
||||
agentWorkflowUiConfig: AgentWorkflowUiConfig;
|
||||
realtimeCanvasEnabled: boolean;
|
||||
aiDetailGenerationEnabled: boolean;
|
||||
canvasDefaultModelId: string | null;
|
||||
@ -642,6 +648,7 @@ export function AdminSettingsClient() {
|
||||
video: [],
|
||||
text: [],
|
||||
},
|
||||
agentWorkflowUiConfig: defaultAgentWorkflowUiConfig,
|
||||
realtimeCanvasEnabled: true,
|
||||
aiDetailGenerationEnabled: false,
|
||||
canvasDefaultModelId: null,
|
||||
@ -739,6 +746,9 @@ export function AdminSettingsClient() {
|
||||
? (siteSettingsData as any).agentSecondaryTypeConfig.text
|
||||
: [],
|
||||
},
|
||||
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
|
||||
(siteSettingsData as any).agentWorkflowUiConfig
|
||||
),
|
||||
realtimeCanvasEnabled: (siteSettingsData as any).realtimeCanvasEnabled ?? true,
|
||||
aiDetailGenerationEnabled: siteSettingsData.aiDetailGenerationEnabled ?? false,
|
||||
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
|
||||
@ -902,6 +912,9 @@ export function AdminSettingsClient() {
|
||||
maxBuffering: 2,
|
||||
maxInputResolution: 1024,
|
||||
jpegQuality: 0.55,
|
||||
previewMegapixels: 0.4,
|
||||
detailMegapixels: 1.0,
|
||||
outputJpegQuality: 62,
|
||||
steps: 3,
|
||||
scheduleMu: 2.3,
|
||||
feedback: 0.95,
|
||||
@ -1070,6 +1083,9 @@ export function AdminSettingsClient() {
|
||||
maxBuffering?: number;
|
||||
maxInputResolution?: number;
|
||||
jpegQuality?: number;
|
||||
previewMegapixels?: number;
|
||||
detailMegapixels?: number;
|
||||
outputJpegQuality?: number;
|
||||
defaults?: {
|
||||
steps?: number;
|
||||
scheduleMu?: number;
|
||||
@ -1099,6 +1115,9 @@ export function AdminSettingsClient() {
|
||||
maxBuffering: Number(config.maxBuffering || 2),
|
||||
maxInputResolution: Number(config.maxInputResolution || 1024),
|
||||
jpegQuality: Number(config.jpegQuality || 0.55),
|
||||
previewMegapixels: Number(config.previewMegapixels || 0.4),
|
||||
detailMegapixels: Number(config.detailMegapixels || 1.0),
|
||||
outputJpegQuality: Number(config.outputJpegQuality || 62),
|
||||
steps: Number(config.defaults?.steps || 3),
|
||||
scheduleMu: Number(config.defaults?.scheduleMu || 2.3),
|
||||
feedback: Number(config.defaults?.feedback || 0.95),
|
||||
@ -1474,6 +1493,7 @@ export function AdminSettingsClient() {
|
||||
const [editingModel, setEditingModel] = useState<string | null>(null);
|
||||
const [modelAvatarUploading, setModelAvatarUploading] = useState(false);
|
||||
const [contactQrUploading, setContactQrUploading] = useState(false);
|
||||
const [workflowOptionUploadingKey, setWorkflowOptionUploadingKey] = useState<string | null>(null);
|
||||
|
||||
// 🆕 火山引擎视频多模型表单(动态从API加载)
|
||||
const [volcanoVideoModels, setVolcanoVideoModels] = useState<Array<{
|
||||
@ -1535,6 +1555,70 @@ export function AdminSettingsClient() {
|
||||
const [showStorageDialog, setShowStorageDialog] = useState(false);
|
||||
const [editingStorage, setEditingStorage] = useState<string | null>(null);
|
||||
|
||||
const updateWorkflowStep = (
|
||||
stepKey: string,
|
||||
updater: (step: AgentWorkflowUiConfig["steps"][number]) => AgentWorkflowUiConfig["steps"][number]
|
||||
) => {
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
agentWorkflowUiConfig: {
|
||||
steps: prev.agentWorkflowUiConfig.steps.map((step) =>
|
||||
step.key === stepKey ? updater(step) : step
|
||||
),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const updateWorkflowOption = (
|
||||
stepKey: string,
|
||||
optionKey: string,
|
||||
updater: (
|
||||
option: AgentWorkflowUiConfig["steps"][number]["options"][number]
|
||||
) => AgentWorkflowUiConfig["steps"][number]["options"][number]
|
||||
) => {
|
||||
updateWorkflowStep(stepKey, (step) => ({
|
||||
...step,
|
||||
options: step.options.map((option) =>
|
||||
option.key === optionKey ? updater(option) : option
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleWorkflowOptionPreviewUpload = async (
|
||||
stepKey: string,
|
||||
optionKey: string,
|
||||
file: File
|
||||
) => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("请上传图片文件");
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadKey = `${stepKey}:${optionKey}`;
|
||||
try {
|
||||
setWorkflowOptionUploadingKey(uploadKey);
|
||||
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 || "上传失败");
|
||||
}
|
||||
|
||||
updateWorkflowOption(stepKey, optionKey, (option) => ({
|
||||
...option,
|
||||
previewImageUrl: result.url,
|
||||
}));
|
||||
toast.success("功能卡片预览图上传成功");
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "功能卡片预览图上传失败");
|
||||
} finally {
|
||||
setWorkflowOptionUploadingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
// --- 用户管理逻辑 ---
|
||||
const handleUserSearch = (search: string) => {
|
||||
setUserSearch(search);
|
||||
@ -2203,6 +2287,18 @@ export function AdminSettingsClient() {
|
||||
Math.max(512, Number(realtimeCanvasConfig.maxInputResolution) || 1024)
|
||||
),
|
||||
jpegQuality: Math.min(0.82, Math.max(0.35, Number(realtimeCanvasConfig.jpegQuality) || 0.55)),
|
||||
previewMegapixels: Math.min(
|
||||
0.8,
|
||||
Math.max(0.2, Number(realtimeCanvasConfig.previewMegapixels) || 0.4)
|
||||
),
|
||||
detailMegapixels: Math.min(
|
||||
1.2,
|
||||
Math.max(0.6, Number(realtimeCanvasConfig.detailMegapixels) || 1.0)
|
||||
),
|
||||
outputJpegQuality: Math.min(
|
||||
90,
|
||||
Math.max(35, Number(realtimeCanvasConfig.outputJpegQuality) || 62)
|
||||
),
|
||||
defaults: {
|
||||
steps: Math.min(6, Math.max(1, Number(realtimeCanvasConfig.steps) || 3)),
|
||||
scheduleMu: Math.min(3.5, Math.max(1.5, Number(realtimeCanvasConfig.scheduleMu) || 2.3)),
|
||||
@ -5528,6 +5624,215 @@ export function AdminSettingsClient() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{siteSettings.agentEnabled && (
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<div>
|
||||
<div className="font-medium">工作流功能卡片配置</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
智能体工作流左侧每个环节的按钮名称、说明文案和预览图都从这里读取,前端不再写死。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{siteSettings.agentWorkflowUiConfig.steps.map((step) => (
|
||||
<div key={step.key} className="rounded-xl border p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label>{step.key}</Label>
|
||||
<Input
|
||||
value={step.title}
|
||||
onChange={(event) =>
|
||||
updateWorkflowStep(step.key, (current) => ({
|
||||
...current,
|
||||
title: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="步骤标题"
|
||||
/>
|
||||
<Textarea
|
||||
value={step.description || ""}
|
||||
onChange={(event) =>
|
||||
updateWorkflowStep(step.key, (current) => ({
|
||||
...current,
|
||||
description: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
placeholder="步骤说明"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-6">
|
||||
<Label htmlFor={`workflow-step-${step.key}-enabled`}>启用</Label>
|
||||
<Switch
|
||||
id={`workflow-step-${step.key}-enabled`}
|
||||
checked={step.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateWorkflowStep(step.key, (current) => ({
|
||||
...current,
|
||||
enabled: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{step.options.map((option) => {
|
||||
const uploadKey = `${step.key}:${option.key}`;
|
||||
const isUploading = workflowOptionUploadingKey === uploadKey;
|
||||
|
||||
return (
|
||||
<div key={option.key} className="rounded-xl border bg-muted/20 p-3 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{option.key}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
工作流按钮名称与预览图
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor={`workflow-option-${step.key}-${option.key}-enabled`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
启用
|
||||
</Label>
|
||||
<Switch
|
||||
id={`workflow-option-${step.key}-${option.key}-enabled`}
|
||||
checked={option.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateWorkflowOption(step.key, option.key, (current) => ({
|
||||
...current,
|
||||
enabled: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[120px_minmax(0,1fr)] gap-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex h-[96px] items-center justify-center overflow-hidden rounded-lg 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">
|
||||
未上传预览图
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
void handleWorkflowOptionPreviewUpload(
|
||||
step.key,
|
||||
option.key,
|
||||
file
|
||||
);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={isUploading}
|
||||
onClick={(event) => {
|
||||
const input =
|
||||
event.currentTarget.parentElement?.querySelector(
|
||||
'input[type="file"]'
|
||||
) as HTMLInputElement | null;
|
||||
input?.click();
|
||||
}}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
上传图片
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>按钮名称</Label>
|
||||
<Input
|
||||
value={option.buttonName}
|
||||
onChange={(event) =>
|
||||
updateWorkflowOption(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) =>
|
||||
updateWorkflowOption(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) =>
|
||||
updateWorkflowOption(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) =>
|
||||
updateWorkflowOption(step.key, option.key, (current) => ({
|
||||
...current,
|
||||
promptFragment: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
placeholder="用于右侧执行时注入的工作流提示片段"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="realtimeCanvasEnabled">实时画布功能</Label>
|
||||
@ -9027,6 +9332,57 @@ export function AdminSettingsClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>预览分支(MP)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0.2}
|
||||
max={0.8}
|
||||
step={0.01}
|
||||
value={realtimeCanvasConfig.previewMegapixels}
|
||||
onChange={(e) =>
|
||||
setRealtimeCanvasConfig({
|
||||
...realtimeCanvasConfig,
|
||||
previewMegapixels: Number.parseFloat(e.target.value) || 0.4,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>细节分支(MP)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0.6}
|
||||
max={1.2}
|
||||
step={0.01}
|
||||
value={realtimeCanvasConfig.detailMegapixels}
|
||||
onChange={(e) =>
|
||||
setRealtimeCanvasConfig({
|
||||
...realtimeCanvasConfig,
|
||||
detailMegapixels: Number.parseFloat(e.target.value) || 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>返回JPEG质量</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={35}
|
||||
max={90}
|
||||
step={1}
|
||||
value={realtimeCanvasConfig.outputJpegQuality}
|
||||
onChange={(e) =>
|
||||
setRealtimeCanvasConfig({
|
||||
...realtimeCanvasConfig,
|
||||
outputJpegQuality: Number.parseInt(e.target.value, 10) || 62,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>推理步数</Label>
|
||||
|
||||
@ -29,6 +29,11 @@ import {
|
||||
defaultAgentSecondaryTypeConfig,
|
||||
normalizeAgentSecondaryTypeConfig,
|
||||
} from "@/lib/agents/secondary-type-config";
|
||||
import {
|
||||
AGENT_WORKFLOW_UI_CONFIG_KEY,
|
||||
defaultAgentWorkflowUiConfig,
|
||||
normalizeAgentWorkflowUiConfig,
|
||||
} from "@/lib/agents/workflow-ui-config";
|
||||
import {
|
||||
defaultOssImageMigrationConfig,
|
||||
normalizeOssImageMigrationConfig,
|
||||
@ -214,6 +219,7 @@ export async function GET() {
|
||||
ossImageMigrationConfigSetting,
|
||||
agentCategoryTreeConfigSetting,
|
||||
agentSecondaryTypeConfigSetting,
|
||||
agentWorkflowUiConfigSetting,
|
||||
] = await Promise.all([
|
||||
db.select().from(siteSettings).limit(1).then(rows => rows[0]),
|
||||
db
|
||||
@ -324,6 +330,12 @@ export async function GET() {
|
||||
.where(eq(systemSettings.key, AGENT_SECONDARY_TYPE_CONFIG_KEY))
|
||||
.limit(1)
|
||||
.then(rows => rows[0]),
|
||||
db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.where(eq(systemSettings.key, AGENT_WORKFLOW_UI_CONFIG_KEY))
|
||||
.limit(1)
|
||||
.then(rows => rows[0]),
|
||||
]);
|
||||
|
||||
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
|
||||
@ -365,6 +377,9 @@ export async function GET() {
|
||||
const agentSecondaryTypeConfig = normalizeAgentSecondaryTypeConfig(
|
||||
agentSecondaryTypeConfigSetting?.value
|
||||
);
|
||||
const agentWorkflowUiConfig = normalizeAgentWorkflowUiConfig(
|
||||
agentWorkflowUiConfigSetting?.value
|
||||
);
|
||||
|
||||
// 如果没有设置记录,返回默认值
|
||||
const baseSettings = settings || {
|
||||
@ -392,6 +407,7 @@ export async function GET() {
|
||||
agentEnabled: true,
|
||||
agentCategoryTreeConfig: normalizeAgentCategoryTreeConfig(undefined),
|
||||
agentSecondaryTypeConfig: defaultAgentSecondaryTypeConfig,
|
||||
agentWorkflowUiConfig: defaultAgentWorkflowUiConfig,
|
||||
realtimeCanvasEnabled: true,
|
||||
realtimeCanvasServiceEnabled: true,
|
||||
storageFailoverConfig,
|
||||
@ -435,6 +451,7 @@ export async function GET() {
|
||||
contactConfig,
|
||||
agentCategoryTreeConfig,
|
||||
agentSecondaryTypeConfig,
|
||||
agentWorkflowUiConfig,
|
||||
membershipConfig,
|
||||
membershipUserOverrides,
|
||||
pointsSystemConfig,
|
||||
@ -498,6 +515,34 @@ const updateSchema = z.object({
|
||||
text: z.array(z.string().trim().min(1).max(64)).max(200).optional(),
|
||||
})
|
||||
.optional(),
|
||||
agentWorkflowUiConfig: z
|
||||
.object({
|
||||
steps: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().trim().min(1).max(64),
|
||||
title: z.string().trim().min(1).max(64),
|
||||
description: z.string().trim().max(300).nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
sortOrder: z.number().min(0).max(999).optional(),
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().trim().min(1).max(64),
|
||||
buttonName: z.string().trim().min(1).max(64),
|
||||
description: z.string().trim().max(300).nullable().optional(),
|
||||
previewImageUrl: z.string().trim().max(1000).nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
sortOrder: z.number().min(0).max(999).optional(),
|
||||
promptFragment: z.string().trim().max(1000).nullable().optional(),
|
||||
})
|
||||
)
|
||||
.max(100),
|
||||
})
|
||||
)
|
||||
.max(20),
|
||||
})
|
||||
.optional(),
|
||||
realtimeCanvasEnabled: z.boolean().optional(),
|
||||
aiDetailGenerationEnabled: z.boolean().optional(),
|
||||
canvasDefaultModelId: z.string().nullable().optional(),
|
||||
@ -575,6 +620,7 @@ export async function PUT(request: NextRequest) {
|
||||
contactConfig,
|
||||
agentCategoryTreeConfig,
|
||||
agentSecondaryTypeConfig,
|
||||
agentWorkflowUiConfig,
|
||||
membershipConfig,
|
||||
membershipUserOverrides,
|
||||
pointsSystemConfig,
|
||||
@ -779,6 +825,26 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
if (agentWorkflowUiConfig !== undefined) {
|
||||
const normalized = normalizeAgentWorkflowUiConfig(agentWorkflowUiConfig);
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({
|
||||
key: AGENT_WORKFLOW_UI_CONFIG_KEY,
|
||||
value: normalized,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: systemSettings.key,
|
||||
set: {
|
||||
value: normalized,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: session.user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (membershipConfig !== undefined) {
|
||||
const normalized = normalizeMembershipConfig(membershipConfig);
|
||||
await db
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, use, useRef } from "react";
|
||||
import { useEffect, useState, useCallback, use, useRef, useMemo } from "react";
|
||||
import { useRouter, useParams, useSearchParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import {
|
||||
@ -57,7 +57,10 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { toast } from "sonner";
|
||||
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
|
||||
import { canvasService, imageGenerationService } from "@/services/canvas";
|
||||
import { AgentPresetPanel } from "@/components/agents/AgentPresetPanel";
|
||||
import {
|
||||
AgentWorkflowPanel,
|
||||
type AgentWorkflowInputs,
|
||||
} from "@/components/agents/AgentWorkflowPanel";
|
||||
import { CanvasWorkspace } from "@/components/canvas";
|
||||
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
|
||||
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
|
||||
@ -65,6 +68,16 @@ import { ObjectPickerOverlay } from "@/components/canvas/ObjectPickerOverlay";
|
||||
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
import {
|
||||
defaultAgentWorkflowUiConfig,
|
||||
normalizeAgentWorkflowUiConfig,
|
||||
} from "@/lib/agents/workflow-ui-config";
|
||||
import { buildAgentWorkflowComposerContext } from "@/lib/agents/workflow-composer";
|
||||
import type {
|
||||
AgentWorkflowComposerContext,
|
||||
AgentWorkflowPersistedState,
|
||||
AgentWorkflowStepKey,
|
||||
} from "@/types/agent";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@ -137,6 +150,18 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
|
||||
const [panelMode, setPanelMode] = useState<'chat' | 'fileList'>('chat');
|
||||
const [viewportWidth, setViewportWidth] = useState(0);
|
||||
const [workflowInputs, setWorkflowInputs] = useState<AgentWorkflowInputs>({
|
||||
primarySku: "",
|
||||
secondarySku: "",
|
||||
brand: "",
|
||||
store: "",
|
||||
});
|
||||
const [workflowSelections, setWorkflowSelections] = useState<
|
||||
Partial<Record<AgentWorkflowStepKey, string>>
|
||||
>({});
|
||||
const [workflowHydrationSource, setWorkflowHydrationSource] = useState<
|
||||
"pending" | "project" | "local" | "empty"
|
||||
>("pending");
|
||||
|
||||
const { data: siteSettingsData } = useSWR<{
|
||||
siteName?: string;
|
||||
@ -145,6 +170,24 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
agentEnabled?: boolean;
|
||||
realtimeCanvasEnabled?: boolean;
|
||||
realtimeCanvasServiceEnabled?: boolean;
|
||||
agentWorkflowUiConfig?: {
|
||||
steps?: Array<{
|
||||
key: AgentWorkflowStepKey;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
sortOrder?: number;
|
||||
options?: Array<{
|
||||
key: string;
|
||||
buttonName: string;
|
||||
description?: string | null;
|
||||
previewImageUrl?: string | null;
|
||||
enabled?: boolean;
|
||||
sortOrder?: number;
|
||||
promptFragment?: string | null;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
}>("/api/site-settings", fetcher);
|
||||
|
||||
// 生成配置
|
||||
@ -153,8 +196,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
const [aspectRatio, setAspectRatio] = useState("auto");
|
||||
const [imageCount, setImageCount] = useState(1);
|
||||
const previousNodeIdsRef = useRef<Set<string>>(new Set());
|
||||
const selectedAgentPresetId = searchParams.get("agentPresetId");
|
||||
const requestedAgentMode = mode === "agent" || Boolean(selectedAgentPresetId);
|
||||
const requestedAgentMode = mode === "agent";
|
||||
const isAgentFeatureEnabled = siteSettingsData?.agentEnabled !== false;
|
||||
const isAgentMode =
|
||||
isAgentFeatureEnabled && requestedAgentMode;
|
||||
@ -188,18 +230,6 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
[id, router, searchParams]
|
||||
);
|
||||
|
||||
const handleSelectAgentPreset = useCallback(
|
||||
(presetId: string | null) => {
|
||||
updateCanvasSearchParams({
|
||||
mode: presetId ? "agent" : "agent",
|
||||
agentPresetId: presetId,
|
||||
});
|
||||
setIsPanelOpen(true);
|
||||
setPanelMode("chat");
|
||||
},
|
||||
[updateCanvasSearchParams]
|
||||
);
|
||||
|
||||
const handleToggleAgentMode = useCallback(() => {
|
||||
if (isAgentMode) {
|
||||
updateCanvasSearchParams({
|
||||
@ -211,6 +241,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
|
||||
updateCanvasSearchParams({
|
||||
mode: "agent",
|
||||
agentPresetId: null,
|
||||
});
|
||||
setIsPanelOpen(true);
|
||||
setPanelMode("chat");
|
||||
@ -222,6 +253,14 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
|
||||
const loadProject = async () => {
|
||||
setIsLoading(true);
|
||||
setWorkflowHydrationSource("pending");
|
||||
setWorkflowInputs({
|
||||
primarySku: "",
|
||||
secondarySku: "",
|
||||
brand: "",
|
||||
store: "",
|
||||
});
|
||||
setWorkflowSelections({});
|
||||
|
||||
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
|
||||
useCanvasStore.getState().clearCanvas();
|
||||
@ -232,11 +271,27 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
useCanvasStore.getState().setProject(id, result.data.title);
|
||||
if (result.data.canvasData) {
|
||||
useCanvasStore.getState().loadCanvas(result.data.canvasData);
|
||||
const persistedWorkflow = result.data.canvasData.workflowState;
|
||||
if (persistedWorkflow) {
|
||||
setWorkflowInputs({
|
||||
primarySku: String(persistedWorkflow.inputs?.primarySku || "").trim(),
|
||||
secondarySku: String(persistedWorkflow.inputs?.secondarySku || "").trim(),
|
||||
brand: String(persistedWorkflow.inputs?.brand || "").trim(),
|
||||
store: String(persistedWorkflow.inputs?.store || "").trim(),
|
||||
});
|
||||
setWorkflowSelections(persistedWorkflow.selections || {});
|
||||
setWorkflowHydrationSource("project");
|
||||
} else {
|
||||
setWorkflowHydrationSource("empty");
|
||||
}
|
||||
} else {
|
||||
setWorkflowHydrationSource("empty");
|
||||
}
|
||||
} else {
|
||||
// 画布不存在时,创建一个新画布
|
||||
console.log('[Canvas] 画布不存在,创建新画布:', id);
|
||||
useCanvasStore.getState().setProject(id, '未命名画布');
|
||||
setWorkflowHydrationSource("empty");
|
||||
// 保存新画布到数据库
|
||||
try {
|
||||
await canvasService.saveCanvasState(id, { nodes: [], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select' });
|
||||
@ -264,16 +319,76 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
}
|
||||
}, [isAgentMode]);
|
||||
|
||||
const workflowStorageKey = useMemo(() => `canvas-workflow-state:${id}`, [id]);
|
||||
const buildPersistedCanvasData = useCallback(() => {
|
||||
const workflowState: AgentWorkflowPersistedState = {
|
||||
inputs: {
|
||||
primarySku: workflowInputs.primarySku.trim(),
|
||||
secondarySku: workflowInputs.secondarySku.trim(),
|
||||
brand: workflowInputs.brand.trim(),
|
||||
store: workflowInputs.store.trim(),
|
||||
},
|
||||
selections: workflowSelections,
|
||||
};
|
||||
|
||||
return {
|
||||
...getCanvasState(),
|
||||
workflowState,
|
||||
};
|
||||
}, [getCanvasState, workflowInputs, workflowSelections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (workflowHydrationSource !== "empty") return;
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(workflowStorageKey);
|
||||
if (!raw) {
|
||||
setWorkflowHydrationSource("local");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as AgentWorkflowPersistedState;
|
||||
setWorkflowInputs({
|
||||
primarySku: String(parsed.inputs?.primarySku || "").trim(),
|
||||
secondarySku: String(parsed.inputs?.secondarySku || "").trim(),
|
||||
brand: String(parsed.inputs?.brand || "").trim(),
|
||||
store: String(parsed.inputs?.store || "").trim(),
|
||||
});
|
||||
setWorkflowSelections(parsed.selections || {});
|
||||
} catch (error) {
|
||||
console.warn("[Canvas] Failed to hydrate workflow state from localStorage:", error);
|
||||
} finally {
|
||||
setWorkflowHydrationSource("local");
|
||||
}
|
||||
}, [workflowHydrationSource, workflowStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || workflowHydrationSource === "pending") return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
workflowStorageKey,
|
||||
JSON.stringify({
|
||||
inputs: workflowInputs,
|
||||
selections: workflowSelections,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("[Canvas] Failed to persist workflow state:", error);
|
||||
}
|
||||
}, [workflowHydrationSource, workflowInputs, workflowSelections, workflowStorageKey]);
|
||||
|
||||
const persistCanvasState = useCallback(
|
||||
async (reason: "autosave" | "delete" | "beforeunload") => {
|
||||
const canvasData = getCanvasState();
|
||||
const canvasData = buildPersistedCanvasData();
|
||||
const result = await canvasService.saveCanvasState(id, canvasData);
|
||||
if (!result.success) {
|
||||
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
||||
}
|
||||
return result.success;
|
||||
},
|
||||
[getCanvasState, id]
|
||||
[buildPersistedCanvasData, id]
|
||||
);
|
||||
|
||||
// 自动保存逻辑
|
||||
@ -291,7 +406,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
}, 3000); // 3秒自动保存
|
||||
|
||||
return () => clearTimeout(saveTimer);
|
||||
}, [nodes, isLoading, persistCanvasState]);
|
||||
}, [nodes, workflowInputs, workflowSelections, isLoading, persistCanvasState]);
|
||||
|
||||
// 删除节点时立即保存(按节点ID差集检测),避免删除后快速刷新被回滚
|
||||
useEffect(() => {
|
||||
@ -330,7 +445,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
try {
|
||||
const canvasData = getCanvasState();
|
||||
const canvasData = buildPersistedCanvasData();
|
||||
void fetch(`/api/canvas/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@ -348,7 +463,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
window.removeEventListener("pagehide", handleBeforeUnload);
|
||||
};
|
||||
}, [getCanvasState, id, isLoading]);
|
||||
}, [buildPersistedCanvasData, id, isLoading]);
|
||||
|
||||
// 智能命名与缩略图逻辑
|
||||
useEffect(() => {
|
||||
@ -389,7 +504,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
// 保存画布 (手动触发保留作为备用,但UI上移除)
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
const canvasData = getCanvasState();
|
||||
const canvasData = buildPersistedCanvasData();
|
||||
const result = await canvasService.saveCanvasState(id, canvasData);
|
||||
|
||||
if (result.success) {
|
||||
@ -398,7 +513,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
toast.error(result.error || "保存失败");
|
||||
}
|
||||
setIsSaving(false);
|
||||
}, [id, getCanvasState]);
|
||||
}, [buildPersistedCanvasData, id]);
|
||||
|
||||
// 更新标题
|
||||
const handleUpdateTitle = async () => {
|
||||
@ -729,6 +844,18 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
const resolvedWorkflowUiConfig = useMemo(
|
||||
() => normalizeAgentWorkflowUiConfig(siteSettingsData?.agentWorkflowUiConfig),
|
||||
[siteSettingsData?.agentWorkflowUiConfig]
|
||||
);
|
||||
|
||||
const workflowContext = useMemo<AgentWorkflowComposerContext | null>(() => {
|
||||
return buildAgentWorkflowComposerContext({
|
||||
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
|
||||
inputs: workflowInputs,
|
||||
selections: workflowSelections,
|
||||
});
|
||||
}, [resolvedWorkflowUiConfig, workflowInputs, workflowSelections]);
|
||||
|
||||
if (isAgentMode) {
|
||||
return (
|
||||
@ -740,9 +867,21 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
|
||||
<div className="absolute inset-0 flex pt-[72px]">
|
||||
<div className="min-w-0 flex-1 border-r bg-white dark:bg-zinc-950">
|
||||
<AgentPresetPanel
|
||||
selectedPresetId={selectedAgentPresetId}
|
||||
onSelectPreset={handleSelectAgentPreset}
|
||||
<AgentWorkflowPanel
|
||||
config={resolvedWorkflowUiConfig}
|
||||
inputs={workflowInputs}
|
||||
onInputsChange={setWorkflowInputs}
|
||||
selections={workflowSelections}
|
||||
onSelectionsChange={setWorkflowSelections}
|
||||
onReset={() => {
|
||||
setWorkflowInputs({
|
||||
primarySku: "",
|
||||
secondarySku: "",
|
||||
brand: "",
|
||||
store: "",
|
||||
});
|
||||
setWorkflowSelections({});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -754,8 +893,9 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
<CanvasChatPanel
|
||||
chatId={`canvas-${id}`}
|
||||
onClose={() => {}}
|
||||
selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
|
||||
selectedAgentPresetId={null}
|
||||
chatScope="agent"
|
||||
workflowContext={workflowContext}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -812,7 +952,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
chatId={`canvas-${id}`}
|
||||
onClose={() => setIsPanelOpen(false)}
|
||||
onOpenFileList={() => setPanelMode('fileList')}
|
||||
selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
|
||||
selectedAgentPresetId={null}
|
||||
chatScope="canvas"
|
||||
/>
|
||||
</div>
|
||||
|
||||
308
components/agents/AgentWorkflowPanel.tsx
Normal file
308
components/agents/AgentWorkflowPanel.tsx
Normal file
@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Copy, RotateCcw, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
|
||||
import {
|
||||
buildAgentWorkflowComposerContext,
|
||||
getAgentWorkflowProgress,
|
||||
} from "@/lib/agents/workflow-composer";
|
||||
import type {
|
||||
AgentWorkflowComposerSelection,
|
||||
AgentWorkflowStepKey,
|
||||
AgentWorkflowUiConfig,
|
||||
} from "@/types/agent";
|
||||
|
||||
export interface AgentWorkflowInputs {
|
||||
primarySku: string;
|
||||
secondarySku: string;
|
||||
brand: string;
|
||||
store: string;
|
||||
}
|
||||
|
||||
interface AgentWorkflowPanelProps {
|
||||
config?: AgentWorkflowUiConfig | null;
|
||||
inputs: AgentWorkflowInputs;
|
||||
onInputsChange: (next: AgentWorkflowInputs) => void;
|
||||
selections: Partial<Record<AgentWorkflowStepKey, string>>;
|
||||
onSelectionsChange: (next: Partial<Record<AgentWorkflowStepKey, string>>) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
function buildSelectionSummary(params: {
|
||||
config: AgentWorkflowUiConfig;
|
||||
selections: Partial<Record<AgentWorkflowStepKey, string>>;
|
||||
}) {
|
||||
const result: AgentWorkflowComposerSelection[] = [];
|
||||
|
||||
for (const step of params.config.steps) {
|
||||
const selectedOptionKey = params.selections[step.key];
|
||||
if (!selectedOptionKey) continue;
|
||||
|
||||
const option = step.options.find((item) => item.key === selectedOptionKey && item.enabled);
|
||||
if (!option) continue;
|
||||
|
||||
result.push({
|
||||
stepKey: step.key,
|
||||
stepTitle: step.title,
|
||||
optionKey: option.key,
|
||||
optionName: option.buttonName,
|
||||
promptFragment: option.promptFragment || "",
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function AgentWorkflowPanel({
|
||||
config,
|
||||
inputs,
|
||||
onInputsChange,
|
||||
selections,
|
||||
onSelectionsChange,
|
||||
onReset,
|
||||
}: AgentWorkflowPanelProps) {
|
||||
const resolvedConfig = useMemo(() => {
|
||||
const source = config?.steps?.length ? config : defaultAgentWorkflowUiConfig;
|
||||
return {
|
||||
steps: [...source.steps]
|
||||
.filter((step) => step.enabled)
|
||||
.map((step) => ({
|
||||
...step,
|
||||
options: [...step.options]
|
||||
.filter((option) => option.enabled)
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder),
|
||||
}))
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder),
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
const summary = useMemo(
|
||||
() => buildSelectionSummary({ config: resolvedConfig, selections }),
|
||||
[resolvedConfig, selections]
|
||||
);
|
||||
const composerContext = useMemo(
|
||||
() =>
|
||||
buildAgentWorkflowComposerContext({
|
||||
config: resolvedConfig,
|
||||
inputs,
|
||||
selections,
|
||||
}),
|
||||
[inputs, resolvedConfig, selections]
|
||||
);
|
||||
const progress = useMemo(
|
||||
() => getAgentWorkflowProgress({ config: resolvedConfig, selections }),
|
||||
[resolvedConfig, selections]
|
||||
);
|
||||
|
||||
const handleCopyPrompt = async () => {
|
||||
if (!composerContext?.promptText) {
|
||||
toast.error("当前还没有可复制的 Prompt");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(composerContext.promptText);
|
||||
toast.success("Prompt 已复制");
|
||||
} catch {
|
||||
toast.error("复制失败,请手动复制");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-r bg-white dark:bg-zinc-950">
|
||||
<div className="border-b p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||
智能体工作流
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
左侧完成步骤选择,右侧执行与追调。
|
||||
</div>
|
||||
<div className="mt-2 inline-flex items-center rounded-full bg-muted px-2.5 py-1 text-[11px] text-muted-foreground">
|
||||
已完成 {progress.completed}/{progress.total} 个环节
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (onReset) {
|
||||
onReset();
|
||||
return;
|
||||
}
|
||||
onSelectionsChange({});
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="mr-1 h-4 w-4" />
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-5 p-4">
|
||||
<section className="space-y-3">
|
||||
<div className="text-sm font-medium">基础输入</div>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Input
|
||||
value={inputs.primarySku}
|
||||
onChange={(event) =>
|
||||
onInputsChange({ ...inputs, primarySku: event.target.value })
|
||||
}
|
||||
placeholder="主 SKU"
|
||||
/>
|
||||
<Input
|
||||
value={inputs.secondarySku}
|
||||
onChange={(event) =>
|
||||
onInputsChange({ ...inputs, secondarySku: event.target.value })
|
||||
}
|
||||
placeholder="搭配 SKU"
|
||||
/>
|
||||
<Input
|
||||
value={inputs.brand}
|
||||
onChange={(event) =>
|
||||
onInputsChange({ ...inputs, brand: event.target.value })
|
||||
}
|
||||
placeholder="品牌"
|
||||
/>
|
||||
<Input
|
||||
value={inputs.store}
|
||||
onChange={(event) =>
|
||||
onInputsChange({ ...inputs, store: event.target.value })
|
||||
}
|
||||
placeholder="店铺"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{resolvedConfig.steps.map((step) => (
|
||||
<section key={step.key} className="space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{step.title}</div>
|
||||
{step.description ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">{step.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{step.options.map((option) => {
|
||||
const selected = selections[step.key] === option.key;
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSelectionsChange({
|
||||
...selections,
|
||||
[step.key]: selected ? undefined : option.key,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"overflow-hidden rounded-2xl border text-left transition-all",
|
||||
selected
|
||||
? "border-primary bg-primary/5 shadow-[0_0_0_1px_rgba(59,130,246,0.12)]"
|
||||
: "border-border bg-background hover:border-primary/40 hover:shadow-sm"
|
||||
)}
|
||||
>
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden bg-muted/40">
|
||||
{option.previewImageUrl ? (
|
||||
<img
|
||||
src={option.previewImageUrl}
|
||||
alt={option.buttonName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-xs text-muted-foreground">
|
||||
暂无预览图
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-2 top-2 rounded-full bg-white/90 px-2 py-1 text-[11px] font-medium text-black shadow-sm">
|
||||
{selected ? "已选择" : step.title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5 p-3">
|
||||
<div className="text-sm font-semibold">{option.buttonName}</div>
|
||||
<div className="line-clamp-2 text-xs text-muted-foreground">
|
||||
{option.description || "点击选择该工作流方案"}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section className="space-y-3 rounded-2xl border bg-muted/20 p-4">
|
||||
<div className="text-sm font-medium">当前工作流摘要</div>
|
||||
{inputs.primarySku || inputs.secondarySku || inputs.brand || inputs.store ? (
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
{inputs.primarySku ? (
|
||||
<span className="rounded-full bg-background px-2 py-1">主SKU:{inputs.primarySku}</span>
|
||||
) : null}
|
||||
{inputs.secondarySku ? (
|
||||
<span className="rounded-full bg-background px-2 py-1">搭配SKU:{inputs.secondarySku}</span>
|
||||
) : null}
|
||||
{inputs.brand ? (
|
||||
<span className="rounded-full bg-background px-2 py-1">品牌:{inputs.brand}</span>
|
||||
) : null}
|
||||
{inputs.store ? (
|
||||
<span className="rounded-full bg-background px-2 py-1">店铺:{inputs.store}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{summary.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={`${item.stepKey}-${item.optionKey}`}
|
||||
className="flex items-center justify-between rounded-xl bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-muted-foreground">{item.stepTitle}</span>
|
||||
<span className="font-medium">{item.optionName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
还没有选择工作流方案,先从上面的卡片开始。
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 rounded-2xl border bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Prompt 预览</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
右侧发送时,会把下面这份工作流设定作为隐藏上下文一起注入。
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleCopyPrompt}>
|
||||
<Copy className="mr-1 h-4 w-4" />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={composerContext?.promptText || ""}
|
||||
readOnly
|
||||
rows={10}
|
||||
placeholder="选择左侧工作流后,这里会自动生成结构化 Prompt。"
|
||||
className="resize-none bg-background"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -48,7 +48,7 @@ import {
|
||||
} from "@/lib/image-url";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||
import type { AgentPreset } from "@/types/agent";
|
||||
import type { AgentPreset, AgentWorkflowComposerContext } from "@/types/agent";
|
||||
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
|
||||
import {
|
||||
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
|
||||
@ -110,6 +110,7 @@ interface CanvasChatPanelProps {
|
||||
onOpenFileList?: () => void;
|
||||
selectedAgentPresetId?: string | null;
|
||||
chatScope?: "canvas" | "agent";
|
||||
workflowContext?: AgentWorkflowComposerContext | null;
|
||||
}
|
||||
|
||||
type VideoStoryboardStreamData =
|
||||
@ -1632,6 +1633,7 @@ export function CanvasChatPanel({
|
||||
onOpenFileList,
|
||||
selectedAgentPresetId,
|
||||
chatScope = "canvas",
|
||||
workflowContext = null,
|
||||
}: CanvasChatPanelProps) {
|
||||
const { data: session } = useSession();
|
||||
const [input, setInput] = useState("");
|
||||
@ -2759,6 +2761,12 @@ export function CanvasChatPanel({
|
||||
() => (agentPresetResponse?.success && agentPresetResponse.data ? agentPresetResponse.data : null),
|
||||
[agentPresetResponse]
|
||||
);
|
||||
const hasWorkflowContext = Boolean(
|
||||
workflowContext &&
|
||||
(String(workflowContext.summaryText || "").trim() ||
|
||||
String(workflowContext.promptText || "").trim() ||
|
||||
workflowContext.selections.length > 0)
|
||||
);
|
||||
const SelectedSkillTagIcon = useMemo(
|
||||
() => getCanvasSkillIconComponent(selectedSkillTag?.iconName),
|
||||
[selectedSkillTag?.iconName]
|
||||
@ -6795,7 +6803,9 @@ export function CanvasChatPanel({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!input.trim() && attachments.length === 0 && !selectedSkillTag) return;
|
||||
if (!input.trim() && attachments.length === 0 && !selectedSkillTag && !hasWorkflowContext) {
|
||||
return;
|
||||
}
|
||||
const effectiveModelId = currentModelIdRef.current || selectedModelId;
|
||||
if (!effectiveModelId) {
|
||||
toast.error("请选择一个模型");
|
||||
@ -6894,6 +6904,9 @@ export function CanvasChatPanel({
|
||||
if (!textContent && effectiveAttachments.length > 0) {
|
||||
textContent = "请处理这张图片";
|
||||
}
|
||||
if (!textContent && hasWorkflowContext && workflowContext?.summaryText) {
|
||||
textContent = `请基于当前工作流设定生成方案:${workflowContext.summaryText}`;
|
||||
}
|
||||
if (!textContent && hasAgentUploadSlots && selectedAgentPreset) {
|
||||
textContent = `请按「${selectedAgentPreset.name}」智能体配置顺序处理产品图1、产品图2和模特图。`;
|
||||
}
|
||||
@ -6908,6 +6921,11 @@ export function CanvasChatPanel({
|
||||
`【快捷标签:${selectedSkillTag.name}】\n${selectedSkillTag.prompt}`
|
||||
);
|
||||
}
|
||||
if (hasWorkflowContext && workflowContext?.promptText) {
|
||||
hiddenSegments.push(
|
||||
`【工作流设定】\n${workflowContext.promptText}\n\n请严格遵循以上工作流设定输出内容,并保持整体结果一致。`
|
||||
);
|
||||
}
|
||||
if (selectedAgentPreset?.type === "text" && selectedAgentPreset.slots?.length) {
|
||||
const presetPrompt = selectedAgentPreset.slots
|
||||
.map((slot, index) => {
|
||||
@ -7016,6 +7034,7 @@ export function CanvasChatPanel({
|
||||
input,
|
||||
attachments,
|
||||
hasAgentUploadSlots,
|
||||
hasWorkflowContext,
|
||||
selectedSkillTag,
|
||||
selectedAgentPreset,
|
||||
selectedModelId,
|
||||
@ -7038,6 +7057,7 @@ export function CanvasChatPanel({
|
||||
setSendGuard,
|
||||
shouldBlockDuplicateSend,
|
||||
status,
|
||||
workflowContext,
|
||||
]);
|
||||
|
||||
const maybeAutoSetCanvasTitle = useCallback(async (firstImageUrl: string | null) => {
|
||||
@ -9256,6 +9276,17 @@ export function CanvasChatPanel({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isDetailGenerationMode && hasWorkflowContext && workflowContext && (
|
||||
<div className="mb-2 rounded-2xl border border-[#dbe3ff] bg-[#f6f8ff] px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-[#334155]">
|
||||
<Sparkles className="h-4 w-4 text-[#4f46e5]" />
|
||||
<span>当前工作流设定</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-[#5b6472]">
|
||||
{workflowContext.summaryText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isDetailGenerationMode && activeComposerMode === "agent" && selectedAgentPreset && (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-[#eef2ff] px-3 py-1.5 text-sm font-medium text-[#4338ca]">
|
||||
|
||||
1048
docs/智能体页面整改升级文档-2026-03-14.md
Normal file
1048
docs/智能体页面整改升级文档-2026-03-14.md
Normal file
File diff suppressed because it is too large
Load Diff
106
lib/agents/workflow-composer.ts
Normal file
106
lib/agents/workflow-composer.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
|
||||
import type {
|
||||
AgentWorkflowComposerContext,
|
||||
AgentWorkflowStepKey,
|
||||
AgentWorkflowUiConfig,
|
||||
} from "@/types/agent";
|
||||
|
||||
export interface AgentWorkflowInputsValue {
|
||||
primarySku: string;
|
||||
secondarySku: string;
|
||||
brand: string;
|
||||
store: string;
|
||||
}
|
||||
|
||||
export function buildAgentWorkflowComposerContext(params: {
|
||||
config?: AgentWorkflowUiConfig | null;
|
||||
inputs: AgentWorkflowInputsValue;
|
||||
selections: Partial<Record<AgentWorkflowStepKey, string>>;
|
||||
}): AgentWorkflowComposerContext | null {
|
||||
const config = params.config || defaultAgentWorkflowUiConfig;
|
||||
const steps = Array.isArray(config.steps) ? config.steps : [];
|
||||
const selections = steps
|
||||
.filter((step) => step.enabled !== false)
|
||||
.map((step) => {
|
||||
const selectedKey = params.selections[step.key];
|
||||
if (!selectedKey) return null;
|
||||
const option = (step.options || []).find(
|
||||
(item) => item.key === selectedKey && item.enabled !== false
|
||||
);
|
||||
if (!option) return null;
|
||||
return {
|
||||
stepKey: step.key,
|
||||
stepTitle: step.title,
|
||||
optionKey: option.key,
|
||||
optionName: option.buttonName,
|
||||
promptFragment: option.promptFragment || option.buttonName,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as AgentWorkflowComposerContext["selections"];
|
||||
|
||||
const summaryParts: string[] = [];
|
||||
if (params.inputs.primarySku.trim()) {
|
||||
summaryParts.push(`主SKU:${params.inputs.primarySku.trim()}`);
|
||||
}
|
||||
if (params.inputs.secondarySku.trim()) {
|
||||
summaryParts.push(`搭配SKU:${params.inputs.secondarySku.trim()}`);
|
||||
}
|
||||
if (params.inputs.brand.trim()) {
|
||||
summaryParts.push(`品牌:${params.inputs.brand.trim()}`);
|
||||
}
|
||||
if (params.inputs.store.trim()) {
|
||||
summaryParts.push(`店铺:${params.inputs.store.trim()}`);
|
||||
}
|
||||
selections.forEach((item) => {
|
||||
summaryParts.push(`${item.stepTitle}:${item.optionName}`);
|
||||
});
|
||||
|
||||
if (summaryParts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const promptLines: string[] = [];
|
||||
if (params.inputs.primarySku.trim()) {
|
||||
promptLines.push(`主SKU:${params.inputs.primarySku.trim()}`);
|
||||
}
|
||||
if (params.inputs.secondarySku.trim()) {
|
||||
promptLines.push(`搭配SKU:${params.inputs.secondarySku.trim()}`);
|
||||
}
|
||||
if (params.inputs.brand.trim()) {
|
||||
promptLines.push(`品牌:${params.inputs.brand.trim()}`);
|
||||
}
|
||||
if (params.inputs.store.trim()) {
|
||||
promptLines.push(`店铺:${params.inputs.store.trim()}`);
|
||||
}
|
||||
selections.forEach((item) => {
|
||||
promptLines.push(`${item.stepTitle}:${item.promptFragment || item.optionName}`);
|
||||
});
|
||||
|
||||
return {
|
||||
primarySku: params.inputs.primarySku.trim() || undefined,
|
||||
secondarySku: params.inputs.secondarySku.trim() || undefined,
|
||||
brand: params.inputs.brand.trim() || undefined,
|
||||
store: params.inputs.store.trim() || undefined,
|
||||
selections,
|
||||
summaryText: summaryParts.join(" / "),
|
||||
promptText: promptLines.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAgentWorkflowProgress(params: {
|
||||
config?: AgentWorkflowUiConfig | null;
|
||||
selections: Partial<Record<AgentWorkflowStepKey, string>>;
|
||||
}) {
|
||||
const config = params.config || defaultAgentWorkflowUiConfig;
|
||||
const enabledSteps = (config.steps || []).filter((step) => step.enabled !== false);
|
||||
const completedSteps = enabledSteps.filter((step) => {
|
||||
const value = params.selections[step.key];
|
||||
return Boolean(String(value || "").trim());
|
||||
});
|
||||
|
||||
return {
|
||||
total: enabledSteps.length,
|
||||
completed: completedSteps.length,
|
||||
isComplete: enabledSteps.length > 0 && completedSteps.length === enabledSteps.length,
|
||||
};
|
||||
}
|
||||
215
lib/agents/workflow-ui-config.ts
Normal file
215
lib/agents/workflow-ui-config.ts
Normal file
@ -0,0 +1,215 @@
|
||||
import type {
|
||||
AgentWorkflowOptionUiConfig,
|
||||
AgentWorkflowStepKey,
|
||||
AgentWorkflowStepUiConfig,
|
||||
AgentWorkflowUiConfig,
|
||||
} from "@/types/agent";
|
||||
|
||||
export const AGENT_WORKFLOW_UI_CONFIG_KEY = "agent_workflow_ui_config";
|
||||
|
||||
function cloneOption(
|
||||
option: AgentWorkflowOptionUiConfig
|
||||
): AgentWorkflowOptionUiConfig {
|
||||
return {
|
||||
...option,
|
||||
description: option.description || "",
|
||||
previewImageUrl: option.previewImageUrl || "",
|
||||
promptFragment: option.promptFragment || "",
|
||||
};
|
||||
}
|
||||
|
||||
function cloneStep(step: AgentWorkflowStepUiConfig): AgentWorkflowStepUiConfig {
|
||||
return {
|
||||
...step,
|
||||
description: step.description || "",
|
||||
options: step.options.map(cloneOption),
|
||||
};
|
||||
}
|
||||
|
||||
function createStep(
|
||||
key: AgentWorkflowStepKey,
|
||||
title: string,
|
||||
description: string,
|
||||
options: AgentWorkflowOptionUiConfig[]
|
||||
): AgentWorkflowStepUiConfig {
|
||||
return {
|
||||
key,
|
||||
title,
|
||||
description,
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(
|
||||
key: string,
|
||||
buttonName: string,
|
||||
promptFragment: string,
|
||||
sortOrder: number
|
||||
): AgentWorkflowOptionUiConfig {
|
||||
return {
|
||||
key,
|
||||
buttonName,
|
||||
description: "",
|
||||
previewImageUrl: "",
|
||||
enabled: true,
|
||||
sortOrder,
|
||||
promptFragment,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultSteps: AgentWorkflowStepUiConfig[] = [
|
||||
createStep("audience", "人群选择", "先确认目标家庭结构与生活状态。", [
|
||||
createOption("single_youth", "独居青年", "面向独居青年,强调轻盈、效率感和个人表达。", 0),
|
||||
createOption("young_couple", "年轻情侣", "面向年轻情侣,强调陪伴感、松弛感和高颜值日常。", 1),
|
||||
createOption("midlife_no_kids", "中年无孩夫妻", "面向中年无孩夫妻,强调稳定、质感与舒适收纳。", 2),
|
||||
createOption("family_with_kids", "有孩家庭", "面向有孩家庭,强调互动、耐用、安全与成长场景。", 3),
|
||||
createOption("senior_couple", "老年夫妻", "面向老年夫妻,强调便利、温暖、低刺激与安心动线。", 4),
|
||||
]),
|
||||
createStep("style", "主题风格", "确定空间视觉主风格。", [
|
||||
createOption("japanese", "日系风", "整体风格为日系,强调自然木色、留白与温和秩序。", 0),
|
||||
createOption("new_chinese", "新中式", "整体风格为新中式,强调东方韵味与现代克制。", 1),
|
||||
createOption("american", "美式", "整体风格为美式,强调舒展体量、生活感与复古质感。", 2),
|
||||
createOption("healing", "治愈风", "整体风格为治愈风,强调柔和、放松与亲和力。", 3),
|
||||
createOption("modern", "现代风", "整体风格为现代风,强调简洁、利落与当代感。", 4),
|
||||
createOption("eclectic", "混搭风", "整体风格为混搭风,强调层次变化与个性搭配。", 5),
|
||||
createOption("vintage", "中古风", "整体风格为中古风,强调复古家具与时间感。", 6),
|
||||
createOption("cream", "奶油风", "整体风格为奶油风,强调柔光、低饱和与绵密材质。", 7),
|
||||
]),
|
||||
createStep("space", "空间选择", "确定主要空间。", [
|
||||
createOption("living_room", "客厅", "主空间为客厅。", 0),
|
||||
createOption("kitchen", "厨房", "主空间为厨房。", 1),
|
||||
createOption("bedroom", "卧室", "主空间为卧室。", 2),
|
||||
createOption("dining_room", "餐厅", "主空间为餐厅。", 3),
|
||||
createOption("study", "书房", "主空间为书房。", 4),
|
||||
createOption("foyer", "门厅", "主空间为门厅。", 5),
|
||||
createOption("balcony", "阳台", "主空间为阳台。", 6),
|
||||
createOption("bathroom", "卫生间", "主空间为卫生间。", 7),
|
||||
]),
|
||||
createStep("subSpace", "子空间选择", "确定重点表现区域。", [
|
||||
createOption("tv_console", "电视柜空间", "重点表现电视柜空间与背景墙关系。", 0),
|
||||
createOption("sofa_area", "沙发空间", "重点表现沙发空间与会客氛围。", 1),
|
||||
createOption("dining_table", "餐桌空间", "重点表现餐桌空间与就餐动线。", 2),
|
||||
createOption("bedside_area", "床头空间", "重点表现床头空间与睡眠氛围。", 3),
|
||||
createOption("island_area", "岛台空间", "重点表现岛台空间与操作效率。", 4),
|
||||
createOption("desk_area", "书桌空间", "重点表现书桌空间与专注氛围。", 5),
|
||||
]),
|
||||
createStep("hardDecoration", "硬装方案", "确定空间硬装基调。", [
|
||||
createOption("hard_plan_1", "硬装方案1", "硬装采用方案1,整体偏克制基础款。", 0),
|
||||
createOption("hard_plan_2", "硬装方案2", "硬装采用方案2,整体偏精致层次款。", 1),
|
||||
createOption("hard_plan_3", "硬装方案3", "硬装采用方案3,整体偏视觉强化款。", 2),
|
||||
]),
|
||||
createStep("softDecoration", "软装方案", "确定软装搭配方向。", [
|
||||
createOption("soft_plan_1", "软装方案1", "软装采用方案1,整体偏稳妥耐看。", 0),
|
||||
createOption("soft_plan_2", "软装方案2", "软装采用方案2,整体偏生活感与柔和搭配。", 1),
|
||||
createOption("soft_plan_3", "软装方案3", "软装采用方案3,整体偏高辨识度和氛围表达。", 2),
|
||||
]),
|
||||
createStep("lighting", "光氛选择", "确定光照时段与氛围。", [
|
||||
createOption("natural_daylight", "自然阳光", "光照氛围为自然阳光,通透明亮。", 0),
|
||||
createOption("warm_afternoon", "暖调午后", "光照氛围为暖调午后,柔和松弛。", 1),
|
||||
createOption("romantic_night", "浪漫夜晚", "光照氛围为浪漫夜晚,暖光层次分明。", 2),
|
||||
]),
|
||||
createStep("accessories", "配饰陈列", "确定配饰收口与陈列方式。", [
|
||||
createOption("accessories_a", "配饰方案A", "配饰采用方案A,强调克制点缀。", 0),
|
||||
createOption("accessories_b", "配饰方案B", "配饰采用方案B,强调生活化陈列。", 1),
|
||||
createOption("accessories_c", "配饰方案C", "配饰采用方案C,强调高完成度展示。", 2),
|
||||
]),
|
||||
];
|
||||
|
||||
export const defaultAgentWorkflowUiConfig: AgentWorkflowUiConfig = {
|
||||
steps: defaultSteps.map((step, index) =>
|
||||
cloneStep({
|
||||
...step,
|
||||
sortOrder: index,
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
function normalizeText(value: unknown, maxLength: number) {
|
||||
return String(value || "").trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown, fallback: boolean) {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeNumber(value: unknown, fallback: number, max = 999) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.max(0, Math.min(max, Math.round(parsed)));
|
||||
}
|
||||
|
||||
export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiConfig {
|
||||
const raw =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
const rawSteps = Array.isArray(raw.steps) ? raw.steps : [];
|
||||
|
||||
const normalizedSteps = defaultAgentWorkflowUiConfig.steps.map((defaultStep, stepIndex) => {
|
||||
const matchedStep =
|
||||
rawSteps.find(
|
||||
(item) =>
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
normalizeText((item as Record<string, unknown>).key, 64) === defaultStep.key
|
||||
) || null;
|
||||
const matchedStepRecord =
|
||||
matchedStep && typeof matchedStep === "object"
|
||||
? (matchedStep as Record<string, unknown>)
|
||||
: null;
|
||||
const rawOptions = Array.isArray(matchedStepRecord?.options)
|
||||
? (matchedStepRecord?.options as unknown[])
|
||||
: [];
|
||||
|
||||
const options = defaultStep.options.map((defaultOption, optionIndex) => {
|
||||
const matchedOption =
|
||||
rawOptions.find(
|
||||
(item) =>
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
normalizeText((item as Record<string, unknown>).key, 64) === defaultOption.key
|
||||
) || null;
|
||||
const matchedOptionRecord =
|
||||
matchedOption && typeof matchedOption === "object"
|
||||
? (matchedOption as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
return {
|
||||
key: defaultOption.key,
|
||||
buttonName:
|
||||
normalizeText(matchedOptionRecord?.buttonName, 64) || defaultOption.buttonName,
|
||||
description:
|
||||
normalizeText(matchedOptionRecord?.description, 300) || defaultOption.description || "",
|
||||
previewImageUrl: normalizeText(matchedOptionRecord?.previewImageUrl, 1000),
|
||||
enabled: normalizeBoolean(matchedOptionRecord?.enabled, defaultOption.enabled),
|
||||
sortOrder: normalizeNumber(
|
||||
matchedOptionRecord?.sortOrder,
|
||||
defaultOption.sortOrder ?? optionIndex
|
||||
),
|
||||
promptFragment:
|
||||
normalizeText(matchedOptionRecord?.promptFragment, 1000) ||
|
||||
defaultOption.promptFragment ||
|
||||
"",
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
key: defaultStep.key,
|
||||
title: normalizeText(matchedStepRecord?.title, 64) || defaultStep.title,
|
||||
description:
|
||||
normalizeText(matchedStepRecord?.description, 300) || defaultStep.description || "",
|
||||
enabled: normalizeBoolean(matchedStepRecord?.enabled, defaultStep.enabled),
|
||||
sortOrder: normalizeNumber(
|
||||
matchedStepRecord?.sortOrder,
|
||||
defaultStep.sortOrder ?? stepIndex
|
||||
),
|
||||
options,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
steps: normalizedSteps.sort((left, right) => left.sortOrder - right.sortOrder),
|
||||
};
|
||||
}
|
||||
@ -601,6 +601,15 @@ export const canvasProject = pgTable("CanvasProject", {
|
||||
viewport?: { x: number; y: number; zoom: number };
|
||||
tool?: string;
|
||||
suppressedMediaKeys?: string[];
|
||||
workflowState?: {
|
||||
inputs?: {
|
||||
primarySku?: string;
|
||||
secondarySku?: string;
|
||||
brand?: string;
|
||||
store?: string;
|
||||
};
|
||||
selections?: Record<string, string>;
|
||||
};
|
||||
}>(),
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
|
||||
@ -10,6 +10,67 @@ export interface AgentCategoryPath {
|
||||
brand?: string | null;
|
||||
}
|
||||
|
||||
export type AgentWorkflowStepKey =
|
||||
| "audience"
|
||||
| "style"
|
||||
| "space"
|
||||
| "subSpace"
|
||||
| "hardDecoration"
|
||||
| "softDecoration"
|
||||
| "lighting"
|
||||
| "accessories";
|
||||
|
||||
export interface AgentWorkflowOptionUiConfig {
|
||||
key: string;
|
||||
buttonName: string;
|
||||
description?: string | null;
|
||||
previewImageUrl?: string | null;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
promptFragment?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentWorkflowStepUiConfig {
|
||||
key: AgentWorkflowStepKey;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
options: AgentWorkflowOptionUiConfig[];
|
||||
}
|
||||
|
||||
export interface AgentWorkflowUiConfig {
|
||||
steps: AgentWorkflowStepUiConfig[];
|
||||
}
|
||||
|
||||
export interface AgentWorkflowComposerSelection {
|
||||
stepKey: AgentWorkflowStepKey;
|
||||
stepTitle: string;
|
||||
optionKey: string;
|
||||
optionName: string;
|
||||
promptFragment?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentWorkflowComposerContext {
|
||||
primarySku?: string;
|
||||
secondarySku?: string;
|
||||
brand?: string;
|
||||
store?: string;
|
||||
selections: AgentWorkflowComposerSelection[];
|
||||
summaryText: string;
|
||||
promptText: string;
|
||||
}
|
||||
|
||||
export interface AgentWorkflowPersistedState {
|
||||
inputs: {
|
||||
primarySku: string;
|
||||
secondarySku: string;
|
||||
brand: string;
|
||||
store: string;
|
||||
};
|
||||
selections: Partial<Record<AgentWorkflowStepKey, string>>;
|
||||
}
|
||||
|
||||
export interface AgentCategoryTreeConfig {
|
||||
productCategories: string[];
|
||||
audienceTags: string[];
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { AgentWorkflowPersistedState } from "@/types/agent";
|
||||
|
||||
// 画布节点类型定义
|
||||
|
||||
export type NodeType = 'message' | 'image' | 'video' | 'text' | 'scene-fusion' | 'model3d';
|
||||
@ -138,6 +140,7 @@ export interface CanvasState {
|
||||
viewport: Viewport;
|
||||
tool: ToolType;
|
||||
suppressedMediaKeys?: string[];
|
||||
workflowState?: AgentWorkflowPersistedState;
|
||||
}
|
||||
|
||||
// 画布项目
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user