feat: add agent workflow mode
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-16 14:28:25 +08:00
parent 31f8c004b2
commit 176fc94293
11 changed files with 2373 additions and 30 deletions

View File

@ -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>

View File

@ -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

View File

@ -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>

View 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>
);
}

View File

@ -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]">

File diff suppressed because it is too large Load Diff

View 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,
};
}

View 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),
};
}

View File

@ -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(),

View File

@ -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[];

View File

@ -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;
}
// 画布项目