diff --git a/multi-agent/api/flask/endpoints/blueprints.py b/multi-agent/api/flask/endpoints/blueprints.py index 027e5007b..cec42f02c 100644 --- a/multi-agent/api/flask/endpoints/blueprints.py +++ b/multi-agent/api/flask/endpoints/blueprints.py @@ -174,6 +174,39 @@ def save_blueprint(blueprint_raw=None, user_id="alice", metadata=None): return jsonify({"status": "error", "error": str(e)}), 500 +@blueprints_bp.route("/blueprint.update", methods=["PUT"]) +@from_body({ + "blueprint_id": fields.Str(data_key="blueprintId", required=True), + "blueprint_raw": fields.Str(data_key="blueprintRaw", required=True), +}) +def update_blueprint(blueprint_id, blueprint_raw): + """Update an existing blueprint in-place, keeping the same ID.""" + try: + parsed = _extract_blueprint_data( + json_field_value=blueprint_raw, + field_name="blueprint_raw" + ) + + svc = current_app.container.blueprint_service + success = svc.update_draft(blueprint_id=blueprint_id, draft_dict=parsed) + + if not success: + return jsonify({"status": "error", "error": "Failed to update blueprint"}), 500 + + return jsonify({ + "status": "success", + "blueprint_id": blueprint_id, + }), 200 + + except BlueprintNotFoundError as e: + return jsonify({"status": "error", "error": str(e)}), 404 + except BadRequest as e: + return jsonify({"status": "error", "error": str(e)}), 400 + except Exception as e: + logger.exception(f"Unexpected error updating blueprint {blueprint_id}") + return jsonify({"status": "error", "error": str(e)}), 500 + + @blueprints_bp.route("/blueprint.info.get", methods=["GET"]) @from_query({ "blueprint_id": fields.Str(data_key="blueprintId", required=True) diff --git a/multi-agent/blueprints/service.py b/multi-agent/blueprints/service.py index eabe52995..6fb0e3ea6 100644 --- a/multi-agent/blueprints/service.py +++ b/multi-agent/blueprints/service.py @@ -42,12 +42,15 @@ def get_blueprint_draft_doc(self, blueprint_id: str) -> BlueprintDocument: """Get blueprint document with metadata for sharing operations.""" return self._repo.load(blueprint_id) - def update_draft(self, *, blueprint_id: str, draft_dict: dict) -> bool: # NEW + def update_draft(self, *, blueprint_id: str, draft_dict: dict) -> bool: draft = BlueprintDraft(**draft_dict) rid_refs = list(RefWalker.external_rids(draft)) - return self._repo.update( - blueprint_id=blueprint_id, spec=draft, rid_refs=rid_refs - ) + try: + return self._repo.update( + blueprint_id=blueprint_id, spec=draft, rid_refs=rid_refs + ) + except KeyError: + raise BlueprintNotFoundError(blueprint_id) from None def load_resolved(self, blueprint_id: str) -> BlueprintSpec: return self._resolver.resolve(self.load_draft(blueprint_id)) diff --git a/ui/client/src/api/blueprints.ts b/ui/client/src/api/blueprints.ts index f23d710bf..5a712bb97 100644 --- a/ui/client/src/api/blueprints.ts +++ b/ui/client/src/api/blueprints.ts @@ -126,6 +126,20 @@ export async function saveBlueprint( return data; } +/** + * Update an existing blueprint in-place (keeps the same ID) + */ +export async function updateBlueprint( + blueprintId: string, + blueprintRaw: string, +): Promise { + const { data } = await axios.put('/blueprints/blueprint.update', { + blueprintId, + blueprintRaw, + }); + return data; +} + // ──────────────────────────────────────────────────────────────────────────────── // Blueprint Metadata & Sharing // ──────────────────────────────────────────────────────────────────────────────── diff --git a/ui/client/src/components/agentic-ai/AgentFlowGraph.tsx b/ui/client/src/components/agentic-ai/AgentFlowGraph.tsx index 830c19a30..2f561f8a2 100644 --- a/ui/client/src/components/agentic-ai/AgentFlowGraph.tsx +++ b/ui/client/src/components/agentic-ai/AgentFlowGraph.tsx @@ -9,12 +9,14 @@ type AgentFlowGraphProps = { selectedFlow: FlowObject | null; setSelectedFlow: (flow: FlowObject | null) => void; onValidationChange?: (isValid: boolean, validationResult: BlueprintValidationResult | null, isValidating: boolean) => void; + onFlowEdit?: (flow: FlowObject) => void; }; export default function AgentFlowGraph({ selectedFlow, setSelectedFlow, onValidationChange, + onFlowEdit, }: AgentFlowGraphProps): React.ReactElement { const handleFlowSelect = (flow: FlowObject | null): void => { @@ -41,9 +43,11 @@ export default function AgentFlowGraph({ selectedFlow={selectedFlow} onFlowSelect={handleFlowSelect} onFlowDelete={handleFlowDelete} + onFlowEdit={onFlowEdit} onValidationChange={onValidationChange} showActiveStatus={true} showDeleteButton={true} + showEditButton={true} height="100%" graphProps={{ showBackground: true, diff --git a/ui/client/src/components/agentic-ai/WorkflowsPanel.tsx b/ui/client/src/components/agentic-ai/WorkflowsPanel.tsx index ec4886e2e..df61220fb 100644 --- a/ui/client/src/components/agentic-ai/WorkflowsPanel.tsx +++ b/ui/client/src/components/agentic-ai/WorkflowsPanel.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback } from "react"; import { motion } from "framer-motion"; -import { Trash2, Users } from "lucide-react"; +import { Trash2, Users, Pencil } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; import { useShared } from "@/contexts/SharedContext"; import { Button } from "@/components/ui/button"; @@ -26,9 +26,11 @@ export interface WorkflowsPanelProps { selectedFlow: FlowObject | null; onFlowSelect: (flow: FlowObject | null) => void; onFlowDelete?: (flow: FlowObject) => void; + onFlowEdit?: (flow: FlowObject) => void; onValidationChange?: (isValid: boolean, validationResult: BlueprintValidationResult | null, isValidating: boolean) => void; showActiveStatus?: boolean; showDeleteButton?: boolean; + showEditButton?: boolean; className?: string; height?: string; graphProps?: { @@ -41,9 +43,11 @@ export default function WorkflowsPanel({ selectedFlow, onFlowSelect, onFlowDelete, + onFlowEdit, onValidationChange, showActiveStatus = false, showDeleteButton = false, + showEditButton = false, className = "", height = "100%", graphProps = { @@ -193,6 +197,13 @@ export default function WorkflowsPanel({ setShowDeleteModal(true); }; + const handleEditClick = (flow: FlowObject, event: React.MouseEvent) => { + event.stopPropagation(); + if (onFlowEdit) { + onFlowEdit(flow); + } + }; + const handleShareClick = (flow: FlowObject, event: React.MouseEvent) => { event.stopPropagation(); // Prevent flow selection when clicking share openShareForItem({ @@ -302,6 +313,18 @@ export default function WorkflowsPanel({ Active )} + {showEditButton && ( + Edit this workflow

}> + +
+ )} Share this workflow

}> diff --git a/ui/client/src/components/agentic-ai/graphs/SaveBlueprintModal.tsx b/ui/client/src/components/agentic-ai/graphs/SaveBlueprintModal.tsx index 1cefe8de9..f6dc73bb3 100644 --- a/ui/client/src/components/agentic-ai/graphs/SaveBlueprintModal.tsx +++ b/ui/client/src/components/agentic-ai/graphs/SaveBlueprintModal.tsx @@ -1,5 +1,5 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { Dialog, DialogContent, @@ -19,6 +19,9 @@ interface SaveBlueprintModalProps { onClose: () => void; onSave: (name: string, description: string) => void; isLoading?: boolean; + isEditMode?: boolean; + currentName?: string; + currentDescription?: string; } const SaveBlueprintModal: React.FC = ({ @@ -26,9 +29,19 @@ const SaveBlueprintModal: React.FC = ({ onClose, onSave, isLoading = false, + isEditMode = false, + currentName = "", + currentDescription = "", }) => { - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); + const [name, setName] = useState(currentName); + const [description, setDescription] = useState(currentDescription); + + useEffect(() => { + if (isOpen) { + setName(currentName); + setDescription(currentDescription); + } + }, [isOpen]); const handleSave = () => { if (!name.trim()) { @@ -38,8 +51,8 @@ const SaveBlueprintModal: React.FC = ({ }; const handleClose = () => { - setName(""); - setDescription(""); + setName(isEditMode ? currentName : ""); + setDescription(isEditMode ? currentDescription : ""); onClose(); }; @@ -49,7 +62,9 @@ const SaveBlueprintModal: React.FC = ({ - Save Workflow + + {isEditMode ? "Update Workflow" : "Save Workflow"} +
@@ -103,10 +118,10 @@ const SaveBlueprintModal: React.FC = ({ {isLoading ? (
- Saving... + {isEditMode ? "Updating..." : "Saving..."}
) : ( - "Save Workflow" + isEditMode ? "Update Workflow" : "Save Workflow" )} diff --git a/ui/client/src/hooks/use-graph-logic.ts b/ui/client/src/hooks/use-graph-logic.ts index 0603ec324..ed4bfa655 100644 --- a/ui/client/src/hooks/use-graph-logic.ts +++ b/ui/client/src/hooks/use-graph-logic.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useMemo } from "react"; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { Node, Edge, @@ -16,37 +16,9 @@ import { useTheme } from "@/contexts/ThemeContext"; import { deriveThemeColors } from "@/lib/colorUtils"; import axios from "../http/axiosAgentConfig"; import * as yaml from "js-yaml"; -import { saveBlueprint } from "@/api/blueprints"; - -interface YamlFlowNode { - rid: string; - name: string; - type?: string; - config?: any; -} - -interface YamlFlowPlanStep { - uid: string; - node: string; - after?: string | string[] | null; - branches?: any; - exit_condition?: string; -} - -interface YamlFlowCondition { - rid: string; - name: string; - type?: string; - config?: any; -} - -interface YamlFlowState { - name?: string; - description?: string; - nodes: YamlFlowNode[]; - plan: YamlFlowPlanStep[]; - conditions?: YamlFlowCondition[]; -} +import { saveBlueprint, updateBlueprint } from "@/api/blueprints"; +import { loadBlueprintForEditing } from "@/hooks/use-load-blueprint"; +import type { YamlFlowState } from "@/hooks/use-load-blueprint"; const defaulYmlState: YamlFlowState = { nodes: [ @@ -88,10 +60,12 @@ export interface SavedBlueprintInfo { interface UseGraphLogicOptions { /** Callback to execute after a successful save (e.g., navigate back to workflow list) */ onSaveComplete?: (savedBlueprint?: SavedBlueprintInfo) => void; + /** When provided, load this blueprint for editing instead of starting with an empty canvas */ + editBlueprintId?: string | null; } export const useGraphLogic = (options: UseGraphLogicOptions = {}) => { - const { onSaveComplete } = options; + const { onSaveComplete, editBlueprintId } = options; const { toast } = useToast(); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -151,6 +125,12 @@ export const useGraphLogic = (options: UseGraphLogicOptions = {}) => { // Drag state to track what type of item is being dragged const [isDraggingCondition, setIsDraggingCondition] = useState(false); + // Edit mode state + const [isEditMode, setIsEditMode] = useState(!!editBlueprintId); + const [editBlueprintName, setEditBlueprintName] = useState(""); + const [editBlueprintDescription, setEditBlueprintDescription] = useState(""); + const blueprintLoadedRef = useRef(false); + const { user } = useAuth(); const USER_ID = user?.username || "default"; @@ -278,15 +258,48 @@ export const useGraphLogic = (options: UseGraphLogicOptions = {}) => { const updatedPlan = prevFlow.plan .filter((step) => step.uid !== nodeId) .map((step) => { - if (step.after === nodeId) { - const { after, ...stepWithoutAfter } = step; - return stepWithoutAfter; + let updated = step; + + // Clean up `after` references to the deleted node + if (updated.after) { + if (Array.isArray(updated.after)) { + const filtered = updated.after.filter((a) => a !== nodeId); + if (filtered.length === 0) { + const { after, ...rest } = updated; + updated = rest as typeof step; + } else if (filtered.length === 1) { + updated = { ...updated, after: filtered[0] }; + } else { + updated = { ...updated, after: filtered }; + } + } else if (updated.after === nodeId) { + const { after, ...rest } = updated; + updated = rest as typeof step; + } + } + + // Clean up `branches` that target the deleted node + if (updated.branches) { + const cleanedBranches = { ...updated.branches }; + for (const key of Object.keys(cleanedBranches)) { + if (cleanedBranches[key] === nodeId) { + delete cleanedBranches[key]; + } + } + if (Object.keys(cleanedBranches).length === 0) { + const { branches, exit_condition, ...rest } = updated; + updated = rest as typeof step; + } else { + updated = { ...updated, branches: cleanedBranches }; + } } - return step; + + return updated; }); return { - nodes: prevFlow.nodes, + ...prevFlow, + nodes: updatedNodes, conditions: prevFlow.conditions || [], plan: updatedPlan, }; @@ -370,7 +383,7 @@ export const useGraphLogic = (options: UseGraphLogicOptions = {}) => { }); return { - nodes: prevFlow.nodes, + ...prevFlow, conditions: prevFlow.conditions || [], plan: updatedPlan, }; @@ -617,9 +630,55 @@ export const useGraphLogic = (options: UseGraphLogicOptions = {}) => { useEffect(() => { loadBuildingBlocks(); - initializeDefaultNodes(); + if (!editBlueprintId) { + initializeDefaultNodes(); + } }, [loadBuildingBlocks]); + // Load existing blueprint for editing once building blocks are ready + useEffect(() => { + if (!editBlueprintId || isLoadingBlocks || blueprintLoadedRef.current) return; + blueprintLoadedRef.current = true; + + loadBlueprintForEditing( + editBlueprintId, + allBlocksData, + conditionsData, + conditionEdgeColor, + ) + .then((result) => { + const nodesWithCallbacks = result.nodes.map((node) => ({ + ...node, + data: { + ...node.data, + onDelete: deleteNode, + allBlocks: allBlocksData, + onAttachCondition: attachConditionToNode, + onRemoveCondition: removeConditionFromNode, + }, + })); + setNodes(nodesWithCallbacks); + setEdges(result.edges); + setYamlFlow(result.yamlFlow); + setNodeId(result.nextNodeId); + setIsEditMode(true); + setEditBlueprintName(result.name); + setEditBlueprintDescription(result.description); + }) + .catch((err) => { + console.error("Failed to load blueprint for editing:", err); + setIsEditMode(false); + setEditBlueprintName(""); + setEditBlueprintDescription(""); + toast({ + title: "Failed to load blueprint", + description: "Could not load the blueprint for editing. Starting with a blank canvas.", + variant: "destructive", + }); + initializeDefaultNodes(); + }); + }, [editBlueprintId, isLoadingBlocks]); + // Trigger validation whenever yamlFlow changes useEffect(() => { // Only validate if yamlFlow has meaningful content @@ -730,7 +789,7 @@ export const useGraphLogic = (options: UseGraphLogicOptions = {}) => { }); return { - nodes: prevFlow.nodes, + ...prevFlow, conditions: prevFlow.conditions || [], plan: updatedPlan, }; @@ -884,6 +943,7 @@ export const useGraphLogic = (options: UseGraphLogicOptions = {}) => { : [...prevFlow.nodes, newYamlNode]; return { + ...prevFlow, nodes: updatedNodes, conditions: prevFlow.conditions || [], plan: [...prevFlow.plan, newPlanStep], @@ -1027,7 +1087,7 @@ export const useGraphLogic = (options: UseGraphLogicOptions = {}) => { try { setIsSaving(true); - // Update yamlFlow with name and description + // Update yamlFlow with name and description const updatedYamlFlow = { ...yamlFlow, name: name, @@ -1044,33 +1104,44 @@ export const useGraphLogic = (options: UseGraphLogicOptions = {}) => { sortKeys: false, }); - const response = await saveBlueprint(yamlString, USER_ID); + let response; + let blueprintId; + + if (isEditMode && editBlueprintId) { + response = await updateBlueprint(editBlueprintId, yamlString); + blueprintId = editBlueprintId; + } else { + response = await saveBlueprint(yamlString, USER_ID); + blueprintId = response.blueprint_id; + } + + if (response.status !== "success") { + throw new Error( + isEditMode + ? "Unknown error occurred while updating blueprint" + : "Unknown error occurred while saving blueprint" + ); + } - if (response.status === "success") { - // Show success toast - toast({ - title: "✅ Blueprint Saved Successfully", - description: `Blueprint "${name}" saved successfully`, - variant: "default", - }); + toast({ + title: isEditMode + ? "✅ Blueprint Updated Successfully" + : "✅ Blueprint Saved Successfully", + description: `Blueprint "${name}" ${isEditMode ? "updated" : "saved"} successfully`, + variant: "default", + }); - // Close the save modal immediately & Stop the saving state - setSaveModalOpen(false); - setIsSaving(false); - - // Call the onSaveComplete callback to navigate back (if provided) - // Pass the saved blueprint info so it can be selected in the workflow list - if (onSaveComplete) { - setTimeout(() => { - onSaveComplete({ - blueprintId: response.blueprint_id, - name, - description, - }); - }, 100); - } - } else { - throw new Error("Unknown error occurred while saving blueprint"); + setSaveModalOpen(false); + setIsSaving(false); + + if (onSaveComplete) { + setTimeout(() => { + onSaveComplete({ + blueprintId, + name, + description, + }); + }, 100); } } catch (error) { console.error("Error saving graph:", error); @@ -1082,7 +1153,7 @@ export const useGraphLogic = (options: UseGraphLogicOptions = {}) => { setIsSaving(false); } }, - [yamlFlow, toast, onSaveComplete], + [yamlFlow, toast, onSaveComplete, isEditMode, editBlueprintId], ); useEffect(() => { @@ -1188,7 +1259,7 @@ export const useGraphLogic = (options: UseGraphLogicOptions = {}) => { } return { - nodes: prevFlow.nodes, + ...prevFlow, conditions: updatedConditions.length > 0 ? updatedConditions : [], plan: updatedPlan, }; @@ -1274,5 +1345,9 @@ export const useGraphLogic = (options: UseGraphLogicOptions = {}) => { saveModalOpen, setSaveModalOpen, isSaving, + // Edit mode state + isEditMode, + editBlueprintName, + editBlueprintDescription, }; }; diff --git a/ui/client/src/hooks/use-load-blueprint.ts b/ui/client/src/hooks/use-load-blueprint.ts new file mode 100644 index 000000000..5636a51a7 --- /dev/null +++ b/ui/client/src/hooks/use-load-blueprint.ts @@ -0,0 +1,482 @@ +import { Node, Edge, MarkerType } from "reactflow"; +import dagre from "@dagrejs/dagre"; +import { BuildingBlock } from "@/types/graph"; +import { getCategoryDisplay } from "@/components/shared/helpers"; +import { getBlueprintInfo } from "@/api/blueprints"; +import { NODE_WIDTH } from "@/components/agentic-ai/graphs/GraphDisplayHelpers"; + +export interface YamlFlowNode { + rid: string; + name: string; + type?: string; + config?: any; +} + +export interface YamlFlowPlanStep { + uid: string; + node: string; + after?: string | string[] | null; + branches?: any; + exit_condition?: string; +} + +export interface YamlFlowCondition { + rid: string; + name: string; + type?: string; + config?: any; +} + +export interface YamlFlowState { + name?: string; + description?: string; + nodes: YamlFlowNode[]; + plan: YamlFlowPlanStep[]; + conditions?: YamlFlowCondition[]; +} + +export interface ReconstructedGraph { + nodes: Node[]; + edges: Edge[]; + yamlFlow: YamlFlowState; + nextNodeId: number; + name: string; + description: string; +} + +function stripRef(rid: string): string { + return rid.startsWith("$ref:") ? rid.slice(5) : rid; +} + +function findBlockByRid(rid: string, blocks: BuildingBlock[]): BuildingBlock | null { + const strippedRid = stripRef(rid); + return ( + blocks.find( + (b) => b.workspaceData?.rid === strippedRid || b.id === strippedRid, + ) || null + ); +} + +// Layout sizing constants — will be revisited when the graph library is swapped (GENIE-1246) +const NODE_BASE_HEIGHT = 64; +const CONDITION_HEADER_HEIGHT = 20; +const CONDITION_CARD_HEIGHT = 40; +const REF_HEADER_HEIGHT = 24; +const REF_ROW_HEIGHT = 28; +const REF_COLS = 3; +const RANK_SEP = 80; + +function countConfigRefs(config: any): number { + if (!config || typeof config !== "object") return 0; + let count = 0; + const traverse = (obj: unknown) => { + if (typeof obj === "string" && obj.startsWith("$ref:")) count++; + else if (Array.isArray(obj)) obj.forEach(traverse); + else if (obj && typeof obj === "object") Object.values(obj).forEach(traverse); + }; + traverse(config); + return count; +} + +function estimateNodeHeight( + conditionCount: number, + refCount: number, +): number { + let h = NODE_BASE_HEIGHT; + if (conditionCount > 0) { + h += CONDITION_HEADER_HEIGHT + conditionCount * CONDITION_CARD_HEIGHT; + } + if (refCount > 0) { + h += REF_HEADER_HEIGHT + Math.ceil(refCount / REF_COLS) * REF_ROW_HEIGHT; + } + return h; +} + +interface SpecialNodeConfig { + id: string; + label: string; + color: string; + style: string; + description: string; + rid: string; + type: string; +} + +const SPECIAL_NODES: Record = { + user_input: { + id: "user_input", + label: "User Input", + color: "#4A90E2", + style: "bg-blue-800 text-white border", + description: "User question input node", + rid: "user_question", + type: "user_question_node", + }, + finalize: { + id: "finalize", + label: "Final Answer", + color: "#50C878", + style: "bg-green-800 text-white border", + description: "Final answer output node", + rid: "final_answer", + type: "final_answer_node", + }, +}; + +function createSpecialNode( + config: SpecialNodeConfig, + position: { x: number; y: number }, +): Node { + const now = new Date().toISOString(); + return { + id: config.id, + type: "custom", + position, + data: { + label: config.label, + icon: getCategoryDisplay("nodes").icon, + color: config.color, + style: config.style, + description: config.description, + workspaceData: { + rid: config.rid, + name: config.rid, + category: "nodes", + type: config.type, + config: { name: config.label, type: config.type }, + version: 1, + created: now, + updated: now, + nested_refs: [], + }, + referencedConditions: [], + }, + }; +} + +function createRegularNode( + step: YamlFlowPlanStep, + position: { x: number; y: number }, + nodeDef: YamlFlowNode | undefined, + block: BuildingBlock | null, +): Node { + const label = block?.label || nodeDef?.name || step.node; + const category = block?.workspaceData?.category || "nodes"; + const color = block?.color || getCategoryDisplay(category).color; + const now = new Date().toISOString(); + + return { + id: step.uid, + type: "custom", + position, + data: { + label, + icon: getCategoryDisplay(category).icon, + color, + style: "bg-gray-800 text-white border", + description: block?.description || `${category} - ${label}`, + workspaceData: block?.workspaceData || { + rid: step.node, + name: nodeDef?.name || step.node, + category: "nodes", + type: nodeDef?.type || "unknown", + config: nodeDef?.config || {}, + version: 1, + created: now, + updated: now, + nested_refs: [], + }, + referencedConditions: [], + }, + }; +} + +function attachConditionToNode( + node: Node, + exitConditionRid: string, + conditionsData: BuildingBlock[], + specConditions: YamlFlowCondition[], +): void { + const normalizedRid = stripRef(exitConditionRid); + const condBlock = findBlockByRid(normalizedRid, conditionsData); + const condDef = specConditions.find( + (c) => stripRef(c.rid) === normalizedRid, + ); + + if (condBlock) { + node.data.referencedConditions = [condBlock]; + } else if (condDef) { + const now = new Date().toISOString(); + node.data.referencedConditions = [ + { + id: stripRef(condDef.rid), + type: condDef.type || "unknown", + label: condDef.name, + color: getCategoryDisplay("conditions").color, + description: `conditions/${condDef.type} - ${condDef.name}`, + workspaceData: { + rid: stripRef(condDef.rid), + name: condDef.name, + category: "conditions", + type: condDef.type || "unknown", + config: condDef.config || {}, + version: 1, + created: now, + updated: now, + nested_refs: [], + }, + }, + ]; + } +} + +interface StepLayoutHints { + conditionCount: number; + refCount: number; + isFinalAnswer: boolean; +} + +/** + * Compute a hierarchical layout for plan steps using dagre, + * matching the display produced by JointJS DirectedGraph.layout. + * + * Mirrors the display-graph behaviour: + * - Variable node heights based on conditions & config references. + * - final_answer node forced to the bottom rank. + */ +function computeLayout( + plan: YamlFlowPlanStep[], + hints: Map, +): Map { + const nonFinalSteps = plan.filter((s) => !hints.get(s.uid)?.isFinalAnswer); + const finalSteps = plan.filter((s) => hints.get(s.uid)?.isFinalAnswer); + + const g = new dagre.graphlib.Graph(); + g.setGraph({ + rankdir: "TB", + nodesep: 60, + edgesep: 40, + ranksep: RANK_SEP, + marginx: 32, + marginy: 32, + }); + g.setDefaultEdgeLabel(() => ({})); + + for (const step of nonFinalSteps) { + const h = hints.get(step.uid); + const height = h + ? estimateNodeHeight(h.conditionCount, h.refCount) + : NODE_BASE_HEIGHT; + g.setNode(step.uid, { width: NODE_WIDTH, height }); + } + + const edgeSet = new Set(); + for (const step of nonFinalSteps) { + if (step.after) { + const afters = Array.isArray(step.after) ? step.after : [step.after]; + for (const a of afters) { + const key = `${a}->${step.uid}`; + if (!edgeSet.has(key)) { g.setEdge(a, step.uid); edgeSet.add(key); } + } + } + if (step.branches) { + for (const targetUid of Object.values(step.branches)) { + const tid = targetUid as string; + if (hints.get(tid)?.isFinalAnswer) continue; + const key = `${step.uid}->${tid}`; + if (!edgeSet.has(key)) { g.setEdge(step.uid, tid); edgeSet.add(key); } + } + } + } + + dagre.layout(g); + + const positions = new Map(); + + let maxBottom = 0; + for (const step of nonFinalSteps) { + const n = g.node(step.uid); + if (n) { + const pos = { x: n.x - NODE_WIDTH / 2, y: n.y - n.height / 2 }; + positions.set(step.uid, pos); + maxBottom = Math.max(maxBottom, pos.y + n.height); + } + } + + for (const step of finalSteps) { + const h = hints.get(step.uid); + const height = h + ? estimateNodeHeight(h.conditionCount, h.refCount) + : NODE_BASE_HEIGHT; + const avgX = + nonFinalSteps.length > 0 + ? Array.from(positions.values()).reduce((s, p) => s + p.x, 0) / positions.size + : 200; + positions.set(step.uid, { + x: avgX, + y: maxBottom + RANK_SEP, + }); + } + + return positions; +} + +function collectBranchPairs(plan: YamlFlowPlanStep[]): Set { + const pairs = new Set(); + for (const step of plan) { + if (step.branches) { + for (const targetUid of Object.values(step.branches)) { + pairs.add(`${step.uid}->${targetUid}`); + } + } + } + return pairs; +} + +function buildEdges(plan: YamlFlowPlanStep[], conditionEdgeColor: string): Edge[] { + const edges: Edge[] = []; + const branchPairs = collectBranchPairs(plan); + + for (const step of plan) { + if (step.after) { + const afterList = Array.isArray(step.after) ? step.after : [step.after]; + for (const afterUid of afterList) { + if (!branchPairs.has(`${afterUid}->${step.uid}`)) { + edges.push({ + id: `${afterUid}-${step.uid}`, + source: afterUid, + target: step.uid, + type: "custom", + style: { strokeWidth: 2 }, + markerEnd: { type: MarkerType.ArrowClosed, width: 20, height: 20 }, + }); + } + } + } + + if (step.branches) { + for (const [branch, targetUid] of Object.entries(step.branches)) { + edges.push({ + id: `${step.uid}-${targetUid}-${branch}`, + source: step.uid, + target: targetUid as string, + type: "custom", + style: { strokeDasharray: "5,5", stroke: conditionEdgeColor }, + markerEnd: { type: MarkerType.ArrowClosed, color: conditionEdgeColor }, + data: { branch: String(branch), isConditional: true }, + label: String(branch), + }); + } + } + } + + return edges; +} + +/** + * Reconstruct a React Flow graph (nodes + edges) and yamlFlow state + * from a blueprint's spec_dict. + */ +export function reconstructBlueprintGraph( + specDict: any, + allBlocksData: BuildingBlock[], + conditionsData: BuildingBlock[], + conditionEdgeColor: string, +): ReconstructedGraph { + const name: string = specDict.name || "Untitled"; + const description: string = specDict.description || ""; + const specNodes: YamlFlowNode[] = specDict.nodes || []; + const plan: YamlFlowPlanStep[] = specDict.plan || []; + const specConditions: YamlFlowCondition[] = specDict.conditions || []; + + const yamlFlow: YamlFlowState = { + name, + description, + nodes: specNodes, + plan, + conditions: specConditions, + }; + + const nodeDefByRef = new Map(); + for (const nodeDef of specNodes) { + const rawRid = stripRef(nodeDef.rid); + nodeDefByRef.set(rawRid, nodeDef); + nodeDefByRef.set(nodeDef.rid, nodeDef); + } + + const layoutHints = new Map(); + for (const step of plan) { + const nodeDef = nodeDefByRef.get(step.node); + const block = findBlockByRid(step.node, allBlocksData); + const nodeType = + nodeDef?.type || block?.workspaceData?.type || "unknown"; + const config = block?.workspaceData?.config || nodeDef?.config; + layoutHints.set(step.uid, { + conditionCount: step.exit_condition ? 1 : 0, + refCount: countConfigRefs(config), + isFinalAnswer: nodeType === "final_answer_node" || step.uid === "finalize", + }); + } + + const positions = computeLayout(plan, layoutHints); + + const reactFlowNodes: Node[] = []; + let maxNodeId = plan.length + 1; + + for (const step of plan) { + const position = positions.get(step.uid) || { x: 400, y: 200 }; + let node: Node; + + const specialConfig = SPECIAL_NODES[step.uid]; + if (specialConfig) { + node = createSpecialNode(specialConfig, position); + } else { + const nodeDef = nodeDefByRef.get(step.node); + const block = findBlockByRid(step.node, allBlocksData); + + const idMatch = step.uid.match(/-(\d+)$/); + if (idMatch) { + const num = parseInt(idMatch[1]); + if (num >= maxNodeId) maxNodeId = num + 1; + } + + node = createRegularNode(step, position, nodeDef, block); + } + + if (step.exit_condition) { + attachConditionToNode(node, step.exit_condition, conditionsData, specConditions); + } + + reactFlowNodes.push(node); + } + + const reactFlowEdges = buildEdges(plan, conditionEdgeColor); + + return { + nodes: reactFlowNodes, + edges: reactFlowEdges, + yamlFlow, + nextNodeId: maxNodeId, + name, + description, + }; +} + +/** + * Fetch a blueprint by ID and reconstruct the graph for editing. + */ +export async function loadBlueprintForEditing( + blueprintId: string, + allBlocksData: BuildingBlock[], + conditionsData: BuildingBlock[], + conditionEdgeColor: string, +): Promise { + const blueprintInfo = await getBlueprintInfo(blueprintId); + const specDict = blueprintInfo.spec_dict; + return reconstructBlueprintGraph( + specDict, + allBlocksData, + conditionsData, + conditionEdgeColor, + ); +} diff --git a/ui/client/src/pages/AgenticWorkflows.tsx b/ui/client/src/pages/AgenticWorkflows.tsx index a783dde00..190cf4631 100644 --- a/ui/client/src/pages/AgenticWorkflows.tsx +++ b/ui/client/src/pages/AgenticWorkflows.tsx @@ -36,6 +36,7 @@ export default function AgenticWorkflows() { const [builtGraphName, setBuiltGraphName] = useState(null); const [selectedGraphId, setSelectedGraphId] = useState(null); const [showGraphBuilder, setShowGraphBuilder] = useState(false); + const [editingBlueprintId, setEditingBlueprintId] = useState(null); const [isLoadingFlow, setIsLoadingFlow] = useState(false); const [isFlowValid, setIsFlowValid] = useState(true); const [isValidatingFlow, setIsValidatingFlow] = useState(false); @@ -95,16 +96,28 @@ export default function AgenticWorkflows() { } }; - const handleBuildGraph = () => { + const handleOpenGraphBuilder = (flow?: FlowObject) => { + setEditingBlueprintId(flow?.id ?? null); setShowGraphBuilder(true); }; const handleBackToFlowConfig = useCallback((_savedBlueprint?: SavedBlueprintInfo) => { setShowGraphBuilder(false); - // Always reset so WorkflowsPanel follows the same mount path as - // initial load: fetch list → auto-select first → resolved fetch. - // If a blueprint was just saved it will appear first in the list. - setSelectedFlow(null); + setEditingBlueprintId(null); + + if (_savedBlueprint?.blueprintId) { + setSelectedFlow({ + id: _savedBlueprint.blueprintId, + name: _savedBlueprint.name, + description: _savedBlueprint.description, + icon: null, + flow: { nodes: [], edges: [] }, + } as FlowObject); + } else { + // Going back without saving (new build or edit) — clear selection so + // WorkflowsPanel remounts cleanly and auto-selects a flow. + setSelectedFlow(null); + } }, []); return ( @@ -119,7 +132,10 @@ export default function AgenticWorkflows() {
{showGraphBuilder ? ( - + ) : (
handleOpenGraphBuilder()} > Build Workflow @@ -200,6 +216,7 @@ export default function AgenticWorkflows() { selectedFlow={selectedFlow} setSelectedFlow={setSelectedFlow} onValidationChange={handleValidationChange} + onFlowEdit={handleOpenGraphBuilder} />
diff --git a/ui/client/src/workspace/NewGraph.tsx b/ui/client/src/workspace/NewGraph.tsx index d966831cb..a0d05c912 100644 --- a/ui/client/src/workspace/NewGraph.tsx +++ b/ui/client/src/workspace/NewGraph.tsx @@ -8,9 +8,10 @@ import SaveBlueprintModal from "@/components/agentic-ai/graphs/SaveBlueprintModa interface NewGraphProps { onBack?: (savedBlueprint?: SavedBlueprintInfo) => void; + editBlueprintId?: string | null; } -export default function NewGraph({ onBack }: NewGraphProps) { +export default function NewGraph({ onBack, editBlueprintId }: NewGraphProps) { const [sidebarOpen, setSidebarOpen] = useState(false); const { @@ -40,7 +41,10 @@ export default function NewGraph({ onBack }: NewGraphProps) { fixSuggestions, isValidating, isSaving, - } = useGraphLogic({ onSaveComplete: onBack }); + isEditMode, + editBlueprintName, + editBlueprintDescription, + } = useGraphLogic({ onSaveComplete: onBack, editBlueprintId }); const [saveModalOpen, setSaveModalOpen] = useState(false); @@ -116,6 +120,7 @@ export default function NewGraph({ onBack }: NewGraphProps) { onAttachCondition={attachConditionToNode} onRemoveCondition={removeConditionFromNode} isGraphValid={isGraphValid} + isEditMode={isEditMode} />
@@ -145,6 +150,9 @@ export default function NewGraph({ onBack }: NewGraphProps) { onClose={() => setSaveModalOpen(false)} onSave={saveGraph} isLoading={isSaving} + isEditMode={isEditMode} + currentName={editBlueprintName} + currentDescription={editBlueprintDescription} /> ); diff --git a/ui/package.json b/ui/package.json index d0aad28e8..8f758688e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -40,6 +40,7 @@ "@radix-ui/react-tooltip": "^1.2.0", "@replit/vite-plugin-shadcn-theme-json": "^0.0.4", "@tailwindcss/vite": "^4.1.3", + "@dagrejs/dagre": "^1.1.8", "@joint/core": "^4.2.3", "@joint/layout-directed-graph": "^4.2.0", "@tanstack/react-query": "^5.60.5", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 9252bb6f0..aaa04d6e6 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@dagrejs/dagre': + specifier: ^1.1.8 + version: 1.1.8 '@hookform/resolvers': specifier: ^3.10.0 version: 3.10.0(react-hook-form@7.60.0(react@18.3.1))