fix: disable duplicate trigger nodes in workflow builder (#402)

* fix: disable duplicate trigger nodes in workflow builder

AddNodePanel: disable trigger buttons and show tooltip when a trigger
already exists on the canvas, using bySpecName to identify trigger-
category specs from the live node list.
useWorkflowState: preflight in saveWorkflow rejects saves with multiple
trigger nodes via a sonner toast before the network request is made.
text_chat_session_service: include the original exception message in
TextChatSessionExecutionError so the HTTP 500 detail surfaces the root
cause without DB inspection.

Closes #378

* style: format test_text_chat_session_service.py with ruff

* chore: retrigger CI checks

* fix(workflow): enforce node instance constraints

---------

Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
nuthalapativarun 2026-06-19 03:29:30 -07:00 committed by GitHub
parent 7c31dd3eec
commit 7d053320df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 591 additions and 91 deletions

View file

@ -692,6 +692,7 @@ function RenderWorkflow({
isOpen={isAddNodePanelOpen}
onNodeSelect={handleNodeSelect}
onClose={() => setIsAddNodePanelOpen(false)}
nodes={nodes}
/>
<VersionHistoryPanel

View file

@ -10,6 +10,7 @@ import { EdgeChange, NodeChange } from "@xyflow/system";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useCallback, useEffect, useRef } from "react";
import { toast } from "sonner";
import { useWorkflowStore } from "@/app/workflow/[workflowId]/stores/workflowStore";
import {
@ -113,7 +114,7 @@ export const useWorkflowState = ({
// Spec catalog. Workflow init waits on this to populate defaults; node
// creation looks up per-type schemas through it.
const { bySpecName, loading: specsLoading } = useNodeSpecs();
const { specs, bySpecName, loading: specsLoading } = useNodeSpecs();
// Get state and actions from the store
const {
@ -306,6 +307,24 @@ export const useWorkflowState = ({
// This avoids a race condition where rfInstance.toObject() may return
// stale node data if React hasn't re-rendered yet after a store update.
const { nodes: currentNodes, edges: currentEdges } = useWorkflowStore.getState();
const nodeTypeCounts = new Map<string, number>();
currentNodes.forEach((node) => {
nodeTypeCounts.set(node.type, (nodeTypeCounts.get(node.type) ?? 0) + 1);
});
const maxInstanceViolation = specs.find((spec) => {
const maxInstances = spec.graph_constraints?.max_instances;
return (
maxInstances !== undefined &&
maxInstances !== null &&
(nodeTypeCounts.get(spec.name) ?? 0) > maxInstances
);
});
if (maxInstanceViolation) {
toast.error(
`${maxInstanceViolation.display_name} limit reached. Remove the extra node before saving.`,
);
return;
}
const viewport = rfInstance.current.getViewport();
const flow = { nodes: currentNodes, edges: currentEdges, viewport };
let result: { versionNumber?: number; versionStatus?: string } | undefined;
@ -372,6 +391,7 @@ export const useWorkflowState = ({
user,
validateWorkflow,
applyWorkflowErrors,
specs,
]);
// Set up keyboard shortcut for save (Cmd/Ctrl + S)

File diff suppressed because one or more lines are too long

View file

@ -519,6 +519,8 @@ export type ByokPipelineAiModelConfiguration = {
} & ElevenlabsTtsConfiguration) | ({
provider: 'cartesia';
} & CartesiaTtsConfiguration) | ({
provider: 'inworld';
} & InworldTtsConfiguration) | ({
provider: 'dograh';
} & DograhTtsService) | ({
provider: 'sarvam';
@ -1078,6 +1080,12 @@ export type CartesiaTtsConfiguration = {
* Volume multiplier for generated speech.
*/
volume?: number;
/**
* Language
*
* Cartesia language code for TTS synthesis (e.g. 'en', 'tr', 'fr', 'de').
*/
language?: string;
};
/**
@ -1779,7 +1787,7 @@ export type DeepgramSttConfiguration = {
/**
* Language
*
* Language code; 'multi' enables auto-detect (Nova-3 only).
* Language code. 'multi' enables Nova-3 auto-detect and omits language hints for Flux multilingual auto-detect.
*/
language?: string;
};
@ -2740,6 +2748,14 @@ export type GraphConstraints = {
* Max Outgoing
*/
max_outgoing?: number | null;
/**
* Min Instances
*/
min_instances?: number | null;
/**
* Max Instances
*/
max_instances?: number | null;
};
/**
@ -2830,6 +2846,14 @@ export type HealthResponse = {
* Force Turn Relay
*/
force_turn_relay: boolean;
/**
* Stack Project Id
*/
stack_project_id?: string | null;
/**
* Stack Publishable Client Key
*/
stack_publishable_client_key?: string | null;
};
/**
@ -3099,6 +3123,52 @@ export type InitiateCallRequest = {
from_phone_number_id?: number | null;
};
/**
* Inworld
*
* Inworld AI streaming text-to-speech with built-in and cloned voices. Defaults to the Ashley system voice on inworld-tts-2.
*/
export type InworldTtsConfiguration = {
/**
* Provider
*/
provider?: 'inworld';
/**
* Api Key
*/
api_key: string | Array<string>;
/**
* Model
*
* Inworld TTS model.
*/
model?: string;
/**
* Voice
*
* Inworld voice ID. Use Ashley for the default warm English voice, or a workspace voice ID for a cloned/custom voice.
*/
voice?: string;
/**
* Language
*
* BCP-47 language code for synthesis.
*/
language?: string;
/**
* Speed
*
* Speech speed multiplier.
*/
speed?: number;
/**
* Delivery Mode
*
* Controls stability versus expressiveness for inworld-tts-2 (STABLE, BALANCED, or CREATIVE).
*/
delivery_mode?: 'STABLE' | 'BALANCED' | 'CREATIVE';
};
/**
* ItemKind
*/
@ -4800,7 +4870,7 @@ export type SarvamTtsConfiguration = {
/**
* Voice
*
* Sarvam voice name; must match the selected model's voice list.
* Sarvam voice name or custom voice ID.
*/
voice?: string;
/**
@ -4964,7 +5034,7 @@ export type SmallestAittsConfiguration = {
/**
* Voice
*
* Smallest AI voice ID.
* Smallest AI voice ID. Available voices differ by model: lightning_v3.1 has a broad multilingual pool; lightning_v3.1_pro has premium American, British, and Indian accent voices (English + Hindi only).
*/
voice?: string;
/**

View file

@ -6,12 +6,13 @@ import type { NodeSpec } from '@/client/types.gen';
import { useNodeSpecs } from '@/components/flow/renderer';
import { Button } from '@/components/ui/button';
import { NodeType } from './types';
import { FlowNode, NodeType } from './types';
type AddNodePanelProps = {
isOpen: boolean;
onClose: () => void;
onNodeSelect: (nodeType: NodeType) => void;
nodes: FlowNode[];
};
// Section ordering and labels. Drives both the category → section title
@ -32,10 +33,12 @@ function NodeSection({
title,
specs,
onNodeSelect,
nodeTypeCounts,
}: {
title: string;
specs: NodeSpec[];
onNodeSelect: (nodeType: NodeType) => void;
nodeTypeCounts: Map<string, number>;
}) {
if (specs.length === 0) return null;
return (
@ -46,12 +49,23 @@ function NodeSection({
<div className="space-y-2">
{specs.map((spec) => {
const Icon = resolveIcon(spec.icon);
const maxInstances = spec.graph_constraints?.max_instances;
const disabled =
maxInstances !== undefined &&
maxInstances !== null &&
(nodeTypeCounts.get(spec.name) ?? 0) >= maxInstances;
return (
<Button
key={spec.name}
variant="outline"
className="w-full justify-start p-4 h-auto hover:bg-accent/50 transition-colors"
onClick={() => onNodeSelect(spec.name as NodeType)}
disabled={disabled}
title={
disabled
? `${spec.display_name} limit reached for this workflow`
: undefined
}
>
<div className="flex items-center">
<div className="bg-muted p-2 rounded-lg mr-3 border border-border">
@ -74,7 +88,7 @@ function NodeSection({
);
}
export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodePanelProps) {
export default function AddNodePanel({ isOpen, onNodeSelect, onClose, nodes }: AddNodePanelProps) {
const { specs } = useNodeSpecs();
// Group registered specs by category, preserving the SECTION_ORDER.
@ -86,6 +100,14 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
}));
}, [specs]);
const nodeTypeCounts = useMemo(() => {
const counts = new Map<string, number>();
nodes.forEach((node) => {
counts.set(node.type, (counts.get(node.type) ?? 0) + 1);
});
return counts;
}, [nodes]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
@ -128,6 +150,7 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
title={title}
specs={specs}
onNodeSelect={onNodeSelect}
nodeTypeCounts={nodeTypeCounts}
/>
))}
</div>