ai-chatbot/lib/agents/psd-template-config.ts
2026-06-04 11:39:53 +08:00

697 lines
21 KiB
TypeScript

import type { AgentPsdTextColorConfig } from "@/types/agent";
import type { AgentPsdTemplateSecondaryCategoryConfig } from "@/lib/agents/psd-template-secondary-category-config";
import type { AgentPsdTemplateCategoryTagConfig } from "@/lib/agents/psd-template-category-tag-config";
import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config";
export const AGENT_PSD_TEMPLATES_KEY = "agent_psd_templates";
export const AGENT_PSD_TEMPLATE_ASSET_VERSION = 7;
export type AgentPsdTemplateStatus = "active" | "disabled";
export type AgentPsdTemplateParseStatus =
| "pending"
| "processing"
| "completed"
| "failed";
export type AgentPsdTemplateMode = "main" | "sku";
export type AgentPsdTemplateReplaceMode = "cover" | "contain" | "stretch";
export type AgentPsdTemplateSlotType = "image" | "text";
export type AgentPsdTextAlign = "left" | "center" | "right";
export type AgentPsdTextStyleSource = "auto" | "custom";
export interface AgentPsdTemplateImageContentBounds {
left: number;
top: number;
width: number;
height: number;
}
export interface AgentPsdTemplateBaseLayerSlot {
id: string;
layerId: string;
layerName: string;
layerPath: string;
depth: number;
x: number;
y: number;
width: number;
height: number;
isEditable: boolean;
isSuperLocked?: boolean;
isRequired: boolean;
previewUrl?: string;
previewPath?: string;
sourceUrl?: string;
sourcePath?: string;
}
export interface AgentPsdTemplateImageLayerSlot
extends AgentPsdTemplateBaseLayerSlot {
slotType: "image";
isDefaultResultSource: boolean;
allowUserUploadOverride: boolean;
forceClipMask?: boolean;
clipMaskLayerId?: string;
replaceMode: AgentPsdTemplateReplaceMode;
contentBounds?: AgentPsdTemplateImageContentBounds;
maskUrl?: string;
maskPath?: string;
}
export interface AgentPsdTemplateTextLayerSlot
extends AgentPsdTemplateBaseLayerSlot {
slotType: "text";
styleSource?: AgentPsdTextStyleSource;
defaultText?: string;
placeholder?: string;
detectedFontName?: string;
detectedFontNames?: string[];
fontId?: string;
fontFamily?: string;
fontLabel?: string;
fontFileUrl?: string;
fontContentType?: string;
fontSize: number;
lineHeight: number;
letterSpacing: number;
fontWeight: number;
textColor: string;
textAlign: AgentPsdTextAlign;
paddingX: number;
paddingY: number;
maxLines: number;
}
export type AgentPsdTemplateLayerSlot =
| AgentPsdTemplateImageLayerSlot
| AgentPsdTemplateTextLayerSlot;
export interface AgentPsdTemplateRenderSegment {
id: string;
beforeLayerId?: string;
url: string;
path?: string;
}
export interface AgentPsdTemplate {
id: string;
name: string;
brandName: string;
mode: AgentPsdTemplateMode;
secondaryCategory?: string;
categoryTags?: string[];
description?: string;
status: AgentPsdTemplateStatus;
parseStatus: AgentPsdTemplateParseStatus;
parseProgress?: number;
parseStage?: string;
parseError?: string;
parseStartedAt?: string;
parseHeartbeatAt?: string;
fileName: string;
fileUrl: string;
filePath?: string;
previewUrl: string;
previewPath?: string;
renderBaseUrl?: string;
renderBasePath?: string;
renderSegments?: AgentPsdTemplateRenderSegment[];
width: number;
height: number;
layerCount: number;
layers: AgentPsdTemplateLayerSlot[];
assetVersion?: number;
createdAt: string;
updatedAt: string;
uploadedById?: string;
}
export interface AgentPsdTemplateListResponse {
success: boolean;
templates: AgentPsdTemplate[];
textColorConfig?: AgentPsdTextColorConfig;
secondaryCategoryConfig?: AgentPsdTemplateSecondaryCategoryConfig;
categoryTagConfig?: AgentPsdTemplateCategoryTagConfig;
error?: string;
}
export interface AgentPsdTemplateMutationResponse {
success: boolean;
template?: AgentPsdTemplate;
error?: string;
}
export interface AgentPsdTemplateRenderResult {
url: string;
pathname: string;
contentType?: string;
}
export type AgentPsdTemplateRenderStatus =
| "pending"
| "running"
| "succeeded"
| "failed";
export interface AgentPsdTemplateRenderResponse {
success: boolean;
processing?: boolean;
status?: AgentPsdTemplateRenderStatus;
taskId?: string;
pollAfterMs?: number;
result?: AgentPsdTemplateRenderResult;
error?: string;
}
function normalizeText(value: unknown) {
return String(value || "").trim();
}
function normalizeUrl(value: unknown) {
const text = normalizeText(value);
return /^https?:\/\//i.test(text) ? text : "";
}
function normalizeOptionalUrl(value: unknown) {
const url = normalizeUrl(value);
return url || undefined;
}
function toIsoDateString(value: unknown) {
const text = normalizeText(value);
if (!text) return null;
const date = new Date(text);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
}
function normalizePositiveInteger(value: unknown) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) return 0;
return Math.max(0, Math.round(numberValue));
}
function normalizeInteger(value: unknown) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) return 0;
return Math.round(numberValue);
}
function normalizePositiveNumber(value: unknown, fallback: number) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue) || numberValue <= 0) {
return fallback;
}
return numberValue;
}
function normalizeColor(value: unknown, fallback = "#111111") {
const text = normalizeText(value);
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(text)) {
return text;
}
return fallback;
}
export function normalizeAgentPsdTemplateStatus(value: unknown): AgentPsdTemplateStatus {
return normalizeText(value) === "active" ? "active" : "disabled";
}
export function normalizeAgentPsdTemplateMode(value: unknown): AgentPsdTemplateMode {
return normalizeText(value) === "sku" ? "sku" : "main";
}
export function normalizeAgentPsdTemplateParseStatus(
value: unknown
): AgentPsdTemplateParseStatus {
const status = normalizeText(value);
if (
status === "pending" ||
status === "processing" ||
status === "failed"
) {
return status;
}
return "completed";
}
export function normalizeAgentPsdTemplateReplaceMode(
value: unknown
): AgentPsdTemplateReplaceMode {
const mode = normalizeText(value);
if (mode === "contain" || mode === "stretch") {
return mode;
}
return "cover";
}
function normalizeAgentPsdTemplateImageContentBounds(
value: unknown,
containerWidth: number,
containerHeight: number
): AgentPsdTemplateImageContentBounds | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Record<string, unknown>;
const left = normalizePositiveInteger(record.left);
const top = normalizePositiveInteger(record.top);
const width = normalizePositiveInteger(record.width);
const height = normalizePositiveInteger(record.height);
if (width <= 0 || height <= 0) {
return undefined;
}
const clampedLeft = Math.min(left, Math.max(0, containerWidth - 1));
const clampedTop = Math.min(top, Math.max(0, containerHeight - 1));
const maxWidth = Math.max(1, containerWidth - clampedLeft);
const maxHeight = Math.max(1, containerHeight - clampedTop);
const clampedWidth = Math.min(width, maxWidth);
const clampedHeight = Math.min(height, maxHeight);
if (
clampedLeft === 0 &&
clampedTop === 0 &&
clampedWidth === containerWidth &&
clampedHeight === containerHeight
) {
return undefined;
}
return {
left: clampedLeft,
top: clampedTop,
width: clampedWidth,
height: clampedHeight,
};
}
export function normalizeAgentPsdTemplateSlotType(
value: unknown
): AgentPsdTemplateSlotType {
return normalizeText(value) === "text" ? "text" : "image";
}
export function normalizeAgentPsdTextAlign(value: unknown): AgentPsdTextAlign {
const align = normalizeText(value);
if (align === "center" || align === "right") {
return align;
}
return "left";
}
export function resolveAgentPsdTextLayerEffectiveAlign(
layer: Pick<
AgentPsdTemplateTextLayerSlot,
"paddingX" | "styleSource" | "textAlign" | "width"
>
): AgentPsdTextAlign {
const textAlign = normalizeAgentPsdTextAlign(layer.textAlign);
const paddingX = normalizePositiveInteger(layer.paddingX);
const width = normalizePositiveInteger(layer.width);
if (
layer.styleSource !== "custom" &&
textAlign === "center" &&
paddingX <= Math.max(2, Math.round(width * 0.02))
) {
return "left";
}
return textAlign;
}
export function normalizeAgentPsdTextStyleSource(value: unknown): AgentPsdTextStyleSource {
return normalizeText(value) === "custom" ? "custom" : "auto";
}
export function parseAgentPsdTemplateLayers(value: unknown) {
if (!Array.isArray(value)) return [] as AgentPsdTemplateLayerSlot[];
const layers: AgentPsdTemplateLayerSlot[] = [];
for (const entry of value) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
continue;
}
const record = entry as Record<string, unknown>;
const id = normalizeText(record.id);
const layerId = normalizeText(record.layerId);
const layerName = normalizeText(record.layerName);
const layerPath = normalizeText(record.layerPath);
const width = normalizePositiveInteger(record.width);
const height = normalizePositiveInteger(record.height);
const slotType = normalizeAgentPsdTemplateSlotType(record.slotType);
if (!id || !layerId || !layerName || !layerPath || width <= 0 || height <= 0) {
continue;
}
const isEditable = record.isEditable === true;
const isSuperLocked = record.isSuperLocked === true;
const baseRecord = {
id,
layerId,
layerName,
layerPath,
depth: Math.max(0, normalizePositiveInteger(record.depth)),
x: normalizeInteger(record.x),
y: normalizeInteger(record.y),
width,
height,
isEditable,
isSuperLocked,
isRequired: isEditable && !isSuperLocked && record.isRequired === true,
previewUrl: normalizeOptionalUrl(record.previewUrl),
previewPath: normalizeText(record.previewPath) || undefined,
sourceUrl: normalizeOptionalUrl(record.sourceUrl),
sourcePath: normalizeText(record.sourcePath) || undefined,
} satisfies AgentPsdTemplateBaseLayerSlot;
if (slotType === "text") {
const estimatedFontSize = Math.max(16, Math.min(height, Math.round(height * 0.78)));
layers.push({
...baseRecord,
slotType: "text",
styleSource: normalizeAgentPsdTextStyleSource(record.styleSource),
defaultText: normalizeText(record.defaultText) || undefined,
placeholder: normalizeText(record.placeholder) || undefined,
detectedFontName: normalizeText(record.detectedFontName) || undefined,
detectedFontNames: Array.isArray(record.detectedFontNames)
? Array.from(
new Set(
record.detectedFontNames
.map((item) => normalizeText(item))
.filter(Boolean)
)
).slice(0, 8)
: undefined,
fontId: normalizeText(record.fontId) || undefined,
fontFamily: normalizeText(record.fontFamily) || undefined,
fontLabel: normalizeText(record.fontLabel) || undefined,
fontFileUrl: normalizeOptionalUrl(record.fontFileUrl),
fontContentType: normalizeText(record.fontContentType) || undefined,
fontSize: normalizePositiveNumber(record.fontSize, estimatedFontSize),
lineHeight: normalizePositiveNumber(record.lineHeight, 1.2),
letterSpacing: Number.isFinite(Number(record.letterSpacing))
? Number(record.letterSpacing)
: 0,
fontWeight: Math.max(
100,
Math.min(900, normalizePositiveInteger(record.fontWeight) || 500)
),
textColor: normalizeColor(record.textColor),
textAlign: normalizeAgentPsdTextAlign(record.textAlign),
paddingX: normalizePositiveInteger(record.paddingX),
paddingY: normalizePositiveInteger(record.paddingY),
maxLines: Math.max(1, normalizePositiveInteger(record.maxLines) || 4),
});
continue;
}
layers.push({
...baseRecord,
slotType: "image",
isDefaultResultSource:
isEditable && !isSuperLocked && record.isDefaultResultSource === true,
allowUserUploadOverride:
isEditable && !isSuperLocked ? record.allowUserUploadOverride !== false : false,
forceClipMask: isEditable && !isSuperLocked && record.forceClipMask === true,
clipMaskLayerId:
isEditable && !isSuperLocked && record.forceClipMask === true
? normalizeText(record.clipMaskLayerId) || undefined
: undefined,
replaceMode: normalizeAgentPsdTemplateReplaceMode(record.replaceMode),
contentBounds: normalizeAgentPsdTemplateImageContentBounds(
record.contentBounds,
width,
height
),
maskUrl: normalizeOptionalUrl(record.maskUrl),
maskPath: normalizeText(record.maskPath) || undefined,
});
}
let defaultAssigned = false;
return layers.map((layer) => {
if (layer.slotType === "image") {
if (!layer.isEditable || !layer.isDefaultResultSource) {
return {
...layer,
isDefaultResultSource: false,
};
}
if (defaultAssigned) {
return {
...layer,
isDefaultResultSource: false,
};
}
defaultAssigned = true;
return layer;
}
return layer;
});
}
function parseAgentPsdTemplateRenderSegments(value: unknown) {
if (!Array.isArray(value)) return [] as AgentPsdTemplateRenderSegment[];
const segments: AgentPsdTemplateRenderSegment[] = [];
const seenIds = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
continue;
}
const record = entry as Record<string, unknown>;
const id = normalizeText(record.id);
const url = normalizeUrl(record.url);
if (!id || !url || seenIds.has(id)) {
continue;
}
seenIds.add(id);
segments.push({
id,
beforeLayerId: normalizeText(record.beforeLayerId) || undefined,
url,
path: normalizeText(record.path) || undefined,
});
}
return segments;
}
export function parseAgentPsdTemplates(value: unknown) {
if (!Array.isArray(value)) return [] as AgentPsdTemplate[];
const templates: AgentPsdTemplate[] = [];
for (const entry of value) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
continue;
}
const record = entry as Record<string, unknown>;
const id = normalizeText(record.id);
const name = normalizeText(record.name);
const brandName = normalizeText(record.brandName) || "未分配品牌";
const fileName = normalizeText(record.fileName);
const fileUrl = normalizeUrl(record.fileUrl);
const previewUrl = normalizeUrl(record.previewUrl);
const createdAt = toIsoDateString(record.createdAt);
const updatedAt = toIsoDateString(record.updatedAt) || createdAt;
const width = normalizePositiveInteger(record.width);
const height = normalizePositiveInteger(record.height);
const layers = parseAgentPsdTemplateLayers(record.layers);
const layerCount = Math.max(layers.length, normalizePositiveInteger(record.layerCount));
if (
!id ||
!name ||
!fileName ||
!fileUrl ||
!previewUrl ||
!createdAt ||
!updatedAt ||
width <= 0 ||
height <= 0
) {
continue;
}
const parseStatus = normalizeAgentPsdTemplateParseStatus(record.parseStatus);
const rawParseProgress = normalizePositiveInteger(record.parseProgress);
const parseStartedAt = toIsoDateString(record.parseStartedAt) || undefined;
const parseHeartbeatAt = toIsoDateString(record.parseHeartbeatAt) || undefined;
const categoryTags = normalizeAgentPsdTemplateCategoryTags(
record.categoryTags ?? record.productCategoryTags
);
templates.push({
id,
name,
brandName,
mode: normalizeAgentPsdTemplateMode(record.mode),
secondaryCategory: normalizeText(record.secondaryCategory) || undefined,
categoryTags: categoryTags.length > 0 ? categoryTags : undefined,
description: normalizeText(record.description) || undefined,
status: normalizeAgentPsdTemplateStatus(record.status),
parseStatus,
parseProgress:
parseStatus === "completed" && rawParseProgress <= 0
? 100
: Math.min(100, Math.max(0, rawParseProgress)),
parseStage: normalizeText(record.parseStage) || undefined,
parseError: normalizeText(record.parseError) || undefined,
parseStartedAt,
parseHeartbeatAt,
fileName,
fileUrl,
filePath: normalizeText(record.filePath) || undefined,
previewUrl,
previewPath: normalizeText(record.previewPath) || undefined,
renderBaseUrl:
normalizeOptionalUrl(record.renderBaseUrl) || normalizeOptionalUrl(record.previewUrl),
renderBasePath: normalizeText(record.renderBasePath) || undefined,
renderSegments: parseAgentPsdTemplateRenderSegments(record.renderSegments),
width,
height,
layerCount,
layers,
assetVersion: normalizePositiveInteger(record.assetVersion),
createdAt,
updatedAt,
uploadedById: normalizeText(record.uploadedById) || undefined,
});
}
return templates.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
export function upsertAgentPsdTemplate(
templates: AgentPsdTemplate[],
template: AgentPsdTemplate
) {
return [template, ...templates.filter((entry) => entry.id !== template.id)].sort((left, right) =>
right.updatedAt.localeCompare(left.updatedAt)
);
}
export function isAgentPsdTemplateReady(template: AgentPsdTemplate) {
return template.parseStatus === "completed" && template.layers.length > 0;
}
export function hasAgentPsdTemplateStoredLayerSource(
layer: Pick<AgentPsdTemplateLayerSlot, "sourceUrl" | "sourcePath">
) {
return Boolean(
String(layer.sourcePath || "").trim() || String(layer.sourceUrl || "").trim()
);
}
export function hasAgentPsdTemplateRenderSegments(
template: Pick<AgentPsdTemplate, "renderSegments">
) {
return Boolean(
template.renderSegments?.some((segment) => String(segment.url || "").trim())
);
}
export function hasAgentPsdTemplateDedicatedRenderBase(
template: Pick<AgentPsdTemplate, "renderBaseUrl" | "renderBasePath" | "previewUrl">
) {
const renderBaseUrl = String(template.renderBaseUrl || "").trim();
const previewUrl = String(template.previewUrl || "").trim();
return Boolean(String(template.renderBasePath || "").trim()) || (
Boolean(renderBaseUrl) &&
renderBaseUrl !== previewUrl
);
}
export function getAgentPsdTemplateRuntimeMissingLayers(template: AgentPsdTemplate) {
const renderBaseUrl = String(template.renderBaseUrl || "").trim();
const hasUsableRenderBase =
hasAgentPsdTemplateDedicatedRenderBase(template) &&
Boolean(renderBaseUrl) &&
renderBaseUrl !== String(template.previewUrl || "").trim();
return template.layers.filter(
(layer) => {
if (hasUsableRenderBase) {
return (
isAgentPsdImageLayerSlot(layer) &&
isAgentPsdTemplateLayerUserEditable(layer) &&
!hasAgentPsdTemplateStoredLayerSource(layer)
);
}
return !hasAgentPsdTemplateStoredLayerSource(layer);
}
);
}
export function getAgentPsdTemplateRuntimeIssue(template: AgentPsdTemplate) {
if (!isAgentPsdTemplateReady(template)) {
return "PSD 模板尚未完成解析,请先在后台重新解析后再使用";
}
const missingLayers = getAgentPsdTemplateRuntimeMissingLayers(template);
if (missingLayers.length === 0) {
return null;
}
const layerNames = Array.from(
new Set(missingLayers.map((layer) => String(layer.layerName || "").trim()).filter(Boolean))
);
const summarizedLayerNames =
layerNames.length <= 3
? layerNames.join("、")
: `${layerNames.slice(0, 3).join("、")} 等 ${layerNames.length} 个图层`;
return summarizedLayerNames
? `PSD 模板图层资源不完整(${summarizedLayerNames}),请在后台重新解析该模板后再导出`
: "PSD 模板图层资源不完整,请在后台重新解析该模板后再导出";
}
export function isAgentPsdTemplateRuntimeReady(template: AgentPsdTemplate) {
return getAgentPsdTemplateRuntimeIssue(template) === null;
}
export function getEditableAgentPsdTemplateLayers(template: AgentPsdTemplate) {
return template.layers.filter(isAgentPsdTemplateLayerUserEditable);
}
export function isAgentPsdLayerSuperLocked(
layer: Pick<AgentPsdTemplateBaseLayerSlot, "isSuperLocked">
) {
return layer.isSuperLocked === true;
}
export function isAgentPsdTemplateLayerUserEditable(
layer: AgentPsdTemplateLayerSlot
) {
return layer.isEditable && !isAgentPsdLayerSuperLocked(layer);
}
export function isAgentPsdTextLayerSlot(
layer: AgentPsdTemplateLayerSlot
): layer is AgentPsdTemplateTextLayerSlot {
return layer.slotType === "text";
}
export function isAgentPsdImageLayerSlot(
layer: AgentPsdTemplateLayerSlot
): layer is AgentPsdTemplateImageLayerSlot {
return layer.slotType === "image";
}