diff --git a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx index 80d449e5d..5fc7219b8 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -170,6 +170,7 @@ function createWorkflowOptionDraft( enabled: true, sortOrder: optionIndex, promptFragment: `请按选项${optionIndex + 1}执行。`, + blockedOptionKeys: [], children: [], }; } @@ -212,6 +213,27 @@ function collectWorkflowOptionKeys(options: AgentWorkflowOptionUiConfig[]): stri return flattenAgentWorkflowOptionTree(options).map(({ option }) => option.key); } +function collectWorkflowOptionRelations( + options: AgentWorkflowOptionUiConfig[], + ancestors: string[] = [] +): Array<{ + key: string; + label: string; + depth: number; +}> { + return options.flatMap((option) => { + const currentPath = [...ancestors, option.buttonName || option.key]; + return [ + { + key: option.key, + label: currentPath.join(" / "), + depth: currentPath.length, + }, + ...collectWorkflowOptionRelations(option.children || [], currentPath), + ]; + }); +} + function updateWorkflowOptionTree( options: AgentWorkflowOptionUiConfig[], optionKey: string, @@ -1212,6 +1234,20 @@ export function AgentWorkflowSettingsClient({ (left, right) => left.sortOrder - right.sortOrder ); const canAddChild = depth < AGENT_WORKFLOW_MAX_OPTION_DEPTH; + const optionRelations = collectWorkflowOptionRelations(step.options || []); + const optionLookup = new Map(optionRelations.map((item) => [item.key, item] as const)); + const blockedCandidates = optionRelations.filter((item) => item.key !== option.key); + const selectedBlockedItems = (option.blockedOptionKeys || []) + .map((blockedKey) => optionLookup.get(blockedKey)) + .filter( + ( + item + ): item is { + key: string; + label: string; + depth: number; + } => Boolean(item) + ); return (
1 && "pl-4 sm:pl-6")}> @@ -1327,6 +1363,101 @@ export function AgentWorkflowSettingsClient({ className="resize-none text-xs" />
+
+
+ +

+ 选中当前卡片后,下方指定的其它卡片会在前台变灰并不可选。 +

+
+ + {selectedBlockedItems.length > 0 ? ( +
+ {selectedBlockedItems.map((blockedItem) => ( + + + {getWorkflowLevelLabel(blockedItem.depth, "卡片")} + + {blockedItem.label} + + + ))} +
+ ) : ( +
+ 当前还没有配置互斥卡片 +
+ )} + +
+ {blockedCandidates.length > 0 ? ( + blockedCandidates.map((candidate) => { + const active = (option.blockedOptionKeys || []).includes(candidate.key); + return ( + + ); + }) + ) : ( +
+ 当前步骤下没有其他可限制卡片 +
+ )} +
+
diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 28cc082be..115329adf 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -2880,6 +2880,57 @@ const AGENT_PROMPT_STRUCTURAL_LABELS = new Set([ "输出", ]); +const AGENT_PROMPT_LABEL_SEMANTIC_ALIASES = new Map([ + ["角色", "role"], + ["技能", "skills"], + ["目标", "goal"], + ["约束条件", "constraints"], + ["流程", "process"], + ["输出", "output"], + ["人群选择", "audience"], + ["人物", "audience"], + ["主题风格", "style"], + ["整体风格", "style"], + ["空间选择", "space"], + ["空间布局", "space"], + ["主空间", "space"], + ["子空间选择", "subspace"], + ["子空间", "subspace"], + ["硬装方案", "hardDecoration"], + ["硬装", "hardDecoration"], + ["软装方案", "softDecoration"], + ["软装", "softDecoration"], + ["光氛选择", "lighting"], + ["光源布置", "lighting"], + ["灯具", "lighting"], + ["机位选择", "camera"], + ["拍摄角度", "camera"], + ["配饰陈列", "accessories"], + ["放在产品内的小件配饰", "accessories"], + ["前景", "sceneLayout"], + ["中景", "sceneLayout"], + ["背景", "sceneLayout"], +]); + +const AGENT_SCENE_WORKFLOW_OWNED_SEMANTICS = new Set([ + "role", + "skills", + "goal", + "constraints", + "process", + "output", + "audience", + "style", + "space", + "subspace", + "hardDecoration", + "softDecoration", + "lighting", + "camera", + "accessories", + "sceneLayout", +]); + function normalizePromptLineForCompare(line: string) { return String(line || "") .replace(/\s+/g, " ") @@ -2893,6 +2944,14 @@ function parsePromptLineLabel(line: string) { const normalized = String(line || "").replace(/\s+/g, " ").trim(); if (!normalized) return { label: "", value: "" }; + const headerOnlyMatch = normalized.match(/^([^::]{1,20})[::]\s*$/); + if (headerOnlyMatch) { + return { + label: headerOnlyMatch[1].trim(), + value: "", + }; + } + const colonMatch = normalized.match(/^([^::]{1,20})[::]\s*(.+)$/); if (colonMatch) { return { @@ -2912,13 +2971,51 @@ function parsePromptLineLabel(line: string) { return { label: "", value: normalized }; } +function normalizePromptLabel(label: string) { + return String(label || "") + .replace(/\s+/g, "") + .replace(/[::]/g, "") + .trim(); +} + +function getPromptSemanticLabel(label: string) { + const normalized = normalizePromptLabel(label); + return AGENT_PROMPT_LABEL_SEMANTIC_ALIASES.get(normalized) || normalized; +} + +function shouldDropSceneSlotSemanticLine( + slotSemanticLabel: string, + workflowSemanticLabels: Set, +) { + if (!slotSemanticLabel || !AGENT_SCENE_WORKFLOW_OWNED_SEMANTICS.has(slotSemanticLabel)) { + return false; + } + + if (slotSemanticLabel === "sceneLayout") { + return [ + "style", + "space", + "subspace", + "hardDecoration", + "softDecoration", + "accessories", + "camera", + "lighting", + ].some((semantic) => workflowSemanticLabels.has(semantic)); + } + + return workflowSemanticLabels.has(slotSemanticLabel); +} + function dedupeAgentSlotPromptAgainstWorkflow(params: { workflowPrompt?: string | null; slotPrompt?: string | null; + modeType?: "scene_generation" | "detail_replacement" | "sku_replacement" | "generic"; }) { const dedupedSlotLines: string[] = []; const seenExactLines = new Set(); const workflowStructuralValues = new Map(); + const workflowSemanticLabels = new Set(); const rememberLine = (line: string) => { const normalizedLine = normalizePromptLineForCompare(line); @@ -2934,6 +3031,9 @@ function dedupeAgentSlotPromptAgainstWorkflow(params: { .filter(Boolean); normalizedWorkflowLines.forEach((line) => { const parsed = parsePromptLineLabel(line); + if (parsed.label) { + workflowSemanticLabels.add(getPromptSemanticLabel(parsed.label)); + } if (parsed.label && AGENT_PROMPT_STRUCTURAL_LABELS.has(parsed.label)) { workflowStructuralValues.set( parsed.label, @@ -2947,12 +3047,26 @@ function dedupeAgentSlotPromptAgainstWorkflow(params: { .split(/\n+/) .map((line) => line.replace(/\s+/g, " ").trim()) .filter(Boolean); + let activeSlotSemanticLabel = ""; normalizedSlotLines.forEach((line) => { const normalizedLine = normalizePromptLineForCompare(line); if (!normalizedLine || seenExactLines.has(normalizedLine)) { return; } const parsed = parsePromptLineLabel(line); + const slotSemanticLabel = parsed.label ? getPromptSemanticLabel(parsed.label) : ""; + if (slotSemanticLabel) { + activeSlotSemanticLabel = slotSemanticLabel; + } + if ( + params.modeType === "scene_generation" && + shouldDropSceneSlotSemanticLine( + slotSemanticLabel || activeSlotSemanticLabel, + workflowSemanticLabels, + ) + ) { + return; + } if (parsed.label && AGENT_PROMPT_STRUCTURAL_LABELS.has(parsed.label)) { const normalizedValue = normalizePromptLineForCompare(parsed.value || ""); const workflowValue = workflowStructuralValues.get(parsed.label); @@ -2972,6 +3086,8 @@ function buildAgentSlotPrompt(params: { slotPrompt: string; slotTitle?: string | null; userSupplementPrompt?: string | null; + includeSlotTitle?: boolean; + modeType?: "scene_generation" | "detail_replacement" | "sku_replacement" | "generic"; }) { const lines: string[] = []; const workflowPrompt = normalizeAgentWorkflowPrompt(params.workflowPrompt || ""); @@ -2979,12 +3095,13 @@ function buildAgentSlotPrompt(params: { lines.push(workflowPrompt); } const slotTitle = String(params.slotTitle || "").trim(); - if (slotTitle) { + if (params.includeSlotTitle !== false && slotTitle) { lines.push(`【槽位】${slotTitle}`); } const dedupedSlotPrompt = dedupeAgentSlotPromptAgainstWorkflow({ workflowPrompt, slotPrompt: params.slotPrompt, + modeType: params.modeType || "generic", }); if (dedupedSlotPrompt) { lines.push(dedupedSlotPrompt); @@ -2996,6 +3113,22 @@ function buildAgentSlotPrompt(params: { return lines.join("\n\n").trim(); } +function buildAgentSceneWorkflowPrompt(params: { + workflowPrompt?: string | null; + userSupplementPrompt?: string | null; +}) { + const lines: string[] = []; + const workflowPrompt = String(params.workflowPrompt || "").trim(); + if (workflowPrompt) { + lines.push(workflowPrompt); + } + const supplement = String(params.userSupplementPrompt || "").trim(); + if (supplement) { + lines.push(`【补充要求】${supplement}`); + } + return lines.join("\n\n").trim(); +} + type AgentReplacementModePayload = NonNullable; type ReplacementReferenceDecision = { @@ -6633,6 +6766,12 @@ export async function POST(request: Request) { replacementSlot?.promptTemplate || selectedAgentPreset.promptTemplate, slotTitle: selectedAgentPreset.name, userSupplementPrompt: sanitizedUserSupplementPrompt || null, + includeSlotTitle: false, + modeType: isDetailReplacementPreset + ? "detail_replacement" + : isSkuReplacementPreset + ? "sku_replacement" + : "generic", }); if (productInputImageUrls.length === 0) { @@ -7082,12 +7221,18 @@ export async function POST(request: Request) { [...sharedUploadedAssetIds, ...(sceneSlot?.referenceAssetIds || [])].filter(Boolean), ), ); - const scenePrompt = buildAgentSlotPrompt({ + const scenePrompt = + buildAgentSceneWorkflowPrompt({ + workflowPrompt: hiddenPromptText || null, + userSupplementPrompt: sanitizedUserSupplementPrompt || null, + }) || + buildAgentSlotPrompt({ workflowPrompt: hiddenPromptText || null, slotPrompt: sceneSlot?.promptTemplate || selectedAgentPreset.promptTemplate, - slotTitle: selectedAgentPreset.name, userSupplementPrompt: sanitizedUserSupplementPrompt || null, - }); + includeSlotTitle: false, + modeType: "scene_generation", + }); const sceneGenerationStartedAt = Date.now(); try { @@ -7113,6 +7258,9 @@ export async function POST(request: Request) { resolvedImageCount, sourceImageCount: sourceImageUrls.length, sourceAssetCount: sourceAssetIds.length, + promptStrategy: hiddenPromptText || sanitizedUserSupplementPrompt + ? "workflow_only" + : "legacy_template_fallback", promptLength: String(scenePrompt || `生成${selectedAgentPreset.name}`).length, aspectRatio: typeof mergedImageParams.aspectRatio === "string" @@ -7326,6 +7474,7 @@ export async function POST(request: Request) { slotPrompt: slot.promptTemplate, slotTitle: slot.title || slot.uploadLabel || null, userSupplementPrompt: sanitizedUserSupplementPrompt || null, + modeType: "generic", }); const assistantMessageId = generateUUID(); diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 4a58a5b14..a4eb27fe6 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -724,6 +724,10 @@ const updateSchema = z.object({ enabled: z.boolean().optional(), sortOrder: z.number().min(0).max(999).optional(), promptFragment: z.string().trim().max(1000).nullable().optional(), + blockedOptionKeys: z + .array(z.string().trim().min(1).max(64)) + .max(100) + .optional(), children: z.array(z.any()).max(100).optional(), }) ) diff --git a/components/agents/AgentWorkflowPanelDarkV2.tsx b/components/agents/AgentWorkflowPanelDarkV2.tsx index 0082df0b4..336b62184 100644 --- a/components/agents/AgentWorkflowPanelDarkV2.tsx +++ b/components/agents/AgentWorkflowPanelDarkV2.tsx @@ -12,6 +12,7 @@ import { defaultAgentWorkflowUiConfig, flattenAgentWorkflowSteps, flattenVisibleAgentWorkflowSteps, + getAgentWorkflowBlockedOptionKeys, getAgentWorkflowBrandStoreEntries, getAgentWorkflowPriceBandOptions, getAgentWorkflowSelectedOptionPath, @@ -159,31 +160,37 @@ function StepOptionCard({ option, step, selected, + disabled = false, onClick, index, }: { option: { key: string; buttonName: string; description?: string | null; previewImageUrl?: string | null }; step: { title: string }; selected: boolean; + disabled?: boolean; onClick: () => void; index: number; }) { return ( {/* 背景 */} @@ -193,7 +200,10 @@ function StepOptionCard({ {option.buttonName}
@@ -215,6 +225,12 @@ function StepOptionCard({ )} + {disabled && !selected ? ( +
+ 已限制 +
+ ) : null} + {/* 步骤标签 */}
@@ -389,6 +405,18 @@ export function AgentWorkflowPanelDarkV2({ ]); return Object.fromEntries(entries) as Record; }, [resolvedConfig.steps, selections]); + const blockedOptionKeyMap = useMemo(() => { + const entries = flattenAgentWorkflowSteps(resolvedConfig.steps).map(({ step }) => [ + step.key, + new Set( + getAgentWorkflowBlockedOptionKeys({ + step, + selectionValue: selections[step.key], + }) + ), + ] as const); + return new Map(entries); + }, [resolvedConfig.steps, selections]); const [activeEmbeddedStepKey, setActiveEmbeddedStepKey] = useState(""); const [activeEmbeddedOptionDepth, setActiveEmbeddedOptionDepth] = useState(1); const [pendingAutoAdvanceStepKey, setPendingAutoAdvanceStepKey] = useState(null); @@ -519,6 +547,7 @@ export function AgentWorkflowPanelDarkV2({ const currentSelectedKey = selectedPathKeys[depth - 1] || ""; const currentSelectedOption = options.find((option) => option.key === currentSelectedKey) || null; + const blockedKeys = blockedOptionKeyMap.get(step.key) || new Set(); const visibleChildren = getEnabledAgentWorkflowOptionTree( currentSelectedOption?.children || [] ); @@ -538,25 +567,37 @@ export function AgentWorkflowPanelDarkV2({
- {options.map((option, optionIndex) => ( - { - const prefix = parentPath.slice(0, depth - 1); - const nextKeys = - currentSelectedKey === option.key ? prefix : [...prefix, option.key]; - setPendingAutoAdvanceStepKey(step.key); - onSelectionsChange({ - ...selections, - [step.key]: nextKeys.length > 0 ? serializeAgentWorkflowSelectionPath(nextKeys) : undefined, - }); - }} - index={optionIndex} - /> - ))} + {options.map((option, optionIndex) => { + const isDisabled = + currentSelectedKey !== option.key && blockedKeys.has(option.key); + + return ( + { + if (isDisabled) { + return; + } + const prefix = parentPath.slice(0, depth - 1); + const nextKeys = + currentSelectedKey === option.key ? prefix : [...prefix, option.key]; + setPendingAutoAdvanceStepKey(step.key); + onSelectionsChange({ + ...selections, + [step.key]: + nextKeys.length > 0 + ? serializeAgentWorkflowSelectionPath(nextKeys) + : undefined, + }); + }} + index={optionIndex} + /> + ); + })}
{currentSelectedOption && visibleChildren.length > 0 @@ -748,6 +789,11 @@ export function AgentWorkflowPanelDarkV2({
{activeEmbeddedOptionLevel.options.map((option, optionIndex) => { const currentSelectedKey = activeEmbeddedOptionLevel.selectedKey; + const blockedKeys = + blockedOptionKeyMap.get(activeEmbeddedStepEntry.step.key) || + new Set(); + const isDisabled = + currentSelectedKey !== option.key && blockedKeys.has(option.key); const nextChildren = getEnabledAgentWorkflowOptionTree( option.children || [] ); @@ -758,7 +804,11 @@ export function AgentWorkflowPanelDarkV2({ option={option} step={activeEmbeddedStepEntry.step} selected={currentSelectedKey === option.key} + disabled={isDisabled} onClick={() => { + if (isDisabled) { + return; + } const selectedPathKeys = activeEmbeddedOptionLevels .slice(0, activeEmbeddedOptionLevel.depth - 1) .map((level) => level.selectedKey) diff --git a/lib/agents/workflow-ui-config.ts b/lib/agents/workflow-ui-config.ts index 4294f0ef3..90f30ff30 100644 --- a/lib/agents/workflow-ui-config.ts +++ b/lib/agents/workflow-ui-config.ts @@ -23,6 +23,10 @@ function cloneOption( description: option.description || "", previewImageUrl: option.previewImageUrl || "", promptFragment: option.promptFragment || "", + blockedOptionKeys: Array.from(new Set((option.blockedOptionKeys || []).filter(Boolean))).slice( + 0, + 100 + ), children: (option.children || []).map(cloneOption), }; } @@ -67,6 +71,7 @@ function createOption( enabled: true, sortOrder, promptFragment, + blockedOptionKeys: [], children: [], }; } @@ -349,6 +354,37 @@ function normalizeNumber(value: unknown, fallback: number, max = 999) { return Math.max(0, Math.min(max, Math.round(parsed))); } +function normalizeKeyList(value: unknown, max = 100) { + if (!Array.isArray(value)) return [] as string[]; + return Array.from( + new Set( + value + .map((item, index) => normalizeKey(item, `blocked_option_${index + 1}`)) + .filter(Boolean) + .slice(0, max) + ) + ); +} + +function sanitizeBlockedOptionTree( + options: AgentWorkflowOptionUiConfig[], + validKeys = new Set( + flattenAgentWorkflowOptionTree(options).map(({ option }) => option.key) + ) +): AgentWorkflowOptionUiConfig[] { + return options.map((option) => ({ + ...option, + blockedOptionKeys: Array.from( + new Set( + (option.blockedOptionKeys || []).filter( + (blockedKey) => blockedKey !== option.key && validKeys.has(blockedKey) + ) + ) + ).slice(0, 100), + children: sanitizeBlockedOptionTree(option.children || [], validKeys), + })); +} + export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiConfig { const raw = value && typeof value === "object" && !Array.isArray(value) @@ -456,6 +492,7 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC normalizeText(optionRecord.promptFragment, 1000) || defaultOption?.promptFragment || buttonName; + const hasRawBlockedOptionKeys = Array.isArray(optionRecord.blockedOptionKeys); const hasRawChildren = Array.isArray(optionRecord.children); const rawChildren = hasRawChildren ? (optionRecord.children as unknown[]) : []; const defaultChildren = @@ -479,6 +516,9 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC defaultOption?.sortOrder ?? optionIndex ), promptFragment, + blockedOptionKeys: hasRawBlockedOptionKeys + ? normalizeKeyList(optionRecord.blockedOptionKeys, 100) + : normalizeKeyList(defaultOption?.blockedOptionKeys, 100), children: depth < AGENT_WORKFLOW_MAX_OPTION_DEPTH ? childSource @@ -529,6 +569,7 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC normalizeOption(option, optionIndex, stepKey, usedOptionKeys, defaultOptions, 1) ) .sort((left, right) => left.sortOrder - right.sortOrder); + const sanitizedOptions = sanitizeBlockedOptionTree(options); const hasRawChildren = Array.isArray(stepRecord?.children); const rawChildren = hasRawChildren ? (stepRecord?.children as unknown[]) : []; @@ -547,7 +588,7 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC description: normalizeText(stepRecord?.description, 300) || fallbackDescription, enabled: normalizeBoolean(stepRecord?.enabled, defaultStep?.enabled ?? true), sortOrder: normalizeNumber(stepRecord?.sortOrder, defaultStep?.sortOrder ?? stepIndex), - options, + options: sanitizedOptions, children: depth < AGENT_WORKFLOW_MAX_TREE_DEPTH ? childSource @@ -638,6 +679,18 @@ export function getAgentWorkflowSelectedOptionPath(params: { return selectedOptions; } +export function getAgentWorkflowBlockedOptionKeys(params: { + step: AgentWorkflowStepUiConfig; + selectionValue?: string | null; +}) { + const selectedOptions = getAgentWorkflowSelectedOptionPath(params); + return Array.from( + new Set( + selectedOptions.flatMap((option) => (option.blockedOptionKeys || []).filter(Boolean)) + ) + ); +} + export function isAgentWorkflowStepSelectionComplete(params: { step: AgentWorkflowStepUiConfig; selectionValue?: string | null; diff --git a/types/agent.ts b/types/agent.ts index d40f13cb6..70d40d87f 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -87,6 +87,7 @@ export interface AgentWorkflowOptionUiConfig { enabled: boolean; sortOrder: number; promptFragment?: string | null; + blockedOptionKeys?: string[]; children?: AgentWorkflowOptionUiConfig[]; }