Files
agent-framework/python/packages/devui/frontend/src/utils/workflow-utils.ts
T
Victor Dibia 1ef24d3e91 Python: Add DevUI to AgentFramework (#781)
* add initial backend service code for devui

* add tests

* add frontendcode

* ui updates

* update readme

* ui updates and tweaks

* update ui bundle

* improve ui, add react flow base

* add react flow ui, fix background

* update ui, fix introspection bug

* update readme

* update ui build

* add support for multimodal input - both backend and frontend

* update ui build

* refactor as main framework package

* backend and tests refactor

* ui build update

* ui build update and refactor

* update pyproject.toml, update uv.lock

* update ui build

* ui update to fit oai responses types

* add backend updat and readme update

* mypy and other fixes

* add intial dev guide

* update ui and fix workflow bug

* update ui build, add thread support

* type fixes

* update workflow view

* update uv.lock

* fix workflow iport errors

* lint and other fixes

* mypy fixes

* minor update

* update ui build

* refactor to use oai dependencies directly, update examples to samples, improve typing

* readme update

* update ui and ui build

* fix workflow pyright error

* update ui, fix issues with run workflow placement, miniamp menu, etc

* make samples integrate serve

---------

Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
2025-09-22 23:30:08 +00:00

516 lines
14 KiB
TypeScript

import { applySimpleLayout } from "./simple-layout";
import type { Node, Edge } from "@xyflow/react";
import type {
ExecutorNodeData,
ExecutorState,
} from "@/components/workflow/executor-node";
import type {
ExtendedResponseStreamEvent,
ResponseWorkflowEventComplete,
} from "@/types";
import type { Workflow } from "@/types/workflow";
import { getTypedWorkflow } from "@/types/workflow";
export interface WorkflowDumpExecutor {
id: string;
type: string;
name?: string;
description?: string;
config?: Record<string, unknown>;
}
interface RawExecutorData {
type_?: string;
type?: string;
name?: string;
description?: string;
config?: Record<string, unknown>;
}
export interface WorkflowDumpConnection {
source: string;
target: string;
condition?: string;
}
export interface WorkflowDump {
executors?: WorkflowDumpExecutor[];
connections?: WorkflowDumpConnection[];
start_executor?: string;
end_executors?: string[];
[key: string]: unknown; // Allow for additional properties
}
export interface NodeUpdate {
nodeId: string;
state: ExecutorState;
data?: unknown;
error?: string;
timestamp: string;
}
/**
* Convert workflow dump data to React Flow nodes
*/
export function convertWorkflowDumpToNodes(
workflowDump: Workflow | Record<string, unknown> | undefined,
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void
): Node<ExecutorNodeData>[] {
if (!workflowDump) {
console.warn("convertWorkflowDumpToNodes: workflowDump is undefined");
return [];
}
// Try to get typed workflow first, then fall back to generic handling
const typedWorkflow = getTypedWorkflow(workflowDump);
let executors: WorkflowDumpExecutor[];
let startExecutorId: string | undefined;
if (typedWorkflow) {
// Use typed workflow structure
executors = Object.values(typedWorkflow.executors).map((executor) => ({
id: executor.id,
type: executor.type,
name:
((executor as Record<string, unknown>).name as string) || executor.id,
description: (executor as Record<string, unknown>).description as string,
config: (executor as Record<string, unknown>).config as Record<
string,
unknown
>,
}));
startExecutorId = typedWorkflow.start_executor_id;
} else {
// Fall back to generic handling for backwards compatibility
executors = getExecutorsFromDump(workflowDump as Record<string, unknown>);
const workflowDumpRecord = workflowDump as Record<string, unknown>;
startExecutorId = workflowDumpRecord?.start_executor_id as
| string
| undefined;
}
if (!executors || !Array.isArray(executors) || executors.length === 0) {
console.warn(
"No executors found in workflow dump. Available keys:",
Object.keys(workflowDump)
);
return [];
}
const nodes = executors.map((executor) => ({
id: executor.id,
type: "executor",
position: { x: 0, y: 0 }, // Will be set by layout algorithm
data: {
executorId: executor.id,
executorType: executor.type,
name: executor.name || executor.id,
state: "pending" as ExecutorState,
isStartNode: executor.id === startExecutorId,
onNodeClick,
},
}));
return nodes;
}
/**
* Convert workflow dump data to React Flow edges
*/
export function convertWorkflowDumpToEdges(
workflowDump: Workflow | Record<string, unknown> | undefined
): Edge[] {
if (!workflowDump) {
console.warn("convertWorkflowDumpToEdges: workflowDump is undefined");
return [];
}
// Try to get typed workflow first, then fall back to generic handling
const typedWorkflow = getTypedWorkflow(workflowDump);
let connections: WorkflowDumpConnection[];
if (typedWorkflow) {
// Use typed workflow structure to extract connections from edge_groups
connections = [];
typedWorkflow.edge_groups.forEach((group) => {
group.edges.forEach((edge) => {
connections.push({
source: edge.source_id,
target: edge.target_id,
condition: edge.condition_name,
});
});
});
} else {
// Fall back to generic handling for backwards compatibility
connections = getConnectionsFromDump(
workflowDump as Record<string, unknown>
);
}
if (!connections || !Array.isArray(connections) || connections.length === 0) {
console.warn(
"No connections found in workflow dump. Available keys:",
Object.keys(workflowDump)
);
return [];
}
const edges = connections.map((connection) => ({
id: `${connection.source}-${connection.target}`,
source: connection.source,
target: connection.target,
type: "default",
animated: false,
style: {
stroke: "#6b7280",
strokeWidth: 2,
},
}));
return edges;
}
/**
* Extract executors from workflow dump - handles different possible structures
*/
function getExecutorsFromDump(
workflowDump: Record<string, unknown>
): WorkflowDumpExecutor[] {
// First check if executors is an object (like in the actual dump structure)
if (
workflowDump.executors &&
typeof workflowDump.executors === "object" &&
!Array.isArray(workflowDump.executors)
) {
const executorsObj = workflowDump.executors as Record<
string,
RawExecutorData
>;
return Object.entries(executorsObj).map(([id, executor]) => ({
id,
type: executor.type_ || executor.type || "executor",
name: executor.name || id,
description: executor.description,
config: executor.config,
}));
}
// Try different possible keys where executors might be stored as arrays
const possibleKeys = ["executors", "agents", "steps", "nodes"];
for (const key of possibleKeys) {
if (workflowDump[key] && Array.isArray(workflowDump[key])) {
return workflowDump[key] as WorkflowDumpExecutor[];
}
}
// If no direct array, try to extract from nested structures
if (workflowDump.config && typeof workflowDump.config === "object") {
return getExecutorsFromDump(workflowDump.config as Record<string, unknown>);
}
// Fallback: create executors from any object keys that look like executor IDs
const executors: WorkflowDumpExecutor[] = [];
Object.entries(workflowDump).forEach(([key, value]) => {
if (
typeof value === "object" &&
value !== null &&
("type" in value || "type_" in value)
) {
const rawExecutor = value as RawExecutorData;
executors.push({
id: key,
type: rawExecutor.type_ || rawExecutor.type || "executor",
name: rawExecutor.name || key,
description: rawExecutor.description,
config: rawExecutor.config,
});
}
});
return executors;
}
/**
* Extract connections from workflow dump - handles different possible structures
*/
function getConnectionsFromDump(
workflowDump: Record<string, unknown>
): WorkflowDumpConnection[] {
// Handle edge_groups structure (actual dump format)
if (workflowDump.edge_groups && Array.isArray(workflowDump.edge_groups)) {
const connections: WorkflowDumpConnection[] = [];
workflowDump.edge_groups.forEach((group: unknown) => {
if (typeof group === "object" && group !== null && "edges" in group) {
const edges = (group as { edges: unknown }).edges;
if (Array.isArray(edges)) {
edges.forEach((edge: unknown) => {
if (
typeof edge === "object" &&
edge !== null &&
"source_id" in edge &&
"target_id" in edge
) {
const edgeObj = edge as {
source_id: string;
target_id: string;
condition_name?: string;
};
connections.push({
source: edgeObj.source_id,
target: edgeObj.target_id,
condition: edgeObj.condition_name || undefined,
});
}
});
}
}
});
return connections;
}
// Try different possible keys where connections might be stored
const possibleKeys = ["connections", "edges", "transitions", "links"];
for (const key of possibleKeys) {
if (workflowDump[key] && Array.isArray(workflowDump[key])) {
return workflowDump[key] as WorkflowDumpConnection[];
}
}
// If no direct array, try to extract from nested structures
if (workflowDump.config && typeof workflowDump.config === "object") {
return getConnectionsFromDump(
workflowDump.config as Record<string, unknown>
);
}
return [];
}
/**
* Apply auto-layout to nodes using a lightweight algorithm
* Replaces dagre to eliminate 4.88MB lodash dependency
*/
export function applyDagreLayout(
nodes: Node<ExecutorNodeData>[],
edges: Edge[],
direction: "TB" | "LR" = "LR"
): Node<ExecutorNodeData>[] {
return applySimpleLayout(nodes, edges, direction);
}
/**
* Process workflow events and extract node updates
*/
export function processWorkflowEvents(
events: ExtendedResponseStreamEvent[]
): Record<string, NodeUpdate> {
const nodeUpdates: Record<string, NodeUpdate> = {};
events.forEach((event) => {
if (
event.type === "response.workflow_event.complete" &&
"data" in event &&
event.data
) {
const workflowEvent = event as ResponseWorkflowEventComplete;
const data = workflowEvent.data;
const executorId = data.executor_id;
const eventType = data.event_type;
const eventData = data.data;
let state: ExecutorState = "pending";
let error: string | undefined;
// Map event types to executor states
if (eventType === "ExecutorInvokedEvent") {
state = "running";
} else if (eventType === "ExecutorCompletedEvent") {
state = "completed";
} else if (
eventType?.includes("Error") ||
eventType?.includes("Failed")
) {
state = "failed";
error = typeof eventData === "string" ? eventData : "Execution failed";
} else if (eventType?.includes("Cancel")) {
state = "cancelled";
} else if (eventType === "WorkflowCompletedEvent") {
state = "completed";
}
// Update the node state (keep most recent update per executor)
if (executorId) {
nodeUpdates[executorId] = {
nodeId: executorId,
state,
data: eventData,
error,
timestamp: new Date().toISOString(),
};
}
}
});
return nodeUpdates;
}
/**
* Update node states based on event processing
*/
export function updateNodesWithEvents(
nodes: Node<ExecutorNodeData>[],
nodeUpdates: Record<string, NodeUpdate>
): Node<ExecutorNodeData>[] {
return nodes.map((node) => {
const update = nodeUpdates[node.id];
if (update) {
return {
...node,
data: {
...node.data,
state: update.state,
outputData: update.data,
error: update.error,
},
};
}
return node;
});
}
/**
* Get executors that are currently in execution (invoked but not yet completed)
*/
export function getCurrentlyExecutingExecutors(
events: ExtendedResponseStreamEvent[]
): string[] {
const executorTimeline: Record<
string,
{ lastEvent: string; timestamp: string }
> = {};
// Process events to find the most recent event for each executor
events.forEach((event) => {
if (
event.type === "response.workflow_event.complete" &&
"data" in event &&
event.data
) {
const workflowEvent = event as ResponseWorkflowEventComplete;
const data = workflowEvent.data;
const executorId = data.executor_id;
const eventType = data.event_type;
if (
executorId &&
(eventType === "ExecutorInvokedEvent" ||
eventType === "ExecutorCompletedEvent")
) {
executorTimeline[executorId] = {
lastEvent: eventType,
timestamp: new Date().toISOString(),
};
}
}
});
// Find executors that were invoked but haven't completed yet
const currentlyExecuting = Object.entries(executorTimeline)
.filter(([, timeline]) => timeline.lastEvent === "ExecutorInvokedEvent")
.map(([executorId]) => executorId);
return currentlyExecuting;
}
/**
* Update edges with sequence-based animation
*/
export function updateEdgesWithSequenceAnalysis(
edges: Edge[],
events: ExtendedResponseStreamEvent[]
): Edge[] {
const currentlyExecuting = getCurrentlyExecutingExecutors(events);
// Build simple state tracking for each executor
const executorStates: Record<
string,
{ completed: boolean; invoked: boolean }
> = {};
events.forEach((event) => {
if (
event.type === "response.workflow_event.complete" &&
"data" in event &&
event.data
) {
const workflowEvent = event as ResponseWorkflowEventComplete;
const data = workflowEvent.data;
const executorId = data.executor_id;
const eventType = data.event_type;
if (executorId && eventType) {
if (!executorStates[executorId]) {
executorStates[executorId] = { completed: false, invoked: false };
}
if (eventType === "ExecutorInvokedEvent") {
executorStates[executorId].invoked = true;
} else if (eventType === "ExecutorCompletedEvent") {
executorStates[executorId].completed = true;
}
}
}
});
return edges.map((edge) => {
const sourceState = executorStates[edge.source];
const targetState = executorStates[edge.target];
const targetIsExecuting = currentlyExecuting.includes(edge.target);
let style = { ...edge.style };
let animated = false;
// Active edge: source completed and target is currently executing
if (sourceState?.completed && targetIsExecuting) {
style = {
stroke: "#3b82f6", // Blue
strokeWidth: 3,
strokeDasharray: "5,5",
};
animated = true;
}
// Completed edge: both source and target have completed
else if (sourceState?.completed && targetState?.completed) {
style = {
stroke: "#10b981", // Green
strokeWidth: 2,
};
}
// Invoked edge: source completed and target invoked (but not necessarily executing)
else if (sourceState?.completed && targetState?.invoked) {
style = {
stroke: "#f59e0b", // Orange
strokeWidth: 2,
};
}
// Default: Not traversed
else {
style = {
stroke: "#6b7280", // Gray
strokeWidth: 2,
};
}
return {
...edge,
style,
animated,
};
});
}