diff --git a/dev_server.log b/dev_server.log index 9f3401e..8f5cc06 100644 --- a/dev_server.log +++ b/dev_server.log @@ -1,6 +1,6 @@ $ vite --host 0.0.0.0 --port 3000 - VITE v5.4.21 ready in 312 ms + VITE v5.4.21 ready in 329 ms ➜ Local: http://localhost:3000/ ➜ Network: http://192.168.0.2:3000/ diff --git a/frontend/src/flow/AgentNode.tsx b/frontend/src/flow/AgentNode.tsx index b14d380..ae06d0c 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(var(--node-transform, 280 75% 55%))", + loop: "hsl(var(--node-loop, 160 70% 40%))", }; interface ExtraData extends AgentNodeData { diff --git a/frontend/src/flow/Palette.tsx b/frontend/src/flow/Palette.tsx index ead67ad..16178b1 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(var(--node-transform, 280 75% 55%))", + loop: "hsl(var(--node-loop, 160 70% 40%))", }; 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..3a37bf9 --- /dev/null +++ b/frontend/src/flow/WorkflowAnalyticsModal.tsx @@ -0,0 +1,334 @@ +import { useMemo, useState } from "react"; +import type { Node, Edge } from "reactflow"; +import type { AgentNodeData } from "./types"; +import type { RunLog } from "./runFlow"; +import { toast } from "sonner"; + +interface Props { + isOpen: boolean; + onClose: () => void; + runLogs: RunLog[] | null; + nodes: Node[]; + edges: Edge[]; +} + +export function WorkflowAnalyticsModal({ + isOpen, + onClose, + runLogs, + nodes, + edges, +}: Props) { + const [searchQuery, setSearchQuery] = useState(""); + const [sortField, setSortField] = useState<"step" | "ms" | "name">("step"); + const [sortAsc, setSortAsc] = useState(true); + + const logs = useMemo(() => runLogs ?? [], [runLogs]); + + // Performance calculations + const totalMs = useMemo(() => logs.reduce((acc, l) => acc + l.ms, 0), [logs]); + const avgMs = useMemo(() => (logs.length > 0 ? Math.round(totalMs / logs.length) : 0), [logs, totalMs]); + const errorCount = useMemo(() => logs.filter((l) => l.error).length, [logs]); + const llmCount = useMemo(() => logs.filter((l) => l.kind === "llm").length, [logs]); + + // Token & cost estimation heuristic (~4 chars per token for LLM prompts/responses) + const tokenMetrics = useMemo(() => { + let charCount = 0; + logs.forEach((l) => { + if (l.kind === "llm" && l.output) { + if (typeof l.output === "string") { + charCount += l.output.length; + } else if (typeof l.output === "object" && l.output !== null) { + charCount += JSON.stringify(l.output).length; + } + } + }); + const estimatedTokens = Math.round(charCount / 4); + const estimatedCostUsd = (estimatedTokens * 0.000002).toFixed(5); + return { estimatedTokens, estimatedCostUsd }; + }, [logs]); + + // Bottleneck detection + const bottlenecks = useMemo(() => { + if (logs.length === 0) return []; + const sorted = [...logs].sort((a, b) => b.ms - a.ms); + const topSlow = sorted.slice(0, 2).filter((l) => l.ms > 100); + return topSlow.map((l) => { + let rec = "Optimize processing or response handling."; + if (l.kind === "llm") { + rec = "Consider lowering temperature or max_tokens, or selecting a faster provider profile."; + } else if (l.kind === "http") { + rec = "Ensure external API latency is acceptable and consider caching responses."; + } else if (l.kind === "subagent") { + rec = "Review subagent workflow depth and optimize child graph execution steps."; + } + return { log: l, recommendation: rec }; + }); + }, [logs]); + + // Filtered and sorted logs + const filteredLogs = useMemo(() => { + let list = [...logs]; + const q = searchQuery.trim().toLowerCase(); + if (q) { + list = list.filter( + (l) => + l.name.toLowerCase().includes(q) || + l.kind.toLowerCase().includes(q) || + (l.error && l.error.toLowerCase().includes(q)) + ); + } + list.sort((a, b) => { + let cmp = 0; + if (sortField === "step") cmp = a.step - b.step; + else if (sortField === "ms") cmp = a.ms - b.ms; + else if (sortField === "name") cmp = a.name.localeCompare(b.name); + return sortAsc ? cmp : -cmp; + }); + return list; + }, [logs, searchQuery, sortField, sortAsc]); + + const handleCopyReport = () => { + if (logs.length === 0) { + toast.error("No execution logs to report"); + return; + } + const reportText = [ + `=== WORKFLOW PERFORMANCE REPORT ===`, + `Total Steps: ${logs.length}`, + `Total Duration: ${totalMs} ms`, + `Average Latency: ${avgMs} ms / step`, + `Errors: ${errorCount}`, + `Estimated LLM Tokens: ${tokenMetrics.estimatedTokens}`, + `Estimated Cost: $${tokenMetrics.estimatedCostUsd}`, + ``, + `--- BOTTLENECKS ---`, + ...bottlenecks.map( + (b) => `• Step #${b.log.step} "${b.log.name}" (${b.log.kind}): ${b.log.ms} ms -> ${b.recommendation}` + ), + ].join("\n"); + + navigator.clipboard.writeText(reportText).then(() => { + toast.success("Performance report copied to clipboard"); + }); + }; + + const handleExportCsv = () => { + if (logs.length === 0) { + toast.error("No execution logs to export"); + return; + } + const headers = ["Step", "Node ID", "Name", "Kind", "Label", "Duration (ms)", "Status", "Error"]; + const rows = logs.map((l) => [ + l.step, + `"${l.nodeId}"`, + `"${l.name}"`, + `"${l.kind}"`, + `"${l.label}"`, + l.ms, + l.error ? "ERROR" : "PASS", + `"${(l.error || "").replace(/"/g, '""')}"`, + ]); + const csvContent = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n"); + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "workflow_performance_analytics.csv"; + a.click(); + URL.revokeObjectURL(url); + toast.success("Exported performance analytics CSV"); + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+ 📊 +
+

+ Workflow Performance Profiler & Analytics +

+
+ {nodes.length} nodes · {edges.length} edges · {logs.length} run steps +
+
+
+ +
+ + {/* Content Body */} +
+ {logs.length === 0 ? ( +
+ +
No Execution Run Logs Available
+

+ Run the workflow canvas using the "▶ run" or Stepper button to analyze execution latency, detect slow node bottlenecks, and estimate token costs. +

+
+ ) : ( + <> + {/* Summary Cards Grid */} +
+
+
Total Duration
+
{totalMs} ms
+
+
+
Avg Step Latency
+
{avgMs} ms
+
+
+
Estimated Tokens
+
{tokenMetrics.estimatedTokens} tkns
+
+
+
Estimated Cost
+
${tokenMetrics.estimatedCostUsd}
+
+
+ + {/* Bottleneck & Slow Node Detection */} + {bottlenecks.length > 0 && ( +
+
+ ⚠️ Bottleneck & Slow Node Recommendations +
+
+ {bottlenecks.map((b) => ( +
+
+ + Step #{b.log.step}: {b.log.name} ({b.log.kind}) + + {b.log.ms} ms +
+

+ {b.recommendation} +

+
+ ))} +
+
+ )} + + {/* Step Performance Breakdown Table */} +
+
+ + Per-Node Execution Table + + setSearchQuery(e.target.value)} + placeholder="Search node or kind..." + className="bg-transparent border border-dashed border-[hsl(var(--ink-faint))] focus:border-[hsl(var(--ink))] outline-none py-0.5 px-2 text-[10px]" + /> +
+ +
+ + + + + + + + + + + + + {filteredLogs.map((l) => ( + + + + + + + + + ))} + +
{ + if (sortField === "step") setSortAsc(!sortAsc); + else { setSortField("step"); setSortAsc(true); } + }} + > + Step {sortField === "step" ? (sortAsc ? "▲" : "▼") : ""} + { + if (sortField === "name") setSortAsc(!sortAsc); + else { setSortField("name"); setSortAsc(true); } + }} + > + Node Name {sortField === "name" ? (sortAsc ? "▲" : "▼") : ""} + KindLabel { + if (sortField === "ms") setSortAsc(!sortAsc); + else { setSortField("ms"); setSortAsc(false); } + }} + > + Duration {sortField === "ms" ? (sortAsc ? "▲" : "▼") : ""} + Status
#{l.step}{l.name} + {l.kind} + {l.label}{l.ms} ms + {l.error ? ( + FAIL ✗ + ) : ( + PASS ✓ + )} +
+
+
+ + )} +
+ + {/* Footer Actions */} +
+
+ + +
+ +
+
+
+ ); +} diff --git a/frontend/src/flow/codegen.ts b/frontend/src/flow/codegen.ts index 3505322..3927552 100644 --- a/frontend/src/flow/codegen.ts +++ b/frontend/src/flow/codegen.ts @@ -432,6 +432,64 @@ 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"; + const target = c.target_key || "transformed"; + const inputKey = c.input_key || ""; + const expr = c.expression || ""; + return [ + `# Data Transform op=${pyStr(op)} target=${pyStr(target)}`, + `input_val = state.get(${pyStr(inputKey || "last")}, state.last)`, + `expr_val = interpolate(${pyStr(expr)}, state)`, + `if ${pyStr(op)} == "template_string":`, + ` res = expr_val`, + `elif ${pyStr(op)} == "pick_fields":`, + ` fields = [f.strip() for f in expr_val.replace("\\n", ",").split(",") if f.strip()]`, + ` res = {f: input_val.get(f) for f in fields if isinstance(input_val, dict) and f in input_val}`, + `elif ${pyStr(op)} == "flatten_object":`, + ` def _flatten(o, prefix=""):`, + ` acc = {}`, + ` for k, v in o.items():`, + ` pre = f"{prefix}.{k}" if prefix else k`, + ` if isinstance(v, dict): acc.update(_flatten(v, pre))`, + ` else: acc[pre] = v`, + ` return acc`, + ` res = _flatten(input_val) if isinstance(input_val, dict) else input_val`, + `else:`, + ` try:`, + ` res = json.loads(expr_val) if expr_val.strip() else input_val`, + ` except Exception:`, + ` res = expr_val`, + `state.set(${pyStr(target)}, res)`, + `state.last = res`, + `return "next"`, + ].join("\n"); + } + case "loop": { + const arrKey = c.array_key || "items"; + const itemVar = c.item_var || "item"; + const targetKey = c.target_key || "processed"; + const tmpl = c.transform_template || ""; + const maxIter = parseInt(c.max_iterations || "100", 10) || 100; + return [ + `# Array Loop over state.${arrKey}`, + `raw_arr = state.get(${pyStr(arrKey)}, [])`, + `arr = raw_arr if isinstance(raw_arr, list) else ([raw_arr] if raw_arr is not None else [])`, + `res_list = []`, + `for idx, item in enumerate(arr[:${maxIter}]):`, + ` state.set(${pyStr(itemVar)}, item)`, + ` tmpl_val = interpolate(${pyStr(tmpl)}, state)`, + ` if tmpl_val.strip():`, + ` try: val = json.loads(tmpl_val)`, + ` except Exception: val = tmpl_val`, + ` else:`, + ` val = item`, + ` res_list.append(val)`, + `state.set(${pyStr(targetKey)}, res_list)`, + `state.last = res_list`, + `return "next"`, + ].join("\n"); + } default: { const _exhaustive: never = d.kind as never; return `return "next" # unknown kind ${_exhaustive}`; @@ -626,6 +684,55 @@ 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"; + const target = c.target_key || "transformed"; + const inputKey = c.input_key || ""; + const expr = c.expression || ""; + return [ + `const op = ${JSON.stringify(op)};`, + `const inputVal = state.get(${JSON.stringify(inputKey || "last")}) ?? state.last;`, + `const exprVal = interpolate(${JSON.stringify(expr)}, state);`, + `let res;`, + `if (op === "template_string") { res = exprVal; }`, + `else if (op === "pick_fields") {`, + ` const fields = exprVal.split(/[\\n,]+/).map(f => f.trim()).filter(Boolean);`, + ` res = {};`, + ` if (typeof inputVal === "object" && inputVal !== null) {`, + ` for (const f of fields) { if (f in inputVal) res[f] = inputVal[f]; }`, + ` }`, + `} else {`, + ` try { res = JSON.parse(exprVal); } catch { res = exprVal; }`, + `}`, + `state.set(${JSON.stringify(target)}, res);`, + `state.last = res;`, + `return "next";`, + ].join("\n"); + } + case "loop": { + const arrKey = c.array_key || "items"; + const itemVar = c.item_var || "item"; + const targetKey = c.target_key || "processed"; + const tmpl = c.transform_template || ""; + const maxIter = parseInt(c.max_iterations || "100", 10) || 100; + return [ + `const rawArr = state.get(${JSON.stringify(arrKey)}) ?? [];`, + `const arr = Array.isArray(rawArr) ? rawArr : (rawArr ? [rawArr] : []);`, + `const resList = [];`, + `for (const item of arr.slice(0, ${maxIter})) {`, + ` state.set(${JSON.stringify(itemVar)}, item);`, + ` const tmplVal = interpolate(${JSON.stringify(tmpl)}, state);`, + ` let val = item;`, + ` if (tmplVal.trim()) {`, + ` try { val = JSON.parse(tmplVal); } catch { val = tmplVal; }`, + ` }`, + ` resList.push(val);`, + `}`, + `state.set(${JSON.stringify(targetKey)}, resList);`, + `state.last = resList;`, + `return "next";`, + ].join("\n"); + } default: return `return "next";`; } @@ -677,4 +784,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..2ba1b0a 100644 --- a/frontend/src/flow/runFlow.ts +++ b/frontend/src/flow/runFlow.ts @@ -77,7 +77,12 @@ export function interpolate( const v = getPath(state, String(k)); return v === undefined ? "" : typeof v === "string" ? v : JSON.stringify(v); }) - .replace(/\{\{?\s*query\s*\}?\}/g, () => String(state.query ?? "")); + .replace(/\{\{?\s*query\s*\}?\}/g, () => String(state.query ?? "")) + .replace(/\{\{?\s*([\w.]+)\s*\}?\}/g, (_m, k) => { + const key = String(k); + const v = getPath(state, key); + return v === undefined ? _m : typeof v === "string" ? v : JSON.stringify(v); + }); } /** @@ -478,6 +483,153 @@ export async function runNode( annotationOnly: true, }; } + case "transform": { + const op = (cfg.operation || "json_map").toLowerCase(); + const inputKey = (cfg.input_key || "").trim(); + const targetKey = (cfg.target_key || "transformed").trim(); + const expr = cfg.expression || ""; + + let rawInput: unknown = state; + if (inputKey) { + if (inputKey.startsWith("state.")) { + rawInput = getPath(state, inputKey.slice(6)); + } else if (inputKey in state) { + rawInput = state[inputKey]; + } else if (inputKey === "last_output") { + rawInput = state.last_output; + } else { + rawInput = getPath(state, inputKey); + } + } else if (state.last_output !== undefined) { + rawInput = state.last_output; + } + + let result: unknown; + + if (op === "template_string") { + result = interpolate(expr, state, globalsList, secretsList); + } else if (op === "pick_fields") { + const fields = expr + .split(/[\n,]+/) + .map((f) => f.trim()) + .filter(Boolean); + if (typeof rawInput === "object" && rawInput !== null) { + const picked: Record = {}; + for (const f of fields) { + const val = getPath(rawInput as Record, f) ?? (rawInput as Record)[f]; + if (val !== undefined) picked[f] = val; + } + result = picked; + } else { + result = {}; + } + } else if (op === "flatten_object") { + const flatten = (obj: Record, prefix = ""): Record => { + return Object.keys(obj).reduce((acc: Record, k: string) => { + const pre = prefix ? `${prefix}.${k}` : k; + if (typeof obj[k] === "object" && obj[k] !== null && !Array.isArray(obj[k])) { + Object.assign(acc, flatten(obj[k] as Record, pre)); + } else { + acc[pre] = obj[k]; + } + return acc; + }, {}); + }; + result = typeof rawInput === "object" && rawInput !== null && !Array.isArray(rawInput) + ? flatten(rawInput as Record) + : rawInput; + } else if (op === "set_keys") { + let parsedKeys: Record = {}; + if (expr.trim()) { + const interpolated = interpolate(expr, state, globalsList, secretsList); + try { + parsedKeys = JSON.parse(interpolated); + } catch { + expr.split("\n").forEach((line) => { + const idx = line.indexOf("="); + if (idx > -1) { + const k = line.slice(0, idx).trim(); + const v = line.slice(idx + 1).trim(); + parsedKeys[k] = interpolate(v, state, globalsList, secretsList); + } + }); + } + } + if (typeof rawInput === "object" && rawInput !== null && !Array.isArray(rawInput)) { + result = { ...rawInput, ...parsedKeys }; + } else { + result = parsedKeys; + } + } else { + // default "json_map" + if (expr.trim()) { + const interpolated = interpolate(expr, state, globalsList, secretsList); + try { + result = JSON.parse(interpolated); + } catch { + result = interpolated; + } + } else { + result = rawInput; + } + } + + state[targetKey] = result; + return { operation: op, targetKey, result }; + } + case "loop": { + const arrayKey = (cfg.array_key || "items").trim(); + const itemVar = (cfg.item_var || "item").trim(); + const targetKey = (cfg.target_key || "processed").trim(); + const template = cfg.transform_template || ""; + const maxIter = parseInt(cfg.max_iterations || "100", 10) || 100; + + let arr: unknown[]; + let rawVal: unknown; + if (arrayKey.startsWith("state.")) { + rawVal = getPath(state, arrayKey.slice(6)); + } else if (arrayKey in state) { + rawVal = state[arrayKey]; + } else { + rawVal = getPath(state, arrayKey); + } + + if (Array.isArray(rawVal)) { + arr = rawVal; + } else if (rawVal !== undefined && rawVal !== null) { + arr = [rawVal]; + } else { + arr = []; + } + + const boundedArr = arr.slice(0, maxIter); + const results: unknown[] = []; + + for (const item of boundedArr) { + const itemCtx = { + ...state, + [itemVar]: item, + item: item, + }; + if (template.trim()) { + const interpolated = interpolate(template, itemCtx, globalsList, secretsList); + try { + results.push(JSON.parse(interpolated)); + } catch { + results.push(interpolated); + } + } else { + results.push(item); + } + } + + state[targetKey] = results; + return { + total: boundedArr.length, + items: results, + target_key: targetKey, + }; + } 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..b989b19 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,37 @@ 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 state variables (json_map, pick_fields, template_string, set_keys, flatten_object).", + defaultName: "transform_data", + configFields: [ + { + key: "operation", + label: "operation", + placeholder: "json_map | pick_fields | template_string | set_keys | flatten_object", + type: "select", + options: ["json_map", "pick_fields", "template_string", "set_keys", "flatten_object"], + }, + { key: "input_key", label: "input_key", placeholder: "state.items or last_output" }, + { key: "target_key", label: "target_key", placeholder: "transformed_result" }, + { key: "expression", label: "expression", placeholder: '{"key": "{{state.val}}"} or "id, name"', type: "textarea" }, + ], + }, + { + kind: "loop", + label: "Array Loop", + description: "Iterates over an array in state, evaluating transform templates per item.", + defaultName: "loop_array", + configFields: [ + { key: "array_key", label: "array_key", placeholder: "state.items" }, + { key: "item_var", label: "item_var", placeholder: "item" }, + { key: "target_key", label: "target_key", placeholder: "processed_list" }, + { key: "transform_template", label: "transform_template", placeholder: '{"item": "{{item}}", "status": "processed"}', type: "textarea" }, + { key: "max_iterations", label: "max_iterations", placeholder: "100" }, + ], + }, ]; 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..9897884 100644 --- a/frontend/src/flow/validate.ts +++ b/frontend/src/flow/validate.ts @@ -3,7 +3,7 @@ import { AgentNodeData } from "./types"; export interface ValidationIssue { nodeId?: string; - kind: "no-trigger" | "orphan" | "router-missing-branch"; + kind: "no-trigger" | "orphan" | "router-missing-branch" | "transform-missing-key" | "loop-missing-key"; message: string; } @@ -49,6 +49,24 @@ export function validateGraph( }); } } + if (n.data.kind === "transform") { + if (!n.data.config?.target_key?.trim()) { + issues.push({ + nodeId: n.id, + kind: "transform-missing-key", + message: `Data Transform "${n.data.name}" requires a target_key`, + }); + } + } + if (n.data.kind === "loop") { + if (!n.data.config?.array_key?.trim() || !n.data.config?.target_key?.trim()) { + issues.push({ + nodeId: n.id, + kind: "loop-missing-key", + message: `Array Loop "${n.data.name}" requires array_key and target_key`, + }); + } + } } return issues; } diff --git a/frontend/src/pages/Index.tsx b/frontend/src/pages/Index.tsx index 4319299..eb1b404 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[]>(() => { @@ -1569,6 +1571,13 @@ function Canvas() { ▤ sample +