Python: DevUI: Add OpenAI Responses API proxy support + HIL for Workflows (#1737)

* DevUI: Add OpenAI Responses API proxy support with enhanced UI features

This commit adds support for proxying requests to OpenAI's Responses API,
allowing DevUI to route conversations to OpenAI models when configured to enable testing.

Backend changes:
- Add OpenAI proxy executor with conversation routing logic
- Enhance event mapper to support OpenAI Responses API format
- Extend server endpoints to handle OpenAI proxy mode
- Update models with OpenAI-specific response types
- Remove emojis from logging and CLI output for cleaner text

Frontend changes:
- Add settings modal with OpenAI proxy configuration UI
- Enhance agent and workflow views with improved state management
- Add new UI components (separator, switch) for settings
- Update debug panel with better event filtering
- Improve message renderers for OpenAI content types
- Update types and API client for OpenAI integration

* update ui, settings modal and workflow input form, add register cleanup hooks.

* add workflow HIL support, user mode, other fixes

* feat(devui): add human-in-the-loop (HIL) support with dynamic response schemas

Implement  HIL workflow support allowing workflows to pause for user input
with dynamically generated JSON schemas based on response handler type hints.

Key Features:
- Automatic response schema extraction from @response_handler decorators
- Dynamic form generation in UI based on Pydantic/dataclass response types
- Checkpoint-based conversation storage for HIL requests/responses
- Resume workflow execution after user provides HIL response

Backend Changes:
- Add extract_response_type_from_executor() to introspect response handlers
- Enrich RequestInfoEvent with response_schema via _enrich_request_info_event_with_response_schema()
- Map RequestInfoEvent to response.input.requested OpenAI event format
- Store HIL responses in conversation history and restore checkpoints

Frontend Changes:
- Add HILInputModal component with SchemaFormRenderer for dynamic forms
- Support Pydantic BaseModel and dataclass response types
- Render enum fields as dropdowns, strings as text/textarea, numbers, booleans, arrays, objects
- Display original request context alongside response form

Testing:
- Add  tests for checkpoint storage (test_checkpoints.py)
- Add schema generation tests for all input types (test_schema_generation.py)
- Validate end-to-end HIL flow with spam workflow sample

This enables workflows to seamlessly pause execution and request structured user input
with type-safe, validated forms generated automatically from response type annotations.

* improve HIL support, improve workflow execution view

* ui updates

* ui updates

* improve HIL for workflows, add auth and view modes

* update workflow

* security improvements , ui fixes

* fix mypy error

* update loading spinner in ui

---------

Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
This commit is contained in:
Victor Dibia
2025-11-07 15:28:32 -08:00
committed by GitHub
Unverified
parent 85484c0259
commit 94eae24082
52 changed files with 10178 additions and 1599 deletions
@@ -36,6 +36,7 @@ import {
X,
Copy,
CheckCheck,
RefreshCw,
} from "lucide-react";
import { apiClient } from "@/services/api";
import type {
@@ -161,7 +162,12 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
<span>{new Date().toLocaleTimeString()}</span>
<span>
{item.created_at
? new Date(item.created_at * 1000).toLocaleTimeString()
: new Date().toLocaleTimeString() // Fallback for legacy items without timestamp
}
</span>
{!isUser && item.usage && (
<>
<span></span>
@@ -207,8 +213,10 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const loadingConversations = useDevUIStore((state) => state.loadingConversations);
const inputValue = useDevUIStore((state) => state.inputValue);
const attachments = useDevUIStore((state) => state.attachments);
const uiMode = useDevUIStore((state) => state.uiMode);
const conversationUsage = useDevUIStore((state) => state.conversationUsage);
const pendingApprovals = useDevUIStore((state) => state.pendingApprovals);
const oaiMode = useDevUIStore((state) => state.oaiMode);
// Get conversation actions from Zustand (only the ones we actually use)
const setCurrentConversation = useDevUIStore((state) => state.setCurrentConversation);
@@ -227,6 +235,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const [dragCounter, setDragCounter] = useState(0);
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [conversationError, setConversationError] = useState<{
message: string;
code?: string;
type?: string;
} | null>(null);
const [isReloading, setIsReloading] = useState(false);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -604,10 +618,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setAvailableConversations([newConversation]);
setChatItems([]);
setIsStreaming(false);
} catch {
setConversationError(null); // Clear any previous errors
// Save to localStorage
localStorage.setItem(cachedKey, JSON.stringify([newConversation]));
} catch (error) {
setAvailableConversations([]);
setChatItems([]);
setIsStreaming(false);
// Extract error details for display
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
setConversationError({
message: errorMessage,
type: "conversation_creation_error",
});
} finally {
setLoadingConversations(false);
}
@@ -856,11 +881,22 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setAvailableConversations([newConversation, ...useDevUIStore.getState().availableConversations]);
setChatItems([]);
setIsStreaming(false);
setConversationError(null); // Clear any previous errors
// Reset conversation usage by setting it to initial state
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
accumulatedTextRef.current = "";
} catch {
// Failed to create conversation
// Update localStorage cache with new conversation
const cachedKey = `devui_convs_${selectedAgent.id}`;
const updated = [newConversation, ...availableConversations];
localStorage.setItem(cachedKey, JSON.stringify(updated));
} catch (error) {
// Failed to create conversation - show error to user
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
setConversationError({
message: errorMessage,
type: "conversation_creation_error",
});
}
}, [selectedAgent, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
@@ -915,6 +951,42 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
[availableConversations, currentConversation, onDebugEvent, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]
);
// Handle entity reload (hot reload)
const handleReloadEntity = useCallback(async () => {
if (isReloading || !selectedAgent) return;
setIsReloading(true);
const addToast = useDevUIStore.getState().addToast;
const updateAgent = useDevUIStore.getState().updateAgent;
try {
// Call backend reload endpoint
await apiClient.reloadEntity(selectedAgent.id);
// Fetch updated entity info
const updatedAgent = await apiClient.getAgentInfo(selectedAgent.id);
// Update store with fresh metadata
updateAgent(updatedAgent);
// Show success toast
addToast({
message: `${selectedAgent.name} has been reloaded successfully`,
type: "success",
});
} catch (error) {
// Show error toast
const errorMessage = error instanceof Error ? error.message : "Failed to reload entity";
addToast({
message: `Failed to reload: ${errorMessage}`,
type: "error",
duration: 6000,
});
} finally {
setIsReloading(false);
}
}, [isReloading, selectedAgent]);
// Handle conversation selection
const handleConversationSelect = useCallback(
async (conversationId: string) => {
@@ -1002,6 +1074,27 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const approval = pendingApprovals.find((a) => a.request_id === request_id);
if (!approval) return;
// Add user's decision as a visible message in the chat
const messageTimestamp = Math.floor(Date.now() / 1000);
const userDecisionMessage: import("@/types/openai").ConversationMessage = {
id: `user-approval-${Date.now()}`,
type: "message",
role: "user",
content: [
{
type: "function_approval_request",
request_id: request_id,
status: approved ? "approved" : "rejected",
function_call: approval.function_call,
} as import("@/types/openai").MessageFunctionApprovalRequestContent,
],
status: "completed",
created_at: messageTimestamp,
};
const currentItems = useDevUIStore.getState().chatItems;
setChatItems([...currentItems, userDecisionMessage]);
// Create approval response in OpenAI-compatible format
const approvalInput: import("@/types/agent-framework").ResponseInputParam = [
{
@@ -1019,13 +1112,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
];
// Send approval response through the conversation
// We'll call handleSendMessage directly when invoked (it's defined below)
const request: RunAgentRequest = {
input: approvalInput,
conversation_id: currentConversation?.id,
};
// Remove from pending immediately (will be confirmed by backend event)
// Remove from pending immediately
setPendingApprovals(
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== request_id)
);
@@ -1039,6 +1131,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
async (request: RunAgentRequest) => {
if (!selectedAgent) return;
// Check if this is a function approval response (internal, don't show in chat)
const isApprovalResponse = request.input.some(
(inputItem) =>
inputItem.type === "message" &&
Array.isArray(inputItem.content) &&
inputItem.content.some((c) => c.type === "function_approval_response")
);
// Extract content from OpenAI format to create ConversationMessage
const messageContent: import("@/types/openai").MessageContent[] = [];
@@ -1069,16 +1169,23 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
}
}
// Add user message to UI state (OpenAI ConversationMessage)
const userMessage: import("@/types/openai").ConversationMessage = {
id: `user-${Date.now()}`,
type: "message",
role: "user",
content: messageContent,
status: "completed",
};
// Capture timestamp once for both user and assistant messages
const messageTimestamp = Math.floor(Date.now() / 1000); // Unix seconds
// Only add user message to UI if it's not an approval response (internal messages)
if (!isApprovalResponse && messageContent.length > 0) {
const userMessage: import("@/types/openai").ConversationMessage = {
id: `user-${Date.now()}`,
type: "message",
role: "user",
content: messageContent,
status: "completed",
created_at: messageTimestamp,
};
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
}
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
setIsStreaming(true);
// Create assistant message placeholder
@@ -1088,6 +1195,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
role: "assistant",
content: [], // Will be filled during streaming
status: "in_progress",
created_at: messageTimestamp,
};
setChatItems([...useDevUIStore.getState().chatItems, assistantMessage]);
@@ -1102,8 +1210,17 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
});
setCurrentConversation(conversationToUse);
setAvailableConversations([conversationToUse, ...useDevUIStore.getState().availableConversations]);
} catch {
// Failed to create conversation
setConversationError(null); // Clear any previous errors
} catch (error) {
// Failed to create conversation - show error and stop execution
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
setConversationError({
message: errorMessage,
type: "conversation_creation_error",
});
setIsSubmitting(false);
setIsStreaming(false);
return; // Stop execution - can't send message without conversation
}
}
@@ -1145,16 +1262,25 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
continue; // Continue processing other events
}
// Handle response.failed event
// Handle response.failed event (OpenAI standard)
if (openAIEvent.type === "response.failed") {
const failedEvent = openAIEvent as import("@/types/openai").ResponseFailedEvent;
const error = failedEvent.response?.error;
const errorMessage = error
? typeof error === "object" && "message" in error
? (error as any).message
: JSON.stringify(error)
: "Request failed";
// Format error message with details
let errorMessage = "Request failed";
if (error) {
if (typeof error === "object" && "message" in error) {
errorMessage = error.message as string;
if ("code" in error && error.code) {
errorMessage += ` (Code: ${error.code})`;
}
} else if (typeof error === "string") {
errorMessage = error;
}
}
// Update assistant message with error
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
@@ -1171,14 +1297,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
: item
));
setIsStreaming(false);
return;
return; // Exit stream processing on failure
}
// Handle function approval request events
if (openAIEvent.type === "response.function_approval.requested") {
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
// Add to pending approvals
// Add to pending approvals (for popup)
setPendingApprovals([
...useDevUIStore.getState().pendingApprovals,
{
@@ -1186,17 +1312,46 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
function_call: approvalEvent.function_call,
},
]);
continue; // Don't add approval requests to chat UI
// Also add to chat UI to show function call progress
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) => {
if (item.id === assistantMessage.id && item.type === "message") {
return {
...item,
content: [
...item.content,
{
type: "function_approval_request",
request_id: approvalEvent.request_id,
status: "pending",
function_call: approvalEvent.function_call,
} as import("@/types/openai").MessageFunctionApprovalRequestContent,
],
status: "in_progress" as const,
};
}
return item;
}));
continue;
}
// Handle function approval response events
if (openAIEvent.type === "response.function_approval.responded") {
const responseEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRespondedEvent;
// Handle function result events (after function execution)
if (openAIEvent.type === "response.function_result.complete") {
const resultEvent = openAIEvent as import("@/types/openai").ResponseFunctionResultComplete;
// Remove from pending approvals
setPendingApprovals(
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== responseEvent.request_id)
);
// Add function result as a separate conversation item for clear visibility
const functionResultItem: import("@/types/openai").ConversationFunctionCallOutput = {
id: `result-${Date.now()}`,
type: "function_call_output",
call_id: resultEvent.call_id,
output: resultEvent.output,
status: resultEvent.status === "completed" ? "completed" : "incomplete",
created_at: Math.floor(Date.now() / 1000),
};
const currentItems = useDevUIStore.getState().chatItems;
setChatItems([...currentItems, functionResultItem]);
continue;
}
@@ -1227,6 +1382,57 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
return; // Exit stream processing early on error
}
// Handle output item added events (images, files, data)
if (openAIEvent.type === "response.output_item.added") {
const outputItemEvent = openAIEvent as import("@/types/openai").ResponseOutputItemAddedEvent;
const item = outputItemEvent.item;
// Add output items to assistant message content
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((chatItem) => {
if (chatItem.id === assistantMessage.id && chatItem.type === "message") {
const existingContent = chatItem.content;
let newContent: import("@/types/openai").MessageContent | null = null;
// Map output items to message content
if (item.type === "output_image") {
newContent = {
type: "output_image",
image_url: item.image_url,
alt_text: item.alt_text,
mime_type: item.mime_type,
} as import("@/types/openai").MessageOutputImage;
} else if (item.type === "output_file") {
newContent = {
type: "output_file",
filename: item.filename,
file_url: item.file_url,
file_data: item.file_data,
mime_type: item.mime_type,
} as import("@/types/openai").MessageOutputFile;
} else if (item.type === "output_data") {
newContent = {
type: "output_data",
data: item.data,
mime_type: item.mime_type,
description: item.description,
} as import("@/types/openai").MessageOutputData;
}
// If we created new content, append it
if (newContent) {
return {
...chatItem,
content: [...existingContent, newContent],
status: "in_progress" as const,
};
}
}
return chatItem;
}));
continue; // Continue to next event
}
// Handle text delta events for chat
if (
openAIEvent.type === "response.output_text.delta" &&
@@ -1236,21 +1442,26 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
accumulatedTextRef.current += openAIEvent.delta;
// Update assistant message with accumulated content
// Preserve any existing non-text content (images, files, data)
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: accumulatedTextRef.current,
} as import("@/types/openai").MessageTextContent,
],
status: "in_progress" as const,
}
: item
));
setChatItems(currentItems.map((item) => {
if (item.id === assistantMessage.id && item.type === "message") {
// Keep existing non-text content, update text content
const existingNonTextContent = item.content.filter(c => c.type !== "text");
return {
...item,
content: [
...existingNonTextContent,
{
type: "text",
text: accumulatedTextRef.current,
} as import("@/types/openai").MessageTextContent,
],
status: "in_progress" as const,
};
}
return item;
}));
}
// Handle completion/error by detecting when streaming stops
@@ -1435,19 +1646,42 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
<div className="flex items-center gap-2">
<Bot className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
Chat with {selectedAgent.name || selectedAgent.id}
{oaiMode.enabled
? `Chat with ${oaiMode.model}`
: `Chat with ${selectedAgent.name || selectedAgent.id}`
}
</span>
</div>
</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setDetailsModalOpen(true)}
className="h-6 w-6 p-0 flex-shrink-0"
title="View agent details"
>
<Info className="h-4 w-4" />
</Button>
{!oaiMode.enabled && uiMode === "developer" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setDetailsModalOpen(true)}
className="h-6 w-6 p-0 flex-shrink-0"
title="View agent details"
>
<Info className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleReloadEntity}
disabled={isReloading || selectedAgent.metadata?.source === "in_memory"}
className="h-6 w-6 p-0 flex-shrink-0"
title={
selectedAgent.metadata?.source === "in_memory"
? "In-memory entities cannot be reloaded"
: isReloading
? "Reloading..."
: "Reload entity code (hot reload)"
}
>
<RefreshCw className={`h-4 w-4 ${isReloading ? "animate-spin" : ""}`} />
</Button>
</>
)}
</div>
{/* Conversation Controls */}
@@ -1539,13 +1773,46 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
</div>
</div>
{selectedAgent.description && (
{oaiMode.enabled ? (
<p className="text-sm text-muted-foreground">
{selectedAgent.description}
Using OpenAI model directly. Local agent tools and instructions are not applied.
</p>
) : (
selectedAgent.description && (
<p className="text-sm text-muted-foreground">
{selectedAgent.description}
</p>
)
)}
</div>
{/* Error Banner */}
{conversationError && (
<div className="mx-4 mt-2 p-3 bg-destructive/10 border border-destructive/30 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-destructive">
Failed to Create Conversation
</div>
<div className="text-xs text-destructive/90 mt-1 break-words">
{conversationError.message}
</div>
{conversationError.code && (
<div className="text-xs text-destructive/70 mt-1">
Error Code: {conversationError.code}
</div>
)}
</div>
<button
onClick={() => setConversationError(null)}
className="text-destructive hover:text-destructive/80 flex-shrink-0"
title="Dismiss error"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Messages */}
<ScrollArea className="flex-1 p-4 h-0" ref={scrollAreaRef}>
<div className="space-y-4">
@@ -11,6 +11,9 @@ import {
ChevronDown,
ChevronRight,
Music,
Check,
X,
Clock,
} from "lucide-react";
import type { MessageContent } from "@/types/openai";
import { MarkdownRenderer } from "@/components/ui/markdown-renderer";
@@ -37,12 +40,12 @@ function TextContentRenderer({ content, className, isStreaming }: ContentRendere
);
}
// Image content renderer
// Image content renderer (handles both input and output images)
function ImageContentRenderer({ content, className }: ContentRendererProps) {
const [imageError, setImageError] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "input_image") return null;
if (content.type !== "input_image" && content.type !== "output_image") return null;
const imageUrl = content.image_url;
@@ -77,9 +80,9 @@ function ImageContentRenderer({ content, className }: ContentRendererProps) {
);
}
// File content renderer
// File content renderer (handles both input and output files)
function FileContentRenderer({ content, className }: ContentRendererProps) {
if (content.type !== "input_file") return null;
if (content.type !== "input_file" && content.type !== "output_file") return null;
const fileUrl = content.file_url || content.file_data;
const filename = content.filename || "file";
@@ -156,6 +159,129 @@ function FileContentRenderer({ content, className }: ContentRendererProps) {
);
}
// Data content renderer (for generic structured data outputs)
function DataContentRenderer({ content, className }: ContentRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "output_data") return null;
const data = content.data;
const mimeType = content.mime_type;
const description = content.description;
// Try to parse as JSON for pretty printing
let displayData = data;
try {
const parsed = JSON.parse(data);
displayData = JSON.stringify(parsed, null, 2);
} catch {
// Not JSON, display as-is
}
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{description || "Data Output"}
</span>
<span className="text-xs text-muted-foreground ml-auto">{mimeType}</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
{isExpanded && (
<pre className="mt-2 text-xs overflow-auto max-h-64 bg-background p-2 rounded border font-mono">
{displayData}
</pre>
)}
</div>
);
}
// Function approval request renderer
function FunctionApprovalRequestRenderer({ content, className }: ContentRendererProps) {
if (content.type !== "function_approval_request") return null;
const [isExpanded, setIsExpanded] = useState(false);
const { status, function_call } = content;
// Status styling
const statusConfig = {
pending: {
icon: Clock,
color: "amber",
label: "Awaiting Approval",
bgClass: "bg-amber-50 dark:bg-amber-950/20",
borderClass: "border-amber-200 dark:border-amber-800",
iconClass: "text-amber-600 dark:text-amber-400",
textClass: "text-amber-800 dark:text-amber-300",
},
approved: {
icon: Check,
color: "green",
label: "Approved",
bgClass: "bg-green-50 dark:bg-green-950/20",
borderClass: "border-green-200 dark:border-green-800",
iconClass: "text-green-600 dark:text-green-400",
textClass: "text-green-800 dark:text-green-300",
},
rejected: {
icon: X,
color: "red",
label: "Rejected",
bgClass: "bg-red-50 dark:bg-red-950/20",
borderClass: "border-red-200 dark:border-red-800",
iconClass: "text-red-600 dark:text-red-400",
textClass: "text-red-800 dark:text-red-300",
},
};
const config = statusConfig[status];
const StatusIcon = config.icon;
let parsedArgs;
try {
parsedArgs = typeof function_call.arguments === "string"
? JSON.parse(function_call.arguments)
: function_call.arguments;
} catch {
parsedArgs = function_call.arguments;
}
return (
<div className={`my-2 p-3 border rounded ${config.bgClass} ${config.borderClass} ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<StatusIcon className={`h-4 w-4 ${config.iconClass}`} />
<span className={`text-sm font-medium ${config.textClass}`}>
{config.label}: {function_call.name}
</span>
{isExpanded ? (
<ChevronDown className={`h-4 w-4 ${config.iconClass} ml-auto`} />
) : (
<ChevronRight className={`h-4 w-4 ${config.iconClass} ml-auto`} />
)}
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
<div className={`${config.textClass} mb-1`}>Arguments:</div>
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedArgs, null, 2)}
</pre>
</div>
)}
</div>
);
}
// Main content renderer that delegates to specific renderers
export function OpenAIContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
switch (content.type) {
@@ -164,9 +290,15 @@ export function OpenAIContentRenderer({ content, className, isStreaming }: Conte
case "output_text":
return <TextContentRenderer content={content} className={className} isStreaming={isStreaming} />;
case "input_image":
case "output_image":
return <ImageContentRenderer content={content} className={className} />;
case "input_file":
case "output_file":
return <FileContentRenderer content={content} className={className} />;
case "output_data":
return <DataContentRenderer content={content} className={className} />;
case "function_approval_request":
return <FunctionApprovalRequestRenderer content={content} className={className} />;
default:
return null;
}
@@ -0,0 +1,523 @@
/**
* ExecutionTimeline - Vertical timeline showing workflow executor runs
* Features: Chronological executor execution, expandable output, bidirectional graph highlighting
*/
import { useState, useEffect, useMemo, useRef } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Loader2,
CheckCircle,
XCircle,
AlertCircle,
ChevronDown,
ChevronRight,
Copy,
Check,
} from "lucide-react";
import type { ExtendedResponseStreamEvent } from "@/types";
import type { ExecutorState } from "./executor-node";
interface ExecutorRun {
executorId: string;
executorName: string;
itemId: string; // Unique ID for this specific run
state: ExecutorState;
output: string;
error?: string;
timestamp: number;
runNumber: number; // For multiple runs of same executor
}
interface ExecutionTimelineProps {
events: ExtendedResponseStreamEvent[];
itemOutputs: Record<string, string>;
currentExecutorId: string | null;
isStreaming: boolean;
onExecutorClick?: (executorId: string) => void;
selectedExecutorId?: string | null;
workflowResult?: string;
}
function getStateIcon(state: ExecutorState) {
switch (state) {
case "running":
return <Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin" />;
case "completed":
return <CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />;
case "failed":
return <XCircle className="w-4 h-4 text-red-500 dark:text-red-400" />;
case "cancelled":
return <AlertCircle className="w-4 h-4 text-orange-500 dark:text-orange-400" />;
default:
return <div className="w-4 h-4 rounded-full border-2 border-gray-400 dark:border-gray-500" />;
}
}
function getStateBadgeClass(state: ExecutorState) {
switch (state) {
case "running":
return "bg-[#643FB2]/10 text-[#643FB2] dark:bg-[#8B5CF6]/10 dark:text-[#8B5CF6] border-[#643FB2]/20 dark:border-[#8B5CF6]/20";
case "completed":
return "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20";
case "failed":
return "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20";
case "cancelled":
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20";
default:
return "bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20";
}
}
function ExecutorRunItem({
run,
isExpanded,
onToggle,
onClick,
isSelected,
}: {
run: ExecutorRun;
isExpanded: boolean;
onToggle: () => void;
onClick: () => void;
isSelected: boolean;
}) {
const timestamp = new Date(run.timestamp).toLocaleTimeString();
const hasOutput = run.output.trim().length > 0;
const canExpand = hasOutput || run.error;
const outputRef = useRef<HTMLPreElement>(null);
// Auto-scroll output to bottom when content changes (during streaming)
useEffect(() => {
if (isExpanded && run.state === "running" && outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [run.output, isExpanded, run.state]);
return (
<div
className={`border rounded-lg transition-all ${
isSelected
? "border-blue-500 dark:border-blue-400 bg-blue-500/5 dark:bg-blue-500/10"
: "border-border hover:border-muted-foreground/30"
}`}
>
{/* Header - Always Visible */}
<div
className="p-3 cursor-pointer"
onClick={() => {
onClick();
if (canExpand) onToggle();
}}
>
<div className="flex items-center gap-2 mb-1">
{canExpand && (
<div className="text-muted-foreground">
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</div>
)}
{getStateIcon(run.state)}
<span className="font-medium text-sm truncate flex-1">
{run.executorName}
</span>
{run.runNumber > 1 && (
<Badge variant="outline" className="text-xs">
Run #{run.runNumber}
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground ml-5">
<span className="font-mono">{timestamp}</span>
<Badge
variant="outline"
className={`text-xs border ${getStateBadgeClass(run.state)}`}
>
{run.state}
</Badge>
</div>
</div>
{/* Expandable Content */}
{isExpanded && canExpand && (
<div className="border-t px-3 py-2 bg-muted/30">
{run.error ? (
<div className="space-y-1">
<div className="text-xs font-medium text-red-600 dark:text-red-400">
Error:
</div>
<pre className="text-xs bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded p-2 overflow-y-auto overflow-x-hidden max-h-40 whitespace-pre-wrap break-all">
{run.error}
</pre>
</div>
) : (
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">
Output:
</div>
<pre
ref={outputRef}
className="text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all"
>
{run.output}
</pre>
</div>
)}
</div>
)}
</div>
);
}
export function ExecutionTimeline({
events,
itemOutputs,
currentExecutorId,
isStreaming,
onExecutorClick,
selectedExecutorId,
workflowResult,
}: ExecutionTimelineProps) {
const [expandedRuns, setExpandedRuns] = useState<Set<string>>(new Set());
const [updateTrigger, setUpdateTrigger] = useState(0);
const [copied, setCopied] = useState(false);
const lastScrolledRunRef = useRef<string | null>(null);
const timelineEndRef = useRef<HTMLDivElement>(null);
// Force re-render when streaming to show updated outputs from itemOutputs ref
// Note: itemOutputs is a ref (not state), so changes don't trigger re-renders automatically.
// This polling approach ensures the UI updates during streaming. Could be optimized by:
// 1. Converting itemOutputs to state (increases re-renders)
// 2. Using requestAnimationFrame instead of setInterval
// 3. Having parent component trigger updates via callback
useEffect(() => {
if (isStreaming) {
const interval = setInterval(() => {
setUpdateTrigger((prev) => prev + 1);
}, 100); // Update 10 times per second during streaming
return () => clearInterval(interval);
}
}, [isStreaming]);
// Process events to extract executor runs - memoized to prevent recalculation
const { executorRuns, executorRunCount } = useMemo(() => {
const runs: ExecutorRun[] = [];
const runCount = new Map<string, number>();
events.forEach((event) => {
// Extract UI timestamp (captured when event arrived, won't change on re-render)
const uiTimestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
? event._uiTimestamp * 1000
: Date.now();
// Handle new standard OpenAI events
if (event.type === "response.output_item.added") {
const item = (event as { item?: { type?: string; executor_id?: string; id?: string; created_at?: number; metadata?: any } }).item;
// Handle both executor_action items AND message items from Magentic agents
if (item && item.type === "executor_action" && item.executor_id && item.id) {
const executorId = item.executor_id;
const itemId = item.id;
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
runs.push({
executorId,
executorName: executorId,
itemId,
state: "running",
output: itemOutputs[itemId] || "",
timestamp: uiTimestamp,
runNumber,
});
} else if (item && item.type === "message" && item.metadata?.agent_id && item.metadata?.source === "magentic" && item.id) {
// Handle message items from Magentic agents
const executorId = item.metadata.agent_id;
const itemId = item.id;
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
runs.push({
executorId,
executorName: executorId,
itemId,
state: "running",
output: itemOutputs[itemId] || "",
timestamp: uiTimestamp,
runNumber,
});
}
}
// Handle completion events
if (event.type === "response.output_item.done") {
const item = (event as { item?: { type?: string; executor_id?: string; id?: string; status?: string; error?: string; metadata?: any } }).item;
// Handle both executor_action items AND message items from Magentic agents
if (item && item.type === "executor_action" && item.executor_id && item.id) {
const itemId = item.id;
// Find the run by ITEM ID (not executor ID!) to handle multiple runs correctly
const existingRun = runs.find((r) => r.itemId === itemId);
if (existingRun) {
existingRun.state =
item.status === "completed"
? "completed"
: item.status === "failed"
? "failed"
: "completed";
// Use item-specific output, not executor-wide output
existingRun.output = itemOutputs[itemId] || "";
if (item.status === "failed" && item.error) {
existingRun.error = item.error;
}
}
} else if (item && item.type === "message" && item.metadata?.agent_id && item.metadata?.source === "magentic" && item.id) {
// Handle message completion from Magentic agents
const itemId = item.id;
const existingRun = runs.find((r) => r.itemId === itemId);
if (existingRun) {
existingRun.state = item.status === "completed" ? "completed" : "failed";
existingRun.output = itemOutputs[itemId] || "";
}
}
}
// Fallback support for workflow_event format (used for unhandled event types and status/warning/error events)
if (
event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data
) {
const data = event.data as { executor_id?: string; event_type?: string; data?: unknown; timestamp?: string };
const executorId = data.executor_id;
if (!executorId) return;
const eventType = data.event_type;
if (eventType === "ExecutorInvokedEvent") {
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
// Create synthetic item ID for fallback format (no real item.id from backend)
const syntheticItemId = `fallback_${executorId}_${uiTimestamp}`;
runs.push({
executorId,
executorName: executorId,
itemId: syntheticItemId,
state: "running",
output: itemOutputs[syntheticItemId] || "",
timestamp: uiTimestamp,
runNumber,
});
} else if (eventType === "ExecutorCompletedEvent") {
// Find the most recent running instance of this executor (search from end)
let existingRun: ExecutorRun | undefined;
for (let i = runs.length - 1; i >= 0; i--) {
if (runs[i].executorId === executorId && runs[i].state === "running") {
existingRun = runs[i];
break;
}
}
if (existingRun) {
existingRun.state = "completed";
existingRun.output = itemOutputs[existingRun.itemId] || "";
}
} else if (
eventType?.includes("Error") ||
eventType?.includes("Failed")
) {
// Find the most recent running instance of this executor (search from end)
let existingRun: ExecutorRun | undefined;
for (let i = runs.length - 1; i >= 0; i--) {
if (runs[i].executorId === executorId && runs[i].state === "running") {
existingRun = runs[i];
break;
}
}
if (existingRun) {
existingRun.state = "failed";
existingRun.error =
typeof data.data === "string" ? data.data : "Execution failed";
}
}
}
});
// Update outputs for running executors using item-specific outputs
// This ensures each run gets its own output, even for multiple runs of the same executor
runs.forEach((run) => {
if (run.state === "running" && itemOutputs[run.itemId]) {
run.output = itemOutputs[run.itemId];
}
});
return { executorRuns: runs, executorRunCount: runCount };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [events, itemOutputs, updateTrigger]);
// Auto-expand running executors
useEffect(() => {
if (currentExecutorId) {
setExpandedRuns((prev) => {
const next = new Set(prev);
next.add(`${currentExecutorId}-${executorRunCount.get(currentExecutorId) || 1}`);
return next;
});
}
}, [currentExecutorId, executorRunCount]);
// Auto-scroll to newest executor when it appears or changes
useEffect(() => {
if (executorRuns.length > 0 && isStreaming) {
const latestRun = executorRuns[executorRuns.length - 1];
const latestRunKey = `${latestRun.executorId}-${latestRun.runNumber}`;
// Only scroll if this is a new run we haven't scrolled to yet
if (latestRunKey !== lastScrolledRunRef.current) {
lastScrolledRunRef.current = latestRunKey;
// Scroll to the end of the timeline
if (timelineEndRef.current) {
timelineEndRef.current.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}
}
}
}, [executorRuns, isStreaming]);
// Auto-scroll to show workflow result when it appears (after streaming completes)
useEffect(() => {
if (workflowResult && !isStreaming && timelineEndRef.current) {
// Small delay to ensure the result card is rendered before scrolling
setTimeout(() => {
timelineEndRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}, 100);
}
}, [workflowResult, isStreaming]);
const handleCopyAll = () => {
const text = executorRuns
.map((run) => {
const timestamp = new Date(run.timestamp).toLocaleTimeString();
const header = `[${timestamp}] ${run.executorName} (${run.state})`;
const content = run.error || run.output || "(no output)";
return `${header}\n${content}\n`;
})
.join("\n");
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="h-full flex flex-col border-l bg-muted/30">
{/* Header */}
<div className="p-3 border-b bg-background flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">Execution Timeline</span>
<Badge variant="outline" className="text-xs">
{executorRuns.length}
</Badge>
{isStreaming && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<div className="h-2 w-2 animate-pulse rounded-full bg-[#643FB2] dark:bg-[#8B5CF6]" />
<span>Running</span>
</div>
)}
</div>
{executorRuns.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleCopyAll}
className={`h-7 px-2 text-xs ${copied ? "text-green-600 dark:text-green-400" : ""}`}
>
{copied ? (
<>
<Check className="w-3 h-3 mr-1" />
Copied!
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copy All
</>
)}
</Button>
)}
</div>
{/* Timeline Content */}
<ScrollArea className="flex-1">
<div className="p-3 space-y-2">
{executorRuns.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
No executor runs yet. Start the workflow to see execution timeline.
</div>
) : (
executorRuns.map((run, index) => {
const runKey = `${run.executorId}-${run.runNumber}`;
return (
<ExecutorRunItem
key={`${runKey}-${index}`}
run={run}
isExpanded={expandedRuns.has(runKey)}
onToggle={() => {
setExpandedRuns((prev) => {
const next = new Set(prev);
if (next.has(runKey)) {
next.delete(runKey);
} else {
next.add(runKey);
}
return next;
});
}}
onClick={() => onExecutorClick?.(run.executorId)}
isSelected={selectedExecutorId === run.executorId}
/>
);
})
)}
{/* Workflow final output card */}
{workflowResult && workflowResult.trim().length > 0 && !isStreaming && (
<div className="border rounded-lg border-green-500/40 bg-green-500/5 dark:bg-green-500/10">
<div className="p-3 bg-green-500/10 border-b border-green-500/20">
<div className="flex items-center gap-2 mb-1">
<CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />
<span className="font-medium text-sm">Workflow Complete</span>
</div>
</div>
<div className="border-t px-3 py-2 bg-muted/30">
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">
Final Output:
</div>
<pre className="text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all">
{workflowResult}
</pre>
</div>
</div>
</div>
)}
{/* Invisible element at the end for scroll target */}
<div ref={timelineEndRef} />
</div>
</ScrollArea>
</div>
);
}
@@ -3,6 +3,7 @@ import { Handle, Position, type NodeProps } from "@xyflow/react";
import {
Workflow,
Home,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -155,34 +156,32 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
isRunning ? config.glow : "shadow-sm",
)}
>
{/* Small circular handles */}
{!nodeData.isStartNode && (
<Handle
type="target"
position={targetPosition}
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
)}
{/* Small circular handles - always render both to support any edge configuration */}
<Handle
type="target"
position={targetPosition}
id="target"
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
{!nodeData.isEndNode && (
<Handle
type="source"
position={sourcePosition}
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
)}
<Handle
type="source"
position={sourcePosition}
id="source"
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
<div className="p-3">
{/* Header with icon and title */}
@@ -196,18 +195,16 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
<Workflow className="w-5 h-5 text-gray-300 dark:text-gray-400" />
)}
</div>
{/* Small status badge for running state */}
{isRunning && (
<div className={cn(
"absolute -top-1 -right-1 w-3 h-3 rounded-full animate-pulse",
config.badgeColor
)} />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
{nodeData.name || nodeData.executorId}
</h3>
<div className="flex items-center gap-1.5">
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
{nodeData.name || nodeData.executorId}
</h3>
{isRunning && (
<Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin flex-shrink-0" />
)}
</div>
{nodeData.executorType && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
{nodeData.executorType}
@@ -0,0 +1,150 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { MessageCircle, Send, Loader2 } from "lucide-react";
import { SchemaFormRenderer, validateSchemaForm } from "./schema-form-renderer";
import type { JSONSchemaProperty } from "@/types";
interface HilRequest {
request_id: string;
request_data: Record<string, unknown>;
request_schema: JSONSchemaProperty;
}
interface HilInputModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
requests: HilRequest[];
responses: Record<string, Record<string, unknown>>;
onResponseChange: (requestId: string, values: Record<string, unknown>) => void;
onSubmit: () => void;
isSubmitting: boolean;
}
export function HilInputModal({
open,
onOpenChange,
requests,
responses,
onResponseChange,
onSubmit,
isSubmitting,
}: HilInputModalProps) {
// Check if all required fields are filled
const areAllRequiredFieldsFilled = () => {
return requests.every((req) => {
const response = responses[req.request_id] || {};
return validateSchemaForm(req.request_schema, response);
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<MessageCircle className="w-5 h-5" />
Workflow Requires Input ({requests.length} request
{requests.length > 1 ? "s" : ""})
</DialogTitle>
<DialogDescription>
The workflow is paused and needs your input to continue.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{requests.map((req, index) => (
<Card key={req.request_id}>
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
Request {index + 1}
<Badge variant="outline" className="ml-2 font-mono text-xs">
{req.request_id.slice(0, 8)}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{/* Show request data as readonly context */}
{Object.keys(req.request_data).length > 0 && (
<div className="mb-4 p-3 bg-muted rounded-md max-h-48 overflow-y-auto">
<p className="text-xs font-medium text-muted-foreground mb-2">
Request Context:
</p>
<div className="space-y-1">
{Object.entries(req.request_data)
.filter(([key]) => !["request_id", "source_executor_id"].includes(key))
.map(([key, value]) => (
<div key={key} className="text-xs">
<span className="font-medium">{key}:</span>{" "}
<span className="text-muted-foreground break-all">
{typeof value === "object" ? JSON.stringify(value) : String(value)}
</span>
</div>
))}
</div>
</div>
)}
{/* Show expected response hint if available */}
{req.request_schema?.description && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-md">
<p className="text-xs font-medium text-blue-900 dark:text-blue-100 mb-1">
Expected Response:
</p>
<p className="text-xs text-blue-700 dark:text-blue-300">
{req.request_schema.description}
</p>
</div>
)}
{/* Use schema-based form renderer for RESPONSE (not request) */}
<SchemaFormRenderer
schema={req.request_schema}
values={responses[req.request_id] || {}}
onChange={(values) => onResponseChange(req.request_id, values)}
disabled={isSubmitting}
/>
</CardContent>
</Card>
))}
</div>
<DialogFooter>
<div className="flex gap-2 w-full justify-end">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
onClick={onSubmit}
disabled={isSubmitting || !areAllRequiredFieldsFilled()}
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Submitting...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Submit & Continue
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -7,3 +7,5 @@ export { WorkflowDetailsModal } from "./workflow-details-modal";
export { WorkflowFlow } from "./workflow-flow";
export { WorkflowInputForm } from "./workflow-input-form";
export { ExecutorNode } from "./executor-node";
export { SchemaFormRenderer, validateSchemaForm, filterEmptyOptionalFields } from "./schema-form-renderer";
export { HilInputModal } from "./hil-input-modal";
@@ -0,0 +1,546 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { JSONSchemaProperty } from "@/types";
// ============================================================================
// Field Type Detection (from WorkflowInputForm)
// ============================================================================
function isShortField(fieldName: string): boolean {
const shortFieldNames = [
"name",
"title",
"id",
"key",
"label",
"type",
"status",
"tag",
"category",
"code",
"username",
"password",
"email",
];
return shortFieldNames.includes(fieldName.toLowerCase());
}
function shouldFieldBeTextarea(
fieldName: string,
schema: JSONSchemaProperty
): boolean {
return (
schema.format === "textarea" ||
(!!schema.description && schema.description.length > 100) ||
(schema.type === "string" && !schema.enum && !isShortField(fieldName))
);
}
function getFieldColumnSpan(
fieldName: string,
schema: JSONSchemaProperty
): string {
const isTextarea = shouldFieldBeTextarea(fieldName, schema);
const hasLongDescription =
!!schema.description && schema.description.length > 150;
if (isTextarea || hasLongDescription) {
return "md:col-span-2 lg:col-span-3 xl:col-span-4";
}
if (
schema.type === "array" ||
(!!schema.description && schema.description.length > 80)
) {
return "xl:col-span-2";
}
return "";
}
// ============================================================================
// ChatMessage Pattern Detection (from WorkflowInputForm)
// ============================================================================
function detectChatMessagePattern(
schema: JSONSchemaProperty,
requiredFields: string[]
): boolean {
if (schema.type !== "object" || !schema.properties) return false;
const properties = schema.properties;
const optionalFields = Object.keys(properties).filter(
(name) => !requiredFields.includes(name)
);
return (
requiredFields.includes("role") &&
optionalFields.some((f) => ["text", "message", "content"].includes(f)) &&
properties["role"]?.type === "string"
);
}
// ============================================================================
// Form Field Component (from WorkflowInputForm)
// ============================================================================
interface FormFieldProps {
name: string;
schema: JSONSchemaProperty;
value: unknown;
onChange: (value: unknown) => void;
isRequired?: boolean;
isReadOnly?: boolean; // NEW: for HIL display-only fields
}
function FormField({
name,
schema,
value,
onChange,
isRequired = false,
isReadOnly = false,
}: FormFieldProps) {
const { type, description, enum: enumValues, default: defaultValue } = schema;
const isTextarea = shouldFieldBeTextarea(name, schema);
const renderInput = () => {
// Read-only display (for HIL request context)
if (isReadOnly) {
return (
<div className="space-y-2">
<Label htmlFor={name} className="text-muted-foreground">
{name}
</Label>
<div className="text-sm p-2 bg-muted rounded border">
{typeof value === "object"
? JSON.stringify(value, null, 2)
: String(value)}
</div>
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</div>
);
}
switch (type) {
case "string":
if (enumValues) {
// Enum select
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Select
value={
typeof value === "string" && value
? value
: typeof defaultValue === "string"
? defaultValue
: enumValues[0]
}
onValueChange={(val) => onChange(val)}
>
<SelectTrigger>
<SelectValue placeholder={`Select ${name}`} />
</SelectTrigger>
<SelectContent>
{enumValues.map((option: string) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
} else if (isTextarea) {
// Multi-line text
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
placeholder={
typeof defaultValue === "string"
? defaultValue
: `Enter ${name}`
}
rows={4}
className="min-w-[300px] w-full"
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
} else {
// Single-line text
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id={name}
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
placeholder={
typeof defaultValue === "string"
? defaultValue
: `Enter ${name}`
}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
}
case "integer":
case "number":
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id={name}
type="number"
step={type === "integer" ? "1" : "any"}
value={typeof value === "number" ? value : ""}
onChange={(e) => {
const val =
type === "integer"
? parseInt(e.target.value)
: parseFloat(e.target.value);
onChange(isNaN(val) ? "" : val);
}}
placeholder={
typeof defaultValue === "number"
? defaultValue.toString()
: `Enter ${name}`
}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "boolean":
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id={name}
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked)}
/>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "array":
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={
Array.isArray(value)
? value.join(", ")
: typeof value === "string"
? value
: ""
}
onChange={(e) => {
const arrayValue = e.target.value
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
onChange(arrayValue);
}}
placeholder="Enter items separated by commas"
rows={2}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "object":
default:
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={
typeof value === "object" && value !== null
? JSON.stringify(value, null, 2)
: typeof value === "string"
? value
: ""
}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
onChange(parsed);
} catch {
onChange(e.target.value);
}
}}
placeholder='{"key": "value"}'
rows={3}
className="font-mono text-xs"
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
}
};
return <div className={getFieldColumnSpan(name, schema)}>{renderInput()}</div>;
}
// ============================================================================
// Main Schema Form Renderer Component
// ============================================================================
export interface SchemaFormRendererProps {
schema: JSONSchemaProperty;
values: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
disabled?: boolean;
readOnlyFields?: string[]; // NEW: Fields to display but not edit (for HIL)
hideFields?: string[]; // NEW: Fields to completely hide
showCollapsedByDefault?: boolean; // NEW: Control initial collapsed state
}
export function SchemaFormRenderer({
schema,
values,
onChange,
disabled = false,
readOnlyFields = [],
hideFields = [],
showCollapsedByDefault = false,
}: SchemaFormRendererProps) {
const [showAdvancedFields, setShowAdvancedFields] = useState(
showCollapsedByDefault
);
const properties = schema.properties || {};
const allFieldNames = Object.keys(properties).filter(
(name) => !hideFields.includes(name)
);
const requiredFields = (schema.required || []).filter(
(name) => !hideFields.includes(name)
);
// Detect ChatMessage pattern
const isChatMessageLike = detectChatMessagePattern(schema, requiredFields);
// Separate required and optional fields
const requiredFieldNames = allFieldNames.filter(
(name) =>
requiredFields.includes(name) && !(isChatMessageLike && name === "role")
);
const optionalFieldNames = allFieldNames.filter(
(name) => !requiredFields.includes(name)
);
// For ChatMessage: prioritize text/message/content
const sortedOptionalFields = isChatMessageLike
? [...optionalFieldNames].sort((a, b) => {
const priority = (name: string) =>
["text", "message", "content"].includes(name) ? 1 : 0;
return priority(b) - priority(a);
})
: optionalFieldNames;
// Show minimum visible fields
const MIN_VISIBLE_FIELDS = isChatMessageLike ? 1 : 6;
const visibleOptionalCount = Math.max(
0,
MIN_VISIBLE_FIELDS - requiredFieldNames.length
);
const visibleOptionalFields = sortedOptionalFields.slice(
0,
visibleOptionalCount
);
const collapsedOptionalFields = sortedOptionalFields.slice(
visibleOptionalCount
);
const hasCollapsedFields = collapsedOptionalFields.length > 0;
const hasRequiredFields = requiredFieldNames.length > 0;
const updateField = (fieldName: string, value: unknown) => {
onChange({
...values,
[fieldName]: value,
});
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-6">
{/* Required fields section */}
{requiredFieldNames.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={values[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={true}
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
/>
))}
{/* Separator between required and optional */}
{hasRequiredFields && optionalFieldNames.length > 0 && (
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
<div className="border-t border-border"></div>
</div>
)}
{/* Visible optional fields */}
{visibleOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={values[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
/>
))}
{/* Collapsed optional fields toggle */}
{hasCollapsedFields && (
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
className="w-full justify-center gap-2"
disabled={disabled}
>
{showAdvancedFields ? (
<>
<ChevronUp className="h-4 w-4" />
Hide {collapsedOptionalFields.length} optional field
{collapsedOptionalFields.length !== 1 ? "s" : ""}
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
Show {collapsedOptionalFields.length} optional field
{collapsedOptionalFields.length !== 1 ? "s" : ""}
</>
)}
</Button>
</div>
)}
{/* Collapsed optional fields */}
{showAdvancedFields &&
collapsedOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={values[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
/>
))}
</div>
);
}
// ============================================================================
// Export helper functions for validation
// ============================================================================
export function validateSchemaForm(
schema: JSONSchemaProperty,
values: Record<string, unknown>
): boolean {
const requiredFields = schema.required || [];
return requiredFields.every((fieldName) => {
const value = values[fieldName];
return value !== undefined && value !== "" && value !== null;
});
}
export function filterEmptyOptionalFields(
schema: JSONSchemaProperty,
values: Record<string, unknown>
): Record<string, unknown> {
const requiredFields = schema.required || [];
const filtered: Record<string, unknown> = {};
Object.keys(values).forEach((key) => {
const value = values[key];
// Include if: 1) required field, OR 2) has non-empty value
if (
requiredFields.includes(key) ||
(value !== undefined && value !== "" && value !== null)
) {
filtered[key] = value;
}
});
return filtered;
}
@@ -8,6 +8,7 @@ import {
Shuffle,
Zap,
ArrowDown,
ArrowLeftRight,
} from "lucide-react";
import {
DropdownMenu,
@@ -39,6 +40,7 @@ import {
processWorkflowEvents,
updateNodesWithEvents,
updateEdgesWithSequenceAnalysis,
consolidateBidirectionalEdges,
type NodeUpdate,
} from "@/utils/workflow-utils";
import type { ExtendedResponseStreamEvent } from "@/types";
@@ -59,7 +61,7 @@ function ViewOptionsPanel({
}: {
workflowDump?: Workflow;
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean };
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean; consolidateBidirectionalEdges: boolean };
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
layoutDirection: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
@@ -134,6 +136,16 @@ function ViewOptionsPanel({
</div>
<Checkbox checked={viewOptions.animateRun} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("consolidateBidirectionalEdges")}
>
<div className="flex items-center">
<ArrowLeftRight className="mr-2 h-4 w-4" />
Merge Bidirectional Edges
</div>
<Checkbox checked={viewOptions.consolidateBidirectionalEdges} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center justify-between"
@@ -192,12 +204,14 @@ interface WorkflowFlowProps {
showMinimap: boolean;
showGrid: boolean;
animateRun: boolean;
consolidateBidirectionalEdges: boolean;
};
onToggleViewOption?: (
key: keyof NonNullable<WorkflowFlowProps["viewOptions"]>
) => void;
layoutDirection?: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
timelineVisible?: boolean;
}
// Animation handler component that runs inside ReactFlow context
@@ -248,16 +262,35 @@ function WorkflowAnimationHandler({
return null; // This component doesn't render anything
}
// Timeline resize handler component that runs inside ReactFlow context
const TimelineResizeHandler = memo(({ timelineVisible }: { timelineVisible: boolean }) => {
const { fitView } = useReactFlow();
// Trigger fitView when timeline visibility changes to adjust ReactFlow viewport
useEffect(() => {
// Delay fitView to let CSS transition complete (timeline animation is 300ms)
const timeoutId = setTimeout(() => {
fitView({ padding: 0.2, duration: 300 });
}, 350); // Slightly longer than timeline animation duration
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timelineVisible]); // Only trigger when timelineVisible changes, not fitView reference
return null; // This component doesn't render anything
});
export const WorkflowFlow = memo(function WorkflowFlow({
workflowDump,
events,
isStreaming,
onNodeSelect,
className = "",
viewOptions = { showMinimap: false, showGrid: true, animateRun: true },
viewOptions = { showMinimap: false, showGrid: true, animateRun: true, consolidateBidirectionalEdges: true },
onToggleViewOption,
layoutDirection = "LR",
onLayoutDirectionChange,
timelineVisible = false,
}: WorkflowFlowProps) {
// Create initial nodes and edges from workflow dump
const { initialNodes, initialEdges } = useMemo(() => {
@@ -272,17 +305,22 @@ export const WorkflowFlow = memo(function WorkflowFlow({
);
const edges = convertWorkflowDumpToEdges(workflowDump);
// Apply bidirectional edge consolidation if enabled
const finalEdges = viewOptions.consolidateBidirectionalEdges
? consolidateBidirectionalEdges(edges)
: edges;
// Apply auto-layout if we have nodes and edges
const layoutedNodes =
nodes.length > 0
? applyDagreLayout(nodes, edges, layoutDirection)
? applyDagreLayout(nodes, finalEdges, layoutDirection)
: nodes;
return {
initialNodes: layoutedNodes,
initialEdges: edges,
initialEdges: finalEdges,
};
}, [workflowDump, onNodeSelect, layoutDirection]);
}, [workflowDump, onNodeSelect, layoutDirection, viewOptions.consolidateBidirectionalEdges]);
const [nodes, setNodes, onNodesChange] =
useNodesState<Node<ExecutorNodeData>>(initialNodes);
@@ -323,31 +361,38 @@ export const WorkflowFlow = memo(function WorkflowFlow({
currentEdges,
events
);
return updatedEdges;
// Apply consolidation if enabled (preserves updated styling from sequence analysis)
return viewOptions.consolidateBidirectionalEdges
? consolidateBidirectionalEdges(updatedEdges)
: updatedEdges;
});
} else {
// Reset all edges to default state when events are cleared
setEdges((currentEdges) =>
currentEdges.map((edge) => ({
setEdges((currentEdges) => {
const resetEdges = currentEdges.map((edge) => ({
...edge,
animated: false,
style: {
stroke: "#6b7280", // Gray
strokeWidth: 2,
},
}))
);
}));
// Apply consolidation if enabled
return viewOptions.consolidateBidirectionalEdges
? consolidateBidirectionalEdges(resetEdges)
: resetEdges;
});
}
}, [events, setEdges]);
}, [events, setEdges, viewOptions.consolidateBidirectionalEdges]);
// Initialize nodes only when workflow structure changes (not on state updates)
// Initialize nodes and edges when workflow structure OR consolidation setting changes
useEffect(() => {
if (initialNodes.length > 0) {
setNodes(initialNodes);
setEdges(initialEdges);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflowDump]); // Only re-initialize when workflowDump changes
}, [workflowDump, viewOptions.consolidateBidirectionalEdges]); // Re-initialize when workflow or consolidation toggle changes
const onNodeClick = useCallback(
(event: React.MouseEvent, node: Node<ExecutorNodeData>) => {
@@ -467,6 +512,7 @@ export const WorkflowFlow = memo(function WorkflowFlow({
isStreaming={isStreaming}
animateRun={viewOptions.animateRun}
/>
<TimelineResizeHandler timelineVisible={timelineVisible} />
<ViewOptionsPanel
workflowDump={workflowDump}
onNodeSelect={onNodeSelect}
@@ -0,0 +1,219 @@
/**
* Workflow Conversation Manager Component
* Handles conversation selection, creation, and deletion for workflow executions
*/
import React, { useEffect, useState, useCallback } from "react";
import { useDevUIStore } from "@/stores/devuiStore";
import { apiClient } from "@/services/api";
import { Trash2, Plus, Clock } from "lucide-react";
import type { WorkflowSession } from "@/types";
interface WorkflowSessionManagerProps {
workflowId: string;
onSessionChange?: (session: WorkflowSession | undefined) => void;
}
export const WorkflowSessionManager: React.FC<WorkflowSessionManagerProps> = ({
workflowId,
onSessionChange,
}) => {
// Use individual selectors to avoid creating new objects on every render
const currentSession = useDevUIStore((state) => state.currentSession);
const availableSessions = useDevUIStore((state) => state.availableSessions);
const loadingSessions = useDevUIStore((state) => state.loadingSessions);
const setCurrentSession = useDevUIStore((state) => state.setCurrentSession);
const setAvailableSessions = useDevUIStore((state) => state.setAvailableSessions);
const setLoadingSessions = useDevUIStore((state) => state.setLoadingSessions);
const addSession = useDevUIStore((state) => state.addSession);
const removeSession = useDevUIStore((state) => state.removeSession);
const addToast = useDevUIStore((state) => state.addToast);
const [creatingSession, setCreatingSession] = useState(false);
const [deletingSession, setDeletingSession] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
setLoadingSessions(true);
try {
const response = await apiClient.listWorkflowSessions(workflowId);
// If no conversations exist, auto-create one (like agent conversations)
if (response.data.length === 0) {
console.log("No workflow conversations found, creating default conversation");
const newSession = await apiClient.createWorkflowSession(workflowId, {
name: `Conversation ${new Date().toLocaleString()}`,
});
setAvailableSessions([newSession]);
setCurrentSession(newSession);
onSessionChange?.(newSession);
addToast({
message: "Default conversation created",
type: "success",
});
} else {
// Conversations exist - set available and auto-select the first one
setAvailableSessions(response.data);
// Auto-select first conversation if no current selection
if (!currentSession) {
const firstSession = response.data[0];
setCurrentSession(firstSession);
onSessionChange?.(firstSession);
}
}
} catch (error) {
console.error("Failed to load workflow conversations:", error);
addToast({
message: "Failed to load workflow conversations",
type: "error",
});
} finally {
setLoadingSessions(false);
}
}, [workflowId, currentSession, setLoadingSessions, setAvailableSessions, setCurrentSession, onSessionChange, addToast]);
// Load sessions on mount
useEffect(() => {
loadSessions();
}, [loadSessions]);
const handleCreateSession = async () => {
setCreatingSession(true);
try {
const newSession = await apiClient.createWorkflowSession(workflowId, {
name: `Conversation ${new Date().toLocaleString()}`,
});
addSession(newSession);
setCurrentSession(newSession);
onSessionChange?.(newSession);
addToast({
message: "New conversation created",
type: "success",
});
} catch (error) {
console.error("Failed to create conversation:", error);
addToast({
message: "Failed to create conversation",
type: "error",
});
} finally {
setCreatingSession(false);
}
};
const handleSelectSession = (session: WorkflowSession) => {
setCurrentSession(session);
onSessionChange?.(session);
};
const handleDeleteSession = async (
sessionId: string,
event: React.MouseEvent
) => {
event.stopPropagation(); // Prevent session selection when clicking delete
if (!confirm("Delete this conversation? All checkpoints will be lost.")) {
return;
}
setDeletingSession(sessionId);
try {
await apiClient.deleteWorkflowSession(workflowId, sessionId);
removeSession(sessionId);
addToast({
message: "Conversation deleted",
type: "success",
});
} catch (error) {
console.error("Failed to delete conversation:", error);
addToast({
message: "Failed to delete conversation",
type: "error",
});
} finally {
setDeletingSession(null);
}
};
const formatTimestamp = (timestamp: number) => {
const date = new Date(timestamp * 1000);
return date.toLocaleString();
};
if (loadingSessions) {
return (
<div className="flex items-center justify-center py-4">
<div className="animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full" />
<span className="ml-2 text-sm text-gray-600">Loading sessions...</span>
</div>
);
}
return (
<div className="workflow-session-manager space-y-3">
{/* Header with Create Button */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Conversations
</h3>
<button
onClick={handleCreateSession}
disabled={creatingSession}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Create new conversation"
>
<Plus className="h-4 w-4" />
New Conversation
</button>
</div>
{/* Conversation List */}
{availableSessions.length === 0 ? (
<div className="text-center py-6 text-sm text-gray-500 dark:text-gray-400">
Loading conversations...
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
{availableSessions.map((session) => (
<div
key={session.conversation_id}
onClick={() => handleSelectSession(session)}
className={`
flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-all
${
currentSession?.conversation_id === session.conversation_id
? "bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
}
`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-gray-400 flex-shrink-0" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{session.metadata.name || "Unnamed Conversation"}
</span>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatTimestamp(session.created_at)}
</div>
</div>
<button
onClick={(e) => handleDeleteSession(session.conversation_id, e)}
disabled={deletingSession === session.conversation_id}
className="ml-3 p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50"
title="Delete conversation"
>
{deletingSession === session.conversation_id ? (
<div className="animate-spin h-4 w-4 border-2 border-red-500 border-t-transparent rounded-full" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
</div>
))}
</div>
)}
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -4,14 +4,17 @@
*/
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EntitySelector } from "./entity-selector";
import { ModeToggle } from "@/components/mode-toggle";
import { Settings } from "lucide-react";
import { Settings, Zap } from "lucide-react";
import type { AgentInfo, WorkflowInfo } from "@/types";
import { useDevUIStore } from "@/stores";
interface AppHeaderProps {
agents: AgentInfo[];
workflows: WorkflowInfo[];
entities?: (AgentInfo | WorkflowInfo)[];
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
onBrowseGallery?: () => void;
@@ -22,12 +25,15 @@ interface AppHeaderProps {
export function AppHeader({
agents,
workflows,
entities,
selectedItem,
onSelect,
onBrowseGallery,
isLoading = false,
onSettingsClick,
}: AppHeaderProps) {
const { oaiMode } = useDevUIStore();
return (
<header className="flex h-14 items-center gap-4 border-b px-4">
<div className="flex items-center gap-2 font-semibold">
@@ -58,15 +64,29 @@ export function AppHeader({
</defs>
</svg>
Dev UI
{/* Mode Badge */}
{oaiMode.enabled && (
<Badge variant="secondary" className="gap-1 ml-2">
<Zap className="h-3 w-3" />
OpenAI: {oaiMode.model}
</Badge>
)}
</div>
<EntitySelector
agents={agents}
workflows={workflows}
selectedItem={selectedItem}
onSelect={onSelect}
onBrowseGallery={onBrowseGallery}
isLoading={isLoading}
/>
{/* Show entity selector only when NOT in OAI mode */}
{!oaiMode.enabled && (
<EntitySelector
agents={agents}
workflows={workflows}
entities={entities}
selectedItem={selectedItem}
onSelect={onSelect}
onBrowseGallery={onBrowseGallery}
isLoading={isLoading}
/>
)}
<div className="flex-1"></div>
<div className="flex items-center gap-2 ml-auto">
<ModeToggle />
@@ -20,7 +20,6 @@ import {
ChevronRight,
ChevronDown,
Info,
PanelRightClose,
} from "lucide-react";
import type { ExtendedResponseStreamEvent } from "@/types";
@@ -95,7 +94,7 @@ interface TraceEventData extends EventDataBase {
interface DebugPanelProps {
events: ExtendedResponseStreamEvent[];
isStreaming?: boolean;
onClose?: () => void;
onMinimize?: () => void;
}
// Helper: Extract function result from DevUI custom event
@@ -116,39 +115,6 @@ function getFunctionResultFromEvent(event: ExtendedResponseStreamEvent): {
return null;
}
// Helper to get a stable timestamp for an event
// Uses event's own timestamp fields if available
function getEventTimestamp(event: ExtendedResponseStreamEvent): string {
// Priority 1: Check for top-level timestamp (DevUI custom events like function_result.complete)
if ('timestamp' in event && typeof event.timestamp === 'string') {
return new Date(event.timestamp).toLocaleTimeString();
}
// Priority 2: Check for nested data.timestamp (workflow/trace events)
if ('data' in event && event.data && typeof event.data === 'object' && 'timestamp' in event.data) {
const dataTimestamp = (event.data as any).timestamp;
if (typeof dataTimestamp === 'string') {
return new Date(dataTimestamp).toLocaleTimeString();
}
}
// Priority 3: Check for created_at in response object (lifecycle events)
if ('response' in event && event.response && typeof event.response === 'object' && 'created_at' in event.response) {
const createdAt = (event.response as any).created_at;
if (typeof createdAt === 'number') {
return new Date(createdAt * 1000).toLocaleTimeString();
}
}
// Fallback: use sequence number as label (better than showing same time for all)
if ('sequence_number' in event && typeof event.sequence_number === 'number') {
return `#${event.sequence_number}`;
}
// Last resort: hide timestamp by returning empty string
return '';
}
// Helper function to accumulate OpenAI events into meaningful units
function processEventsForDisplay(
events: ExtendedResponseStreamEvent[]
@@ -170,8 +136,8 @@ function processEventsForDisplay(
for (const event of events) {
// Skip trace events - they belong in the Traces tab only
if (
event.type === "response.trace_event.complete" ||
event.type === "response.trace.complete"
event.type === "response.trace.completed" ||
event.type === "response.trace.completed"
) {
continue;
}
@@ -212,9 +178,9 @@ function processEventsForDisplay(
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.workflow_event.completed" ||
event.type === "response.trace.completed" ||
event.type === "response.trace.completed" ||
isFunctionResult
) {
// Flush any accumulated text before showing these events
@@ -228,8 +194,8 @@ function processEventsForDisplay(
// Extract function names from trace events
if (
(event.type === "response.trace_event.complete" ||
event.type === "response.trace.complete") &&
(event.type === "response.trace.completed" ||
event.type === "response.trace.completed") &&
"data" in event
) {
const traceData = event.data as TraceEventData;
@@ -483,15 +449,14 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
return "Output item added";
}
case "response.workflow_event.complete":
case "response.workflow_event.completed":
if ("data" in event && event.data) {
const data = event.data as WorkflowEventData;
return `Executor: ${data.executor_id || "unknown"}`;
}
return "Workflow event";
case "response.trace_event.complete":
case "response.trace.complete":
case "response.trace.completed":
if ("data" in event && event.data) {
const data = event.data as TraceEventData;
return `Trace: ${data.operation_name || "unknown"}`;
@@ -536,10 +501,9 @@ function getEventIcon(type: string) {
return CheckCircle2;
case "response.output_item.added":
return CheckCircle2;
case "response.workflow_event.complete":
case "response.workflow_event.completed":
return Activity;
case "response.trace_event.complete":
case "response.trace.complete":
case "response.trace.completed":
return Search;
case "response.completed":
return CheckCircle2;
@@ -564,10 +528,9 @@ function getEventColor(type: string) {
return "text-green-600 dark:text-green-400";
case "response.output_item.added":
return "text-green-600 dark:text-green-400";
case "response.workflow_event.complete":
case "response.workflow_event.completed":
return "text-purple-600 dark:text-purple-400";
case "response.trace_event.complete":
case "response.trace.complete":
case "response.trace.completed":
return "text-orange-600 dark:text-orange-400";
case "response.completed":
return "text-green-600 dark:text-green-400";
@@ -582,9 +545,15 @@ function getEventColor(type: string) {
function EventItem({ event }: EventItemProps) {
const [isExpanded, setIsExpanded] = useState(false);
const Icon = getEventIcon(event.type);
const colorClass = getEventColor(event.type);
const timestamp = getEventTimestamp(event);
const eventType = event.type || "unknown";
const Icon = getEventIcon(eventType);
const colorClass = getEventColor(eventType);
// Use stored UI timestamp if available, otherwise compute from event data
const timestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
? new Date(event._uiTimestamp * 1000).toLocaleTimeString()
: new Date().toLocaleTimeString();
const summary = getEventSummary(event);
// Determine if this event has expandable content
@@ -595,13 +564,13 @@ function EventItem({ event }: EventItemProps) {
event.type === "response.function_result.complete" ||
(event.type === "response.output_item.added" &&
getFunctionResultFromEvent(event) !== null) ||
(event.type === "response.workflow_event.complete" &&
(event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data) ||
(event.type === "response.trace_event.complete" &&
(event.type === "response.trace.completed" &&
"data" in event &&
event.data) ||
(event.type === "response.trace.complete" &&
(event.type === "response.trace.completed" &&
"data" in event &&
event.data) ||
(event.type === "response.output_text.delta" &&
@@ -620,7 +589,7 @@ function EventItem({ event }: EventItemProps) {
<Icon className={`h-3 w-3 ${colorClass}`} />
<span className="font-mono">{timestamp}</span>
<Badge variant="outline" className="text-xs py-0">
{event.type.replace("response.", "")}
{event.type ? event.type.replace("response.", "") : "unknown"}
</Badge>
</div>
@@ -859,7 +828,7 @@ function EventExpandedContent({
break;
}
case "response.workflow_event.complete":
case "response.workflow_event.completed":
if ("data" in event && event.data) {
const data = event.data as WorkflowEventData;
return (
@@ -915,8 +884,7 @@ function EventExpandedContent({
}
break;
case "response.trace_event.complete":
case "response.trace.complete":
case "response.trace.completed":
if ("data" in event && event.data) {
const data = event.data as TraceEventData;
return (
@@ -1193,8 +1161,8 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
// ONLY show actual trace events - handle both event type formats
const traceEvents = events.filter(
(e) =>
e.type === "response.trace_event.complete" ||
e.type === "response.trace.complete"
e.type === "response.trace.completed" ||
e.type === "response.trace.completed"
);
// Add separators between message rounds
@@ -1253,8 +1221,8 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
const [isExpanded, setIsExpanded] = useState(false);
if (
(event.type !== "response.trace_event.complete" &&
event.type !== "response.trace.complete") ||
(event.type !== "response.trace.completed" &&
event.type !== "response.trace.completed") ||
!("data" in event)
) {
return (
@@ -1266,14 +1234,19 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
const data = event.data as TraceEventData;
// Use actual trace timestamp if available, fallback to current time
let timestamp = new Date().toLocaleTimeString();
if (data.end_time) {
// Use stored UI timestamp first, then trace timestamps, then fallback to current time
let timestamp: string;
if ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number') {
// Use stored UI timestamp from when event was received
timestamp = new Date(event._uiTimestamp * 1000).toLocaleTimeString();
} else if (data.end_time) {
timestamp = new Date(data.end_time * 1000).toLocaleTimeString();
} else if (data.start_time) {
timestamp = new Date(data.start_time * 1000).toLocaleTimeString();
} else if (data.timestamp) {
timestamp = new Date(data.timestamp).toLocaleTimeString();
} else {
timestamp = new Date().toLocaleTimeString();
}
const operationName = data.operation_name || "Unknown Operation";
@@ -1520,7 +1493,10 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
}
function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
const timestamp = getEventTimestamp(event);
// Use stored UI timestamp if available, otherwise compute from current time
const timestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
? new Date(event._uiTimestamp * 1000).toLocaleTimeString()
: new Date().toLocaleTimeString();
// Check if this is a function call or result event
const isFunctionCall = event.type === "response.function_call.complete";
@@ -1621,7 +1597,7 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
export function DebugPanel({
events,
isStreaming = false,
onClose,
onMinimize,
}: DebugPanelProps) {
return (
<div className="flex-1 border-l flex flex-col min-h-0">
@@ -1638,15 +1614,15 @@ export function DebugPanel({
Tools
</TabsTrigger>
</TabsList>
{onClose && (
{onMinimize && (
<Button
variant="ghost"
size="sm"
onClick={onClose}
onClick={onMinimize}
className="h-8 w-8 p-0 flex-shrink-0"
title="Hide debug panel"
title="Minimize debug panel"
>
<PanelRightClose className="h-4 w-4" />
<ChevronRight className="h-4 w-4" />
</Button>
)}
</div>
@@ -20,12 +20,18 @@ import {
Copy,
CheckCircle2,
ExternalLink,
Loader2,
AlertCircle,
} from "lucide-react";
import { useDevUIStore } from "@/stores";
import { apiClient } from "@/services/api";
import type { AgentInfo, WorkflowInfo } from "@/types";
interface DeploymentModalProps {
open: boolean;
onClose: () => void;
agentName?: string;
entity?: AgentInfo | WorkflowInfo;
}
type Tab = "docker" | "azure";
@@ -34,10 +40,108 @@ export function DeploymentModal({
open,
onClose,
agentName = "Agent",
entity,
}: DeploymentModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("docker");
// Get the Azure deployment feature flag from store
const azureDeploymentEnabled = useDevUIStore((state) => state.azureDeploymentEnabled);
// Check if deployment is truly supported (both feature flag and backend support)
const deploymentSupported = azureDeploymentEnabled && (entity?.deployment_supported ?? false);
// Context-aware tab ordering: Azure first if deployable, Docker first otherwise
const [activeTab, setActiveTab] = useState<Tab>(
deploymentSupported ? "azure" : "docker"
);
const [copiedTemplate, setCopiedTemplate] = useState<string | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const logsContainerRef = useRef<HTMLDivElement | null>(null);
// Deployment state from Zustand
const isDeploying = useDevUIStore((state) => state.isDeploying);
const deploymentLogs = useDevUIStore((state) => state.deploymentLogs);
const lastDeployment = useDevUIStore((state) => state.lastDeployment);
const startDeployment = useDevUIStore((state) => state.startDeployment);
const addDeploymentLog = useDevUIStore((state) => state.addDeploymentLog);
const setDeploymentResult = useDevUIStore((state) => state.setDeploymentResult);
const stopDeployment = useDevUIStore((state) => state.stopDeployment);
const clearDeploymentState = useDevUIStore((state) => state.clearDeploymentState);
// Generate Azure-compliant default app name from entity name
const generateDefaultAppName = (entityName: string) => {
// Convert to lowercase, replace spaces and underscores with hyphens
// Remove any non-alphanumeric characters except hyphens
// Ensure it starts with a letter and is under 32 chars
const cleaned = entityName
.toLowerCase()
.replace(/[_\s]+/g, '-') // Replace underscores and spaces with hyphens
.replace(/[^a-z0-9-]/g, '') // Remove any other special characters
.replace(/--+/g, '-') // Replace multiple hyphens with single
.replace(/^[^a-z]+/, '') // Remove non-letter prefix
.replace(/-$/, ''); // Remove trailing hyphen
// Ensure it starts with a letter, add 'app-' prefix if needed
const withPrefix = cleaned.match(/^[a-z]/) ? cleaned : `app-${cleaned}`;
// Truncate to 31 chars max (32 limit)
return withPrefix.substring(0, 31);
};
// Form state for deployment with smart defaults
const defaultAppName = entity ? generateDefaultAppName(entity.id) : "";
const [resourceGroup, setResourceGroup] = useState("my-test-rg");
const [appName, setAppName] = useState(defaultAppName);
const [region, setRegion] = useState("eastus");
const [appNameError, setAppNameError] = useState<string | null>(null);
// Update app name when entity changes or modal opens
useEffect(() => {
if (entity) {
const newDefaultName = generateDefaultAppName(entity.id);
setAppName(newDefaultName);
// Validate the default name
const error = validateAppName(newDefaultName);
setAppNameError(error);
}
}, [entity?.id]); // Only re-run when entity ID changes
// Auto-scroll deployment logs to bottom when new logs are added
useEffect(() => {
if (logsContainerRef.current && deploymentLogs.length > 0) {
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
}
}, [deploymentLogs]);
// Validate Azure Container App name
const validateAppName = (name: string): string | null => {
if (!name) return null; // Don't show error for empty field
// Check length
if (name.length >= 32) {
return "App name must be less than 32 characters";
}
// Check for valid characters (lowercase alphanumeric and hyphens only)
if (!/^[a-z0-9-]+$/.test(name)) {
return "App name must contain only lowercase letters, numbers, and hyphens (no underscores or uppercase)";
}
// Must start with a letter
if (!/^[a-z]/.test(name)) {
return "App name must start with a lowercase letter";
}
// Must end with alphanumeric
if (!/[a-z0-9]$/.test(name)) {
return "App name must end with a letter or number";
}
// Cannot have double hyphens
if (name.includes("--")) {
return "App name cannot contain consecutive hyphens (--)";
}
return null;
};
// Cleanup timeout on unmount
useEffect(() => {
@@ -48,6 +152,48 @@ export function DeploymentModal({
};
}, []);
const handleDeploy = async () => {
if (!entity?.id || !resourceGroup || !appName) return;
// Trim whitespace from inputs
const trimmedResourceGroup = resourceGroup.trim();
const trimmedAppName = appName.trim();
// Validate trimmed app name before deployment
const nameError = validateAppName(trimmedAppName);
if (nameError) {
setAppNameError(nameError);
return;
}
try {
startDeployment();
for await (const event of apiClient.streamDeployment({
entity_id: entity.id,
resource_group: trimmedResourceGroup,
app_name: trimmedAppName,
region,
ui_mode: "user",
})) {
addDeploymentLog(event.message);
if (event.type === "deploy.completed" && event.url && event.auth_token) {
setDeploymentResult({
url: event.url,
authToken: event.auth_token,
});
} else if (event.type === "deploy.failed") {
// Stop deploying but keep logs visible
stopDeployment();
}
}
} catch (error) {
addDeploymentLog(`Error: ${error instanceof Error ? error.message : "Deployment failed"}`);
stopDeployment();
}
};
const handleCopy = async (template: string, templateName: string) => {
try {
await navigator.clipboard.writeText(template);
@@ -64,8 +210,7 @@ export function DeploymentModal({
timeoutRef.current = null;
}, 2000);
} catch (err) {
console.error("Failed to copy template:", err);
// Reset state on error
// Reset state on error - clipboard write failed
setCopiedTemplate(null);
}
};
@@ -149,20 +294,22 @@ openai>=1.0.0
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
<button
onClick={() => setActiveTab("azure")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "azure"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Cloud className="h-4 w-4 mr-2 inline" />
Azure
{activeTab === "azure" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
{deploymentSupported && (
<button
onClick={() => setActiveTab("azure")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "azure"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Cloud className="h-4 w-4 mr-2 inline" />
Azure
{activeTab === "azure" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
)}
</div>
{/* Tab Content */}
@@ -360,34 +507,230 @@ openai>=1.0.0
Deploy to Azure Container Apps
</h3>
<p className="text-sm text-muted-foreground">
Azure Container Apps provides serverless containers with
auto-scaling and integrated monitoring.
{deploymentSupported
? "One-click deployment to Azure with automatic containerization and authentication."
: "Azure Container Apps provides serverless containers with auto-scaling and integrated monitoring."}
</p>
</div>
{/* Prerequisites */}
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium text-sm">Prerequisites</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-muted-foreground">
<li>Azure subscription</li>
<li>
Azure CLI installed (
<code className="bg-muted px-1 rounded">
az --version
</code>
)
</li>
{/* Prerequisites Notice */}
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3">
<h4 className="text-sm font-semibold mb-2 text-blue-900 dark:text-blue-100">
Prerequisites for Azure Deployment
</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-blue-800 dark:text-blue-200">
<li>Azure CLI installed and authenticated (<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded">az login</code>)</li>
<li>Docker installed and running</li>
<li>
Logged in to Azure:{" "}
<code className="bg-muted px-1 rounded">az login</code>
<li>Azure subscription with the following providers registered:
<ul className="ml-4 mt-1 space-y-0.5">
<li className="list-none"> <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.App</code> (Container Apps)</li>
<li className="list-none"> <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.ContainerRegistry</code> (ACR)</li>
<li className="list-none"> <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.OperationalInsights</code> (Logging)</li>
</ul>
</li>
</ul>
<details className="mt-2">
<summary className="text-xs cursor-pointer hover:underline text-blue-700 dark:text-blue-300">
How to register providers?
</summary>
<div className="mt-2 p-2 bg-blue-100 dark:bg-blue-900 rounded text-xs">
<p className="mb-1">Run these commands once per subscription:</p>
<code className="block font-mono">
az provider register -n Microsoft.App --wait<br/>
az provider register -n Microsoft.ContainerRegistry --wait<br/>
az provider register -n Microsoft.OperationalInsights --wait
</code>
</div>
</details>
</div>
{/* Step-by-step */}
<div className="space-y-3">
<h4 className="font-medium text-sm">Deployment Steps</h4>
{/* Functional Deployment Form (only if supported) */}
{deploymentSupported && entity && !lastDeployment && (
<div className="border rounded-lg p-4 space-y-4">
{!isDeploying ? (
<>
<div className="space-y-3">
<div>
<label className="text-sm font-medium">Resource Group</label>
<input
type="text"
className="w-full mt-1 px-3 py-2 border rounded-md text-sm"
placeholder="my-test-rg"
value={resourceGroup}
onChange={(e) => setResourceGroup(e.target.value)}
/>
</div>
<div>
<label className="text-sm font-medium">App Name</label>
<input
type="text"
className={`w-full mt-1 px-3 py-2 border rounded-md text-sm ${
appNameError ? "border-red-500" : ""
}`}
placeholder="my-agent-app"
value={appName}
onChange={(e) => {
const newName = e.target.value;
setAppName(newName);
// Validate on change to provide immediate feedback
// Trim for validation to match what will be sent
const error = validateAppName(newName.trim());
setAppNameError(error);
}}
/>
{appNameError && (
<p className="mt-1 text-xs text-red-600">{appNameError}</p>
)}
</div>
<div>
<label className="text-sm font-medium">Region</label>
<select
className="w-full mt-1 px-3 py-2 border rounded-md text-sm"
value={region}
onChange={(e) => setRegion(e.target.value)}
>
<option value="eastus">East US</option>
<option value="westus">West US</option>
<option value="westeurope">West Europe</option>
<option value="eastasia">East Asia</option>
</select>
</div>
</div>
<Button
onClick={handleDeploy}
disabled={!resourceGroup || !appName || !!appNameError}
className="w-full"
>
<Rocket className="h-4 w-4 mr-2" />
Deploy to Azure
</Button>
</>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Loader2 className="h-4 w-4 animate-spin" />
Deploying...
</div>
<div
ref={logsContainerRef}
className="bg-muted p-3 rounded-md text-xs font-mono max-h-60 overflow-y-auto space-y-1"
>
{deploymentLogs.map((log, i) => (
<div key={i} className={log.includes("failed") || log.includes("Error") ? "text-red-600" : ""}>{log}</div>
))}
</div>
</div>
)}
{/* Show logs after deployment stops (success or failure) */}
{!isDeploying && deploymentLogs.length > 0 && !lastDeployment && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-red-600">
<AlertCircle className="h-4 w-4" />
Deployment Failed
</div>
<div className="bg-muted p-3 rounded-md text-xs font-mono max-h-60 overflow-y-auto space-y-1">
{deploymentLogs.map((log, i) => (
<div key={i} className={log.includes("failed") || log.includes("Error") ? "text-red-600" : ""}>{log}</div>
))}
</div>
<Button onClick={clearDeploymentState} variant="outline" className="w-full">
Try Again
</Button>
</div>
)}
</div>
)}
{/* Success Screen */}
{lastDeployment && (
<div className="border-2 border-green-200 bg-green-50 dark:bg-green-950/50 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<h4 className="font-semibold text-green-900 dark:text-green-100">
Deployment Successful!
</h4>
</div>
<div className="space-y-2">
<div>
<label className="text-xs font-medium text-green-800 dark:text-green-200">
Deployment URL
</label>
<div className="flex gap-2 mt-1">
<code className="flex-1 bg-white dark:bg-gray-900 px-3 py-2 rounded border text-sm">
{lastDeployment.url}
</code>
<Button
size="sm"
variant="outline"
onClick={() => window.open(lastDeployment.url, "_blank")}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<label className="text-xs font-medium text-green-800 dark:text-green-200">
Auth Token (save this - shown only once)
</label>
<div className="flex gap-2 mt-1">
<code className="flex-1 bg-white dark:bg-gray-900 px-3 py-2 rounded border text-sm font-mono">
{lastDeployment.authToken}
</code>
<Button
size="sm"
variant="outline"
onClick={() => navigator.clipboard.writeText(lastDeployment.authToken)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<Button onClick={clearDeploymentState} variant="outline" className="w-full">
Deploy Another
</Button>
</div>
)}
{/* Deployment Not Supported Warning */}
{!deploymentSupported && entity?.deployment_reason && (
<div className="bg-amber-50 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 rounded-md p-3">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 mt-0.5 text-amber-600 flex-shrink-0" />
<div className="text-sm text-amber-800 dark:text-amber-200">
<strong>Deployment not available:</strong> {entity.deployment_reason}
</div>
</div>
</div>
)}
{/* CLI Instructions (only show when deployment not supported) */}
{!deploymentSupported && (
<>
{/* Prerequisites */}
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium text-sm">Prerequisites</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-muted-foreground">
<li>Azure subscription</li>
<li>
Azure CLI installed (
<code className="bg-muted px-1 rounded">
az --version
</code>
)
</li>
<li>Docker installed and running</li>
<li>
Logged in to Azure:{" "}
<code className="bg-muted px-1 rounded">az login</code>
</li>
</ul>
</div>
{/* Step-by-step */}
<div className="space-y-3">
<h4 className="font-medium text-sm">Deployment Steps</h4>
<div className="space-y-3">
{/* Step 1 */}
@@ -508,6 +851,8 @@ az acr build --registry myregistry \\
</a>
</Button>
</div>
</>
)}
</div>
)}
</div>
@@ -20,6 +20,7 @@ import type { AgentInfo, WorkflowInfo } from "@/types";
interface EntitySelectorProps {
agents: AgentInfo[];
workflows: WorkflowInfo[];
entities?: (AgentInfo | WorkflowInfo)[]; // Full list in backend order
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
onBrowseGallery?: () => void;
@@ -33,6 +34,7 @@ const getTypeIcon = (type: "agent" | "workflow") => {
export function EntitySelector({
agents,
workflows,
entities,
selectedItem,
onSelect,
onBrowseGallery,
@@ -40,9 +42,8 @@ export function EntitySelector({
}: EntitySelectorProps) {
const [open, setOpen] = useState(false);
const allItems = [...agents, ...workflows].sort(
(a, b) => a.name?.localeCompare(b.name || a.id) || a.id.localeCompare(b.id)
);
// Use entities if provided (preserves backend order), otherwise combine agents and workflows
const allItems = entities || [...agents, ...workflows];
const handleSelect = (item: AgentInfo | WorkflowInfo) => {
onSelect(item);
@@ -82,80 +83,125 @@ export function EntitySelector({
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80 font-mono">
{agents.length > 0 && (
<>
<DropdownMenuLabel className="flex items-center gap-2">
<Bot className="h-4 w-4" />
Agents ({agents.length})
</DropdownMenuLabel>
{agents.map((agent) => {
const isAgentLoaded = agent.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={agent.id}
className="cursor-pointer group"
>
<div className="flex items-center justify-between w-full gap-2">
<div
className="flex items-center gap-2 min-w-0 flex-1"
onClick={() => handleSelect(agent)}
>
<Bot className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{agent.name || agent.id}
</span>
{isAgentLoaded && agent.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{agent.description}
</div>
)}
</div>
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
{/* Show items in backend order but with type grouping for clarity */}
{(() => {
// Group items by type while preserving order within each group
const workflowItems = allItems.filter(item => item.type === "workflow");
const agentItems = allItems.filter(item => item.type === "agent");
{workflows.length > 0 && (
<>
{agents.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="flex items-center gap-2">
<Workflow className="h-4 w-4" />
Workflows ({workflows.length})
</DropdownMenuLabel>
{workflows.map((workflow) => {
const isWorkflowLoaded = workflow.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={workflow.id}
className="cursor-pointer group"
>
<div className="flex items-center justify-between w-full gap-2">
<div
className="flex items-center gap-2 min-w-0 flex-1"
onClick={() => handleSelect(workflow)}
>
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{workflow.name || workflow.id}
</span>
{isWorkflowLoaded && workflow.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{workflow.description}
// Determine which type appears first in backend order
const firstItemType = allItems[0]?.type;
return (
<>
{/* Show workflows first if they appear first, otherwise agents */}
{firstItemType === "workflow" && workflowItems.length > 0 && (
<>
<DropdownMenuLabel className="flex items-center gap-2">
<Workflow className="h-4 w-4" />
Workflows ({workflowItems.length})
</DropdownMenuLabel>
{workflowItems.map((item) => {
const isLoaded = item.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={item.id}
className="cursor-pointer group"
onClick={() => handleSelect(item)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{item.name || item.id}
</span>
{isLoaded && item.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{item.description}
</div>
)}
</div>
)}
</div>
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
</div>
</DropdownMenuItem>
);
})}
</>
)}
{/* Separator if both types exist */}
{workflowItems.length > 0 && agentItems.length > 0 && <DropdownMenuSeparator />}
{/* Agents section */}
{agentItems.length > 0 && (
<>
<DropdownMenuLabel className="flex items-center gap-2">
<Bot className="h-4 w-4" />
Agents ({agentItems.length})
</DropdownMenuLabel>
{agentItems.map((item) => {
const isLoaded = item.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={item.id}
className="cursor-pointer group"
onClick={() => handleSelect(item)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Bot className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{item.name || item.id}
</span>
{isLoaded && item.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{item.description}
</div>
)}
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
{/* Show workflows last if agents appear first */}
{firstItemType === "agent" && workflowItems.length > 0 && (
<>
{agentItems.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="flex items-center gap-2">
<Workflow className="h-4 w-4" />
Workflows ({workflowItems.length})
</DropdownMenuLabel>
{workflowItems.map((item) => {
const isLoaded = item.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={item.id}
className="cursor-pointer group"
onClick={() => handleSelect(item)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{item.name || item.id}
</span>
{isLoaded && item.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{item.description}
</div>
)}
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
</>
);
})()}
{allItems.length === 0 && (
<DropdownMenuItem disabled>
@@ -13,7 +13,9 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ExternalLink, RotateCcw } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { ExternalLink, RotateCcw, Info, ChevronRight } from "lucide-react";
import { useDevUIStore } from "@/stores";
interface SettingsModalProps {
open: boolean;
@@ -21,10 +23,26 @@ interface SettingsModalProps {
onBackendUrlChange?: (url: string) => void;
}
type Tab = "about" | "settings";
type Tab = "general" | "proxy" | "about";
export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: SettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("settings");
// Preset OpenAI models for quick selection
const PRESET_MODELS = [
"gpt-4.1",
"gpt-4.1-mini",
"o1",
"o1-mini",
"o3-mini",
] as const;
export function SettingsModal({
open,
onOpenChange,
onBackendUrlChange,
}: SettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("general");
// OpenAI proxy mode, Azure deployment, and auth status from store
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired } = useDevUIStore();
// Get current backend URL from localStorage or default
const defaultUrl = import.meta.env.VITE_API_BASE_URL !== undefined ? import.meta.env.VITE_API_BASE_URL : "";
@@ -33,6 +51,10 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
});
const [tempUrl, setTempUrl] = useState(backendUrl);
// Auth token state
const [authTokenStored, setAuthTokenStored] = useState(!!localStorage.getItem("devui_auth_token"));
const [newAuthToken, setNewAuthToken] = useState("");
const handleSave = () => {
// Validate URL format
try {
@@ -59,30 +81,63 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
window.location.reload();
};
const handleAuthTokenSave = () => {
if (!newAuthToken.trim()) return;
localStorage.setItem("devui_auth_token", newAuthToken.trim());
setAuthTokenStored(true);
setNewAuthToken("");
// Reload to apply the auth token
window.location.reload();
};
const handleClearAuthToken = () => {
localStorage.removeItem("devui_auth_token");
setAuthTokenStored(false);
setNewAuthToken("");
// Reload to clear auth state
window.location.reload();
};
const isModified = tempUrl !== backendUrl;
const isDefault = !localStorage.getItem("devui_backend_url");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[600px] max-w-[90vw]">
<DialogHeader className="p-6 pb-2">
<DialogContent className="w-[600px] max-w-[90vw] flex flex-col max-h-[85vh]">
<DialogHeader className="p-6 pb-2 flex-shrink-0">
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<DialogClose onClose={() => onOpenChange(false)} />
{/* Tabs */}
<div className="flex border-b px-6">
<div className="flex border-b px-6 flex-shrink-0">
<button
onClick={() => setActiveTab("settings")}
onClick={() => setActiveTab("general")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "settings"
activeTab === "general"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Settings
{activeTab === "settings" && (
General
{activeTab === "general" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
<button
onClick={() => setActiveTab("proxy")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "proxy"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
OpenAI Proxy
{activeTab === "proxy" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
@@ -101,9 +156,9 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
</button>
</div>
{/* Tab Content */}
<div className="px-6 pb-6 min-h-[240px]">
{activeTab === "settings" && (
{/* Tab Content - Scrollable with min-height */}
<div className="px-6 pb-6 overflow-y-auto flex-1 min-h-[400px]">
{activeTab === "general" && (
<div className="space-y-6 pt-4">
{/* Backend URL Setting */}
<div className="space-y-3">
@@ -142,11 +197,7 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
<div className="flex gap-2 pt-2 min-h-[36px]">
{isModified && (
<>
<Button
onClick={handleSave}
size="sm"
className="flex-1"
>
<Button onClick={handleSave} size="sm" className="flex-1">
Apply & Reload
</Button>
<Button
@@ -161,6 +212,371 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
)}
</div>
</div>
{/* Auth Token Setting - Only show if backend requires auth OR token is already stored */}
{(authRequired || authTokenStored) && (
<div className="space-y-3 border-t pt-6">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Authentication Token
</Label>
{!authRequired && authTokenStored && (
<span className="text-xs text-muted-foreground">
(Not required by current backend)
</span>
)}
</div>
{authTokenStored ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
type="password"
value="••••••••••••••••••••"
disabled
className="font-mono text-sm flex-1"
/>
<Button
variant="destructive"
size="sm"
onClick={handleClearAuthToken}
className="flex-shrink-0"
>
Clear
</Button>
</div>
<p className="text-xs text-green-600 dark:text-green-400">
Token configured and stored locally
</p>
</div>
) : (
<div className="space-y-3">
<Input
type="password"
value={newAuthToken}
onChange={(e) => setNewAuthToken(e.target.value)}
placeholder="Enter bearer token"
className="font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter" && newAuthToken.trim()) {
handleAuthTokenSave();
}
}}
/>
<Button
onClick={handleAuthTokenSave}
size="sm"
disabled={!newAuthToken.trim()}
className="w-full"
>
Save & Reload
</Button>
<p className="text-xs text-muted-foreground">
{authRequired
? "Required by backend (started with --auth flag)"
: "Not required by current backend"}
</p>
</div>
)}
</div>
)}
{/* Deployment Setting */}
<div className="space-y-3 border-t pt-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
Azure Deployment
</Label>
<p className="text-xs text-muted-foreground">
Enable one-click deployment to Azure Container Apps
</p>
</div>
<Switch
checked={azureDeploymentEnabled}
onCheckedChange={setAzureDeploymentEnabled}
/>
</div>
{/* Expandable info section */}
<details className="group">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
Learn more about Azure deployment
</summary>
<div className="mt-3 space-y-3 pl-4">
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, agents that support deployment will show a "Deploy to Azure"
button. This allows you to deploy your agent to Azure Container Apps directly
from DevUI.
</p>
<div className="space-y-1.5">
<p className="text-xs font-medium">When enabled:</p>
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
<li>Shows "Deploy to Azure" for supported agents</li>
<li>Requires Azure CLI and proper authentication</li>
<li>Backend must have deployment capabilities enabled</li>
</ul>
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium">When disabled:</p>
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
<li>Shows "Deployment Guide" for all agents</li>
<li>Provides Docker templates and manual deployment instructions</li>
<li>No backend deployment capabilities required</li>
</ul>
</div>
</div>
</details>
</div>
</div>
)}
{activeTab === "proxy" && (
<div className="space-y-6 pt-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-base font-medium">
OpenAI Proxy Mode
</Label>
<p className="text-xs text-muted-foreground">
Route requests through DevUI backend to OpenAI API
</p>
</div>
<Switch
checked={oaiMode.enabled}
onCheckedChange={(checked: boolean) =>
setOAIMode({ ...oaiMode, enabled: checked })
}
/>
</div>
{/* Info box when disabled - prominent */}
{!oaiMode.enabled && (
<div className="bordder border-muted bg-muted/30 rounded-lg p-4 space-y-3">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 flex-shrink-0 mt-0.5 text-blue-600 dark:text-blue-400" />
<div className="space-y-2">
<p className="text-sm font-medium">
About OpenAI Proxy Mode
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, your chat requests are sent to your
DevUI backend{" "}
<span className="font-mono font-semibold">
({backendUrl})
</span>
, which then forwards them to OpenAI's API. This keeps
your{" "}
<span className="font-mono font-semibold">
OPENAI_API_KEY
</span>{" "}
secure on the server instead of exposing it in the
browser.
</p>
<div className="space-y-1.5 pt-1">
<p className="text-xs font-medium">Requirements:</p>
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
<li>
Backend must have{" "}
<span className="font-mono">OPENAI_API_KEY</span>{" "}
configured
</li>
<li>
Backend must support OpenAI Responses API proxying
(DevUI does)
</li>
</ul>
</div>
<div className="space-y-1.5 pt-1">
<p className="text-xs font-medium">Why use this?</p>
<p className="text-xs text-muted-foreground">
Quickly test and compare OpenAI models directly
through the DevUI interface without creating custom
agents or exposing API keys in the browser.
</p>
</div>
</div>
</div>
</div>
)}
{oaiMode.enabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
{/* Model ID Input - Primary control */}
<div className="space-y-2">
<Label className="text-sm font-medium">Model</Label>
<Input
type="text"
value={oaiMode.model}
onChange={(e) =>
setOAIMode({ ...oaiMode, model: e.target.value })
}
placeholder="gpt-4.1-mini"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Enter any OpenAI model ID (e.g., gpt-4.1, o1, o3-mini)
</p>
</div>
{/* Quick Preset Buttons */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Common presets
</Label>
<div className="flex flex-wrap gap-2">
{PRESET_MODELS.map((model) => (
<Button
key={model}
variant={
oaiMode.model === model ? "default" : "outline"
}
size="sm"
onClick={() => setOAIMode({ ...oaiMode, model })}
className="text-xs h-7"
>
{model}
</Button>
))}
</div>
</div>
{/* Advanced Parameters */}
<details className="group">
<summary className="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
Advanced Parameters (optional)
</summary>
<div className="space-y-3 mt-3 pl-4">
{/* Temperature */}
<div className="space-y-1">
<Label className="text-xs">Temperature</Label>
<Input
type="number"
step="0.1"
min="0"
max="2"
value={oaiMode.temperature ?? ""}
onChange={(e) =>
setOAIMode({
...oaiMode,
temperature: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder="1.0 (default)"
className="text-sm"
/>
<p className="text-xs text-muted-foreground">
Controls randomness (0-2)
</p>
</div>
{/* Max Output Tokens */}
<div className="space-y-1">
<Label className="text-xs">Max Output Tokens</Label>
<Input
type="number"
min="1"
value={oaiMode.max_output_tokens ?? ""}
onChange={(e) =>
setOAIMode({
...oaiMode,
max_output_tokens: e.target.value
? parseInt(e.target.value)
: undefined,
})
}
placeholder="Auto"
className="text-sm"
/>
<p className="text-xs text-muted-foreground">
Maximum tokens in response
</p>
</div>
{/* Top P */}
<div className="space-y-1">
<Label className="text-xs">Top P</Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
value={oaiMode.top_p ?? ""}
onChange={(e) =>
setOAIMode({
...oaiMode,
top_p: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder="1.0 (default)"
className="text-sm"
/>
<p className="text-xs text-muted-foreground">
Nucleus sampling (0-1)
</p>
</div>
{/* Reasoning Effort */}
<div className="space-y-1">
<Label className="text-xs">Reasoning Effort (o-series models)</Label>
<select
value={oaiMode.reasoning_effort ?? ""}
onChange={(e) =>
setOAIMode({
...oaiMode,
reasoning_effort: e.target.value
? (e.target.value as "minimal" | "low" | "medium" | "high")
: undefined,
})
}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="">Auto (default)</option>
<option value="minimal">Minimal</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<p className="text-xs text-muted-foreground">
Constrains reasoning effort (faster/cheaper vs thorough)
</p>
</div>
</div>
</details>
</div>
)}
</div>
{/* Collapsed info at bottom when enabled */}
{oaiMode.enabled && (
<div className="flex items-start gap-2 text-xs text-muted-foreground bg-muted/50 p-3 rounded">
<Info className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p>
Requests route through{" "}
<span className="font-mono font-semibold">
{backendUrl}
</span>{" "}
to OpenAI API. Server must have{" "}
<span className="font-mono font-semibold">
OPENAI_API_KEY
</span>{" "}
configured.
</p>
</div>
</div>
)}
</div>
)}
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }