diff --git a/frontend/src/flow/AgentNode.tsx b/frontend/src/flow/AgentNode.tsx index b14d380..26c5b45 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(190 70% 40%)", + loop: "hsl(28 85% 45%)", }; interface ExtraData extends AgentNodeData { diff --git a/frontend/src/flow/RunComparisonModal.tsx b/frontend/src/flow/RunComparisonModal.tsx new file mode 100644 index 0000000..ea2ebbf --- /dev/null +++ b/frontend/src/flow/RunComparisonModal.tsx @@ -0,0 +1,258 @@ +import { useState, useMemo } from "react"; +import { RunLog } from "./runFlow"; +import { WorkflowRun, loadRunHistory } from "./runHistory"; + +interface RunComparisonModalProps { + workflowId: string | null; + currentLogs?: RunLog[] | null; + onClose: () => void; +} + +export function RunComparisonModal({ + workflowId, + currentLogs, + onClose, +}: RunComparisonModalProps) { + const history = useMemo(() => loadRunHistory(workflowId), [workflowId]); + + // Construct synthetic "Current Run" if available + const allRuns = useMemo(() => { + const list: WorkflowRun[] = [...history]; + if (currentLogs && currentLogs.length > 0) { + const currentRunObj: WorkflowRun = { + id: "current_active_run", + workflowId: workflowId || "default", + runAt: Date.now(), + durationMs: currentLogs.reduce((acc, l) => acc + l.ms, 0), + stepCount: currentLogs.length, + status: currentLogs.some((l) => l.error) ? "error" : "pass", + logs: currentLogs, + }; + return [currentRunObj, ...list]; + } + return list; + }, [history, currentLogs, workflowId]); + + const [runAId, setRunAId] = useState(() => allRuns[0]?.id || ""); + const [runBId, setRunBId] = useState(() => allRuns[1]?.id || allRuns[0]?.id || ""); + + const runA = useMemo(() => allRuns.find((r) => r.id === runAId) || null, [allRuns, runAId]); + const runB = useMemo(() => allRuns.find((r) => r.id === runBId) || null, [allRuns, runBId]); + + // Max steps to render in diff table + const maxStepsCount = Math.max(runA?.logs.length || 0, runB?.logs.length || 0); + + return ( +
+
+ {/* Header */} +
+
+
+ Execution Run Comparison +
+

+ Compare Workflow Execution Runs Side-by-Side +

+
+ +
+ + {/* Content Body */} +
+ {allRuns.length < 2 && (!currentLogs || currentLogs.length === 0) ? ( +
+
Not Enough Execution Runs
+

Execute the workflow at least twice to enable side-by-side run comparisons.

+
+ ) : ( + <> + {/* Run Selectors */} +
+
+ + Run A (Base Run) + + +
+ +
+ + Run B (Comparison Run) + + +
+
+ + {/* Side-by-Side Summary Cards */} +
+ {/* Run A Card */} +
+
+ + {runA?.id === "current_active_run" ? "Current Run" : "Run A"} + + + {runA?.status.toUpperCase()} + +
+
+ Duration: {runA?.durationMs}ms · Steps:{" "} + {runA?.stepCount} +
+
+ + {/* Run B Card */} +
+
+ + {runB?.id === "current_active_run" ? "Current Run" : "Run B"} + + + {runB?.status.toUpperCase()} + +
+
+ Duration: {runB?.durationMs}ms + {runA && runB && ( + + ({runB.durationMs - runA.durationMs >= 0 ? "+" : ""} + {runB.durationMs - runA.durationMs}ms delta) + + )} + {" · "}Steps: {runB?.stepCount} +
+
+
+ + {/* Step-by-Step Side-by-Side Diff Table */} +
+ + Step-by-Step Execution Diff + +
+ + + + + + + + + + {Array.from({ length: maxStepsCount }).map((_, idx) => { + const logA = runA?.logs[idx]; + const logB = runB?.logs[idx]; + const isDiff = + logA?.name !== logB?.name || + logA?.error !== logB?.error || + Math.abs((logA?.ms || 0) - (logB?.ms || 0)) > 100; + + return ( + + + + {/* Run A Cell */} + + + {/* Run B Cell */} + + + ); + })} + +
Step + Run A Step Details + Run B Step Details
#{idx + 1} + {logA ? ( +
+
+ {logA.name} ({logA.kind}) +
+
{logA.ms}ms
+ {logA.error ? ( +
{logA.error}
+ ) : ( +
+                                      {typeof logA.output === "string" ? logA.output : JSON.stringify(logA.output)}
+                                    
+ )} +
+ ) : ( + + )} +
+ {logB ? ( +
+
+ {logB.name} ({logB.kind}) +
+
{logB.ms}ms
+ {logB.error ? ( +
{logB.error}
+ ) : ( +
+                                      {typeof logB.output === "string" ? logB.output : JSON.stringify(logB.output)}
+                                    
+ )} +
+ ) : ( + + )} +
+
+
+ + )} +
+
+
+ ); +} diff --git a/frontend/src/flow/WorkflowAnalyticsModal.tsx b/frontend/src/flow/WorkflowAnalyticsModal.tsx new file mode 100644 index 0000000..6a6848d --- /dev/null +++ b/frontend/src/flow/WorkflowAnalyticsModal.tsx @@ -0,0 +1,305 @@ +import { useState, useMemo } from "react"; +import { toast } from "sonner"; +import { RunLog } from "./runFlow"; + +interface WorkflowAnalyticsModalProps { + runLogs: RunLog[]; + onClose: () => void; +} + +export function WorkflowAnalyticsModal({ runLogs, onClose }: WorkflowAnalyticsModalProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [sortField, setSortField] = useState<"step" | "ms">("step"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + + // Computed metrics + const totalMs = useMemo(() => runLogs.reduce((acc, l) => acc + l.ms, 0), [runLogs]); + const avgMs = useMemo(() => (runLogs.length ? Math.round(totalMs / runLogs.length) : 0), [totalMs, runLogs]); + + // Bottleneck detection + const slowestLog = useMemo(() => { + if (!runLogs.length) return null; + return [...runLogs].sort((a, b) => b.ms - a.ms)[0]; + }, [runLogs]); + + // Token and cost estimation + const llmMetrics = useMemo(() => { + const llmLogs = runLogs.filter((l) => l.kind === "llm"); + let estimatedTokens = 0; + llmLogs.forEach((l) => { + let charCount = 0; + if (typeof l.output === "string") { + charCount += l.output.length; + } else if (l.output && typeof l.output === "object") { + charCount += JSON.stringify(l.output).length; + } + estimatedTokens += Math.max(20, Math.round(charCount / 4)); + }); + // Approx $0.00015 per 1k tokens (blended input/output mini model rate) + const estimatedCost = (estimatedTokens / 1000) * 0.00015; + return { + callCount: llmLogs.length, + tokens: estimatedTokens, + costFormatted: `$${estimatedCost.toFixed(5)}`, + }; + }, [runLogs]); + + // Recommendations + const recommendations = useMemo(() => { + const recs: string[] = []; + if (!slowestLog) return recs; + + if (slowestLog.ms > totalMs * 0.4 && totalMs > 500) { + recs.push( + `Node "${slowestLog.name}" (${slowestLog.kind}) accounts for ${Math.round((slowestLog.ms / totalMs) * 100)}% of total execution time. Consider caching or optimizing this step.` + ); + } + if (slowestLog.kind === "http") { + recs.push(`HTTP Request node "${slowestLog.name}" is the primary latency bottleneck (${slowestLog.ms}ms). Verify endpoint response time and consider response payload trimming.`); + } + if (llmMetrics.callCount > 3) { + recs.push(`Multiple LLM calls detected (${llmMetrics.callCount}). Batch prompts or reuse step states to reduce API latency and token cost.`); + } + if (recs.length === 0) { + recs.push("Workflow execution is highly optimized! All step latencies are within expected bounds."); + } + return recs; + }, [slowestLog, totalMs, llmMetrics]); + + // Filtered and sorted logs + const processedLogs = useMemo(() => { + let list = runLogs.filter((l) => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return true; + return ( + l.name.toLowerCase().includes(q) || + l.kind.toLowerCase().includes(q) || + (l.error ? l.error.toLowerCase().includes(q) : false) + ); + }); + + list.sort((a, b) => { + const mult = sortOrder === "asc" ? 1 : -1; + if (sortField === "ms") return (a.ms - b.ms) * mult; + return (a.step - b.step) * mult; + }); + + return list; + }, [runLogs, searchQuery, sortField, sortOrder]); + + const handleCopySummary = () => { + const text = [ + `=== WORKFLOW PERFORMANCE PROFILER REPORT ===`, + `Total Steps: ${runLogs.length}`, + `Total Duration: ${totalMs}ms (${(totalMs / 1000).toFixed(2)}s)`, + `Average Step Latency: ${avgMs}ms`, + `Slowest Node: ${slowestLog ? `${slowestLog.name} (${slowestLog.ms}ms)` : "N/A"}`, + `LLM Calls: ${llmMetrics.callCount} (~${llmMetrics.tokens} est. tokens, est. cost ${llmMetrics.costFormatted})`, + `\nRecommendations:`, + ...recommendations.map((r) => `- ${r}`), + ].join("\n"); + + navigator.clipboard.writeText(text).then(() => { + toast.success("Performance summary report copied to clipboard"); + }); + }; + + const handleExportCSV = () => { + const header = ["Step", "Node Name", "Kind", "Label", "Duration (ms)", "Status", "Error"]; + const rows = runLogs.map((l) => [ + l.step, + `"${l.name.replace(/"/g, '""')}"`, + l.kind, + l.label, + l.ms, + l.error ? "ERROR" : "SUCCESS", + `"${(l.error || "").replace(/"/g, '""')}"`, + ]); + + const csvContent = [header.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_analytics_${Date.now()}.csv`; + a.click(); + URL.revokeObjectURL(url); + toast.success("Performance CSV report exported"); + }; + + return ( +
+
+ {/* Header */} +
+
+
+ Performance Profiler & Analytics +
+

+ Workflow Execution Insights ({runLogs.length} Steps) +

+
+ +
+ + {/* Content Body */} +
+ {/* Metrics Overview Cards */} +
+
+
Total Duration
+
{totalMs}ms
+
{(totalMs / 1000).toFixed(2)} seconds
+
+ +
+
Avg Step Latency
+
{avgMs}ms
+
{runLogs.length} total steps
+
+ +
+
Slowest Node
+
+ {slowestLog ? slowestLog.name : "N/A"} +
+
+ {slowestLog ? `${slowestLog.ms}ms` : "0ms"} +
+
+ +
+
Est. LLM Cost
+
{llmMetrics.costFormatted}
+
~{llmMetrics.tokens} est. tokens
+
+
+ + {/* Actionable Recommendations */} +
+
+ 💡 Bottleneck Analysis & Recommendations +
+
    + {recommendations.map((rec, i) => ( +
  • {rec}
  • + ))} +
+
+ + {/* Per-Node Performance Table Controls */} +
+
+ + Per-Node Step Metrics + +
+ setSearchQuery(e.target.value)} + placeholder="Filter nodes..." + className="bg-transparent border border-dashed border-[hsl(var(--grid-line))] px-2 py-0.5 font-mono text-[10px] outline-none" + /> + + +
+
+ + {/* Table */} +
+ + + + + + + + + + + + + {processedLogs.map((log) => { + const pct = totalMs > 0 ? Math.round((log.ms / totalMs) * 100) : 0; + return ( + + + + + + + + + ); + })} + +
{ + if (sortField === "step") setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + else { setSortField("step"); setSortOrder("asc"); } + }} + > + # {sortField === "step" ? (sortOrder === "asc" ? "▲" : "▼") : ""} + Node NameKind { + if (sortField === "ms") setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + else { setSortField("ms"); setSortOrder("desc"); } + }} + > + Duration {sortField === "ms" ? (sortOrder === "asc" ? "▲" : "▼") : ""} + Latency BarStatus
#{log.step}{log.name}{log.kind}{log.ms}ms +
+
35 + ? "hsl(var(--accent-deep))" + : "hsl(var(--ink))", + }} + /> +
+
+ {log.error ? ( + + Error + + ) : ( + + Pass + + )} +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/flow/WorkspaceManager.tsx b/frontend/src/flow/WorkspaceManager.tsx new file mode 100644 index 0000000..03048be --- /dev/null +++ b/frontend/src/flow/WorkspaceManager.tsx @@ -0,0 +1,304 @@ +import { useState, useRef } from "react"; +import { toast } from "sonner"; +import { + exportWorkspaceBundle, + validateWorkspaceBundle, + importWorkspaceBundle, + WorkspaceBundle, +} from "./workspace"; + +interface WorkspaceManagerProps { + onClose: () => void; + onWorkspaceImported?: () => void; +} + +export function WorkspaceManager({ onClose, onWorkspaceImported }: WorkspaceManagerProps) { + const [tab, setTab] = useState<"export" | "import">("export"); + const [includeApiKeys, setIncludeApiKeys] = useState(false); + + // Import state + const [importMode, setImportMode] = useState<"merge" | "replace">("merge"); + const [importJsonText, setImportJsonText] = useState(""); + const [validatedBundle, setValidatedBundle] = useState(null); + const [validationError, setValidationError] = useState(null); + const fileInputRef = useRef(null); + + const handleTextChange = (text: string) => { + setImportJsonText(text); + if (!text.trim()) { + setValidatedBundle(null); + setValidationError(null); + return; + } + const res = validateWorkspaceBundle(text); + if (res.valid && res.bundle) { + setValidatedBundle(res.bundle); + setValidationError(null); + } else { + setValidatedBundle(null); + setValidationError(res.error || "Invalid workspace bundle"); + } + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + const content = event.target?.result; + if (typeof content === "string") { + handleTextChange(content); + } + }; + reader.readAsText(file); + e.target.value = ""; + }; + + const handleExportDownload = () => { + const bundle = exportWorkspaceBundle({ includeApiKeys }); + const dataStr = JSON.stringify(bundle, null, 2); + const blob = new Blob([dataStr], { type: "application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `agent_flow_workspace_${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + toast.success("Workspace backup file downloaded"); + }; + + const handleExportCopy = () => { + const bundle = exportWorkspaceBundle({ includeApiKeys }); + const dataStr = JSON.stringify(bundle, null, 2); + navigator.clipboard.writeText(dataStr).then(() => { + toast.success("Workspace backup JSON copied to clipboard"); + }); + }; + + const handleExecuteImport = () => { + if (!validatedBundle) return; + + if ( + importMode === "replace" && + !confirm("Are you sure you want to REPLACE your entire workspace? Existing workflows and settings will be overwritten!") + ) { + return; + } + + const result = importWorkspaceBundle(validatedBundle, importMode); + toast.success( + `Workspace restored (${importMode} mode): ${result.workflowsCount} workflows, ${result.globalsCount + result.secretsCount} variables/secrets, ${result.gatewaysCount} gateways.` + ); + onWorkspaceImported?.(); + onClose(); + }; + + return ( +
+
+ {/* Modal Header */} +
+
+
+ Workspace Manager +
+

+ Backup & Restore Full Workspace +

+
+ +
+ + {/* Tab switcher */} +
+ + +
+ + {/* Modal Body */} +
+ {tab === "export" ? ( +
+

+ Export a full workspace bundle file (agent_flow.workspace.v1) + containing all custom workflows, initial state presets, dynamic global variables, secrets, and provider gateway settings. +

+ +
+ +
+ +
+ + +
+
+ ) : ( +
+

+ Restore workspace workflows, presets, variables, and gateway settings from an existing backup bundle file or clipboard JSON. +

+ + {/* Import Mode Selection */} +
+ + Import Mode + +
+ + +
+
+ + {/* File upload or text paste */} +
+
+ + Backup File or Raw JSON + + + +
+