From f41c09a6c1e33b4c48507cedde569a3cfb3cc73b Mon Sep 17 00:00:00 2001 From: shrimbly Date: Thu, 26 Feb 2026 21:45:28 +1300 Subject: [PATCH] feat(45-02): add ConditionalSwitch execution and match-aware text routing - Import executeConditionalSwitch in workflowStore - Add evaluateRuleMatch helper function for rule evaluation - Add conditionalSwitch case to executeWorkflow with fresh match evaluation - Add conditionalSwitch case to regenerateNode with same pattern - Import ConditionalSwitchNodeData and MatchMode in connectedInputs - Add conditionalSwitch passthrough in getConnectedInputsPure - Block non-active outputs (isMatched=false) from data flow - Default output active when no rules match - Active outputs recursively get upstream text --- src/store/utils/connectedInputs.ts | 29 +++++++++++ src/store/workflowStore.ts | 81 ++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/src/store/utils/connectedInputs.ts b/src/store/utils/connectedInputs.ts index b222edf0..71bdd096 100644 --- a/src/store/utils/connectedInputs.ts +++ b/src/store/utils/connectedInputs.ts @@ -25,6 +25,8 @@ import { LLMGenerateNodeData, GLBViewerNodeData, SwitchNodeData, + ConditionalSwitchNodeData, + MatchMode, } from "@/types"; /** @@ -227,6 +229,33 @@ export function getConnectedInputsPure( return; // Skip normal getSourceOutput processing } + // Conditional Switch passthrough — traverse upstream if output is active (matched or default) + if (sourceNode.type === "conditionalSwitch") { + const condData = sourceNode.data as ConditionalSwitchNodeData; + const sourceHandle = edge.sourceHandle; + + // Find matching rule or check if default + const rule = condData.rules.find(r => r.id === sourceHandle); + const isDefaultHandle = sourceHandle === "default"; + + // Determine if this output is active + let isActive = false; + if (rule) { + isActive = rule.isMatched; + } else if (isDefaultHandle) { + // Default is active when NO rules match + isActive = !condData.rules.some(r => r.isMatched); + } + + // Block non-active outputs (data does not flow through non-matching rules) + if (!isActive) return; + + // Active output: recursively get upstream text + const condInputs = getConnectedInputsPure(sourceNode.id, nodes, edges, _visited, dimmedNodeIds); + if (condInputs.text) text = condInputs.text; + return; // Skip normal getSourceOutput processing + } + const handleId = edge.targetHandle; const { type, value } = getSourceOutput( sourceNode, diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 6ac6080b..73c7e4bb 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -80,11 +80,48 @@ import { executeGlbViewer, executeRouter, executeSwitch, + executeConditionalSwitch, } from "./execution"; import type { NodeExecutionContext } from "./execution"; export type { LevelGroup } from "./utils/executionUtils"; export { CONCURRENCY_SETTINGS_KEY } from "./utils/executionUtils"; +/** + * Evaluates a rule against incoming text with the specified match mode. + * Returns true if any comma-separated value matches using OR logic. + */ +function evaluateRuleMatch(text: string | null, ruleValue: string, mode: "exact" | "contains" | "starts-with" | "ends-with"): boolean { + // Guard: no match if text or ruleValue is empty + if (!text || !ruleValue) return false; + + // Normalize text + const normalizedText = text.toLowerCase().trim(); + + // Split and normalize rule values (comma-separated) + const values = ruleValue + .split(',') + .map(v => v.toLowerCase().trim()) + .filter(v => v.length > 0); + + if (values.length === 0) return false; + + // OR logic: match if ANY value matches + return values.some(value => { + switch (mode) { + case "exact": + return normalizedText === value; + case "contains": + return normalizedText.includes(value); + case "starts-with": + return normalizedText.startsWith(value); + case "ends-with": + return normalizedText.endsWith(value); + default: + return false; + } + }); +} + function saveLogSession(): void { const session = logger.getCurrentSession(); if (session) { @@ -1050,6 +1087,28 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ case "switch": await executeSwitch(executionCtx); break; + case "conditionalSwitch": { + // Before executing, compute fresh match status from incoming text + const condInputs = get().getConnectedInputs(node.id); + const incomingText = condInputs.text; + const nodeData = node.data as { rules: Array<{ id: string; value: string; mode: string; label: string; isMatched: boolean }> }; + + // Evaluate each rule against incoming text + const updatedRules = nodeData.rules.map(rule => { + const isMatched = evaluateRuleMatch(incomingText, rule.value, rule.mode as "exact" | "contains" | "starts-with" | "ends-with"); + return { ...rule, isMatched }; + }); + + // Update node data with fresh match status + get().updateNodeData(node.id, { + incomingText, + rules: updatedRules, + }); + + // Then execute the node (status flash) + await executeConditionalSwitch(executionCtx); + break; + } } }; // End of executeSingleNode helper @@ -1374,6 +1433,28 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ case "switch": await executeSwitch(executionCtx); break; + case "conditionalSwitch": { + // Before executing, compute fresh match status from incoming text + const condInputs = get().getConnectedInputs(node.id); + const incomingText = condInputs.text; + const nodeData = node.data as { rules: Array<{ id: string; value: string; mode: string; label: string; isMatched: boolean }> }; + + // Evaluate each rule against incoming text + const updatedRules = nodeData.rules.map(rule => { + const isMatched = evaluateRuleMatch(incomingText, rule.value, rule.mode as "exact" | "contains" | "starts-with" | "ends-with"); + return { ...rule, isMatched }; + }); + + // Update node data with fresh match status + get().updateNodeData(node.id, { + incomingText, + rules: updatedRules, + }); + + // Then execute the node (status flash) + await executeConditionalSwitch(executionCtx); + break; + } } };