feat: add workflow material selection flow
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-17 20:26:58 +08:00
parent 5f81f596b8
commit 38f1151099
12 changed files with 1526 additions and 26 deletions

View File

@ -16,6 +16,10 @@ import {
defaultAgentWorkflowUiConfig,
normalizeAgentWorkflowUiConfig,
} from "@/lib/agents/workflow-ui-config";
import {
defaultAgentWorkflowMaterialApiConfig,
normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@ -23,15 +27,19 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import type { AgentWorkflowUiConfig } from "@/types/agent";
import type { AgentWorkflowMaterialApiConfig, AgentWorkflowUiConfig } from "@/types/agent";
const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json());
export function AgentWorkflowSettingsClient() {
const { data, isLoading, mutate } = useSWR<{
agentWorkflowUiConfig?: AgentWorkflowUiConfig;
agentWorkflowMaterialApiConfig?: AgentWorkflowMaterialApiConfig;
}>("/api/site-settings", fetcher);
const [config, setConfig] = useState<AgentWorkflowUiConfig>(defaultAgentWorkflowUiConfig);
const [materialApiConfig, setMaterialApiConfig] = useState<AgentWorkflowMaterialApiConfig>(
defaultAgentWorkflowMaterialApiConfig
);
const [isSaving, setIsSaving] = useState(false);
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
@ -39,6 +47,9 @@ export function AgentWorkflowSettingsClient() {
useEffect(() => {
if (!data) return;
setConfig(normalizeAgentWorkflowUiConfig(data.agentWorkflowUiConfig));
setMaterialApiConfig(
normalizeAgentWorkflowMaterialApiConfig(data.agentWorkflowMaterialApiConfig)
);
}, [data]);
const sortedSteps = useMemo(
@ -110,6 +121,7 @@ export function AgentWorkflowSettingsClient() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agentWorkflowUiConfig: config,
agentWorkflowMaterialApiConfig: materialApiConfig,
}),
});
@ -171,6 +183,68 @@ export function AgentWorkflowSettingsClient() {
</Card>
) : (
<div className="space-y-5">
<Card>
<CardHeader>
<CardTitle>素材搜索接口配置</CardTitle>
<CardDescription>
配置主 SPU / 搭配 SKU 搜图所需的接口地址与 API Key。Key 会按系统设置方式存储到数据库中,不会下发到前台页面。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-xl border bg-muted/20 px-4 py-3">
<div className="space-y-1">
<div className="text-sm font-medium">启用素材搜索</div>
<div className="text-xs text-muted-foreground">
开启后,智能体工作流页面支持搜索并选择白底素材图。
</div>
</div>
<Switch
checked={materialApiConfig.enabled}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({
...prev,
enabled: checked,
}))
}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>接口基地址</Label>
<Input
value={materialApiConfig.baseUrl}
onChange={(event) =>
setMaterialApiConfig((prev) => ({
...prev,
baseUrl: event.target.value,
}))
}
placeholder="https://sc.jiansun.vip/api/external"
/>
</div>
<div className="space-y-2">
<Label>API Key</Label>
<Input
type="password"
value={materialApiConfig.apiKey}
onChange={(event) =>
setMaterialApiConfig((prev) => ({
...prev,
apiKey: event.target.value,
}))
}
placeholder="请输入素材搜索 API Key"
/>
</div>
</div>
<div className="text-xs text-muted-foreground">
当前适配的是白底图搜索接口;前台不会直接暴露 Key,而是由服务端代查并转存图片。
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>基础下拉配置</CardTitle>

View File

@ -0,0 +1,319 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getSystemSetting, upsertMediaAsset } from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config";
export const maxDuration = 180;
const querySchema = z.object({
keyword: z.string().trim().min(1).max(64),
source: z.enum(["primary", "secondary"]).optional(),
});
type ExternalImageRecord = {
key?: string;
label?: string;
fileName?: string;
fileSize?: number;
downloadUrl?: string;
};
type ExternalSearchResponse = {
success?: boolean;
data?: {
sku?: string;
materialId?: string;
productModel?: string;
whiteBackground?: {
fixed?: ExternalImageRecord[];
other?: ExternalImageRecord[];
};
downloadUrls?: {
zip?: string;
};
};
error?: {
code?: string;
message?: string;
};
};
function normalizeBaseUrl(value: string) {
return String(value || "").trim().replace(/\/+$/, "");
}
function sanitizeFilenamePart(value: string) {
return String(value || "")
.trim()
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, "-")
.slice(0, 80);
}
function getExtensionFromMimeType(mimeType: string) {
const normalized = String(mimeType || "").toLowerCase();
if (normalized.includes("png")) return "png";
if (normalized.includes("webp")) return "webp";
if (normalized.includes("jpeg") || normalized.includes("jpg")) return "jpg";
if (normalized.includes("gif")) return "gif";
return "png";
}
function ensureFilenameWithExtension(filename: string, mimeType: string) {
const trimmed = sanitizeFilenamePart(filename) || `workflow-material-${Date.now()}`;
if (/\.[a-z0-9]+$/i.test(trimmed)) {
return trimmed;
}
return `${trimmed}.${getExtensionFromMimeType(mimeType)}`;
}
async function importExternalImage(params: {
userId: string;
keyword: string;
source: "primary" | "secondary";
matchedSku: string;
productModel?: string | null;
group: "fixed" | "other";
sortOrder: number;
image: ExternalImageRecord;
}) {
const downloadUrl = String(params.image.downloadUrl || "").trim();
if (!downloadUrl) return null;
const imageResponse = await fetch(downloadUrl, {
cache: "no-store",
redirect: "follow",
});
if (!imageResponse.ok) {
throw new Error(`下载图片失败(${imageResponse.status})`);
}
const imageBuffer = await imageResponse.arrayBuffer();
const mimeType =
String(imageResponse.headers.get("content-type") || "").split(";")[0].trim() || "image/png";
const filename = ensureFilenameWithExtension(
params.image.fileName ||
params.image.label ||
`${params.matchedSku || params.keyword}-${params.group}-${params.sortOrder + 1}`,
mimeType
);
const uploaded = await uploadFile(filename, imageBuffer);
let assetId: string | null = null;
try {
const asset = await upsertMediaAsset({
userId: params.userId,
mediaType: "image",
sourceType: "agent_workflow_material_search",
status: "ready",
originalUrl: uploaded.url,
previewUrl: uploaded.url,
thumbnailUrl: uploaded.url,
mimeType: uploaded.contentType || mimeType,
displayName: filename,
sourceTable: "agent_workflow_material_search",
metadata: {
keyword: params.keyword,
source: params.source,
matchedSku: params.matchedSku,
productModel: params.productModel || null,
group: params.group,
externalKey: String(params.image.key || "").trim() || null,
fileSize: Number(params.image.fileSize || 0) || null,
pathname: uploaded.pathname,
},
});
assetId = asset?.id ?? null;
} catch (error) {
console.warn("[Agent Workflow Material Search] Failed to register media asset:", error);
}
return {
id: assetId || `${params.group}-${params.sortOrder + 1}`,
key: String(params.image.key || "").trim() || `${params.group}-${params.sortOrder + 1}`,
label: String(params.image.label || "").trim() || `图片${params.sortOrder + 1}`,
fileName: filename,
imageUrl: uploaded.url,
assetId,
mimeType: uploaded.contentType || mimeType,
group: params.group,
sortOrder: params.sortOrder,
};
}
function mapUpstreamErrorStatus(code: string) {
if (code === "SKU_NOT_FOUND" || code === "WHITE_BACKGROUND_NOT_FOUND") {
return 404;
}
if (code === "INVALID_API_KEY") {
return 500;
}
if (code === "SKU_NOT_UNIQUE") {
return 409;
}
return 502;
}
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const parsed = querySchema.parse({
keyword: request.nextUrl.searchParams.get("keyword"),
source: request.nextUrl.searchParams.get("source"),
});
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY });
const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value);
if (!config.enabled) {
return NextResponse.json(
{ error: "素材搜索功能未开启,请先在系统设置中启用" },
{ status: 400 }
);
}
if (!config.apiKey) {
return NextResponse.json(
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" },
{ status: 500 }
);
}
const normalizedKeyword = parsed.keyword.trim().toUpperCase();
const upstreamUrl = `${normalizeBaseUrl(
config.baseUrl
)}/standard-model-white-background?sku=${encodeURIComponent(normalizedKeyword)}`;
const upstreamResponse = await fetch(upstreamUrl, {
method: "GET",
headers: {
"X-API-Key": config.apiKey,
},
cache: "no-store",
});
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalSearchResponse;
if (!upstreamResponse.ok || upstreamResult.success !== true || !upstreamResult.data) {
const errorCode = String(upstreamResult.error?.code || "UPSTREAM_ERROR").trim();
const errorMessage =
String(upstreamResult.error?.message || "").trim() ||
`素材搜索失败(${upstreamResponse.status})`;
return NextResponse.json(
{
success: false,
error: {
code: errorCode,
message: errorMessage,
},
},
{ status: mapUpstreamErrorStatus(errorCode) }
);
}
const matchedSku = String(upstreamResult.data.sku || normalizedKeyword).trim() || normalizedKeyword;
const productModel = String(upstreamResult.data.productModel || "").trim() || null;
const fixedImages = Array.isArray(upstreamResult.data.whiteBackground?.fixed)
? upstreamResult.data.whiteBackground?.fixed || []
: [];
const otherImages = Array.isArray(upstreamResult.data.whiteBackground?.other)
? upstreamResult.data.whiteBackground?.other || []
: [];
const importedImages = (
await Promise.all([
...fixedImages.map((image, index) =>
importExternalImage({
userId: session.user.id,
keyword: normalizedKeyword,
source: parsed.source || "primary",
matchedSku,
productModel,
group: "fixed",
sortOrder: index,
image,
}).catch((error) => {
console.warn("[Agent Workflow Material Search] Fixed image import failed:", error);
return null;
})
),
...otherImages.map((image, index) =>
importExternalImage({
userId: session.user.id,
keyword: normalizedKeyword,
source: parsed.source || "primary",
matchedSku,
productModel,
group: "other",
sortOrder: fixedImages.length + index,
image,
}).catch((error) => {
console.warn("[Agent Workflow Material Search] Other image import failed:", error);
return null;
})
),
])
).filter(
(
item
): item is {
id: string;
key: string;
label: string;
fileName: string;
imageUrl: string;
assetId: string | null;
mimeType: string;
group: "fixed" | "other";
sortOrder: number;
} => Boolean(item)
);
if (importedImages.length === 0) {
return NextResponse.json(
{
success: false,
error: {
code: "IMAGE_IMPORT_FAILED",
message: "已获取到素材记录,但图片导入失败,请稍后重试",
},
},
{ status: 502 }
);
}
return NextResponse.json({
success: true,
data: {
query: normalizedKeyword,
matchedSku,
materialId: String(upstreamResult.data.materialId || "").trim() || null,
productModel,
zipUrl: String(upstreamResult.data.downloadUrls?.zip || "").trim() || null,
fixedCount: importedImages.filter((item) => item.group === "fixed").length,
otherCount: importedImages.filter((item) => item.group === "other").length,
totalCount: importedImages.length,
images: importedImages,
},
});
} catch (error) {
console.error("[Agent Workflow Material Search] Error:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "搜索参数无效", details: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "素材搜索失败,请稍后重试" },
{ status: 500 }
);
}
}

View File

@ -34,6 +34,12 @@ import {
defaultAgentWorkflowUiConfig,
normalizeAgentWorkflowUiConfig,
} from "@/lib/agents/workflow-ui-config";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
defaultAgentWorkflowMaterialApiConfig,
normalizeAgentWorkflowMaterialApiConfig,
normalizeAgentWorkflowMaterialApiConfigForPublic,
} from "@/lib/agents/workflow-material-config";
import {
defaultOssImageMigrationConfig,
normalizeOssImageMigrationConfig,
@ -220,6 +226,7 @@ export async function GET() {
agentCategoryTreeConfigSetting,
agentSecondaryTypeConfigSetting,
agentWorkflowUiConfigSetting,
agentWorkflowMaterialApiConfigSetting,
] = await Promise.all([
db.select().from(siteSettings).limit(1).then(rows => rows[0]),
db
@ -336,6 +343,12 @@ export async function GET() {
.where(eq(systemSettings.key, AGENT_WORKFLOW_UI_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
]);
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
@ -380,6 +393,11 @@ export async function GET() {
const agentWorkflowUiConfig = normalizeAgentWorkflowUiConfig(
agentWorkflowUiConfigSetting?.value
);
const agentWorkflowMaterialApiConfig = isAdmin
? normalizeAgentWorkflowMaterialApiConfig(agentWorkflowMaterialApiConfigSetting?.value)
: normalizeAgentWorkflowMaterialApiConfigForPublic(
agentWorkflowMaterialApiConfigSetting?.value
);
// 如果没有设置记录,返回默认值
const baseSettings = settings || {
@ -408,6 +426,7 @@ export async function GET() {
agentCategoryTreeConfig: normalizeAgentCategoryTreeConfig(undefined),
agentSecondaryTypeConfig: defaultAgentSecondaryTypeConfig,
agentWorkflowUiConfig: defaultAgentWorkflowUiConfig,
agentWorkflowMaterialApiConfig: defaultAgentWorkflowMaterialApiConfig,
realtimeCanvasEnabled: true,
realtimeCanvasServiceEnabled: true,
storageFailoverConfig,
@ -452,6 +471,7 @@ export async function GET() {
agentCategoryTreeConfig,
agentSecondaryTypeConfig,
agentWorkflowUiConfig,
agentWorkflowMaterialApiConfig,
membershipConfig,
membershipUserOverrides,
pointsSystemConfig,
@ -549,6 +569,13 @@ const updateSchema = z.object({
.max(20),
})
.optional(),
agentWorkflowMaterialApiConfig: z
.object({
enabled: z.boolean().optional(),
baseUrl: z.string().trim().max(255).optional(),
apiKey: z.string().trim().max(500).optional(),
})
.optional(),
realtimeCanvasEnabled: z.boolean().optional(),
aiDetailGenerationEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(),
@ -627,6 +654,7 @@ export async function PUT(request: NextRequest) {
agentCategoryTreeConfig,
agentSecondaryTypeConfig,
agentWorkflowUiConfig,
agentWorkflowMaterialApiConfig,
membershipConfig,
membershipUserOverrides,
pointsSystemConfig,
@ -851,6 +879,28 @@ export async function PUT(request: NextRequest) {
});
}
if (agentWorkflowMaterialApiConfig !== undefined) {
const normalized = normalizeAgentWorkflowMaterialApiConfig(
agentWorkflowMaterialApiConfig
);
await db
.insert(systemSettings)
.values({
key: AGENT_WORKFLOW_MATERIAL_API_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
@ -1023,6 +1073,14 @@ export async function PUT(request: NextRequest) {
agentSecondaryTypeConfig !== undefined
? normalizeAgentSecondaryTypeConfig(agentSecondaryTypeConfig)
: defaultAgentSecondaryTypeConfig,
agentWorkflowUiConfig:
agentWorkflowUiConfig !== undefined
? normalizeAgentWorkflowUiConfig(agentWorkflowUiConfig)
: defaultAgentWorkflowUiConfig,
agentWorkflowMaterialApiConfig:
agentWorkflowMaterialApiConfig !== undefined
? normalizeAgentWorkflowMaterialApiConfig(agentWorkflowMaterialApiConfig)
: defaultAgentWorkflowMaterialApiConfig,
membershipConfig:
membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig)
@ -1231,6 +1289,88 @@ export async function PUT(request: NextRequest) {
});
}
if (agentCategoryTreeConfig !== undefined) {
const normalized = normalizeAgentCategoryTreeConfig(agentCategoryTreeConfig);
await db
.insert(systemSettings)
.values({
key: AGENT_CATEGORY_TREE_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 (agentSecondaryTypeConfig !== undefined) {
const normalized = normalizeAgentSecondaryTypeConfig(agentSecondaryTypeConfig);
await db
.insert(systemSettings)
.values({
key: AGENT_SECONDARY_TYPE_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 (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 (agentWorkflowMaterialApiConfig !== undefined) {
const normalized = normalizeAgentWorkflowMaterialApiConfig(
agentWorkflowMaterialApiConfig
);
await db
.insert(systemSettings)
.values({
key: AGENT_WORKFLOW_MATERIAL_API_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
@ -1395,6 +1535,22 @@ export async function PUT(request: NextRequest) {
contactConfig !== undefined
? normalizeContactConfig(contactConfig)
: normalizeContactConfig(undefined),
agentCategoryTreeConfig:
agentCategoryTreeConfig !== undefined
? normalizeAgentCategoryTreeConfig(agentCategoryTreeConfig)
: normalizeAgentCategoryTreeConfig(undefined),
agentSecondaryTypeConfig:
agentSecondaryTypeConfig !== undefined
? normalizeAgentSecondaryTypeConfig(agentSecondaryTypeConfig)
: defaultAgentSecondaryTypeConfig,
agentWorkflowUiConfig:
agentWorkflowUiConfig !== undefined
? normalizeAgentWorkflowUiConfig(agentWorkflowUiConfig)
: defaultAgentWorkflowUiConfig,
agentWorkflowMaterialApiConfig:
agentWorkflowMaterialApiConfig !== undefined
? normalizeAgentWorkflowMaterialApiConfig(agentWorkflowMaterialApiConfig)
: defaultAgentWorkflowMaterialApiConfig,
membershipConfig:
membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig)

View File

@ -74,12 +74,17 @@ import {
defaultAgentWorkflowUiConfig,
normalizeAgentWorkflowUiConfig,
} from "@/lib/agents/workflow-ui-config";
import {
createEmptyAgentWorkflowMaterialSelections,
normalizeAgentWorkflowMaterialSelections,
} from "@/lib/agents/workflow-material-config";
import {
buildAgentWorkflowComposerContext,
getAgentWorkflowProgress,
} from "@/lib/agents/workflow-composer";
import type {
AgentWorkflowComposerContext,
AgentWorkflowMaterialSelections,
AgentWorkflowPersistedState,
AgentWorkflowStepKey,
} from "@/types/agent";
@ -161,6 +166,9 @@ export default function CanvasEditorPage({ params }: PageProps) {
brand: "",
store: "",
});
const [workflowMaterials, setWorkflowMaterials] = useState<AgentWorkflowMaterialSelections>(
() => createEmptyAgentWorkflowMaterialSelections()
);
const [workflowSelections, setWorkflowSelections] = useState<
Partial<Record<AgentWorkflowStepKey, string>>
>({});
@ -267,6 +275,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
brand: "",
store: "",
});
setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections());
setWorkflowSelections({});
setSelectedAgentPresetId(null);
@ -287,6 +296,9 @@ export default function CanvasEditorPage({ params }: PageProps) {
brand: String(persistedWorkflow.inputs?.brand || "").trim(),
store: String(persistedWorkflow.inputs?.store || "").trim(),
});
setWorkflowMaterials(
normalizeAgentWorkflowMaterialSelections(persistedWorkflow.materials)
);
setWorkflowSelections(persistedWorkflow.selections || {});
setSelectedAgentPresetId(
String(persistedWorkflow.selectedAgentPresetId || "").trim() || null
@ -342,6 +354,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
brand: workflowInputs.brand.trim(),
store: workflowInputs.store.trim(),
},
materials: workflowMaterials,
selections: workflowSelections,
selectedAgentPresetId: selectedAgentPresetId || null,
currentStage: currentAgentStage,
@ -351,7 +364,14 @@ export default function CanvasEditorPage({ params }: PageProps) {
...getCanvasState(),
workflowState,
};
}, [getCanvasState, workflowInputs, workflowSelections, selectedAgentPresetId]);
}, [
currentAgentStage,
getCanvasState,
selectedAgentPresetId,
workflowInputs,
workflowMaterials,
workflowSelections,
]);
useEffect(() => {
if (typeof window === "undefined") return;
@ -371,6 +391,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
brand: String(parsed.inputs?.brand || "").trim(),
store: String(parsed.inputs?.store || "").trim(),
});
setWorkflowMaterials(normalizeAgentWorkflowMaterialSelections(parsed.materials));
setWorkflowSelections(parsed.selections || {});
setSelectedAgentPresetId(String(parsed.selectedAgentPresetId || "").trim() || null);
setCurrentAgentStage(parsed.currentStage === "mode" ? "mode" : "workflow");
@ -389,6 +410,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
workflowStorageKey,
JSON.stringify({
inputs: workflowInputs,
materials: workflowMaterials,
selections: workflowSelections,
selectedAgentPresetId,
currentAgentStage,
@ -400,6 +422,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
}, [
workflowHydrationSource,
workflowInputs,
workflowMaterials,
workflowSelections,
workflowStorageKey,
selectedAgentPresetId,
@ -836,9 +859,10 @@ export default function CanvasEditorPage({ params }: PageProps) {
return buildAgentWorkflowComposerContext({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
inputs: workflowInputs,
materials: workflowMaterials,
selections: workflowSelections,
});
}, [resolvedWorkflowUiConfig, workflowInputs, workflowSelections]);
}, [resolvedWorkflowUiConfig, workflowInputs, workflowMaterials, workflowSelections]);
const workflowProgress = useMemo(
() =>
getAgentWorkflowProgress({
@ -910,6 +934,8 @@ export default function CanvasEditorPage({ params }: PageProps) {
config={resolvedWorkflowUiConfig}
inputs={workflowInputs}
onInputsChange={setWorkflowInputs}
materials={workflowMaterials}
onMaterialsChange={setWorkflowMaterials}
selections={workflowSelections}
onSelectionsChange={setWorkflowSelections}
onNextStep={() => setCurrentAgentStage("mode")}
@ -920,6 +946,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
brand: "",
store: "",
});
setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections());
setWorkflowSelections({});
setSelectedAgentPresetId(null);
setCurrentAgentStage("workflow");
@ -936,7 +963,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
生图模式
</div>
<div className="mt-1 text-xs text-muted-foreground">
先完成第一步工作流,再选择具体的生图模式模板。
先选择生图模式,右侧再搜索 SKU / SPU 选图,并自动进入对应图片槽位。
</div>
<div className="mt-2 inline-flex items-center rounded-full bg-muted px-2.5 py-1 text-[11px] text-muted-foreground">
第 2 步 / 2 步
@ -964,7 +991,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
selectedPresetId={selectedAgentPresetId}
onSelectPreset={setSelectedAgentPresetId}
title="生图模式"
description="选择生图模式后,右侧对话框会按其要求显示上传槽位"
description="先选择生图模式,右侧会出现对应槽位和 SKU / SPU 搜图入口"
searchPlaceholder="搜索生图模式"
publicTitle="公共生图模式"
privateTitle="我的生图模式"
@ -985,6 +1012,10 @@ export default function CanvasEditorPage({ params }: PageProps) {
selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
chatScope="agent"
workflowContext={workflowContext}
workflowInputs={workflowInputs}
onWorkflowInputsChange={setWorkflowInputs}
workflowMaterials={workflowMaterials}
onWorkflowMaterialsChange={setWorkflowMaterials}
/>
</div>
</div>

View File

@ -0,0 +1,223 @@
"use client";
import { useMemo } from "react";
import { Check, ImageIcon, Loader2, RefreshCw, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { AgentWorkflowMaterialSearchResult } from "@/types/agent";
interface AgentWorkflowMaterialPickerProps {
title: string;
description: string;
value: string;
placeholder: string;
buttonLabel: string;
searchNote?: string;
isLoading?: boolean;
errorText?: string | null;
result?: AgentWorkflowMaterialSearchResult | null;
selectedImageIds: string[];
onValueChange: (value: string) => void;
onSearch: () => void;
onSelectedImageIdsChange: (next: string[]) => void;
}
export function AgentWorkflowMaterialPicker({
title,
description,
value,
placeholder,
buttonLabel,
searchNote,
isLoading = false,
errorText,
result,
selectedImageIds,
onValueChange,
onSearch,
onSelectedImageIdsChange,
}: AgentWorkflowMaterialPickerProps) {
const selectedSet = useMemo(
() => new Set((selectedImageIds || []).map((item) => String(item || "").trim()).filter(Boolean)),
[selectedImageIds]
);
const selectedCount = result?.images
? result.images.filter((item) => selectedSet.has(item.id)).length
: 0;
const handleToggleImage = (imageId: string) => {
const next = new Set(selectedImageIds);
if (next.has(imageId)) {
next.delete(imageId);
} else {
next.add(imageId);
}
onSelectedImageIdsChange(Array.from(next));
};
const handleSelectAll = () => {
onSelectedImageIdsChange(result?.images?.map((item) => item.id) || []);
};
return (
<section className="space-y-3 rounded-2xl border bg-muted/20 p-4">
<div className="space-y-1">
<div className="text-sm font-medium">{title}</div>
<div className="text-xs text-muted-foreground">{description}</div>
</div>
<div className="flex flex-col gap-2 md:flex-row">
<Input
value={value}
onChange={(event) => onValueChange(event.target.value)}
placeholder={placeholder}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
onSearch();
}
}}
/>
<Button type="button" onClick={onSearch} disabled={isLoading || !value.trim()}>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
{buttonLabel}
</Button>
</div>
{searchNote ? (
<div className="text-xs text-muted-foreground">{searchNote}</div>
) : null}
{errorText ? (
<div className="rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{errorText}
</div>
) : null}
{result ? (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-full bg-background px-2 py-1">
命中:{result.matchedSku || result.query}
</span>
{result.productModel ? (
<span className="rounded-full bg-background px-2 py-1">
型号:{result.productModel}
</span>
) : null}
<span className="rounded-full bg-background px-2 py-1">
共 {result.totalCount} 张
</span>
<span className="rounded-full bg-background px-2 py-1">
已选 {selectedCount} 张
</span>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleSelectAll}
disabled={!result.images?.length}
>
全选
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onSelectedImageIdsChange([])}
disabled={selectedCount === 0}
>
清空已选
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
{result.images.map((image) => {
const selected = selectedSet.has(image.id);
return (
<button
key={image.id}
type="button"
onClick={() => handleToggleImage(image.id)}
className={cn(
"group overflow-hidden rounded-2xl border bg-background text-left transition-all",
selected
? "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]"
: "border-border hover:border-primary/40 hover:shadow-sm"
)}
>
<div className="relative aspect-square overflow-hidden bg-muted/40">
<img
src={image.imageUrl}
alt={image.label}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
onError={(event) => {
event.currentTarget.style.display = "none";
}}
/>
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2">
<span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white">
{image.group === "fixed" ? "固定角度" : "其他图片"}
</span>
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border text-white transition-colors",
selected
? "border-primary bg-primary"
: "border-white/60 bg-black/35 opacity-0 group-hover:opacity-100"
)}
>
<Check className="h-3.5 w-3.5" />
</span>
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 via-black/25 to-transparent px-3 py-2 text-[11px] text-white">
{image.label}
</div>
</div>
<div className="space-y-1 px-3 py-2">
<div className="truncate text-xs font-medium">{image.fileName}</div>
<div className="text-[11px] text-muted-foreground">
点击{selected ? "取消" : "选择"},可多选带入生图模式
</div>
</div>
</button>
);
})}
</div>
</div>
) : (
<div className="flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed bg-background/60 px-4 text-center text-sm text-muted-foreground">
{isLoading ? (
<>
<Loader2 className="mb-2 h-5 w-5 animate-spin" />
正在搜索并导入图片,请稍候...
</>
) : (
<>
<ImageIcon className="mb-2 h-5 w-5 opacity-60" />
{value.trim() ? "输入后点击搜索,从素材库拉取白底图。" : "先输入关键词,再搜索素材图。"}
</>
)}
</div>
)}
{result?.images?.length ? (
<div className="flex items-start gap-2 rounded-xl bg-background px-3 py-2 text-xs text-muted-foreground">
<RefreshCw className="mt-0.5 h-3.5 w-3.5 shrink-0" />
已选中的图片会在下一步优先带入右侧“生图模式”槽位,你也可以进入后继续替换。
</div>
) : null}
</section>
);
}

View File

@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import { Sparkles } from "lucide-react";
import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import type { AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanel";
import type { AgentWorkflowMaterialSelections } from "@/types/agent";
interface AgentWorkflowMaterialSelectionPanelProps {
inputs: AgentWorkflowInputs;
onInputsChange: (next: AgentWorkflowInputs) => void;
materials: AgentWorkflowMaterialSelections;
onMaterialsChange: (next: AgentWorkflowMaterialSelections) => void;
title?: string;
description?: string;
}
export function AgentWorkflowMaterialSelectionPanel({
inputs,
onInputsChange,
materials,
onMaterialsChange,
title = "SKU / SPU 选图",
description = "先确定生图模式,再搜索主 SKU 或搭配 SKU 的白底图。选中的图片会自动进入对应槽位。",
}: AgentWorkflowMaterialSelectionPanelProps) {
const [materialSearchState, setMaterialSearchState] = useState<{
primary: { isLoading: boolean; errorText: string | null };
secondary: { isLoading: boolean; errorText: string | null };
}>({
primary: { isLoading: false, errorText: null },
secondary: { isLoading: false, errorText: null },
});
const updateMaterialSearchState = (
source: "primary" | "secondary",
updates: Partial<{ isLoading: boolean; errorText: string | null }>
) => {
setMaterialSearchState((prev) => ({
...prev,
[source]: {
...prev[source],
...updates,
},
}));
};
const handleMaterialInputChange = (source: "primary" | "secondary", value: string) => {
const inputKey = source === "primary" ? "primarySku" : "secondarySku";
const trimmedValue = value.trim().toUpperCase();
const currentQuery = String(materials[source]?.result?.query || "").trim().toUpperCase();
onInputsChange({
...inputs,
[inputKey]: value,
});
if (
trimmedValue !== currentQuery &&
(materials[source]?.result || materials[source]?.selectedImageIds.length)
) {
onMaterialsChange({
...materials,
[source]: createEmptyAgentWorkflowMaterialSelection(),
});
}
if (materialSearchState[source].errorText) {
updateMaterialSearchState(source, { errorText: null });
}
};
const handleSearchMaterials = async (source: "primary" | "secondary") => {
const keyword = (source === "primary" ? inputs.primarySku : inputs.secondarySku).trim();
if (!keyword) {
updateMaterialSearchState(source, { errorText: "请先输入搜索关键词" });
return;
}
try {
updateMaterialSearchState(source, { isLoading: true, errorText: null });
const response = await fetch(
`/api/agent-workflow/material-search?keyword=${encodeURIComponent(keyword)}&source=${source}`,
{ cache: "no-store" }
);
const result = await response.json().catch(() => ({}));
if (!response.ok || !result?.success || !result?.data) {
throw new Error(result?.error?.message || result?.error || "搜索失败");
}
onMaterialsChange({
...materials,
[source]: {
result: result.data,
selectedImageIds: [],
},
});
} catch (error) {
updateMaterialSearchState(source, {
errorText: error instanceof Error ? error.message : "搜索失败",
});
} finally {
updateMaterialSearchState(source, { isLoading: false });
}
};
return (
<div className="space-y-3 rounded-2xl border border-[#dbe3ff] bg-[#f8faff] p-3">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium text-[#334155]">
<Sparkles className="h-4 w-4 text-[#4f46e5]" />
<span>{title}</span>
</div>
<div className="text-xs leading-5 text-[#5b6472]">{description}</div>
</div>
<AgentWorkflowMaterialPicker
title="主 SKU / SPU"
description="主商品图会优先自动填充到“产品图1”;如果没有搭配图,再继续补到“产品图2”。"
value={inputs.primarySku}
placeholder="输入主 SKU / SPU"
buttonLabel="搜索主图"
searchNote="当前上游接口实际按可命中的 SKU 查图;若提供了 SPU 接口,后续可直接无缝切换。"
isLoading={materialSearchState.primary.isLoading}
errorText={materialSearchState.primary.errorText}
result={materials.primary.result}
selectedImageIds={materials.primary.selectedImageIds}
onValueChange={(value) => handleMaterialInputChange("primary", value)}
onSearch={() => {
void handleSearchMaterials("primary");
}}
onSelectedImageIdsChange={(next) =>
onMaterialsChange({
...materials,
primary: {
...materials.primary,
selectedImageIds: next,
},
})
}
/>
<AgentWorkflowMaterialPicker
title="搭配 SKU"
description="搭配商品图会优先自动填充到“产品图2”;模特图仍保留手动上传。"
value={inputs.secondarySku}
placeholder="输入搭配 SKU"
buttonLabel="搜索搭配图"
searchNote="选中的搭配图会进入第二个产品槽位,你也可以在下面继续手动替换。"
isLoading={materialSearchState.secondary.isLoading}
errorText={materialSearchState.secondary.errorText}
result={materials.secondary.result}
selectedImageIds={materials.secondary.selectedImageIds}
onValueChange={(value) => handleMaterialInputChange("secondary", value)}
onSearch={() => {
void handleSearchMaterials("secondary");
}}
onSelectedImageIdsChange={(next) =>
onMaterialsChange({
...materials,
secondary: {
...materials.secondary,
selectedImageIds: next,
},
})
}
/>
</div>
);
}

View File

@ -4,18 +4,19 @@ import { useMemo } from "react";
import { ArrowRight, 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 { SearchableSelect } from "@/components/ui/searchable-select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import {
buildAgentWorkflowComposerContext,
getAgentWorkflowProgress,
} from "@/lib/agents/workflow-composer";
import type {
AgentWorkflowComposerSelection,
AgentWorkflowMaterialSelections,
AgentWorkflowStepKey,
AgentWorkflowUiConfig,
} from "@/types/agent";
@ -31,6 +32,8 @@ interface AgentWorkflowPanelProps {
config?: AgentWorkflowUiConfig | null;
inputs: AgentWorkflowInputs;
onInputsChange: (next: AgentWorkflowInputs) => void;
materials: AgentWorkflowMaterialSelections;
onMaterialsChange: (next: AgentWorkflowMaterialSelections) => void;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
onSelectionsChange: (next: Partial<Record<AgentWorkflowStepKey, string>>) => void;
onReset?: () => void;
@ -67,6 +70,8 @@ export function AgentWorkflowPanel({
config,
inputs,
onInputsChange,
materials,
onMaterialsChange,
selections,
onSelectionsChange,
onReset,
@ -105,9 +110,10 @@ export function AgentWorkflowPanel({
buildAgentWorkflowComposerContext({
config: resolvedConfig,
inputs,
materials,
selections,
}),
[inputs, resolvedConfig, selections]
[inputs, materials, resolvedConfig, selections]
);
const progress = useMemo(
() => getAgentWorkflowProgress({ config: resolvedConfig, selections }),
@ -158,6 +164,16 @@ export function AgentWorkflowPanel({
onReset();
return;
}
onInputsChange({
primarySku: "",
secondarySku: "",
brand: "",
store: "",
});
onMaterialsChange({
primary: createEmptyAgentWorkflowMaterialSelection(),
secondary: createEmptyAgentWorkflowMaterialSelection(),
});
onSelectionsChange({});
}}
>
@ -170,22 +186,8 @@ export function AgentWorkflowPanel({
<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="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"
/>
<SearchableSelect
value={inputs.brand}
options={brandOptions}
@ -208,6 +210,9 @@ export function AgentWorkflowPanel({
品牌和店铺支持输入关键字匹配下拉选择,候选列表由系统设置配置。
</div>
)}
<div className="text-xs text-muted-foreground">
SKU / SPU 搜图已移动到下一步“生图模式”中,选中的图片会直接进入对应生图槽位。
</div>
</section>
{resolvedConfig.steps.map((step) => (
@ -270,7 +275,12 @@ export function AgentWorkflowPanel({
<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 ? (
{inputs.primarySku ||
inputs.secondarySku ||
inputs.brand ||
inputs.store ||
materials.primary.selectedImageIds.length > 0 ||
materials.secondary.selectedImageIds.length > 0 ? (
<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>
@ -284,6 +294,16 @@ export function AgentWorkflowPanel({
{inputs.store ? (
<span className="rounded-full bg-background px-2 py-1">店铺:{inputs.store}</span>
) : null}
{materials.primary.selectedImageIds.length > 0 ? (
<span className="rounded-full bg-background px-2 py-1">
主SPU图:已选 {materials.primary.selectedImageIds.length} 张
</span>
) : null}
{materials.secondary.selectedImageIds.length > 0 ? (
<span className="rounded-full bg-background px-2 py-1">
搭配SKU图:已选 {materials.secondary.selectedImageIds.length} 张
</span>
) : null}
</div>
) : null}
{summary.length > 0 ? (
@ -303,6 +323,18 @@ export function AgentWorkflowPanel({
还没有选择工作流方案,先从上面的卡片开始。
</div>
)}
{composerContext?.referenceImages.length ? (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">已选素材图</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
{composerContext.referenceImages.map((image) => (
<span key={image.id} className="rounded-full bg-background px-2 py-1">
{image.sourceLabel} · {image.label}
</span>
))}
</div>
</div>
) : null}
</section>
<section className="space-y-3 rounded-2xl border bg-muted/20 p-4">

View File

@ -9,6 +9,7 @@ import useSWR, { useSWRConfig } from "swr";
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { AgentWorkflowMaterialSelectionPanel } from "@/components/agents/AgentWorkflowMaterialSelectionPanel";
import {
Collapsible,
CollapsibleContent,
@ -48,13 +49,18 @@ import {
} from "@/lib/image-url";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import type { Attachment, ChatMessage } from "@/lib/types";
import type { AgentPreset, AgentWorkflowComposerContext } from "@/types/agent";
import type {
AgentPreset,
AgentWorkflowComposerContext,
AgentWorkflowMaterialSelections,
} from "@/types/agent";
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
import {
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
normalizeCanvasSkillTags,
type CanvasSkillTag,
} from "@/lib/canvas-skill-tags";
import type { AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanel";
import { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-icons";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar";
@ -111,6 +117,10 @@ interface CanvasChatPanelProps {
selectedAgentPresetId?: string | null;
chatScope?: "canvas" | "agent";
workflowContext?: AgentWorkflowComposerContext | null;
workflowInputs?: AgentWorkflowInputs | null;
onWorkflowInputsChange?: ((next: AgentWorkflowInputs) => void) | null;
workflowMaterials?: AgentWorkflowMaterialSelections | null;
onWorkflowMaterialsChange?: ((next: AgentWorkflowMaterialSelections) => void) | null;
}
type VideoStoryboardStreamData =
@ -1683,6 +1693,10 @@ export function CanvasChatPanel({
selectedAgentPresetId,
chatScope = "canvas",
workflowContext = null,
workflowInputs = null,
onWorkflowInputsChange = null,
workflowMaterials = null,
onWorkflowMaterialsChange = null,
}: CanvasChatPanelProps) {
const { data: session } = useSession();
const [input, setInput] = useState("");
@ -1751,6 +1765,8 @@ export function CanvasChatPanel({
const detailModeSwitchRef = useRef(false);
const hiddenPromptRef = useRef<string | null>(null);
const autoContextRef = useRef<string | null>(null);
const lastWorkflowAutofillSignatureRef = useRef<string | null>(null);
const lastWorkflowAutofillUrlsRef = useRef<string[]>([]);
const pendingLaunchRef = useRef<CanvasLaunchPayload | null>(null);
const [launchAutoSendTick, setLaunchAutoSendTick] = useState(0);
const hasInitializedChatStateRef = useRef(false);
@ -2810,11 +2826,16 @@ export function CanvasChatPanel({
() => (agentPresetResponse?.success && agentPresetResponse.data ? agentPresetResponse.data : null),
[agentPresetResponse]
);
const workflowReferenceImages = workflowContext?.referenceImages || [];
const canManageWorkflowMaterials = Boolean(
workflowInputs && workflowMaterials && onWorkflowInputsChange && onWorkflowMaterialsChange
);
const hasWorkflowContext = Boolean(
workflowContext &&
(String(workflowContext.summaryText || "").trim() ||
String(workflowContext.promptText || "").trim() ||
workflowContext.selections.length > 0)
workflowContext.selections.length > 0 ||
workflowReferenceImages.length > 0)
);
const SelectedSkillTagIcon = useMemo(
() => getCanvasSkillIconComponent(selectedSkillTag?.iconName),
@ -3321,6 +3342,97 @@ export function CanvasChatPanel({
});
}, [agentUploadSlots.length, hasAgentUploadSlots]);
useEffect(() => {
if (!hasAgentUploadSlots || workflowReferenceImages.length === 0) {
lastWorkflowAutofillSignatureRef.current = null;
lastWorkflowAutofillUrlsRef.current = [];
return;
}
const primaryImages = workflowReferenceImages.filter((image) => image.source === "primary");
const secondaryImages = workflowReferenceImages.filter((image) => image.source === "secondary");
const usedUrls = new Set<string>();
const pickNextImage = (
candidates: typeof workflowReferenceImages
) =>
candidates.find((image) => {
if (!image?.url || usedUrls.has(image.url)) return false;
usedUrls.add(image.url);
return true;
}) || null;
const productSlot1Image = pickNextImage(primaryImages) || pickNextImage(secondaryImages);
const productSlot2Image =
pickNextImage(secondaryImages) ||
pickNextImage(primaryImages) ||
pickNextImage(workflowReferenceImages);
const suggestedSlots: Array<PickedObjectAttachment | null> = Array.from(
{ length: agentUploadSlots.length },
() => null
);
const toAttachment = (image: (typeof workflowReferenceImages)[number] | null) =>
image
? {
url: image.url,
name: image.name || image.label || image.sourceLabel,
contentType: image.mimeType || "image/png",
assetId: image.assetId || undefined,
}
: null;
if (agentUploadSlots.length > 0) {
suggestedSlots[0] = toAttachment(productSlot1Image);
}
if (agentUploadSlots.length > 1) {
suggestedSlots[1] = toAttachment(productSlot2Image);
}
const nextSuggestedUrls = suggestedSlots
.filter((item): item is PickedObjectAttachment => Boolean(item))
.map((item) => item.url);
if (nextSuggestedUrls.length === 0) return;
const nextSignature = JSON.stringify({
presetId: selectedAgentPresetId || "",
urls: nextSuggestedUrls,
});
if (lastWorkflowAutofillSignatureRef.current === nextSignature) {
return;
}
setAttachments((current) => {
const previousAutoUrls = new Set(lastWorkflowAutofillUrlsRef.current);
const next: Array<PickedObjectAttachment | null> = Array.from(
{ length: agentUploadSlots.length },
(_, index) => current[index] ?? null
);
let changed = false;
suggestedSlots.forEach((attachment, index) => {
if (!attachment || index >= 2) return;
const existing = next[index];
const canReplace = !existing?.url || previousAutoUrls.has(existing.url);
if (!canReplace) return;
if (existing?.url === attachment.url) return;
next[index] = attachment;
changed = true;
});
if (!changed) {
return current;
}
lastWorkflowAutofillSignatureRef.current = nextSignature;
lastWorkflowAutofillUrlsRef.current = nextSuggestedUrls;
return next.filter((item): item is PickedObjectAttachment => Boolean(item));
});
}, [
agentUploadSlots.length,
hasAgentUploadSlots,
selectedAgentPresetId,
workflowReferenceImages,
]);
useEffect(() => {
if (!currentModel) return;
if (currentModel.id !== "auto" && isDetailGenerationMode) {
@ -9356,6 +9468,26 @@ export function CanvasChatPanel({
) : null}
</div>
)}
{!isDetailGenerationMode &&
activeComposerMode === "agent" &&
selectedAgentPreset &&
hasAgentUploadSlots &&
canManageWorkflowMaterials &&
workflowInputs &&
workflowMaterials &&
onWorkflowInputsChange &&
onWorkflowMaterialsChange ? (
<div className="mb-2">
<AgentWorkflowMaterialSelectionPanel
inputs={workflowInputs}
onInputsChange={onWorkflowInputsChange}
materials={workflowMaterials}
onMaterialsChange={onWorkflowMaterialsChange}
title="选择商品素材图"
description="已选生图模式后,再搜索主 SKU / 搭配 SKU。选中的图片会自动进入产品图槽位,模特图继续手动上传。"
/>
</div>
) : null}
<div
ref={textareaRef as any}
contentEditable={!isSubmitting}
@ -9487,7 +9619,7 @@ export function CanvasChatPanel({
})}
</div>
<div className="mt-1.5 text-[11px] text-muted-foreground">
智能体需要上传 3 张图片:产品图1、产品图2、模特图;已选中画布图片时,点击槽位可直接填充,未选中则打开上传。
智能体需要上传 3 张图片:产品图1、产品图2、模特图;已选主 SKU / 搭配 SKU 的素材图会优先进入前两个产品槽位,模特图继续手动上传或替换。
</div>
</div>
)}

View File

@ -1,6 +1,8 @@
import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
import type {
AgentWorkflowComposerContext,
AgentWorkflowMaterialSelections,
AgentWorkflowReferenceImage,
AgentWorkflowStepKey,
AgentWorkflowUiConfig,
} from "@/types/agent";
@ -15,6 +17,7 @@ export interface AgentWorkflowInputsValue {
export function buildAgentWorkflowComposerContext(params: {
config?: AgentWorkflowUiConfig | null;
inputs: AgentWorkflowInputsValue;
materials?: AgentWorkflowMaterialSelections | null;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
}): AgentWorkflowComposerContext | null {
const config = params.config || defaultAgentWorkflowUiConfig;
@ -37,6 +40,10 @@ export function buildAgentWorkflowComposerContext(params: {
};
})
.filter(Boolean) as AgentWorkflowComposerContext["selections"];
const referenceImages = getSelectedWorkflowReferenceImages({
inputs: params.inputs,
materials: params.materials,
});
const summaryParts: string[] = [];
if (params.inputs.primarySku.trim()) {
@ -51,6 +58,16 @@ export function buildAgentWorkflowComposerContext(params: {
if (params.inputs.store.trim()) {
summaryParts.push(`店铺:${params.inputs.store.trim()}`);
}
if (referenceImages.some((item) => item.source === "primary")) {
summaryParts.push(
`主SPU图:${referenceImages.filter((item) => item.source === "primary").length}张`
);
}
if (referenceImages.some((item) => item.source === "secondary")) {
summaryParts.push(
`搭配SKU图:${referenceImages.filter((item) => item.source === "secondary").length}张`
);
}
selections.forEach((item) => {
summaryParts.push(`${item.stepTitle}:${item.optionName}`);
});
@ -72,6 +89,30 @@ export function buildAgentWorkflowComposerContext(params: {
if (params.inputs.store.trim()) {
promptLines.push(`店铺:${params.inputs.store.trim()}`);
}
if (referenceImages.some((item) => item.source === "primary")) {
const labels = referenceImages
.filter((item) => item.source === "primary")
.map((item) => item.label)
.filter(Boolean)
.slice(0, 8);
promptLines.push(
`主SPU参考图:已选${referenceImages.filter((item) => item.source === "primary").length}张${
labels.length > 0 ? `(${labels.join("、")})` : ""
}`
);
}
if (referenceImages.some((item) => item.source === "secondary")) {
const labels = referenceImages
.filter((item) => item.source === "secondary")
.map((item) => item.label)
.filter(Boolean)
.slice(0, 8);
promptLines.push(
`搭配SKU参考图:已选${referenceImages.filter((item) => item.source === "secondary").length}张${
labels.length > 0 ? `(${labels.join("、")})` : ""
}`
);
}
selections.forEach((item) => {
promptLines.push(`${item.stepTitle}:${item.promptFragment || item.optionName}`);
});
@ -82,11 +123,59 @@ export function buildAgentWorkflowComposerContext(params: {
brand: params.inputs.brand.trim() || undefined,
store: params.inputs.store.trim() || undefined,
selections,
referenceImages,
summaryText: summaryParts.join(" / "),
promptText: promptLines.join("\n"),
};
}
export function getSelectedWorkflowReferenceImages(params: {
inputs: AgentWorkflowInputsValue;
materials?: AgentWorkflowMaterialSelections | null;
}): AgentWorkflowReferenceImage[] {
const materials = params.materials;
if (!materials) return [];
const collect = (
source: "primary" | "secondary",
sourceLabel: string,
fallbackSku: string
) => {
const state = materials[source];
const result = state?.result;
if (!result || !Array.isArray(result.images) || result.images.length === 0) {
return [] as AgentWorkflowReferenceImage[];
}
const selectedIds = new Set(
(state.selectedImageIds || []).map((item) => String(item || "").trim()).filter(Boolean)
);
if (selectedIds.size === 0) {
return [] as AgentWorkflowReferenceImage[];
}
return result.images
.filter((item) => selectedIds.has(item.id))
.map((item) => ({
id: item.id,
source,
sourceLabel,
sku: result.matchedSku || fallbackSku || undefined,
productModel: result.productModel || null,
label: item.label,
name: item.fileName || item.label,
url: item.imageUrl,
assetId: item.assetId || null,
mimeType: item.mimeType || null,
}));
};
return [
...collect("primary", "主SPU", params.inputs.primarySku.trim()),
...collect("secondary", "搭配SKU", params.inputs.secondarySku.trim()),
];
}
export function getAgentWorkflowProgress(params: {
config?: AgentWorkflowUiConfig | null;
selections: Partial<Record<AgentWorkflowStepKey, string>>;

View File

@ -0,0 +1,166 @@
import type {
AgentWorkflowMaterialApiConfig,
AgentWorkflowMaterialImage,
AgentWorkflowMaterialSearchResult,
AgentWorkflowMaterialSelectionState,
AgentWorkflowMaterialSelections,
} from "@/types/agent";
export const AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY = "agent_workflow_material_api_config";
export const defaultAgentWorkflowMaterialApiConfig: AgentWorkflowMaterialApiConfig = {
enabled: false,
baseUrl: "https://sc.jiansun.vip/api/external",
apiKey: "",
};
function sanitizeText(value: unknown, maxLength: number) {
return String(value || "").trim().slice(0, maxLength);
}
function normalizeMaterialImage(
value: unknown,
index: number
): AgentWorkflowMaterialImage | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const raw = value as Record<string, unknown>;
const imageUrl = sanitizeText(raw.imageUrl, 2000);
if (!imageUrl) return null;
const id = sanitizeText(raw.id, 128) || `image-${index + 1}`;
const group = raw.group === "other" ? "other" : "fixed";
return {
id,
key: sanitizeText(raw.key, 64) || id,
label: sanitizeText(raw.label, 128) || `图片${index + 1}`,
fileName: sanitizeText(raw.fileName, 255) || `image-${index + 1}`,
imageUrl,
assetId: sanitizeText(raw.assetId, 128) || null,
mimeType: sanitizeText(raw.mimeType, 128) || null,
group,
sortOrder: Number.isFinite(Number(raw.sortOrder))
? Math.max(0, Math.min(999, Math.round(Number(raw.sortOrder))))
: index,
};
}
function normalizeMaterialSearchResult(value: unknown): AgentWorkflowMaterialSearchResult | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const raw = value as Record<string, unknown>;
const images = Array.isArray(raw.images)
? raw.images
.map((item, index) => normalizeMaterialImage(item, index))
.filter((item): item is AgentWorkflowMaterialImage => Boolean(item))
.slice(0, 32)
: [];
if (images.length === 0) {
return null;
}
return {
query: sanitizeText(raw.query, 64),
matchedSku: sanitizeText(raw.matchedSku, 64),
materialId: sanitizeText(raw.materialId, 128) || null,
productModel: sanitizeText(raw.productModel, 128) || null,
zipUrl: sanitizeText(raw.zipUrl, 2000) || null,
fixedCount: Number.isFinite(Number(raw.fixedCount))
? Math.max(0, Math.min(999, Math.round(Number(raw.fixedCount))))
: images.filter((item) => item.group === "fixed").length,
otherCount: Number.isFinite(Number(raw.otherCount))
? Math.max(0, Math.min(999, Math.round(Number(raw.otherCount))))
: images.filter((item) => item.group === "other").length,
totalCount: Number.isFinite(Number(raw.totalCount))
? Math.max(0, Math.min(999, Math.round(Number(raw.totalCount))))
: images.length,
images: images
.slice()
.sort((left, right) => left.sortOrder - right.sortOrder),
};
}
export function createEmptyAgentWorkflowMaterialSelection(): AgentWorkflowMaterialSelectionState {
return {
result: null,
selectedImageIds: [],
};
}
export function createEmptyAgentWorkflowMaterialSelections(): AgentWorkflowMaterialSelections {
return {
primary: createEmptyAgentWorkflowMaterialSelection(),
secondary: createEmptyAgentWorkflowMaterialSelection(),
};
}
function normalizeMaterialSelectionState(value: unknown): AgentWorkflowMaterialSelectionState {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const result = normalizeMaterialSearchResult(raw.result);
const validIds = new Set((result?.images || []).map((item) => item.id));
const selectedImageIds = Array.isArray(raw.selectedImageIds)
? Array.from(
new Set(
raw.selectedImageIds
.map((item) => sanitizeText(item, 128))
.filter((item) => item && validIds.has(item))
.slice(0, 32)
)
)
: [];
return {
result,
selectedImageIds,
};
}
export function normalizeAgentWorkflowMaterialSelections(
value: unknown
): AgentWorkflowMaterialSelections {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
return {
primary: normalizeMaterialSelectionState(raw.primary),
secondary: normalizeMaterialSelectionState(raw.secondary),
};
}
export function normalizeAgentWorkflowMaterialApiConfig(
value: unknown
): AgentWorkflowMaterialApiConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return defaultAgentWorkflowMaterialApiConfig;
}
const raw = value as Partial<AgentWorkflowMaterialApiConfig>;
return {
enabled: raw.enabled === true,
baseUrl:
sanitizeText(raw.baseUrl, 255) || defaultAgentWorkflowMaterialApiConfig.baseUrl,
apiKey: sanitizeText(raw.apiKey, 500),
};
}
export function normalizeAgentWorkflowMaterialApiConfigForPublic(
value: unknown
): AgentWorkflowMaterialApiConfig {
const config = normalizeAgentWorkflowMaterialApiConfig(value);
return {
...config,
apiKey: "",
};
}

View File

@ -608,6 +608,56 @@ export const canvasProject = pgTable("CanvasProject", {
brand?: string;
store?: string;
};
materials?: {
primary?: {
result?: {
query?: string;
matchedSku?: string;
materialId?: string | null;
productModel?: string | null;
zipUrl?: string | null;
fixedCount?: number;
otherCount?: number;
totalCount?: number;
images?: Array<{
id?: string;
key?: string;
label?: string;
fileName?: string;
imageUrl?: string;
assetId?: string | null;
mimeType?: string | null;
group?: "fixed" | "other";
sortOrder?: number;
}>;
} | null;
selectedImageIds?: string[];
};
secondary?: {
result?: {
query?: string;
matchedSku?: string;
materialId?: string | null;
productModel?: string | null;
zipUrl?: string | null;
fixedCount?: number;
otherCount?: number;
totalCount?: number;
images?: Array<{
id?: string;
key?: string;
label?: string;
fileName?: string;
imageUrl?: string;
assetId?: string | null;
mimeType?: string | null;
group?: "fixed" | "other";
sortOrder?: number;
}>;
} | null;
selectedImageIds?: string[];
};
};
selections?: Record<string, string>;
selectedAgentPresetId?: string | null;
currentStage?: "workflow" | "mode";

View File

@ -47,6 +47,61 @@ export interface AgentWorkflowUiConfig {
steps: AgentWorkflowStepUiConfig[];
}
export interface AgentWorkflowMaterialApiConfig {
enabled: boolean;
baseUrl: string;
apiKey: string;
}
export type AgentWorkflowMaterialSource = "primary" | "secondary";
export interface AgentWorkflowMaterialImage {
id: string;
key: string;
label: string;
fileName: string;
imageUrl: string;
assetId?: string | null;
mimeType?: string | null;
group: "fixed" | "other";
sortOrder: number;
}
export interface AgentWorkflowMaterialSearchResult {
query: string;
matchedSku: string;
materialId?: string | null;
productModel?: string | null;
zipUrl?: string | null;
fixedCount: number;
otherCount: number;
totalCount: number;
images: AgentWorkflowMaterialImage[];
}
export interface AgentWorkflowMaterialSelectionState {
result?: AgentWorkflowMaterialSearchResult | null;
selectedImageIds: string[];
}
export interface AgentWorkflowMaterialSelections {
primary: AgentWorkflowMaterialSelectionState;
secondary: AgentWorkflowMaterialSelectionState;
}
export interface AgentWorkflowReferenceImage {
id: string;
source: AgentWorkflowMaterialSource;
sourceLabel: string;
sku?: string;
productModel?: string | null;
label: string;
name: string;
url: string;
assetId?: string | null;
mimeType?: string | null;
}
export interface AgentWorkflowComposerSelection {
stepKey: AgentWorkflowStepKey;
stepTitle: string;
@ -61,6 +116,7 @@ export interface AgentWorkflowComposerContext {
brand?: string;
store?: string;
selections: AgentWorkflowComposerSelection[];
referenceImages: AgentWorkflowReferenceImage[];
summaryText: string;
promptText: string;
}
@ -72,6 +128,7 @@ export interface AgentWorkflowPersistedState {
brand: string;
store: string;
};
materials?: AgentWorkflowMaterialSelections;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
selectedAgentPresetId?: string | null;
currentStage?: "workflow" | "mode";