mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
0521f5bed8
* [BREAKING] Rename ChatAgent -> Agent, ChatMessage -> Message, ChatClientProtocol -> SupportsChatGetResponse Simplify the public API by removing redundant 'Chat' prefix from core types: - ChatAgent -> Agent - RawChatAgent -> RawAgent - ChatMessage -> Message - ChatClientProtocol -> SupportsChatGetResponse Also renamed internal WorkflowMessage (was Message in _runner_context) to avoid collision. No backward compatibility aliases - this is a clean breaking change. * [BREAKING] Rename Agent chat_client parameter to client * Fix rebase issues: WorkflowMessage references and broken markdown links * Fix formatting and lint issues from code quality checks * Fix import ordering in workflow sample files * fixed rebase * Fix test failures: use WorkflowMessage and A2AMessage after ChatMessage→Message rename - Replace Message(data=..., source_id=...) with WorkflowMessage(...) in workflow tests - Fix isinstance check in A2A agent to use A2AMessage instead of Message - Fix import in test_workflow_observability.py (Message→WorkflowMessage) * Fix lint, fmt, and sample errors after ChatMessage→Message rename - Auto-fix 70+ ruff lint issues across samples (ChatMessage→Message refs) - Fix HostedVectorStoreContent→Content.from_hosted_vector_store in file search sample - Fix _normalize_messages→normalize_messages in custom agent sample - Fix context.terminate→raise MiddlewareTermination in middleware samples - Fix with_update_hook→with_transform_hook in override middleware sample - Add TOptions_co import back to custom_chat_client sample - Add noqa for FastAPI File() default in chatkit sample - Fix B023 loop variable capture in weather agent sample * fix: update Agent constructor calls from chat_client to client in declaration-only tool tests * fix: add register_cleanup to devui lazy-loading proxy and type stub * fixed tests and updated new pieces * fix agui typevar * fix merge errors * fix merge conflicts * fiux merge * Remove unused links --------- Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
772 lines
24 KiB
TypeScript
772 lines
24 KiB
TypeScript
import { applySimpleLayout } from "./simple-layout";
|
|
import type { Node, Edge } from "@xyflow/react";
|
|
import type {
|
|
ExecutorNodeData,
|
|
ExecutorState,
|
|
} from "@/components/features/workflow/executor-node";
|
|
import type {
|
|
ExtendedResponseStreamEvent,
|
|
ResponseWorkflowEventComplete,
|
|
ResponseOutputItemAddedEvent,
|
|
ResponseOutputItemDoneEvent,
|
|
JSONSchemaProperty,
|
|
} from "@/types";
|
|
import type { Workflow } from "@/types/workflow";
|
|
import { getTypedWorkflow } from "@/types/workflow";
|
|
|
|
/**
|
|
* Detects if a JSON schema represents a Message input type.
|
|
* Message schemas typically have:
|
|
* - type: "object"
|
|
* - properties with "text" (required string) and "role" (optional string)
|
|
*
|
|
* This is used to determine whether to show the rich ChatMessageInput
|
|
* component for workflows that start with an AgentExecutor.
|
|
*
|
|
* @param schema - The JSON schema to check
|
|
* @returns true if the schema represents a Message-like input
|
|
*/
|
|
export function isChatMessageSchema(schema: JSONSchemaProperty | undefined): boolean {
|
|
if (!schema) return false;
|
|
|
|
// Must be an object type
|
|
if (schema.type !== "object") return false;
|
|
|
|
// Must have properties
|
|
if (!schema.properties) return false;
|
|
|
|
const props = schema.properties;
|
|
|
|
// Message has "text" property (the main content)
|
|
const hasText = "text" in props && props.text?.type === "string";
|
|
|
|
// Message has "role" property (user, assistant, system)
|
|
const hasRole = "role" in props && props.role?.type === "string";
|
|
|
|
// If it has both text and role, it's likely a Message
|
|
if (hasText && hasRole) {
|
|
return true;
|
|
}
|
|
|
|
// Also check for simpler chat-like schemas (just text field)
|
|
// This covers cases where the schema might be simplified
|
|
const propKeys = Object.keys(props);
|
|
if (propKeys.length === 1 && hasText) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Truncates text that exceeds the maximum length and appends ellipsis
|
|
* @param text - The text to truncate
|
|
* @param maxLength - Maximum length before truncation (default: 50)
|
|
* @param ellipsis - String to append when truncated (default: '...')
|
|
* @returns Truncated text with ellipsis if it exceeds maxLength, otherwise original text
|
|
*
|
|
* @example
|
|
* truncateText('Hello World', 5) // 'Hello...'
|
|
* truncateText('Short', 10) // 'Short'
|
|
* truncateText('workflow_assistant_43ca50a006aa425e96e8fcf54206a7e3', 35) // 'workflow_assistant_43ca50a006aa4...'
|
|
*/
|
|
export function truncateText(text: string, maxLength: number = 50, ellipsis: string = '...'): string {
|
|
if (text.length <= maxLength) return text;
|
|
return text.substring(0, maxLength) + ellipsis;
|
|
}
|
|
|
|
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,
|
|
layoutDirection?: "LR" | "TB"
|
|
): 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,
|
|
layoutDirection: layoutDirection || "LR",
|
|
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) => {
|
|
const isSelfLoop = connection.source === connection.target;
|
|
return {
|
|
id: `${connection.source}-${connection.target}`,
|
|
source: connection.source,
|
|
target: connection.target,
|
|
sourceHandle: "source",
|
|
targetHandle: "target",
|
|
type: isSelfLoop ? "selfLoop" : "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
|
|
* Handles both standard OpenAI events and fallback workflow_event format
|
|
*/
|
|
export function processWorkflowEvents(
|
|
events: ExtendedResponseStreamEvent[],
|
|
startExecutorId?: string
|
|
): Record<string, NodeUpdate> {
|
|
const nodeUpdates: Record<string, NodeUpdate> = {};
|
|
let hasWorkflowStarted = false;
|
|
|
|
// Track the latest item ID for each executor to handle multiple runs
|
|
const latestItemIds: Record<string, string> = {};
|
|
|
|
events.forEach((event) => {
|
|
// Handle new standard OpenAI events
|
|
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
|
|
const outputEvent = event as ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent;
|
|
const item = outputEvent.item;
|
|
if (item && item.type === "executor_action" && "executor_id" in item) {
|
|
const executorId = item.executor_id as string;
|
|
const itemId = item.id;
|
|
|
|
// Track the latest item ID for this executor
|
|
if (event.type === "response.output_item.added") {
|
|
latestItemIds[executorId] = itemId;
|
|
}
|
|
|
|
// Only process this event if it's for the latest item ID of this executor
|
|
// This prevents older "done" events from overwriting newer "added" events
|
|
const isLatestItem = latestItemIds[executorId] === itemId;
|
|
|
|
if (!isLatestItem && event.type === "response.output_item.done") {
|
|
return; // Skip this old completion event
|
|
}
|
|
|
|
let state: ExecutorState = "pending";
|
|
let error: string | undefined;
|
|
|
|
if (event.type === "response.output_item.added") {
|
|
state = "running";
|
|
} else if (event.type === "response.output_item.done") {
|
|
if (item.status === "completed") {
|
|
state = "completed";
|
|
} else if (item.status === "failed") {
|
|
state = "failed";
|
|
error = item.error ? (typeof item.error === "string" ? item.error : JSON.stringify(item.error)) : "Execution failed";
|
|
} else if (item.status === "cancelled") {
|
|
state = "cancelled";
|
|
}
|
|
}
|
|
|
|
nodeUpdates[executorId] = {
|
|
nodeId: executorId,
|
|
state,
|
|
data: item.result,
|
|
error,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
}
|
|
}
|
|
// Handle workflow lifecycle events
|
|
else if (event.type === "response.created" || event.type === "response.in_progress") {
|
|
hasWorkflowStarted = true;
|
|
}
|
|
// Handle workflow event format
|
|
else if (
|
|
event.type === "response.workflow_event.completed" &&
|
|
"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" || eventType === "WorkflowOutputEvent") {
|
|
state = "completed";
|
|
} else if (eventType === "WorkflowStartedEvent") {
|
|
// Mark that workflow has started - we'll set start node to running
|
|
hasWorkflowStarted = true;
|
|
}
|
|
|
|
// Update the node state (keep most recent update per executor)
|
|
if (executorId) {
|
|
nodeUpdates[executorId] = {
|
|
nodeId: executorId,
|
|
state,
|
|
data: eventData,
|
|
error,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
}
|
|
}
|
|
});
|
|
|
|
// FALLBACK LOGIC: If workflow has started and we have a start executor, set it to running
|
|
// ONLY if it hasn't received any explicit executor events
|
|
// This prevents overwriting the actual state after the executor has run
|
|
if (hasWorkflowStarted && startExecutorId && !nodeUpdates[startExecutorId]) {
|
|
// Additional check: only set to running if we don't have completion/failure events for this executor
|
|
// This prevents setting to "running" after the executor has already completed
|
|
const hasCompletionEvent = events.some((event) => {
|
|
if (event.type === "response.output_item.done") {
|
|
const outputEvent = event as ResponseOutputItemDoneEvent;
|
|
const item = outputEvent.item;
|
|
return item && item.type === "executor_action" && "executor_id" in item && item.executor_id === startExecutorId;
|
|
}
|
|
if (event.type === "response.workflow_event.completed" && "data" in event && event.data) {
|
|
const data = event.data as Record<string, unknown>;
|
|
return data.executor_id === startExecutorId &&
|
|
(data.event_type === "ExecutorCompletedEvent" ||
|
|
data.event_type === "ExecutorFailedEvent" ||
|
|
(typeof data.event_type === "string" && data.event_type.includes("Error")) ||
|
|
(typeof data.event_type === "string" && data.event_type.includes("Failed")));
|
|
}
|
|
return false;
|
|
});
|
|
|
|
// Only set to running if the executor hasn't completed yet
|
|
if (!hasCompletionEvent) {
|
|
nodeUpdates[startExecutorId] = {
|
|
nodeId: startExecutorId,
|
|
state: "running",
|
|
data: undefined,
|
|
error: undefined,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
}
|
|
}
|
|
|
|
return nodeUpdates;
|
|
}
|
|
|
|
/**
|
|
* Update node states based on event processing
|
|
*/
|
|
export function updateNodesWithEvents(
|
|
nodes: Node<ExecutorNodeData>[],
|
|
nodeUpdates: Record<string, NodeUpdate>,
|
|
isStreaming: boolean = true
|
|
): 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,
|
|
// Add isStreaming to control spinning animation
|
|
isStreaming: isStreaming,
|
|
// Preserve layoutDirection
|
|
layoutDirection: node.data.layoutDirection,
|
|
},
|
|
};
|
|
}
|
|
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) => {
|
|
// Handle new standard OpenAI events
|
|
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
|
|
const outputEvent = event as ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent;
|
|
const item = outputEvent.item;
|
|
if (item && item.type === "executor_action" && "executor_id" in item) {
|
|
const executorId = item.executor_id as string;
|
|
|
|
executorTimeline[executorId] = {
|
|
lastEvent: event.type === "response.output_item.added" ? "ExecutorInvokedEvent" : "ExecutorCompletedEvent",
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
}
|
|
}
|
|
// Handle workflow event format
|
|
else if (
|
|
event.type === "response.workflow_event.completed" &&
|
|
"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.completed" &&
|
|
"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: "#643FB2", // Purple accent
|
|
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,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Consolidate bidirectional edges into single edges with arrows on both ends
|
|
* This reduces visual clutter when edges go in both directions between nodes
|
|
*
|
|
* Smart handle selection algorithm:
|
|
* The current implementation keeps whichever edge was encountered first in the array.
|
|
* Since edges are typically created in workflow definition order (following the primary flow),
|
|
* this naturally keeps the "forward" edge and discards the "backward" one.
|
|
*
|
|
* For example, if the workflow defines:
|
|
* 1. coordinator → planner (primary flow)
|
|
* 2. planner → coordinator (feedback loop)
|
|
*
|
|
* We keep edge #1 and add bidirectional arrows. This ensures the edge follows
|
|
* the natural output→input handle connection of the primary flow direction.
|
|
*
|
|
* React Flow will automatically route the edge to avoid overlaps, and the
|
|
* bidirectional arrows indicate that communication flows both ways.
|
|
*/
|
|
export function consolidateBidirectionalEdges(edges: Edge[]): Edge[] {
|
|
const edgeMap = new Map<string, Edge>();
|
|
const bidirectionalKeys = new Set<string>();
|
|
|
|
edges.forEach(edge => {
|
|
const forwardKey = `${edge.source}-${edge.target}`;
|
|
const reverseKey = `${edge.target}-${edge.source}`;
|
|
|
|
// Self-loops (source === target) should always be preserved as-is
|
|
// They are not bidirectional edges, just a node pointing to itself
|
|
if (edge.source === edge.target) {
|
|
edgeMap.set(forwardKey, edge);
|
|
return;
|
|
}
|
|
|
|
// Check if we already have the reverse edge
|
|
if (edgeMap.has(reverseKey)) {
|
|
// Mark both keys as bidirectional
|
|
bidirectionalKeys.add(reverseKey);
|
|
bidirectionalKeys.add(forwardKey);
|
|
|
|
// Update the existing reverse edge to be bidirectional
|
|
const existingEdge = edgeMap.get(reverseKey)!;
|
|
|
|
// Keep the existing edge's handles (they follow the primary workflow direction)
|
|
// Add bidirectional arrows to show two-way communication
|
|
edgeMap.set(reverseKey, {
|
|
...existingEdge,
|
|
markerStart: {
|
|
type: 'arrow' as const,
|
|
width: 20,
|
|
height: 20,
|
|
},
|
|
markerEnd: {
|
|
type: 'arrow' as const,
|
|
width: 20,
|
|
height: 20,
|
|
},
|
|
data: {
|
|
...existingEdge.data,
|
|
isBidirectional: true,
|
|
},
|
|
});
|
|
} else if (!bidirectionalKeys.has(forwardKey)) {
|
|
// Only add if this isn't the reverse of a bidirectional pair
|
|
edgeMap.set(forwardKey, edge);
|
|
}
|
|
});
|
|
|
|
return Array.from(edgeMap.values());
|
|
}
|