mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
@@ -15,9 +15,7 @@
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
|
||||
@@ -3,49 +3,33 @@
|
||||
* Features: Entity selection, layout management, debug coordination
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useState } from "react";
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { AppHeader, DebugPanel, SettingsModal, DeploymentModal } from "@/components/layout";
|
||||
import { GalleryView } from "@/components/features/gallery";
|
||||
import { AgentView } from "@/components/features/agent";
|
||||
import { WorkflowView } from "@/components/features/workflow";
|
||||
import { Toast, ToastContainer } from "@/components/ui/toast";
|
||||
import { Toast } from "@/components/ui/toast";
|
||||
import { apiClient } from "@/services/api";
|
||||
import { PanelRightOpen, ChevronLeft, ChevronDown, ServerOff, Rocket, Lock } from "lucide-react";
|
||||
import { PanelRightOpen, ChevronDown, ServerOff, Rocket } from "lucide-react";
|
||||
import type {
|
||||
AgentInfo,
|
||||
WorkflowInfo,
|
||||
ExtendedResponseStreamEvent,
|
||||
} from "@/types";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { Input } from "./components/ui/input";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
|
||||
export default function App() {
|
||||
// Local state for auth handling
|
||||
const [authRequired, setAuthRequired] = useState(false);
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
const [isTestingToken, setIsTestingToken] = useState(false);
|
||||
const [authError, setAuthError] = useState("");
|
||||
|
||||
// Entity state from Zustand
|
||||
const agents = useDevUIStore((state) => state.agents);
|
||||
const workflows = useDevUIStore((state) => state.workflows);
|
||||
const entities = useDevUIStore((state) => state.entities);
|
||||
const selectedAgent = useDevUIStore((state) => state.selectedAgent);
|
||||
const azureDeploymentEnabled = useDevUIStore((state) => state.azureDeploymentEnabled);
|
||||
const isLoadingEntities = useDevUIStore((state) => state.isLoadingEntities);
|
||||
const entityError = useDevUIStore((state) => state.entityError);
|
||||
|
||||
// OpenAI proxy mode
|
||||
const oaiMode = useDevUIStore((state) => state.oaiMode);
|
||||
|
||||
// UI mode
|
||||
const uiMode = useDevUIStore((state) => state.uiMode);
|
||||
|
||||
// Entity actions
|
||||
const setAgents = useDevUIStore((state) => state.setAgents);
|
||||
const setWorkflows = useDevUIStore((state) => state.setWorkflows);
|
||||
const setEntities = useDevUIStore((state) => state.setEntities);
|
||||
const selectEntity = useDevUIStore((state) => state.selectEntity);
|
||||
const updateAgent = useDevUIStore((state) => state.updateAgent);
|
||||
const updateWorkflow = useDevUIStore((state) => state.updateWorkflow);
|
||||
@@ -54,14 +38,12 @@ export default function App() {
|
||||
|
||||
// UI state from Zustand
|
||||
const showDebugPanel = useDevUIStore((state) => state.showDebugPanel);
|
||||
const debugPanelMinimized = useDevUIStore((state) => state.debugPanelMinimized);
|
||||
const debugPanelWidth = useDevUIStore((state) => state.debugPanelWidth);
|
||||
const debugEvents = useDevUIStore((state) => state.debugEvents);
|
||||
const isResizing = useDevUIStore((state) => state.isResizing);
|
||||
|
||||
// UI actions
|
||||
const setShowDebugPanel = useDevUIStore((state) => state.setShowDebugPanel);
|
||||
const setDebugPanelMinimized = useDevUIStore((state) => state.setDebugPanelMinimized);
|
||||
const setDebugPanelWidth = useDevUIStore((state) => state.setDebugPanelWidth);
|
||||
const addDebugEvent = useDevUIStore((state) => state.addDebugEvent);
|
||||
const clearDebugEvents = useDevUIStore((state) => state.clearDebugEvents);
|
||||
@@ -79,40 +61,13 @@ export default function App() {
|
||||
const setShowDeployModal = useDevUIStore((state) => state.setShowDeployModal);
|
||||
const setShowEntityNotFoundToast = useDevUIStore((state) => state.setShowEntityNotFoundToast);
|
||||
|
||||
// Toast state and actions
|
||||
const toasts = useDevUIStore((state) => state.toasts);
|
||||
const removeToast = useDevUIStore((state) => state.removeToast);
|
||||
|
||||
// Initialize app - load agents and workflows
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// Fetch server metadata first (ui_mode, capabilities, auth status)
|
||||
const meta = await apiClient.getMeta();
|
||||
|
||||
// Check if auth is required
|
||||
if (meta.auth_required) {
|
||||
setAuthRequired(true);
|
||||
|
||||
// If we don't have a token, stop here and show auth UI
|
||||
if (!apiClient.getAuthToken()) {
|
||||
setEntityError("UNAUTHORIZED");
|
||||
setIsLoadingEntities(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
useDevUIStore.getState().setServerMeta({
|
||||
uiMode: meta.ui_mode,
|
||||
runtime: meta.runtime,
|
||||
capabilities: meta.capabilities,
|
||||
authRequired: meta.auth_required,
|
||||
});
|
||||
|
||||
// Single API call instead of two parallel calls to same endpoint
|
||||
const { entities: allEntities, agents: agentList, workflows: workflowList } = await apiClient.getEntities();
|
||||
const { agents: agentList, workflows: workflowList } = await apiClient.getEntities();
|
||||
|
||||
setEntities(allEntities);
|
||||
setAgents(agentList);
|
||||
setWorkflows(workflowList);
|
||||
|
||||
@@ -124,7 +79,9 @@ export default function App() {
|
||||
|
||||
// Try to find entity from URL parameter first
|
||||
if (entityId) {
|
||||
selectedEntity = allEntities.find((e) => e.id === entityId);
|
||||
selectedEntity =
|
||||
agentList.find((a) => a.id === entityId) ||
|
||||
workflowList.find((w) => w.id === entityId);
|
||||
|
||||
// If entity not found but was requested, show notification
|
||||
if (!selectedEntity) {
|
||||
@@ -134,9 +91,12 @@ export default function App() {
|
||||
|
||||
// Fallback to first available entity if URL entity not found
|
||||
if (!selectedEntity) {
|
||||
// Use the first entity from the backend's original order
|
||||
// This respects the backend's intended display order
|
||||
selectedEntity = allEntities.length > 0 ? allEntities[0] : undefined;
|
||||
selectedEntity =
|
||||
agentList.length > 0
|
||||
? agentList[0]
|
||||
: workflowList.length > 0
|
||||
? workflowList[0]
|
||||
: undefined;
|
||||
|
||||
// Update URL to match actual selected entity (or clear if none)
|
||||
if (selectedEntity) {
|
||||
@@ -180,14 +140,9 @@ export default function App() {
|
||||
setIsLoadingEntities(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to load agents/workflows:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to load data";
|
||||
|
||||
// Check if this is an auth error
|
||||
if (errorMessage === "UNAUTHORIZED") {
|
||||
setAuthRequired(true);
|
||||
}
|
||||
|
||||
setEntityError(errorMessage);
|
||||
setEntityError(
|
||||
error instanceof Error ? error.message : "Failed to load data"
|
||||
);
|
||||
setIsLoadingEntities(false);
|
||||
}
|
||||
};
|
||||
@@ -195,47 +150,6 @@ export default function App() {
|
||||
loadData();
|
||||
}, [setAgents, setWorkflows, selectEntity, updateAgent, updateWorkflow, setIsLoadingEntities, setEntityError, setShowEntityNotFoundToast]);
|
||||
|
||||
// Handle auth token submission
|
||||
const handleAuthTokenSubmit = useCallback(async () => {
|
||||
if (!authToken.trim()) return;
|
||||
|
||||
setIsTestingToken(true);
|
||||
setAuthError("");
|
||||
|
||||
try {
|
||||
// Set token in API client (stores in localStorage)
|
||||
apiClient.setAuthToken(authToken.trim());
|
||||
|
||||
// Test the token with an actual PROTECTED endpoint (not /meta which is public)
|
||||
await apiClient.getEntities();
|
||||
|
||||
// If successful, reload to initialize with new token
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
// Token is invalid - clear it and show error
|
||||
apiClient.clearAuthToken();
|
||||
setIsTestingToken(false);
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
||||
if (errorMsg === "UNAUTHORIZED") {
|
||||
setAuthError("Invalid token. Please check and try again.");
|
||||
} else {
|
||||
setAuthError(`Failed to connect: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
}, [authToken]);
|
||||
|
||||
// Auto-switch from workflow to agent when OpenAI proxy mode is enabled
|
||||
useEffect(() => {
|
||||
if (oaiMode.enabled && selectedAgent?.type === "workflow") {
|
||||
// Workflows don't work with OpenAI proxy - switch to first available agent
|
||||
const firstAgent = agents[0];
|
||||
if (firstAgent) {
|
||||
selectEntity(firstAgent);
|
||||
}
|
||||
}
|
||||
}, [oaiMode.enabled, selectedAgent, agents, selectEntity]);
|
||||
|
||||
// Handle resize drag
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -328,14 +242,12 @@ export default function App() {
|
||||
// Show error state if loading failed
|
||||
if (entityError) {
|
||||
const currentBackendUrl = apiClient.getBaseUrl();
|
||||
const isAuthError = entityError === "UNAUTHORIZED" || authRequired;
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-background">
|
||||
<AppHeader
|
||||
agents={[]}
|
||||
workflows={[]}
|
||||
entities={[]}
|
||||
selectedItem={undefined}
|
||||
onSelect={() => {}}
|
||||
isLoading={false}
|
||||
@@ -348,124 +260,63 @@ export default function App() {
|
||||
{/* Icon */}
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-full bg-muted p-4 animate-pulse">
|
||||
{isAuthError ? (
|
||||
<Lock className="h-12 w-12 text-muted-foreground" />
|
||||
) : (
|
||||
<ServerOff className="h-12 w-12 text-muted-foreground" />
|
||||
)}
|
||||
<ServerOff className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
{isAuthError ? "Authentication Required" : "Can't Connect to Backend"}
|
||||
Can't Connect to Backend
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-base">
|
||||
{isAuthError
|
||||
? "This backend requires a bearer token to access."
|
||||
: "No worries! Just start the DevUI backend server and you'll be good to go."}
|
||||
No worries! Just start the DevUI backend server and you'll be
|
||||
good to go.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auth Input or Command Instructions */}
|
||||
{isAuthError ? (
|
||||
<div className="space-y-4">
|
||||
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Enter Authentication Token
|
||||
</p>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Paste token from server logs"
|
||||
value={authToken}
|
||||
onChange={(e) => setAuthToken(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isTestingToken) {
|
||||
handleAuthTokenSubmit();
|
||||
}
|
||||
}}
|
||||
disabled={isTestingToken}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAuthTokenSubmit}
|
||||
disabled={!authToken.trim() || isTestingToken}
|
||||
className="w-full"
|
||||
>
|
||||
{isTestingToken ? "Verifying..." : "Connect"}
|
||||
</Button>
|
||||
|
||||
{/* Error message */}
|
||||
{authError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 text-center">
|
||||
{authError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<details className="text-left group">
|
||||
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2 justify-center">
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
|
||||
Where do I find the token?
|
||||
</summary>
|
||||
<div className="mt-3 text-left bg-muted/30 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Look for this in your DevUI server startup logs:
|
||||
</p>
|
||||
<code className="block bg-background px-2 py-1 rounded text-xs font-mono text-foreground">
|
||||
🔑 DEV TOKEN (localhost only, shown once):
|
||||
<br />
|
||||
abc123xyz...
|
||||
</code>
|
||||
</div>
|
||||
</details>
|
||||
{/* Command Instructions */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Start the backend:
|
||||
</p>
|
||||
<code className="block bg-background px-3 py-2 rounded border text-sm font-mono text-foreground">
|
||||
devui ./agents --port 8080
|
||||
</code>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Or launch programmatically with{" "}
|
||||
<code className="text-xs">serve(entities=[agent])</code>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Start the backend:
|
||||
</p>
|
||||
<code className="block bg-background px-3 py-2 rounded border text-sm font-mono text-foreground">
|
||||
devui ./agents --port 8080
|
||||
</code>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Or launch programmatically with{" "}
|
||||
<code className="text-xs">serve(entities=[agent])</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default:{" "}
|
||||
<span className="font-mono">{currentBackendUrl}</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default:{" "}
|
||||
<span className="font-mono">{currentBackendUrl}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Details (Collapsible) */}
|
||||
{entityError && (
|
||||
<details className="text-left group">
|
||||
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2">
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
|
||||
Error details
|
||||
</summary>
|
||||
<p className="mt-2 text-xs text-muted-foreground font-mono bg-muted/30 p-3 rounded border">
|
||||
{entityError}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Retry Button */}
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="default"
|
||||
className="mt-2"
|
||||
>
|
||||
Retry Connection
|
||||
</Button>
|
||||
</>
|
||||
{/* Error Details (Collapsible) */}
|
||||
{entityError && (
|
||||
<details className="text-left group">
|
||||
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2">
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
|
||||
Error details
|
||||
</summary>
|
||||
<p className="mt-2 text-xs text-muted-foreground font-mono bg-muted/30 p-3 rounded border">
|
||||
{entityError}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Retry Button */}
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="default"
|
||||
className="mt-2"
|
||||
>
|
||||
Retry Connection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -480,7 +331,6 @@ export default function App() {
|
||||
<AppHeader
|
||||
agents={agents}
|
||||
workflows={workflows}
|
||||
entities={entities}
|
||||
selectedItem={selectedAgent}
|
||||
onSelect={handleEntitySelect}
|
||||
onBrowseGallery={() => setShowGallery(true)}
|
||||
@@ -527,7 +377,7 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uiMode === "developer" && showDebugPanel ? (
|
||||
{showDebugPanel ? (
|
||||
<>
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
@@ -550,68 +400,31 @@ export default function App() {
|
||||
{/* Right Panel - Debug */}
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col h-[calc(100vh-3.7rem)]"
|
||||
style={{ width: debugPanelMinimized ? '2.5rem' : `${debugPanelWidth}px` }}
|
||||
style={{ width: `${debugPanelWidth}px` }}
|
||||
>
|
||||
{debugPanelMinimized ? (
|
||||
/* Minimized Debug Panel - Vertical Bar (fully clickable) */
|
||||
<div
|
||||
className="h-full w-10 bg-background border-l flex flex-col items-center py-2 cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => setDebugPanelMinimized(false)}
|
||||
title="Expand debug panel"
|
||||
<DebugPanel
|
||||
events={debugEvents}
|
||||
isStreaming={false} // Each view manages its own streaming state
|
||||
onClose={() => setShowDebugPanel(false)}
|
||||
/>
|
||||
|
||||
{/* Deploy Footer - Pinned to bottom */}
|
||||
<div className="border-t bg-muted/30 px-3 py-2.5 flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => setShowDeployModal(true)}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{/* Expand button at top (visual affordance) */}
|
||||
<div className="h-8 w-8 flex items-center justify-center">
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Text and count centered in middle */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 pointer-events-none">
|
||||
<div
|
||||
className="text-xs text-muted-foreground select-none"
|
||||
style={{
|
||||
writingMode: 'vertical-rl',
|
||||
transform: 'rotate(180deg)'
|
||||
}}
|
||||
>
|
||||
Debug Panel
|
||||
</div>
|
||||
{debugEvents.length > 0 && (
|
||||
<div className="bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center"
|
||||
style={{ fontSize: '10px' }}>
|
||||
{debugEvents.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DebugPanel
|
||||
events={debugEvents}
|
||||
isStreaming={false} // Each view manages its own streaming state
|
||||
onMinimize={() => setDebugPanelMinimized(true)}
|
||||
/>
|
||||
|
||||
{/* Deploy Footer - Pinned to bottom */}
|
||||
<div className="border-t bg-muted/30 px-3 py-2.5 flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => setShowDeployModal(true)}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Rocket className="h-3 w-3 mr-2 flex-shrink-0" />
|
||||
<span className="truncate text-xs">
|
||||
{azureDeploymentEnabled && selectedAgent?.deployment_supported
|
||||
? "Deploy to Azure"
|
||||
: "Deployment Guide"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Rocket className="h-3 w-3 mr-2 flex-shrink-0" />
|
||||
<span className="truncate text-xs">
|
||||
Deployment Guide for {selectedAgent?.name || "Agent"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : uiMode === "developer" ? (
|
||||
) : (
|
||||
/* Button to reopen when closed */
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
@@ -624,7 +437,7 @@ export default function App() {
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -637,7 +450,6 @@ export default function App() {
|
||||
open={showDeployModal}
|
||||
onClose={() => setShowDeployModal(false)}
|
||||
agentName={selectedAgent?.name}
|
||||
entity={selectedAgent}
|
||||
/>
|
||||
|
||||
{/* Toast Notification */}
|
||||
@@ -648,9 +460,6 @@ export default function App() {
|
||||
onClose={() => setShowEntityNotFoundToast(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toast Container for reload and other notifications */}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
X,
|
||||
Copy,
|
||||
CheckCheck,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "@/services/api";
|
||||
import type {
|
||||
@@ -119,7 +118,7 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
|
||||
>
|
||||
<div className="relative group">
|
||||
<div
|
||||
className={`rounded px-3 py-2 text-sm ${
|
||||
className={`rounded px-3 py-2 text-sm break-all ${
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isError
|
||||
@@ -162,12 +161,7 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
|
||||
<span>
|
||||
{item.created_at
|
||||
? new Date(item.created_at * 1000).toLocaleTimeString()
|
||||
: new Date().toLocaleTimeString() // Fallback for legacy items without timestamp
|
||||
}
|
||||
</span>
|
||||
<span>{new Date().toLocaleTimeString()}</span>
|
||||
{!isUser && item.usage && (
|
||||
<>
|
||||
<span>•</span>
|
||||
@@ -213,10 +207,8 @@ 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);
|
||||
@@ -235,12 +227,6 @@ 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);
|
||||
@@ -618,21 +604,10 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
setAvailableConversations([newConversation]);
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
setConversationError(null); // Clear any previous errors
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem(cachedKey, JSON.stringify([newConversation]));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
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);
|
||||
}
|
||||
@@ -881,22 +856,11 @@ 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 = "";
|
||||
|
||||
// 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",
|
||||
});
|
||||
} catch {
|
||||
// Failed to create conversation
|
||||
}
|
||||
}, [selectedAgent, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
|
||||
|
||||
@@ -951,42 +915,6 @@ 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) => {
|
||||
@@ -1074,27 +1002,6 @@ 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 = [
|
||||
{
|
||||
@@ -1112,12 +1019,13 @@ 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
|
||||
// Remove from pending immediately (will be confirmed by backend event)
|
||||
setPendingApprovals(
|
||||
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== request_id)
|
||||
);
|
||||
@@ -1131,14 +1039,6 @@ 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[] = [];
|
||||
|
||||
@@ -1169,23 +1069,16 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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]);
|
||||
}
|
||||
// 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",
|
||||
};
|
||||
|
||||
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
|
||||
setIsStreaming(true);
|
||||
|
||||
// Create assistant message placeholder
|
||||
@@ -1195,7 +1088,6 @@ 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]);
|
||||
@@ -1210,17 +1102,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
});
|
||||
setCurrentConversation(conversationToUse);
|
||||
setAvailableConversations([conversationToUse, ...useDevUIStore.getState().availableConversations]);
|
||||
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
|
||||
} catch {
|
||||
// Failed to create conversation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1262,25 +1145,16 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
continue; // Continue processing other events
|
||||
}
|
||||
|
||||
// Handle response.failed event (OpenAI standard)
|
||||
// Handle response.failed event
|
||||
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"
|
||||
@@ -1297,14 +1171,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
: item
|
||||
));
|
||||
setIsStreaming(false);
|
||||
return; // Exit stream processing on failure
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle function approval request events
|
||||
if (openAIEvent.type === "response.function_approval.requested") {
|
||||
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
|
||||
|
||||
// Add to pending approvals (for popup)
|
||||
// Add to pending approvals
|
||||
setPendingApprovals([
|
||||
...useDevUIStore.getState().pendingApprovals,
|
||||
{
|
||||
@@ -1312,46 +1186,17 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
function_call: approvalEvent.function_call,
|
||||
},
|
||||
]);
|
||||
|
||||
// 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;
|
||||
continue; // Don't add approval requests to chat UI
|
||||
}
|
||||
|
||||
// Handle function result events (after function execution)
|
||||
if (openAIEvent.type === "response.function_result.complete") {
|
||||
const resultEvent = openAIEvent as import("@/types/openai").ResponseFunctionResultComplete;
|
||||
// Handle function approval response events
|
||||
if (openAIEvent.type === "response.function_approval.responded") {
|
||||
const responseEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRespondedEvent;
|
||||
|
||||
// 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]);
|
||||
// Remove from pending approvals
|
||||
setPendingApprovals(
|
||||
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== responseEvent.request_id)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1382,57 +1227,6 @@ 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" &&
|
||||
@@ -1442,26 +1236,21 @@ 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) => {
|
||||
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;
|
||||
}));
|
||||
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
|
||||
));
|
||||
}
|
||||
|
||||
// Handle completion/error by detecting when streaming stops
|
||||
@@ -1646,42 +1435,19 @@ 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">
|
||||
{oaiMode.enabled
|
||||
? `Chat with ${oaiMode.model}`
|
||||
: `Chat with ${selectedAgent.name || selectedAgent.id}`
|
||||
}
|
||||
Chat with {selectedAgent.name || selectedAgent.id}
|
||||
</span>
|
||||
</div>
|
||||
</h2>
|
||||
{!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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Conversation Controls */}
|
||||
@@ -1773,46 +1539,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{oaiMode.enabled ? (
|
||||
{selectedAgent.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Using OpenAI model directly. Local agent tools and instructions are not applied.
|
||||
{selectedAgent.description}
|
||||
</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">
|
||||
|
||||
+4
-136
@@ -11,9 +11,6 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Music,
|
||||
Check,
|
||||
X,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import type { MessageContent } from "@/types/openai";
|
||||
import { MarkdownRenderer } from "@/components/ui/markdown-renderer";
|
||||
@@ -40,12 +37,12 @@ function TextContentRenderer({ content, className, isStreaming }: ContentRendere
|
||||
);
|
||||
}
|
||||
|
||||
// Image content renderer (handles both input and output images)
|
||||
// Image content renderer
|
||||
function ImageContentRenderer({ content, className }: ContentRendererProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (content.type !== "input_image" && content.type !== "output_image") return null;
|
||||
if (content.type !== "input_image") return null;
|
||||
|
||||
const imageUrl = content.image_url;
|
||||
|
||||
@@ -80,9 +77,9 @@ function ImageContentRenderer({ content, className }: ContentRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// File content renderer (handles both input and output files)
|
||||
// File content renderer
|
||||
function FileContentRenderer({ content, className }: ContentRendererProps) {
|
||||
if (content.type !== "input_file" && content.type !== "output_file") return null;
|
||||
if (content.type !== "input_file") return null;
|
||||
|
||||
const fileUrl = content.file_url || content.file_data;
|
||||
const filename = content.filename || "file";
|
||||
@@ -159,129 +156,6 @@ 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) {
|
||||
@@ -290,15 +164,9 @@ 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;
|
||||
}
|
||||
|
||||
-528
@@ -1,528 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
import { truncateText } from "@/utils/workflow-utils";
|
||||
|
||||
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="grid grid-cols-[auto_auto_1fr_auto] items-center gap-2 mb-1">
|
||||
<div className="w-3 text-muted-foreground">
|
||||
{canExpand && (
|
||||
<>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>{getStateIcon(run.state)}</div>
|
||||
<span className="font-medium text-sm truncate overflow-hidden">
|
||||
{run.executorName}
|
||||
</span>
|
||||
{run.runNumber > 1 ? (
|
||||
<Badge variant="outline" className="text-xs whitespace-nowrap">
|
||||
Run #{run.runNumber}
|
||||
</Badge>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
</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: truncateText(executorId, 35),
|
||||
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: truncateText(executorId, 35),
|
||||
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: truncateText(executorId, 35),
|
||||
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,10 +3,8 @@ import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import {
|
||||
Workflow,
|
||||
Home,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { truncateText } from "@/utils/workflow-utils";
|
||||
|
||||
export type ExecutorState =
|
||||
| "pending"
|
||||
@@ -83,13 +81,11 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
const details = [];
|
||||
|
||||
if (nodeData.error && typeof nodeData.error === "string") {
|
||||
// Truncate error to first 150 characters for node display
|
||||
const truncatedError = truncateText(nodeData.error, 150);
|
||||
details.push(
|
||||
<div key="error" className="mb-2">
|
||||
<div className="text-xs font-medium text-red-600 dark:text-red-400 mb-1">Error:</div>
|
||||
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800 break-words">
|
||||
{truncatedError}
|
||||
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800">
|
||||
{nodeData.error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -159,32 +155,34 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
isRunning ? config.glow : "shadow-sm",
|
||||
)}
|
||||
>
|
||||
{/* 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"
|
||||
}}
|
||||
/>
|
||||
{/* 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"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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"
|
||||
}}
|
||||
/>
|
||||
{!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"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="p-3">
|
||||
{/* Header with icon and title */}
|
||||
@@ -198,16 +196,18 @@ 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">
|
||||
<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>
|
||||
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
|
||||
{nodeData.name || nodeData.executorId}
|
||||
</h3>
|
||||
{nodeData.executorType && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
|
||||
{nodeData.executorType}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
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,5 +7,3 @@ 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";
|
||||
|
||||
-546
@@ -1,546 +0,0 @@
|
||||
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,7 +8,6 @@ import {
|
||||
Shuffle,
|
||||
Zap,
|
||||
ArrowDown,
|
||||
ArrowLeftRight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -40,7 +39,6 @@ import {
|
||||
processWorkflowEvents,
|
||||
updateNodesWithEvents,
|
||||
updateEdgesWithSequenceAnalysis,
|
||||
consolidateBidirectionalEdges,
|
||||
type NodeUpdate,
|
||||
} from "@/utils/workflow-utils";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
@@ -61,7 +59,7 @@ function ViewOptionsPanel({
|
||||
}: {
|
||||
workflowDump?: Workflow;
|
||||
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
|
||||
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean; consolidateBidirectionalEdges: boolean };
|
||||
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean };
|
||||
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
|
||||
layoutDirection: "LR" | "TB";
|
||||
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
|
||||
@@ -136,16 +134,6 @@ 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"
|
||||
@@ -204,14 +192,12 @@ 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
|
||||
@@ -262,35 +248,16 @@ 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, consolidateBidirectionalEdges: true },
|
||||
viewOptions = { showMinimap: false, showGrid: true, animateRun: true },
|
||||
onToggleViewOption,
|
||||
layoutDirection = "LR",
|
||||
onLayoutDirectionChange,
|
||||
timelineVisible = false,
|
||||
}: WorkflowFlowProps) {
|
||||
// Create initial nodes and edges from workflow dump
|
||||
const { initialNodes, initialEdges } = useMemo(() => {
|
||||
@@ -305,22 +272,17 @@ 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, finalEdges, layoutDirection)
|
||||
? applyDagreLayout(nodes, edges, layoutDirection)
|
||||
: nodes;
|
||||
|
||||
return {
|
||||
initialNodes: layoutedNodes,
|
||||
initialEdges: finalEdges,
|
||||
initialEdges: edges,
|
||||
};
|
||||
}, [workflowDump, onNodeSelect, layoutDirection, viewOptions.consolidateBidirectionalEdges]);
|
||||
}, [workflowDump, onNodeSelect, layoutDirection]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] =
|
||||
useNodesState<Node<ExecutorNodeData>>(initialNodes);
|
||||
@@ -361,38 +323,31 @@ export const WorkflowFlow = memo(function WorkflowFlow({
|
||||
currentEdges,
|
||||
events
|
||||
);
|
||||
// Apply consolidation if enabled (preserves updated styling from sequence analysis)
|
||||
return viewOptions.consolidateBidirectionalEdges
|
||||
? consolidateBidirectionalEdges(updatedEdges)
|
||||
: updatedEdges;
|
||||
return updatedEdges;
|
||||
});
|
||||
} else {
|
||||
// Reset all edges to default state when events are cleared
|
||||
setEdges((currentEdges) => {
|
||||
const resetEdges = currentEdges.map((edge) => ({
|
||||
setEdges((currentEdges) =>
|
||||
currentEdges.map((edge) => ({
|
||||
...edge,
|
||||
animated: false,
|
||||
style: {
|
||||
stroke: "#6b7280", // Gray
|
||||
strokeWidth: 2,
|
||||
},
|
||||
}));
|
||||
// Apply consolidation if enabled
|
||||
return viewOptions.consolidateBidirectionalEdges
|
||||
? consolidateBidirectionalEdges(resetEdges)
|
||||
: resetEdges;
|
||||
});
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [events, setEdges, viewOptions.consolidateBidirectionalEdges]);
|
||||
}, [events, setEdges]);
|
||||
|
||||
// Initialize nodes and edges when workflow structure OR consolidation setting changes
|
||||
// Initialize nodes only when workflow structure changes (not on state updates)
|
||||
useEffect(() => {
|
||||
if (initialNodes.length > 0) {
|
||||
setNodes(initialNodes);
|
||||
setEdges(initialEdges);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workflowDump, viewOptions.consolidateBidirectionalEdges]); // Re-initialize when workflow or consolidation toggle changes
|
||||
}, [workflowDump]); // Only re-initialize when workflowDump changes
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
(event: React.MouseEvent, node: Node<ExecutorNodeData>) => {
|
||||
@@ -512,7 +467,6 @@ export const WorkflowFlow = memo(function WorkflowFlow({
|
||||
isStreaming={isStreaming}
|
||||
animateRun={viewOptions.animateRun}
|
||||
/>
|
||||
<TimelineResizeHandler timelineVisible={timelineVisible} />
|
||||
<ViewOptionsPanel
|
||||
workflowDump={workflowDump}
|
||||
onNodeSelect={onNodeSelect}
|
||||
|
||||
-225
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* 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 runtime = useDevUIStore((state) => state.runtime);
|
||||
|
||||
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);
|
||||
|
||||
// Silently handle for .NET backend (doesn't support conversations yet)
|
||||
// Only show error for Python backend where this is unexpected
|
||||
if (runtime !== "dotnet") {
|
||||
addToast({
|
||||
message: "Failed to load workflow conversations",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
}, [workflowId, currentSession, runtime, 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>
|
||||
);
|
||||
};
|
||||
+664
-962
File diff suppressed because it is too large
Load Diff
@@ -4,17 +4,14 @@
|
||||
*/
|
||||
|
||||
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, Zap } from "lucide-react";
|
||||
import { Settings } 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;
|
||||
@@ -25,15 +22,12 @@ 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">
|
||||
@@ -64,29 +58,15 @@ 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>
|
||||
|
||||
{/* 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>
|
||||
<EntitySelector
|
||||
agents={agents}
|
||||
workflows={workflows}
|
||||
selectedItem={selectedItem}
|
||||
onSelect={onSelect}
|
||||
onBrowseGallery={onBrowseGallery}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<ModeToggle />
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Info,
|
||||
PanelRightClose,
|
||||
} from "lucide-react";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
|
||||
@@ -94,7 +95,7 @@ interface TraceEventData extends EventDataBase {
|
||||
interface DebugPanelProps {
|
||||
events: ExtendedResponseStreamEvent[];
|
||||
isStreaming?: boolean;
|
||||
onMinimize?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// Helper: Extract function result from DevUI custom event
|
||||
@@ -115,6 +116,39 @@ 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[]
|
||||
@@ -136,8 +170,8 @@ function processEventsForDisplay(
|
||||
for (const event of events) {
|
||||
// Skip trace events - they belong in the Traces tab only
|
||||
if (
|
||||
event.type === "response.trace.completed" ||
|
||||
event.type === "response.trace.completed"
|
||||
event.type === "response.trace_event.complete" ||
|
||||
event.type === "response.trace.complete"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -178,9 +212,9 @@ function processEventsForDisplay(
|
||||
event.type === "response.completed" ||
|
||||
event.type === "response.done" ||
|
||||
event.type === "error" ||
|
||||
event.type === "response.workflow_event.completed" ||
|
||||
event.type === "response.trace.completed" ||
|
||||
event.type === "response.trace.completed" ||
|
||||
event.type === "response.workflow_event.complete" ||
|
||||
event.type === "response.trace_event.complete" ||
|
||||
event.type === "response.trace.complete" ||
|
||||
isFunctionResult
|
||||
) {
|
||||
// Flush any accumulated text before showing these events
|
||||
@@ -194,8 +228,8 @@ function processEventsForDisplay(
|
||||
|
||||
// Extract function names from trace events
|
||||
if (
|
||||
(event.type === "response.trace.completed" ||
|
||||
event.type === "response.trace.completed") &&
|
||||
(event.type === "response.trace_event.complete" ||
|
||||
event.type === "response.trace.complete") &&
|
||||
"data" in event
|
||||
) {
|
||||
const traceData = event.data as TraceEventData;
|
||||
@@ -449,14 +483,15 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
|
||||
return "Output item added";
|
||||
}
|
||||
|
||||
case "response.workflow_event.completed":
|
||||
case "response.workflow_event.complete":
|
||||
if ("data" in event && event.data) {
|
||||
const data = event.data as WorkflowEventData;
|
||||
return `Executor: ${data.executor_id || "unknown"}`;
|
||||
}
|
||||
return "Workflow event";
|
||||
|
||||
case "response.trace.completed":
|
||||
case "response.trace_event.complete":
|
||||
case "response.trace.complete":
|
||||
if ("data" in event && event.data) {
|
||||
const data = event.data as TraceEventData;
|
||||
return `Trace: ${data.operation_name || "unknown"}`;
|
||||
@@ -501,9 +536,10 @@ function getEventIcon(type: string) {
|
||||
return CheckCircle2;
|
||||
case "response.output_item.added":
|
||||
return CheckCircle2;
|
||||
case "response.workflow_event.completed":
|
||||
case "response.workflow_event.complete":
|
||||
return Activity;
|
||||
case "response.trace.completed":
|
||||
case "response.trace_event.complete":
|
||||
case "response.trace.complete":
|
||||
return Search;
|
||||
case "response.completed":
|
||||
return CheckCircle2;
|
||||
@@ -528,9 +564,10 @@ 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.completed":
|
||||
case "response.workflow_event.complete":
|
||||
return "text-purple-600 dark:text-purple-400";
|
||||
case "response.trace.completed":
|
||||
case "response.trace_event.complete":
|
||||
case "response.trace.complete":
|
||||
return "text-orange-600 dark:text-orange-400";
|
||||
case "response.completed":
|
||||
return "text-green-600 dark:text-green-400";
|
||||
@@ -545,15 +582,9 @@ function getEventColor(type: string) {
|
||||
|
||||
function EventItem({ event }: EventItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
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 Icon = getEventIcon(event.type);
|
||||
const colorClass = getEventColor(event.type);
|
||||
const timestamp = getEventTimestamp(event);
|
||||
const summary = getEventSummary(event);
|
||||
|
||||
// Determine if this event has expandable content
|
||||
@@ -564,13 +595,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.completed" &&
|
||||
(event.type === "response.workflow_event.complete" &&
|
||||
"data" in event &&
|
||||
event.data) ||
|
||||
(event.type === "response.trace.completed" &&
|
||||
(event.type === "response.trace_event.complete" &&
|
||||
"data" in event &&
|
||||
event.data) ||
|
||||
(event.type === "response.trace.completed" &&
|
||||
(event.type === "response.trace.complete" &&
|
||||
"data" in event &&
|
||||
event.data) ||
|
||||
(event.type === "response.output_text.delta" &&
|
||||
@@ -589,7 +620,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 ? event.type.replace("response.", "") : "unknown"}
|
||||
{event.type.replace("response.", "")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -828,7 +859,7 @@ function EventExpandedContent({
|
||||
break;
|
||||
}
|
||||
|
||||
case "response.workflow_event.completed":
|
||||
case "response.workflow_event.complete":
|
||||
if ("data" in event && event.data) {
|
||||
const data = event.data as WorkflowEventData;
|
||||
return (
|
||||
@@ -884,7 +915,8 @@ function EventExpandedContent({
|
||||
}
|
||||
break;
|
||||
|
||||
case "response.trace.completed":
|
||||
case "response.trace_event.complete":
|
||||
case "response.trace.complete":
|
||||
if ("data" in event && event.data) {
|
||||
const data = event.data as TraceEventData;
|
||||
return (
|
||||
@@ -1161,8 +1193,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.completed" ||
|
||||
e.type === "response.trace.completed"
|
||||
e.type === "response.trace_event.complete" ||
|
||||
e.type === "response.trace.complete"
|
||||
);
|
||||
|
||||
// Add separators between message rounds
|
||||
@@ -1221,8 +1253,8 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (
|
||||
(event.type !== "response.trace.completed" &&
|
||||
event.type !== "response.trace.completed") ||
|
||||
(event.type !== "response.trace_event.complete" &&
|
||||
event.type !== "response.trace.complete") ||
|
||||
!("data" in event)
|
||||
) {
|
||||
return (
|
||||
@@ -1234,19 +1266,14 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
|
||||
const data = event.data as TraceEventData;
|
||||
|
||||
// Use stored UI timestamp first, then trace timestamps, then fallback to current time
|
||||
let timestamp: string;
|
||||
if ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number') {
|
||||
// Use stored UI timestamp from when event was received
|
||||
timestamp = new Date(event._uiTimestamp * 1000).toLocaleTimeString();
|
||||
} else if (data.end_time) {
|
||||
// Use actual trace timestamp if available, fallback to current time
|
||||
let timestamp = new Date().toLocaleTimeString();
|
||||
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";
|
||||
@@ -1493,10 +1520,7 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
}
|
||||
|
||||
function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
// 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();
|
||||
const timestamp = getEventTimestamp(event);
|
||||
|
||||
// Check if this is a function call or result event
|
||||
const isFunctionCall = event.type === "response.function_call.complete";
|
||||
@@ -1597,7 +1621,7 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
export function DebugPanel({
|
||||
events,
|
||||
isStreaming = false,
|
||||
onMinimize,
|
||||
onClose,
|
||||
}: DebugPanelProps) {
|
||||
return (
|
||||
<div className="flex-1 border-l flex flex-col min-h-0">
|
||||
@@ -1614,15 +1638,15 @@ export function DebugPanel({
|
||||
Tools
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{onMinimize && (
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onMinimize}
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0 flex-shrink-0"
|
||||
title="Minimize debug panel"
|
||||
title="Hide debug panel"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,18 +20,12 @@ 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";
|
||||
@@ -40,108 +34,10 @@ export function DeploymentModal({
|
||||
open,
|
||||
onClose,
|
||||
agentName = "Agent",
|
||||
entity,
|
||||
}: DeploymentModalProps) {
|
||||
// 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 [activeTab, setActiveTab] = useState<Tab>("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(() => {
|
||||
@@ -152,48 +48,6 @@ 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);
|
||||
@@ -210,7 +64,8 @@ export function DeploymentModal({
|
||||
timeoutRef.current = null;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
// Reset state on error - clipboard write failed
|
||||
console.error("Failed to copy template:", err);
|
||||
// Reset state on error
|
||||
setCopiedTemplate(null);
|
||||
}
|
||||
};
|
||||
@@ -294,22 +149,20 @@ openai>=1.0.0
|
||||
<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>
|
||||
)}
|
||||
<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 */}
|
||||
@@ -507,230 +360,34 @@ openai>=1.0.0
|
||||
Deploy to Azure Container Apps
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{deploymentSupported
|
||||
? "One-click deployment to Azure with automatic containerization and authentication."
|
||||
: "Azure Container Apps provides serverless containers with auto-scaling and integrated monitoring."}
|
||||
Azure Container Apps provides serverless containers with
|
||||
auto-scaling and integrated monitoring.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* 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>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>
|
||||
Logged in to Azure:{" "}
|
||||
<code className="bg-muted px-1 rounded">az login</code>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
{/* Step-by-step */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Deployment Steps</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Step 1 */}
|
||||
@@ -851,8 +508,6 @@ az acr build --registry myregistry \\
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,6 @@ 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;
|
||||
@@ -34,7 +33,6 @@ const getTypeIcon = (type: "agent" | "workflow") => {
|
||||
export function EntitySelector({
|
||||
agents,
|
||||
workflows,
|
||||
entities,
|
||||
selectedItem,
|
||||
onSelect,
|
||||
onBrowseGallery,
|
||||
@@ -42,8 +40,9 @@ export function EntitySelector({
|
||||
}: EntitySelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Use entities if provided (preserves backend order), otherwise combine agents and workflows
|
||||
const allItems = entities || [...agents, ...workflows];
|
||||
const allItems = [...agents, ...workflows].sort(
|
||||
(a, b) => a.name?.localeCompare(b.name || a.id) || a.id.localeCompare(b.id)
|
||||
);
|
||||
|
||||
const handleSelect = (item: AgentInfo | WorkflowInfo) => {
|
||||
onSelect(item);
|
||||
@@ -83,125 +82,80 @@ export function EntitySelector({
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-80 font-mono">
|
||||
{/* 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");
|
||||
|
||||
// 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>
|
||||
)}
|
||||
{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>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
{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}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{allItems.length === 0 && (
|
||||
<DropdownMenuItem disabled>
|
||||
|
||||
@@ -13,9 +13,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ExternalLink, RotateCcw, Info, ChevronRight } from "lucide-react";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
import { ExternalLink, RotateCcw } from "lucide-react";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
@@ -23,26 +21,10 @@ interface SettingsModalProps {
|
||||
onBackendUrlChange?: (url: string) => void;
|
||||
}
|
||||
|
||||
type Tab = "general" | "proxy" | "about";
|
||||
type Tab = "about" | "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, auth status, and server capabilities from store
|
||||
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired, serverCapabilities } = useDevUIStore();
|
||||
export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: SettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("settings");
|
||||
|
||||
// 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 : "";
|
||||
@@ -51,10 +33,6 @@ export function SettingsModal({
|
||||
});
|
||||
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 {
|
||||
@@ -81,68 +59,33 @@ export function SettingsModal({
|
||||
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] flex flex-col max-h-[85vh]">
|
||||
<DialogHeader className="p-6 pb-2 flex-shrink-0">
|
||||
<DialogContent className="w-[600px] max-w-[90vw]">
|
||||
<DialogHeader className="p-6 pb-2">
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogClose onClose={() => onOpenChange(false)} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b px-6 flex-shrink-0">
|
||||
<div className="flex border-b px-6">
|
||||
<button
|
||||
onClick={() => setActiveTab("general")}
|
||||
onClick={() => setActiveTab("settings")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
activeTab === "general"
|
||||
activeTab === "settings"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
General
|
||||
{activeTab === "general" && (
|
||||
Settings
|
||||
{activeTab === "settings" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
{serverCapabilities.openai_proxy && (
|
||||
<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>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setActiveTab("about")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
@@ -158,9 +101,9 @@ export function SettingsModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content - Scrollable with min-height */}
|
||||
<div className="px-6 pb-6 overflow-y-auto flex-1 min-h-[400px]">
|
||||
{activeTab === "general" && (
|
||||
{/* Tab Content */}
|
||||
<div className="px-6 pb-6 min-h-[240px]">
|
||||
{activeTab === "settings" && (
|
||||
<div className="space-y-6 pt-4">
|
||||
{/* Backend URL Setting */}
|
||||
<div className="space-y-3">
|
||||
@@ -199,7 +142,11 @@ export function SettingsModal({
|
||||
<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
|
||||
@@ -214,373 +161,6 @@ export function SettingsModal({
|
||||
)}
|
||||
</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 - Only show if backend supports deployment */}
|
||||
{serverCapabilities.deployment && (
|
||||
<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" && serverCapabilities.openai_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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,29 +0,0 @@
|
||||
"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 }
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useMemo, useState, useCallback } from "react";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
// import type { ExecutorNodeData } from "@/components/workflow/executor-node";
|
||||
|
||||
// Type for executor input/output data - can be various types based on workflow events
|
||||
export type ExecutorData =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Record<string, unknown>
|
||||
| null;
|
||||
|
||||
// State tracking for a specific executor
|
||||
interface ExecutorState {
|
||||
executorId: string;
|
||||
state: "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||
inputData?: ExecutorData;
|
||||
outputData?: ExecutorData;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
|
||||
interface WorkflowEventCorrelationResult {
|
||||
// State access
|
||||
isWorkflowRunning: boolean;
|
||||
selectedExecutorId: string | null;
|
||||
recentlyActive: string[];
|
||||
|
||||
// Actions
|
||||
selectExecutor: (executorId: string) => void;
|
||||
getExecutorData: (executorId: string) => ExecutorState | null;
|
||||
getExecutorEvents: (executorId: string) => ExtendedResponseStreamEvent[];
|
||||
}
|
||||
|
||||
// Hook for correlating workflow events with executor states
|
||||
export function useWorkflowEventCorrelation(
|
||||
events: ExtendedResponseStreamEvent[],
|
||||
isStreaming: boolean
|
||||
): WorkflowEventCorrelationResult {
|
||||
const [selectedExecutorId, setSelectedExecutorId] = useState<string | null>(null);
|
||||
|
||||
// Process events into executor states
|
||||
const { executors, recentlyActive, isWorkflowRunning } = useMemo(() => {
|
||||
const executorMap: Record<string, ExecutorState> = {};
|
||||
const activeExecutors: string[] = [];
|
||||
let workflowActive = isStreaming;
|
||||
|
||||
// Process workflow events
|
||||
events.forEach((event) => {
|
||||
if (event.type === "response.workflow_event.complete" && "data" in event && event.data) {
|
||||
const data = event.data as any;
|
||||
const executorId = data.executor_id;
|
||||
|
||||
if (!executorId) return;
|
||||
|
||||
// Initialize executor if not exists
|
||||
if (!executorMap[executorId]) {
|
||||
executorMap[executorId] = {
|
||||
executorId,
|
||||
state: "pending",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const executor = executorMap[executorId];
|
||||
const eventType = data.event_type;
|
||||
|
||||
// Update state based on event type
|
||||
if (eventType === "ExecutorInvokedEvent") {
|
||||
executor.state = "running";
|
||||
executor.inputData = data.data;
|
||||
if (!activeExecutors.includes(executorId)) {
|
||||
activeExecutors.push(executorId);
|
||||
}
|
||||
} else if (eventType === "ExecutorCompletedEvent") {
|
||||
executor.state = "completed";
|
||||
executor.outputData = data.data;
|
||||
} else if (eventType?.includes("Error") || eventType?.includes("Failed")) {
|
||||
executor.state = "failed";
|
||||
executor.error = typeof data.data === "string" ? data.data : "Execution failed";
|
||||
} else if (eventType?.includes("Cancel")) {
|
||||
executor.state = "cancelled";
|
||||
}
|
||||
|
||||
executor.timestamp = new Date().toISOString();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
executors: executorMap,
|
||||
recentlyActive: activeExecutors.slice(-3), // Keep last 3 active executors
|
||||
isWorkflowRunning: workflowActive,
|
||||
};
|
||||
}, [events, isStreaming]);
|
||||
|
||||
const selectExecutor = useCallback((executorId: string) => {
|
||||
setSelectedExecutorId(executorId);
|
||||
}, []);
|
||||
|
||||
const getExecutorData = useCallback((executorId: string): ExecutorState | null => {
|
||||
return executors[executorId] || null;
|
||||
}, [executors]);
|
||||
|
||||
const getExecutorEvents = useCallback(
|
||||
(executorId: string): ExtendedResponseStreamEvent[] => {
|
||||
return events.filter((event) => {
|
||||
if (event.type === "response.workflow_event.complete" && "data" in event && event.data) {
|
||||
const data = event.data as any;
|
||||
return data.executor_id === executorId;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
},
|
||||
[events]
|
||||
);
|
||||
|
||||
return {
|
||||
isWorkflowRunning,
|
||||
selectedExecutorId,
|
||||
recentlyActive,
|
||||
selectExecutor,
|
||||
getExecutorData,
|
||||
getExecutorEvents,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
AgentSource,
|
||||
Conversation,
|
||||
HealthResponse,
|
||||
MetaResponse,
|
||||
RunAgentRequest,
|
||||
RunWorkflowRequest,
|
||||
WorkflowInfo,
|
||||
@@ -33,9 +32,6 @@ interface BackendEntityInfo {
|
||||
tools?: (string | Record<string, unknown>)[];
|
||||
metadata: Record<string, unknown>;
|
||||
source?: string;
|
||||
// Deployment support
|
||||
deployment_supported?: boolean;
|
||||
deployment_reason?: string;
|
||||
// Agent-specific fields (present when type === "agent")
|
||||
instructions?: string;
|
||||
model?: string;
|
||||
@@ -68,8 +64,8 @@ const DEFAULT_API_BASE_URL =
|
||||
: ""; // Default to relative URLs (same host as frontend)
|
||||
|
||||
// Retry configuration for streaming
|
||||
const RETRY_INTERVAL_MS = 1000; // Base retry interval (will use exponential backoff)
|
||||
const MAX_RETRY_ATTEMPTS = 10; // Max 10 retries (~30 seconds with exponential backoff)
|
||||
const RETRY_INTERVAL_MS = 1000; // Retry every second
|
||||
const MAX_RETRY_ATTEMPTS = 600; // Max 600 retries (10 minutes total)
|
||||
|
||||
// Get backend URL from localStorage or default
|
||||
function getBackendUrl(): string {
|
||||
@@ -86,12 +82,9 @@ function sleep(ms: number): Promise<void> {
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
private authToken: string | null = null;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl || getBackendUrl();
|
||||
// Load auth token from localStorage on initialization
|
||||
this.authToken = localStorage.getItem("devui_auth_token");
|
||||
}
|
||||
|
||||
// Allow updating the base URL at runtime
|
||||
@@ -103,68 +96,27 @@ class ApiClient {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
// Set auth token and persist to localStorage
|
||||
setAuthToken(token: string | null): void {
|
||||
this.authToken = token;
|
||||
if (token) {
|
||||
localStorage.setItem("devui_auth_token", token);
|
||||
} else {
|
||||
localStorage.removeItem("devui_auth_token");
|
||||
}
|
||||
}
|
||||
|
||||
// Get current auth token
|
||||
getAuthToken(): string | null {
|
||||
return this.authToken;
|
||||
}
|
||||
|
||||
// Clear auth token
|
||||
clearAuthToken(): void {
|
||||
this.setAuthToken(null);
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
|
||||
// Build headers with auth token if available
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (this.authToken) {
|
||||
headers["Authorization"] = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle 401 Unauthorized - clear invalid token
|
||||
if (response.status === 401) {
|
||||
this.clearAuthToken();
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
// Try to extract error message from response body
|
||||
let errorMessage = `API request failed: ${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
// Handle detail as string or object
|
||||
if (errorData.detail) {
|
||||
if (typeof errorData.detail === "string") {
|
||||
errorMessage = errorData.detail;
|
||||
} else if (typeof errorData.detail === "object" && errorData.detail.error?.message) {
|
||||
// Backend returns detail: { error: { message: "...", type: "...", code: "..." } }
|
||||
errorMessage = errorData.detail.error.message;
|
||||
}
|
||||
} else if (errorData.error?.message) {
|
||||
errorMessage = errorData.error.message;
|
||||
errorMessage = errorData.detail;
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, use default message
|
||||
@@ -180,11 +132,6 @@ class ApiClient {
|
||||
return this.request<HealthResponse>("/health");
|
||||
}
|
||||
|
||||
// Server metadata
|
||||
async getMeta(): Promise<MetaResponse> {
|
||||
return this.request<MetaResponse>("/meta");
|
||||
}
|
||||
|
||||
// Entity discovery using new unified endpoint
|
||||
async getEntities(): Promise<{
|
||||
entities: (AgentInfo | WorkflowInfo)[];
|
||||
@@ -193,14 +140,17 @@ class ApiClient {
|
||||
}> {
|
||||
const response = await this.request<DiscoveryResponse>("/v1/entities");
|
||||
|
||||
// Transform entities while preserving backend order
|
||||
const entities: (AgentInfo | WorkflowInfo)[] = response.entities.map((entity) => {
|
||||
// Separate agents and workflows
|
||||
const agents: AgentInfo[] = [];
|
||||
const workflows: WorkflowInfo[] = [];
|
||||
|
||||
response.entities.forEach((entity) => {
|
||||
if (entity.type === "agent") {
|
||||
return {
|
||||
agents.push({
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
type: "agent" as const,
|
||||
type: "agent",
|
||||
source: (entity.source as AgentSource) || "directory",
|
||||
tools: (entity.tools || []).map((tool) =>
|
||||
typeof tool === "string" ? tool : JSON.stringify(tool)
|
||||
@@ -211,26 +161,22 @@ class ApiClient {
|
||||
? entity.metadata.module_path
|
||||
: undefined,
|
||||
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
|
||||
// Deployment support
|
||||
deployment_supported: entity.deployment_supported,
|
||||
deployment_reason: entity.deployment_reason,
|
||||
// Agent-specific fields
|
||||
instructions: entity.instructions,
|
||||
model: entity.model,
|
||||
chat_client_type: entity.chat_client_type,
|
||||
context_providers: entity.context_providers,
|
||||
middleware: entity.middleware,
|
||||
};
|
||||
} else {
|
||||
// Workflow
|
||||
});
|
||||
} else if (entity.type === "workflow") {
|
||||
const firstTool = entity.tools?.[0];
|
||||
const startExecutorId = typeof firstTool === "string" ? firstTool : "";
|
||||
|
||||
return {
|
||||
workflows.push({
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
type: "workflow" as const,
|
||||
type: "workflow",
|
||||
source: (entity.source as AgentSource) || "directory",
|
||||
executors: (entity.tools || []).map((tool) =>
|
||||
typeof tool === "string" ? tool : JSON.stringify(tool)
|
||||
@@ -241,24 +187,17 @@ class ApiClient {
|
||||
? entity.metadata.module_path
|
||||
: undefined,
|
||||
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
|
||||
// Deployment support
|
||||
deployment_supported: entity.deployment_supported,
|
||||
deployment_reason: entity.deployment_reason,
|
||||
input_schema:
|
||||
(entity.input_schema as unknown as import("@/types").JSONSchema) || {
|
||||
type: "string",
|
||||
}, // Default schema
|
||||
input_type_name: entity.input_type_name || "Input",
|
||||
start_executor_id: startExecutorId,
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Create filtered arrays for backward compatibility
|
||||
const agents = entities.filter((e): e is AgentInfo => e.type === "agent");
|
||||
const workflows = entities.filter((e): e is WorkflowInfo => e.type === "workflow");
|
||||
|
||||
return { entities, agents, workflows };
|
||||
return { entities: [...agents, ...workflows], agents, workflows };
|
||||
}
|
||||
|
||||
// Legacy methods for compatibility
|
||||
@@ -274,7 +213,7 @@ class ApiClient {
|
||||
|
||||
async getAgentInfo(agentId: string): Promise<AgentInfo> {
|
||||
// Get detailed entity info from unified endpoint
|
||||
return this.request<AgentInfo>(`/v1/entities/${agentId}/info?type=agent`);
|
||||
return this.request<AgentInfo>(`/v1/entities/${agentId}/info`);
|
||||
}
|
||||
|
||||
async getWorkflowInfo(
|
||||
@@ -282,17 +221,7 @@ class ApiClient {
|
||||
): Promise<import("@/types").WorkflowInfo> {
|
||||
// Get detailed entity info from unified endpoint
|
||||
return this.request<import("@/types").WorkflowInfo>(
|
||||
`/v1/entities/${workflowId}/info?type=workflow`
|
||||
);
|
||||
}
|
||||
|
||||
async reloadEntity(entityId: string): Promise<{ success: boolean; message: string }> {
|
||||
// Hot reload entity - clears cache and forces reimport on next access
|
||||
return this.request<{ success: boolean; message: string }>(
|
||||
`/v1/entities/${entityId}/reload`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
`/v1/entities/${workflowId}/info`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -303,23 +232,10 @@ class ApiClient {
|
||||
async createConversation(
|
||||
metadata?: Record<string, string>
|
||||
): Promise<Conversation> {
|
||||
// Check if OAI proxy mode is enabled
|
||||
const { oaiMode } = await import("@/stores").then((m) => ({
|
||||
oaiMode: m.useDevUIStore.getState().oaiMode,
|
||||
}));
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Add proxy mode header if enabled
|
||||
if (oaiMode.enabled) {
|
||||
headers["X-Proxy-Backend"] = "openai";
|
||||
}
|
||||
|
||||
const response = await this.request<ConversationApiResponse>(
|
||||
"/v1/conversations",
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ metadata }),
|
||||
}
|
||||
);
|
||||
@@ -399,19 +315,6 @@ class ApiClient {
|
||||
return this.request<{ data: unknown[]; has_more: boolean }>(url);
|
||||
}
|
||||
|
||||
async deleteConversationItem(
|
||||
conversationId: string,
|
||||
itemId: string
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/v1/conversations/${conversationId}/items/${itemId}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete item: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI-compatible streaming methods using /v1/responses endpoint
|
||||
|
||||
// Private helper method that handles the actual streaming with retry logic
|
||||
@@ -420,35 +323,6 @@ class ApiClient {
|
||||
conversationId?: string,
|
||||
resumeResponseId?: string
|
||||
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
|
||||
// Check if OpenAI proxy mode is enabled
|
||||
const { oaiMode } = await import("@/stores").then((m) => ({
|
||||
oaiMode: m.useDevUIStore.getState().oaiMode,
|
||||
}));
|
||||
|
||||
// Modify request if OAI mode is enabled
|
||||
if (oaiMode.enabled) {
|
||||
// Override model with OAI model
|
||||
openAIRequest.model = oaiMode.model;
|
||||
|
||||
// Merge optional OpenAI parameters
|
||||
if (oaiMode.temperature !== undefined) {
|
||||
openAIRequest.temperature = oaiMode.temperature;
|
||||
}
|
||||
if (oaiMode.max_output_tokens !== undefined) {
|
||||
openAIRequest.max_output_tokens = oaiMode.max_output_tokens;
|
||||
}
|
||||
if (oaiMode.top_p !== undefined) {
|
||||
openAIRequest.top_p = oaiMode.top_p;
|
||||
}
|
||||
if (oaiMode.instructions !== undefined) {
|
||||
openAIRequest.instructions = oaiMode.instructions;
|
||||
}
|
||||
// Reasoning parameters (for o-series models)
|
||||
if (oaiMode.reasoning_effort !== undefined) {
|
||||
openAIRequest.reasoning = { effort: oaiMode.reasoning_effort };
|
||||
}
|
||||
}
|
||||
|
||||
let lastSequenceNumber = -1;
|
||||
let retryCount = 0;
|
||||
let hasYieldedAnyEvent = false;
|
||||
@@ -493,68 +367,26 @@ class ApiClient {
|
||||
params.set("starting_after", lastSequenceNumber.toString());
|
||||
}
|
||||
const url = `${this.baseUrl}/v1/responses/${currentResponseId}?${params.toString()}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "text/event-stream",
|
||||
};
|
||||
|
||||
// Add auth token if available
|
||||
if (this.authToken) {
|
||||
headers["Authorization"] = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const url = `${this.baseUrl}/v1/responses`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
};
|
||||
|
||||
// Add proxy header if OAI mode is enabled
|
||||
if (oaiMode.enabled) {
|
||||
headers["X-Proxy-Backend"] = "openai";
|
||||
}
|
||||
|
||||
// Add auth token if available
|
||||
if (this.authToken) {
|
||||
headers["Authorization"] = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body: JSON.stringify(openAIRequest),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle authentication errors - don't retry these
|
||||
if (response.status === 401) {
|
||||
this.clearAuthToken(); // Clear invalid token
|
||||
throw new Error("UNAUTHORIZED"); // Special error that won't be retried
|
||||
}
|
||||
|
||||
// Handle other client errors (400-499) - don't retry these either
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
let errorMessage = `Client error ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
if (errorBody.error && errorBody.error.message) {
|
||||
errorMessage = errorBody.error.message;
|
||||
} else if (errorBody.detail) {
|
||||
errorMessage = errorBody.detail;
|
||||
}
|
||||
} catch {
|
||||
// Fallback to generic message
|
||||
}
|
||||
throw new Error(`CLIENT_ERROR: ${errorMessage}`);
|
||||
}
|
||||
|
||||
// Server errors (500-599) - these can be retried
|
||||
// Try to extract detailed error message from response body
|
||||
let errorMessage = `Request failed with status ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
@@ -687,26 +519,18 @@ class ApiClient {
|
||||
reader.releaseLock();
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Don't retry on auth errors or client errors
|
||||
if (errorMessage === "UNAUTHORIZED" || errorMessage.startsWith("CLIENT_ERROR:")) {
|
||||
throw error; // Re-throw without retrying
|
||||
}
|
||||
|
||||
// Network error or server error occurred - prepare to retry
|
||||
// Network error occurred - prepare to retry
|
||||
retryCount++;
|
||||
|
||||
if (retryCount > MAX_RETRY_ATTEMPTS) {
|
||||
// Max retries exceeded - give up
|
||||
throw new Error(
|
||||
`Connection failed after ${MAX_RETRY_ATTEMPTS} retry attempts: ${errorMessage}`
|
||||
`Connection failed after ${MAX_RETRY_ATTEMPTS} retry attempts: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
|
||||
const retryDelay = Math.min(RETRY_INTERVAL_MS * Math.pow(2, retryCount - 1), 30000);
|
||||
await sleep(retryDelay);
|
||||
// Wait before retrying
|
||||
await sleep(RETRY_INTERVAL_MS);
|
||||
// Loop will retry with GET if we have response_id, otherwise POST
|
||||
}
|
||||
}
|
||||
@@ -719,7 +543,7 @@ class ApiClient {
|
||||
resumeResponseId?: string
|
||||
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
|
||||
const openAIRequest: AgentFrameworkRequest = {
|
||||
metadata: { entity_id: agentId }, // Entity ID in metadata for routing
|
||||
model: agentId, // Model IS the entity_id (simplified routing!)
|
||||
input: request.input, // Direct OpenAI ResponseInputParam
|
||||
stream: true,
|
||||
conversation: request.conversation_id, // OpenAI standard conversation param
|
||||
@@ -735,7 +559,6 @@ class ApiClient {
|
||||
conversationId?: string,
|
||||
resumeResponseId?: string
|
||||
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
|
||||
// Proxy mode handling is now inside streamOpenAIResponse
|
||||
yield* this.streamOpenAIResponse(openAIRequest, conversationId, resumeResponseId);
|
||||
}
|
||||
|
||||
@@ -744,15 +567,12 @@ class ApiClient {
|
||||
workflowId: string,
|
||||
request: RunWorkflowRequest
|
||||
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
|
||||
// Convert to OpenAI format - use metadata.entity_id for routing
|
||||
// Convert to OpenAI format - use model field for entity_id (same as agents)
|
||||
const openAIRequest: AgentFrameworkRequest = {
|
||||
metadata: { entity_id: workflowId }, // Entity ID in metadata for routing
|
||||
input: JSON.stringify(request.input_data || {}), // Serialize workflow input as JSON string
|
||||
model: workflowId, // Use workflow ID in model field (matches agent pattern)
|
||||
input: request.input_data || "", // Send dict directly, no stringification needed
|
||||
stream: true,
|
||||
conversation: request.conversation_id, // Include conversation if present
|
||||
extra_body: request.checkpoint_id
|
||||
? { entity_id: workflowId, checkpoint_id: request.checkpoint_id }
|
||||
: undefined, // Pass checkpoint_id if provided
|
||||
};
|
||||
|
||||
yield* this.streamOpenAIResponse(openAIRequest, request.conversation_id);
|
||||
@@ -793,139 +613,6 @@ class ApiClient {
|
||||
clearStreamingState(conversationId: string): void {
|
||||
clearStreamingState(conversationId);
|
||||
}
|
||||
|
||||
// Deployment methods
|
||||
async* streamDeployment(config: {
|
||||
entity_id: string;
|
||||
resource_group: string;
|
||||
app_name: string;
|
||||
region?: string;
|
||||
ui_mode?: string;
|
||||
}): AsyncGenerator<{
|
||||
type: string;
|
||||
message: string;
|
||||
url?: string;
|
||||
auth_token?: string;
|
||||
}> {
|
||||
const response = await fetch(`${this.baseUrl}/v1/deployments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ...config, stream: true }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Deployment failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6);
|
||||
if (data === "[DONE]") return;
|
||||
try {
|
||||
yield JSON.parse(data);
|
||||
} catch (e) {
|
||||
// Emit error event for parsing failures
|
||||
yield {
|
||||
type: "deploy.error",
|
||||
message: `Failed to parse deployment event: ${e instanceof Error ? e.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Emit error event before throwing
|
||||
yield {
|
||||
type: "deploy.failed",
|
||||
message: `Stream interrupted: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
throw error;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Workflow Session Management (uses /conversations API)
|
||||
// ============================================================================
|
||||
|
||||
async listWorkflowSessions(entityId: string): Promise<{ data: import("@/types").WorkflowSession[] }> {
|
||||
// Workflow sessions are conversations with entity_id and type metadata
|
||||
const url = `/v1/conversations?entity_id=${encodeURIComponent(entityId)}&type=workflow_session`;
|
||||
const response = await this.request<{
|
||||
object: "list";
|
||||
data: ConversationApiResponse[];
|
||||
has_more: boolean;
|
||||
}>(url);
|
||||
|
||||
// Transform conversations to WorkflowSession format (no checkpoint counting)
|
||||
const sessions = response.data.map((conv) => ({
|
||||
conversation_id: conv.id,
|
||||
entity_id: conv.metadata?.entity_id || entityId,
|
||||
created_at: conv.created_at,
|
||||
metadata: {
|
||||
name: conv.metadata?.name || `Session ${new Date(conv.created_at * 1000).toLocaleString()}`,
|
||||
description: conv.metadata?.description,
|
||||
type: "workflow_session" as const,
|
||||
},
|
||||
}));
|
||||
|
||||
return { data: sessions };
|
||||
}
|
||||
|
||||
async createWorkflowSession(
|
||||
entityId: string,
|
||||
params?: { name?: string; description?: string }
|
||||
): Promise<import("@/types").WorkflowSession> {
|
||||
// Create conversation with workflow session metadata
|
||||
const metadata = {
|
||||
entity_id: entityId,
|
||||
type: "workflow_session" as const,
|
||||
name: params?.name || `Session ${new Date().toLocaleString()}`,
|
||||
...(params?.description && { description: params.description }),
|
||||
};
|
||||
|
||||
const conversation = await this.createConversation(metadata);
|
||||
|
||||
return {
|
||||
conversation_id: conversation.id,
|
||||
entity_id: entityId,
|
||||
created_at: conversation.created_at,
|
||||
metadata: {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
type: "workflow_session" as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async deleteWorkflowSession(_entityId: string, conversationId: string): Promise<void> {
|
||||
// Delete conversation (this also deletes all associated items/checkpoints)
|
||||
const success = await this.deleteConversation(conversationId);
|
||||
if (!success) {
|
||||
throw new Error("Failed to delete workflow session");
|
||||
}
|
||||
}
|
||||
|
||||
// Checkpoint operations now handled through standard conversation items API
|
||||
// Checkpoints are conversation items with type="checkpoint"
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
||||
@@ -11,9 +11,6 @@ import type {
|
||||
ExtendedResponseStreamEvent,
|
||||
Conversation,
|
||||
PendingApproval,
|
||||
OAIProxyMode,
|
||||
WorkflowSession,
|
||||
CheckpointInfo,
|
||||
} from "@/types";
|
||||
import type { ConversationItem } from "@/types/openai";
|
||||
import type { AttachmentItem } from "@/components/ui/attachment-gallery";
|
||||
@@ -26,7 +23,6 @@ interface DevUIState {
|
||||
// Entity Management Slice
|
||||
agents: AgentInfo[];
|
||||
workflows: WorkflowInfo[];
|
||||
entities: (AgentInfo | WorkflowInfo)[]; // Full list in backend order
|
||||
selectedAgent: AgentInfo | WorkflowInfo | undefined;
|
||||
isLoadingEntities: boolean;
|
||||
entityError: string | null;
|
||||
@@ -46,16 +42,8 @@ interface DevUIState {
|
||||
};
|
||||
pendingApprovals: PendingApproval[];
|
||||
|
||||
// Workflow Session Slice (workflow-specific session management)
|
||||
currentSession: WorkflowSession | undefined;
|
||||
availableSessions: WorkflowSession[];
|
||||
sessionCheckpoints: CheckpointInfo[];
|
||||
loadingSessions: boolean;
|
||||
loadingCheckpoints: boolean;
|
||||
|
||||
// UI Slice
|
||||
showDebugPanel: boolean;
|
||||
debugPanelMinimized: boolean;
|
||||
debugPanelWidth: number;
|
||||
debugEvents: ExtendedResponseStreamEvent[];
|
||||
isResizing: boolean;
|
||||
@@ -65,36 +53,6 @@ interface DevUIState {
|
||||
showGallery: boolean;
|
||||
showDeployModal: boolean;
|
||||
showEntityNotFoundToast: boolean;
|
||||
|
||||
// Toast Slice
|
||||
toasts: Array<{
|
||||
id: string;
|
||||
message: string;
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
duration?: number;
|
||||
}>;
|
||||
|
||||
// OpenAI Proxy Mode Slice
|
||||
oaiMode: OAIProxyMode;
|
||||
|
||||
// Server Meta Slice
|
||||
uiMode: "developer" | "user";
|
||||
runtime: "python" | "dotnet";
|
||||
serverCapabilities: {
|
||||
tracing: boolean;
|
||||
openai_proxy: boolean;
|
||||
deployment: boolean;
|
||||
};
|
||||
authRequired: boolean;
|
||||
|
||||
// Deployment Slice
|
||||
isDeploying: boolean;
|
||||
deploymentLogs: string[];
|
||||
lastDeployment: {
|
||||
url: string;
|
||||
authToken: string;
|
||||
} | null;
|
||||
azureDeploymentEnabled: boolean; // Feature flag for Azure deployment
|
||||
}
|
||||
|
||||
// ========================================
|
||||
@@ -105,7 +63,6 @@ interface DevUIActions {
|
||||
// Entity Actions
|
||||
setAgents: (agents: AgentInfo[]) => void;
|
||||
setWorkflows: (workflows: WorkflowInfo[]) => void;
|
||||
setEntities: (entities: (AgentInfo | WorkflowInfo)[]) => void;
|
||||
setSelectedAgent: (agent: AgentInfo | WorkflowInfo | undefined) => void;
|
||||
addAgent: (agent: AgentInfo) => void;
|
||||
addWorkflow: (workflow: WorkflowInfo) => void;
|
||||
@@ -127,18 +84,8 @@ interface DevUIActions {
|
||||
updateConversationUsage: (tokens: number) => void;
|
||||
setPendingApprovals: (approvals: PendingApproval[]) => void;
|
||||
|
||||
// Workflow Session Actions
|
||||
setCurrentSession: (session: WorkflowSession | undefined) => void;
|
||||
setAvailableSessions: (sessions: WorkflowSession[]) => void;
|
||||
setSessionCheckpoints: (checkpoints: CheckpointInfo[]) => void;
|
||||
setLoadingSessions: (loading: boolean) => void;
|
||||
setLoadingCheckpoints: (loading: boolean) => void;
|
||||
addSession: (session: WorkflowSession) => void;
|
||||
removeSession: (conversationId: string) => void;
|
||||
|
||||
// UI Actions
|
||||
setShowDebugPanel: (show: boolean) => void;
|
||||
setDebugPanelMinimized: (minimized: boolean) => void;
|
||||
setDebugPanelWidth: (width: number) => void;
|
||||
addDebugEvent: (event: ExtendedResponseStreamEvent) => void;
|
||||
clearDebugEvents: () => void;
|
||||
@@ -150,29 +97,6 @@ interface DevUIActions {
|
||||
setShowDeployModal: (show: boolean) => void;
|
||||
setShowEntityNotFoundToast: (show: boolean) => void;
|
||||
|
||||
// Toast Actions
|
||||
addToast: (toast: {
|
||||
message: string;
|
||||
type?: "info" | "success" | "warning" | "error";
|
||||
duration?: number;
|
||||
}) => void;
|
||||
removeToast: (id: string) => void;
|
||||
|
||||
// OpenAI Proxy Mode Actions
|
||||
setOAIMode: (config: OAIProxyMode) => void;
|
||||
toggleOAIMode: () => void;
|
||||
|
||||
// Server Meta Actions
|
||||
setServerMeta: (meta: { uiMode: "developer" | "user"; runtime: "python" | "dotnet"; capabilities: { tracing: boolean; openai_proxy: boolean; deployment: boolean }; authRequired: boolean }) => void;
|
||||
|
||||
// Deployment Actions
|
||||
startDeployment: () => void;
|
||||
addDeploymentLog: (log: string) => void;
|
||||
setDeploymentResult: (result: { url: string; authToken: string }) => void;
|
||||
stopDeployment: () => void;
|
||||
clearDeploymentState: () => void;
|
||||
setAzureDeploymentEnabled: (enabled: boolean) => void;
|
||||
|
||||
// Combined Actions (handle multiple state updates + side effects)
|
||||
selectEntity: (entity: AgentInfo | WorkflowInfo) => void;
|
||||
}
|
||||
@@ -194,7 +118,6 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
// Entity State
|
||||
agents: [],
|
||||
workflows: [],
|
||||
entities: [],
|
||||
selectedAgent: undefined,
|
||||
isLoadingEntities: true,
|
||||
entityError: null,
|
||||
@@ -211,16 +134,8 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
conversationUsage: { total_tokens: 0, message_count: 0 },
|
||||
pendingApprovals: [],
|
||||
|
||||
// Workflow Session State
|
||||
currentSession: undefined,
|
||||
availableSessions: [],
|
||||
sessionCheckpoints: [],
|
||||
loadingSessions: false,
|
||||
loadingCheckpoints: false,
|
||||
|
||||
// UI State
|
||||
showDebugPanel: true,
|
||||
debugPanelMinimized: false,
|
||||
debugPanelWidth: 320,
|
||||
debugEvents: [],
|
||||
isResizing: false,
|
||||
@@ -231,38 +146,12 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
showDeployModal: false,
|
||||
showEntityNotFoundToast: false,
|
||||
|
||||
// Toast State
|
||||
toasts: [],
|
||||
|
||||
// OpenAI Proxy Mode State
|
||||
oaiMode: {
|
||||
enabled: false,
|
||||
model: "gpt-4o-mini", // Default to cheaper model
|
||||
},
|
||||
|
||||
// Server Meta State
|
||||
uiMode: "developer", // Default to developer mode
|
||||
runtime: "python", // Default to Python runtime
|
||||
serverCapabilities: {
|
||||
tracing: false,
|
||||
openai_proxy: false,
|
||||
deployment: false,
|
||||
},
|
||||
authRequired: false,
|
||||
|
||||
// Deployment State
|
||||
isDeploying: false,
|
||||
deploymentLogs: [],
|
||||
lastDeployment: null,
|
||||
azureDeploymentEnabled: false, // Default to disabled for safety
|
||||
|
||||
// ========================================
|
||||
// Entity Actions
|
||||
// ========================================
|
||||
|
||||
setAgents: (agents) => set({ agents }),
|
||||
setWorkflows: (workflows) => set({ workflows }),
|
||||
setEntities: (entities) => set({ entities }),
|
||||
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
|
||||
addAgent: (agent) =>
|
||||
set((state) => ({ agents: [...state.agents, agent] })),
|
||||
@@ -327,69 +216,14 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
})),
|
||||
setPendingApprovals: (approvals) => set({ pendingApprovals: approvals }),
|
||||
|
||||
// ========================================
|
||||
// Workflow Session Actions
|
||||
// ========================================
|
||||
|
||||
setCurrentSession: (session) => set({ currentSession: session }),
|
||||
setAvailableSessions: (sessions) => set({ availableSessions: sessions }),
|
||||
setSessionCheckpoints: (checkpoints) =>
|
||||
set({ sessionCheckpoints: checkpoints }),
|
||||
setLoadingSessions: (loading) => set({ loadingSessions: loading }),
|
||||
setLoadingCheckpoints: (loading) => set({ loadingCheckpoints: loading }),
|
||||
addSession: (session) =>
|
||||
set((state) => ({
|
||||
availableSessions: [session, ...state.availableSessions],
|
||||
})),
|
||||
removeSession: (conversationId) =>
|
||||
set((state) => ({
|
||||
availableSessions: state.availableSessions.filter(
|
||||
(s) => s.conversation_id !== conversationId
|
||||
),
|
||||
// Clear current session if it's the one being deleted
|
||||
currentSession:
|
||||
state.currentSession?.conversation_id === conversationId
|
||||
? undefined
|
||||
: state.currentSession,
|
||||
// Clear checkpoints if they belong to deleted session
|
||||
sessionCheckpoints:
|
||||
state.currentSession?.conversation_id === conversationId
|
||||
? []
|
||||
: state.sessionCheckpoints,
|
||||
})),
|
||||
|
||||
// ========================================
|
||||
// UI Actions
|
||||
// ========================================
|
||||
|
||||
setShowDebugPanel: (show) => set({ showDebugPanel: show }),
|
||||
setDebugPanelMinimized: (minimized) => set({ debugPanelMinimized: minimized }),
|
||||
setDebugPanelWidth: (width) => set({ debugPanelWidth: width }),
|
||||
addDebugEvent: (event) =>
|
||||
set((state) => {
|
||||
// Generate unique timestamp for each event
|
||||
// Use current time + small increment to ensure uniqueness even for rapid events
|
||||
const baseTimestamp = Math.floor(Date.now() / 1000);
|
||||
const lastTimestamp = state.debugEvents.length > 0
|
||||
? (state.debugEvents[state.debugEvents.length - 1] as any)._uiTimestamp || 0
|
||||
: 0;
|
||||
// Ensure new timestamp is always greater than the last one
|
||||
const uniqueTimestamp = Math.max(baseTimestamp, lastTimestamp + 1);
|
||||
|
||||
return {
|
||||
debugEvents: [
|
||||
...state.debugEvents,
|
||||
{
|
||||
...event,
|
||||
// Add UI display timestamp when event is received (Unix seconds)
|
||||
// Each event gets a unique timestamp to preserve chronological order
|
||||
_uiTimestamp: ('created_at' in event && event.created_at)
|
||||
? event.created_at
|
||||
: uniqueTimestamp,
|
||||
} as ExtendedResponseStreamEvent & { _uiTimestamp: number },
|
||||
],
|
||||
};
|
||||
}),
|
||||
set((state) => ({ debugEvents: [...state.debugEvents, event] })),
|
||||
clearDebugEvents: () => set({ debugEvents: [] }),
|
||||
setIsResizing: (resizing) => set({ isResizing: resizing }),
|
||||
|
||||
@@ -403,154 +237,6 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
setShowEntityNotFoundToast: (show) =>
|
||||
set({ showEntityNotFoundToast: show }),
|
||||
|
||||
// ========================================
|
||||
// Toast Actions
|
||||
// ========================================
|
||||
|
||||
addToast: (toast) =>
|
||||
set((state) => ({
|
||||
toasts: [
|
||||
...state.toasts,
|
||||
{
|
||||
id: `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: toast.type || "info",
|
||||
duration: toast.duration || 4000,
|
||||
...toast,
|
||||
},
|
||||
],
|
||||
})),
|
||||
|
||||
removeToast: (id) =>
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
})),
|
||||
|
||||
// ========================================
|
||||
// OpenAI Proxy Mode Actions
|
||||
// ========================================
|
||||
|
||||
setOAIMode: (config) =>
|
||||
set((state) => {
|
||||
// If enabling OAI mode, clear conversation state
|
||||
if (config.enabled && !state.oaiMode.enabled) {
|
||||
// Clear ALL conversation localStorage caches
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
if (key.startsWith('devui_convs_')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
oaiMode: config,
|
||||
// Clear conversation state when switching to OAI mode
|
||||
currentConversation: undefined,
|
||||
availableConversations: [],
|
||||
chatItems: [],
|
||||
inputValue: "",
|
||||
attachments: [],
|
||||
conversationUsage: { total_tokens: 0, message_count: 0 },
|
||||
isStreaming: false,
|
||||
isSubmitting: false,
|
||||
pendingApprovals: [],
|
||||
debugEvents: [],
|
||||
};
|
||||
}
|
||||
// If disabling OAI mode, also clear state
|
||||
if (!config.enabled && state.oaiMode.enabled) {
|
||||
// Clear ALL conversation localStorage caches
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
if (key.startsWith('devui_convs_')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
oaiMode: config,
|
||||
// Clear conversation state when switching back to local mode
|
||||
currentConversation: undefined,
|
||||
availableConversations: [],
|
||||
chatItems: [],
|
||||
inputValue: "",
|
||||
attachments: [],
|
||||
conversationUsage: { total_tokens: 0, message_count: 0 },
|
||||
isStreaming: false,
|
||||
isSubmitting: false,
|
||||
pendingApprovals: [],
|
||||
debugEvents: [],
|
||||
};
|
||||
}
|
||||
// Just update config (model, temperature, etc.) without clearing state
|
||||
return { oaiMode: config };
|
||||
}),
|
||||
|
||||
toggleOAIMode: () =>
|
||||
set((state) => {
|
||||
const newEnabled = !state.oaiMode.enabled;
|
||||
return {
|
||||
oaiMode: { ...state.oaiMode, enabled: newEnabled },
|
||||
// Clear conversation state when toggling
|
||||
currentConversation: undefined,
|
||||
availableConversations: [],
|
||||
chatItems: [],
|
||||
inputValue: "",
|
||||
attachments: [],
|
||||
conversationUsage: { total_tokens: 0, message_count: 0 },
|
||||
isStreaming: false,
|
||||
isSubmitting: false,
|
||||
pendingApprovals: [],
|
||||
debugEvents: [],
|
||||
};
|
||||
}),
|
||||
|
||||
// ========================================
|
||||
// Server Meta Actions
|
||||
// ========================================
|
||||
|
||||
setServerMeta: (meta) =>
|
||||
set({
|
||||
uiMode: meta.uiMode,
|
||||
runtime: meta.runtime,
|
||||
serverCapabilities: meta.capabilities,
|
||||
authRequired: meta.authRequired,
|
||||
}),
|
||||
|
||||
// ========================================
|
||||
// Deployment Actions
|
||||
// ========================================
|
||||
|
||||
startDeployment: () =>
|
||||
set({
|
||||
isDeploying: true,
|
||||
deploymentLogs: [],
|
||||
lastDeployment: null,
|
||||
}),
|
||||
|
||||
addDeploymentLog: (log) =>
|
||||
set((state) => ({
|
||||
deploymentLogs: [...state.deploymentLogs, log],
|
||||
})),
|
||||
|
||||
setDeploymentResult: (result) =>
|
||||
set({
|
||||
isDeploying: false,
|
||||
lastDeployment: result,
|
||||
}),
|
||||
|
||||
stopDeployment: () =>
|
||||
set({
|
||||
isDeploying: false,
|
||||
}),
|
||||
|
||||
clearDeploymentState: () =>
|
||||
set({
|
||||
isDeploying: false,
|
||||
deploymentLogs: [],
|
||||
lastDeployment: null,
|
||||
}),
|
||||
|
||||
setAzureDeploymentEnabled: (enabled) =>
|
||||
set({ azureDeploymentEnabled: enabled }),
|
||||
|
||||
// ========================================
|
||||
// Combined Actions
|
||||
// ========================================
|
||||
@@ -559,7 +245,6 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
* Select an entity (agent/workflow) and handle all side effects:
|
||||
* - Update selected entity
|
||||
* - Clear conversation state (FIXES THE BUG!)
|
||||
* - Clear session state (for workflows)
|
||||
* - Clear debug events
|
||||
* - Update URL
|
||||
*/
|
||||
@@ -576,10 +261,6 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
isStreaming: false,
|
||||
isSubmitting: false,
|
||||
pendingApprovals: [],
|
||||
// Clear workflow session state when switching entities
|
||||
currentSession: undefined,
|
||||
availableSessions: [], // Let WorkflowView reload sessions
|
||||
sessionCheckpoints: [],
|
||||
// Clear debug events when switching
|
||||
debugEvents: [],
|
||||
});
|
||||
@@ -595,10 +276,7 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
// Only persist UI preferences, not runtime state
|
||||
partialize: (state) => ({
|
||||
showDebugPanel: state.showDebugPanel,
|
||||
debugPanelMinimized: state.debugPanelMinimized,
|
||||
debugPanelWidth: state.debugPanelWidth,
|
||||
oaiMode: state.oaiMode, // Persist OpenAI proxy mode settings
|
||||
azureDeploymentEnabled: state.azureDeploymentEnabled, // Persist Azure deployment preference
|
||||
}),
|
||||
}
|
||||
),
|
||||
|
||||
@@ -68,13 +68,12 @@ export type ResponseInputParam = ResponseInputItem[];
|
||||
// Agent Framework extension fields (matches backend AgentFrameworkExtraBody)
|
||||
export interface AgentFrameworkExtraBody {
|
||||
entity_id: string;
|
||||
checkpoint_id?: string; // Optional checkpoint ID for workflow resume
|
||||
// input_data removed - now using standard input field for all data
|
||||
}
|
||||
|
||||
// Agent Framework Request - OpenAI ResponseCreateParams with extensions
|
||||
export interface AgentFrameworkRequest {
|
||||
model?: string;
|
||||
model: string;
|
||||
input: string | ResponseInputParam | Record<string, unknown>; // Union type matching OpenAI + dict for workflows
|
||||
stream?: boolean;
|
||||
|
||||
@@ -86,15 +85,8 @@ export interface AgentFrameworkRequest {
|
||||
metadata?: Record<string, unknown>;
|
||||
temperature?: number;
|
||||
max_output_tokens?: number;
|
||||
top_p?: number;
|
||||
tools?: Record<string, unknown>[];
|
||||
|
||||
// Reasoning parameters (for o-series models)
|
||||
reasoning?: {
|
||||
effort?: "minimal" | "low" | "medium" | "high";
|
||||
summary?: "auto" | "concise" | "detailed";
|
||||
};
|
||||
|
||||
// Agent Framework extension - strongly typed
|
||||
extra_body?: AgentFrameworkExtraBody;
|
||||
entity_id?: string; // Allow entity_id as top-level field too
|
||||
|
||||
@@ -32,9 +32,6 @@ export interface AgentInfo {
|
||||
module_path?: string;
|
||||
required_env_vars?: EnvVarRequirement[];
|
||||
metadata?: Record<string, unknown>; // Backend metadata including lazy_loaded flag
|
||||
// Deployment support
|
||||
deployment_supported?: boolean;
|
||||
deployment_reason?: string;
|
||||
// Agent-specific fields
|
||||
instructions?: string;
|
||||
model?: string;
|
||||
@@ -74,7 +71,6 @@ export interface WorkflowInfo extends Omit<AgentInfo, "tools"> {
|
||||
input_schema: JSONSchema; // JSON Schema for workflow input
|
||||
input_type_name: string; // Human-readable input type name
|
||||
start_executor_id: string; // Entry point executor ID
|
||||
// Note: DevUI provides runtime checkpoint storage for ALL workflows via conversations
|
||||
}
|
||||
|
||||
// OpenAI Conversations API (standard)
|
||||
@@ -93,22 +89,6 @@ export interface RunAgentRequest {
|
||||
export interface RunWorkflowRequest {
|
||||
input_data: Record<string, unknown>;
|
||||
conversation_id?: string;
|
||||
checkpoint_id?: string;
|
||||
}
|
||||
|
||||
// OpenAI Proxy Mode Configuration
|
||||
export interface OAIProxyMode {
|
||||
enabled: boolean;
|
||||
model: string; // Model ID like "gpt-4o", "gpt-4o-mini", or custom
|
||||
|
||||
// Optional OpenAI Responses API parameters
|
||||
temperature?: number;
|
||||
max_output_tokens?: number;
|
||||
top_p?: number;
|
||||
instructions?: string;
|
||||
|
||||
// Reasoning parameters (for o-series models)
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
}
|
||||
|
||||
// Legacy types - DEPRECATED - use new structured events from openai.ts instead
|
||||
@@ -153,19 +133,6 @@ export interface HealthResponse {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface MetaResponse {
|
||||
ui_mode: "developer" | "user";
|
||||
version: string;
|
||||
framework: string;
|
||||
runtime: "python" | "dotnet";
|
||||
capabilities: {
|
||||
tracing: boolean;
|
||||
openai_proxy: boolean;
|
||||
deployment: boolean;
|
||||
};
|
||||
auth_required: boolean;
|
||||
}
|
||||
|
||||
// Chat message types matching Agent Framework
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
@@ -208,54 +175,3 @@ export interface PendingApproval {
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
// Deployment types
|
||||
export interface DeploymentConfig {
|
||||
entity_id: string;
|
||||
resource_group: string;
|
||||
app_name: string;
|
||||
region?: string;
|
||||
ui_mode?: string;
|
||||
ui_enabled?: boolean;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
export interface DeploymentEvent {
|
||||
type: string;
|
||||
message: string;
|
||||
url?: string;
|
||||
auth_token?: string;
|
||||
}
|
||||
|
||||
export interface Deployment {
|
||||
id: string;
|
||||
entity_id: string;
|
||||
resource_group: string;
|
||||
app_name: string;
|
||||
region: string;
|
||||
url: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Workflow Session Management Types
|
||||
export interface WorkflowSession {
|
||||
conversation_id: string;
|
||||
entity_id: string;
|
||||
created_at: number;
|
||||
metadata: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
type: "workflow_session";
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CheckpointInfo {
|
||||
checkpoint_id: string;
|
||||
workflow_id: string;
|
||||
timestamp: number;
|
||||
iteration_count: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export interface ResponseFailedEvent {
|
||||
|
||||
// Custom Agent Framework OpenAI event types with structured data
|
||||
export interface ResponseWorkflowEventComplete {
|
||||
type: "response.workflow_event.completed";
|
||||
type: "response.workflow_event.complete";
|
||||
data: {
|
||||
event_type: string;
|
||||
data?: Record<string, unknown>;
|
||||
@@ -125,32 +125,6 @@ export interface ResponseFunctionToolCall {
|
||||
status?: "in_progress" | "completed" | "incomplete";
|
||||
}
|
||||
|
||||
// DevUI Extension: Output item types for response.output_item.added events
|
||||
export interface ResponseOutputImageItem {
|
||||
id: string;
|
||||
type: "output_image";
|
||||
image_url: string;
|
||||
alt_text?: string;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
export interface ResponseOutputFileItem {
|
||||
id: string;
|
||||
type: "output_file";
|
||||
filename: string;
|
||||
file_url?: string;
|
||||
file_data?: string;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
export interface ResponseOutputDataItem {
|
||||
id: string;
|
||||
type: "output_data";
|
||||
data: string;
|
||||
mime_type: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Workflow Item Types - flexible interface for any workflow item
|
||||
export interface WorkflowItem {
|
||||
type: string; // "executor_action", "workflow_action", "message", or any future type
|
||||
@@ -173,34 +147,24 @@ export function isExecutorAction(item: WorkflowItem): item is ExecutorActionItem
|
||||
return item.type === "executor_action" && "executor_id" in item;
|
||||
}
|
||||
|
||||
// Union of all possible output items
|
||||
export type ResponseOutputItem =
|
||||
| ResponseFunctionToolCall
|
||||
| ResponseOutputImageItem
|
||||
| ResponseOutputFileItem
|
||||
| ResponseOutputDataItem
|
||||
| ExecutorActionItem
|
||||
| WorkflowItem;
|
||||
|
||||
// OpenAI Responses API - Output Item Added Event
|
||||
// OpenAI standard: Output item added event (extended to support our output types)
|
||||
// OpenAI Responses API - Output Item Events
|
||||
export interface ResponseOutputItemAddedEvent {
|
||||
type: "response.output_item.added";
|
||||
item: ResponseOutputItem;
|
||||
item: WorkflowItem | ResponseFunctionToolCall | any; // Flexible to support various item types
|
||||
output_index: number;
|
||||
sequence_number?: number;
|
||||
}
|
||||
|
||||
export interface ResponseOutputItemDoneEvent {
|
||||
type: "response.output_item.done";
|
||||
item: ResponseOutputItem;
|
||||
item: WorkflowItem | ResponseFunctionToolCall | any;
|
||||
output_index: number;
|
||||
sequence_number?: number;
|
||||
}
|
||||
|
||||
// Trace event - matching actual backend output
|
||||
export interface ResponseTraceEventComplete {
|
||||
type: "response.trace.completed";
|
||||
type: "response.trace_event.complete";
|
||||
data: {
|
||||
operation_name?: string;
|
||||
duration_ms?: number;
|
||||
@@ -215,7 +179,7 @@ export interface ResponseTraceEventComplete {
|
||||
|
||||
// New trace event format from backend
|
||||
export interface ResponseTraceComplete {
|
||||
type: "response.trace.completed";
|
||||
type: "response.trace.complete";
|
||||
data: {
|
||||
type?: string;
|
||||
span_id?: string;
|
||||
@@ -280,20 +244,6 @@ export interface ResponseFunctionResultComplete {
|
||||
timestamp?: string; // Optional ISO timestamp for UI display
|
||||
}
|
||||
|
||||
// DevUI Extension: Workflow Requests Human Input (HIL)
|
||||
export interface ResponseRequestInfoEvent {
|
||||
type: "response.request_info.requested";
|
||||
request_id: string;
|
||||
source_executor_id: string;
|
||||
request_type: string;
|
||||
request_data: Record<string, unknown>;
|
||||
request_schema: Record<string, unknown>;
|
||||
item_id: string;
|
||||
output_index: number;
|
||||
sequence_number: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// DevUI Extension: Turn Separator (UI-only event for grouping)
|
||||
export interface TurnSeparatorEvent {
|
||||
type: "debug.turn_separator";
|
||||
@@ -316,7 +266,6 @@ export type StructuredEvent =
|
||||
| ResponseFunctionCallDelta
|
||||
| ResponseFunctionCallArgumentsDelta
|
||||
| ResponseFunctionResultComplete
|
||||
| ResponseRequestInfoEvent
|
||||
| ResponseErrorEvent
|
||||
| ResponseFunctionApprovalRequestedEvent
|
||||
| ResponseFunctionApprovalRespondedEvent
|
||||
@@ -425,18 +374,6 @@ export interface MessageInputFile {
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
// DevUI Extension: Function approval request content (shown in chat)
|
||||
export interface MessageFunctionApprovalRequestContent {
|
||||
type: "function_approval_request";
|
||||
request_id: string;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
function_call: {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
// DevUI Extension: Function approval response content
|
||||
export interface MessageFunctionApprovalResponseContent {
|
||||
type: "function_approval_response";
|
||||
@@ -449,45 +386,12 @@ export interface MessageFunctionApprovalResponseContent {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DevUI Extension: Output Content Types (Agent-Generated Media/Data)
|
||||
// ============================================================================
|
||||
// These extend the OpenAI Responses API to support rich content outputs
|
||||
// that aren't natively supported (images, files, data). They mirror the
|
||||
// input types but for agent outputs.
|
||||
|
||||
export interface MessageOutputImage {
|
||||
type: "output_image";
|
||||
image_url: string; // URL or data URI (data:image/png;base64,...)
|
||||
alt_text?: string;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
export interface MessageOutputFile {
|
||||
type: "output_file";
|
||||
filename: string;
|
||||
file_url?: string;
|
||||
file_data?: string; // base64
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
export interface MessageOutputData {
|
||||
type: "output_data";
|
||||
data: string;
|
||||
mime_type: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type MessageContent =
|
||||
| MessageTextContent
|
||||
| MessageInputTextContent
|
||||
| MessageOutputTextContent
|
||||
| MessageInputImage
|
||||
| MessageInputFile
|
||||
| MessageOutputImage
|
||||
| MessageOutputFile
|
||||
| MessageOutputData
|
||||
| MessageFunctionApprovalRequestContent
|
||||
| MessageFunctionApprovalResponseContent;
|
||||
|
||||
// Message item (user/assistant messages with content)
|
||||
@@ -497,7 +401,6 @@ export interface ConversationMessage {
|
||||
role: "user" | "assistant" | "system" | "tool";
|
||||
content: MessageContent[];
|
||||
status: "in_progress" | "completed" | "incomplete";
|
||||
created_at?: number; // Unix timestamp in seconds - when this message was created
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
@@ -513,7 +416,6 @@ export interface ConversationFunctionCall {
|
||||
name: string;
|
||||
arguments: string;
|
||||
status: "in_progress" | "completed" | "incomplete";
|
||||
created_at?: number; // Unix timestamp in seconds - when this function call was made
|
||||
}
|
||||
|
||||
// Function call output item
|
||||
@@ -523,7 +425,6 @@ export interface ConversationFunctionCallOutput {
|
||||
call_id: string;
|
||||
output: string;
|
||||
status?: "in_progress" | "completed" | "incomplete";
|
||||
created_at?: number; // Unix timestamp in seconds - when this function result was received
|
||||
}
|
||||
|
||||
// Union of all conversation item types
|
||||
|
||||
@@ -11,23 +11,6 @@ import type {
|
||||
import type { Workflow } from "@/types/workflow";
|
||||
import { getTypedWorkflow } from "@/types/workflow";
|
||||
|
||||
/**
|
||||
* Truncates text that exceeds the maximum length and appends ellipsis
|
||||
* @param text - The text to truncate
|
||||
* @param maxLength - Maximum length before truncation (default: 50)
|
||||
* @param ellipsis - String to append when truncated (default: '...')
|
||||
* @returns Truncated text with ellipsis if it exceeds maxLength, otherwise original text
|
||||
*
|
||||
* @example
|
||||
* truncateText('Hello World', 5) // 'Hello...'
|
||||
* truncateText('Short', 10) // 'Short'
|
||||
* truncateText('workflow_assistant_43ca50a006aa425e96e8fcf54206a7e3', 35) // 'workflow_assistant_43ca50a006aa4...'
|
||||
*/
|
||||
export function truncateText(text: string, maxLength: number = 50, ellipsis: string = '...'): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + ellipsis;
|
||||
}
|
||||
|
||||
export interface WorkflowDumpExecutor {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -181,8 +164,6 @@ export function convertWorkflowDumpToEdges(
|
||||
id: `${connection.source}-${connection.target}`,
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
sourceHandle: "source",
|
||||
targetHandle: "target",
|
||||
type: "default",
|
||||
animated: false,
|
||||
style: {
|
||||
@@ -326,7 +307,7 @@ export function applyDagreLayout(
|
||||
|
||||
/**
|
||||
* Process workflow events and extract node updates
|
||||
* Handles both standard OpenAI events and fallback workflow_event format
|
||||
* Handles both new standard OpenAI events and legacy workflow events
|
||||
*/
|
||||
export function processWorkflowEvents(
|
||||
events: ExtendedResponseStreamEvent[],
|
||||
@@ -335,29 +316,12 @@ export function processWorkflowEvents(
|
||||
const nodeUpdates: Record<string, NodeUpdate> = {};
|
||||
let hasWorkflowStarted = false;
|
||||
|
||||
// Track the latest item ID for each executor to handle multiple runs
|
||||
const latestItemIds: Record<string, string> = {};
|
||||
|
||||
events.forEach((event) => {
|
||||
// Handle new standard OpenAI events
|
||||
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
|
||||
const item = (event as any).item;
|
||||
if (item && item.type === "executor_action" && item.executor_id) {
|
||||
const executorId = item.executor_id;
|
||||
const itemId = item.id;
|
||||
|
||||
// Track the latest item ID for this executor
|
||||
if (event.type === "response.output_item.added") {
|
||||
latestItemIds[executorId] = itemId;
|
||||
}
|
||||
|
||||
// Only process this event if it's for the latest item ID of this executor
|
||||
// This prevents older "done" events from overwriting newer "added" events
|
||||
const isLatestItem = latestItemIds[executorId] === itemId;
|
||||
|
||||
if (!isLatestItem && event.type === "response.output_item.done") {
|
||||
return; // Skip this old completion event
|
||||
}
|
||||
|
||||
let state: ExecutorState = "pending";
|
||||
let error: string | undefined;
|
||||
@@ -388,9 +352,9 @@ export function processWorkflowEvents(
|
||||
else if (event.type === "response.created" || event.type === "response.in_progress") {
|
||||
hasWorkflowStarted = true;
|
||||
}
|
||||
// Handle workflow event format
|
||||
// Legacy support for older backends
|
||||
else if (
|
||||
event.type === "response.workflow_event.completed" &&
|
||||
event.type === "response.workflow_event.complete" &&
|
||||
"data" in event &&
|
||||
event.data
|
||||
) {
|
||||
@@ -436,38 +400,16 @@ export function processWorkflowEvents(
|
||||
}
|
||||
});
|
||||
|
||||
// FALLBACK LOGIC: If workflow has started and we have a start executor, set it to running
|
||||
// ONLY if it hasn't received any explicit executor events
|
||||
// This prevents overwriting the actual state after the executor has run
|
||||
// If workflow has started and we have a start executor, set it to running
|
||||
// (unless it already has a specific state from an ExecutorInvokedEvent)
|
||||
if (hasWorkflowStarted && startExecutorId && !nodeUpdates[startExecutorId]) {
|
||||
// Additional check: only set to running if we don't have completion/failure events for this executor
|
||||
// This prevents setting to "running" after the executor has already completed
|
||||
const hasCompletionEvent = events.some((event) => {
|
||||
if (event.type === "response.output_item.done") {
|
||||
const item = (event as any).item;
|
||||
return item && item.type === "executor_action" && item.executor_id === startExecutorId;
|
||||
}
|
||||
if (event.type === "response.workflow_event.completed" && "data" in event && event.data) {
|
||||
const data = event.data as any;
|
||||
return data.executor_id === startExecutorId &&
|
||||
(data.event_type === "ExecutorCompletedEvent" ||
|
||||
data.event_type === "ExecutorFailedEvent" ||
|
||||
data.event_type?.includes("Error") ||
|
||||
data.event_type?.includes("Failed"));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Only set to running if the executor hasn't completed yet
|
||||
if (!hasCompletionEvent) {
|
||||
nodeUpdates[startExecutorId] = {
|
||||
nodeId: startExecutorId,
|
||||
state: "running",
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
nodeUpdates[startExecutorId] = {
|
||||
nodeId: startExecutorId,
|
||||
state: "running",
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return nodeUpdates;
|
||||
@@ -524,9 +466,9 @@ export function getCurrentlyExecutingExecutors(
|
||||
};
|
||||
}
|
||||
}
|
||||
// Handle workflow event format
|
||||
// Legacy support for older backends
|
||||
else if (
|
||||
event.type === "response.workflow_event.completed" &&
|
||||
event.type === "response.workflow_event.complete" &&
|
||||
"data" in event &&
|
||||
event.data
|
||||
) {
|
||||
@@ -573,7 +515,7 @@ export function updateEdgesWithSequenceAnalysis(
|
||||
|
||||
events.forEach((event) => {
|
||||
if (
|
||||
event.type === "response.workflow_event.completed" &&
|
||||
event.type === "response.workflow_event.complete" &&
|
||||
"data" in event &&
|
||||
event.data
|
||||
) {
|
||||
@@ -642,67 +584,3 @@ export function updateEdgesWithSequenceAnalysis(
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidate bidirectional edges into single edges with arrows on both ends
|
||||
* This reduces visual clutter when edges go in both directions between nodes
|
||||
*
|
||||
* Smart handle selection algorithm:
|
||||
* The current implementation keeps whichever edge was encountered first in the array.
|
||||
* Since edges are typically created in workflow definition order (following the primary flow),
|
||||
* this naturally keeps the "forward" edge and discards the "backward" one.
|
||||
*
|
||||
* For example, if the workflow defines:
|
||||
* 1. coordinator → planner (primary flow)
|
||||
* 2. planner → coordinator (feedback loop)
|
||||
*
|
||||
* We keep edge #1 and add bidirectional arrows. This ensures the edge follows
|
||||
* the natural output→input handle connection of the primary flow direction.
|
||||
*
|
||||
* React Flow will automatically route the edge to avoid overlaps, and the
|
||||
* bidirectional arrows indicate that communication flows both ways.
|
||||
*/
|
||||
export function consolidateBidirectionalEdges(edges: Edge[]): Edge[] {
|
||||
const edgeMap = new Map<string, Edge>();
|
||||
const bidirectionalKeys = new Set<string>();
|
||||
|
||||
edges.forEach(edge => {
|
||||
const forwardKey = `${edge.source}-${edge.target}`;
|
||||
const reverseKey = `${edge.target}-${edge.source}`;
|
||||
|
||||
// Check if we already have the reverse edge
|
||||
if (edgeMap.has(reverseKey)) {
|
||||
// Mark both keys as bidirectional
|
||||
bidirectionalKeys.add(reverseKey);
|
||||
bidirectionalKeys.add(forwardKey);
|
||||
|
||||
// Update the existing reverse edge to be bidirectional
|
||||
const existingEdge = edgeMap.get(reverseKey)!;
|
||||
|
||||
// Keep the existing edge's handles (they follow the primary workflow direction)
|
||||
// Add bidirectional arrows to show two-way communication
|
||||
edgeMap.set(reverseKey, {
|
||||
...existingEdge,
|
||||
markerStart: {
|
||||
type: 'arrow' as const,
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
markerEnd: {
|
||||
type: 'arrow' as const,
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
data: {
|
||||
...existingEdge.data,
|
||||
isBidirectional: true,
|
||||
},
|
||||
});
|
||||
} else if (!bidirectionalKeys.has(forwardKey)) {
|
||||
// Only add if this isn't the reverse of a bidirectional pair
|
||||
edgeMap.set(forwardKey, edge);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(edgeMap.values());
|
||||
}
|
||||
|
||||
@@ -584,33 +584,13 @@
|
||||
aria-hidden "^1.2.4"
|
||||
react-remove-scroll "^2.6.3"
|
||||
|
||||
"@radix-ui/react-separator@^1.1.7":
|
||||
version "1.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-separator/-/react-separator-1.1.7.tgz#a18bd7fd07c10fda1bba14f2a3032e7b1a2b3470"
|
||||
integrity sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==
|
||||
dependencies:
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
|
||||
"@radix-ui/react-slot@1.2.3", "@radix-ui/react-slot@^1.2.3":
|
||||
"@radix-ui/react-slot@^1.2.3", "@radix-ui/react-slot@1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz"
|
||||
integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
|
||||
"@radix-ui/react-switch@^1.2.6":
|
||||
version "1.2.6"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz#ff79acb831f0d5ea9216cfcc5b939912571358e3"
|
||||
integrity sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
"@radix-ui/react-use-previous" "1.1.1"
|
||||
"@radix-ui/react-use-size" "1.1.1"
|
||||
|
||||
"@radix-ui/react-tabs@^1.1.13":
|
||||
version "1.1.13"
|
||||
resolved "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz"
|
||||
|
||||
Reference in New Issue
Block a user