mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Improve DevUI, add Context Inspector view as new tab under traces (#2742)
* Improve DevUI, add Context Inspector view as new tab under traces * fix mypy errors * fix: Handle stale MCP connections in DevUI executor MCP tools can become stale when HTTP streaming responses end - the underlying stdio streams close but `is_connected` remains True. This causes subsequent requests to fail with `ClosedResourceError`. Add `_ensure_mcp_connections()` to detect and reconnect stale MCP tools before agent execution. This is a workaround for an upstream Agent Framework issue where connection state isn't properly tracked. Fixes MCP tools failing on second HTTP request in DevUI. fixes #1476 #1515 #2865 * fix #1572 report import dependency errors more clearly * Ensure there is streaming toggle where users can select streaming vs non streaming mode in devui . Fixes .NET: [Python] DevUI tool call rendering in non-streaming mode? * remove unused dead code * improve ux - workflows with agents show a chat component in execution timelien, also ensure magentic final output shows correctly * update ui build * update devui to use instrumentation instead of tracing, other instrumentation and type/instance check fixes
This commit is contained in:
committed by
GitHub
Unverified
parent
db283cd396
commit
2e1189ca65
@@ -270,6 +270,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const conversationUsage = useDevUIStore((state) => state.conversationUsage);
|
||||
const pendingApprovals = useDevUIStore((state) => state.pendingApprovals);
|
||||
const oaiMode = useDevUIStore((state) => state.oaiMode);
|
||||
const streamingEnabled = useDevUIStore((state) => state.streamingEnabled);
|
||||
|
||||
// Get conversation actions from Zustand (only the ones we actually use)
|
||||
const setCurrentConversation = useDevUIStore((state) => state.setCurrentConversation);
|
||||
@@ -570,6 +571,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
let allItems: unknown[] = [];
|
||||
let hasMore = true;
|
||||
let after: string | undefined = undefined;
|
||||
let storedTraces: unknown[] = [];
|
||||
|
||||
while (hasMore) {
|
||||
const result = await apiClient.listConversationItems(
|
||||
@@ -578,7 +580,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
);
|
||||
allItems = allItems.concat(result.data);
|
||||
hasMore = result.has_more;
|
||||
|
||||
|
||||
// Capture traces from metadata (only need from one response, they accumulate)
|
||||
if (result.metadata?.traces && result.metadata.traces.length > 0) {
|
||||
storedTraces = result.metadata.traces;
|
||||
}
|
||||
|
||||
// Get the last item's ID for pagination
|
||||
if (hasMore && result.data.length > 0) {
|
||||
const lastItem = result.data[result.data.length - 1] as { id?: string };
|
||||
@@ -590,6 +597,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
setChatItems(allItems as import("@/types/openai").ConversationItem[]);
|
||||
setIsStreaming(false);
|
||||
|
||||
// Restore stored traces as debug events for context inspection
|
||||
if (storedTraces.length > 0) {
|
||||
// Clear any previous debug events first
|
||||
onDebugEvent("clear");
|
||||
for (const trace of storedTraces) {
|
||||
// Convert stored trace back to ResponseTraceComplete event format
|
||||
const traceEvent: ExtendedResponseStreamEvent = {
|
||||
type: "response.trace.completed",
|
||||
data: trace as Record<string, unknown>,
|
||||
sequence_number: 0, // Not used for display
|
||||
};
|
||||
onDebugEvent(traceEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for incomplete stream and resume if needed
|
||||
const state = loadStreamingState(mostRecent.id);
|
||||
|
||||
@@ -724,6 +746,9 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
|
||||
accumulatedTextRef.current = "";
|
||||
|
||||
// Clear debug panel for fresh conversation
|
||||
onDebugEvent("clear");
|
||||
|
||||
// Update localStorage cache with new conversation
|
||||
const cachedKey = `devui_convs_${selectedAgent.id}`;
|
||||
const updated = [newConversation, ...availableConversations];
|
||||
@@ -736,7 +761,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
type: "conversation_creation_error",
|
||||
});
|
||||
}
|
||||
}, [selectedAgent, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
|
||||
}, [selectedAgent, onDebugEvent, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
|
||||
|
||||
// Handle conversation deletion
|
||||
const handleDeleteConversation = useCallback(
|
||||
@@ -843,6 +868,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
let allItems: unknown[] = [];
|
||||
let hasMore = true;
|
||||
let after: string | undefined = undefined;
|
||||
let storedTraces: unknown[] = [];
|
||||
|
||||
while (hasMore) {
|
||||
const result = await apiClient.listConversationItems(conversationId, {
|
||||
@@ -851,7 +877,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
});
|
||||
allItems = allItems.concat(result.data);
|
||||
hasMore = result.has_more;
|
||||
|
||||
|
||||
// Capture traces from metadata (only need from one response, they accumulate)
|
||||
if (result.metadata?.traces && result.metadata.traces.length > 0) {
|
||||
storedTraces = result.metadata.traces;
|
||||
}
|
||||
|
||||
// Get the last item's ID for pagination
|
||||
if (hasMore && result.data.length > 0) {
|
||||
const lastItem = result.data[result.data.length - 1] as { id?: string };
|
||||
@@ -865,6 +896,19 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
setChatItems(items);
|
||||
setIsStreaming(false);
|
||||
|
||||
// Restore stored traces as debug events for context inspection
|
||||
if (storedTraces.length > 0) {
|
||||
for (const trace of storedTraces) {
|
||||
// Convert stored trace back to ResponseTraceComplete event format
|
||||
const traceEvent: ExtendedResponseStreamEvent = {
|
||||
type: "response.trace.completed",
|
||||
data: trace as Record<string, unknown>,
|
||||
sequence_number: 0, // Not used for display
|
||||
};
|
||||
onDebugEvent(traceEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate usage from loaded items
|
||||
useDevUIStore.setState({
|
||||
conversationUsage: {
|
||||
@@ -1249,13 +1293,15 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
|
||||
// Handle function calls as separate conversation items
|
||||
if (item.type === "function_call") {
|
||||
// Type assertion for function call - narrows from union type
|
||||
const funcCall = item as import("@/types/openai").ResponseFunctionToolCall;
|
||||
const functionCallItem: import("@/types/openai").ConversationFunctionCall = {
|
||||
id: item.id || `call-${Date.now()}`,
|
||||
id: funcCall.id || `call-${Date.now()}`,
|
||||
type: "function_call",
|
||||
name: item.name,
|
||||
arguments: item.arguments || "",
|
||||
call_id: item.call_id,
|
||||
status: (item.status === "failed" || item.status === "cancelled" ? "incomplete" : item.status) || "in_progress",
|
||||
name: funcCall.name,
|
||||
arguments: funcCall.arguments || "",
|
||||
call_id: funcCall.call_id,
|
||||
status: funcCall.status || "in_progress",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
@@ -1414,6 +1460,209 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
[selectedAgent, currentConversation, onDebugEvent, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage, createAbortSignal, resetCancelling]
|
||||
);
|
||||
|
||||
// Handle non-streaming message sending
|
||||
const handleSendMessageSync = useCallback(
|
||||
async (request: RunAgentRequest) => {
|
||||
if (!selectedAgent) return;
|
||||
|
||||
// Check if this is a function approval response (internal, don't show in chat)
|
||||
const isApprovalResponse = request.input.some(
|
||||
(inputItem) =>
|
||||
inputItem.type === "message" &&
|
||||
Array.isArray(inputItem.content) &&
|
||||
inputItem.content.some((c) => c.type === "function_approval_response")
|
||||
);
|
||||
|
||||
// Extract content from OpenAI format to create ConversationMessage
|
||||
const messageContent: import("@/types/openai").MessageContent[] = [];
|
||||
|
||||
// Parse OpenAI ResponseInputParam to extract content
|
||||
for (const inputItem of request.input) {
|
||||
if (inputItem.type === "message" && Array.isArray(inputItem.content)) {
|
||||
for (const contentItem of inputItem.content) {
|
||||
if (contentItem.type === "input_text") {
|
||||
messageContent.push({
|
||||
type: "text",
|
||||
text: contentItem.text,
|
||||
});
|
||||
} else if (contentItem.type === "input_image") {
|
||||
messageContent.push({
|
||||
type: "input_image",
|
||||
image_url: contentItem.image_url || "",
|
||||
detail: "auto",
|
||||
});
|
||||
} else if (contentItem.type === "input_file") {
|
||||
const fileItem = contentItem as import("@/types/agent-framework").ResponseInputFileParam;
|
||||
messageContent.push({
|
||||
type: "input_file",
|
||||
file_data: fileItem.file_data,
|
||||
filename: fileItem.filename,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture timestamp once for both user and assistant messages
|
||||
const messageTimestamp = Math.floor(Date.now() / 1000); // Unix seconds
|
||||
|
||||
// Only add user message to UI if it's not an approval response (internal messages)
|
||||
if (!isApprovalResponse && messageContent.length > 0) {
|
||||
const userMessage: import("@/types/openai").ConversationMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: messageContent,
|
||||
status: "completed",
|
||||
created_at: messageTimestamp,
|
||||
};
|
||||
|
||||
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
|
||||
}
|
||||
|
||||
// Show loading state (but not streaming indicator)
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// If no conversation selected, create one automatically
|
||||
let conversationToUse = currentConversation;
|
||||
if (!conversationToUse) {
|
||||
try {
|
||||
conversationToUse = await apiClient.createConversation({
|
||||
agent_id: selectedAgent.id,
|
||||
});
|
||||
setCurrentConversation(conversationToUse);
|
||||
setAvailableConversations([conversationToUse, ...useDevUIStore.getState().availableConversations]);
|
||||
setConversationError(null);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
|
||||
setConversationError({
|
||||
message: errorMessage,
|
||||
type: "conversation_creation_error",
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Call non-streaming API
|
||||
const response = await apiClient.runAgentSync(selectedAgent.id, {
|
||||
input: request.input,
|
||||
conversation_id: conversationToUse?.id,
|
||||
});
|
||||
|
||||
// Extract content from response output
|
||||
const assistantContent: import("@/types/openai").MessageContent[] = [];
|
||||
const toolCalls: import("@/types/openai").ConversationFunctionCall[] = [];
|
||||
const toolResults: import("@/types/openai").ConversationFunctionCallOutput[] = [];
|
||||
|
||||
if (response.output) {
|
||||
for (const outputItem of response.output) {
|
||||
if (outputItem.type === "message") {
|
||||
// Extract message content
|
||||
const msgItem = outputItem as import("@/types/openai").ResponseOutputMessage;
|
||||
if (msgItem.content) {
|
||||
for (const content of msgItem.content) {
|
||||
if (content.type === "output_text") {
|
||||
assistantContent.push({
|
||||
type: "text",
|
||||
text: (content as { text: string }).text,
|
||||
} as import("@/types/openai").MessageTextContent);
|
||||
} else if (content.type === "output_image") {
|
||||
assistantContent.push(content as unknown as import("@/types/openai").MessageOutputImage);
|
||||
} else if (content.type === "output_file") {
|
||||
assistantContent.push(content as unknown as import("@/types/openai").MessageOutputFile);
|
||||
} else if (content.type === "output_data") {
|
||||
assistantContent.push(content as unknown as import("@/types/openai").MessageOutputData);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (outputItem.type === "function_call") {
|
||||
const funcCall = outputItem as unknown as import("@/types/openai").ResponseFunctionToolCall;
|
||||
toolCalls.push({
|
||||
id: funcCall.id || `call-${Date.now()}`,
|
||||
type: "function_call",
|
||||
name: funcCall.name,
|
||||
arguments: funcCall.arguments || "",
|
||||
call_id: funcCall.call_id,
|
||||
status: funcCall.status || "completed",
|
||||
created_at: messageTimestamp,
|
||||
});
|
||||
} else if (outputItem.type === "function_call_output") {
|
||||
const resultItem = outputItem as unknown as { call_id: string; output: string };
|
||||
toolResults.push({
|
||||
id: `result-${Date.now()}`,
|
||||
type: "function_call_output",
|
||||
call_id: resultItem.call_id,
|
||||
output: resultItem.output,
|
||||
status: "completed",
|
||||
created_at: messageTimestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create assistant message with all content
|
||||
const assistantMessage: import("@/types/openai").ConversationMessage = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: assistantContent,
|
||||
status: "completed",
|
||||
created_at: messageTimestamp,
|
||||
usage: response.usage ? {
|
||||
input_tokens: response.usage.input_tokens,
|
||||
output_tokens: response.usage.output_tokens,
|
||||
total_tokens: response.usage.total_tokens,
|
||||
} : undefined,
|
||||
};
|
||||
|
||||
// Add all items to chat
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
const newItems: import("@/types/openai").ConversationItem[] = [
|
||||
...currentItems,
|
||||
assistantMessage,
|
||||
...toolCalls,
|
||||
...toolResults,
|
||||
];
|
||||
setChatItems(newItems);
|
||||
|
||||
// Update conversation-level usage stats
|
||||
if (response.usage) {
|
||||
updateConversationUsage(response.usage.total_tokens);
|
||||
}
|
||||
|
||||
// Send debug event with response completed
|
||||
onDebugEvent({
|
||||
type: "response.completed",
|
||||
response: response,
|
||||
sequence_number: 0,
|
||||
} as ExtendedResponseStreamEvent);
|
||||
|
||||
} catch (error) {
|
||||
// Show error message
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to get response";
|
||||
const assistantMessage: import("@/types/openai").ConversationMessage = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Error: ${errorMessage}`,
|
||||
} as import("@/types/openai").MessageTextContent],
|
||||
status: "incomplete",
|
||||
created_at: messageTimestamp,
|
||||
};
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems([...currentItems, assistantMessage]);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[selectedAgent, currentConversation, onDebugEvent, setChatItems, setCurrentConversation, setAvailableConversations, updateConversationUsage, setIsSubmitting]
|
||||
);
|
||||
|
||||
|
||||
// Handle message submission from ChatMessageInput
|
||||
const handleChatInputSubmit = async (content: import("@/types/agent-framework").ResponseInputContent[]) => {
|
||||
@@ -1435,11 +1684,17 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
},
|
||||
];
|
||||
|
||||
// Use pure OpenAI format
|
||||
await handleSendMessage({
|
||||
const request = {
|
||||
input: openaiInput,
|
||||
conversation_id: currentConversation?.id,
|
||||
});
|
||||
};
|
||||
|
||||
// Use streaming or non-streaming based on setting
|
||||
if (streamingEnabled) {
|
||||
await handleSendMessage(request);
|
||||
} else {
|
||||
await handleSendMessageSync(request);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,949 @@
|
||||
/**
|
||||
* ContextInspector - Token usage visualization and context analysis
|
||||
*
|
||||
* Features:
|
||||
* - Stacked bar chart showing input/output tokens per turn
|
||||
* - Composition view showing what fills the context (system, user, assistant, tools)
|
||||
* - Per-turn vs cumulative modes
|
||||
* - Summary statistics (total, average, peak)
|
||||
* - Pure CSS visualization (no external charting library)
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useDevUIStore } from "@/stores/devuiStore";
|
||||
import {
|
||||
BarChart3,
|
||||
Layers,
|
||||
Info,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
import {
|
||||
TraceAttributes,
|
||||
type TypedTraceAttributes,
|
||||
type TraceMessage,
|
||||
parseTraceMessages,
|
||||
isTextPart,
|
||||
isToolCallPart,
|
||||
isToolResultPart,
|
||||
} from "@/types/openai";
|
||||
|
||||
// Trace data interface matching debug-panel types
|
||||
interface TraceEventData {
|
||||
operation_name?: string;
|
||||
duration_ms?: number;
|
||||
status?: string;
|
||||
attributes?: TypedTraceAttributes;
|
||||
span_id?: string;
|
||||
trace_id?: string;
|
||||
parent_span_id?: string | null;
|
||||
start_time?: number;
|
||||
end_time?: number;
|
||||
entity_id?: string;
|
||||
response_id?: string | null;
|
||||
}
|
||||
|
||||
// Context composition breakdown
|
||||
interface ContextComposition {
|
||||
system: number; // character count
|
||||
user: number;
|
||||
assistant: number;
|
||||
toolCalls: number; // function definitions + arguments
|
||||
toolResults: number; // function outputs
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Turn data extracted from traces
|
||||
interface TurnData {
|
||||
response_id: string;
|
||||
timestamp: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
model?: string;
|
||||
entity_id?: string;
|
||||
duration_ms: number;
|
||||
composition: ContextComposition;
|
||||
}
|
||||
|
||||
// Props for the component
|
||||
interface ContextInspectorProps {
|
||||
events: ExtendedResponseStreamEvent[];
|
||||
}
|
||||
|
||||
// Parse message content to extract composition using typed TraceMessage format
|
||||
function parseComposition(messagesJson: string | unknown): ContextComposition {
|
||||
const composition: ContextComposition = {
|
||||
system: 0,
|
||||
user: 0,
|
||||
assistant: 0,
|
||||
toolCalls: 0,
|
||||
toolResults: 0,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// Use the typed parser for string input
|
||||
let messages: TraceMessage[];
|
||||
|
||||
if (typeof messagesJson === "string") {
|
||||
messages = parseTraceMessages(messagesJson);
|
||||
} else if (Array.isArray(messagesJson)) {
|
||||
messages = messagesJson as TraceMessage[];
|
||||
} else {
|
||||
return composition;
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
if (!message || typeof message !== "object") continue;
|
||||
|
||||
const role = message.role;
|
||||
const parts = message.parts;
|
||||
|
||||
// Calculate character count for this message
|
||||
let charCount = 0;
|
||||
|
||||
// Handle parts array (Agent Framework format)
|
||||
// Using type guards for type-safe access to part properties
|
||||
if (Array.isArray(parts)) {
|
||||
for (const part of parts) {
|
||||
if (!part || typeof part !== "object") continue;
|
||||
|
||||
if (isTextPart(part)) {
|
||||
// Text content can be in either 'content' or 'text' field
|
||||
const text = part.content || part.text || "";
|
||||
charCount += text.length;
|
||||
} else if (isToolCallPart(part)) {
|
||||
// Tool call includes name and arguments
|
||||
const name = part.name || "";
|
||||
const args = part.arguments || "";
|
||||
composition.toolCalls += name.length + args.length;
|
||||
} else if (isToolResultPart(part)) {
|
||||
// Tool result - check both 'result' and 'response' fields
|
||||
const result = part.result || part.response || "";
|
||||
composition.toolResults += result.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Categorize by role
|
||||
if (role === "system") {
|
||||
composition.system += charCount;
|
||||
} else if (role === "user") {
|
||||
composition.user += charCount;
|
||||
} else if (role === "assistant") {
|
||||
composition.assistant += charCount;
|
||||
} else if (role === "tool") {
|
||||
composition.toolResults += charCount;
|
||||
}
|
||||
}
|
||||
|
||||
composition.total =
|
||||
composition.system +
|
||||
composition.user +
|
||||
composition.assistant +
|
||||
composition.toolCalls +
|
||||
composition.toolResults;
|
||||
|
||||
} catch {
|
||||
// Parsing failed, return empty composition
|
||||
}
|
||||
|
||||
return composition;
|
||||
}
|
||||
|
||||
// Extract turn data from trace events
|
||||
function extractTurnData(events: ExtendedResponseStreamEvent[]): TurnData[] {
|
||||
const traceEvents = events.filter(e => e.type === "response.trace.completed");
|
||||
|
||||
// Group by response_id
|
||||
const byResponseId = new Map<string, TraceEventData[]>();
|
||||
|
||||
for (const event of traceEvents) {
|
||||
if (!("data" in event)) continue;
|
||||
const data = event.data as TraceEventData;
|
||||
const responseId = data.response_id || "unknown";
|
||||
|
||||
if (!byResponseId.has(responseId)) {
|
||||
byResponseId.set(responseId, []);
|
||||
}
|
||||
byResponseId.get(responseId)!.push(data);
|
||||
}
|
||||
|
||||
const turns: TurnData[] = [];
|
||||
|
||||
for (const [responseId, traces] of byResponseId) {
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let model: string | undefined;
|
||||
let timestamp = Date.now() / 1000;
|
||||
let entity_id: string | undefined;
|
||||
let totalDuration = 0;
|
||||
let composition: ContextComposition = {
|
||||
system: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0
|
||||
};
|
||||
|
||||
for (const trace of traces) {
|
||||
const attrs = trace.attributes || {};
|
||||
|
||||
// Get token counts using typed attribute keys
|
||||
const traceInput = attrs[TraceAttributes.INPUT_TOKENS];
|
||||
const traceOutput = attrs[TraceAttributes.OUTPUT_TOKENS];
|
||||
|
||||
if (traceInput !== undefined) {
|
||||
inputTokens += Number(traceInput);
|
||||
}
|
||||
if (traceOutput !== undefined) {
|
||||
outputTokens += Number(traceOutput);
|
||||
}
|
||||
|
||||
// Get model using typed attribute key
|
||||
if (attrs[TraceAttributes.MODEL]) {
|
||||
model = String(attrs[TraceAttributes.MODEL]);
|
||||
}
|
||||
|
||||
// Get timestamp
|
||||
if (trace.start_time && trace.start_time < timestamp) {
|
||||
timestamp = trace.start_time;
|
||||
}
|
||||
|
||||
// Get entity_id
|
||||
if (trace.entity_id) {
|
||||
entity_id = trace.entity_id;
|
||||
}
|
||||
|
||||
// Sum durations
|
||||
if (trace.duration_ms) {
|
||||
totalDuration += Number(trace.duration_ms);
|
||||
}
|
||||
|
||||
// Parse composition from input messages using typed attribute key
|
||||
const inputMessages = attrs[TraceAttributes.INPUT_MESSAGES];
|
||||
if (inputMessages && composition.total === 0) {
|
||||
composition = parseComposition(inputMessages);
|
||||
}
|
||||
|
||||
// Also check for system instructions using typed attribute key
|
||||
const systemInstructions = attrs[TraceAttributes.SYSTEM_INSTRUCTIONS];
|
||||
if (systemInstructions && typeof systemInstructions === "string" && composition.system === 0) {
|
||||
composition.system = systemInstructions.length;
|
||||
composition.total += systemInstructions.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Only include turns that have token data
|
||||
if (inputTokens > 0 || outputTokens > 0) {
|
||||
turns.push({
|
||||
response_id: responseId,
|
||||
timestamp,
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
total_tokens: inputTokens + outputTokens,
|
||||
model,
|
||||
entity_id,
|
||||
duration_ms: totalDuration,
|
||||
composition,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp (oldest first)
|
||||
turns.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
return turns;
|
||||
}
|
||||
|
||||
// Calculate summary stats
|
||||
function calculateStats(turns: TurnData[]) {
|
||||
if (turns.length === 0) {
|
||||
return {
|
||||
totalInput: 0,
|
||||
totalOutput: 0,
|
||||
totalTokens: 0,
|
||||
avgInput: 0,
|
||||
avgOutput: 0,
|
||||
avgTotal: 0,
|
||||
peakInput: 0,
|
||||
peakOutput: 0,
|
||||
peakTotal: 0,
|
||||
turnCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const totalInput = turns.reduce((sum, t) => sum + t.input_tokens, 0);
|
||||
const totalOutput = turns.reduce((sum, t) => sum + t.output_tokens, 0);
|
||||
const totalTokens = totalInput + totalOutput;
|
||||
|
||||
const peakInput = Math.max(...turns.map(t => t.input_tokens));
|
||||
const peakOutput = Math.max(...turns.map(t => t.output_tokens));
|
||||
const peakTotal = Math.max(...turns.map(t => t.total_tokens));
|
||||
|
||||
return {
|
||||
totalInput,
|
||||
totalOutput,
|
||||
totalTokens,
|
||||
avgInput: Math.round(totalInput / turns.length),
|
||||
avgOutput: Math.round(totalOutput / turns.length),
|
||||
avgTotal: Math.round(totalTokens / turns.length),
|
||||
peakInput,
|
||||
peakOutput,
|
||||
peakTotal,
|
||||
turnCount: turns.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Aggregate composition across all turns
|
||||
function aggregateComposition(turns: TurnData[]): ContextComposition {
|
||||
return turns.reduce(
|
||||
(acc, turn) => ({
|
||||
system: acc.system + turn.composition.system,
|
||||
user: acc.user + turn.composition.user,
|
||||
assistant: acc.assistant + turn.composition.assistant,
|
||||
toolCalls: acc.toolCalls + turn.composition.toolCalls,
|
||||
toolResults: acc.toolResults + turn.composition.toolResults,
|
||||
total: acc.total + turn.composition.total,
|
||||
}),
|
||||
{ system: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
// Format large numbers with K suffix
|
||||
function formatTokenCount(n: number): string {
|
||||
if (n >= 1000) {
|
||||
return `${(n / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// Color constants - single source of truth for all visualizations
|
||||
const SEGMENT_COLORS = {
|
||||
// Token segments
|
||||
input: "bg-blue-500 dark:bg-blue-600",
|
||||
output: "bg-emerald-500 dark:bg-emerald-600",
|
||||
// Composition segments
|
||||
system: "bg-purple-500 dark:bg-purple-600",
|
||||
user: "bg-blue-500 dark:bg-blue-600",
|
||||
assistant: "bg-emerald-500 dark:bg-emerald-600",
|
||||
toolCalls: "bg-amber-500 dark:bg-amber-600",
|
||||
toolResults: "bg-orange-500 dark:bg-orange-600",
|
||||
} as const;
|
||||
|
||||
// Segment definition for the unified bar component
|
||||
interface BarSegment {
|
||||
key: string;
|
||||
value: number;
|
||||
color: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Unified segmented bar component with tooltips
|
||||
// Replaces both TokenBar and CompositionBar for consistency and maintainability
|
||||
function SegmentedBar({
|
||||
segments,
|
||||
maxValue,
|
||||
height = 20,
|
||||
renderLabel,
|
||||
}: {
|
||||
segments: BarSegment[];
|
||||
maxValue: number;
|
||||
height?: number;
|
||||
renderLabel?: (total: number, segments: BarSegment[]) => React.ReactNode;
|
||||
}) {
|
||||
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div
|
||||
className="rounded bg-muted/30 flex-1"
|
||||
style={{ height: `${height}px` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// When maxValue is 0, use full width (100%) - focus on ratios within the bar
|
||||
// When maxValue > 0, scale relative to max - focus on size comparison
|
||||
const widthPercent = maxValue > 0 ? (total / maxValue) * 100 : 100;
|
||||
|
||||
// Pre-compute segment metadata for tooltips
|
||||
const segmentsWithMeta = segments
|
||||
.filter(s => s.value > 0)
|
||||
.map(seg => ({
|
||||
...seg,
|
||||
percent: Math.round((seg.value / total) * 100),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div
|
||||
className="relative rounded overflow-hidden bg-muted/30 flex-1"
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div
|
||||
className="h-full flex transition-all duration-300"
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
>
|
||||
{segmentsWithMeta.map((seg) => (
|
||||
<Tooltip key={seg.key}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`h-full ${seg.color} transition-all duration-150 hover:brightness-110 hover:scale-y-[1.15] origin-bottom cursor-default`}
|
||||
style={{ width: `${(seg.value / total) * 100}%` }}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2 h-2 rounded-sm ${seg.color} flex-shrink-0`} />
|
||||
<span className="font-medium">{seg.label}</span>
|
||||
<span className="opacity-80">{formatTokenCount(seg.value)} ({seg.percent}%)</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{renderLabel?.(total, segments)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to create token segments (input/output)
|
||||
function createTokenSegments(input: number, output: number): BarSegment[] {
|
||||
return [
|
||||
{ key: "input", value: input, color: SEGMENT_COLORS.input, label: "Input" },
|
||||
{ key: "output", value: output, color: SEGMENT_COLORS.output, label: "Output" },
|
||||
];
|
||||
}
|
||||
|
||||
// Helper to create composition segments
|
||||
function createCompositionSegments(composition: ContextComposition): BarSegment[] {
|
||||
return [
|
||||
{ key: "system", value: composition.system, color: SEGMENT_COLORS.system, label: "System" },
|
||||
{ key: "user", value: composition.user, color: SEGMENT_COLORS.user, label: "User" },
|
||||
{ key: "assistant", value: composition.assistant, color: SEGMENT_COLORS.assistant, label: "Assistant" },
|
||||
{ key: "toolCalls", value: composition.toolCalls, color: SEGMENT_COLORS.toolCalls, label: "Tool Calls" },
|
||||
{ key: "toolResults", value: composition.toolResults, color: SEGMENT_COLORS.toolResults, label: "Tool Results" },
|
||||
];
|
||||
}
|
||||
|
||||
// Composition breakdown list
|
||||
function CompositionBreakdown({
|
||||
composition,
|
||||
className = "",
|
||||
}: {
|
||||
composition: ContextComposition;
|
||||
className?: string;
|
||||
}) {
|
||||
const { system, user, assistant, toolCalls, toolResults, total } = composition;
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className={`text-xs text-muted-foreground ${className}`}>
|
||||
No composition data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ label: "System", value: system, color: SEGMENT_COLORS.system },
|
||||
{ label: "User", value: user, color: SEGMENT_COLORS.user },
|
||||
{ label: "Assistant", value: assistant, color: SEGMENT_COLORS.assistant },
|
||||
{ label: "Tool Calls", value: toolCalls, color: SEGMENT_COLORS.toolCalls },
|
||||
{ label: "Tool Results", value: toolResults, color: SEGMENT_COLORS.toolResults },
|
||||
].filter(item => item.value > 0);
|
||||
|
||||
return (
|
||||
<div className={`space-y-1.5 ${className}`}>
|
||||
{items.map((item) => {
|
||||
const percent = Math.round((item.value / total) * 100);
|
||||
return (
|
||||
<div key={item.label} className="flex items-center gap-2 text-xs">
|
||||
<div className={`w-2 h-2 rounded-sm ${item.color}`} />
|
||||
<span className="text-muted-foreground w-20">{item.label}</span>
|
||||
<div className="flex-1 h-3 bg-muted/30 rounded overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${item.color} transition-all duration-300`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono w-10 text-right text-muted-foreground">
|
||||
{percent}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Turn row component
|
||||
function TurnRow({
|
||||
turn,
|
||||
index,
|
||||
maxValue,
|
||||
maxCompositionValue,
|
||||
cumulativeInput,
|
||||
cumulativeOutput,
|
||||
cumulativeComposition,
|
||||
showCumulative,
|
||||
viewMode,
|
||||
}: {
|
||||
turn: TurnData;
|
||||
index: number;
|
||||
maxValue: number;
|
||||
maxCompositionValue: number;
|
||||
cumulativeInput: number;
|
||||
cumulativeOutput: number;
|
||||
cumulativeComposition: ContextComposition;
|
||||
showCumulative: boolean;
|
||||
viewMode: "tokens" | "composition";
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const displayInput = showCumulative ? cumulativeInput : turn.input_tokens;
|
||||
const displayOutput = showCumulative ? cumulativeOutput : turn.output_tokens;
|
||||
const displayComposition = showCumulative ? cumulativeComposition : turn.composition;
|
||||
|
||||
const timestamp = new Date(turn.timestamp * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="border-b border-muted/50 last:border-0">
|
||||
<div
|
||||
className="flex items-center gap-3 py-2 px-2 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{/* Turn number */}
|
||||
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{viewMode === "tokens" ? (
|
||||
<SegmentedBar
|
||||
segments={createTokenSegments(displayInput, displayOutput)}
|
||||
maxValue={maxValue}
|
||||
height={20}
|
||||
renderLabel={(_, segs) => (
|
||||
<div className="flex items-center gap-1 text-xs font-mono text-muted-foreground min-w-[80px] justify-end">
|
||||
<span className="text-blue-600 dark:text-blue-400">↑{formatTokenCount(segs[0]?.value || 0)}</span>
|
||||
<span>/</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">↓{formatTokenCount(segs[1]?.value || 0)}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<SegmentedBar
|
||||
segments={createCompositionSegments(displayComposition)}
|
||||
maxValue={maxCompositionValue}
|
||||
height={20}
|
||||
renderLabel={(total) => (
|
||||
<div className="text-xs font-mono text-muted-foreground min-w-[50px] text-right">
|
||||
{formatTokenCount(Math.round(total / 4))}~
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expand icon */}
|
||||
<div className="text-muted-foreground flex-shrink-0">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{isExpanded && (
|
||||
<div className="pb-3">
|
||||
{/* Connector line */}
|
||||
<div className="flex items-start gap-3 px-2">
|
||||
<div className="w-6 flex justify-center flex-shrink-0">
|
||||
<div className="w-px h-full bg-muted" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* L-connector and composition */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="text-muted-foreground text-xs mt-1">└─</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* Basic info */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<div>Time: <span className="font-mono text-foreground">{timestamp}</span></div>
|
||||
<div>Duration: <span className="font-mono text-foreground">{turn.duration_ms.toFixed(0)}ms</span></div>
|
||||
{turn.model && (
|
||||
<div>Model: <span className="font-mono text-foreground">{turn.model}</span></div>
|
||||
)}
|
||||
{turn.entity_id && (
|
||||
<div>Entity: <span className="font-mono text-foreground">{turn.entity_id}</span></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Token counts - shown in tokens mode */}
|
||||
{viewMode === "tokens" && (
|
||||
<div className="flex gap-4 text-xs">
|
||||
<div>
|
||||
<span className="text-blue-600 dark:text-blue-400">Input:</span>{" "}
|
||||
<span className="font-mono">{turn.input_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">Output:</span>{" "}
|
||||
<span className="font-mono">{turn.output_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total:</span>{" "}
|
||||
<span className="font-mono">{turn.total_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Composition breakdown - shown in composition mode */}
|
||||
{viewMode === "composition" && turn.composition.total > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
Context Composition (estimated from ~{formatTokenCount(Math.round(turn.composition.total / 4))} tokens)
|
||||
</div>
|
||||
<CompositionBreakdown composition={turn.composition} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Summary stats card
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
color = "default",
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: typeof BarChart3;
|
||||
color?: "default" | "blue" | "green";
|
||||
}) {
|
||||
const colorClass = {
|
||||
default: "text-muted-foreground",
|
||||
blue: "text-blue-600 dark:text-blue-400",
|
||||
green: "text-emerald-600 dark:text-emerald-400",
|
||||
}[color];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/30 rounded">
|
||||
<Icon className={`h-4 w-4 ${colorClass}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-muted-foreground truncate">{label}</div>
|
||||
<div className="font-mono text-sm font-medium">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main component
|
||||
export function ContextInspector({ events }: ContextInspectorProps) {
|
||||
// Use persisted store state instead of local useState
|
||||
const viewMode = useDevUIStore((state) => state.contextInspectorViewMode);
|
||||
const setViewMode = useDevUIStore((state) => state.setContextInspectorViewMode);
|
||||
const showCumulative = useDevUIStore((state) => state.contextInspectorCumulative);
|
||||
const setShowCumulative = useDevUIStore((state) => state.setContextInspectorCumulative);
|
||||
|
||||
// Extract turn data from traces
|
||||
const turns = useMemo(() => extractTurnData(events), [events]);
|
||||
|
||||
// Calculate stats
|
||||
const stats = useMemo(() => calculateStats(turns), [turns]);
|
||||
|
||||
// Aggregate composition
|
||||
const totalComposition = useMemo(() => aggregateComposition(turns), [turns]);
|
||||
|
||||
// Calculate max value for bar scaling (tokens)
|
||||
// In non-cumulative mode, use 0 to signal full-width bars (focus on ratios)
|
||||
// In cumulative mode, scale relative to total (focus on growth)
|
||||
const maxValue = useMemo(() => {
|
||||
if (turns.length === 0) return 0;
|
||||
|
||||
if (showCumulative) {
|
||||
return stats.totalTokens;
|
||||
} else {
|
||||
// Return 0 to signal "use full width" - each bar shows its own ratio
|
||||
return 0;
|
||||
}
|
||||
}, [turns, showCumulative, stats.totalTokens]);
|
||||
|
||||
// Calculate max value for composition bar scaling
|
||||
// Same logic: full-width in non-cumulative, scaled in cumulative
|
||||
const maxCompositionValue = useMemo(() => {
|
||||
if (turns.length === 0) return 0;
|
||||
|
||||
if (showCumulative) {
|
||||
return totalComposition.total;
|
||||
} else {
|
||||
// Return 0 to signal "use full width"
|
||||
return 0;
|
||||
}
|
||||
}, [turns, showCumulative, totalComposition.total]);
|
||||
|
||||
// Calculate cumulative values for tokens and composition
|
||||
const cumulativeData = useMemo(() => {
|
||||
let cumInput = 0;
|
||||
let cumOutput = 0;
|
||||
let cumComposition: ContextComposition = {
|
||||
system: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0
|
||||
};
|
||||
|
||||
return turns.map(t => {
|
||||
cumInput += t.input_tokens;
|
||||
cumOutput += t.output_tokens;
|
||||
cumComposition = {
|
||||
system: cumComposition.system + t.composition.system,
|
||||
user: cumComposition.user + t.composition.user,
|
||||
assistant: cumComposition.assistant + t.composition.assistant,
|
||||
toolCalls: cumComposition.toolCalls + t.composition.toolCalls,
|
||||
toolResults: cumComposition.toolResults + t.composition.toolResults,
|
||||
total: cumComposition.total + t.composition.total,
|
||||
};
|
||||
return {
|
||||
input: cumInput,
|
||||
output: cumOutput,
|
||||
composition: { ...cumComposition }
|
||||
};
|
||||
});
|
||||
}, [turns]);
|
||||
|
||||
// No data state
|
||||
if (turns.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center p-6 pt-9">
|
||||
<BarChart3 className="h-8 w-8 text-muted-foreground mb-3" />
|
||||
<div className="text-sm font-medium mb-1">No Data</div>
|
||||
<div className="text-xs text-muted-foreground max-w-[200px]">
|
||||
Run{" "}
|
||||
<span className="font-mono bg-accent/10 px-1 rounded">
|
||||
devui --instrumentation
|
||||
</span>{" "}
|
||||
and start a conversation.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b flex-shrink-0 space-y-2">
|
||||
{/* Title row */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
<span className="font-medium text-sm">Context Inspector</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{turns.length} turn{turns.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Cumulative checkbox */}
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<Checkbox
|
||||
checked={showCumulative}
|
||||
onCheckedChange={(checked) => setShowCumulative(checked === true)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span>Cumulative</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* View mode segmented control */}
|
||||
<div className="flex items-center bg-muted rounded-md p-1">
|
||||
<button
|
||||
onClick={() => setViewMode("tokens")}
|
||||
className={`flex-1 px-3 py-1.5 text-xs rounded transition-colors ${
|
||||
viewMode === "tokens"
|
||||
? "bg-background shadow-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Tokens
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("composition")}
|
||||
className={`flex-1 px-3 py-1.5 text-xs rounded transition-colors ${
|
||||
viewMode === "composition"
|
||||
? "bg-background shadow-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Composition
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View mode description */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{viewMode === "tokens"
|
||||
? "Token usage per turn"
|
||||
: "Context breakdown by message type (chars)"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3 space-y-4">
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 text-xs px-1 flex-wrap">
|
||||
{viewMode === "tokens" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-3 h-3 rounded ${SEGMENT_COLORS.input}`} />
|
||||
<span className="text-muted-foreground">Input (↑)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-3 h-3 rounded ${SEGMENT_COLORS.output}`} />
|
||||
<span className="text-muted-foreground">Output (↓)</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.system}`} />
|
||||
<span className="text-muted-foreground">System</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.user}`} />
|
||||
<span className="text-muted-foreground">User</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.assistant}`} />
|
||||
<span className="text-muted-foreground">Assistant</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.toolCalls}`} />
|
||||
<span className="text-muted-foreground">Tools</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.toolResults}`} />
|
||||
<span className="text-muted-foreground">Results</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Info className="h-3 w-3" />
|
||||
<span>Click for details</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Turn bars */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{turns.map((turn, index) => (
|
||||
<TurnRow
|
||||
key={turn.response_id}
|
||||
turn={turn}
|
||||
index={index}
|
||||
maxValue={maxValue}
|
||||
maxCompositionValue={maxCompositionValue}
|
||||
cumulativeInput={cumulativeData[index]?.input || 0}
|
||||
cumulativeOutput={cumulativeData[index]?.output || 0}
|
||||
cumulativeComposition={cumulativeData[index]?.composition || turn.composition}
|
||||
showCumulative={showCumulative}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Session summary */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="p-3 bg-muted/30 border-b">
|
||||
<span className="text-xs font-medium">Session Summary</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Token summary cards */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatCard
|
||||
label="Total Tokens"
|
||||
value={formatTokenCount(stats.totalTokens)}
|
||||
icon={Layers}
|
||||
/>
|
||||
<StatCard
|
||||
label="Input"
|
||||
value={formatTokenCount(stats.totalInput)}
|
||||
icon={BarChart3}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label="Output"
|
||||
value={formatTokenCount(stats.totalOutput)}
|
||||
icon={BarChart3}
|
||||
color="green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Per-turn statistics (only for multi-turn sessions) */}
|
||||
{turns.length > 1 && (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs pt-2 border-t border-muted/50">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Avg per turn:</span>
|
||||
<span className="font-mono">{formatTokenCount(stats.avgTotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Peak turn:</span>
|
||||
<span className="font-mono">{formatTokenCount(stats.peakTotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Avg input:</span>
|
||||
<span className="font-mono text-blue-600 dark:text-blue-400">{formatTokenCount(stats.avgInput)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Avg output:</span>
|
||||
<span className="font-mono text-emerald-600 dark:text-emerald-400">{formatTokenCount(stats.avgOutput)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total composition */}
|
||||
{totalComposition.total > 0 && (
|
||||
<div className="pt-3 border-t border-muted/50">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="text-muted-foreground text-xs mt-0.5">└─</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
Total Composition (all turns)
|
||||
</div>
|
||||
<CompositionBreakdown composition={totalComposition} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+13
-6
@@ -133,10 +133,10 @@ function useBase64ToBlobUrl(data: string | undefined, mimeType: string): string
|
||||
function FileContentRenderer({ content, className }: ContentRendererProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
if (content.type !== "input_file" && content.type !== "output_file") return null;
|
||||
|
||||
const fileUrl = content.file_url || content.file_data;
|
||||
const filename = content.filename || "file";
|
||||
// Determine file properties (must be before hooks for conditional logic)
|
||||
const isFileContent = content.type === "input_file" || content.type === "output_file";
|
||||
const fileUrl = isFileContent ? (content.file_url || content.file_data) : undefined;
|
||||
const filename = isFileContent ? (content.filename || "file") : undefined;
|
||||
|
||||
// Determine file type from filename or data URI
|
||||
const isPdf = filename?.toLowerCase().endsWith(".pdf") || fileUrl?.includes("application/pdf");
|
||||
@@ -144,9 +144,13 @@ function FileContentRenderer({ content, className }: ContentRendererProps) {
|
||||
|
||||
// Convert base64 to blob URL for PDFs (better browser compatibility)
|
||||
// Use file_data (raw base64) if available, otherwise try file_url
|
||||
const pdfData = isPdf ? (content.file_data || content.file_url) : undefined;
|
||||
// Hook must be called unconditionally - pass undefined if not a PDF
|
||||
const pdfData = (isFileContent && isPdf) ? (content.file_data || content.file_url) : undefined;
|
||||
const pdfBlobUrl = useBase64ToBlobUrl(pdfData, 'application/pdf');
|
||||
|
||||
// Early return after all hooks
|
||||
if (!isFileContent) return null;
|
||||
|
||||
// Use blob URL if available, otherwise fall back to original URL
|
||||
const effectivePdfUrl = pdfBlobUrl || fileUrl;
|
||||
|
||||
@@ -299,9 +303,12 @@ function DataContentRenderer({ content, className }: ContentRendererProps) {
|
||||
|
||||
// Function approval request renderer - compact version
|
||||
function FunctionApprovalRequestRenderer({ content, className }: ContentRendererProps) {
|
||||
// Hooks must be called unconditionally
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Early return after hooks
|
||||
if (content.type !== "function_approval_request") return null;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { status, function_call } = content;
|
||||
|
||||
// Status styling - compact
|
||||
|
||||
+5
-5
@@ -23,7 +23,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/services/api";
|
||||
import type { CheckpointItem, WorkflowSession } from "@/types";
|
||||
import type { CheckpointItem, WorkflowSession, FullCheckpoint, PendingRequestInfoEvent } from "@/types";
|
||||
|
||||
interface CheckpointInfoModalProps {
|
||||
session: WorkflowSession | null;
|
||||
@@ -39,7 +39,7 @@ export function CheckpointInfoModal({
|
||||
onOpenChange,
|
||||
}: CheckpointInfoModalProps) {
|
||||
const [selectedCheckpointId, setSelectedCheckpointId] = useState<string | null>(null);
|
||||
const [fullCheckpoint, setFullCheckpoint] = useState<any>(null);
|
||||
const [fullCheckpoint, setFullCheckpoint] = useState<FullCheckpoint | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [jsonExpanded, setJsonExpanded] = useState(true);
|
||||
|
||||
@@ -68,7 +68,7 @@ export function CheckpointInfoModal({
|
||||
session.conversation_id,
|
||||
`checkpoint_${selectedCheckpointId}`
|
||||
);
|
||||
setFullCheckpoint((item as CheckpointItem).metadata?.full_checkpoint);
|
||||
setFullCheckpoint((item as CheckpointItem).metadata?.full_checkpoint ?? null);
|
||||
} catch (error) {
|
||||
console.error("Failed to load checkpoint:", error);
|
||||
setFullCheckpoint(null);
|
||||
@@ -276,7 +276,7 @@ export function CheckpointInfoModal({
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{messageExecutors.length > 0 && (
|
||||
{messageExecutors.length > 0 && fullCheckpoint && (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
@@ -311,7 +311,7 @@ export function CheckpointInfoModal({
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(fullCheckpoint.pending_request_info_events).map(
|
||||
([reqId, reqData]: [string, any]) => (
|
||||
([reqId, reqData]: [string, PendingRequestInfoEvent]) => (
|
||||
<div
|
||||
key={reqId}
|
||||
className="bg-muted/50 border border-border p-3 rounded-lg"
|
||||
|
||||
+63
-35
@@ -9,6 +9,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HilTimelineItem } from "./hil-timeline-item";
|
||||
import { RunWorkflowButton } from "./run-workflow-button";
|
||||
import { ChatMessageInput } from "@/components/ui/chat-message-input";
|
||||
import { isChatMessageSchema } from "@/utils/workflow-utils";
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import type { ExtendedResponseStreamEvent, JSONSchemaProperty } from "@/types";
|
||||
import type { ResponseInputContent } from "@/types/agent-framework";
|
||||
import type { ExecutorState } from "./executor-node";
|
||||
import { truncateText } from "@/utils/workflow-utils";
|
||||
|
||||
@@ -262,8 +265,8 @@ export function ExecutionTimeline({
|
||||
const item = (event as import("@/types/openai").ResponseOutputItemAddedEvent).item;
|
||||
|
||||
// Handle both executor_action items AND message items from Magentic agents
|
||||
if (item && item.type === "executor_action" && item.executor_id && item.id) {
|
||||
const executorId = item.executor_id;
|
||||
if (item && item.type === "executor_action" && "executor_id" in item && item.id) {
|
||||
const executorId = String(item.executor_id);
|
||||
const itemId = item.id;
|
||||
const runNumber = (runCount.get(executorId) || 0) + 1;
|
||||
runCount.set(executorId, runNumber);
|
||||
@@ -277,22 +280,25 @@ export function ExecutionTimeline({
|
||||
timestamp: uiTimestamp,
|
||||
runNumber,
|
||||
});
|
||||
} else if (item && item.type === "message" && item.metadata?.agent_id && item.metadata?.source === "magentic" && item.id) {
|
||||
} else if (item && item.type === "message" && "metadata" in item && item.id) {
|
||||
// Handle message items from Magentic agents
|
||||
const executorId = item.metadata.agent_id;
|
||||
const itemId = item.id;
|
||||
const runNumber = (runCount.get(executorId) || 0) + 1;
|
||||
runCount.set(executorId, runNumber);
|
||||
const metadata = item.metadata as { agent_id?: string; source?: string } | undefined;
|
||||
if (metadata?.agent_id && metadata?.source === "magentic") {
|
||||
const executorId = metadata.agent_id;
|
||||
const itemId = item.id;
|
||||
const runNumber = (runCount.get(executorId) || 0) + 1;
|
||||
runCount.set(executorId, runNumber);
|
||||
|
||||
runs.push({
|
||||
executorId,
|
||||
executorName: truncateText(executorId, 35),
|
||||
itemId,
|
||||
state: "running",
|
||||
output: itemOutputs[itemId] || "",
|
||||
timestamp: uiTimestamp,
|
||||
runNumber,
|
||||
});
|
||||
runs.push({
|
||||
executorId,
|
||||
executorName: truncateText(executorId, 35),
|
||||
itemId,
|
||||
state: "running",
|
||||
output: itemOutputs[itemId] || "",
|
||||
timestamp: uiTimestamp,
|
||||
runNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +307,7 @@ export function ExecutionTimeline({
|
||||
const item = (event as import("@/types/openai").ResponseOutputItemDoneEvent).item;
|
||||
|
||||
// Handle both executor_action items AND message items from Magentic agents
|
||||
if (item && item.type === "executor_action" && item.executor_id && item.id) {
|
||||
if (item && item.type === "executor_action" && "executor_id" in item && item.id) {
|
||||
const itemId = item.id;
|
||||
// Find the run by ITEM ID (not executor ID!) to handle multiple runs correctly
|
||||
const existingRun = runs.find((r) => r.itemId === itemId);
|
||||
@@ -315,18 +321,21 @@ export function ExecutionTimeline({
|
||||
: "completed";
|
||||
// Use item-specific output, not executor-wide output
|
||||
existingRun.output = itemOutputs[itemId] || "";
|
||||
if (item.status === "failed" && item.error) {
|
||||
existingRun.error = item.error;
|
||||
if (item.status === "failed" && "error" in item && item.error) {
|
||||
existingRun.error = String(item.error);
|
||||
}
|
||||
}
|
||||
} else if (item && item.type === "message" && item.metadata?.agent_id && item.metadata?.source === "magentic" && item.id) {
|
||||
} else if (item && item.type === "message" && "metadata" in item && item.id) {
|
||||
// Handle message completion from Magentic agents
|
||||
const itemId = item.id;
|
||||
const existingRun = runs.find((r) => r.itemId === itemId);
|
||||
const metadata = item.metadata as { agent_id?: string; source?: string } | undefined;
|
||||
if (metadata?.agent_id && metadata?.source === "magentic") {
|
||||
const itemId = item.id;
|
||||
const existingRun = runs.find((r) => r.itemId === itemId);
|
||||
|
||||
if (existingRun) {
|
||||
existingRun.state = item.status === "completed" ? "completed" : "failed";
|
||||
existingRun.output = itemOutputs[itemId] || "";
|
||||
if (existingRun) {
|
||||
existingRun.state = item.status === "completed" ? "completed" : "failed";
|
||||
existingRun.output = itemOutputs[itemId] || "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -625,16 +634,35 @@ export function ExecutionTimeline({
|
||||
{/* Bottom Control Bar - Sticky (hidden when HIL is active) */}
|
||||
{(onRun || onCancel) && pendingHilRequests.length === 0 && (
|
||||
<div className="border-t p-3 bg-background flex-shrink-0">
|
||||
<RunWorkflowButton
|
||||
inputSchema={inputSchema}
|
||||
onRun={onRun || (() => {})}
|
||||
onCancel={onCancel}
|
||||
isSubmitting={workflowState === "running"}
|
||||
isCancelling={isCancelling}
|
||||
workflowState={workflowState}
|
||||
checkpoints={checkpoints}
|
||||
showCheckpoints={false}
|
||||
/>
|
||||
{inputSchema && isChatMessageSchema(inputSchema) ? (
|
||||
<ChatMessageInput
|
||||
onSubmit={async (content: ResponseInputContent[]) => {
|
||||
// Wrap in OpenAI message format (same as run-workflow-button modal)
|
||||
const openaiInput = [
|
||||
{ type: "message", role: "user", content },
|
||||
];
|
||||
onRun?.(openaiInput as unknown as Record<string, unknown>);
|
||||
}}
|
||||
isSubmitting={workflowState === "running"}
|
||||
isStreaming={workflowState === "running"}
|
||||
onCancel={onCancel}
|
||||
isCancelling={isCancelling}
|
||||
placeholder="Message workflow..."
|
||||
showFileUpload={true}
|
||||
entityName="workflow"
|
||||
/>
|
||||
) : (
|
||||
<RunWorkflowButton
|
||||
inputSchema={inputSchema}
|
||||
onRun={onRun || (() => {})}
|
||||
onCancel={onCancel}
|
||||
isSubmitting={workflowState === "running"}
|
||||
isCancelling={isCancelling}
|
||||
workflowState={workflowState}
|
||||
checkpoints={checkpoints}
|
||||
showCheckpoints={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { memo } from "react";
|
||||
import { memo, useState } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import {
|
||||
Workflow,
|
||||
Home,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { truncateText } from "@/utils/workflow-utils";
|
||||
@@ -70,8 +72,9 @@ const getExecutorStateConfig = (state: ExecutorState) => {
|
||||
export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
const nodeData = data as ExecutorNodeData;
|
||||
const config = getExecutorStateConfig(nodeData.state);
|
||||
const [isOutputExpanded, setIsOutputExpanded] = useState(false);
|
||||
|
||||
const hasData = nodeData.inputData || nodeData.outputData || nodeData.error;
|
||||
const hasOutput = nodeData.outputData || nodeData.error;
|
||||
const isRunning = nodeData.state === "running";
|
||||
const shouldAnimate = isRunning && (nodeData.isStreaming ?? true); // Default to true for backwards compatibility
|
||||
|
||||
@@ -80,19 +83,13 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
const targetPosition = isVertical ? Position.Top : Position.Left;
|
||||
const sourcePosition = isVertical ? Position.Bottom : Position.Right;
|
||||
|
||||
// Helper to safely render data with full details
|
||||
// Helper to render output/error details when expanded
|
||||
const renderDataDetails = () => {
|
||||
const details = [];
|
||||
|
||||
if (nodeData.error && typeof nodeData.error === "string") {
|
||||
// Truncate error to first 150 characters for node display
|
||||
const truncatedError = truncateText(nodeData.error, 150);
|
||||
details.push(
|
||||
<div key="error" className="mb-2">
|
||||
<div className="text-xs font-medium text-red-600 dark:text-red-400 mb-1">Error:</div>
|
||||
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800 break-words">
|
||||
{truncatedError}
|
||||
</div>
|
||||
const truncatedError = truncateText(nodeData.error, 200);
|
||||
return (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800 break-words max-h-32 overflow-auto">
|
||||
{truncatedError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -103,53 +100,21 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
typeof nodeData.outputData === "string"
|
||||
? nodeData.outputData
|
||||
: JSON.stringify(nodeData.outputData, null, 2);
|
||||
details.push(
|
||||
<div key="output" className="mb-2">
|
||||
<div className="text-xs font-medium text-green-600 dark:text-green-400 mb-1">Output:</div>
|
||||
<div className="text-xs text-gray-700 dark:text-gray-300 bg-green-50 dark:bg-green-950/20 p-2 rounded border border-green-200 dark:border-green-800 max-h-20 overflow-auto">
|
||||
<pre className="whitespace-pre-wrap font-mono">{outputStr}</pre>
|
||||
</div>
|
||||
return (
|
||||
<div className="text-xs text-gray-700 dark:text-gray-300 bg-muted/50 p-2 rounded border max-h-32 overflow-auto">
|
||||
<pre className="whitespace-pre-wrap font-mono">{outputStr}</pre>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
details.push(
|
||||
<div key="output" className="mb-2">
|
||||
<div className="text-xs font-medium text-green-600 dark:text-green-400 mb-1">Output:</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 bg-green-50 dark:bg-green-950/20 p-2 rounded border border-green-200 dark:border-green-800">
|
||||
[Unable to display output data]
|
||||
</div>
|
||||
return (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 bg-muted/50 p-2 rounded border">
|
||||
[Unable to display output]
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeData.inputData) {
|
||||
try {
|
||||
const inputStr =
|
||||
typeof nodeData.inputData === "string"
|
||||
? nodeData.inputData
|
||||
: JSON.stringify(nodeData.inputData, null, 2);
|
||||
details.push(
|
||||
<div key="input" className="mb-2">
|
||||
<div className="text-xs font-medium text-blue-600 dark:text-blue-400 mb-1">Input:</div>
|
||||
<div className="text-xs text-gray-700 dark:text-gray-300 bg-blue-50 dark:bg-blue-950/20 p-2 rounded border border-blue-200 dark:border-blue-800 max-h-20 overflow-auto">
|
||||
<pre className="whitespace-pre-wrap font-mono">{inputStr}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
details.push(
|
||||
<div key="input" className="mb-2">
|
||||
<div className="text-xs font-medium text-blue-600 dark:text-blue-400 mb-1">Input:</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 bg-blue-50 dark:bg-blue-950/20 p-2 rounded border border-blue-200 dark:border-blue-800">
|
||||
[Unable to display input data]
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return details.length > 0 ? details : null;
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -218,10 +183,28 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data details */}
|
||||
{hasData && (
|
||||
<div className="mt-3">
|
||||
{renderDataDetails()}
|
||||
{/* Collapsible output section */}
|
||||
{hasOutput && (
|
||||
<div className="mt-2 border-t border-border/50 pt-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOutputExpanded(!isOutputExpanded);
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-full"
|
||||
>
|
||||
{isOutputExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
<span>{nodeData.error ? "Show error" : "Show output"}</span>
|
||||
</button>
|
||||
{isOutputExpanded && (
|
||||
<div className="mt-2">
|
||||
{renderDataDetails()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+116
-17
@@ -110,6 +110,7 @@ export function WorkflowView({
|
||||
const removeSession = useDevUIStore((state) => state.removeSession);
|
||||
const addToast = useDevUIStore((state) => state.addToast);
|
||||
const runtime = useDevUIStore((state) => state.runtime);
|
||||
const streamingEnabled = useDevUIStore((state) => state.streamingEnabled);
|
||||
|
||||
// View options state
|
||||
const [viewOptions, setViewOptions] = useState(() => {
|
||||
@@ -304,8 +305,9 @@ export function WorkflowView({
|
||||
{ limit: 100 }
|
||||
);
|
||||
const checkpointItems = response.data.filter(
|
||||
(item: any) => item.type === "checkpoint"
|
||||
) as CheckpointItem[];
|
||||
(item): item is CheckpointItem =>
|
||||
typeof item === "object" && item !== null && "type" in item && (item as { type: string }).type === "checkpoint"
|
||||
);
|
||||
setSessionCheckpoints(checkpointItems);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load checkpoints for session ${currentSession.conversation_id}:`, error);
|
||||
@@ -453,9 +455,9 @@ export function WorkflowView({
|
||||
| import("@/types/openai").ResponseOutputItemAddedEvent
|
||||
| import("@/types/openai").ResponseOutputItemDoneEvent
|
||||
).item;
|
||||
if (item && item.type === "executor_action" && item.executor_id) {
|
||||
if (item && item.type === "executor_action" && "executor_id" in item && item.executor_id) {
|
||||
history.push({
|
||||
executorId: item.executor_id,
|
||||
executorId: String(item.executor_id),
|
||||
message:
|
||||
event.type === "response.output_item.added"
|
||||
? "Executor started"
|
||||
@@ -624,7 +626,8 @@ export function WorkflowView({
|
||||
if (
|
||||
item &&
|
||||
item.type === "message" &&
|
||||
item.metadata?.source === "magentic" &&
|
||||
"metadata" in item &&
|
||||
(item.metadata as { source?: string } | undefined)?.source === "magentic" &&
|
||||
item.id
|
||||
) {
|
||||
// Track this message ID as the current streaming target for Magentic agents
|
||||
@@ -639,19 +642,21 @@ export function WorkflowView({
|
||||
if (
|
||||
item &&
|
||||
item.type === "message" &&
|
||||
!item.metadata?.source &&
|
||||
item.content
|
||||
(!("metadata" in item) || !(item.metadata as { source?: string } | undefined)?.source) &&
|
||||
"content" in item &&
|
||||
Array.isArray(item.content)
|
||||
) {
|
||||
// Extract text from message content
|
||||
for (const content of item.content) {
|
||||
for (const content of item.content as Array<{ type: string; text?: string }>) {
|
||||
if (content.type === "output_text" && content.text) {
|
||||
const text = content.text; // Capture for closure
|
||||
// Append to workflow result (support multiple yield_output calls)
|
||||
setWorkflowResult((prev) => {
|
||||
if (prev && prev.length > 0) {
|
||||
// If there's existing output, add separator
|
||||
return prev + "\n\n" + content.text;
|
||||
return prev + "\n\n" + text;
|
||||
}
|
||||
return content.text;
|
||||
return text;
|
||||
});
|
||||
|
||||
// Try to parse as JSON for structured metadata
|
||||
@@ -820,6 +825,99 @@ export function WorkflowView({
|
||||
]
|
||||
);
|
||||
|
||||
// Handle non-streaming workflow data sending
|
||||
const handleSendWorkflowDataSync = useCallback(
|
||||
async (inputData: Record<string, unknown>, checkpointId?: string) => {
|
||||
if (!selectedWorkflow || selectedWorkflow.type !== "workflow") return;
|
||||
|
||||
setIsStreaming(false); // Not actually streaming
|
||||
setWasCancelled(false);
|
||||
setOpenAIEvents([]);
|
||||
setWorkflowResult("");
|
||||
itemOutputs.current = {};
|
||||
currentStreamingItemId.current = null;
|
||||
workflowMetadata.current = null;
|
||||
setPendingHilRequests([]);
|
||||
setHilResponses({});
|
||||
onDebugEvent("clear");
|
||||
|
||||
try {
|
||||
const response = await apiClient.runWorkflowSync(selectedWorkflow.id, {
|
||||
input_data: inputData,
|
||||
conversation_id: currentSession?.conversation_id || undefined,
|
||||
checkpoint_id: checkpointId,
|
||||
});
|
||||
|
||||
// Extract workflow result from response output
|
||||
if (response.output) {
|
||||
for (const outputItem of response.output) {
|
||||
if (outputItem.type === "message" && "content" in outputItem && Array.isArray(outputItem.content)) {
|
||||
for (const content of outputItem.content as Array<{ type: string; text?: string }>) {
|
||||
if (content.type === "output_text" && content.text) {
|
||||
setWorkflowResult((prev) => {
|
||||
if (prev && prev.length > 0) {
|
||||
return prev + "\n\n" + content.text;
|
||||
}
|
||||
return content.text || "";
|
||||
});
|
||||
|
||||
// Try to parse as JSON for structured metadata
|
||||
try {
|
||||
const parsed = JSON.parse(content.text || "");
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
workflowMetadata.current = parsed;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, keep as text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a synthetic completion event for the timeline
|
||||
const completedEvent = {
|
||||
type: "response.completed",
|
||||
response: response,
|
||||
sequence_number: 0,
|
||||
} as ExtendedResponseStreamEvent;
|
||||
setOpenAIEvents([completedEvent]);
|
||||
onDebugEvent(completedEvent);
|
||||
|
||||
// Refetch checkpoints after completion
|
||||
await loadCheckpoints();
|
||||
} catch (error) {
|
||||
console.error("Workflow execution error:", error);
|
||||
|
||||
// Create a synthetic error event for the timeline
|
||||
const errorMessage = error instanceof Error ? error.message : "Workflow execution failed";
|
||||
const errorEvent: ExtendedResponseStreamEvent = {
|
||||
type: "response.failed",
|
||||
response: {
|
||||
error: { message: errorMessage },
|
||||
},
|
||||
sequence_number: 0,
|
||||
} as ExtendedResponseStreamEvent;
|
||||
setOpenAIEvents([errorEvent]);
|
||||
onDebugEvent(errorEvent);
|
||||
}
|
||||
},
|
||||
[selectedWorkflow, currentSession, onDebugEvent, loadCheckpoints]
|
||||
);
|
||||
|
||||
// Wrapper to choose between streaming and non-streaming
|
||||
const handleWorkflowRun = useCallback(
|
||||
async (inputData: Record<string, unknown>, checkpointId?: string) => {
|
||||
if (streamingEnabled) {
|
||||
await handleSendWorkflowData(inputData, checkpointId);
|
||||
} else {
|
||||
await handleSendWorkflowDataSync(inputData, checkpointId);
|
||||
}
|
||||
},
|
||||
[streamingEnabled, handleSendWorkflowData, handleSendWorkflowDataSync]
|
||||
);
|
||||
|
||||
// Check if all HIL responses are valid
|
||||
const areAllHilResponsesValid = useCallback(() => {
|
||||
// Check each pending request has a valid response
|
||||
@@ -979,22 +1077,23 @@ export function WorkflowView({
|
||||
}
|
||||
|
||||
// Handle workflow output messages
|
||||
if (item && item.type === "message" && item.content) {
|
||||
if (item && item.type === "message" && "content" in item && Array.isArray(item.content)) {
|
||||
// Extract text from message content
|
||||
for (const content of item.content) {
|
||||
for (const content of item.content as Array<{ type: string; text?: string }>) {
|
||||
if (content.type === "output_text" && content.text) {
|
||||
const text = content.text; // Capture for closure
|
||||
// Append to workflow result (support multiple yield_output calls)
|
||||
setWorkflowResult((prev) => {
|
||||
if (prev && prev.length > 0) {
|
||||
// If there's existing output, add separator
|
||||
return prev + "\n\n" + content.text;
|
||||
return prev + "\n\n" + text;
|
||||
}
|
||||
return content.text;
|
||||
return text;
|
||||
});
|
||||
|
||||
// Try to parse as JSON for structured metadata
|
||||
try {
|
||||
const parsed = JSON.parse(content.text);
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
workflowMetadata.current = parsed;
|
||||
}
|
||||
@@ -1296,7 +1395,7 @@ export function WorkflowView({
|
||||
{timelineMinimized && (
|
||||
<RunWorkflowButton
|
||||
inputSchema={workflowInfo.input_schema}
|
||||
onRun={handleSendWorkflowData}
|
||||
onRun={handleWorkflowRun}
|
||||
onCancel={handleCancel}
|
||||
isSubmitting={isStreaming}
|
||||
isCancelling={isCancelling}
|
||||
@@ -1481,7 +1580,7 @@ export function WorkflowView({
|
||||
inputSchema={workflowInfo?.input_schema}
|
||||
onRun={(data, checkpointId) => {
|
||||
// Use the form data from timeline
|
||||
handleSendWorkflowData(data, checkpointId);
|
||||
handleWorkflowRun(data, checkpointId);
|
||||
}}
|
||||
onCancel={handleCancel}
|
||||
isCancelling={isCancelling}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* Features: Real-time event streaming, trace visualization, tool call details
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRef, useState, useMemo } from "react";
|
||||
import { useDevUIStore } from "@/stores/devuiStore";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -19,8 +20,9 @@ import {
|
||||
MessageSquare,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Info,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import { ContextInspector } from "@/components/features/agent/context-inspector";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
|
||||
// Simple visual separator component
|
||||
@@ -88,7 +90,23 @@ interface TraceEventData extends EventDataBase {
|
||||
start_time?: number;
|
||||
end_time?: number;
|
||||
entity_id?: string;
|
||||
session_id?: string | null;
|
||||
response_id?: string | null;
|
||||
}
|
||||
|
||||
// Helper type for trace hierarchy
|
||||
interface TraceNode {
|
||||
event: ExtendedResponseStreamEvent;
|
||||
data: TraceEventData;
|
||||
children: TraceNode[];
|
||||
}
|
||||
|
||||
// Helper type for grouped traces by response
|
||||
interface TraceGroup {
|
||||
response_id: string;
|
||||
timestamp: number;
|
||||
traces: TraceNode[];
|
||||
totalDuration: number;
|
||||
entity_id?: string;
|
||||
}
|
||||
|
||||
interface DebugPanelProps {
|
||||
@@ -149,20 +167,22 @@ function processEventsForDisplay(
|
||||
const item = outputEvent.item;
|
||||
|
||||
// If it's a function call item, extract metadata
|
||||
if (item.type === "function_call" && item.call_id && item.name) {
|
||||
const callId = item.call_id;
|
||||
if (item.type === "function_call") {
|
||||
// Type assertion for function call
|
||||
const funcCall = item as import("@/types").ResponseFunctionToolCall;
|
||||
const callId = funcCall.call_id;
|
||||
|
||||
// Initialize function call tracking with REAL function name from backend!
|
||||
functionCalls.set(callId, {
|
||||
name: item.name, // ← REAL NAME! (not "unknown")
|
||||
name: funcCall.name, // ← REAL NAME! (not "unknown")
|
||||
arguments: "",
|
||||
callId: callId,
|
||||
itemId: item.id, // Track item_id for delta matching
|
||||
itemId: funcCall.id, // Track item_id for delta matching
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Also track in callIdToName map for result pairing
|
||||
callIdToName.set(callId, item.name);
|
||||
callIdToName.set(callId, funcCall.name);
|
||||
}
|
||||
|
||||
// Pass through the event for display
|
||||
@@ -955,27 +975,7 @@ function EventExpandedContent({
|
||||
</span>
|
||||
<div className="mt-1 max-h-32 overflow-auto">
|
||||
<pre className="text-xs bg-background border rounded p-2 whitespace-pre-wrap break-all">
|
||||
{(() => {
|
||||
try {
|
||||
// Try to pretty-print JSON, and unescape string values that contain JSON
|
||||
const attrs = { ...data.attributes };
|
||||
Object.keys(attrs).forEach((key) => {
|
||||
if (
|
||||
typeof attrs[key] === "string" &&
|
||||
attrs[key].startsWith("[")
|
||||
) {
|
||||
try {
|
||||
attrs[key] = JSON.parse(attrs[key]);
|
||||
} catch {
|
||||
// Keep original if parsing fails
|
||||
}
|
||||
}
|
||||
});
|
||||
return JSON.stringify(attrs, null, 2);
|
||||
} catch {
|
||||
return JSON.stringify(data.attributes, null, 2);
|
||||
}
|
||||
})()}
|
||||
{formatTraceAttributes(data.attributes)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1149,257 +1149,418 @@ function EventsTab({
|
||||
);
|
||||
}
|
||||
|
||||
function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
// ONLY show actual trace events - handle both event type formats
|
||||
const traceEvents = events.filter(
|
||||
(e) =>
|
||||
e.type === "response.trace.completed" ||
|
||||
e.type === "response.trace.completed"
|
||||
);
|
||||
// Build hierarchical trace structure from flat trace events
|
||||
function buildTraceHierarchy(traceEvents: ExtendedResponseStreamEvent[]): TraceGroup[] {
|
||||
// Group by response_id first
|
||||
const groupedByResponse = new Map<string, ExtendedResponseStreamEvent[]>();
|
||||
|
||||
// Add separators between message rounds
|
||||
const tracesWithSeparators = addSeparatorsToEvents(traceEvents);
|
||||
for (const event of traceEvents) {
|
||||
if (!("data" in event)) continue;
|
||||
const data = event.data as TraceEventData;
|
||||
const responseId = data.response_id || "unknown";
|
||||
|
||||
// Reverse to show latest traces at the top
|
||||
const reversedTraceEvents = [...tracesWithSeparators].reverse();
|
||||
if (!groupedByResponse.has(responseId)) {
|
||||
groupedByResponse.set(responseId, []);
|
||||
}
|
||||
groupedByResponse.get(responseId)!.push(event);
|
||||
}
|
||||
|
||||
// Convert each group to hierarchical structure
|
||||
const groups: TraceGroup[] = [];
|
||||
|
||||
for (const [responseId, events] of groupedByResponse) {
|
||||
// Build tree from parent_span_id relationships
|
||||
const nodeMap = new Map<string, TraceNode>();
|
||||
const rootNodes: TraceNode[] = [];
|
||||
|
||||
// First pass: create all nodes
|
||||
for (const event of events) {
|
||||
if (!("data" in event)) continue;
|
||||
const data = (event as { data: TraceEventData }).data;
|
||||
const spanId = data.span_id || `span_${Math.random()}`;
|
||||
nodeMap.set(spanId, {
|
||||
event,
|
||||
data,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Second pass: build parent-child relationships
|
||||
for (const event of events) {
|
||||
if (!("data" in event)) continue;
|
||||
const data = (event as { data: TraceEventData }).data;
|
||||
const spanId = data.span_id || "";
|
||||
const parentSpanId = data.parent_span_id;
|
||||
const node = nodeMap.get(spanId);
|
||||
|
||||
if (!node) continue;
|
||||
|
||||
if (parentSpanId && nodeMap.has(parentSpanId)) {
|
||||
// Has a parent in this group
|
||||
nodeMap.get(parentSpanId)!.children.push(node);
|
||||
} else {
|
||||
// Root node (no parent or parent not in this group)
|
||||
rootNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort root nodes by start_time (earliest first)
|
||||
rootNodes.sort((a, b) => (a.data.start_time || 0) - (b.data.start_time || 0));
|
||||
|
||||
// Sort children recursively by start_time
|
||||
const sortChildren = (node: TraceNode) => {
|
||||
node.children.sort((a, b) => (a.data.start_time || 0) - (b.data.start_time || 0));
|
||||
node.children.forEach(sortChildren);
|
||||
};
|
||||
rootNodes.forEach(sortChildren);
|
||||
|
||||
// Calculate group metadata
|
||||
const firstEvent = events[0];
|
||||
const firstData = firstEvent && "data" in firstEvent ? (firstEvent.data as TraceEventData) : null;
|
||||
const timestamp = Math.min(...events.map(e => {
|
||||
const d = "data" in e ? (e.data as TraceEventData) : null;
|
||||
return d?.start_time || Date.now() / 1000;
|
||||
}));
|
||||
const totalDuration = events.reduce((sum, e) => {
|
||||
const d = "data" in e ? (e.data as TraceEventData) : null;
|
||||
return sum + (d?.duration_ms || 0);
|
||||
}, 0);
|
||||
|
||||
groups.push({
|
||||
response_id: responseId,
|
||||
timestamp,
|
||||
traces: rootNodes,
|
||||
totalDuration,
|
||||
entity_id: firstData?.entity_id,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort groups by timestamp (newest first)
|
||||
groups.sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// Recursively parse escaped JSON strings at any depth
|
||||
function parseEscapedJson(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
// Try to parse JSON strings (arrays or objects)
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
// Recursively process the parsed result
|
||||
return parseEscapedJson(parsed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(parseEscapedJson);
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === "object") {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
result[k] = parseEscapedJson(v);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// Format trace attributes by parsing escaped JSON strings for better readability
|
||||
function formatTraceAttributes(attributes: Record<string, unknown>): string {
|
||||
try {
|
||||
const formatted = parseEscapedJson(attributes);
|
||||
return JSON.stringify(formatted, null, 2);
|
||||
} catch {
|
||||
return JSON.stringify(attributes, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Get operation type badge color
|
||||
function getOperationColor(operationName: string): string {
|
||||
if (operationName.includes("invoke_agent") || operationName.includes("Agent")) {
|
||||
return "bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200";
|
||||
}
|
||||
if (operationName.includes("chat") || operationName.includes("Chat")) {
|
||||
return "bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200";
|
||||
}
|
||||
if (operationName.includes("tool") || operationName.includes("execute")) {
|
||||
return "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200";
|
||||
}
|
||||
return "bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200";
|
||||
}
|
||||
|
||||
// Recursive component for rendering trace tree nodes
|
||||
function TraceTreeNode({ node, depth = 0 }: { node: TraceNode; depth?: number }) {
|
||||
const [isExpanded, setIsExpanded] = useState(depth < 2); // Auto-expand first 2 levels
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
const { data } = node;
|
||||
const operationName = data.operation_name || "Unknown";
|
||||
const duration = data.duration_ms ? `${Number(data.duration_ms).toFixed(1)}ms` : "";
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
// Extract token usage from attributes if available
|
||||
const inputTokens = data.attributes?.["gen_ai.usage.input_tokens"];
|
||||
const outputTokens = data.attributes?.["gen_ai.usage.output_tokens"];
|
||||
const hasTokens = inputTokens !== undefined || outputTokens !== undefined;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center gap-2 p-3 border-b">
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="font-medium">Traces</span>
|
||||
<Badge variant="outline">{traceEvents.length}</Badge>
|
||||
<div className="relative">
|
||||
{/* Vertical line for tree structure */}
|
||||
{depth > 0 && (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 border-l-2 border-muted"
|
||||
style={{ marginLeft: `${(depth - 1) * 16 + 8}px` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex items-center gap-2 py-1.5 hover:bg-muted/50 rounded transition-colors"
|
||||
style={{ paddingLeft: `${depth * 16}px` }}
|
||||
>
|
||||
{/* Expand/collapse for children OR details */}
|
||||
<button
|
||||
onClick={() => hasChildren ? setIsExpanded(!isExpanded) : setShowDetails(!showDetails)}
|
||||
className="w-4 h-4 flex items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{hasChildren ? (
|
||||
isExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />
|
||||
) : (
|
||||
showDetails ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Operation badge */}
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${getOperationColor(operationName)}`}>
|
||||
{operationName.replace("ChatAgent.", "").replace("invoke_agent ", "")}
|
||||
</span>
|
||||
|
||||
{/* Duration */}
|
||||
{duration && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{duration}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Token usage */}
|
||||
{hasTokens && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{inputTokens !== undefined && <span>↑{String(inputTokens)}</span>}
|
||||
{inputTokens !== undefined && outputTokens !== undefined && <span className="mx-0.5">/</span>}
|
||||
{outputTokens !== undefined && <span>↓{String(outputTokens)}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3">
|
||||
{traceEvents.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground text-sm py-8">
|
||||
No trace data available.
|
||||
<br />
|
||||
{events && events.length > 0 && (
|
||||
<div className="mt-3 text-xs border rounded p-2">
|
||||
{" "}
|
||||
<Info className="inline h-4 w-4 mr-1 " />
|
||||
You may have to set the environment variable{" "}
|
||||
<span className="font-mono bg-accent/10 px-1 rounded">
|
||||
ENABLE_INSTRUMENTATION=true
|
||||
</span>{" "}
|
||||
or restart devui with the tracing flag{" "}
|
||||
<div className="font-mono bg-accent/10 px-1 rounded">
|
||||
devui --tracing
|
||||
</div>
|
||||
to enable tracing.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{reversedTraceEvents.map((event, index) => {
|
||||
if ('type' in event && event.type === "separator") {
|
||||
return <MessageSeparator key={(event as { type: "separator"; id: string }).id} />;
|
||||
}
|
||||
return <TraceEventItem key={index} event={event as ExtendedResponseStreamEvent} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Details panel */}
|
||||
{showDetails && !hasChildren && (
|
||||
<div
|
||||
className="ml-4 mt-1 mb-2 p-2 bg-muted/30 rounded border text-xs"
|
||||
style={{ marginLeft: `${depth * 16 + 20}px` }}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{data.span_id && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground w-20">Span ID:</span>
|
||||
<span className="font-mono text-xs break-all">{data.span_id}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.trace_id && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground w-20">Trace ID:</span>
|
||||
<span className="font-mono text-xs break-all">{data.trace_id}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.status && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground w-20">Status:</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${
|
||||
data.status === "StatusCode.UNSET" || data.status === "OK"
|
||||
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
|
||||
: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
|
||||
}`}>
|
||||
{data.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.attributes && Object.keys(data.attributes).length > 0 && (
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground block mb-1">Attributes:</span>
|
||||
<pre className="text-xs bg-background border rounded p-2 overflow-auto max-h-32 whitespace-pre-wrap break-all">
|
||||
{formatTraceAttributes(data.attributes)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{/* Children */}
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
{node.children.map((child, idx) => (
|
||||
<TraceTreeNode key={child.data.span_id || idx} node={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
// Component for a single trace group (one response/turn)
|
||||
function TraceGroupItem({ group }: { group: TraceGroup }) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
if (
|
||||
(event.type !== "response.trace.completed" &&
|
||||
event.type !== "response.trace.completed") ||
|
||||
!("data" in event)
|
||||
) {
|
||||
return (
|
||||
<div className="border rounded p-3 text-red-600 dark:text-red-400 text-xs">
|
||||
Error: Expected trace event but got {event.type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = event.data as TraceEventData;
|
||||
|
||||
// Use stored UI timestamp first, then trace timestamps, then fallback to current time
|
||||
let timestamp: string;
|
||||
if ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number') {
|
||||
// Use stored UI timestamp from when event was received
|
||||
timestamp = new Date(event._uiTimestamp * 1000).toLocaleTimeString();
|
||||
} else if (data.end_time) {
|
||||
timestamp = new Date(data.end_time * 1000).toLocaleTimeString();
|
||||
} else if (data.start_time) {
|
||||
timestamp = new Date(data.start_time * 1000).toLocaleTimeString();
|
||||
} else if (data.timestamp) {
|
||||
timestamp = new Date(data.timestamp).toLocaleTimeString();
|
||||
} else {
|
||||
timestamp = new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
const operationName = data.operation_name || "Unknown Operation";
|
||||
const duration = data.duration_ms
|
||||
? `${Number(data.duration_ms).toFixed(1)}ms`
|
||||
: "";
|
||||
const entityId = data.entity_id || "";
|
||||
const timestamp = new Date(group.timestamp * 1000).toLocaleTimeString();
|
||||
const duration = group.totalDuration > 0 ? `${group.totalDuration.toFixed(0)}ms` : "";
|
||||
const spanCount = group.traces.reduce((count, node) => {
|
||||
const countNode = (n: TraceNode): number => 1 + n.children.reduce((c, child) => c + countNode(child), 0);
|
||||
return count + countNode(node);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="border-l-2 border-muted pl-3 py-2 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
||||
<Search className="h-3 w-3 text-orange-600 dark:text-orange-400" />
|
||||
<span className="font-mono">{timestamp}</span>
|
||||
<Badge variant="outline" className="text-xs py-0">
|
||||
trace
|
||||
</Badge>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{/* Group header */}
|
||||
<div
|
||||
className="flex items-center gap-2 p-2 bg-muted/50 cursor-pointer hover:bg-muted/70 transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="text-muted-foreground">
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</div>
|
||||
|
||||
<span className="font-mono text-xs text-muted-foreground">{timestamp}</span>
|
||||
|
||||
{group.entity_id && (
|
||||
<Badge variant="outline" className="text-xs py-0">
|
||||
{group.entity_id.replace("agent_", "").replace("workflow_", "")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{duration && (
|
||||
<Badge variant="secondary" className="text-xs py-0">
|
||||
{duration}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{spanCount} span{spanCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="text-muted-foreground">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex-1 break-all">
|
||||
<span className="font-medium">{operationName}</span>
|
||||
{entityId && <span className="ml-2 text-xs">({entityId})</span>}
|
||||
</div>
|
||||
{/* Group content - trace tree */}
|
||||
{isExpanded && (
|
||||
<div className="p-2 border-t">
|
||||
{group.traces.map((node, idx) => (
|
||||
<TraceTreeNode key={node.data.span_id || idx} node={node} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Expandable content */}
|
||||
{isExpanded && (
|
||||
<div className="mt-2 ml-5 p-3 bg-muted/30 rounded border">
|
||||
<div className="space-y-2">
|
||||
function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
// Use persisted store state instead of local useState
|
||||
const subTab = useDevUIStore((state) => state.debugTraceSubTab);
|
||||
const setSubTab = useDevUIStore((state) => state.setDebugTraceSubTab);
|
||||
|
||||
// ONLY show actual trace events
|
||||
const traceEvents = events.filter(
|
||||
(e) => e.type === "response.trace.completed"
|
||||
);
|
||||
|
||||
// Build hierarchical structure grouped by response_id
|
||||
const traceGroups = buildTraceHierarchy(traceEvents);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Sub-tab header */}
|
||||
<div className="flex items-center gap-2 p-3 border-b">
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="font-medium">Traces</span>
|
||||
<Badge variant="outline">{traceEvents.length}</Badge>
|
||||
|
||||
{/* Sub-tab toggle */}
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center bg-muted rounded-md p-1 min-w-0">
|
||||
<button
|
||||
onClick={() => setSubTab("spans")}
|
||||
className={`px-3 py-1.5 text-xs rounded transition-colors truncate ${
|
||||
subTab === "spans"
|
||||
? "bg-background shadow-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
OTel Spans
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSubTab("context")}
|
||||
className={`px-3 py-1.5 text-xs rounded transition-colors flex items-center gap-1.5 min-w-0 ${
|
||||
subTab === "context"
|
||||
? "bg-background shadow-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="truncate">Context Inspector</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-tab content */}
|
||||
{subTab === "spans" ? (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* OTel Spans header - only show when we have data */}
|
||||
{traceEvents.length > 0 && (
|
||||
<div className="p-3 border-b flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-orange-500" />
|
||||
<span className="font-semibold text-sm">Trace Details</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Operation:
|
||||
</span>
|
||||
<span className="ml-2 font-mono bg-orange-100 dark:bg-orange-900 px-2 py-1 rounded">
|
||||
{operationName}
|
||||
</span>
|
||||
</div>
|
||||
{data.span_id && (
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Span ID:
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs break-all">
|
||||
{data.span_id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.trace_id && (
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Trace ID:
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs break-all">
|
||||
{data.trace_id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.parent_span_id && (
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Parent Span:
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs break-all">
|
||||
{data.parent_span_id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.duration_ms && (
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Duration:
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs">
|
||||
{Number(data.duration_ms).toFixed(2)}ms
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.status && (
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Status:
|
||||
</span>
|
||||
<span
|
||||
className={`ml-2 px-2 py-1 rounded text-xs font-medium ${data.status === "StatusCode.UNSET" ||
|
||||
data.status === "OK"
|
||||
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
|
||||
: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
|
||||
}`}
|
||||
>
|
||||
{data.status || "unknown"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.entity_id && (
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Entity:
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs break-all">
|
||||
{data.entity_id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.attributes && Object.keys(data.attributes).length > 0 && (
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Attributes:
|
||||
</span>
|
||||
<div className="mt-1 max-h-32 overflow-auto">
|
||||
<pre className="text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all">
|
||||
{(() => {
|
||||
try {
|
||||
// Try to pretty-print JSON, and unescape string values that contain JSON
|
||||
const attrs = { ...data.attributes };
|
||||
Object.keys(attrs).forEach((key) => {
|
||||
if (
|
||||
typeof attrs[key] === "string" &&
|
||||
attrs[key].startsWith("[")
|
||||
) {
|
||||
try {
|
||||
attrs[key] = JSON.parse(attrs[key]);
|
||||
} catch {
|
||||
// Keep original if parsing fails
|
||||
}
|
||||
}
|
||||
});
|
||||
return JSON.stringify(attrs, null, 2);
|
||||
} catch {
|
||||
return JSON.stringify(data.attributes, null, 2);
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="font-medium text-sm">OTel Spans</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{traceGroups.length} turn{traceGroups.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{traceEvents.length === 0 ? (
|
||||
<div className="flex flex-col items-center text-center p-6 pt-9">
|
||||
<BarChart3 className="h-8 w-8 text-muted-foreground mb-3" />
|
||||
<div className="text-sm font-medium mb-1">No Data</div>
|
||||
<div className="text-xs text-muted-foreground max-w-[200px]">
|
||||
Run{" "}
|
||||
<span className="font-mono bg-accent/10 px-1 rounded">
|
||||
devui --instrumentation
|
||||
</span>{" "}
|
||||
and start a conversation.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3">
|
||||
<div className="space-y-3">
|
||||
{traceGroups.map((group) => (
|
||||
<TraceGroupItem key={group.response_id} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ContextInspector events={events} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1590,19 +1751,48 @@ export function DebugPanel({
|
||||
isStreaming = false,
|
||||
onMinimize,
|
||||
}: DebugPanelProps) {
|
||||
// Use persisted store state for active tab
|
||||
const activeTab = useDevUIStore((state) => state.debugPanelTab);
|
||||
const setActiveTab = useDevUIStore((state) => state.setDebugPanelTab);
|
||||
|
||||
// Compute counts once for tab badges (memoized to avoid perf hits)
|
||||
const counts = useMemo(() => {
|
||||
const processedEvents = processEventsForDisplay(events);
|
||||
const eventsCount = processedEvents.length;
|
||||
const tracesCount = events.filter(e => e.type === "response.trace.completed").length;
|
||||
const toolsCount = processedEvents.filter(e => e.type === "response.function_call.complete").length
|
||||
+ events.filter(e => getFunctionResultFromEvent(e) !== null).length;
|
||||
return { eventsCount, tracesCount, toolsCount };
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 border-l flex flex-col min-h-0">
|
||||
<Tabs defaultValue="events" className="flex-1 flex flex-col min-h-0">
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as "events" | "traces" | "tools")} className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-3 pt-3 flex items-center gap-2 flex-shrink-0">
|
||||
<TabsList className="flex-1">
|
||||
<TabsTrigger value="events" className="flex-1">
|
||||
<TabsTrigger value="events" className="flex-1 gap-1.5">
|
||||
Events
|
||||
{counts.eventsCount > 0 && (
|
||||
<span className="text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center">
|
||||
{counts.eventsCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="traces" className="flex-1">
|
||||
<TabsTrigger value="traces" className="flex-1 gap-1.5">
|
||||
Traces
|
||||
{counts.tracesCount > 0 && (
|
||||
<span className="text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center">
|
||||
{counts.tracesCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tools" className="flex-1">
|
||||
<TabsTrigger value="tools" className="flex-1 gap-1.5">
|
||||
Tools
|
||||
{counts.toolsCount > 0 && (
|
||||
<span className="text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center">
|
||||
{counts.toolsCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{onMinimize && (
|
||||
|
||||
@@ -209,7 +209,7 @@ export function DeploymentModal({
|
||||
setCopiedTemplate(null);
|
||||
timeoutRef.current = null;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Reset state on error - clipboard write failed
|
||||
setCopiedTemplate(null);
|
||||
}
|
||||
@@ -248,7 +248,7 @@ services:
|
||||
- AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY}
|
||||
- AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT}
|
||||
- AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=\${AZURE_OPENAI_CHAT_DEPLOYMENT_NAME}
|
||||
# Optional: Enable tracing
|
||||
# Optional: Enable instrumentation
|
||||
- ENABLE_INSTRUMENTATION=\${ENABLE_INSTRUMENTATION:-false}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
@@ -41,8 +41,8 @@ export function SettingsModal({
|
||||
}: SettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("general");
|
||||
|
||||
// OpenAI proxy mode, Azure deployment, auth status, server capabilities, and version from store
|
||||
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired, serverCapabilities, serverVersion, runtime, uiMode } = useDevUIStore();
|
||||
// OpenAI proxy mode, Azure deployment, auth status, server capabilities, streaming, and version from store
|
||||
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired, serverCapabilities, serverVersion, runtime, uiMode, streamingEnabled, setStreamingEnabled } = useDevUIStore();
|
||||
|
||||
// Get current backend URL from localStorage or default
|
||||
const defaultUrl = import.meta.env.VITE_API_BASE_URL !== undefined ? import.meta.env.VITE_API_BASE_URL : "";
|
||||
@@ -353,6 +353,37 @@ export function SettingsModal({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Streaming Mode Setting */}
|
||||
<div className="space-y-3 border-t pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">
|
||||
Streaming Mode
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stream responses token-by-token as they're generated
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={streamingEnabled}
|
||||
onCheckedChange={setStreamingEnabled}
|
||||
/>
|
||||
</div>
|
||||
{!streamingEnabled && (
|
||||
<div className="flex items-start gap-2 text-xs text-amber-600 dark:text-amber-400 bg-amber-500/10 p-3 rounded">
|
||||
<Info className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Non-streaming mode limitations:</p>
|
||||
<ul className="mt-1 space-y-0.5 list-disc list-inside text-amber-600/80 dark:text-amber-400/80">
|
||||
<li>Tool calls won't display in real-time</li>
|
||||
<li>No typing indicator during generation</li>
|
||||
<li>Response appears all at once when complete</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -628,11 +659,11 @@ export function SettingsModal({
|
||||
<div className="space-y-2 pt-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Capabilities</p>
|
||||
<div className="space-y-1 text-sm">
|
||||
{serverCapabilities?.tracing !== undefined && (
|
||||
{serverCapabilities?.instrumentation !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Tracing:</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.tracing ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
|
||||
{serverCapabilities.tracing ? 'Enabled' : 'Disabled'}
|
||||
<span className="text-muted-foreground">Instrumentation:</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.instrumentation ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
|
||||
{serverCapabilities.instrumentation ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -398,7 +398,11 @@ class ApiClient {
|
||||
async listConversationItems(
|
||||
conversationId: string,
|
||||
options?: { limit?: number; after?: string; order?: "asc" | "desc" }
|
||||
): Promise<{ data: unknown[]; has_more: boolean }> {
|
||||
): Promise<{
|
||||
data: unknown[];
|
||||
has_more: boolean;
|
||||
metadata?: { traces?: unknown[] };
|
||||
}> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit) params.set("limit", options.limit.toString());
|
||||
if (options?.after) params.set("after", options.after);
|
||||
@@ -409,7 +413,11 @@ class ApiClient {
|
||||
queryString ? `?${queryString}` : ""
|
||||
}`;
|
||||
|
||||
return this.request<{ data: unknown[]; has_more: boolean }>(url);
|
||||
return this.request<{
|
||||
data: unknown[];
|
||||
has_more: boolean;
|
||||
metadata?: { traces?: unknown[] };
|
||||
}>(url);
|
||||
}
|
||||
|
||||
async getConversationItem(
|
||||
@@ -800,34 +808,68 @@ class ApiClient {
|
||||
yield* this.streamOpenAIResponse(openAIRequest, request.conversation_id, signal);
|
||||
}
|
||||
|
||||
// REMOVED: Legacy streaming methods - use streamAgentExecutionOpenAI and streamWorkflowExecutionOpenAI instead
|
||||
// ========================================
|
||||
// Non-Streaming Execution Methods
|
||||
// ========================================
|
||||
|
||||
// Non-streaming execution (for testing)
|
||||
async runAgent(
|
||||
// Non-streaming agent execution using /v1/responses with stream=false
|
||||
async runAgentSync(
|
||||
agentId: string,
|
||||
request: RunAgentRequest
|
||||
): Promise<{
|
||||
conversation_id: string;
|
||||
result: unknown[];
|
||||
message_count: number;
|
||||
}> {
|
||||
return this.request(`/agents/${agentId}/run`, {
|
||||
): Promise<import("@/types/openai").OpenAIResponse> {
|
||||
// Check if OAI proxy mode is enabled
|
||||
const { oaiMode } = await import("@/stores").then((m) => ({
|
||||
oaiMode: m.useDevUIStore.getState().oaiMode,
|
||||
}));
|
||||
|
||||
const openAIRequest: AgentFrameworkRequest = {
|
||||
metadata: { entity_id: agentId },
|
||||
input: request.input,
|
||||
stream: false,
|
||||
conversation: request.conversation_id,
|
||||
};
|
||||
|
||||
// Apply OAI mode settings if enabled
|
||||
if (oaiMode.enabled) {
|
||||
openAIRequest.model = oaiMode.model;
|
||||
if (oaiMode.temperature !== undefined) {
|
||||
openAIRequest.temperature = oaiMode.temperature;
|
||||
}
|
||||
if (oaiMode.max_output_tokens !== undefined) {
|
||||
openAIRequest.max_output_tokens = oaiMode.max_output_tokens;
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (oaiMode.enabled) {
|
||||
headers["X-Proxy-Backend"] = "openai";
|
||||
}
|
||||
|
||||
return this.request<import("@/types/openai").OpenAIResponse>("/v1/responses", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
headers,
|
||||
body: JSON.stringify(openAIRequest),
|
||||
});
|
||||
}
|
||||
|
||||
async runWorkflow(
|
||||
// Non-streaming workflow execution using /v1/responses with stream=false
|
||||
async runWorkflowSync(
|
||||
workflowId: string,
|
||||
request: RunWorkflowRequest
|
||||
): Promise<{
|
||||
result: string;
|
||||
events: number;
|
||||
message_count: number;
|
||||
}> {
|
||||
return this.request(`/workflows/${workflowId}/run`, {
|
||||
): Promise<import("@/types/openai").OpenAIResponse> {
|
||||
const openAIRequest: AgentFrameworkRequest = {
|
||||
metadata: { entity_id: workflowId },
|
||||
input: JSON.stringify(request.input_data || {}),
|
||||
stream: false,
|
||||
conversation: request.conversation_id,
|
||||
extra_body: request.checkpoint_id
|
||||
? { entity_id: workflowId, checkpoint_id: request.checkpoint_id }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return this.request<import("@/types/openai").OpenAIResponse>("/v1/responses", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
body: JSON.stringify(openAIRequest),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,13 @@ interface DevUIState {
|
||||
debugEvents: ExtendedResponseStreamEvent[];
|
||||
isResizing: boolean;
|
||||
showToolCalls: boolean; // UI setting to show/hide tool calls in chat
|
||||
streamingEnabled: boolean; // Whether to use streaming mode for responses
|
||||
|
||||
// Debug Panel Preferences (persisted)
|
||||
debugPanelTab: "events" | "traces" | "tools"; // Main debug panel tab
|
||||
debugTraceSubTab: "spans" | "context"; // OTel Spans vs Context Inspector
|
||||
contextInspectorViewMode: "tokens" | "composition";
|
||||
contextInspectorCumulative: boolean;
|
||||
|
||||
// Modal Slice
|
||||
showAboutModal: boolean;
|
||||
@@ -82,7 +89,7 @@ interface DevUIState {
|
||||
uiMode: "developer" | "user";
|
||||
runtime: "python" | "dotnet";
|
||||
serverCapabilities: {
|
||||
tracing: boolean;
|
||||
instrumentation: boolean;
|
||||
openai_proxy: boolean;
|
||||
deployment: boolean;
|
||||
};
|
||||
@@ -146,6 +153,13 @@ interface DevUIActions {
|
||||
clearDebugEvents: () => void;
|
||||
setIsResizing: (resizing: boolean) => void;
|
||||
setShowToolCalls: (show: boolean) => void;
|
||||
setStreamingEnabled: (enabled: boolean) => void;
|
||||
|
||||
// Debug Panel Preference Actions
|
||||
setDebugPanelTab: (tab: "events" | "traces" | "tools") => void;
|
||||
setDebugTraceSubTab: (tab: "spans" | "context") => void;
|
||||
setContextInspectorViewMode: (mode: "tokens" | "composition") => void;
|
||||
setContextInspectorCumulative: (cumulative: boolean) => void;
|
||||
|
||||
// Modal Actions
|
||||
setShowAboutModal: (show: boolean) => void;
|
||||
@@ -166,7 +180,7 @@ interface DevUIActions {
|
||||
toggleOAIMode: () => void;
|
||||
|
||||
// Server Meta Actions
|
||||
setServerMeta: (meta: { uiMode: "developer" | "user"; runtime: "python" | "dotnet"; capabilities: { tracing: boolean; openai_proxy: boolean; deployment: boolean }; authRequired: boolean; version?: string }) => void;
|
||||
setServerMeta: (meta: { uiMode: "developer" | "user"; runtime: "python" | "dotnet"; capabilities: { instrumentation: boolean; openai_proxy: boolean; deployment: boolean }; authRequired: boolean; version?: string }) => void;
|
||||
|
||||
// Deployment Actions
|
||||
startDeployment: () => void;
|
||||
@@ -228,6 +242,13 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
debugEvents: [],
|
||||
isResizing: false,
|
||||
showToolCalls: true, // Default to showing tool calls
|
||||
streamingEnabled: true, // Default to streaming mode (recommended)
|
||||
|
||||
// Debug Panel Preferences (persisted)
|
||||
debugPanelTab: "events", // Default to events tab
|
||||
debugTraceSubTab: "spans", // Default to spans sub-tab
|
||||
contextInspectorViewMode: "tokens", // Default to tokens view
|
||||
contextInspectorCumulative: false, // Default to per-message view
|
||||
|
||||
// Modal State
|
||||
showAboutModal: false,
|
||||
@@ -248,7 +269,7 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
uiMode: "developer", // Default to developer mode
|
||||
runtime: "python", // Default to Python runtime
|
||||
serverCapabilities: {
|
||||
tracing: false,
|
||||
instrumentation: false,
|
||||
openai_proxy: false,
|
||||
deployment: false,
|
||||
},
|
||||
@@ -371,14 +392,16 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
setDebugPanelMinimized: (minimized) => set({ debugPanelMinimized: minimized }),
|
||||
setDebugPanelWidth: (width) => set({ debugPanelWidth: width }),
|
||||
setShowToolCalls: (show) => set({ showToolCalls: show }),
|
||||
setStreamingEnabled: (enabled) => set({ streamingEnabled: enabled }),
|
||||
addDebugEvent: (event) =>
|
||||
set((state) => {
|
||||
// Generate unique timestamp for each event
|
||||
// Use current time + small increment to ensure uniqueness even for rapid events
|
||||
const baseTimestamp = Math.floor(Date.now() / 1000);
|
||||
const lastTimestamp = state.debugEvents.length > 0
|
||||
? (state.debugEvents[state.debugEvents.length - 1] as any)._uiTimestamp || 0
|
||||
: 0;
|
||||
const lastEvent = state.debugEvents.length > 0
|
||||
? state.debugEvents[state.debugEvents.length - 1] as { _uiTimestamp?: number }
|
||||
: null;
|
||||
const lastTimestamp = lastEvent?._uiTimestamp ?? 0;
|
||||
// Ensure new timestamp is always greater than the last one
|
||||
const uniqueTimestamp = Math.max(baseTimestamp, lastTimestamp + 1);
|
||||
|
||||
@@ -399,6 +422,12 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
clearDebugEvents: () => set({ debugEvents: [] }),
|
||||
setIsResizing: (resizing) => set({ isResizing: resizing }),
|
||||
|
||||
// Debug Panel Preference Actions
|
||||
setDebugPanelTab: (tab) => set({ debugPanelTab: tab }),
|
||||
setDebugTraceSubTab: (tab) => set({ debugTraceSubTab: tab }),
|
||||
setContextInspectorViewMode: (mode) => set({ contextInspectorViewMode: mode }),
|
||||
setContextInspectorCumulative: (cumulative) => set({ contextInspectorCumulative: cumulative }),
|
||||
|
||||
// ========================================
|
||||
// Modal Actions
|
||||
// ========================================
|
||||
@@ -605,8 +634,14 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
debugPanelMinimized: state.debugPanelMinimized,
|
||||
debugPanelWidth: state.debugPanelWidth,
|
||||
showToolCalls: state.showToolCalls, // Persist tool calls visibility preference
|
||||
streamingEnabled: state.streamingEnabled, // Persist streaming mode preference
|
||||
oaiMode: state.oaiMode, // Persist OpenAI proxy mode settings
|
||||
azureDeploymentEnabled: state.azureDeploymentEnabled, // Persist Azure deployment preference
|
||||
// Debug panel tab preferences
|
||||
debugPanelTab: state.debugPanelTab,
|
||||
debugTraceSubTab: state.debugTraceSubTab,
|
||||
contextInspectorViewMode: state.contextInspectorViewMode,
|
||||
contextInspectorCumulative: state.contextInspectorCumulative,
|
||||
}),
|
||||
}
|
||||
),
|
||||
|
||||
@@ -130,6 +130,7 @@ export type {
|
||||
ResponseCompletedEvent,
|
||||
ResponseFailedEvent,
|
||||
ResponseFunctionResultComplete,
|
||||
ResponseFunctionToolCall,
|
||||
StructuredEvent,
|
||||
WorkflowItem,
|
||||
ExecutorActionItem,
|
||||
@@ -159,7 +160,7 @@ export interface MetaResponse {
|
||||
framework: string;
|
||||
runtime: "python" | "dotnet";
|
||||
capabilities: {
|
||||
tracing: boolean;
|
||||
instrumentation: boolean;
|
||||
openai_proxy: boolean;
|
||||
deployment: boolean;
|
||||
};
|
||||
@@ -266,6 +267,29 @@ export interface CheckpointInfo {
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Full checkpoint data structure
|
||||
export interface FullCheckpoint {
|
||||
checkpoint_id: string;
|
||||
workflow_id: string;
|
||||
timestamp: string;
|
||||
messages: Record<string, unknown[]>;
|
||||
shared_state: Record<string, unknown>;
|
||||
pending_request_info_events: Record<string, PendingRequestInfoEvent>;
|
||||
iteration_count: number;
|
||||
metadata: Record<string, unknown>;
|
||||
version: string;
|
||||
}
|
||||
|
||||
// Pending request info event data
|
||||
export interface PendingRequestInfoEvent {
|
||||
source_executor_id: string;
|
||||
request_type?: string;
|
||||
response_type?: string;
|
||||
request_data?: Record<string, unknown>;
|
||||
request_schema?: Record<string, unknown>;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
// Checkpoint item from conversation items API
|
||||
export interface CheckpointItem {
|
||||
id: string;
|
||||
@@ -281,16 +305,6 @@ export interface CheckpointItem {
|
||||
message_count: number;
|
||||
size_bytes?: number;
|
||||
version: string;
|
||||
full_checkpoint?: {
|
||||
checkpoint_id: string;
|
||||
workflow_id: string;
|
||||
timestamp: string;
|
||||
messages: Record<string, unknown[]>;
|
||||
shared_state: Record<string, unknown>;
|
||||
pending_request_info_events: Record<string, unknown>;
|
||||
iteration_count: number;
|
||||
metadata: Record<string, unknown>;
|
||||
version: string;
|
||||
};
|
||||
full_checkpoint?: FullCheckpoint;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,45 @@
|
||||
* Based on OpenAI's official response types
|
||||
*/
|
||||
|
||||
// OpenAI Response Error (from response_error.py)
|
||||
export type ResponseErrorCode =
|
||||
| "server_error"
|
||||
| "rate_limit_exceeded"
|
||||
| "invalid_prompt"
|
||||
| "vector_store_timeout"
|
||||
| "invalid_image"
|
||||
| "invalid_image_format"
|
||||
| "invalid_base64_image"
|
||||
| "invalid_image_url"
|
||||
| "image_too_large"
|
||||
| "image_too_small"
|
||||
| "image_parse_error"
|
||||
| "image_content_policy_violation"
|
||||
| "invalid_image_mode"
|
||||
| "image_file_too_large"
|
||||
| "unsupported_image_media_type"
|
||||
| "empty_image_file"
|
||||
| "failed_to_download_image"
|
||||
| "image_file_not_found";
|
||||
|
||||
export interface ResponseError {
|
||||
code: ResponseErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// OpenAI Response Usage (from response_usage.py)
|
||||
export interface ResponseUsage {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
input_tokens_details?: {
|
||||
cached_tokens: number;
|
||||
};
|
||||
output_tokens_details?: {
|
||||
reasoning_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Core OpenAI Response Stream Event
|
||||
export interface ResponseStreamEvent {
|
||||
type: string;
|
||||
@@ -28,7 +67,7 @@ export interface ResponseCreatedEvent {
|
||||
id: string;
|
||||
status: "in_progress";
|
||||
created_at: number;
|
||||
output?: any[];
|
||||
output?: ResponseOutputItem[];
|
||||
};
|
||||
sequence_number?: number;
|
||||
}
|
||||
@@ -47,9 +86,11 @@ export interface ResponseCompletedEvent {
|
||||
response: {
|
||||
id: string;
|
||||
status?: "completed";
|
||||
usage?: any; // Optional usage information
|
||||
usage?: ResponseUsage; // Optional usage information
|
||||
model?: string; // Optional model information
|
||||
[key: string]: any; // Allow any additional fields
|
||||
output?: ResponseOutputItem[]; // Output items
|
||||
error?: ResponseError; // Error if failed
|
||||
metadata?: Record<string, unknown>; // Additional metadata
|
||||
};
|
||||
sequence_number?: number;
|
||||
}
|
||||
@@ -59,7 +100,7 @@ export interface ResponseFailedEvent {
|
||||
response: {
|
||||
id: string;
|
||||
status: "failed";
|
||||
error?: any;
|
||||
error?: ResponseError;
|
||||
};
|
||||
sequence_number?: number;
|
||||
}
|
||||
@@ -157,16 +198,16 @@ export interface WorkflowItem {
|
||||
type: string; // "executor_action", "workflow_action", "message", or any future type
|
||||
id: string;
|
||||
status?: "in_progress" | "completed" | "failed" | "cancelled";
|
||||
[key: string]: any; // Allow any additional fields
|
||||
[key: string]: unknown; // Allow any additional fields with unknown type
|
||||
}
|
||||
|
||||
// Executor Action Item (DevUI specific)
|
||||
export interface ExecutorActionItem extends WorkflowItem {
|
||||
type: "executor_action";
|
||||
executor_id: string;
|
||||
metadata?: Record<string, any>;
|
||||
result?: any;
|
||||
error?: any;
|
||||
metadata?: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
// Type guard for executor actions
|
||||
@@ -364,18 +405,7 @@ export interface ResponseOutputText {
|
||||
annotations: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface ResponseUsage {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
input_tokens_details: {
|
||||
cached_tokens: number;
|
||||
};
|
||||
output_tokens_details: {
|
||||
reasoning_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Note: ResponseUsage is defined at the top of this file
|
||||
|
||||
// Request format for Agent Framework
|
||||
// AgentFrameworkRequest moved to agent-framework.ts to avoid conflicts
|
||||
@@ -404,11 +434,62 @@ export interface MessageInputTextContent {
|
||||
text: string;
|
||||
}
|
||||
|
||||
// Annotation types for output text (from response_output_text.py)
|
||||
export interface AnnotationFileCitation {
|
||||
type: "file_citation";
|
||||
file_id: string;
|
||||
filename: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface AnnotationURLCitation {
|
||||
type: "url_citation";
|
||||
url: string;
|
||||
title: string;
|
||||
start_index: number;
|
||||
end_index: number;
|
||||
}
|
||||
|
||||
export interface AnnotationContainerFileCitation {
|
||||
type: "container_file_citation";
|
||||
container_id: string;
|
||||
file_id: string;
|
||||
filename: string;
|
||||
start_index: number;
|
||||
end_index: number;
|
||||
}
|
||||
|
||||
export interface AnnotationFilePath {
|
||||
type: "file_path";
|
||||
file_id: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export type OutputTextAnnotation =
|
||||
| AnnotationFileCitation
|
||||
| AnnotationURLCitation
|
||||
| AnnotationContainerFileCitation
|
||||
| AnnotationFilePath;
|
||||
|
||||
// Logprob types for output text
|
||||
export interface LogprobTopLogprob {
|
||||
token: string;
|
||||
bytes: number[];
|
||||
logprob: number;
|
||||
}
|
||||
|
||||
export interface Logprob {
|
||||
token: string;
|
||||
bytes: number[];
|
||||
logprob: number;
|
||||
top_logprobs: LogprobTopLogprob[];
|
||||
}
|
||||
|
||||
export interface MessageOutputTextContent {
|
||||
type: "output_text";
|
||||
text: string;
|
||||
annotations?: any[];
|
||||
logprobs?: any[];
|
||||
annotations?: OutputTextAnnotation[];
|
||||
logprobs?: Logprob[];
|
||||
}
|
||||
|
||||
export interface MessageInputImage {
|
||||
@@ -541,9 +622,218 @@ export interface Conversation {
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// List response
|
||||
// ============================================================================
|
||||
// OpenTelemetry Trace Attribute Keys
|
||||
// Mirrored from Python: agent_framework/observability.py ObservabilityAttributes
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Standard attribute keys for OpenTelemetry traces.
|
||||
* These match the Python ObservabilityAttributes enum exactly.
|
||||
*/
|
||||
export const TraceAttributes = {
|
||||
// Request attributes
|
||||
MODEL: "gen_ai.request.model",
|
||||
MAX_TOKENS: "gen_ai.request.max_tokens",
|
||||
TEMPERATURE: "gen_ai.request.temperature",
|
||||
TOP_P: "gen_ai.request.top_p",
|
||||
SEED: "gen_ai.request.seed",
|
||||
FREQUENCY_PENALTY: "gen_ai.request.frequency_penalty",
|
||||
PRESENCE_PENALTY: "gen_ai.request.presence_penalty",
|
||||
STOP_SEQUENCES: "gen_ai.request.stop_sequences",
|
||||
|
||||
// Response attributes
|
||||
FINISH_REASONS: "gen_ai.response.finish_reasons",
|
||||
RESPONSE_ID: "gen_ai.response.id",
|
||||
|
||||
// Usage attributes
|
||||
INPUT_TOKENS: "gen_ai.usage.input_tokens",
|
||||
OUTPUT_TOKENS: "gen_ai.usage.output_tokens",
|
||||
|
||||
// Content attributes (messages sent/received)
|
||||
INPUT_MESSAGES: "gen_ai.input.messages",
|
||||
OUTPUT_MESSAGES: "gen_ai.output.messages",
|
||||
SYSTEM_INSTRUCTIONS: "gen_ai.system_instructions",
|
||||
OUTPUT_TYPE: "gen_ai.output.type",
|
||||
|
||||
// Tool attributes
|
||||
TOOL_CALL_ID: "gen_ai.tool.call.id",
|
||||
TOOL_NAME: "gen_ai.tool.name",
|
||||
TOOL_TYPE: "gen_ai.tool.type",
|
||||
TOOL_DEFINITIONS: "gen_ai.tool.definitions",
|
||||
TOOL_ARGUMENTS: "gen_ai.tool.call.arguments",
|
||||
TOOL_RESULT: "gen_ai.tool.call.result",
|
||||
|
||||
// Agent attributes
|
||||
AGENT_ID: "gen_ai.agent.id",
|
||||
AGENT_NAME: "gen_ai.agent.name",
|
||||
AGENT_DESCRIPTION: "gen_ai.agent.description",
|
||||
CONVERSATION_ID: "gen_ai.conversation.id",
|
||||
|
||||
// Workflow attributes
|
||||
WORKFLOW_ID: "workflow.id",
|
||||
WORKFLOW_NAME: "workflow.name",
|
||||
EXECUTOR_ID: "executor.id",
|
||||
EXECUTOR_TYPE: "executor.type",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Type for trace attribute keys - ensures type safety when accessing attributes
|
||||
*/
|
||||
export type TraceAttributeKey = (typeof TraceAttributes)[keyof typeof TraceAttributes];
|
||||
|
||||
/**
|
||||
* Typed interface for known trace attributes.
|
||||
* Using this instead of Record<string, unknown> provides compile-time safety.
|
||||
*/
|
||||
export interface TypedTraceAttributes {
|
||||
// Request attributes
|
||||
[TraceAttributes.MODEL]?: string;
|
||||
[TraceAttributes.MAX_TOKENS]?: number;
|
||||
[TraceAttributes.TEMPERATURE]?: number;
|
||||
[TraceAttributes.TOP_P]?: number;
|
||||
[TraceAttributes.SEED]?: number;
|
||||
|
||||
// Usage attributes
|
||||
[TraceAttributes.INPUT_TOKENS]?: number;
|
||||
[TraceAttributes.OUTPUT_TOKENS]?: number;
|
||||
|
||||
// Content attributes (JSON strings that need parsing)
|
||||
[TraceAttributes.INPUT_MESSAGES]?: string;
|
||||
[TraceAttributes.OUTPUT_MESSAGES]?: string;
|
||||
[TraceAttributes.SYSTEM_INSTRUCTIONS]?: string;
|
||||
|
||||
// Tool attributes
|
||||
[TraceAttributes.TOOL_NAME]?: string;
|
||||
[TraceAttributes.TOOL_DEFINITIONS]?: string;
|
||||
[TraceAttributes.TOOL_ARGUMENTS]?: string;
|
||||
[TraceAttributes.TOOL_RESULT]?: string;
|
||||
|
||||
// Agent/workflow attributes
|
||||
[TraceAttributes.AGENT_NAME]?: string;
|
||||
[TraceAttributes.WORKFLOW_NAME]?: string;
|
||||
[TraceAttributes.EXECUTOR_ID]?: string;
|
||||
|
||||
// Allow additional unknown attributes
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message part types used in gen_ai.input.messages / gen_ai.output.messages
|
||||
*
|
||||
* Source: Python agent_framework/observability.py _to_otel_part()
|
||||
*
|
||||
* Python produces:
|
||||
* - text: {"type": "text", "content": "..."}
|
||||
* - function_call: {"type": "tool_call", "id": "...", "name": "...", "arguments": "..."}
|
||||
* - function_result: {"type": "tool_call_response", "id": "...", "response": "..."}
|
||||
*/
|
||||
|
||||
// Text content part
|
||||
// Python: {"type": "text", "content": content.text}
|
||||
export interface TraceTextPart {
|
||||
type: "text";
|
||||
content?: string; // Agent Framework format (from Python)
|
||||
text?: string; // Alternative field name (OpenAI format)
|
||||
}
|
||||
|
||||
// Tool/function call part (from assistant)
|
||||
// Python: {"type": "tool_call", "id": content.call_id, "name": content.name, "arguments": content.arguments}
|
||||
export interface TraceToolCallPart {
|
||||
type: "tool_call" | "function_call";
|
||||
id?: string; // Tool call ID for correlation
|
||||
name?: string; // Function name
|
||||
arguments?: string; // JSON string of arguments
|
||||
}
|
||||
|
||||
// Tool/function result part (response to tool call)
|
||||
// Python: {"type": "tool_call_response", "id": content.call_id, "response": response}
|
||||
export interface TraceToolResultPart {
|
||||
type: "tool_call_response" | "tool_result" | "function_result";
|
||||
id?: string; // Tool call ID for correlation
|
||||
response?: string; // Agent Framework format (from Python)
|
||||
result?: string; // Alternative field name (other formats)
|
||||
}
|
||||
|
||||
// Union type for all message parts
|
||||
export type TraceMessagePart = TraceTextPart | TraceToolCallPart | TraceToolResultPart;
|
||||
|
||||
// Helper type guard functions
|
||||
export function isTextPart(part: TraceMessagePart): part is TraceTextPart {
|
||||
return part.type === "text";
|
||||
}
|
||||
|
||||
export function isToolCallPart(part: TraceMessagePart): part is TraceToolCallPart {
|
||||
return part.type === "tool_call" || part.type === "function_call";
|
||||
}
|
||||
|
||||
export function isToolResultPart(part: TraceMessagePart): part is TraceToolResultPart {
|
||||
return (
|
||||
part.type === "tool_result" ||
|
||||
part.type === "function_result" ||
|
||||
part.type === "tool_call_response"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Message structure in gen_ai.input.messages / gen_ai.output.messages
|
||||
* Format: [{role: "system"|"user"|"assistant"|"tool", parts: [...]}]
|
||||
*/
|
||||
export interface TraceMessage {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
parts: TraceMessagePart[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to safely get a typed attribute value
|
||||
*/
|
||||
export function getTraceAttribute<K extends keyof TypedTraceAttributes>(
|
||||
attributes: TypedTraceAttributes,
|
||||
key: K
|
||||
): TypedTraceAttributes[K] {
|
||||
return attributes[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to parse JSON message array from trace attributes
|
||||
*/
|
||||
export function parseTraceMessages(jsonString: string | undefined): TraceMessage[] {
|
||||
if (!jsonString) return [];
|
||||
try {
|
||||
return JSON.parse(jsonString) as TraceMessage[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Stored trace span (from conversation metadata)
|
||||
export interface TraceSpan {
|
||||
type?: string;
|
||||
span_id: string;
|
||||
trace_id: string;
|
||||
parent_span_id?: string | null;
|
||||
operation_name: string;
|
||||
start_time: number;
|
||||
end_time?: number;
|
||||
duration_ms?: number;
|
||||
attributes: TypedTraceAttributes;
|
||||
status: string;
|
||||
response_id?: string | null;
|
||||
entity_id?: string;
|
||||
events?: Array<{
|
||||
name: string;
|
||||
timestamp: number;
|
||||
attributes?: Record<string, unknown>;
|
||||
}>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// List response with trace metadata (DevUI extension)
|
||||
export interface ConversationItemsListResponse {
|
||||
object: "list";
|
||||
data: ConversationItem[];
|
||||
has_more: boolean;
|
||||
metadata?: {
|
||||
traces?: TraceSpan[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,54 +99,54 @@ export interface Workflow {
|
||||
* Type guards for runtime type checking
|
||||
*/
|
||||
export function isWorkflow(obj: unknown): obj is Workflow {
|
||||
if (typeof obj !== "object" || obj === null) return false;
|
||||
const record = obj as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj === "object" &&
|
||||
obj !== null &&
|
||||
"id" in obj &&
|
||||
"edge_groups" in obj &&
|
||||
"executors" in obj &&
|
||||
"start_executor_id" in obj &&
|
||||
"max_iterations" in obj &&
|
||||
typeof (obj as any).id === "string" &&
|
||||
Array.isArray((obj as any).edge_groups) &&
|
||||
typeof (obj as any).executors === "object" &&
|
||||
typeof (obj as any).start_executor_id === "string" &&
|
||||
typeof (obj as any).max_iterations === "number"
|
||||
"id" in record &&
|
||||
"edge_groups" in record &&
|
||||
"executors" in record &&
|
||||
"start_executor_id" in record &&
|
||||
"max_iterations" in record &&
|
||||
typeof record.id === "string" &&
|
||||
Array.isArray(record.edge_groups) &&
|
||||
typeof record.executors === "object" &&
|
||||
typeof record.start_executor_id === "string" &&
|
||||
typeof record.max_iterations === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function isExecutor(obj: unknown): obj is Executor {
|
||||
if (typeof obj !== "object" || obj === null) return false;
|
||||
const record = obj as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj === "object" &&
|
||||
obj !== null &&
|
||||
"id" in obj &&
|
||||
"type" in obj &&
|
||||
typeof (obj as any).id === "string" &&
|
||||
typeof (obj as any).type === "string"
|
||||
"id" in record &&
|
||||
"type" in record &&
|
||||
typeof record.id === "string" &&
|
||||
typeof record.type === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function isEdge(obj: unknown): obj is Edge {
|
||||
if (typeof obj !== "object" || obj === null) return false;
|
||||
const record = obj as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj === "object" &&
|
||||
obj !== null &&
|
||||
"source_id" in obj &&
|
||||
"target_id" in obj &&
|
||||
typeof (obj as any).source_id === "string" &&
|
||||
typeof (obj as any).target_id === "string"
|
||||
"source_id" in record &&
|
||||
"target_id" in record &&
|
||||
typeof record.source_id === "string" &&
|
||||
typeof record.target_id === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function isEdgeGroup(obj: unknown): obj is EdgeGroup {
|
||||
if (typeof obj !== "object" || obj === null) return false;
|
||||
const record = obj as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj === "object" &&
|
||||
obj !== null &&
|
||||
"id" in obj &&
|
||||
"type" in obj &&
|
||||
"edges" in obj &&
|
||||
typeof (obj as any).id === "string" &&
|
||||
typeof (obj as any).type === "string" &&
|
||||
Array.isArray((obj as any).edges)
|
||||
"id" in record &&
|
||||
"type" in record &&
|
||||
"edges" in record &&
|
||||
typeof record.id === "string" &&
|
||||
typeof record.type === "string" &&
|
||||
Array.isArray(record.edges)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
import type {
|
||||
ExtendedResponseStreamEvent,
|
||||
ResponseWorkflowEventComplete,
|
||||
ResponseOutputItemAddedEvent,
|
||||
ResponseOutputItemDoneEvent,
|
||||
JSONSchemaProperty,
|
||||
} from "@/types";
|
||||
import type { Workflow } from "@/types/workflow";
|
||||
@@ -389,9 +391,10 @@ export function processWorkflowEvents(
|
||||
events.forEach((event) => {
|
||||
// Handle new standard OpenAI events
|
||||
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
|
||||
const item = (event as any).item;
|
||||
if (item && item.type === "executor_action" && item.executor_id) {
|
||||
const executorId = item.executor_id;
|
||||
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
|
||||
@@ -492,16 +495,17 @@ export function processWorkflowEvents(
|
||||
// This prevents setting to "running" after the executor has already completed
|
||||
const hasCompletionEvent = events.some((event) => {
|
||||
if (event.type === "response.output_item.done") {
|
||||
const item = (event as any).item;
|
||||
return item && item.type === "executor_action" && item.executor_id === startExecutorId;
|
||||
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 any;
|
||||
const data = event.data as Record<string, unknown>;
|
||||
return data.executor_id === startExecutorId &&
|
||||
(data.event_type === "ExecutorCompletedEvent" ||
|
||||
data.event_type === "ExecutorFailedEvent" ||
|
||||
data.event_type?.includes("Error") ||
|
||||
data.event_type?.includes("Failed"));
|
||||
(typeof data.event_type === "string" && data.event_type.includes("Error")) ||
|
||||
(typeof data.event_type === "string" && data.event_type.includes("Failed")));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
@@ -565,9 +569,10 @@ export function getCurrentlyExecutingExecutors(
|
||||
events.forEach((event) => {
|
||||
// Handle new standard OpenAI events
|
||||
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
|
||||
const item = (event as any).item;
|
||||
if (item && item.type === "executor_action" && item.executor_id) {
|
||||
const executorId = item.executor_id;
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user