mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: DevUI - Internal Refactor, Conversations API support, and per… (#1235)
* Python: DevUI - Internal Refactor, Conversations API support, and performance improvements Comprehensive refactor of DevUI package including samples relocation, frontend reorganization, OpenAI Conversations API support, and critical performance and code quality improvements. Key Changes: Architecture & Organization - Moved DevUI samples to python/samples/getting_started/devui/ - Consolidated with other framework samples for better discoverability - Added .env.example files and comprehensive README - Restructured frontend components into feature-based folders (agent, workflow, gallery, layout) - Created new OpenAI-compliant message renderers (devui should render oai responses types primarily) New Features - Added _conversations.py (467 lines) - Full conversation storage abstraction, replaces the /threads endpoint to better match oai conversations api - Implements OpenAI Conversations API for thread management, Supports in-memory and extensible storage backends API Simplification - Use 'model' field as entity_id (agent/workflow name) instead of extra_body - Use standard OpenAI 'conversation' field for conversation context. Performance & Quality Improvements - Improved context management in MessageMapper with bounded memory (~500KB max) - Implemented hybrid LRU + cleanup approach to prevent unbounded memory growth - General QOL improvement - Eliminated ~150 lines of dead/duplicate code, Consolidated helper functions into _utils.py, Extracted magic numbers to module-level constants, Optimized conversation item lookups with index-based approach Testing - Added test_conversations.py (13 tests) - Added test_performance_fixes.py (9 tests) - Updated existing tests for code consolidation - 53 tests passing Impact: 76 files changed: +4,106 insertions, -2,373 deletions All linting and formatting checks passing. No breaking changes - backward compatible. Migration: Samples moved to python/samples/getting_started/devui/ * readme lint fixes * initial support for function approval and minor ui fixes
This commit is contained in:
committed by
GitHub
Unverified
parent
f5abbc67ae
commit
c341ee7ed2
@@ -4,12 +4,10 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { AppHeader } from "@/components/shared/app-header";
|
||||
import { DebugPanel } from "@/components/shared/debug-panel";
|
||||
import { SettingsModal } from "@/components/shared/settings-modal";
|
||||
import { GalleryView } from "@/components/gallery";
|
||||
import { AgentView } from "@/components/agent/agent-view";
|
||||
import { WorkflowView } from "@/components/workflow/workflow-view";
|
||||
import { AppHeader, DebugPanel, SettingsModal } from "@/components/layout";
|
||||
import { GalleryView } from "@/components/features/gallery";
|
||||
import { AgentView } from "@/components/features/agent";
|
||||
import { WorkflowView } from "@/components/features/workflow";
|
||||
import { LoadingState } from "@/components/ui/loading-state";
|
||||
import { Toast } from "@/components/ui/toast";
|
||||
import { apiClient } from "@/services/api";
|
||||
|
||||
+532
-350
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Agent Feature - Exports
|
||||
*/
|
||||
|
||||
export { AgentView } from "./agent-view";
|
||||
export { AgentDetailsModal } from "./agent-details-modal";
|
||||
export * from "./message-renderers";
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* OpenAI Content Renderer - Renders OpenAI Conversations API content types
|
||||
* This is the CORRECT implementation that works with OpenAI types only
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Download,
|
||||
FileText,
|
||||
Code,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Music,
|
||||
} from "lucide-react";
|
||||
import type { MessageContent } from "@/types/openai";
|
||||
|
||||
interface ContentRendererProps {
|
||||
content: MessageContent;
|
||||
className?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
// Text content renderer
|
||||
function TextContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (content.type !== "text") return null;
|
||||
|
||||
const text = content.text;
|
||||
const TRUNCATE_LENGTH = 1600;
|
||||
const shouldTruncate = text.length > TRUNCATE_LENGTH;
|
||||
const displayText =
|
||||
shouldTruncate && !isExpanded
|
||||
? text.slice(0, TRUNCATE_LENGTH) + "..."
|
||||
: text;
|
||||
|
||||
return (
|
||||
<div className={`whitespace-pre-wrap break-words ${className || ""}`}>
|
||||
<div
|
||||
className={
|
||||
isExpanded && shouldTruncate ? "max-h-96 overflow-y-auto" : ""
|
||||
}
|
||||
>
|
||||
{displayText}
|
||||
{isStreaming && text.length > 0 && (
|
||||
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
</div>
|
||||
{shouldTruncate && (
|
||||
<div className="flex justify-end mt-1">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="inline-flex items-center gap-1 text-xs
|
||||
bg-background/80 hover:bg-background border border-border/50 hover:border-border
|
||||
text-muted-foreground hover:text-foreground
|
||||
transition-colors cursor-pointer px-2 py-1 rounded"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
less <ChevronUp className="h-3 w-3" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{(text.length - TRUNCATE_LENGTH).toLocaleString()} more{" "}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Image content renderer
|
||||
function ImageContentRenderer({ content, className }: ContentRendererProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (content.type !== "input_image") return null;
|
||||
|
||||
const imageUrl = content.image_url;
|
||||
|
||||
if (imageError) {
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>Image could not be loaded</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 ${className || ""}`}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Uploaded image"
|
||||
className={`rounded-lg border max-w-full transition-all cursor-pointer ${
|
||||
isExpanded ? "max-h-none" : "max-h-64"
|
||||
}`}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
{isExpanded && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Click to collapse
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// File content renderer
|
||||
function FileContentRenderer({ content, className }: ContentRendererProps) {
|
||||
if (content.type !== "input_file") return null;
|
||||
|
||||
const fileUrl = content.file_url || content.file_data;
|
||||
const filename = content.filename || "file";
|
||||
|
||||
// Determine file type from filename or data URI
|
||||
const isPdf = filename?.toLowerCase().endsWith(".pdf") || fileUrl?.includes("application/pdf");
|
||||
const isAudio = filename?.toLowerCase().match(/\.(mp3|wav|m4a|ogg|flac|aac)$/);
|
||||
|
||||
// For PDFs, try to embed
|
||||
if (isPdf && fileUrl) {
|
||||
return (
|
||||
<div className={`my-2 ${className || ""}`}>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<iframe
|
||||
src={fileUrl}
|
||||
className="w-full h-96"
|
||||
title={filename}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{filename}</span>
|
||||
{fileUrl && (
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={filename}
|
||||
className="ml-auto text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For audio files
|
||||
if (isAudio && fileUrl) {
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg ${className || ""}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Music className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{filename}</span>
|
||||
</div>
|
||||
<audio controls className="w-full">
|
||||
<source src={fileUrl} />
|
||||
Your browser does not support audio playback.
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic file display
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{filename}</span>
|
||||
</div>
|
||||
{fileUrl && (
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={filename}
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main content renderer that delegates to specific renderers
|
||||
export function OpenAIContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
return <TextContentRenderer content={content} className={className} isStreaming={isStreaming} />;
|
||||
case "input_image":
|
||||
return <ImageContentRenderer content={content} className={className} />;
|
||||
case "input_file":
|
||||
return <FileContentRenderer content={content} className={className} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Function call renderer (for displaying function calls in chat)
|
||||
interface FunctionCallRendererProps {
|
||||
name: string;
|
||||
arguments: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FunctionCallRenderer({ name, arguments: args, className }: FunctionCallRendererProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
let parsedArgs;
|
||||
try {
|
||||
parsedArgs = typeof args === "string" ? JSON.parse(args) : args;
|
||||
} catch {
|
||||
parsedArgs = args;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-blue-50 dark:bg-blue-950/20 ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Code className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
Function Call: {name}
|
||||
</span>
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400">{isExpanded ? "▼" : "▶"}</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
|
||||
<div className="text-blue-600 dark:text-blue-400 mb-1">Arguments:</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{JSON.stringify(parsedArgs, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Function result renderer
|
||||
interface FunctionResultRendererProps {
|
||||
output: string;
|
||||
call_id: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FunctionResultRenderer({ output, call_id, className }: FunctionResultRendererProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
let parsedOutput;
|
||||
try {
|
||||
parsedOutput = typeof output === "string" ? JSON.parse(output) : output;
|
||||
} catch {
|
||||
parsedOutput = output;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-green-50 dark:bg-green-950/20 ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Code className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm font-medium text-green-800 dark:text-green-300">
|
||||
Function Result
|
||||
</span>
|
||||
<span className="text-xs text-green-600 dark:text-green-400">{isExpanded ? "▼" : "▶"}</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
|
||||
<div className="text-green-600 dark:text-green-400 mb-1">Output:</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{JSON.stringify(parsedOutput, null, 2)}
|
||||
</pre>
|
||||
<div className="text-gray-500 text-[10px] mt-2">Call ID: {call_id}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* OpenAI Message Renderer - Renders OpenAI ConversationItem types
|
||||
* This replaces the legacy AgentFramework-based renderer
|
||||
*/
|
||||
|
||||
import type { ConversationItem } from "@/types/openai";
|
||||
import {
|
||||
OpenAIContentRenderer,
|
||||
FunctionCallRenderer,
|
||||
FunctionResultRenderer,
|
||||
} from "./OpenAIContentRenderer";
|
||||
|
||||
interface OpenAIMessageRendererProps {
|
||||
item: ConversationItem;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function OpenAIMessageRenderer({
|
||||
item,
|
||||
className,
|
||||
}: OpenAIMessageRendererProps) {
|
||||
// Handle message items (user/assistant with content)
|
||||
if (item.type === "message") {
|
||||
// Determine if message is actively streaming
|
||||
const isStreaming = item.status === "in_progress";
|
||||
const hasContent = item.content.length > 0;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{item.content.map((content, index) => (
|
||||
<OpenAIContentRenderer
|
||||
key={index}
|
||||
content={content}
|
||||
className={index > 0 ? "mt-2" : ""}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Show typing indicator when streaming with no content yet */}
|
||||
{isStreaming && !hasContent && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="flex space-x-1">
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-current" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle function call items
|
||||
if (item.type === "function_call") {
|
||||
return (
|
||||
<FunctionCallRenderer
|
||||
name={item.name}
|
||||
arguments={item.arguments}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle function result items
|
||||
if (item.type === "function_call_output") {
|
||||
return (
|
||||
<FunctionResultRenderer
|
||||
output={item.output}
|
||||
call_id={item.call_id}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown item type
|
||||
return null;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Message Renderer - Exports
|
||||
* Uses OpenAI Responses API types exclusively
|
||||
*/
|
||||
|
||||
export { OpenAIMessageRenderer } from "./OpenAIMessageRenderer";
|
||||
export { OpenAIContentRenderer, FunctionCallRenderer, FunctionResultRenderer } from "./OpenAIContentRenderer";
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Workflow Feature - Exports
|
||||
*/
|
||||
|
||||
export { WorkflowView } from "./workflow-view";
|
||||
export { WorkflowDetailsModal } from "./workflow-details-modal";
|
||||
export { WorkflowFlow } from "./workflow-flow";
|
||||
export { WorkflowInputForm } from "./workflow-input-form";
|
||||
export { ExecutorNode } from "./executor-node";
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useCallback, useEffect } from "react";
|
||||
import { useMemo, useCallback, useEffect, memo } from "react";
|
||||
import {
|
||||
MoreVertical,
|
||||
Map,
|
||||
@@ -248,7 +248,7 @@ function WorkflowAnimationHandler({
|
||||
return null; // This component doesn't render anything
|
||||
}
|
||||
|
||||
export function WorkflowFlow({
|
||||
export const WorkflowFlow = memo(function WorkflowFlow({
|
||||
workflowDump,
|
||||
events,
|
||||
isStreaming,
|
||||
@@ -290,8 +290,8 @@ export function WorkflowFlow({
|
||||
|
||||
// Process events and update node/edge states
|
||||
const nodeUpdates = useMemo(() => {
|
||||
return processWorkflowEvents(events);
|
||||
}, [events]);
|
||||
return processWorkflowEvents(events, workflowDump?.start_executor_id);
|
||||
}, [events, workflowDump?.start_executor_id]);
|
||||
|
||||
// Update nodes and edges with real-time state from events
|
||||
useMemo(() => {
|
||||
@@ -514,4 +514,4 @@ export function WorkflowFlow({
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
+380
-71
@@ -13,20 +13,22 @@ import {
|
||||
RotateCcw,
|
||||
Info,
|
||||
Workflow as WorkflowIcon,
|
||||
Maximize2,
|
||||
ChevronsDown,
|
||||
} from "lucide-react";
|
||||
import { LoadingState } from "@/components/ui/loading-state";
|
||||
import { WorkflowInputForm } from "@/components/workflow/workflow-input-form";
|
||||
import { WorkflowInputForm } from "./workflow-input-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { WorkflowFlow } from "@/components/workflow/workflow-flow";
|
||||
import { WorkflowFlow } from "./workflow-flow";
|
||||
import { useWorkflowEventCorrelation } from "@/hooks/useWorkflowEventCorrelation";
|
||||
import { WorkflowDetailsModal } from "@/components/shared/workflow-details-modal";
|
||||
import { WorkflowDetailsModal } from "./workflow-details-modal";
|
||||
import { apiClient } from "@/services/api";
|
||||
import type {
|
||||
WorkflowInfo,
|
||||
ExtendedResponseStreamEvent,
|
||||
JSONSchemaProperty,
|
||||
} from "@/types";
|
||||
import type { ExecutorNodeData } from "@/components/workflow/executor-node";
|
||||
import type { ExecutorNodeData } from "./executor-node";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -269,8 +271,17 @@ export function WorkflowView({
|
||||
useState<ExecutorNodeData | null>(null);
|
||||
const [workflowResult, setWorkflowResult] = useState<string>("");
|
||||
const [workflowError, setWorkflowError] = useState<string>("");
|
||||
const accumulatedText = useRef<string>("");
|
||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||
const [resultModalOpen, setResultModalOpen] = useState(false);
|
||||
const [errorModalOpen, setErrorModalOpen] = useState(false);
|
||||
const resultContentRef = useRef<HTMLDivElement>(null);
|
||||
const errorContentRef = useRef<HTMLDivElement>(null);
|
||||
const [isErrorScrollable, setIsErrorScrollable] = useState(false);
|
||||
|
||||
// Track per-executor outputs and workflow metadata
|
||||
const executorOutputs = useRef<Record<string, string>>({});
|
||||
const currentStreamingExecutor = useRef<string | null>(null);
|
||||
const workflowMetadata = useRef<Record<string, unknown> | null>(null);
|
||||
|
||||
// Panel resize state
|
||||
const [bottomPanelHeight, setBottomPanelHeight] = useState(() => {
|
||||
@@ -312,6 +323,40 @@ export function WorkflowView({
|
||||
localStorage.setItem("workflowLayoutDirection", layoutDirection);
|
||||
}, [layoutDirection]);
|
||||
|
||||
// Auto-scroll output panel when new content arrives (if user is at bottom)
|
||||
useEffect(() => {
|
||||
const handleAutoScroll = () => {
|
||||
if (resultContentRef.current) {
|
||||
const container = resultContentRef.current;
|
||||
const isScrollable = container.scrollHeight > container.clientHeight;
|
||||
|
||||
// Check if user is near the bottom (within 100px threshold)
|
||||
const scrollBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
const isNearBottom = scrollBottom < 100;
|
||||
|
||||
// Auto-scroll smoothly if user is near bottom and content is streaming
|
||||
if (isStreaming && isNearBottom && isScrollable) {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (errorContentRef.current) {
|
||||
const isScrollable =
|
||||
errorContentRef.current.scrollHeight >
|
||||
errorContentRef.current.clientHeight;
|
||||
setIsErrorScrollable(isScrollable);
|
||||
}
|
||||
};
|
||||
|
||||
handleAutoScroll();
|
||||
// Recheck on window resize
|
||||
window.addEventListener("resize", handleAutoScroll);
|
||||
return () => window.removeEventListener("resize", handleAutoScroll);
|
||||
}, [workflowResult, workflowError, bottomPanelHeight, isStreaming]);
|
||||
|
||||
// View option handlers
|
||||
const toggleViewOption = (key: keyof typeof viewOptions) => {
|
||||
setViewOptions((prev: typeof viewOptions) => ({
|
||||
@@ -330,8 +375,8 @@ export function WorkflowView({
|
||||
const info = await apiClient.getWorkflowInfo(selectedWorkflow.id);
|
||||
setWorkflowInfo(info);
|
||||
} catch (error) {
|
||||
console.error("Failed to load workflow info:", error);
|
||||
setWorkflowInfo(null);
|
||||
console.error("Error loading workflow info:", error);
|
||||
} finally {
|
||||
setWorkflowLoading(false);
|
||||
}
|
||||
@@ -343,7 +388,9 @@ export function WorkflowView({
|
||||
setSelectedExecutor(null);
|
||||
setWorkflowResult("");
|
||||
setWorkflowError("");
|
||||
accumulatedText.current = "";
|
||||
executorOutputs.current = {};
|
||||
currentStreamingExecutor.current = null;
|
||||
workflowMetadata.current = null;
|
||||
|
||||
loadWorkflowInfo();
|
||||
}, [selectedWorkflow.id, selectedWorkflow.type]);
|
||||
@@ -351,6 +398,14 @@ export function WorkflowView({
|
||||
const handleNodeSelect = (executorId: string, data: ExecutorNodeData) => {
|
||||
setSelectedExecutor(data);
|
||||
selectExecutor(executorId);
|
||||
|
||||
// Update result display to show selected executor's output
|
||||
if (executorOutputs.current[executorId]) {
|
||||
// Show per-executor output if available
|
||||
setWorkflowResult(executorOutputs.current[executorId]);
|
||||
}
|
||||
// Note: For executors without output, we don't clear workflowResult
|
||||
// This preserves the workflow's final output for display
|
||||
};
|
||||
|
||||
// Extract workflow events from OpenAI events for executor tracking
|
||||
@@ -360,29 +415,39 @@ export function WorkflowView({
|
||||
);
|
||||
}, [openAIEvents]);
|
||||
|
||||
// Extract executor history from workflow events
|
||||
// Extract executor history from workflow events (filter out workflow-level events)
|
||||
const executorHistory = useMemo(() => {
|
||||
return workflowEvents.map((event) => {
|
||||
if ("data" in event && event.data && typeof event.data === "object") {
|
||||
const data = event.data as Record<string, unknown>;
|
||||
return workflowEvents
|
||||
.filter((event) => {
|
||||
if ("data" in event && event.data && typeof event.data === "object") {
|
||||
const data = event.data as Record<string, unknown>;
|
||||
// Filter out workflow-level events (those without executor_id)
|
||||
// These include: WorkflowStartedEvent, WorkflowOutputEvent, WorkflowStatusEvent, etc.
|
||||
return data.executor_id != null;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map((event) => {
|
||||
if ("data" in event && event.data && typeof event.data === "object") {
|
||||
const data = event.data as Record<string, unknown>;
|
||||
return {
|
||||
executorId: String(data.executor_id),
|
||||
message: String(data.event_type || "Processing"),
|
||||
timestamp: String(data.timestamp || new Date().toISOString()),
|
||||
status: String(data.event_type || "").includes("Completed")
|
||||
? ("completed" as const)
|
||||
: String(data.event_type || "").includes("Error")
|
||||
? ("error" as const)
|
||||
: ("running" as const),
|
||||
};
|
||||
}
|
||||
return {
|
||||
executorId: String(data.executor_id || "unknown"),
|
||||
message: String(data.event_type || "Processing"),
|
||||
timestamp: String(data.timestamp || new Date().toISOString()),
|
||||
status: String(data.event_type || "").includes("Completed")
|
||||
? ("completed" as const)
|
||||
: String(data.event_type || "").includes("Error")
|
||||
? ("error" as const)
|
||||
: ("running" as const),
|
||||
executorId: "unknown",
|
||||
message: "Processing",
|
||||
timestamp: new Date().toISOString(),
|
||||
status: "running" as const,
|
||||
};
|
||||
}
|
||||
return {
|
||||
executorId: "unknown",
|
||||
message: "Processing",
|
||||
timestamp: new Date().toISOString(),
|
||||
status: "running" as const,
|
||||
};
|
||||
});
|
||||
});
|
||||
}, [workflowEvents]);
|
||||
|
||||
// Track active executors
|
||||
@@ -441,7 +506,11 @@ export function WorkflowView({
|
||||
setOpenAIEvents([]); // Clear previous OpenAI events for new execution
|
||||
setWorkflowResult("");
|
||||
setWorkflowError("");
|
||||
accumulatedText.current = "";
|
||||
|
||||
// Clear per-executor outputs and metadata for new run
|
||||
executorOutputs.current = {};
|
||||
currentStreamingExecutor.current = null;
|
||||
workflowMetadata.current = null;
|
||||
|
||||
// Clear debug panel events for new workflow run
|
||||
onDebugEvent("clear");
|
||||
@@ -456,23 +525,16 @@ export function WorkflowView({
|
||||
);
|
||||
|
||||
for await (const openAIEvent of streamGenerator) {
|
||||
// Store all events for processing
|
||||
setOpenAIEvents((prev) => [...prev, openAIEvent]);
|
||||
// Only store workflow events in state for performance
|
||||
// Text deltas are processed directly without state updates
|
||||
if (openAIEvent.type === "response.workflow_event.complete") {
|
||||
setOpenAIEvents((prev) => [...prev, openAIEvent]);
|
||||
}
|
||||
|
||||
// Pass to debug panel
|
||||
onDebugEvent(openAIEvent);
|
||||
|
||||
// Handle text output for workflow result
|
||||
if (
|
||||
openAIEvent.type === "response.output_text.delta" &&
|
||||
"delta" in openAIEvent &&
|
||||
openAIEvent.delta
|
||||
) {
|
||||
accumulatedText.current += openAIEvent.delta;
|
||||
setWorkflowResult(accumulatedText.current);
|
||||
}
|
||||
|
||||
// Handle workflow completion with final result
|
||||
// Handle workflow events to track current executor
|
||||
if (
|
||||
openAIEvent.type === "response.workflow_event.complete" &&
|
||||
"data" in openAIEvent &&
|
||||
@@ -481,13 +543,72 @@ export function WorkflowView({
|
||||
const data = openAIEvent.data as {
|
||||
event_type?: string;
|
||||
data?: unknown;
|
||||
executor_id?: string | null;
|
||||
};
|
||||
|
||||
// Track when executor starts (to know which executor is streaming)
|
||||
if (
|
||||
data.event_type === "ExecutorInvokedEvent" &&
|
||||
data.executor_id
|
||||
) {
|
||||
currentStreamingExecutor.current = data.executor_id;
|
||||
// Initialize output for this executor if not exists
|
||||
if (!executorOutputs.current[data.executor_id]) {
|
||||
executorOutputs.current[data.executor_id] = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Handle workflow completion and output events
|
||||
if (
|
||||
(data.event_type === "WorkflowCompletedEvent" ||
|
||||
data.event_type === "WorkflowOutputEvent") &&
|
||||
data.data
|
||||
) {
|
||||
setWorkflowResult(String(data.data));
|
||||
// For workflows that don't emit text deltas (e.g., ctx.yield_output),
|
||||
// the WorkflowOutputEvent contains the final output
|
||||
if (typeof data.data === "string") {
|
||||
setWorkflowResult(data.data);
|
||||
} else {
|
||||
// Store object data and display as formatted JSON
|
||||
workflowMetadata.current = data.data as Record<string, unknown>;
|
||||
const jsonOutput = JSON.stringify(data.data, null, 2);
|
||||
setWorkflowResult(jsonOutput);
|
||||
}
|
||||
currentStreamingExecutor.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle text output - assign to current executor
|
||||
if (
|
||||
openAIEvent.type === "response.output_text.delta" &&
|
||||
"delta" in openAIEvent &&
|
||||
openAIEvent.delta
|
||||
) {
|
||||
// Determine which executor owns this text
|
||||
const executorId = currentStreamingExecutor.current;
|
||||
|
||||
if (executorId) {
|
||||
// Initialize executor output if needed
|
||||
if (!executorOutputs.current[executorId]) {
|
||||
executorOutputs.current[executorId] = "";
|
||||
}
|
||||
|
||||
// Append to specific executor's output
|
||||
executorOutputs.current[executorId] += openAIEvent.delta;
|
||||
|
||||
// Update display based on what should be shown
|
||||
if (
|
||||
selectedExecutor &&
|
||||
executorOutputs.current[selectedExecutor.executorId]
|
||||
) {
|
||||
// If user has selected an executor, show that executor's output
|
||||
setWorkflowResult(
|
||||
executorOutputs.current[selectedExecutor.executorId]
|
||||
);
|
||||
} else {
|
||||
// Otherwise show current streaming executor's output
|
||||
setWorkflowResult(executorOutputs.current[executorId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,17 +623,15 @@ export function WorkflowView({
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended
|
||||
setIsStreaming(false);
|
||||
} catch (error) {
|
||||
console.error("Workflow execution failed:", error);
|
||||
setWorkflowError(
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
);
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
[selectedWorkflow, onDebugEvent]
|
||||
[selectedWorkflow, onDebugEvent, workflowInfo]
|
||||
);
|
||||
|
||||
// Show loading state when workflow is being loaded
|
||||
@@ -594,7 +713,7 @@ export function WorkflowView({
|
||||
{workflowInfo?.workflow_dump && (
|
||||
<WorkflowFlow
|
||||
workflowDump={workflowInfo.workflow_dump}
|
||||
events={openAIEvents}
|
||||
events={workflowEvents}
|
||||
isStreaming={isStreaming}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
className="h-full"
|
||||
@@ -626,17 +745,17 @@ export function WorkflowView({
|
||||
|
||||
{/* Bottom Panel - Execution Details */}
|
||||
<div
|
||||
className="flex-shrink-0 border-t"
|
||||
className="flex-shrink-0 border-t overflow-hidden"
|
||||
style={{ height: `${bottomPanelHeight}px` }}
|
||||
>
|
||||
{/* Full Width - Execution Details */}
|
||||
<div className="flex-1 min-w-0 p-4 overflow-auto">
|
||||
<div className="h-full flex gap-4 p-4">
|
||||
{selectedExecutor ||
|
||||
activeExecutors.length > 0 ||
|
||||
executorHistory.length > 0 ||
|
||||
workflowResult ||
|
||||
workflowError ? (
|
||||
<div className="h-full flex gap-4">
|
||||
<>
|
||||
{/* Current/Last Executor Panel */}
|
||||
{(selectedExecutor ||
|
||||
activeExecutors.length > 0 ||
|
||||
@@ -831,28 +950,106 @@ export function WorkflowView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enhanced Result Display */}
|
||||
{workflowResult && (
|
||||
<div className="border-2 border-emerald-300 dark:border-emerald-600 rounded bg-emerald-50 dark:bg-emerald-950/50 shadow flex-1 flex flex-col">
|
||||
<div className="border-b border-emerald-300 dark:border-emerald-600 px-4 py-3 bg-emerald-100 dark:bg-emerald-900/50 rounded-t flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
|
||||
<h4 className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
|
||||
Workflow Complete
|
||||
</h4>
|
||||
{/* Output Panel - displays workflow execution results and streaming output */}
|
||||
{workflowResult &&
|
||||
(() => {
|
||||
// Determine the panel state and styling
|
||||
const isStreamingState =
|
||||
isStreaming && currentStreamingExecutor.current;
|
||||
const isSelectedExecutor = !isStreaming && selectedExecutor;
|
||||
|
||||
// Define theme based on state - use colors sparingly (borders/icons only, not text)
|
||||
const theme = isStreamingState
|
||||
? {
|
||||
// Purple theme when streaming (matches running node color #643FB2)
|
||||
border: "border-[#643FB2]/40 dark:border-[#8B5CF6]/40",
|
||||
bg: "bg-[#643FB2]/5 dark:bg-[#8B5CF6]/5",
|
||||
headerBg: "bg-[#643FB2]/10 dark:bg-[#8B5CF6]/10",
|
||||
icon: (
|
||||
<Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin" />
|
||||
),
|
||||
buttonBg: "bg-background dark:bg-background",
|
||||
buttonBorder:
|
||||
"border-[#643FB2]/30 dark:border-[#8B5CF6]/30",
|
||||
buttonHover:
|
||||
"hover:bg-[#643FB2]/10 dark:hover:bg-[#8B5CF6]/10",
|
||||
}
|
||||
: isSelectedExecutor
|
||||
? {
|
||||
// Blue theme when executor selected (matches selected node ring blue-500)
|
||||
border: "border-blue-500/40 dark:border-blue-500/40",
|
||||
bg: "bg-blue-500/5 dark:bg-blue-500/5",
|
||||
headerBg: "bg-blue-500/10 dark:bg-blue-500/10",
|
||||
icon: (
|
||||
<Info className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
||||
),
|
||||
buttonBg: "bg-background dark:bg-background",
|
||||
buttonBorder:
|
||||
"border-blue-500/30 dark:border-blue-500/30",
|
||||
buttonHover:
|
||||
"hover:bg-blue-500/10 dark:hover:bg-blue-500/10",
|
||||
}
|
||||
: {
|
||||
// Green theme when workflow complete (matches completed node green-500)
|
||||
border: "border-green-500/40 dark:border-green-400/40",
|
||||
bg: "bg-green-500/5 dark:bg-green-400/5",
|
||||
headerBg: "bg-green-500/10 dark:bg-green-400/10",
|
||||
icon: (
|
||||
<CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />
|
||||
),
|
||||
buttonBg: "bg-background dark:bg-background",
|
||||
buttonBorder:
|
||||
"border-green-500/30 dark:border-green-400/30",
|
||||
buttonHover:
|
||||
"hover:bg-green-500/10 dark:hover:bg-green-400/10",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-2 ${theme.border} rounded ${theme.bg} shadow flex-1 flex flex-col min-w-0 relative`}
|
||||
>
|
||||
<div
|
||||
className={`border-b ${theme.border} px-4 py-3 ${theme.headerBg} rounded-t flex-shrink-0`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{theme.icon}
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{isStreamingState
|
||||
? `Output: ${currentStreamingExecutor.current}`
|
||||
: isSelectedExecutor
|
||||
? `Output: ${selectedExecutor.executorId}`
|
||||
: "Workflow Complete"}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={resultContentRef}
|
||||
className="p-4 overflow-auto flex-1 min-h-0 relative"
|
||||
>
|
||||
<div className="text-foreground whitespace-pre-wrap break-words text-sm pb-12">
|
||||
{workflowResult}
|
||||
</div>
|
||||
</div>
|
||||
{/* Sticky "View Full" button - always visible at bottom-right */}
|
||||
<div className="absolute bottom-3 right-3 pointer-events-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setResultModalOpen(true)}
|
||||
className={`h-8 px-3 ${theme.buttonBg} ${theme.buttonBorder} ${theme.buttonHover} shadow-md`}
|
||||
title="Expand to full view"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5 mr-1.5" />
|
||||
View Full
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 overflow-auto flex-1">
|
||||
<div className="text-emerald-700 dark:text-emerald-300 whitespace-pre-wrap break-words text-sm">
|
||||
{workflowResult}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Enhanced Error Display */}
|
||||
{workflowError && (
|
||||
<div className="border-2 border-destructive/70 rounded bg-destructive/5 shadow flex-1 flex flex-col">
|
||||
<div className="border-2 border-destructive/70 rounded bg-destructive/5 shadow flex-1 flex flex-col min-w-0 relative">
|
||||
<div className="border-b border-destructive/70 px-4 py-3 bg-destructive/10 rounded-t flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="w-4 h-4 text-destructive" />
|
||||
@@ -861,17 +1058,42 @@ export function WorkflowView({
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 overflow-auto flex-1">
|
||||
<div className="text-destructive whitespace-pre-wrap break-words text-sm">
|
||||
<div
|
||||
ref={errorContentRef}
|
||||
className="p-4 overflow-auto flex-1 min-h-0 relative"
|
||||
>
|
||||
<div className="text-destructive whitespace-pre-wrap break-words text-sm pb-12">
|
||||
{workflowError}
|
||||
</div>
|
||||
</div>
|
||||
{/* Sticky "View Full" button - always visible at bottom-right */}
|
||||
<div className="absolute bottom-3 right-3 pointer-events-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setErrorModalOpen(true)}
|
||||
className="h-8 px-3 bg-destructive/10 dark:bg-destructive/20 border-destructive/50 text-destructive hover:bg-destructive/20 dark:hover:bg-destructive/30 shadow-md"
|
||||
title="Expand to full view"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5 mr-1.5" />
|
||||
View Full
|
||||
</Button>
|
||||
</div>
|
||||
{/* Scroll indicator - only show when scrollable */}
|
||||
{isErrorScrollable && (
|
||||
<div className="absolute bottom-14 left-1/2 transform -translate-x-1/2 pointer-events-none">
|
||||
<div className="bg-destructive/80 text-white px-2 py-1 rounded-full flex items-center gap-1 text-xs animate-bounce">
|
||||
<ChevronsDown className="w-3 h-3" />
|
||||
<span>Scroll for more</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<p>Select a workflow to see execution details</p>
|
||||
<p>Select a workflow node (executor) to see execution details</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -883,6 +1105,93 @@ export function WorkflowView({
|
||||
open={detailsModalOpen}
|
||||
onOpenChange={setDetailsModalOpen}
|
||||
/>
|
||||
|
||||
{/* Result Full View Modal */}
|
||||
<Dialog open={resultModalOpen} onOpenChange={setResultModalOpen}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
|
||||
Workflow Results
|
||||
</DialogTitle>
|
||||
<DialogClose onClose={() => setResultModalOpen(false)} />
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6 overflow-y-auto flex-1">
|
||||
<div className="space-y-4">
|
||||
{/* Show per-executor outputs if we have multiple executors with output */}
|
||||
{Object.values(executorOutputs.current).some(
|
||||
(output) => output && output.trim().length > 0
|
||||
) ? (
|
||||
Object.entries(executorOutputs.current).map(
|
||||
([executorId, output]) =>
|
||||
output && (
|
||||
<div
|
||||
key={executorId}
|
||||
className="border-2 border-emerald-300 dark:border-emerald-600 rounded overflow-hidden"
|
||||
>
|
||||
<div className="bg-emerald-100 dark:bg-emerald-900/50 px-4 py-2 border-b border-emerald-300 dark:border-emerald-600">
|
||||
<h5 className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
|
||||
{executorId}
|
||||
</h5>
|
||||
</div>
|
||||
<div className="bg-emerald-50 dark:bg-emerald-950/50 p-4">
|
||||
<div className="text-emerald-700 dark:text-emerald-300 whitespace-pre-wrap break-words text-sm font-mono">
|
||||
{output}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
/* Show workflow result for simple workflows without per-executor tracking */
|
||||
<div className="bg-emerald-50 dark:bg-emerald-950/50 rounded border-2 border-emerald-300 dark:border-emerald-600 p-6">
|
||||
<div className="text-emerald-700 dark:text-emerald-300 whitespace-pre-wrap break-words text-sm font-mono">
|
||||
{workflowResult || "No output available"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show workflow output if available */}
|
||||
{workflowMetadata.current && (
|
||||
<div className="border-2 border-blue-300 dark:border-blue-600 rounded overflow-hidden">
|
||||
<div className="bg-blue-100 dark:bg-blue-900/50 px-4 py-2 border-b border-blue-300 dark:border-blue-600">
|
||||
<h5 className="text-sm font-semibold text-blue-800 dark:text-blue-200">
|
||||
Workflow Output (Structured)
|
||||
</h5>
|
||||
</div>
|
||||
<div className="bg-blue-50 dark:bg-blue-950/50 p-4">
|
||||
<pre className="text-blue-700 dark:text-blue-300 whitespace-pre-wrap break-words text-xs font-mono">
|
||||
{JSON.stringify(workflowMetadata.current, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Error Full View Modal */}
|
||||
<Dialog open={errorModalOpen} onOpenChange={setErrorModalOpen}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-destructive" />
|
||||
Workflow Error
|
||||
</DialogTitle>
|
||||
<DialogClose onClose={() => setErrorModalOpen(false)} />
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6 overflow-y-auto flex-1">
|
||||
<div className="bg-destructive/5 rounded border-2 border-destructive/70 p-6">
|
||||
<div className="text-destructive whitespace-pre-wrap break-words text-sm font-mono">
|
||||
{workflowError}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EntitySelector } from "@/components/shared/entity-selector";
|
||||
import { EntitySelector } from "./entity-selector";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { Settings } from "lucide-react";
|
||||
import type { AgentInfo, WorkflowInfo } from "@/types";
|
||||
@@ -73,7 +73,7 @@ export function AppHeader({
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<ModeToggle />
|
||||
<Button variant="ghost" size="sm" onClick={onSettingsClick}>
|
||||
<Button variant="ghost" size="sm" onClick={(e: React.MouseEvent) => { e.stopPropagation(); onSettingsClick?.(); }}>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
+213
-111
@@ -32,12 +32,6 @@ interface EventDataBase {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FunctionResultData extends EventDataBase {
|
||||
result?: unknown;
|
||||
status?: "completed" | "failed";
|
||||
exception?: string;
|
||||
}
|
||||
|
||||
interface FunctionCallData extends EventDataBase {
|
||||
name?: string;
|
||||
arguments?: string | object;
|
||||
@@ -70,6 +64,24 @@ interface DebugPanelProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// Helper: Extract function result from DevUI custom format
|
||||
function getFunctionResultFromEvent(event: ExtendedResponseStreamEvent): {
|
||||
call_id: string;
|
||||
output: string;
|
||||
status: string;
|
||||
} | null {
|
||||
if (event.type === "response.function_result.complete") {
|
||||
const resultEvent =
|
||||
event as import("@/types").ResponseFunctionResultComplete;
|
||||
return {
|
||||
call_id: resultEvent.call_id,
|
||||
output: resultEvent.output,
|
||||
status: resultEvent.status,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to accumulate OpenAI events into meaningful units
|
||||
function processEventsForDisplay(
|
||||
events: ExtendedResponseStreamEvent[]
|
||||
@@ -81,22 +93,53 @@ function processEventsForDisplay(
|
||||
name?: string;
|
||||
arguments: string;
|
||||
callId: string;
|
||||
itemId?: string; // Track item_id for delta matching
|
||||
timestamp: string;
|
||||
}
|
||||
>();
|
||||
const callIdToName = new Map<string, string>(); // Track call_id -> function name mappings
|
||||
let accumulatedText = "";
|
||||
const lastFunctionCallId: string | null = null; // Track the most recent function call
|
||||
|
||||
for (const event of events) {
|
||||
// Handle response.output_item.added - NEW! Extract function call metadata
|
||||
if (event.type === "response.output_item.added") {
|
||||
const outputEvent = event as import("@/types").ResponseOutputItemAddedEvent;
|
||||
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;
|
||||
|
||||
// Initialize function call tracking with REAL function name from backend!
|
||||
functionCalls.set(callId, {
|
||||
name: item.name, // ← REAL NAME! (not "unknown")
|
||||
arguments: "",
|
||||
callId: callId,
|
||||
itemId: item.id, // Track item_id for delta matching
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Also track in callIdToName map for result pairing
|
||||
callIdToName.set(callId, item.name);
|
||||
}
|
||||
|
||||
// Pass through the event for display
|
||||
processedEvents.push(event);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a function result (OpenAI standard format)
|
||||
const isFunctionResult = getFunctionResultFromEvent(event) !== null;
|
||||
|
||||
// Always show completion, error, workflow events, and function results
|
||||
if (
|
||||
event.type === "response.completed" ||
|
||||
event.type === "response.done" ||
|
||||
event.type === "error" ||
|
||||
event.type === "response.workflow_event.complete" ||
|
||||
event.type === "response.trace_event.complete" ||
|
||||
event.type === "response.trace.complete" ||
|
||||
event.type === "response.function_result.complete"
|
||||
isFunctionResult
|
||||
) {
|
||||
// Flush any accumulated text before showing these events
|
||||
if (accumulatedText.trim()) {
|
||||
@@ -140,12 +183,9 @@ function processEventsForDisplay(
|
||||
}
|
||||
|
||||
// For function results, ensure we have the corresponding function call
|
||||
if (
|
||||
event.type === "response.function_result.complete" &&
|
||||
"data" in event
|
||||
) {
|
||||
const resultData = event.data as FunctionResultData;
|
||||
const callId = resultData.call_id;
|
||||
const functionResult = getFunctionResultFromEvent(event);
|
||||
if (functionResult) {
|
||||
const callId = functionResult.call_id;
|
||||
|
||||
// Only create function call event if we have actual argument data
|
||||
if (callId && functionCalls.has(callId)) {
|
||||
@@ -198,55 +238,52 @@ function processEventsForDisplay(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle function call arguments accumulation - ACTUAL BACKEND FORMAT
|
||||
// Handle function call arguments accumulation - UPDATED to use item_id
|
||||
if (event.type === "response.function_call_arguments.delta") {
|
||||
let deltaData: string = "";
|
||||
let callId: string;
|
||||
let callId: string | null = null;
|
||||
|
||||
// Extract delta from actual backend format
|
||||
if ("delta" in event && typeof event.delta === "string") {
|
||||
deltaData = event.delta;
|
||||
}
|
||||
|
||||
// Use a simple tracking approach since backend doesn't provide call_id in argument deltas
|
||||
if (
|
||||
"data" in event &&
|
||||
event.data &&
|
||||
(event.data as EventDataBase).call_id
|
||||
) {
|
||||
callId = String((event.data as EventDataBase).call_id);
|
||||
} else {
|
||||
callId = lastFunctionCallId || `call_${Date.now()}`;
|
||||
// NEW: Use item_id to find the matching function call
|
||||
// Since backend now uses call_id as item_id, we can match directly
|
||||
if ("item_id" in event && event.item_id) {
|
||||
const itemId = event.item_id;
|
||||
|
||||
// Find function call by item_id (which equals call_id in our implementation)
|
||||
for (const [cId, call] of functionCalls.entries()) {
|
||||
if (call.itemId === itemId || cId === itemId) {
|
||||
callId = cId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deltaData && callId) {
|
||||
// Ensure we have a function call entry
|
||||
if (!functionCalls.has(callId)) {
|
||||
functionCalls.set(callId, {
|
||||
name: "unknown", // Backend doesn't provide function name in these events
|
||||
arguments: "",
|
||||
callId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
const call = functionCalls.get(callId);
|
||||
|
||||
// Accumulate the delta
|
||||
const call = functionCalls.get(callId)!;
|
||||
if (call) {
|
||||
// Function name should already be set from output_item.added event
|
||||
// Just accumulate arguments
|
||||
|
||||
// Skip the initial "{}" delta that backend sends
|
||||
if (deltaData === "{}" && call.arguments === "") {
|
||||
continue;
|
||||
}
|
||||
// Skip the initial "{}" delta that backend sends
|
||||
if (deltaData === "{}" && call.arguments === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accumulate and clean up the delta
|
||||
let cleanedDelta = deltaData;
|
||||
try {
|
||||
// Remove extra quotes and escaping that backend adds
|
||||
cleanedDelta = deltaData.replace(/^"|"$/g, "").replace(/\\"/g, '"');
|
||||
} catch {
|
||||
cleanedDelta = deltaData;
|
||||
// Accumulate the delta (no cleaning needed - use raw delta)
|
||||
call.arguments += deltaData;
|
||||
} else {
|
||||
// Shouldn't happen if output_item.added was emitted first
|
||||
console.warn(
|
||||
`Received argument delta for unknown call with item_id: ${
|
||||
"item_id" in event ? event.item_id : "unknown"
|
||||
}`
|
||||
);
|
||||
}
|
||||
call.arguments += cleanedDelta;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -351,20 +388,22 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
|
||||
}
|
||||
return "Function arguments...";
|
||||
|
||||
case "response.function_result.complete":
|
||||
if ("data" in event && event.data) {
|
||||
const data = event.data as FunctionResultData;
|
||||
const resultStr = data.result
|
||||
? typeof data.result === "string"
|
||||
? data.result
|
||||
: JSON.stringify(data.result)
|
||||
: "no result";
|
||||
const truncated = resultStr.slice(0, 40);
|
||||
case "response.output_item.added": {
|
||||
const result = getFunctionResultFromEvent(event);
|
||||
if (result) {
|
||||
const truncated = result.output.slice(0, 40);
|
||||
return `Tool result: ${truncated}${
|
||||
truncated.length >= 40 ? "..." : ""
|
||||
}`;
|
||||
}
|
||||
return "Function result";
|
||||
// Could also be a function call
|
||||
const addedEvent =
|
||||
event as import("@/types").ResponseOutputItemAddedEvent;
|
||||
if (addedEvent.item.type === "function_call") {
|
||||
return `Tool call: ${addedEvent.item.name}`;
|
||||
}
|
||||
return "Output item added";
|
||||
}
|
||||
|
||||
case "response.workflow_event.complete":
|
||||
if ("data" in event && event.data) {
|
||||
@@ -381,6 +420,16 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
|
||||
}
|
||||
return "Trace event";
|
||||
|
||||
case "response.completed":
|
||||
if ("response" in event && event.response && "usage" in event.response) {
|
||||
const completedEvent = event as import("@/types").ResponseCompletedEvent;
|
||||
const usage = completedEvent.response.usage;
|
||||
if (usage) {
|
||||
return `Response complete (${usage.total_tokens} tokens)`;
|
||||
}
|
||||
}
|
||||
return "Response complete";
|
||||
|
||||
case "response.done":
|
||||
return "Response complete";
|
||||
|
||||
@@ -404,13 +453,15 @@ function getEventIcon(type: string) {
|
||||
case "response.function_call.delta":
|
||||
case "response.function_call_arguments.delta":
|
||||
return Wrench;
|
||||
case "response.function_result.complete":
|
||||
case "response.output_item.added":
|
||||
return CheckCircle2;
|
||||
case "response.workflow_event.complete":
|
||||
return Activity;
|
||||
case "response.trace_event.complete":
|
||||
case "response.trace.complete":
|
||||
return Search;
|
||||
case "response.completed":
|
||||
return CheckCircle2;
|
||||
case "response.done":
|
||||
return CheckCircle2;
|
||||
case "error":
|
||||
@@ -428,13 +479,15 @@ function getEventColor(type: string) {
|
||||
case "response.function_call.delta":
|
||||
case "response.function_call_arguments.delta":
|
||||
return "text-blue-600 dark:text-blue-400";
|
||||
case "response.function_result.complete":
|
||||
case "response.output_item.added":
|
||||
return "text-green-600 dark:text-green-400";
|
||||
case "response.workflow_event.complete":
|
||||
return "text-purple-600 dark:text-purple-400";
|
||||
case "response.trace_event.complete":
|
||||
case "response.trace.complete":
|
||||
return "text-orange-600 dark:text-orange-400";
|
||||
case "response.completed":
|
||||
return "text-green-600 dark:text-green-400";
|
||||
case "response.done":
|
||||
return "text-green-600 dark:text-green-400";
|
||||
case "error":
|
||||
@@ -456,9 +509,8 @@ function EventItem({ event }: EventItemProps) {
|
||||
(event.type === "response.function_call.complete" &&
|
||||
"data" in event &&
|
||||
event.data) ||
|
||||
(event.type === "response.function_result.complete" &&
|
||||
"data" in event &&
|
||||
event.data) ||
|
||||
(event.type === "response.output_item.added" &&
|
||||
getFunctionResultFromEvent(event) !== null) ||
|
||||
(event.type === "response.workflow_event.complete" &&
|
||||
"data" in event &&
|
||||
event.data) ||
|
||||
@@ -472,6 +524,9 @@ function EventItem({ event }: EventItemProps) {
|
||||
"delta" in event &&
|
||||
event.delta &&
|
||||
event.delta.length > 100) ||
|
||||
(event.type === "response.completed" &&
|
||||
"response" in event &&
|
||||
event.response) ||
|
||||
// Make error events expandable to show full error details
|
||||
event.type === "error";
|
||||
|
||||
@@ -626,9 +681,9 @@ function EventExpandedContent({
|
||||
}
|
||||
break;
|
||||
|
||||
case "response.function_result.complete":
|
||||
if ("data" in event && event.data) {
|
||||
const data = event.data as FunctionResultData;
|
||||
case "response.output_item.added": {
|
||||
const result = getFunctionResultFromEvent(event);
|
||||
if (result) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -636,57 +691,42 @@ function EventExpandedContent({
|
||||
<span className="font-semibold text-sm">Function Result</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 text-xs">
|
||||
{data.call_id && (
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Call ID:
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs">{data.call_id}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Call ID:
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs">{result.call_id}</span>
|
||||
</div>
|
||||
<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 === "completed"
|
||||
result.status === "completed"
|
||||
? "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"}
|
||||
{result.status}
|
||||
</span>
|
||||
</div>
|
||||
{data.result !== undefined && (
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Result:
|
||||
</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">
|
||||
{typeof data.result === "string"
|
||||
? data.result
|
||||
: JSON.stringify(data.result, null, 1)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Output:
|
||||
</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">
|
||||
{result.output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{data.exception !== null && data.exception !== undefined && (
|
||||
<div>
|
||||
<span className="font-medium text-destructive">Error:</span>
|
||||
<div className="mt-1">
|
||||
<pre className="text-xs bg-destructive/10 border border-destructive/30 rounded p-2 text-destructive whitespace-pre-wrap break-all">
|
||||
{data.exception}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "response.workflow_event.complete":
|
||||
if ("data" in event && event.data) {
|
||||
@@ -873,6 +913,74 @@ function EventExpandedContent({
|
||||
}
|
||||
break;
|
||||
|
||||
case "response.completed":
|
||||
if ("response" in event && event.response) {
|
||||
const completedEvent = event as import("@/types").ResponseCompletedEvent;
|
||||
const response = completedEvent.response;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-1 gap-2 text-xs">
|
||||
{response.usage && (
|
||||
<>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Usage:
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-4 space-y-1">
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Input tokens:
|
||||
</span>
|
||||
<span className="ml-2 font-mono">
|
||||
{response.usage.input_tokens}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Output tokens:
|
||||
</span>
|
||||
<span className="ml-2 font-mono">
|
||||
{response.usage.output_tokens}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Total tokens:
|
||||
</span>
|
||||
<span className="ml-2 font-mono bg-green-100 dark:bg-green-900 px-2 py-1 rounded">
|
||||
{response.usage.total_tokens}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{response.id && (
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Response ID:
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs break-all">
|
||||
{response.id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{response.model && (
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Model:
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs break-all">
|
||||
{response.model}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@@ -978,7 +1086,7 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
</span>{" "}
|
||||
or restart devui with the tracing flag{" "}
|
||||
<div className="font-mono bg-accent/10 px-1 rounded">
|
||||
devui --enable-tracing
|
||||
devui --tracing
|
||||
</div>
|
||||
to enable tracing.
|
||||
</div>
|
||||
@@ -1198,21 +1306,15 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
(event) => event.type === "response.function_call.complete"
|
||||
);
|
||||
const functionResults = events.filter(
|
||||
(event) => event.type === "response.function_result.complete"
|
||||
(event) => getFunctionResultFromEvent(event) !== null
|
||||
);
|
||||
|
||||
// Create a map of call_id to results for easy lookup
|
||||
const resultsByCallId = new Map();
|
||||
functionResults.forEach((result) => {
|
||||
if (
|
||||
"data" in result &&
|
||||
result.data &&
|
||||
(result.data as EventDataBase).call_id
|
||||
) {
|
||||
resultsByCallId.set(
|
||||
String((result.data as EventDataBase).call_id),
|
||||
result
|
||||
);
|
||||
const resultData = getFunctionResultFromEvent(result);
|
||||
if (resultData) {
|
||||
resultsByCallId.set(resultData.call_id, result);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1277,7 +1379,7 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
|
||||
// Check if this is a function call event
|
||||
const isFunctionCall = event.type === "response.function_call.complete";
|
||||
const isFunctionResult = event.type === "response.function_result.complete";
|
||||
const isFunctionResult = getFunctionResultFromEvent(event) !== null;
|
||||
|
||||
if (!isFunctionCall && !isFunctionResult) {
|
||||
return null;
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Layout Components - Exports
|
||||
*/
|
||||
|
||||
export { AppHeader } from "./app-header";
|
||||
export { EntitySelector } from "./entity-selector";
|
||||
export { DebugPanel } from "./debug-panel";
|
||||
export { SettingsModal } from "./settings-modal";
|
||||
export { AboutModal } from "./about-modal";
|
||||
@@ -1,331 +0,0 @@
|
||||
/**
|
||||
* ContentRenderer - Renders individual content items based on type
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Download,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
Code,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Music,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { RenderProps } from "./types";
|
||||
import {
|
||||
isTextContent,
|
||||
isFunctionCallContent,
|
||||
isFunctionResultContent,
|
||||
} from "@/types/agent-framework";
|
||||
|
||||
function TextContentRenderer({ content, isStreaming, className }: RenderProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (!isTextContent(content)) return null;
|
||||
|
||||
const text = content.text;
|
||||
const TRUNCATE_LENGTH = 1600;
|
||||
const shouldTruncate = text.length > TRUNCATE_LENGTH && !isStreaming;
|
||||
const displayText =
|
||||
shouldTruncate && !isExpanded
|
||||
? text.slice(0, TRUNCATE_LENGTH) + "..."
|
||||
: text;
|
||||
|
||||
return (
|
||||
<div className={`whitespace-pre-wrap break-words ${className || ""}`}>
|
||||
<div
|
||||
className={
|
||||
isExpanded && shouldTruncate ? "max-h-96 overflow-y-auto" : ""
|
||||
}
|
||||
>
|
||||
{displayText}
|
||||
</div>
|
||||
{isStreaming && (
|
||||
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
{shouldTruncate && (
|
||||
<div className="flex justify-end mt-1">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="inline-flex items-center gap-1 text-xs
|
||||
bg-background/80 hover:bg-background border border-border/50 hover:border-border
|
||||
text-muted-foreground hover:text-foreground
|
||||
transition-colors cursor-pointer px-2 py-1 rounded"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
less <ChevronUp className="h-3 w-3" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{(text.length - TRUNCATE_LENGTH).toLocaleString()} more{" "}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataContentRenderer({ content, className }: RenderProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (content.type !== "data") return null;
|
||||
|
||||
// Extract data URI and media type (updated for new field names)
|
||||
const dataUri = typeof content.uri === "string" ? content.uri : "";
|
||||
const mediaTypeMatch = dataUri.match(/^data:([^;]+)/);
|
||||
const mediaType = content.media_type || mediaTypeMatch?.[1] || "unknown";
|
||||
|
||||
const isImage = mediaType.startsWith("image/");
|
||||
const isPdf = mediaType === "application/pdf";
|
||||
const isAudio = mediaType.startsWith("audio/");
|
||||
|
||||
if (isImage && !imageError) {
|
||||
return (
|
||||
<div className={`my-2 ${className || ""}`}>
|
||||
<img
|
||||
src={dataUri}
|
||||
alt="Uploaded image"
|
||||
className={`rounded-lg border max-w-full transition-all cursor-pointer ${
|
||||
isExpanded ? "max-h-none" : "max-h-64"
|
||||
}`}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{mediaType} • Click to {isExpanded ? "collapse" : "expand"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAudio) {
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-purple-50 dark:bg-purple-950/20 ${className || ""}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Music className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-purple-800 dark:text-purple-300">Audio File</span>
|
||||
<span className="text-xs text-muted-foreground">({mediaType})</span>
|
||||
</div>
|
||||
<audio controls className="w-full max-w-md">
|
||||
<source src={dataUri} type={mediaType} />
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for non-images/non-audio or failed images
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isPdf ? (
|
||||
<FileText className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{isPdf ? "PDF Document" : "File Attachment"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">({mediaType})</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUri;
|
||||
link.download = `attachment.${mediaType.split("/")[1] || "bin"}`;
|
||||
link.click();
|
||||
}}
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FunctionCallRenderer({ content, className }: RenderProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (!isFunctionCallContent(content)) return null;
|
||||
|
||||
let parsedArgs;
|
||||
try {
|
||||
parsedArgs =
|
||||
typeof content.arguments === "string"
|
||||
? JSON.parse(content.arguments)
|
||||
: content.arguments;
|
||||
} catch {
|
||||
parsedArgs = content.arguments;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-blue-50 ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Code className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-800">
|
||||
Function Call: {content.name}
|
||||
</span>
|
||||
<span className="text-xs text-blue-600">{isExpanded ? "▼" : "▶"}</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white p-2 rounded border">
|
||||
<div className="text-blue-600 mb-1">Arguments:</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{JSON.stringify(parsedArgs, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FunctionResultRenderer({ content, className }: RenderProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (!isFunctionResultContent(content)) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`my-2 p-3 border rounded-lg bg-green-50 ${className || ""}`}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Code className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm font-medium text-green-800">
|
||||
Function Result
|
||||
</span>
|
||||
<span className="text-xs text-green-600">{isExpanded ? "▼" : "▶"}</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white p-2 rounded border">
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{typeof content.result === "string"
|
||||
? content.result
|
||||
: JSON.stringify(content.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorContentRenderer({ content, className }: RenderProps) {
|
||||
if (content.type !== "error") return null;
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-red-50 ${className || ""}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||
<span className="text-sm font-medium text-red-800">Error</span>
|
||||
{content.error_code && (
|
||||
<span className="text-xs text-red-600">({content.error_code})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-red-700">{content.error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UriContentRenderer({ content, className }: RenderProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
if (content.type !== "uri") return null;
|
||||
|
||||
const isImage = content.media_type?.startsWith("image/");
|
||||
|
||||
if (isImage && !imageError) {
|
||||
return (
|
||||
<div className={`my-2 ${className || ""}`}>
|
||||
<img
|
||||
src={content.uri}
|
||||
alt="Referenced image"
|
||||
className="rounded-lg border max-w-full max-h-64"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
<a
|
||||
href={content.uri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{content.uri}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
<a
|
||||
href={content.uri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
{content.media_type || "External Link"}
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 break-all">
|
||||
{content.uri}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContentRenderer({
|
||||
content,
|
||||
isStreaming,
|
||||
className,
|
||||
}: RenderProps) {
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
return (
|
||||
<TextContentRenderer
|
||||
content={content}
|
||||
isStreaming={isStreaming}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
case "data":
|
||||
return <DataContentRenderer content={content} className={className} />;
|
||||
case "uri":
|
||||
return <UriContentRenderer content={content} className={className} />;
|
||||
case "function_call":
|
||||
return <FunctionCallRenderer content={content} className={className} />;
|
||||
case "function_result":
|
||||
return <FunctionResultRenderer content={content} className={className} />;
|
||||
case "error":
|
||||
return <ErrorContentRenderer content={content} className={className} />;
|
||||
default:
|
||||
// Fallback for unsupported content types
|
||||
return (
|
||||
<div
|
||||
className={`my-2 p-2 bg-gray-100 rounded text-xs ${className || ""}`}
|
||||
>
|
||||
<div>Unsupported content type: {content.type}</div>
|
||||
<pre className="mt-1 text-xs whitespace-pre-wrap">
|
||||
{JSON.stringify(content, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* MessageRenderer - Main orchestrator for rendering message contents
|
||||
*/
|
||||
|
||||
import { StreamingRenderer } from "./StreamingRenderer";
|
||||
import { ContentRenderer } from "./ContentRenderer";
|
||||
import type { MessageRendererProps } from "./types";
|
||||
|
||||
export function MessageRenderer({
|
||||
contents,
|
||||
isStreaming = false,
|
||||
className,
|
||||
}: MessageRendererProps) {
|
||||
// If not streaming, render each content item individually
|
||||
if (!isStreaming) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{contents.map((content, index) => (
|
||||
<ContentRenderer
|
||||
key={index}
|
||||
content={content}
|
||||
isStreaming={false}
|
||||
className={index > 0 ? "mt-2" : ""}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For streaming, use the streaming renderer for smart accumulation
|
||||
return (
|
||||
<StreamingRenderer
|
||||
contents={contents}
|
||||
isStreaming={isStreaming}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* StreamingRenderer - Handles accumulation and display of streaming content
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { ContentRenderer } from "./ContentRenderer";
|
||||
import type { Contents, MessageRenderState } from "./types";
|
||||
import { isTextContent } from "@/types/agent-framework";
|
||||
|
||||
interface StreamingRendererProps {
|
||||
contents: Contents[];
|
||||
isStreaming?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StreamingRenderer({
|
||||
contents,
|
||||
isStreaming = false,
|
||||
className,
|
||||
}: StreamingRendererProps) {
|
||||
const [renderState, setRenderState] = useState<MessageRenderState>({
|
||||
textAccumulator: "",
|
||||
dataContentItems: [],
|
||||
functionCalls: [],
|
||||
errors: [],
|
||||
isComplete: !isStreaming,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Process and accumulate content
|
||||
let textAccumulator = "";
|
||||
const dataContentItems: Contents[] = [];
|
||||
const functionCalls: Contents[] = [];
|
||||
const errors: Contents[] = [];
|
||||
|
||||
contents.forEach((content) => {
|
||||
if (isTextContent(content)) {
|
||||
textAccumulator += content.text;
|
||||
} else if (content.type === "data") {
|
||||
// Only show data content when streaming is complete or item is complete
|
||||
if (!isStreaming) {
|
||||
dataContentItems.push(content);
|
||||
}
|
||||
} else if (content.type === "function_call") {
|
||||
functionCalls.push(content);
|
||||
} else if (content.type === "error") {
|
||||
errors.push(content);
|
||||
} else {
|
||||
// Other content types (uri, function_result, etc.)
|
||||
dataContentItems.push(content);
|
||||
}
|
||||
});
|
||||
|
||||
setRenderState({
|
||||
textAccumulator,
|
||||
dataContentItems,
|
||||
functionCalls,
|
||||
errors,
|
||||
isComplete: !isStreaming,
|
||||
});
|
||||
}, [contents, isStreaming]);
|
||||
|
||||
const hasTextContent = renderState.textAccumulator.length > 0;
|
||||
const hasOtherContent =
|
||||
renderState.dataContentItems.length > 0 ||
|
||||
renderState.functionCalls.length > 0 ||
|
||||
renderState.errors.length > 0;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Render accumulated text with streaming indicator */}
|
||||
{hasTextContent && (
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{renderState.textAccumulator}
|
||||
{isStreaming && hasTextContent && (
|
||||
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render other content types when complete or non-data items immediately */}
|
||||
{hasOtherContent && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{renderState.errors.map((content, index) => (
|
||||
<ContentRenderer key={`error-${index}`} content={content} />
|
||||
))}
|
||||
|
||||
{renderState.functionCalls.map((content, index) => (
|
||||
<ContentRenderer key={`function-${index}`} content={content} />
|
||||
))}
|
||||
|
||||
{renderState.dataContentItems.map((content, index) => (
|
||||
<ContentRenderer
|
||||
key={`data-${index}`}
|
||||
content={content}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show loading indicator when streaming and no text content yet */}
|
||||
{isStreaming && !hasTextContent && !hasOtherContent && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="flex space-x-1">
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-current" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* Message Renderer - Exports
|
||||
*/
|
||||
|
||||
export { MessageRenderer } from "./MessageRenderer";
|
||||
export { ContentRenderer } from "./ContentRenderer";
|
||||
export { StreamingRenderer } from "./StreamingRenderer";
|
||||
export type { MessageRendererProps, RenderProps, MessageRenderState } from "./types";
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Types for message rendering components
|
||||
*/
|
||||
|
||||
// Re-export and extend types from agent-framework
|
||||
import type {
|
||||
Contents,
|
||||
TextContent,
|
||||
DataContent,
|
||||
UriContent,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
ErrorContent,
|
||||
AgentRunResponseUpdate,
|
||||
} from "@/types/agent-framework";
|
||||
|
||||
export type {
|
||||
Contents,
|
||||
TextContent,
|
||||
DataContent,
|
||||
UriContent,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
ErrorContent,
|
||||
AgentRunResponseUpdate,
|
||||
};
|
||||
|
||||
// UI-specific types for message rendering
|
||||
export interface MessageRenderState {
|
||||
// Track accumulated content during streaming
|
||||
textAccumulator: string;
|
||||
dataContentItems: Contents[];
|
||||
functionCalls: Contents[];
|
||||
errors: Contents[];
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
export interface RenderProps {
|
||||
content: Contents;
|
||||
isStreaming?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface MessageRendererProps {
|
||||
contents: Contents[];
|
||||
isStreaming?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -24,43 +24,13 @@ export interface SampleEntity {
|
||||
|
||||
export const SAMPLE_ENTITIES: SampleEntity[] = [
|
||||
// Beginner Agents
|
||||
{
|
||||
id: "weather-agent",
|
||||
name: "Weather Agent",
|
||||
description:
|
||||
"Simple weather agent with mock data demonstrating basic tool usage",
|
||||
type: "agent",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/weather_agent/agent.py",
|
||||
tags: ["openai", "tools", "basic"],
|
||||
author: "Microsoft",
|
||||
difficulty: "beginner",
|
||||
features: [
|
||||
"Function calling",
|
||||
"Mock weather data",
|
||||
"Simple tool integration",
|
||||
],
|
||||
requiredEnvVars: [
|
||||
{
|
||||
name: "OPENAI_API_KEY",
|
||||
description: "OpenAI API key for chat completions",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "OPENAI_CHAT_MODEL_ID",
|
||||
description: "OpenAI model ID (e.g., gpt-4o)",
|
||||
required: false,
|
||||
example: "gpt-4o",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: "foundry-weather-agent",
|
||||
name: "Azure AI Weather Agent",
|
||||
description:
|
||||
"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",
|
||||
type: "agent",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/foundry_agent/agent.py",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/foundry_agent/agent.py",
|
||||
tags: ["azure-ai", "foundry", "tools"],
|
||||
author: "Microsoft",
|
||||
difficulty: "beginner",
|
||||
@@ -91,7 +61,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [
|
||||
description:
|
||||
"Weather agent using Azure OpenAI with API key authentication",
|
||||
type: "agent",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/weather_agent_azure/agent.py",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/weather_agent_azure/agent.py",
|
||||
tags: ["azure", "openai", "tools"],
|
||||
author: "Microsoft",
|
||||
difficulty: "beginner",
|
||||
@@ -129,7 +99,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [
|
||||
description:
|
||||
"5-step workflow demonstrating email spam detection with branching logic",
|
||||
type: "workflow",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/spam_workflow/workflow.py",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/spam_workflow/workflow.py",
|
||||
tags: ["workflow", "branching", "multi-step"],
|
||||
author: "Microsoft",
|
||||
difficulty: "beginner",
|
||||
@@ -147,7 +117,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [
|
||||
description:
|
||||
"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",
|
||||
type: "workflow",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/fanout_workflow/workflow.py",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/fanout_workflow/workflow.py",
|
||||
tags: ["workflow", "fan-out", "fan-in", "parallel"],
|
||||
author: "Microsoft",
|
||||
difficulty: "advanced",
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
import type {
|
||||
AgentInfo,
|
||||
AgentSource,
|
||||
Conversation,
|
||||
HealthResponse,
|
||||
RunAgentRequest,
|
||||
RunWorkflowRequest,
|
||||
ThreadInfo,
|
||||
WorkflowInfo,
|
||||
} from "@/types";
|
||||
import type { AgentFrameworkRequest } from "@/types/agent-framework";
|
||||
@@ -45,23 +45,12 @@ interface DiscoveryResponse {
|
||||
entities: BackendEntityInfo[];
|
||||
}
|
||||
|
||||
interface ThreadApiResponse {
|
||||
// Conversation API types (OpenAI standard)
|
||||
interface ConversationApiResponse {
|
||||
id: string;
|
||||
object: "thread";
|
||||
object: "conversation";
|
||||
created_at: number;
|
||||
metadata: { agent_id: string };
|
||||
}
|
||||
|
||||
interface ThreadListResponse {
|
||||
object: "list";
|
||||
data: ThreadApiObject[];
|
||||
}
|
||||
|
||||
interface ThreadApiObject {
|
||||
id: string;
|
||||
object: "thread";
|
||||
agent_id: string;
|
||||
created_at?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
const DEFAULT_API_BASE_URL =
|
||||
@@ -217,36 +206,69 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
// Thread management using real /v1/threads endpoints
|
||||
async createThread(agentId: string): Promise<ThreadInfo> {
|
||||
const response = await this.request<ThreadApiResponse>("/v1/threads", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ agent_id: agentId }),
|
||||
});
|
||||
// ========================================
|
||||
// Conversation Management (OpenAI Standard)
|
||||
// ========================================
|
||||
|
||||
async createConversation(
|
||||
metadata?: Record<string, string>
|
||||
): Promise<Conversation> {
|
||||
const response = await this.request<ConversationApiResponse>(
|
||||
"/v1/conversations",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ metadata }),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
id: response.id,
|
||||
agent_id: agentId,
|
||||
created_at: new Date(response.created_at * 1000).toISOString(),
|
||||
message_count: 0,
|
||||
object: "conversation",
|
||||
created_at: response.created_at,
|
||||
metadata: response.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
async getThreads(agentId: string): Promise<ThreadInfo[]> {
|
||||
const response = await this.request<ThreadListResponse>(
|
||||
`/v1/threads?agent_id=${agentId}`
|
||||
);
|
||||
return response.data.map((thread: ThreadApiObject) => ({
|
||||
id: thread.id,
|
||||
agent_id: thread.agent_id,
|
||||
created_at: thread.created_at || new Date().toISOString(),
|
||||
message_count: 0, // We don't track this yet
|
||||
}));
|
||||
async listConversations(
|
||||
agentId?: string
|
||||
): Promise<{ data: Conversation[]; has_more: boolean }> {
|
||||
const url = agentId
|
||||
? `/v1/conversations?agent_id=${encodeURIComponent(agentId)}`
|
||||
: "/v1/conversations";
|
||||
|
||||
const response = await this.request<{
|
||||
object: "list";
|
||||
data: ConversationApiResponse[];
|
||||
has_more: boolean;
|
||||
}>(url);
|
||||
|
||||
return {
|
||||
data: response.data.map((conv) => ({
|
||||
id: conv.id,
|
||||
object: "conversation",
|
||||
created_at: conv.created_at,
|
||||
metadata: conv.metadata,
|
||||
})),
|
||||
has_more: response.has_more,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteThread(threadId: string): Promise<boolean> {
|
||||
async getConversation(conversationId: string): Promise<Conversation> {
|
||||
const response = await this.request<ConversationApiResponse>(
|
||||
`/v1/conversations/${conversationId}`
|
||||
);
|
||||
|
||||
return {
|
||||
id: response.id,
|
||||
object: "conversation",
|
||||
created_at: response.created_at,
|
||||
metadata: response.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteConversation(conversationId: string): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/v1/threads/${threadId}`, {
|
||||
await this.request(`/v1/conversations/${conversationId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return true;
|
||||
@@ -255,56 +277,35 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async getThreadMessages(
|
||||
threadId: string
|
||||
): Promise<import("@/types").ChatMessage[]> {
|
||||
try {
|
||||
const response = await this.request<{ data: unknown[] }>(
|
||||
`/v1/threads/${threadId}/messages`
|
||||
);
|
||||
async listConversationItems(
|
||||
conversationId: string,
|
||||
options?: { limit?: number; after?: string; order?: "asc" | "desc" }
|
||||
): Promise<{ data: unknown[]; has_more: boolean }> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit) params.set("limit", options.limit.toString());
|
||||
if (options?.after) params.set("after", options.after);
|
||||
if (options?.order) params.set("order", options.order);
|
||||
|
||||
// Convert API messages to ChatMessage format, handling missing fields
|
||||
return response.data.map((msg: unknown, index: number) => {
|
||||
const msgObj = msg as Record<string, unknown>;
|
||||
const role = msgObj.role as string;
|
||||
return {
|
||||
id: (msgObj.message_id as string) || `restored-${index}`,
|
||||
role:
|
||||
role === "user" ||
|
||||
role === "assistant" ||
|
||||
role === "system" ||
|
||||
role === "tool"
|
||||
? role
|
||||
: "user",
|
||||
contents:
|
||||
(msgObj.contents as import("@/types/agent-framework").Contents[]) ||
|
||||
[],
|
||||
timestamp: (msgObj.timestamp as string) || new Date().toISOString(),
|
||||
author_name: msgObj.author_name as string | undefined,
|
||||
message_id: msgObj.message_id as string | undefined,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to get thread messages:", error);
|
||||
return [];
|
||||
}
|
||||
const queryString = params.toString();
|
||||
const url = `/v1/conversations/${conversationId}/items${
|
||||
queryString ? `?${queryString}` : ""
|
||||
}`;
|
||||
|
||||
return this.request<{ data: unknown[]; has_more: boolean }>(url);
|
||||
}
|
||||
|
||||
// OpenAI-compatible streaming methods using /v1/responses endpoint
|
||||
|
||||
// Stream agent execution using pure OpenAI format
|
||||
// Stream agent execution using OpenAI format with simplified routing
|
||||
async *streamAgentExecutionOpenAI(
|
||||
agentId: string,
|
||||
request: RunAgentRequest
|
||||
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
|
||||
const openAIRequest: AgentFrameworkRequest = {
|
||||
model: "agent-framework",
|
||||
model: agentId, // Model IS the entity_id (simplified routing!)
|
||||
input: request.input, // Direct OpenAI ResponseInputParam
|
||||
stream: true,
|
||||
extra_body: {
|
||||
entity_id: agentId,
|
||||
thread_id: request.thread_id,
|
||||
},
|
||||
conversation: request.conversation_id, // OpenAI standard conversation param
|
||||
};
|
||||
|
||||
return yield* this.streamAgentExecutionOpenAIDirect(agentId, openAIRequest);
|
||||
@@ -326,7 +327,19 @@ class ApiClient {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI streaming request failed: ${response.status}`);
|
||||
// Try to extract detailed error message from response body
|
||||
let errorMessage = `Request failed with status ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
if (errorBody.error && errorBody.error.message) {
|
||||
errorMessage = errorBody.error.message;
|
||||
} else if (errorBody.detail) {
|
||||
errorMessage = errorBody.detail;
|
||||
}
|
||||
} catch {
|
||||
// Fallback to generic message if parsing fails
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
@@ -380,15 +393,14 @@ class ApiClient {
|
||||
workflowId: string,
|
||||
request: RunWorkflowRequest
|
||||
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
|
||||
// Convert to OpenAI format
|
||||
// Convert to OpenAI format - use model field for entity_id (same as agents)
|
||||
const openAIRequest: AgentFrameworkRequest = {
|
||||
model: "agent-framework", // Placeholder model name
|
||||
input: "", // Empty string for workflows - actual data is in extra_body.input_data
|
||||
model: workflowId, // Use workflow ID in model field (matches agent pattern)
|
||||
input: typeof request.input_data === 'string'
|
||||
? request.input_data
|
||||
: JSON.stringify(request.input_data || ""), // Convert input_data to string
|
||||
stream: true,
|
||||
extra_body: {
|
||||
entity_id: workflowId,
|
||||
input_data: request.input_data, // Preserve structured data
|
||||
},
|
||||
conversation: request.conversation_id, // Include conversation if present
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/v1/responses`, {
|
||||
@@ -401,7 +413,19 @@ class ApiClient {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI streaming request failed: ${response.status}`);
|
||||
// Try to extract detailed error message from response body
|
||||
let errorMessage = `Request failed with status ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
if (errorBody.error && errorBody.error.message) {
|
||||
errorMessage = errorBody.error.message;
|
||||
} else if (errorBody.detail) {
|
||||
errorMessage = errorBody.detail;
|
||||
}
|
||||
} catch {
|
||||
// Fallback to generic message if parsing fails
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
@@ -457,7 +481,7 @@ class ApiClient {
|
||||
agentId: string,
|
||||
request: RunAgentRequest
|
||||
): Promise<{
|
||||
thread_id: string;
|
||||
conversation_id: string;
|
||||
result: unknown[];
|
||||
message_count: number;
|
||||
}> {
|
||||
|
||||
@@ -34,10 +34,27 @@ export interface ResponseInputFileParam {
|
||||
filename: string;
|
||||
}
|
||||
|
||||
// DevUI Extension: Function Approval Response Input
|
||||
export interface ResponseInputFunctionApprovalParam {
|
||||
/** The type of the input item. Always `function_approval_response`. */
|
||||
type: "function_approval_response";
|
||||
/** The ID of the approval request being responded to. */
|
||||
request_id: string;
|
||||
/** Whether the function call is approved. */
|
||||
approved: boolean;
|
||||
/** The function call being approved/rejected. */
|
||||
function_call: {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export type ResponseInputContent =
|
||||
| ResponseInputTextParam
|
||||
| ResponseInputImageParam
|
||||
| ResponseInputFileParam;
|
||||
| ResponseInputFileParam
|
||||
| ResponseInputFunctionApprovalParam;
|
||||
|
||||
export interface EasyInputMessage {
|
||||
type?: "message";
|
||||
@@ -51,7 +68,6 @@ export type ResponseInputParam = ResponseInputItem[];
|
||||
// Agent Framework extension fields (matches backend AgentFrameworkExtraBody)
|
||||
export interface AgentFrameworkExtraBody {
|
||||
entity_id: string;
|
||||
thread_id?: string;
|
||||
input_data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -61,6 +77,9 @@ export interface AgentFrameworkRequest {
|
||||
input: string | ResponseInputParam; // Union type matching OpenAI
|
||||
stream?: boolean;
|
||||
|
||||
// OpenAI conversation parameter (standard!)
|
||||
conversation?: string | { id: string };
|
||||
|
||||
// Common OpenAI optional fields
|
||||
instructions?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
@@ -230,7 +249,9 @@ export interface ChatResponseUpdate {
|
||||
raw_representation?: unknown;
|
||||
}
|
||||
|
||||
// Agent thread
|
||||
// Agent thread (internal AgentFramework type - not exposed via DevUI API)
|
||||
// Note: DevUI uses OpenAI Conversations API. This type represents the internal
|
||||
// AgentThread used by the framework for execution, wrapped by ConversationStore.
|
||||
export interface AgentThread {
|
||||
service_thread_id?: string;
|
||||
message_store?: unknown; // ChatMessageStore - could be typed further if needed
|
||||
|
||||
@@ -72,28 +72,22 @@ export interface WorkflowInfo extends Omit<AgentInfo, "tools"> {
|
||||
start_executor_id: string; // Entry point executor ID
|
||||
}
|
||||
|
||||
export interface ThreadInfo {
|
||||
// OpenAI Conversations API (standard)
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
created_at: string;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
thread_id: string;
|
||||
agent_id: string;
|
||||
created_at: string;
|
||||
messages: Array<Record<string, unknown>>;
|
||||
metadata: Record<string, unknown>;
|
||||
object: "conversation";
|
||||
created_at: number;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RunAgentRequest {
|
||||
input: import("./agent-framework").ResponseInputParam;
|
||||
thread_id?: string;
|
||||
conversation_id?: string; // OpenAI standard conversation parameter
|
||||
}
|
||||
|
||||
export interface RunWorkflowRequest {
|
||||
input_data: Record<string, unknown>;
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
// Legacy types - DEPRECATED - use new structured events from openai.ts instead
|
||||
@@ -107,9 +101,10 @@ export type {
|
||||
// New structured event types
|
||||
ExtendedResponseStreamEvent,
|
||||
ResponseWorkflowEventComplete,
|
||||
ResponseFunctionResultComplete,
|
||||
ResponseTraceEventComplete,
|
||||
ResponseUsageEventComplete,
|
||||
ResponseOutputItemAddedEvent,
|
||||
ResponseFunctionResultComplete,
|
||||
ResponseCompletedEvent,
|
||||
StructuredEvent,
|
||||
} from "./openai";
|
||||
|
||||
@@ -149,7 +144,7 @@ export interface ChatMessage {
|
||||
// UI State types
|
||||
export interface AppState {
|
||||
selectedAgent?: AgentInfo | WorkflowInfo;
|
||||
currentThread?: ThreadInfo;
|
||||
currentConversation?: Conversation;
|
||||
agents: AgentInfo[];
|
||||
workflows: WorkflowInfo[];
|
||||
isLoading: boolean;
|
||||
@@ -161,3 +156,13 @@ export interface ChatState {
|
||||
isStreaming: boolean;
|
||||
// streamEvents removed - use OpenAI events directly instead
|
||||
}
|
||||
|
||||
// DevUI-specific: Pending approval state
|
||||
export interface PendingApproval {
|
||||
request_id: string;
|
||||
function_call: {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,32 +36,13 @@ export interface ResponseWorkflowEventComplete {
|
||||
sequence_number: number;
|
||||
}
|
||||
|
||||
// Custom DevUI: Function result event
|
||||
// This is a DevUI extension - OpenAI doesn't stream function execution results
|
||||
export interface ResponseFunctionResultComplete {
|
||||
type: "response.function_result.complete";
|
||||
data: {
|
||||
call_id: string;
|
||||
result: unknown;
|
||||
status: "completed" | "failed";
|
||||
exception?: string;
|
||||
timestamp: string;
|
||||
};
|
||||
call_id: string;
|
||||
item_id: string;
|
||||
output_index: number;
|
||||
sequence_number: number;
|
||||
}
|
||||
|
||||
// Removed - using ResponseTraceEventComplete defined below
|
||||
|
||||
export interface ResponseUsageEventComplete {
|
||||
type: "response.usage.complete";
|
||||
data: {
|
||||
usage_data: Record<string, unknown>;
|
||||
total_tokens: number;
|
||||
completion_tokens: number;
|
||||
prompt_tokens: number;
|
||||
timestamp: string;
|
||||
};
|
||||
output: string;
|
||||
status: "in_progress" | "completed" | "incomplete";
|
||||
item_id: string;
|
||||
output_index: number;
|
||||
sequence_number: number;
|
||||
@@ -103,6 +84,25 @@ export interface ResponseFunctionCallArgumentsDelta {
|
||||
sequence_number?: number;
|
||||
}
|
||||
|
||||
// OpenAI Responses API - Function Tool Call Item
|
||||
export interface ResponseFunctionToolCall {
|
||||
id: string; // Item ID
|
||||
call_id: string; // Call ID for pairing with results
|
||||
name: string; // Function name
|
||||
arguments: string; // JSON arguments
|
||||
type: "function_call";
|
||||
status?: "in_progress" | "completed" | "incomplete";
|
||||
}
|
||||
|
||||
// OpenAI Responses API - Output Item Added Event
|
||||
// OpenAI standard: Output item added event
|
||||
export interface ResponseOutputItemAddedEvent {
|
||||
type: "response.output_item.added";
|
||||
item: ResponseFunctionToolCall;
|
||||
output_index: number;
|
||||
sequence_number: number;
|
||||
}
|
||||
|
||||
// Trace event - matching actual backend output
|
||||
export interface ResponseTraceEventComplete {
|
||||
type: "response.trace_event.complete";
|
||||
@@ -150,17 +150,43 @@ export interface ResponseErrorEvent extends ResponseStreamEvent {
|
||||
sequence_number: number;
|
||||
}
|
||||
|
||||
// DevUI Extension: Function Approval Events
|
||||
export interface ResponseFunctionApprovalRequestedEvent {
|
||||
type: "response.function_approval.requested";
|
||||
request_id: string;
|
||||
function_call: {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
item_id: string;
|
||||
output_index: number;
|
||||
sequence_number: number;
|
||||
}
|
||||
|
||||
export interface ResponseFunctionApprovalRespondedEvent {
|
||||
type: "response.function_approval.responded";
|
||||
request_id: string;
|
||||
approved: boolean;
|
||||
item_id: string;
|
||||
output_index: number;
|
||||
sequence_number: number;
|
||||
}
|
||||
|
||||
// Union type for all structured events
|
||||
export type StructuredEvent =
|
||||
| ResponseCompletedEvent
|
||||
| ResponseWorkflowEventComplete
|
||||
| ResponseFunctionResultComplete
|
||||
| ResponseTraceEventComplete
|
||||
| ResponseTraceComplete
|
||||
| ResponseUsageEventComplete
|
||||
| ResponseOutputItemAddedEvent
|
||||
| ResponseFunctionResultComplete
|
||||
| ResponseFunctionCallComplete
|
||||
| ResponseFunctionCallDelta
|
||||
| ResponseFunctionCallArgumentsDelta
|
||||
| ResponseErrorEvent;
|
||||
| ResponseErrorEvent
|
||||
| ResponseFunctionApprovalRequestedEvent
|
||||
| ResponseFunctionApprovalRespondedEvent;
|
||||
|
||||
// Extended stream event that includes our structured events
|
||||
export type ExtendedResponseStreamEvent = ResponseStreamEvent | StructuredEvent;
|
||||
@@ -215,6 +241,13 @@ export interface ResponseUsage {
|
||||
};
|
||||
}
|
||||
|
||||
// OpenAI standard: response.completed event
|
||||
export interface ResponseCompletedEvent {
|
||||
type: "response.completed";
|
||||
response: OpenAIResponse;
|
||||
sequence_number: number;
|
||||
}
|
||||
|
||||
// Request format for Agent Framework
|
||||
// AgentFrameworkRequest moved to agent-framework.ts to avoid conflicts
|
||||
|
||||
@@ -226,3 +259,100 @@ export interface OpenAIError {
|
||||
code?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OpenAI Conversations API Types - for conversation history
|
||||
// ============================================================================
|
||||
|
||||
// Message content types (what goes inside Message.content[])
|
||||
export interface MessageTextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MessageInputImage {
|
||||
type: "input_image";
|
||||
image_url: string;
|
||||
detail?: "low" | "high" | "auto";
|
||||
file_id?: string;
|
||||
}
|
||||
|
||||
export interface MessageInputFile {
|
||||
type: "input_file";
|
||||
file_url?: string;
|
||||
file_data?: string;
|
||||
file_id?: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
// DevUI Extension: Function approval response content
|
||||
export interface MessageFunctionApprovalResponseContent {
|
||||
type: "function_approval_response";
|
||||
request_id: string;
|
||||
approved: boolean;
|
||||
function_call: {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export type MessageContent =
|
||||
| MessageTextContent
|
||||
| MessageInputImage
|
||||
| MessageInputFile
|
||||
| MessageFunctionApprovalResponseContent;
|
||||
|
||||
// Message item (user/assistant messages with content)
|
||||
export interface ConversationMessage {
|
||||
id: string;
|
||||
type: "message";
|
||||
role: "user" | "assistant" | "system" | "tool";
|
||||
content: MessageContent[];
|
||||
status: "in_progress" | "completed" | "incomplete";
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Function call item (separate from message)
|
||||
export interface ConversationFunctionCall {
|
||||
id: string;
|
||||
type: "function_call";
|
||||
call_id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
status: "in_progress" | "completed" | "incomplete";
|
||||
}
|
||||
|
||||
// Function call output item
|
||||
export interface ConversationFunctionCallOutput {
|
||||
id: string;
|
||||
type: "function_call_output";
|
||||
call_id: string;
|
||||
output: string;
|
||||
status?: "in_progress" | "completed" | "incomplete";
|
||||
}
|
||||
|
||||
// Union of all conversation item types
|
||||
export type ConversationItem =
|
||||
| ConversationMessage
|
||||
| ConversationFunctionCall
|
||||
| ConversationFunctionCallOutput;
|
||||
|
||||
// Conversation metadata
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
object: "conversation";
|
||||
created_at: number;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
// List response
|
||||
export interface ConversationItemsListResponse {
|
||||
object: "list";
|
||||
data: ConversationItem[];
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,14 @@ export interface WorkflowExecutor extends Executor {
|
||||
workflow: Workflow; // Nested workflow
|
||||
}
|
||||
|
||||
export interface MagenticOrchestratorExecutor extends Executor {
|
||||
type: "MagenticOrchestratorExecutor";
|
||||
}
|
||||
|
||||
export interface MagenticAgentExecutor extends Executor {
|
||||
type: "MagenticAgentExecutor";
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge interface that mirrors agent_framework_workflow._edge.Edge
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
import type { ExecutorNodeData } from "@/components/workflow/executor-node";
|
||||
import type { ExecutorNodeData } from "@/components/features/workflow/executor-node";
|
||||
|
||||
/**
|
||||
* Lightweight auto-layout algorithm to replace dagre
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Node, Edge } from "@xyflow/react";
|
||||
import type {
|
||||
ExecutorNodeData,
|
||||
ExecutorState,
|
||||
} from "@/components/workflow/executor-node";
|
||||
} from "@/components/features/workflow/executor-node";
|
||||
import type {
|
||||
ExtendedResponseStreamEvent,
|
||||
ResponseWorkflowEventComplete,
|
||||
@@ -309,9 +309,11 @@ export function applyDagreLayout(
|
||||
* Process workflow events and extract node updates
|
||||
*/
|
||||
export function processWorkflowEvents(
|
||||
events: ExtendedResponseStreamEvent[]
|
||||
events: ExtendedResponseStreamEvent[],
|
||||
startExecutorId?: string
|
||||
): Record<string, NodeUpdate> {
|
||||
const nodeUpdates: Record<string, NodeUpdate> = {};
|
||||
let hasWorkflowStarted = false;
|
||||
|
||||
events.forEach((event) => {
|
||||
if (
|
||||
@@ -343,6 +345,9 @@ export function processWorkflowEvents(
|
||||
state = "cancelled";
|
||||
} else if (eventType === "WorkflowCompletedEvent" || eventType === "WorkflowOutputEvent") {
|
||||
state = "completed";
|
||||
} else if (eventType === "WorkflowStartedEvent") {
|
||||
// Mark that workflow has started - we'll set start node to running
|
||||
hasWorkflowStarted = true;
|
||||
}
|
||||
|
||||
// Update the node state (keep most recent update per executor)
|
||||
@@ -358,6 +363,18 @@ export function processWorkflowEvents(
|
||||
}
|
||||
});
|
||||
|
||||
// If workflow has started and we have a start executor, set it to running
|
||||
// (unless it already has a specific state from an ExecutorInvokedEvent)
|
||||
if (hasWorkflowStarted && startExecutorId && !nodeUpdates[startExecutorId]) {
|
||||
nodeUpdates[startExecutorId] = {
|
||||
nodeId: startExecutorId,
|
||||
state: "running",
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return nodeUpdates;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user