feat: add workflow material selection flow
This commit is contained in:
parent
5f81f596b8
commit
38f1151099
@ -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>
|
||||
|
||||
319
app/api/agent-workflow/material-search/route.ts
Normal file
319
app/api/agent-workflow/material-search/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
223
components/agents/AgentWorkflowMaterialPicker.tsx
Normal file
223
components/agents/AgentWorkflowMaterialPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
components/agents/AgentWorkflowMaterialSelectionPanel.tsx
Normal file
171
components/agents/AgentWorkflowMaterialSelectionPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>>;
|
||||
|
||||
166
lib/agents/workflow-material-config.ts
Normal file
166
lib/agents/workflow-material-config.ts
Normal 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: "",
|
||||
};
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user