ai-chatbot/lib/agents/workflow-ui-config.ts
Your Name 98fe210b64
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: separate agent projects from canvas storage
2026-03-19 13:07:39 +08:00

423 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
AgentWorkflowOptionUiConfig,
AgentWorkflowStepUiConfig,
AgentWorkflowUiConfig,
} from "@/types/agent";
export const AGENT_WORKFLOW_UI_CONFIG_KEY = "agent_workflow_ui_config";
export const AGENT_WORKFLOW_MAX_TREE_DEPTH = 3;
export interface FlattenedAgentWorkflowStep {
step: AgentWorkflowStepUiConfig;
depth: number;
parentKeys: string[];
}
function cloneOption(
option: AgentWorkflowOptionUiConfig
): AgentWorkflowOptionUiConfig {
return {
...option,
description: option.description || "",
previewImageUrl: option.previewImageUrl || "",
promptFragment: option.promptFragment || "",
};
}
function cloneStep(step: AgentWorkflowStepUiConfig): AgentWorkflowStepUiConfig {
return {
...step,
description: step.description || "",
options: step.options.map(cloneOption),
children: (step.children || []).map(cloneStep),
};
}
function createStep(
key: string,
title: string,
description: string,
options: AgentWorkflowOptionUiConfig[]
): AgentWorkflowStepUiConfig {
return {
key,
title,
description,
enabled: true,
sortOrder: 0,
options,
children: [],
};
}
function createOption(
key: string,
buttonName: string,
promptFragment: string,
sortOrder: number
): AgentWorkflowOptionUiConfig {
return {
key,
buttonName,
description: "",
previewImageUrl: "",
enabled: true,
sortOrder,
promptFragment,
};
}
const defaultSteps: AgentWorkflowStepUiConfig[] = [
createStep("audience", "人群选择", "先确认目标家庭结构与生活状态。", [
createOption("single_youth", "独居青年", "面向独居青年,强调轻盈、效率感和个人表达。", 0),
createOption("young_couple", "年轻情侣", "面向年轻情侣,强调陪伴感、松弛感和高颜值日常。", 1),
createOption("midlife_no_kids", "中年无孩夫妻", "面向中年无孩夫妻,强调稳定、质感与舒适收纳。", 2),
createOption("family_with_kids", "有孩家庭", "面向有孩家庭,强调互动、耐用、安全与成长场景。", 3),
createOption("senior_couple", "老年夫妻", "面向老年夫妻,强调便利、温暖、低刺激与安心动线。", 4),
]),
createStep("style", "主题风格", "确定空间视觉主风格。", [
createOption("japanese", "日系风", "整体风格为日系,强调自然木色、留白与温和秩序。", 0),
createOption("new_chinese", "新中式", "整体风格为新中式,强调东方韵味与现代克制。", 1),
createOption("american", "美式", "整体风格为美式,强调舒展体量、生活感与复古质感。", 2),
createOption("healing", "治愈风", "整体风格为治愈风,强调柔和、放松与亲和力。", 3),
createOption("modern", "现代风", "整体风格为现代风,强调简洁、利落与当代感。", 4),
createOption("eclectic", "混搭风", "整体风格为混搭风,强调层次变化与个性搭配。", 5),
createOption("vintage", "中古风", "整体风格为中古风,强调复古家具与时间感。", 6),
createOption("cream", "奶油风", "整体风格为奶油风,强调柔光、低饱和与绵密材质。", 7),
]),
createStep("space", "空间选择", "确定主要空间。", [
createOption("living_room", "客厅", "主空间为客厅。", 0),
createOption("kitchen", "厨房", "主空间为厨房。", 1),
createOption("bedroom", "卧室", "主空间为卧室。", 2),
createOption("dining_room", "餐厅", "主空间为餐厅。", 3),
createOption("study", "书房", "主空间为书房。", 4),
createOption("foyer", "门厅", "主空间为门厅。", 5),
createOption("balcony", "阳台", "主空间为阳台。", 6),
createOption("bathroom", "卫生间", "主空间为卫生间。", 7),
]),
createStep("subSpace", "子空间选择", "确定重点表现区域。", [
createOption("tv_console", "电视柜空间", "重点表现电视柜空间与背景墙关系。", 0),
createOption("sofa_area", "沙发空间", "重点表现沙发空间与会客氛围。", 1),
createOption("dining_table", "餐桌空间", "重点表现餐桌空间与就餐动线。", 2),
createOption("bedside_area", "床头空间", "重点表现床头空间与睡眠氛围。", 3),
createOption("island_area", "岛台空间", "重点表现岛台空间与操作效率。", 4),
createOption("desk_area", "书桌空间", "重点表现书桌空间与专注氛围。", 5),
]),
createStep("hardDecoration", "硬装方案", "确定空间硬装基调。", [
createOption("hard_plan_1", "硬装方案1", "硬装采用方案1,整体偏克制基础款。", 0),
createOption("hard_plan_2", "硬装方案2", "硬装采用方案2,整体偏精致层次款。", 1),
createOption("hard_plan_3", "硬装方案3", "硬装采用方案3,整体偏视觉强化款。", 2),
]),
createStep("softDecoration", "软装方案", "确定软装搭配方向。", [
createOption("soft_plan_1", "软装方案1", "软装采用方案1,整体偏稳妥耐看。", 0),
createOption("soft_plan_2", "软装方案2", "软装采用方案2,整体偏生活感与柔和搭配。", 1),
createOption("soft_plan_3", "软装方案3", "软装采用方案3,整体偏高辨识度和氛围表达。", 2),
]),
createStep("lighting", "光氛选择", "确定光照时段与氛围。", [
createOption("natural_daylight", "自然阳光", "光照氛围为自然阳光,通透明亮。", 0),
createOption("warm_afternoon", "暖调午后", "光照氛围为暖调午后,柔和松弛。", 1),
createOption("romantic_night", "浪漫夜晚", "光照氛围为浪漫夜晚,暖光层次分明。", 2),
]),
createStep("accessories", "配饰陈列", "确定配饰收口与陈列方式。", [
createOption("accessories_a", "配饰方案A", "配饰采用方案A,强调克制点缀。", 0),
createOption("accessories_b", "配饰方案B", "配饰采用方案B,强调生活化陈列。", 1),
createOption("accessories_c", "配饰方案C", "配饰采用方案C,强调高完成度展示。", 2),
]),
];
export const defaultAgentWorkflowUiConfig: AgentWorkflowUiConfig = {
inputOptions: {
brands: [],
stores: [],
},
steps: defaultSteps.map((step, index) =>
cloneStep({
...step,
sortOrder: index,
})
),
};
export function flattenAgentWorkflowSteps(
steps: AgentWorkflowStepUiConfig[],
depth = 1,
parentKeys: string[] = []
): FlattenedAgentWorkflowStep[] {
return steps.flatMap((step) => [
{
step,
depth,
parentKeys,
},
...flattenAgentWorkflowSteps(step.children || [], depth + 1, [...parentKeys, step.key]),
]);
}
export function flattenVisibleAgentWorkflowSteps(
steps: AgentWorkflowStepUiConfig[],
selections: Partial<Record<string, string>>,
depth = 1,
parentKeys: string[] = [],
ancestorsSatisfied = true
): FlattenedAgentWorkflowStep[] {
return [...steps]
.sort((left, right) => left.sortOrder - right.sortOrder)
.flatMap((step) => {
const normalizedStep = {
...step,
options: [...(step.options || [])].sort((left, right) => left.sortOrder - right.sortOrder),
children: [...(step.children || [])].sort((left, right) => left.sortOrder - right.sortOrder),
};
if (!ancestorsSatisfied) {
return [] as FlattenedAgentWorkflowStep[];
}
const currentEntry: FlattenedAgentWorkflowStep = {
step: normalizedStep,
depth,
parentKeys,
};
const currentSatisfied =
normalizedStep.options.length === 0 || Boolean(String(selections[normalizedStep.key] || "").trim());
return [
currentEntry,
...flattenVisibleAgentWorkflowSteps(
normalizedStep.children,
selections,
depth + 1,
[...parentKeys, normalizedStep.key],
currentSatisfied
),
];
});
}
export function findAgentWorkflowStepByKey(
steps: AgentWorkflowStepUiConfig[],
stepKey: string
): AgentWorkflowStepUiConfig | null {
for (const step of steps) {
if (step.key === stepKey) {
return step;
}
const foundChild = findAgentWorkflowStepByKey(step.children || [], stepKey);
if (foundChild) {
return foundChild;
}
}
return null;
}
export function countAgentWorkflowSteps(steps: AgentWorkflowStepUiConfig[]): number {
return flattenAgentWorkflowSteps(steps).length;
}
export function countAgentWorkflowOptions(steps: AgentWorkflowStepUiConfig[]): number {
return flattenAgentWorkflowSteps(steps).reduce(
(total, item) => total + (item.step.options?.length || 0),
0
);
}
export function normalizeAgentWorkflowStepOrdering(
steps: AgentWorkflowStepUiConfig[]
): AgentWorkflowStepUiConfig[] {
return steps.map((step, stepIndex) => ({
...step,
sortOrder: stepIndex,
options: (step.options || []).map((option, optionIndex) => ({
...option,
sortOrder: optionIndex,
})),
children: normalizeAgentWorkflowStepOrdering(step.children || []),
}));
}
export function getEnabledAgentWorkflowStepTree(
steps: AgentWorkflowStepUiConfig[]
): AgentWorkflowStepUiConfig[] {
return [...steps]
.sort((left, right) => left.sortOrder - right.sortOrder)
.filter((step) => step.enabled !== false)
.map((step) => ({
...step,
options: [...(step.options || [])]
.filter((option) => option.enabled !== false)
.sort((left, right) => left.sortOrder - right.sortOrder),
children: getEnabledAgentWorkflowStepTree(step.children || []),
}))
.filter((step) => step.options.length > 0 || (step.children || []).length > 0);
}
function normalizeText(value: unknown, maxLength: number) {
return String(value || "").trim().slice(0, maxLength);
}
function normalizeKey(value: unknown, fallback: string) {
const normalized = String(value || "")
.trim()
.slice(0, 64)
.replace(/\s+/g, "_")
.replace(/[^\w-]/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return normalized || fallback;
}
function normalizeBoolean(value: unknown, fallback: boolean) {
return typeof value === "boolean" ? value : fallback;
}
function normalizeNumber(value: unknown, fallback: number, max = 999) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(0, Math.min(max, Math.round(parsed)));
}
export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiConfig {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const hasRawSteps = Array.isArray(raw.steps);
const rawSteps = Array.isArray(raw.steps) ? raw.steps : [];
const normalizeStringList = (input: unknown) => {
if (!Array.isArray(input)) return [] as string[];
return Array.from(
new Set(
input
.map((item) => normalizeText(item, 64))
.filter(Boolean)
.slice(0, 300)
)
);
};
const rawInputOptions =
raw.inputOptions && typeof raw.inputOptions === "object" && !Array.isArray(raw.inputOptions)
? (raw.inputOptions as Record<string, unknown>)
: {};
const defaultStepByKey = new Map(
flattenAgentWorkflowSteps(defaultAgentWorkflowUiConfig.steps).map(({ step }) => [
step.key,
step,
] as const)
);
const sourceSteps = hasRawSteps ? rawSteps : (defaultAgentWorkflowUiConfig.steps as unknown[]);
const usedStepKeys = new Set<string>();
const normalizeStep = (
item: unknown,
stepIndex: number,
depth: number
): AgentWorkflowStepUiConfig => {
const stepRecord =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: null;
const candidateKey = normalizeKey(stepRecord?.key, `step_${stepIndex + 1}`);
let stepKey = candidateKey;
let duplicateIndex = 2;
while (usedStepKeys.has(stepKey)) {
stepKey = `${candidateKey}_${duplicateIndex}`;
duplicateIndex += 1;
}
usedStepKeys.add(stepKey);
const defaultStep = defaultStepByKey.get(stepKey);
const fallbackTitle = defaultStep?.title || `步骤${stepIndex + 1}`;
const fallbackDescription = defaultStep?.description || "";
const hasRawOptions = Array.isArray(stepRecord?.options);
const rawOptions = hasRawOptions ? (stepRecord?.options as unknown[]) : [];
const defaultOptions = !hasRawOptions && defaultStep?.options ? defaultStep.options : [];
const optionSource = rawOptions.length > 0 || hasRawOptions ? rawOptions : defaultOptions;
const usedOptionKeys = new Set<string>();
const options = optionSource
.map((option, optionIndex) => {
const optionRecord =
option && typeof option === "object" && !Array.isArray(option)
? (option as Record<string, unknown>)
: null;
const defaultOption = defaultStep?.options.find(
(defaultItem) => defaultItem.key === normalizeText(optionRecord?.key, 64)
);
const optionBaseKey = normalizeKey(
optionRecord?.key,
defaultOption?.key || `${stepKey}_option_${optionIndex + 1}`
);
let optionKey = optionBaseKey;
let optionDuplicateIndex = 2;
while (usedOptionKeys.has(optionKey)) {
optionKey = `${optionBaseKey}_${optionDuplicateIndex}`;
optionDuplicateIndex += 1;
}
usedOptionKeys.add(optionKey);
const buttonName =
normalizeText(optionRecord?.buttonName, 64) ||
defaultOption?.buttonName ||
`选项${optionIndex + 1}`;
const promptFragment =
normalizeText(optionRecord?.promptFragment, 1000) ||
defaultOption?.promptFragment ||
buttonName;
return {
key: optionKey,
buttonName,
description:
normalizeText(optionRecord?.description, 300) || defaultOption?.description || "",
previewImageUrl: normalizeText(optionRecord?.previewImageUrl, 1000),
enabled: normalizeBoolean(optionRecord?.enabled, defaultOption?.enabled ?? true),
sortOrder: normalizeNumber(
optionRecord?.sortOrder,
defaultOption?.sortOrder ?? optionIndex
),
promptFragment,
};
})
.sort((left, right) => left.sortOrder - right.sortOrder);
const hasRawChildren = Array.isArray(stepRecord?.children);
const rawChildren = hasRawChildren ? (stepRecord?.children as unknown[]) : [];
const defaultChildren =
!hasRawChildren && defaultStep?.children ? defaultStep.children : [];
const childSource =
depth < AGENT_WORKFLOW_MAX_TREE_DEPTH && (rawChildren.length > 0 || hasRawChildren)
? rawChildren
: depth < AGENT_WORKFLOW_MAX_TREE_DEPTH
? defaultChildren
: [];
return {
key: stepKey,
title: normalizeText(stepRecord?.title, 64) || fallbackTitle,
description: normalizeText(stepRecord?.description, 300) || fallbackDescription,
enabled: normalizeBoolean(stepRecord?.enabled, defaultStep?.enabled ?? true),
sortOrder: normalizeNumber(stepRecord?.sortOrder, defaultStep?.sortOrder ?? stepIndex),
options,
children:
depth < AGENT_WORKFLOW_MAX_TREE_DEPTH
? childSource
.map((child, childIndex) => normalizeStep(child, childIndex, depth + 1))
.sort((left, right) => left.sortOrder - right.sortOrder)
: [],
};
};
const normalizedSteps = sourceSteps
.map((item, stepIndex) => normalizeStep(item, stepIndex, 1))
.sort((left, right) => left.sortOrder - right.sortOrder);
return {
inputOptions: {
brands: normalizeStringList(rawInputOptions.brands),
stores: normalizeStringList(rawInputOptions.stores),
},
steps: normalizedSteps,
};
}