Files
agent-framework/python/packages/devui/frontend/src/App.tsx

682 lines
27 KiB
TypeScript

/**
* DevUI App - Minimal orchestrator for agent/workflow interactions
* Features: Entity selection, layout management, debug coordination
*/
import { useEffect, useCallback, useState } 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 { apiClient } from "@/services/api";
import { PanelRightOpen, ChevronLeft, ChevronDown, ServerOff, Rocket, Lock } 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);
const setIsLoadingEntities = useDevUIStore((state) => state.setIsLoadingEntities);
const setEntityError = useDevUIStore((state) => state.setEntityError);
// 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);
const setIsResizing = useDevUIStore((state) => state.setIsResizing);
// Modal state
const showAboutModal = useDevUIStore((state) => state.showAboutModal);
const showGallery = useDevUIStore((state) => state.showGallery);
const showDeployModal = useDevUIStore((state) => state.showDeployModal);
const showEntityNotFoundToast = useDevUIStore((state) => state.showEntityNotFoundToast);
// Modal actions
const setShowAboutModal = useDevUIStore((state) => state.setShowAboutModal);
const setShowGallery = useDevUIStore((state) => state.setShowGallery);
const setShowDeployModal = useDevUIStore((state) => state.setShowDeployModal);
const setShowEntityNotFoundToast = useDevUIStore((state) => state.setShowEntityNotFoundToast);
// Toast state and actions
const toasts = useDevUIStore((state) => state.toasts);
const addToast = useDevUIStore((state) => state.addToast);
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,
version: meta.version,
});
// Single API call instead of two parallel calls to same endpoint
const { entities: allEntities, agents: agentList, workflows: workflowList } = await apiClient.getEntities();
setEntities(allEntities);
setAgents(agentList);
setWorkflows(workflowList);
// Check if there's an entity_id in the URL
const urlParams = new URLSearchParams(window.location.search);
const entityId = urlParams.get("entity_id");
let selectedEntity: AgentInfo | WorkflowInfo | undefined;
// Try to find entity from URL parameter first
if (entityId) {
selectedEntity = allEntities.find((e) => e.id === entityId);
// If entity not found but was requested, show notification
if (!selectedEntity) {
setShowEntityNotFoundToast(true);
}
}
// 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;
// Update URL to match actual selected entity (or clear if none)
if (selectedEntity) {
const url = new URL(window.location.href);
url.searchParams.set("entity_id", selectedEntity.id);
window.history.replaceState({}, "", url);
} else {
// Clear entity_id if no entities available
const url = new URL(window.location.href);
url.searchParams.delete("entity_id");
window.history.replaceState({}, "", url);
}
}
if (selectedEntity) {
selectEntity(selectedEntity);
// Load full info for the first entity immediately
if (selectedEntity.metadata?.lazy_loaded === false) {
try {
if (selectedEntity.type === "agent") {
const fullAgent = await apiClient.getAgentInfo(
selectedEntity.id
);
updateAgent(fullAgent);
} else {
const fullWorkflow = await apiClient.getWorkflowInfo(
selectedEntity.id
);
updateWorkflow(fullWorkflow);
}
} catch (error) {
console.error(
`Failed to load full info for first entity ${selectedEntity.id}:`,
error
);
// Show toast for entity load errors (don't use setEntityError - that kills the whole UI)
const errorMessage = error instanceof Error ? error.message : String(error);
addToast({
type: "error",
message: `Failed to load "${selectedEntity.id}": ${errorMessage}`,
});
}
}
}
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);
setIsLoadingEntities(false);
}
};
loadData();
}, [setAgents, setWorkflows, selectEntity, updateAgent, updateWorkflow, setIsLoadingEntities, setEntityError, setShowEntityNotFoundToast, addToast, setEntities]);
// 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) => {
e.preventDefault();
setIsResizing(true);
const startX = e.clientX;
const startWidth = debugPanelWidth;
const handleMouseMove = (e: MouseEvent) => {
const deltaX = startX - e.clientX; // Subtract because we're dragging from right
const newWidth = Math.max(
200,
Math.min(window.innerWidth * 0.5, startWidth + deltaX)
);
setDebugPanelWidth(newWidth);
};
const handleMouseUp = () => {
setIsResizing(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[debugPanelWidth]
);
// Handle entity selection - uses Zustand's selectEntity which handles ALL side effects
const handleEntitySelect = useCallback(
async (item: AgentInfo | WorkflowInfo) => {
selectEntity(item); // This clears conversation state, debug events, and updates URL!
// If entity is sparse (not fully loaded), load full details
if (item.metadata?.lazy_loaded === false) {
try {
if (item.type === "agent") {
const fullAgent = await apiClient.getAgentInfo(item.id);
updateAgent(fullAgent);
} else {
const fullWorkflow = await apiClient.getWorkflowInfo(item.id);
updateWorkflow(fullWorkflow);
}
} catch (error) {
console.error(`Failed to load full info for ${item.id}:`, error);
// Show toast for entity load errors (don't use setEntityError - that kills the whole UI)
const errorMessage = error instanceof Error ? error.message : String(error);
addToast({
type: "error",
message: `Failed to load "${item.id}": ${errorMessage}`,
});
}
}
},
[selectEntity, updateAgent, updateWorkflow, addToast]
);
// Handle debug events from active view
const handleDebugEvent = useCallback(
(event: ExtendedResponseStreamEvent | "clear") => {
if (event === "clear") {
clearDebugEvents();
} else {
addDebugEvent(event);
}
},
[addDebugEvent, clearDebugEvents]
);
// Show loading state while initializing
if (isLoadingEntities) {
return (
<div className="h-screen flex flex-col bg-background">
{/* Top Bar - Skeleton */}
<header className="flex h-14 items-center gap-4 border-b px-4">
<div className="w-64 h-9 bg-muted animate-pulse rounded-md" />
<div className="flex items-center gap-2 ml-auto">
<div className="w-8 h-8 bg-muted animate-pulse rounded-md" />
<div className="w-8 h-8 bg-muted animate-pulse rounded-md" />
</div>
</header>
{/* Loading Content */}
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="text-lg font-medium">Initializing DevUI...</div>
<div className="text-sm text-muted-foreground mt-2">Loading agents and workflows from your configuration</div>
</div>
</div>
</div>
);
}
// Show error state if loading failed
if (entityError) {
const currentBackendUrl = apiClient.getBaseUrl();
const isAuthError = entityError === "UNAUTHORIZED" || authRequired;
// Extract port from the backend URL for the command suggestion
let backendPort = "8080"; // default fallback
try {
if (currentBackendUrl) {
const url = new URL(currentBackendUrl);
backendPort = url.port || (url.protocol === "https:" ? "443" : "80");
}
} catch {
// If URL parsing fails, keep default
}
return (
<div className="h-screen flex flex-col bg-background">
<AppHeader
agents={[]}
workflows={[]}
entities={[]}
selectedItem={undefined}
onSelect={() => {}}
isLoading={false}
onSettingsClick={() => setShowAboutModal(true)}
/>
{/* Error Content */}
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center space-y-6 max-w-2xl">
{/* 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" />
)}
</div>
</div>
{/* Heading */}
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-foreground">
{isAuthError ? "Authentication Required" : "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."}
</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 />
&nbsp;&nbsp; abc123xyz...
</code>
</div>
</details>
</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 {backendPort}
</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>
{/* 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>
{/* Settings Modal */}
<SettingsModal open={showAboutModal} onOpenChange={setShowAboutModal} />
</div>
);
}
return (
<div className="h-screen flex flex-col bg-background max-h-screen">
<AppHeader
agents={agents}
workflows={workflows}
entities={entities}
selectedItem={selectedAgent}
onSelect={handleEntitySelect}
onBrowseGallery={() => setShowGallery(true)}
isLoading={isLoadingEntities}
onSettingsClick={() => setShowAboutModal(true)}
/>
{/* Main Content - Split Panel or Gallery */}
<div className="flex flex-1 overflow-hidden">
{showGallery ? (
// Show gallery full screen (w-full ensures it takes entire width)
<div className="flex-1 w-full">
<GalleryView
variant="route"
onClose={() => setShowGallery(false)}
hasExistingEntities={
agents.length > 0 || workflows.length > 0
}
/>
</div>
) : agents.length === 0 && workflows.length === 0 ? (
// Empty state - show gallery inline (full width, no debug panel)
<GalleryView variant="inline" />
) : (
<>
{/* Left Panel - Main View */}
<div className="flex-1 min-w-0">
{selectedAgent ? (
selectedAgent.type === "agent" ? (
<AgentView
selectedAgent={selectedAgent as AgentInfo}
onDebugEvent={handleDebugEvent}
/>
) : (
<WorkflowView
selectedWorkflow={selectedAgent as WorkflowInfo}
onDebugEvent={handleDebugEvent}
/>
)
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Select an agent or workflow to get started.
</div>
)}
</div>
{uiMode === "developer" && showDebugPanel ? (
<>
{/* Resize Handle */}
<div
className={`w-1 cursor-col-resize flex-shrink-0 relative group transition-colors duration-200 ease-in-out ${
isResizing ? "bg-primary/40" : "bg-border hover:bg-primary/20"
}`}
onMouseDown={handleMouseDown}
>
<div className="absolute inset-y-0 -left-2 -right-2 flex items-center justify-center">
<div
className={`h-12 w-1 rounded-full transition-all duration-200 ease-in-out ${
isResizing
? "bg-primary shadow-lg shadow-primary/25"
: "bg-primary/30 group-hover:bg-primary group-hover:shadow-md group-hover:shadow-primary/20"
}`}
></div>
</div>
</div>
{/* Right Panel - Debug */}
<div
className="flex-shrink-0 flex flex-col h-[calc(100vh-3.7rem)]"
style={{ width: debugPanelMinimized ? '2.5rem' : `${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"
>
{/* 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>
</>
)}
</div>
</>
) : uiMode === "developer" ? (
/* Button to reopen when closed */
<div className="flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setShowDebugPanel(true)}
className="h-full w-10 rounded-none border-l"
title="Show debug panel"
>
<PanelRightOpen className="h-4 w-4" />
</Button>
</div>
) : null}
</>
)}
</div>
{/* Settings Modal */}
<SettingsModal open={showAboutModal} onOpenChange={setShowAboutModal} />
{/* Deployment Modal */}
<DeploymentModal
open={showDeployModal}
onClose={() => setShowDeployModal(false)}
agentName={selectedAgent?.name}
entity={selectedAgent}
/>
{/* Toast Notification */}
{showEntityNotFoundToast && (
<Toast
message="Entity not found. Showing first available entity instead."
type="info"
onClose={() => setShowEntityNotFoundToast(false)}
/>
)}
{/* Toast Container for reload and other notifications */}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>
);
}