diff --git a/frontend/src/flow/AgentNode.tsx b/frontend/src/flow/AgentNode.tsx index b14d380..2d4ab2c 100644 --- a/frontend/src/flow/AgentNode.tsx +++ b/frontend/src/flow/AgentNode.tsx @@ -14,6 +14,8 @@ const KIND_COLOR: Record = { http: "hsl(var(--node-http))", script: "hsl(var(--node-script))", note: "hsl(45 90% 48%)", + transform: "hsl(185 75% 40%)", + loop: "hsl(280 65% 50%)", }; interface ExtraData extends AgentNodeData { diff --git a/frontend/src/flow/Palette.tsx b/frontend/src/flow/Palette.tsx index ead67ad..0a612c2 100644 --- a/frontend/src/flow/Palette.tsx +++ b/frontend/src/flow/Palette.tsx @@ -17,6 +17,8 @@ const KIND_COLOR: Record = { http: "hsl(var(--node-http))", script: "hsl(var(--node-script))", note: "hsl(45 90% 48%)", + transform: "hsl(185 75% 40%)", + loop: "hsl(280 65% 50%)", }; export function Palette({ onAdd }: Props) { diff --git a/frontend/src/flow/WorkflowAnalyticsModal.tsx b/frontend/src/flow/WorkflowAnalyticsModal.tsx new file mode 100644 index 0000000..3cb8a31 --- /dev/null +++ b/frontend/src/flow/WorkflowAnalyticsModal.tsx @@ -0,0 +1,386 @@ +import React, { useMemo, useState } from "react"; +import { toast } from "sonner"; +import type { Node } from "reactflow"; +import type { AgentNodeData } from "./types"; +import type { RunLog } from "./runFlow"; + +interface WorkflowAnalyticsModalProps { + isOpen: boolean; + runLogs: RunLog[] | null; + nodes: Node[]; + onClose: () => void; +} + +interface NodePerfStat { + nodeId: string; + name: string; + kind: string; + calls: number; + totalMs: number; + avgMs: number; + percentTotal: number; + errorCount: number; +} + +export function WorkflowAnalyticsModal({ + isOpen, + runLogs, + nodes, + onClose, +}: WorkflowAnalyticsModalProps) { + const [filterQuery, setFilterQuery] = useState(""); + const [sortField, setSortField] = useState<"totalMs" | "calls" | "avgMs" | "name">("totalMs"); + const [sortAsc, setSortAsc] = useState(false); + + // Compute analytics statistics + const analytics = useMemo(() => { + if (!runLogs || runLogs.length === 0) return null; + + const totalSteps = runLogs.length; + const totalDurationMs = runLogs.reduce((acc, l) => acc + l.ms, 0); + const avgStepMs = totalSteps > 0 ? Math.round(totalDurationMs / totalSteps) : 0; + const errorCount = runLogs.filter((l) => !!l.error).length; + const isSuccess = errorCount === 0; + + // Aggregate by nodeId + const nodeStatsMap = new Map(); + + let totalLlmChars = 0; + let totalLlmOutputChars = 0; + + runLogs.forEach((log) => { + if (log.nodeId && log.nodeId !== "_error" && log.nodeId !== "_runtime") { + const existing = nodeStatsMap.get(log.nodeId) ?? { + name: log.name, + kind: log.kind, + calls: 0, + totalMs: 0, + errorCount: 0, + }; + existing.calls += 1; + existing.totalMs += log.ms; + if (log.error) existing.errorCount += 1; + nodeStatsMap.set(log.nodeId, existing); + } + + if (log.kind === "llm") { + const outputText = typeof log.output === "string" ? log.output : JSON.stringify(log.output ?? ""); + totalLlmOutputChars += outputText.length; + if (log.stateSnapshot?.query) { + totalLlmChars += String(log.stateSnapshot.query).length; + } + } + }); + + const perfStats: NodePerfStat[] = Array.from(nodeStatsMap.entries()).map(([nodeId, stat]) => ({ + nodeId, + name: stat.name, + kind: stat.kind, + calls: stat.calls, + totalMs: stat.totalMs, + avgMs: stat.calls > 0 ? Math.round(stat.totalMs / stat.calls) : 0, + percentTotal: totalDurationMs > 0 ? parseFloat(((stat.totalMs / totalDurationMs) * 100).toFixed(1)) : 0, + errorCount: stat.errorCount, + })); + + // Find bottleneck + const bottleneck = perfStats.length > 0 ? [...perfStats].sort((a, b) => b.totalMs - a.totalMs)[0] : null; + + // Token & Cost estimates + const estInputTokens = Math.ceil(totalLlmChars / 4); + const estOutputTokens = Math.ceil(totalLlmOutputChars / 4); + const estCostUSD = ((estInputTokens * 0.0000025) + (estOutputTokens * 0.00001)).toFixed(5); + + return { + totalSteps, + totalDurationMs, + avgStepMs, + errorCount, + isSuccess, + perfStats, + bottleneck, + estInputTokens, + estOutputTokens, + estCostUSD, + uniqueKinds: new Set(runLogs.map((l) => l.kind)).size, + }; + }, [runLogs]); + + if (!isOpen) return null; + + const filteredStats = (analytics?.perfStats ?? []).filter((s) => { + const q = filterQuery.toLowerCase(); + return s.name.toLowerCase().includes(q) || s.kind.toLowerCase().includes(q) || s.nodeId.toLowerCase().includes(q); + }).sort((a, b) => { + let valA = a[sortField]; + let valB = b[sortField]; + if (typeof valA === "string") { + valA = (valA as string).toLowerCase(); + valB = (valB as string).toLowerCase(); + } + if (valA < valB) return sortAsc ? -1 : 1; + if (valA > valB) return sortAsc ? 1 : -1; + return 0; + }); + + const handleCopyReport = () => { + if (!analytics) return; + const text = [ + `=== AGENT FLOW ANALYTICS REPORT ===`, + `Status: ${analytics.isSuccess ? "PASS" : "FAILED (" + analytics.errorCount + " errors)"}`, + `Total Steps: ${analytics.totalSteps}`, + `Total Duration: ${analytics.totalDurationMs} ms`, + `Avg Step Duration: ${analytics.avgStepMs} ms`, + `Bottleneck Node: ${analytics.bottleneck ? `${analytics.bottleneck.name} (${analytics.bottleneck.totalMs} ms)` : "None"}`, + `Est LLM Tokens: ~${analytics.estInputTokens + analytics.estOutputTokens} (${analytics.estInputTokens} in / ${analytics.estOutputTokens} out)`, + `Est LLM Cost: ~$${analytics.estCostUSD}`, + ``, + `--- Node Latency Breakdown ---`, + ...analytics.perfStats.map( + (s) => `${s.name} [${s.kind}]: ${s.totalMs}ms (${s.percentTotal}%) | Calls: ${s.calls} | Avg: ${s.avgMs}ms` + ), + ].join("\n"); + + navigator.clipboard.writeText(text); + toast.success("Analytics summary report copied to clipboard"); + }; + + const handleDownloadCSV = () => { + if (!analytics) return; + const rows = [ + ["Node ID", "Node Name", "Node Kind", "Invocations", "Total Duration (ms)", "Avg Latency (ms)", "Share of Run Time (%)", "Errors"], + ...analytics.perfStats.map((s) => [ + s.nodeId, + `"${s.name.replace(/"/g, '""')}"`, + s.kind, + s.calls, + s.totalMs, + s.avgMs, + s.percentTotal, + s.errorCount, + ]), + ]; + + const csvContent = "data:text/csv;charset=utf-8," + rows.map((e) => e.join(",")).join("\n"); + const encodedUri = encodeURI(csvContent); + const link = document.createElement("a"); + link.setAttribute("href", encodedUri); + link.setAttribute("download", "agent_flow_analytics.csv"); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + toast.success("Analytics CSV exported"); + }; + + return ( +
+
+ {/* Header */} +
+
+
+ execution analytics +
+

+ ๐Ÿ“Š Workflow Performance Profiler +

+
+ +
+ + {/* Modal Content */} +
+ {!analytics ? ( +
+ No execution run logs available. Run a workflow first to analyze performance. +
+ ) : ( + <> + {/* Key Metrics Banner */} +
+
+
+ Total Duration +
+
+ {analytics.totalDurationMs} ms +
+
+
+
+ Total Steps +
+
+ {analytics.totalSteps} steps +
+
+
+
+ Avg Step Latency +
+
+ {analytics.avgStepMs} ms +
+
+
+
+ Status +
+
+ {analytics.isSuccess ? "PASS โœ“" : "ERROR โœ—"} +
+
+
+ + {/* Bottleneck Recommendation Alert */} + {analytics.bottleneck && ( +
+
+ โšก Bottleneck Analysis + + {analytics.bottleneck.totalMs} ms ({analytics.bottleneck.percentTotal}% of total run) + +
+

+ The slowest node in this execution was {analytics.bottleneck.name} ({analytics.bottleneck.kind}). + {analytics.bottleneck.kind === "llm" && " Tip: Consider lowering temperature/max_tokens or tuning model prompts for faster responses."} + {analytics.bottleneck.kind === "http" && " Tip: Consider caching HTTP responses or reducing payload sizes."} + {analytics.bottleneck.kind === "script" && " Tip: Optimize JavaScript execution loops inside the script node."} +

+
+ )} + + {/* LLM Tokens & Cost Estimator */} + {(analytics.estInputTokens > 0 || analytics.estOutputTokens > 0) && ( +
+
+ + ๐Ÿค– LLM Token & Cost Estimate + + + Input: ~{analytics.estInputTokens} tokens ยท Output: ~{analytics.estOutputTokens} tokens + +
+
+ Est Cost + ${analytics.estCostUSD} USD +
+
+ )} + + {/* Per-Node Breakdown Table */} +
+
+ + Node Latency & Execution Breakdown + + setFilterQuery(e.target.value)} + placeholder="Search node name/kind..." + className="bg-transparent border border-dashed border-[hsl(var(--ink-faint))] focus:border-[hsl(var(--ink))] outline-none py-0.5 px-2 font-mono text-[10px] w-48" + /> +
+ +
+ + + + + + + + + + + + + + {filteredStats.map((stat) => ( + + + + + + + + + + ))} + +
{ setSortField("name"); setSortAsc(!sortAsc); }} + > + Node Name {sortField === "name" && (sortAsc ? "โ–ฒ" : "โ–ผ")} + Kind { setSortField("calls"); setSortAsc(!sortAsc); }} + > + Calls {sortField === "calls" && (sortAsc ? "โ–ฒ" : "โ–ผ")} + { setSortField("totalMs"); setSortAsc(!sortAsc); }} + > + Total (ms) {sortField === "totalMs" && (sortAsc ? "โ–ฒ" : "โ–ผ")} + { setSortField("avgMs"); setSortAsc(!sortAsc); }} + > + Avg (ms) {sortField === "avgMs" && (sortAsc ? "โ–ฒ" : "โ–ผ")} + % ShareStatus
+ {stat.name} + + {stat.kind} + {stat.calls}{stat.totalMs} ms{stat.avgMs} ms{stat.percentTotal}% + {stat.errorCount > 0 ? ( + FAIL ({stat.errorCount}) + ) : ( + OK + )} +
+
+
+ + )} +
+ + {/* Footer Actions */} +
+
+ + +
+ +
+
+
+ ); +} diff --git a/frontend/src/flow/codegen.ts b/frontend/src/flow/codegen.ts index 3505322..16b32fe 100644 --- a/frontend/src/flow/codegen.ts +++ b/frontend/src/flow/codegen.ts @@ -432,6 +432,38 @@ export function generatePython( `# ${c.content ? c.content.replace(/\n/g, "\n# ") : "(empty note)"}`, `return "next"`, ].join("\n"); + case "transform": { + const op = (c.operation || "json_map").toLowerCase(); + const src = pyStr(c.source_path || "state"); + const tgt = pyStr(c.target_key || "transformed_result"); + const param = pyStr(c.param || ""); + return [ + `# Data Transform op=${op}`, + `source_val = state.get(${src}, state.last)`, + `param_val = interpolate(${param}, state)`, + `result = {"op": ${pyStr(op)}, "source": source_val, "param": param_val}`, + `state.set(${tgt}, result)`, + `state.last = result`, + `return "next"`, + ].join("\n"); + } + case "loop": { + const itemsPath = pyStr(c.items_path || "state.items"); + const outputKey = pyStr(c.output_key || "loop_results"); + const tmpl = pyStr(c.transform_template || ""); + const maxIter = parseInt(c.max_iterations || "50", 10) || 50; + return [ + `# Loop Iterator items=${itemsPath}`, + `raw_items = state.get(${itemsPath}, [])`, + `items_list = raw_items if isinstance(raw_items, list) else [raw_items] if raw_items else []`, + `mapped_results = []`, + `for idx, item in enumerate(items_list[:${maxIter}]):`, + ` mapped_results.append(interpolate(${tmpl}, state) if ${tmpl} else item)`, + `state.set(${outputKey}, mapped_results)`, + `state.last = mapped_results`, + `return "next"`, + ].join("\n"); + } default: { const _exhaustive: never = d.kind as never; return `return "next" # unknown kind ${_exhaustive}`; @@ -626,6 +658,34 @@ export function generateJavaScript( `// ${c.content ? c.content.replace(/\n/g, "\n// ") : "(empty note)"}`, `return "next";`, ].join("\n"); + case "transform": { + const op = (c.operation || "json_map").toLowerCase(); + const src = JSON.stringify(c.source_path || "state"); + const tgt = JSON.stringify(c.target_key || "transformed_result"); + const param = JSON.stringify(c.param || ""); + return [ + `const sourceVal = state.get(${src}) ?? state.last;`, + `const paramVal = interpolate(${param}, state);`, + `const result = { op: "${op}", source: sourceVal, param: paramVal };`, + `state.set(${tgt}, result);`, + `state.last = result;`, + `return "next";`, + ].join("\n"); + } + case "loop": { + const itemsPath = JSON.stringify(c.items_path || "state.items"); + const outputKey = JSON.stringify(c.output_key || "loop_results"); + const tmpl = JSON.stringify(c.transform_template || ""); + const maxIter = parseInt(c.max_iterations || "50", 10) || 50; + return [ + `const rawItems = state.get(${itemsPath}) ?? [];`, + `const itemsList = Array.isArray(rawItems) ? rawItems : [rawItems];`, + `const mapped = itemsList.slice(0, ${maxIter}).map(item => interpolate(${tmpl}, state) || item);`, + `state.set(${outputKey}, mapped);`, + `state.last = mapped;`, + `return "next";`, + ].join("\n"); + } default: return `return "next";`; } @@ -677,4 +737,4 @@ export function generateCode( } // also export the kind set for sanity -export const ALL_KINDS: AgentNodeKind[] = ["trigger","llm","tool","router","subagent","memory","human","sink","http","script","note"]; \ No newline at end of file +export const ALL_KINDS: AgentNodeKind[] = ["trigger","llm","tool","router","subagent","memory","human","sink","http","script","note","transform","loop"]; \ No newline at end of file diff --git a/frontend/src/flow/runFlow.ts b/frontend/src/flow/runFlow.ts index 1347c4d..1eda634 100644 --- a/frontend/src/flow/runFlow.ts +++ b/frontend/src/flow/runFlow.ts @@ -96,6 +96,22 @@ function getPath(obj: Record, path: string): unknown { }, obj); } +/** + * Recursively flattens a nested object structure into a single-level object with dot-separated keys. + */ +function flattenObject(obj: Record, prefix = ""): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const newKey = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0) { + Object.assign(result, flattenObject(value as Record, newKey)); + } else { + result[newKey] = value; + } + } + return result; +} + /** * Evaluates the output state of the current node to select the matching outgoing edge. * Prioritizes on_error edges if an error occurred, router conditions, tool_results, @@ -478,6 +494,149 @@ export async function runNode( annotationOnly: true, }; } + case "transform": { + const op = (cfg.operation || "json_map").toLowerCase(); + const sourcePath = (cfg.source_path || "state").trim(); + const targetKey = (cfg.target_key || "transformed_result").trim(); + const param = cfg.param || ""; + + // Resolve source value + let sourceVal: unknown; + if (!sourcePath || sourcePath === "state") { + sourceVal = state; + } else { + const cleanPath = sourcePath.startsWith("state.") ? sourcePath.slice(6) : sourcePath; + sourceVal = getPath(state, cleanPath) ?? state.last_output; + } + + let result: unknown; + + if (op === "pick_fields") { + const fields = param.split(",").map((f) => f.trim()).filter(Boolean); + if (typeof sourceVal === "object" && sourceVal !== null && !Array.isArray(sourceVal)) { + const picked: Record = {}; + const sourceObj = sourceVal as Record; + fields.forEach((f) => { + if (f in sourceObj) picked[f] = sourceObj[f]; + }); + result = picked; + } else { + result = sourceVal; + } + } else if (op === "template_string") { + result = interpolate(param, state, globalsList, secretsList); + } else if (op === "set_keys") { + let patch: Record = {}; + if (param.trim()) { + try { + const interpolatedParam = interpolate(param, state, globalsList, secretsList); + patch = JSON.parse(interpolatedParam); + } catch { + patch = { value: interpolate(param, state, globalsList, secretsList) }; + } + } + if (typeof sourceVal === "object" && sourceVal !== null && !Array.isArray(sourceVal)) { + result = { ...(sourceVal as Record), ...patch }; + } else { + result = { ...patch }; + } + } else if (op === "flatten_object") { + if (typeof sourceVal === "object" && sourceVal !== null && !Array.isArray(sourceVal)) { + result = flattenObject(sourceVal as Record); + } else { + result = sourceVal; + } + } else { + // Default json_map + if (param.trim()) { + try { + const interpolatedParam = interpolate(param, state, globalsList, secretsList); + const mapping = JSON.parse(interpolatedParam); + if (typeof mapping === "object" && mapping !== null && !Array.isArray(mapping)) { + const mappedObj: Record = {}; + const sourceObj = (typeof sourceVal === "object" && sourceVal !== null ? sourceVal : state) as Record; + Object.entries(mapping as Record).forEach(([newKey, origPath]) => { + const cleanOrigPath = String(origPath).startsWith("state.") ? String(origPath).slice(6) : String(origPath); + mappedObj[newKey] = getPath(sourceObj, cleanOrigPath) ?? getPath(state, cleanOrigPath) ?? origPath; + }); + result = mappedObj; + } else { + result = mapping; + } + } catch { + result = interpolate(param, state, globalsList, secretsList); + } + } else { + result = sourceVal; + } + } + + state[targetKey] = result; + return result; + } + case "loop": { + const itemsPath = (cfg.items_path || "state.items").trim(); + const itemVar = (cfg.item_var || "item").trim(); + const outputKey = (cfg.output_key || "loop_results").trim(); + const template = cfg.transform_template || ""; + const maxIter = parseIntOr(cfg.max_iterations, 50); + + const cleanPath = itemsPath.startsWith("state.") ? itemsPath.slice(6) : itemsPath; + const rawItems = getPath(state, cleanPath) ?? (cleanPath in state ? state[cleanPath] : undefined); + + let itemsArray: unknown[] = []; + if (Array.isArray(rawItems)) { + itemsArray = rawItems; + } else if (rawItems !== undefined && rawItems !== null) { + itemsArray = [rawItems]; + } + + const truncatedItems = itemsArray.slice(0, maxIter); + const mappedResults: unknown[] = []; + + for (const item of truncatedItems) { + const itemScope: Record = { ...state, [itemVar]: item }; + if (!template.trim()) { + mappedResults.push(item); + } else if (template.includes("{{")) { + let interpolated = template; + if (globalsList) { + globalsList.forEach((g) => { + interpolated = interpolated.replace(new RegExp(`\\{\\{\\s*global\\.${g.key}\\s*\\}\\}`, "g"), g.value); + }); + } + if (secretsList) { + secretsList.forEach((s) => { + interpolated = interpolated.replace(new RegExp(`\\{\\{\\s*secret\\.${s.key}\\s*\\}\\}`, "g"), s.value); + }); + } + interpolated = interpolated.replace(new RegExp(`\\{\\{\\s*${itemVar}\\.([\\w.]+)\\s*\\}\\}`, "g"), (_m, prop) => { + if (typeof item === "object" && item !== null) { + const val = getPath(item as Record, String(prop)); + return val === undefined ? "" : typeof val === "string" ? val : JSON.stringify(val); + } + return ""; + }); + interpolated = interpolated.replace(new RegExp(`\\{\\{\\s*${itemVar}\\s*\\}\\}`, "g"), () => { + return typeof item === "string" ? item : JSON.stringify(item); + }); + interpolated = interpolate(interpolated, state, globalsList, secretsList); + mappedResults.push(interpolated); + } else if (template.startsWith(`${itemVar}.`)) { + const propPath = template.slice(itemVar.length + 1); + if (typeof item === "object" && item !== null) { + mappedResults.push(getPath(item as Record, propPath) ?? null); + } else { + mappedResults.push(null); + } + } else { + mappedResults.push(template); + } + } + + state[outputKey] = mappedResults; + return { count: mappedResults.length, items: mappedResults }; + } default: return { kind: node.data.kind, note: "no executor" }; } diff --git a/frontend/src/flow/types.ts b/frontend/src/flow/types.ts index 2ccd1e1..ec9fd9e 100644 --- a/frontend/src/flow/types.ts +++ b/frontend/src/flow/types.ts @@ -9,7 +9,9 @@ export type AgentNodeKind = | "sink" | "http" | "script" - | "note"; + | "note" + | "transform" + | "loop"; export interface AgentNodeData { kind: AgentNodeKind; @@ -151,6 +153,43 @@ export const NODE_TYPES: NodeTypeMeta[] = [ { key: "color", label: "color", placeholder: "yellow", type: "select", options: ["yellow", "blue", "green", "pink", "purple"] }, ], }, + { + kind: "transform", + label: "Data Transform", + description: "Transforms, maps, picks fields, or formats data in state.", + defaultName: "transform_data", + configFields: [ + { + key: "operation", + label: "operation", + placeholder: "json_map", + type: "select", + options: [ + "json_map", + "pick_fields", + "template_string", + "set_keys", + "flatten_object", + ], + }, + { key: "source_path", label: "source_path", placeholder: "state.data" }, + { key: "target_key", label: "target_key", placeholder: "transformed_result" }, + { key: "param", label: "param / template", placeholder: "e.g. name,email,id or Hello {{state.name}}", type: "textarea" }, + ], + }, + { + kind: "loop", + label: "Loop Iterator", + description: "Iterates over an array in state and maps or transforms items.", + defaultName: "loop_items", + configFields: [ + { key: "items_path", label: "items_path", placeholder: "state.items" }, + { key: "item_var", label: "item_var", placeholder: "item" }, + { key: "output_key", label: "output_key", placeholder: "loop_results" }, + { key: "transform_template", label: "transform_template", placeholder: "{{item.name}}: {{item.status}}", type: "textarea" }, + { key: "max_iterations", label: "max_iterations", placeholder: "50" }, + ], + }, ]; export const EDGE_LABELS = ["next", "on_success", "on_error", "tool_result", "true", "false"] as const; diff --git a/frontend/src/flow/validate.ts b/frontend/src/flow/validate.ts index bba1430..f99bb58 100644 --- a/frontend/src/flow/validate.ts +++ b/frontend/src/flow/validate.ts @@ -49,6 +49,26 @@ export function validateGraph( }); } } + if (n.data.kind === "transform") { + const cfg = n.data.config ?? {}; + if ("target_key" in cfg && !cfg.target_key?.trim()) { + issues.push({ + nodeId: n.id, + kind: "orphan", + message: `Transform "${n.data.name}" missing target_key`, + }); + } + } + if (n.data.kind === "loop") { + const cfg = n.data.config ?? {}; + if ("items_path" in cfg && !cfg.items_path?.trim()) { + issues.push({ + nodeId: n.id, + kind: "orphan", + message: `Loop "${n.data.name}" missing items_path`, + }); + } + } } return issues; } diff --git a/frontend/src/pages/Index.tsx b/frontend/src/pages/Index.tsx index 4319299..6c17180 100644 --- a/frontend/src/pages/Index.tsx +++ b/frontend/src/pages/Index.tsx @@ -66,6 +66,7 @@ import { cryptoId as presetCryptoId, } from "@/flow/statePresets"; import { CommandPalette } from "@/flow/CommandPalette"; +import { WorkflowAnalyticsModal } from "@/flow/WorkflowAnalyticsModal"; const nodeTypes = { agent: AgentNode, note: NoteNode }; @@ -199,6 +200,7 @@ function Canvas() { const [activeWorkflowId, setActiveWorkflowId] = useState(() => loadActiveWorkflowId()); const [showWorkflows, setShowWorkflows] = useState(false); const [showCommandPalette, setShowCommandPalette] = useState(false); + const [showAnalytics, setShowAnalytics] = useState(false); // Initialize nodes and edges based on active workflow id const [nodes, setNodes] = useState[]>(() => { @@ -1920,6 +1922,13 @@ function Canvas() {
+
); } diff --git a/frontend/src/test/transformAndLoopAndAnalytics.test.tsx b/frontend/src/test/transformAndLoopAndAnalytics.test.tsx new file mode 100644 index 0000000..1e8c3f4 --- /dev/null +++ b/frontend/src/test/transformAndLoopAndAnalytics.test.tsx @@ -0,0 +1,322 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import type { Node, Edge } from "reactflow"; +import type { AgentNodeData } from "@/flow/types"; +import { runFlow, runNode } from "@/flow/runFlow"; +import { generateCode } from "@/flow/codegen"; +import { validateGraph } from "@/flow/validate"; +import { WorkflowAnalyticsModal } from "@/flow/WorkflowAnalyticsModal"; + +if (typeof window !== "undefined" && !window.ResizeObserver) { + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; +} + +describe("Data Transform & Loop Nodes & Analytics Modal", () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe("Transform Node Execution", () => { + it("executes pick_fields operation correctly", async () => { + const node: Node = { + id: "n1", + type: "agent", + position: { x: 0, y: 0 }, + data: { + kind: "transform", + name: "pick_user_info", + config: { + operation: "pick_fields", + source_path: "state.user", + target_key: "picked_user", + param: "name, email", + }, + }, + }; + + const state: Record = { + user: { name: "Alice", email: "alice@example.com", age: 30, secretKey: "12345" }, + }; + + const res = await runNode(node, state, [], { nodes: [node], edges: [], gateways: [] }, [], []); + expect(res).toEqual({ name: "Alice", email: "alice@example.com" }); + expect(state.picked_user).toEqual({ name: "Alice", email: "alice@example.com" }); + }); + + it("executes template_string operation correctly", async () => { + const node: Node = { + id: "n1", + type: "agent", + position: { x: 0, y: 0 }, + data: { + kind: "transform", + name: "format_greeting", + config: { + operation: "template_string", + source_path: "state", + target_key: "greeting", + param: "Hello, {{state.username}}!", + }, + }, + }; + + const state: Record = { username: "Bob" }; + const res = await runNode(node, state, [], { nodes: [node], edges: [], gateways: [] }, [], []); + expect(res).toBe("Hello, Bob!"); + expect(state.greeting).toBe("Hello, Bob!"); + }); + + it("executes flatten_object operation correctly", async () => { + const node: Node = { + id: "n1", + type: "agent", + position: { x: 0, y: 0 }, + data: { + kind: "transform", + name: "flatten_meta", + config: { + operation: "flatten_object", + source_path: "state.meta", + target_key: "flat_meta", + param: "", + }, + }, + }; + + const state: Record = { + meta: { + app: "agent_flow", + config: { theme: "dark", level: 2 }, + }, + }; + + const res = await runNode(node, state, [], { nodes: [node], edges: [], gateways: [] }, [], []); + expect(res).toEqual({ + "app": "agent_flow", + "config.theme": "dark", + "config.level": 2, + }); + expect(state.flat_meta).toEqual({ + "app": "agent_flow", + "config.theme": "dark", + "config.level": 2, + }); + }); + + it("executes json_map operation correctly", async () => { + const node: Node = { + id: "n1", + type: "agent", + position: { x: 0, y: 0 }, + data: { + kind: "transform", + name: "map_fields", + config: { + operation: "json_map", + source_path: "state.input", + target_key: "mapped_output", + param: JSON.stringify({ fullName: "state.input.first", role: "state.input.title" }), + }, + }, + }; + + const state: Record = { + input: { first: "Charlie", title: "Engineer" }, + }; + + const res = await runNode(node, state, [], { nodes: [node], edges: [], gateways: [] }, [], []); + expect(res).toEqual({ fullName: "Charlie", role: "Engineer" }); + expect(state.mapped_output).toEqual({ fullName: "Charlie", role: "Engineer" }); + }); + }); + + describe("Loop Node Execution", () => { + it("iterates array items and applies template transformation", async () => { + const node: Node = { + id: "n1", + type: "agent", + position: { x: 0, y: 0 }, + data: { + kind: "loop", + name: "process_users", + config: { + items_path: "state.users", + item_var: "u", + output_key: "processed_users", + transform_template: "{{u.name}} ({{u.role}})", + max_iterations: "10", + }, + }, + }; + + const state: Record = { + users: [ + { name: "Alice", role: "Dev" }, + { name: "Bob", role: "Design" }, + ], + }; + + const res = (await runNode(node, state, [], { nodes: [node], edges: [], gateways: [] }, [], [])) as any; + expect(res.count).toBe(2); + expect(res.items).toEqual(["Alice (Dev)", "Bob (Design)"]); + expect(state.processed_users).toEqual(["Alice (Dev)", "Bob (Design)"]); + }); + + it("respects max_iterations limit in loop", async () => { + const node: Node = { + id: "n1", + type: "agent", + position: { x: 0, y: 0 }, + data: { + kind: "loop", + name: "limit_loop", + config: { + items_path: "state.numbers", + item_var: "num", + output_key: "capped_numbers", + transform_template: "{{num}}", + max_iterations: "2", + }, + }, + }; + + const state: Record = { numbers: [10, 20, 30, 40, 50] }; + const res = (await runNode(node, state, [], { nodes: [node], edges: [], gateways: [] }, [], [])) as any; + expect(res.count).toBe(2); + expect(res.items).toEqual(["10", "20"]); + }); + }); + + describe("Codegen & Graph Validation", () => { + it("generates Python and JS code for transform and loop nodes", () => { + const nodes: Node[] = [ + { + id: "n1", + type: "agent", + position: { x: 0, y: 0 }, + data: { kind: "trigger", name: "start", config: {}, isEntry: true }, + }, + { + id: "n2", + type: "agent", + position: { x: 100, y: 0 }, + data: { + kind: "transform", + name: "transform_data", + config: { operation: "pick_fields", source_path: "state.data", target_key: "picked", param: "id,name" }, + }, + }, + { + id: "n3", + type: "agent", + position: { x: 200, y: 0 }, + data: { + kind: "loop", + name: "loop_data", + config: { items_path: "state.items", item_var: "x", output_key: "res", transform_template: "{{x}}" }, + }, + }, + ]; + + const edges: Edge[] = [ + { id: "e1", source: "n1", target: "n2", label: "next" }, + { id: "e2", source: "n2", target: "n3", label: "next" }, + ]; + + const pyRes = generateCode("python", nodes, edges); + expect(pyRes.code).toContain("Data Transform op=pick_fields"); + expect(pyRes.code).toContain("Loop Iterator items="); + + const jsRes = generateCode("javascript", nodes, edges); + expect(jsRes.code).toContain("const sourceVal = state.get"); + expect(jsRes.code).toContain("const itemsList = Array.isArray"); + }); + + it("validates transform and loop node configuration in validateGraph", () => { + const nodes: Node[] = [ + { + id: "n1", + type: "agent", + position: { x: 0, y: 0 }, + data: { kind: "trigger", name: "start", config: {}, isEntry: true }, + }, + { + id: "n2", + type: "agent", + position: { x: 100, y: 0 }, + data: { + kind: "transform", + name: "bad_transform", + config: { target_key: "" }, + }, + }, + { + id: "n3", + type: "agent", + position: { x: 200, y: 0 }, + data: { + kind: "loop", + name: "bad_loop", + config: { items_path: "" }, + }, + }, + ]; + + const edges: Edge[] = [ + { id: "e1", source: "n1", target: "n2" }, + { id: "e2", source: "n2", target: "n3" }, + ]; + + const issues = validateGraph(nodes, edges); + expect(issues.some((i) => i.message.includes("missing target_key"))).toBe(true); + expect(issues.some((i) => i.message.includes("missing items_path"))).toBe(true); + }); + }); + + describe("Workflow Analytics Modal", () => { + it("renders analytics modal and calculates metrics correctly", () => { + const sampleLogs = [ + { + step: 1, + nodeId: "n1", + name: "Trigger Node", + kind: "trigger", + label: "start", + output: { triggered: true }, + ms: 5, + }, + { + step: 2, + nodeId: "n2", + name: "Reason Step", + kind: "llm", + label: "next", + output: "AI completion output text", + ms: 120, + stateSnapshot: { query: "User input query" }, + }, + ]; + + render( + {}} + /> + ); + + expect(screen.getByText("๐Ÿ“Š Workflow Performance Profiler")).toBeInTheDocument(); + expect(screen.getByText("125 ms")).toBeInTheDocument(); + expect(screen.getByText("2 steps")).toBeInTheDocument(); + expect(screen.getByText("PASS โœ“")).toBeInTheDocument(); + expect(screen.getAllByText("Reason Step")[0]).toBeInTheDocument(); + }); + }); +}); diff --git a/server.log b/server.log index 9a4850b..707c6ed 100644 --- a/server.log +++ b/server.log @@ -1,6 +1,6 @@ $ vite --host 0.0.0.0 --port 3000 - VITE v5.4.21 ready in 338 ms + VITE v5.4.21 ready in 330 ms โžœ Local: http://localhost:3000/ โžœ Network: http://192.168.0.2:3000/