mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-25 08:48:13 +02:00
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:
parent
7c31dd3eec
commit
7d053320df
27 changed files with 591 additions and 91 deletions
|
|
@ -692,6 +692,7 @@ function RenderWorkflow({
|
|||
isOpen={isAddNodePanelOpen}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onClose={() => setIsAddNodePanelOpen(false)}
|
||||
nodes={nodes}
|
||||
/>
|
||||
|
||||
<VersionHistoryPanel
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue