Python: DevUI improvements. (#1091)

* enable deeplinking in ui, add agent details to entity info, add usage data, add middleware example in samples and foundry agent.

* update ui build

* Update python/packages/devui/frontend/src/components/workflow/workflow-input-form.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update python/packages/devui/pyproject.toml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update python/packages/devui/pyproject.toml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* imporove mapping for agent nodes and serialiation for agent run events

* lint fixes

* update pyproj toml and ui updates

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Victor Dibia
2025-10-03 15:22:03 -07:00
committed by GitHub
Unverified
parent 61ac6d43b2
commit 01f438d710
33 changed files with 2733 additions and 1164 deletions
+17 -5
View File
@@ -1,11 +1,23 @@
# React + TypeScript + Vite
# DevUI Frontend
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
## Build Instructions
Currently, two official plugins are available:
```bash
cd frontend
yarn install
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
# Create .env.local with backend URL
echo 'VITE_API_BASE_URL=http://localhost:8000' > .env.local
# Create .env.production (empty for relative URLs)
echo '' > .env.production
# Development
yarn dev
# Build (copies to backend)
yarn build
```
## Expanding the ESLint configuration
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/agentframework.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Framework Dev UI</title>
</head>
@@ -0,0 +1,33 @@
<svg width="805" height="805" viewBox="0 0 805 805" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_iii_510_1294)">
<path d="M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z" fill="url(#paint0_linear_510_1294)"/>
</g>
<defs>
<filter id="filter0_iii_510_1294" x="103.759" y="93.4694" width="578.735" height="599.314" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="8.39647" dy="8.39647"/>
<feGaussianBlur stdDeviation="20.9912"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.835294 0 0 0 0 0.623529 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_510_1294"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-26.2432" dy="-26.2432"/>
<feGaussianBlur stdDeviation="20.9912"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.3 0"/>
<feBlend mode="plus-darker" in2="effect1_innerShadow_510_1294" result="effect2_innerShadow_510_1294"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-26.2432" dy="-26.2432"/>
<feGaussianBlur stdDeviation="50"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.1 0"/>
<feBlend mode="plus-darker" in2="effect2_innerShadow_510_1294" result="effect3_innerShadow_510_1294"/>
</filter>
<linearGradient id="paint0_linear_510_1294" x1="255.628" y1="-34.3245" x2="618.483" y2="632.032" gradientUnits="userSpaceOnUse">
<stop stop-color="#D59FFF"/>
<stop offset="1" stop-color="#8562C5"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

+199 -120
View File
@@ -4,7 +4,6 @@
*/
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { AppHeader } from "@/components/shared/app-header";
import { DebugPanel } from "@/components/shared/debug-panel";
import { SettingsModal } from "@/components/shared/settings-modal";
@@ -12,8 +11,9 @@ import { GalleryView } from "@/components/gallery";
import { AgentView } from "@/components/agent/agent-view";
import { WorkflowView } from "@/components/workflow/workflow-view";
import { LoadingState } from "@/components/ui/loading-state";
import { Toast } from "@/components/ui/toast";
import { apiClient } from "@/services/api";
import { ChevronLeft, ChevronDown, ServerOff } from "lucide-react";
import { PanelRightOpen, ChevronDown, ServerOff } from "lucide-react";
import type { SampleEntity } from "@/data/gallery";
import type {
AgentInfo,
@@ -21,6 +21,7 @@ import type {
AppState,
ExtendedResponseStreamEvent,
} from "@/types";
import { Button } from "./components/ui/button";
export default function App() {
const [appState, setAppState] = useState<AppState>({
@@ -29,8 +30,13 @@ export default function App() {
isLoading: true,
});
const [debugEvents, setDebugEvents] = useState<ExtendedResponseStreamEvent[]>([]);
const [debugPanelOpen, setDebugPanelOpen] = useState(true);
const [debugEvents, setDebugEvents] = useState<ExtendedResponseStreamEvent[]>(
[]
);
const [showDebugPanel, setShowDebugPanel] = useState(() => {
const saved = localStorage.getItem("showDebugPanel");
return saved !== null ? saved === "true" : true;
});
const [debugPanelWidth, setDebugPanelWidth] = useState(() => {
const savedWidth = localStorage.getItem("debugPanelWidth");
return savedWidth ? parseInt(savedWidth, 10) : 320;
@@ -41,6 +47,7 @@ export default function App() {
const [addingEntityId, setAddingEntityId] = useState<string | null>(null);
const [errorEntityId, setErrorEntityId] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showEntityNotFoundToast, setShowEntityNotFoundToast] = useState(false);
// Initialize app - load agents and workflows
useEffect(() => {
@@ -51,16 +58,51 @@ export default function App() {
apiClient.getWorkflows(),
]);
setAppState((prev) => ({
...prev,
agents,
workflows,
selectedAgent:
// Check if there's an entity_id in the URL
const urlParams = new URLSearchParams(window.location.search);
const entityId = urlParams.get("entity_id");
let selectedAgent: AgentInfo | WorkflowInfo | undefined;
// Try to find entity from URL parameter first
if (entityId) {
selectedAgent =
agents.find((a) => a.id === entityId) ||
workflows.find((w) => w.id === entityId);
// If entity not found but was requested, show notification
if (!selectedAgent) {
setShowEntityNotFoundToast(true);
}
}
// Fallback to first available entity if URL entity not found
if (!selectedAgent) {
selectedAgent =
agents.length > 0
? agents[0]
: workflows.length > 0
? workflows[0]
: undefined,
: undefined;
// Update URL to match actual selected entity (or clear if none)
if (selectedAgent) {
const url = new URL(window.location.href);
url.searchParams.set("entity_id", selectedAgent.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);
}
}
setAppState((prev) => ({
...prev,
agents,
workflows,
selectedAgent,
isLoading: false,
}));
} catch (error) {
@@ -76,7 +118,11 @@ export default function App() {
loadData();
}, []);
// Save debug panel width to localStorage
// Save debug panel state to localStorage
useEffect(() => {
localStorage.setItem("showDebugPanel", showDebugPanel.toString());
}, [showDebugPanel]);
useEffect(() => {
localStorage.setItem("debugPanelWidth", debugPanelWidth.toString());
}, [debugPanelWidth]);
@@ -111,11 +157,6 @@ export default function App() {
[debugPanelWidth]
);
// Handle double-click to collapse
const handleDoubleClick = useCallback(() => {
setDebugPanelOpen(false);
}, []);
// Handle entity selection
const handleEntitySelect = useCallback((item: AgentInfo | WorkflowInfo) => {
setAppState((prev) => ({
@@ -124,18 +165,26 @@ export default function App() {
currentThread: undefined,
}));
// Update URL with selected entity ID
const url = new URL(window.location.href);
url.searchParams.set("entity_id", item.id);
window.history.pushState({}, "", url);
// Clear debug events when switching entities
setDebugEvents([]);
}, []);
// Handle debug events from active view
const handleDebugEvent = useCallback((event: ExtendedResponseStreamEvent | 'clear') => {
if (event === 'clear') {
setDebugEvents([]);
} else {
setDebugEvents((prev) => [...prev, event]);
}
}, []);
const handleDebugEvent = useCallback(
(event: ExtendedResponseStreamEvent | "clear") => {
if (event === "clear") {
setDebugEvents([]);
} else {
setDebugEvents((prev) => [...prev, event]);
}
},
[]
);
// Handle adding sample entity
const handleAddSample = useCallback(async (sample: SampleEntity) => {
@@ -146,9 +195,9 @@ export default function App() {
try {
// Call backend to fetch and add entity
const newEntity = await apiClient.addEntity(sample.url, {
source: 'remote_gallery',
source: "remote_gallery",
originalUrl: sample.url,
sampleId: sample.id
sampleId: sample.id,
});
// Convert backend entity to frontend format
@@ -157,52 +206,67 @@ export default function App() {
name: newEntity.name,
description: newEntity.description,
type: newEntity.type,
source: (newEntity.source as "directory" | "in_memory" | "remote_gallery") || 'remote_gallery',
source:
(newEntity.source as "directory" | "in_memory" | "remote_gallery") ||
"remote_gallery",
has_env: false,
module_path: undefined
module_path: undefined,
};
// Update app state
if (newEntity.type === 'agent') {
if (newEntity.type === "agent") {
const agentEntity = {
...convertedEntity,
tools: (newEntity.tools || []).map(tool =>
typeof tool === 'string' ? tool : JSON.stringify(tool)
)
tools: (newEntity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
),
} as AgentInfo;
setAppState(prev => ({
setAppState((prev) => ({
...prev,
agents: [...prev.agents, agentEntity],
selectedAgent: agentEntity
selectedAgent: agentEntity,
}));
// Update URL with new entity
const url = new URL(window.location.href);
url.searchParams.set("entity_id", agentEntity.id);
window.history.pushState({}, "", url);
} else {
const workflowEntity = {
...convertedEntity,
executors: (newEntity.tools || []).map(tool =>
typeof tool === 'string' ? tool : JSON.stringify(tool)
executors: (newEntity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
),
input_schema: { type: "string" },
input_type_name: "Input",
start_executor_id: (newEntity.tools && newEntity.tools.length > 0)
? (typeof newEntity.tools[0] === 'string' ? newEntity.tools[0] : JSON.stringify(newEntity.tools[0]))
: "unknown"
start_executor_id:
newEntity.tools && newEntity.tools.length > 0
? typeof newEntity.tools[0] === "string"
? newEntity.tools[0]
: JSON.stringify(newEntity.tools[0])
: "unknown",
} as WorkflowInfo;
setAppState(prev => ({
setAppState((prev) => ({
...prev,
workflows: [...prev.workflows, workflowEntity],
selectedAgent: workflowEntity
selectedAgent: workflowEntity,
}));
// Update URL with new entity
const url = new URL(window.location.href);
url.searchParams.set("entity_id", workflowEntity.id);
window.history.pushState({}, "", url);
}
// Close gallery and clear debug events
setShowGallery(false);
setDebugEvents([]);
} catch (error) {
const errMsg = error instanceof Error ? error.message : 'Failed to add sample entity';
console.error('Failed to add sample entity:', errMsg);
const errMsg =
error instanceof Error ? error.message : "Failed to add sample entity";
console.error("Failed to add sample entity:", errMsg);
setErrorEntityId(sample.id);
setErrorMessage(errMsg);
} finally {
@@ -216,29 +280,35 @@ export default function App() {
}, []);
// Handle removing entity
const handleRemoveEntity = useCallback(async (entityId: string) => {
try {
await apiClient.removeEntity(entityId);
const handleRemoveEntity = useCallback(
async (entityId: string) => {
try {
await apiClient.removeEntity(entityId);
// Update app state
setAppState(prev => ({
...prev,
agents: prev.agents.filter(a => a.id !== entityId),
workflows: prev.workflows.filter(w => w.id !== entityId),
selectedAgent: prev.selectedAgent?.id === entityId
? undefined
: prev.selectedAgent
}));
// Update app state
setAppState((prev) => ({
...prev,
agents: prev.agents.filter((a) => a.id !== entityId),
workflows: prev.workflows.filter((w) => w.id !== entityId),
selectedAgent:
prev.selectedAgent?.id === entityId
? undefined
: prev.selectedAgent,
}));
// Clear debug events if we removed the selected entity
if (appState.selectedAgent?.id === entityId) {
setDebugEvents([]);
// Update URL - clear entity_id if we removed the selected entity
if (appState.selectedAgent?.id === entityId) {
const url = new URL(window.location.href);
url.searchParams.delete("entity_id");
window.history.pushState({}, "", url);
setDebugEvents([]);
}
} catch (error) {
console.error("Failed to remove entity:", error);
}
} catch (error) {
console.error('Failed to remove entity:', error);
}
}, [appState.selectedAgent?.id]);
},
[appState.selectedAgent?.id]
);
// Show loading state while initializing
if (appState.isLoading) {
@@ -293,24 +363,29 @@ export default function App() {
Can't Connect to Backend
</h2>
<p className="text-muted-foreground text-base">
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>
{/* 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>
<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>
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">http://localhost:8080</span>
Default:{" "}
<span className="font-mono">http://localhost:8080</span>
</p>
</div>
@@ -339,10 +414,7 @@ export default function App() {
</div>
{/* Settings Modal */}
<SettingsModal
open={showAboutModal}
onOpenChange={setShowAboutModal}
/>
<SettingsModal open={showAboutModal} onOpenChange={setShowAboutModal} />
</div>
);
}
@@ -373,7 +445,9 @@ export default function App() {
errorMessage={errorMessage}
onClearError={handleClearError}
onClose={() => setShowGallery(false)}
hasExistingEntities={appState.agents.length > 0 || appState.workflows.length > 0}
hasExistingEntities={
appState.agents.length > 0 || appState.workflows.length > 0
}
/>
</div>
) : appState.agents.length === 0 && appState.workflows.length === 0 ? (
@@ -409,51 +483,50 @@ export default function App() {
)}
</div>
{/* Resize Handle */}
{debugPanelOpen && (
<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}
onDoubleClick={handleDoubleClick}
>
<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>
)}
{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>
{/* Button to reopen when closed */}
{!debugPanelOpen && (
<div className="flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setDebugPanelOpen(true)}
className="h-full w-8 rounded-none border-l"
>
<ChevronLeft className="h-4 w-4" />
</Button>
</div>
)}
{/* Right Panel - Debug */}
{debugPanelOpen && (
<div
className="flex-shrink-0"
style={{ width: `${debugPanelWidth}px` }}
>
<DebugPanel
events={debugEvents}
isStreaming={false} // Each view manages its own streaming state
/>
{/* Right Panel - Debug */}
<div
className="flex-shrink-0"
style={{ width: `${debugPanelWidth}px` }}
>
<DebugPanel
events={debugEvents}
isStreaming={false} // Each view manages its own streaming state
onClose={() => setShowDebugPanel(false)}
/>
</div>
</>
) : (
/* 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>
)}
</>
@@ -461,10 +534,16 @@ export default function App() {
</div>
{/* Settings Modal */}
<SettingsModal
open={showAboutModal}
onOpenChange={setShowAboutModal}
/>
<SettingsModal open={showAboutModal} onOpenChange={setShowAboutModal} />
{/* Toast Notification */}
{showEntityNotFoundToast && (
<Toast
message="Entity not found. Showing first available entity instead."
type="info"
onClose={() => setShowEntityNotFoundToast(false)}
/>
)}
</div>
);
}
@@ -21,6 +21,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { AgentDetailsModal } from "@/components/shared/agent-details-modal";
import {
SendHorizontal,
User,
@@ -28,14 +29,9 @@ import {
Plus,
AlertCircle,
Paperclip,
Info,
Trash2,
FileText,
ChevronDown,
Package,
FolderOpen,
Database,
Globe,
CheckCircle,
XCircle,
} from "lucide-react";
import { apiClient } from "@/services/api";
import type {
@@ -111,8 +107,33 @@ function MessageBubble({ message }: MessageBubbleProps) {
</div>
</div>
<div className="text-xs text-muted-foreground font-mono">
{new Date(message.timestamp).toLocaleTimeString()}
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
<span>{new Date(message.timestamp).toLocaleTimeString()}</span>
{!isUser && message.usage && (
<>
<span></span>
<span className="text-[11px]">
{message.usage.total_tokens >= 1000
? `${(message.usage.total_tokens / 1000).toFixed(2)}k`
: message.usage.total_tokens}{" "}
tokens
{message.usage.prompt_tokens > 0 && (
<span className="opacity-70">
{" "}
(
{message.usage.prompt_tokens >= 1000
? `${(message.usage.prompt_tokens / 1000).toFixed(1)}k`
: message.usage.prompt_tokens}{" "}
in,{" "}
{message.usage.completion_tokens >= 1000
? `${(message.usage.completion_tokens / 1000).toFixed(1)}k`
: message.usage.completion_tokens}{" "}
out)
</span>
)}
</span>
</>
)}
</div>
</div>
</div>
@@ -154,12 +175,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const [pasteNotification, setPasteNotification] = useState<string | null>(
null
);
const [detailsExpanded, setDetailsExpanded] = useState(false);
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [threadUsage, setThreadUsage] = useState<{
total_tokens: number;
message_count: number;
}>({ total_tokens: 0, message_count: 0 });
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const accumulatedText = useRef<string>("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const currentMessageUsage = useRef<{
total_tokens: number;
prompt_tokens: number;
completion_tokens: number;
} | null>(null);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
@@ -439,12 +469,78 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
messages: [],
isStreaming: false,
});
setThreadUsage({ total_tokens: 0, message_count: 0 });
accumulatedText.current = "";
} catch (error) {
console.error("Failed to create thread:", error);
}
}, [selectedAgent]);
// Handle thread deletion
const handleDeleteThread = useCallback(
async (threadId: string, e?: React.MouseEvent) => {
// Prevent event from bubbling to SelectItem
if (e) {
e.preventDefault();
e.stopPropagation();
}
// Confirm deletion
if (!confirm("Delete this thread? This cannot be undone.")) {
return;
}
try {
const success = await apiClient.deleteThread(threadId);
if (success) {
// Remove thread from available threads
const updatedThreads = availableThreads.filter((t) => t.id !== threadId);
setAvailableThreads(updatedThreads);
// If deleted thread was selected, switch to another thread or clear chat
if (currentThread?.id === threadId) {
if (updatedThreads.length > 0) {
// Select the most recent remaining thread
const nextThread = updatedThreads[0];
setCurrentThread(nextThread);
// Load messages for the next thread
try {
const threadMessages = await apiClient.getThreadMessages(nextThread.id);
setChatState({
messages: threadMessages,
isStreaming: false,
});
} catch (error) {
console.error("Failed to load thread messages:", error);
setChatState({
messages: [],
isStreaming: false,
});
}
} else {
// No threads left, clear everything
setCurrentThread(undefined);
setChatState({
messages: [],
isStreaming: false,
});
setThreadUsage({ total_tokens: 0, message_count: 0 });
accumulatedText.current = "";
}
}
// Clear debug panel
onDebugEvent("clear");
}
} catch (error) {
console.error("Failed to delete thread:", error);
alert("Failed to delete thread. Please try again.");
}
},
[availableThreads, currentThread, onDebugEvent]
);
// Handle thread selection
const handleThreadSelect = useCallback(
async (threadId: string) => {
@@ -465,6 +561,16 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
isStreaming: false,
});
// Calculate cumulative usage for this thread
const totalTokens = threadMessages.reduce(
(sum, msg) => sum + (msg.usage?.total_tokens || 0),
0
);
const messageCount = threadMessages.filter(
(msg) => msg.role === "assistant" && msg.usage
).length;
setThreadUsage({ total_tokens: totalTokens, message_count: messageCount });
console.log(
`Restored ${threadMessages.length} messages for thread ${threadId}`
);
@@ -602,6 +708,20 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Pass all events to debug panel
onDebugEvent(openAIEvent);
// Handle usage events
if (openAIEvent.type === "response.usage.complete") {
const usageEvent = openAIEvent as import("@/types").ResponseUsageEventComplete;
console.log("📊 Usage event received:", usageEvent.data);
if (usageEvent.data) {
currentMessageUsage.current = {
total_tokens: usageEvent.data.total_tokens || 0,
prompt_tokens: usageEvent.data.prompt_tokens || 0,
completion_tokens: usageEvent.data.completion_tokens || 0,
};
console.log("📊 Set usage:", currentMessageUsage.current);
}
}
// Handle error events from the stream
if (openAIEvent.type === "error") {
const errorEvent = openAIEvent as ExtendedResponseStreamEvent & {
@@ -663,14 +783,35 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// (Server will close the stream when done, so we'll exit the loop naturally)
}
// Stream ended - mark as complete
// Stream ended - mark as complete and attach usage
const finalUsage = currentMessageUsage.current;
console.log("📊 Stream ended, attaching usage to message:", finalUsage);
setChatState((prev) => ({
...prev,
isStreaming: false,
messages: prev.messages.map((msg) =>
msg.id === assistantMessage.id ? { ...msg, streaming: false } : msg
msg.id === assistantMessage.id
? {
...msg,
streaming: false,
usage: finalUsage || undefined,
}
: msg
),
}));
// Update thread-level usage stats
if (finalUsage) {
setThreadUsage((prev) => ({
total_tokens: prev.total_tokens + finalUsage.total_tokens,
message_count: prev.message_count + 1,
}));
console.log("📊 Updated thread usage");
}
// Reset usage for next message
currentMessageUsage.current = null;
} catch (error) {
console.error("Streaming error:", error);
setChatState((prev) => ({
@@ -831,14 +972,11 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
<Button
variant="ghost"
size="sm"
onClick={() => setDetailsExpanded(!detailsExpanded)}
onClick={() => setDetailsModalOpen(true)}
className="h-6 w-6 p-0 flex-shrink-0"
title="View agent details"
>
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${
detailsExpanded ? "rotate-180" : ""
}`}
/>
<Info className="h-4 w-4" />
</Button>
</div>
@@ -849,7 +987,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
onValueChange={handleThreadSelect}
disabled={loadingThreads || isSubmitting}
>
<SelectTrigger className="w-full sm:w-48">
<SelectTrigger className="w-full sm:w-64">
<SelectValue
placeholder={
loadingThreads
@@ -860,7 +998,24 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
? `Thread ${currentThread.id.slice(-8)}`
: "Select thread"
}
/>
>
{currentThread && (
<div className="flex items-center gap-2 text-xs">
<span>Thread {currentThread.id.slice(-8)}</span>
{threadUsage.total_tokens > 0 && (
<>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">
{threadUsage.total_tokens >= 1000
? `${(threadUsage.total_tokens / 1000).toFixed(1)}k`
: threadUsage.total_tokens}{" "}
tokens
</span>
</>
)}
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{availableThreads.map((thread) => (
@@ -878,6 +1033,16 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={() => currentThread && handleDeleteThread(currentThread.id)}
disabled={!currentThread || isSubmitting}
title={currentThread ? `Delete Thread ${currentThread.id.slice(-8)}` : "No thread selected"}
>
<Trash2 className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="lg"
@@ -896,68 +1061,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
{selectedAgent.description}
</p>
)}
{/* Collapsible Details Section */}
<div
className={`overflow-hidden transition-all duration-200 ease-in-out ${
detailsExpanded ? "max-h-40 mt-3" : "max-h-0"
}`}
>
<div className="space-y-2 text-xs">
{/* Tools */}
<div className="flex items-center gap-2">
<Package className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Tools:</span>
<span className="font-mono">
{selectedAgent.tools.length > 0
? selectedAgent.tools.join(", ")
: "No tools"}
</span>
<span className="text-muted-foreground">
({selectedAgent.tools.length})
</span>
</div>
{/* Source */}
<div className="flex items-center gap-2">
{selectedAgent.source === "directory" ? (
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
) : selectedAgent.source === "in_memory" ? (
<Database className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="text-muted-foreground">Source:</span>
<span>
{selectedAgent.source === "directory"
? "Local"
: selectedAgent.source === "in_memory"
? "In-Memory"
: "Gallery"}
</span>
{selectedAgent.module_path && (
<span className="text-muted-foreground font-mono text-[11px]">
({selectedAgent.module_path})
</span>
)}
</div>
{/* Environment */}
<div className="flex items-center gap-2">
{selectedAgent.has_env ? (
<XCircle className="h-3.5 w-3.5 text-orange-500" />
) : (
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
)}
<span className="text-muted-foreground">Environment:</span>
<span>
{selectedAgent.has_env
? "Requires environment variables"
: "No environment variables required"}
</span>
</div>
</div>
</div>
</div>
{/* Messages */}
@@ -1077,6 +1180,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
</form>
</div>
</div>
{/* Agent Details Modal */}
<AgentDetailsModal
agent={selectedAgent}
open={detailsModalOpen}
onOpenChange={setDetailsModalOpen}
/>
</div>
);
}
@@ -0,0 +1,219 @@
/**
* AgentDetailsModal - Responsive grid-based modal for displaying agent metadata
*/
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import {
Bot,
Package,
FileText,
FolderOpen,
Database,
Globe,
CheckCircle,
XCircle,
} from "lucide-react";
import type { AgentInfo } from "@/types";
interface AgentDetailsModalProps {
agent: AgentInfo;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface DetailCardProps {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
className?: string;
}
function DetailCard({ title, icon, children, className = "" }: DetailCardProps) {
return (
<div className={`border rounded-lg p-4 bg-card ${className}`}>
<div className="flex items-center gap-2 mb-3">
{icon}
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
</div>
<div className="text-sm text-muted-foreground">{children}</div>
</div>
);
}
export function AgentDetailsModal({
agent,
open,
onOpenChange,
}: AgentDetailsModalProps) {
const sourceIcon =
agent.source === "directory" ? (
<FolderOpen className="h-4 w-4 text-muted-foreground" />
) : agent.source === "in_memory" ? (
<Database className="h-4 w-4 text-muted-foreground" />
) : (
<Globe className="h-4 w-4 text-muted-foreground" />
);
const sourceLabel =
agent.source === "directory"
? "Local"
: agent.source === "in_memory"
? "In-Memory"
: "Gallery";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader className="px-6 pt-6 flex-shrink-0">
<DialogTitle>Agent Details</DialogTitle>
<DialogClose onClose={() => onOpenChange(false)} />
</DialogHeader>
<div className="px-6 pb-6 overflow-y-auto flex-1">
{/* Header Section */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<Bot className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold text-foreground">
{agent.name || agent.id}
</h2>
</div>
{agent.description && (
<p className="text-muted-foreground">{agent.description}</p>
)}
</div>
<div className="h-px bg-border mb-6" />
{/* Grid Layout for Metadata */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{/* Model & Client */}
{(agent.model || agent.chat_client_type) && (
<DetailCard
title="Model & Client"
icon={<Bot className="h-4 w-4 text-muted-foreground" />}
>
<div className="space-y-1">
{agent.model && (
<div className="font-mono text-foreground">{agent.model}</div>
)}
{agent.chat_client_type && (
<div className="text-xs">({agent.chat_client_type})</div>
)}
</div>
</DetailCard>
)}
{/* Source */}
<DetailCard title="Source" icon={sourceIcon}>
<div className="space-y-1">
<div className="text-foreground">{sourceLabel}</div>
{agent.module_path && (
<div className="font-mono text-xs break-all">
{agent.module_path}
</div>
)}
</div>
</DetailCard>
{/* Environment */}
<DetailCard
title="Environment"
icon={
agent.has_env ? (
<XCircle className="h-4 w-4 text-orange-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)
}
className="md:col-span-2"
>
<div
className={
agent.has_env ? "text-orange-600 dark:text-orange-400" : "text-green-600 dark:text-green-400"
}
>
{agent.has_env
? "Requires environment variables"
: "No environment variables required"}
</div>
</DetailCard>
</div>
{/* Full Width Sections */}
{agent.instructions && (
<DetailCard
title="Instructions"
icon={<FileText className="h-4 w-4 text-muted-foreground" />}
className="mb-4"
>
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
{agent.instructions}
</div>
</DetailCard>
)}
{/* Tools and Middleware Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Tools */}
<DetailCard
title={`Tools (${agent.tools.length})`}
icon={<Package className="h-4 w-4 text-muted-foreground" />}
>
{agent.tools.length > 0 ? (
<ul className="space-y-1">
{agent.tools.map((tool, index) => (
<li key={index} className="font-mono text-xs text-foreground">
{tool}
</li>
))}
</ul>
) : (
<div className="text-muted-foreground">No tools configured</div>
)}
</DetailCard>
{/* Middleware */}
{agent.middleware && agent.middleware.length > 0 && (
<DetailCard
title={`Middleware (${agent.middleware.length})`}
icon={<Package className="h-4 w-4 text-muted-foreground" />}
>
<ul className="space-y-1">
{agent.middleware.map((mw, index) => (
<li key={index} className="font-mono text-xs text-foreground">
{mw}
</li>
))}
</ul>
</DetailCard>
)}
{/* Context Providers */}
{agent.context_providers && agent.context_providers.length > 0 && (
<DetailCard
title={`Context Providers (${agent.context_providers.length})`}
icon={<Database className="h-4 w-4 text-muted-foreground" />}
className={!agent.middleware || agent.middleware.length === 0 ? "md:col-start-2" : ""}
>
<ul className="space-y-1">
{agent.context_providers.map((cp, index) => (
<li key={index} className="font-mono text-xs text-foreground">
{cp}
</li>
))}
</ul>
</DetailCard>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -32,7 +32,35 @@ export function AppHeader({
}: AppHeaderProps) {
return (
<header className="flex h-14 items-center gap-4 border-b px-4">
<div className="font-semibold">Dev UI</div>
<div className="flex items-center gap-2 font-semibold">
<svg
width="24"
height="24"
viewBox="0 0 805 805"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="flex-shrink-0"
>
<path
d="M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z"
fill="url(#paint0_linear_510_1294)"
/>
<defs>
<linearGradient
id="paint0_linear_510_1294"
x1="255.628"
y1="-34.3245"
x2="618.483"
y2="632.032"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#D59FFF" />
<stop offset="1" stopColor="#8562C5" />
</linearGradient>
</defs>
</svg>
Dev UI
</div>
<EntitySelector
agents={agents}
workflows={workflows}
@@ -51,4 +79,4 @@ export function AppHeader({
</div>
</header>
);
}
}
@@ -7,6 +7,7 @@ import { useRef, useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Activity,
Search,
@@ -19,6 +20,7 @@ import {
ChevronRight,
ChevronDown,
Info,
PanelRightClose,
} from "lucide-react";
import type { ExtendedResponseStreamEvent } from "@/types";
@@ -65,6 +67,7 @@ interface TraceEventData extends EventDataBase {
interface DebugPanelProps {
events: ExtendedResponseStreamEvent[];
isStreaming?: boolean;
onClose?: () => void;
}
// Helper function to accumulate OpenAI events into meaningful units
@@ -1359,12 +1362,16 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
);
}
export function DebugPanel({ events, isStreaming = false }: DebugPanelProps) {
export function DebugPanel({
events,
isStreaming = false,
onClose,
}: DebugPanelProps) {
return (
<div className=" overflow-auto h-[calc(100vh-3.7rem)] border-l">
<Tabs defaultValue="events" className="h-full flex flex-col">
<div className="px-3 pt-3">
<TabsList className="w-full">
<div className="px-3 pt-3 flex items-center gap-2">
<TabsList className="flex-1">
<TabsTrigger value="events" className="flex-1">
Events
</TabsTrigger>
@@ -1375,6 +1382,17 @@ export function DebugPanel({ events, isStreaming = false }: DebugPanelProps) {
Tools
</TabsTrigger>
</TabsList>
{onClose && (
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 flex-shrink-0"
title="Hide debug panel"
>
<PanelRightClose className="h-4 w-4" />
</Button>
)}
</div>
<TabsContent value="events" className="flex-1 mt-0">
@@ -0,0 +1,168 @@
/**
* WorkflowDetailsModal - Responsive grid-based modal for displaying workflow metadata
*/
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import {
Workflow as WorkflowIcon,
Package,
FolderOpen,
Database,
Globe,
CheckCircle,
XCircle,
PlayCircle,
} from "lucide-react";
import type { WorkflowInfo } from "@/types";
interface WorkflowDetailsModalProps {
workflow: WorkflowInfo;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface DetailCardProps {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
className?: string;
}
function DetailCard({ title, icon, children, className = "" }: DetailCardProps) {
return (
<div className={`border rounded-lg p-4 bg-card ${className}`}>
<div className="flex items-center gap-2 mb-3">
{icon}
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
</div>
<div className="text-sm text-muted-foreground">{children}</div>
</div>
);
}
export function WorkflowDetailsModal({
workflow,
open,
onOpenChange,
}: WorkflowDetailsModalProps) {
const sourceIcon =
workflow.source === "directory" ? (
<FolderOpen className="h-4 w-4 text-muted-foreground" />
) : workflow.source === "in_memory" ? (
<Database className="h-4 w-4 text-muted-foreground" />
) : (
<Globe className="h-4 w-4 text-muted-foreground" />
);
const sourceLabel =
workflow.source === "directory"
? "Local"
: workflow.source === "in_memory"
? "In-Memory"
: "Gallery";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader className="px-6 pt-6 flex-shrink-0">
<DialogTitle>Workflow Details</DialogTitle>
<DialogClose onClose={() => onOpenChange(false)} />
</DialogHeader>
<div className="px-6 pb-6 overflow-y-auto flex-1">
{/* Header Section */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<WorkflowIcon className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold text-foreground">
{workflow.name || workflow.id}
</h2>
</div>
{workflow.description && (
<p className="text-muted-foreground">{workflow.description}</p>
)}
</div>
<div className="h-px bg-border mb-6" />
{/* Grid Layout for Metadata */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{/* Start Executor */}
<DetailCard
title="Start Executor"
icon={<PlayCircle className="h-4 w-4 text-muted-foreground" />}
>
<div className="font-mono text-foreground">
{workflow.start_executor_id}
</div>
</DetailCard>
{/* Source */}
<DetailCard title="Source" icon={sourceIcon}>
<div className="space-y-1">
<div className="text-foreground">{sourceLabel}</div>
{workflow.module_path && (
<div className="font-mono text-xs break-all">
{workflow.module_path}
</div>
)}
</div>
</DetailCard>
{/* Environment */}
<DetailCard
title="Environment"
icon={
workflow.has_env ? (
<XCircle className="h-4 w-4 text-orange-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)
}
className="md:col-span-2"
>
<div
className={
workflow.has_env
? "text-orange-600 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}
>
{workflow.has_env
? "Requires environment variables"
: "No environment variables required"}
</div>
</DetailCard>
</div>
{/* Executors */}
<DetailCard
title={`Executors (${workflow.executors.length})`}
icon={<Package className="h-4 w-4 text-muted-foreground" />}
>
{workflow.executors.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{workflow.executors.map((executor, index) => (
<div
key={index}
className="font-mono text-xs text-foreground bg-muted px-2 py-1 rounded"
>
{executor}
</div>
))}
</div>
) : (
<div className="text-muted-foreground">No executors configured</div>
)}
</DetailCard>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,89 @@
/**
* Simple toast notification component
* Displays floating notifications in the top-right corner
*/
import { useEffect, useState } from "react";
import { X } from "lucide-react";
export interface ToastProps {
message: string;
type?: "info" | "success" | "warning" | "error";
duration?: number;
onClose: () => void;
}
export function Toast({ message, type = "info", duration = 4000, onClose }: ToastProps) {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(onClose, 300); // Wait for fade out animation
}, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
const bgColorClass = {
info: "bg-primary/10 border-primary/20",
success: "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800",
warning: "bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800",
error: "bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800",
}[type];
const textColorClass = {
info: "text-primary",
success: "text-green-800 dark:text-green-200",
warning: "text-orange-800 dark:text-orange-200",
error: "text-red-800 dark:text-red-200",
}[type];
return (
<div
className={`fixed top-4 right-4 z-50 flex items-start gap-3 p-4 rounded-lg border shadow-lg max-w-md transition-all duration-300 ${
isVisible ? "opacity-100 translate-x-0" : "opacity-0 translate-x-4"
} ${bgColorClass}`}
>
<p className={`text-sm flex-1 ${textColorClass}`}>{message}</p>
<button
onClick={() => {
setIsVisible(false);
setTimeout(onClose, 300);
}}
className={`flex-shrink-0 hover:opacity-70 transition-opacity ${textColorClass}`}
>
<X className="h-4 w-4" />
</button>
</div>
);
}
// Toast container for managing multiple toasts
export interface ToastData {
id: string;
message: string;
type?: "info" | "success" | "warning" | "error";
duration?: number;
}
interface ToastContainerProps {
toasts: ToastData[];
onRemove: (id: string) => void;
}
export function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
duration={toast.duration}
onClose={() => onRemove(toast.id)}
/>
))}
</div>
);
}
@@ -20,7 +20,7 @@ import {
DialogClose,
DialogFooter,
} from "@/components/ui/dialog";
import { Send } from "lucide-react";
import { Send, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
import type { JSONSchemaProperty } from "@/types";
@@ -29,22 +29,27 @@ interface FormFieldProps {
schema: JSONSchemaProperty;
value: unknown;
onChange: (value: unknown) => void;
isRequired?: boolean;
}
function FormField({ name, schema, value, onChange }: FormFieldProps) {
function FormField({ name, schema, value, onChange, isRequired = false }: FormFieldProps) {
const { type, description, enum: enumValues, default: defaultValue } = schema;
// For text/message/content fields, treat as textarea for better UX
const isTextContentField = ['text', 'message', 'content', 'query', 'prompt'].includes(name.toLowerCase());
// Determine if this field should span full width
// Only span full if it's a textarea or has very long description
const shouldSpanFullWidth =
schema.format === "textarea" ||
(description && description.length > 100) ||
type === "object" ||
type === "array";
isTextContentField || // text/message fields span full width
(description && description.length > 150);
const shouldSpanTwoColumns =
type === "object" ||
schema.format === "textarea" ||
(description && description.length > 50);
isTextContentField ||
(description && description.length > 80) ||
type === "array"; // Arrays might need more space for comma-separated values
const fieldContent = (() => {
// Handle different field types based on JSON Schema
@@ -54,7 +59,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
// Enum select
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Select
value={
typeof value === "string" && value
@@ -83,12 +91,16 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
);
} else if (
schema.format === "textarea" ||
isTextContentField ||
(description && description.length > 100)
) {
// Multi-line text
// Multi-line text (including text/message/content fields)
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={typeof value === "string" ? value : ""}
@@ -98,7 +110,7 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
? defaultValue
: `Enter ${name}`
}
rows={2}
rows={isTextContentField ? 4 : 2}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
@@ -109,7 +121,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
// Single-line text
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id={name}
type="text"
@@ -131,7 +146,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
case "number":
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id={name}
type="number"
@@ -161,7 +179,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked)}
/>
<Label htmlFor={name}>{name}</Label>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
@@ -172,7 +193,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
case "array":
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={
@@ -203,7 +227,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
// For complex objects or unknown types, use JSON textarea
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={
@@ -235,7 +262,7 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
// Return the field with appropriate grid column spanning
const getColumnSpan = () => {
if (shouldSpanFullWidth) return "md:col-span-3 xl:col-span-4";
if (shouldSpanFullWidth) return "md:col-span-2 lg:col-span-3 xl:col-span-4";
if (shouldSpanTwoColumns) return "xl:col-span-2";
return "";
};
@@ -259,6 +286,7 @@ export function WorkflowInputForm({
className,
}: WorkflowInputFormProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [showAdvancedFields, setShowAdvancedFields] = useState(false);
// Check if we're in embedded mode (being used inside another modal)
const isEmbedded = className?.includes('embedded');
@@ -268,10 +296,55 @@ export function WorkflowInputForm({
// Determine field info
const properties = inputSchema.properties || {};
const fieldNames = Object.keys(properties);
const requiredFields = inputSchema.required || [];
const isSimpleInput = inputSchema.type === "string" && !inputSchema.enum;
const primaryField = isSimpleInput ? "value" : fieldNames[0];
const canSubmit = primaryField
? formData[primaryField] !== undefined && formData[primaryField] !== ""
// Plan D: Separate required and optional fields first
const allOptionalFieldNames = fieldNames.filter(name => !requiredFields.includes(name));
// Detect ChatMessage-like pattern
const isChatMessageLike =
requiredFields.includes('role') &&
allOptionalFieldNames.some(f => ['text', 'message', 'content'].includes(f)) &&
properties['role']?.type === 'string';
// For ChatMessage: hide 'role' field (will be auto-filled)
const requiredFieldNames = fieldNames.filter(name =>
requiredFields.includes(name) && !(isChatMessageLike && name === 'role')
);
const optionalFieldNames = allOptionalFieldNames;
// For ChatMessage: prioritize text/message/content field to show first
const sortedOptionalFields = isChatMessageLike
? [...optionalFieldNames].sort((a, b) => {
const priority = (name: string) =>
['text', 'message', 'content'].includes(name) ? 1 : 0;
return priority(b) - priority(a);
})
: optionalFieldNames;
// Always show ALL required fields + fill to minimum visible with optional fields
// For ChatMessage: show only 1 optional field (text)
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;
// Update canSubmit to check required fields properly
// For ChatMessage: role is auto-filled, so it's always valid
const canSubmit = isSimpleInput
? formData.value !== undefined && formData.value !== ""
: requiredFields.length > 0
? requiredFields.every(fieldName => {
// Auto-filled fields are always valid
if (isChatMessageLike && fieldName === 'role' && formData['role'] === 'user') {
return true;
}
return formData[fieldName] !== undefined && formData[fieldName] !== "";
})
: Object.keys(formData).length > 0;
// Initialize form data
@@ -287,9 +360,15 @@ export function WorkflowInputForm({
initialData[key] = fieldSchema.enum[0];
}
});
// Auto-fill role="user" for ChatMessage-like inputs
if (isChatMessageLike && !initialData['role']) {
initialData['role'] = 'user';
}
setFormData(initialData);
}
}, [inputSchema]);
}, [inputSchema, isChatMessageLike]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -306,7 +385,16 @@ export function WorkflowInputForm({
const fieldName = fieldNames[0];
onSubmit({ [fieldName]: formData[fieldName] || "" });
} else {
onSubmit(formData);
// Filter out empty optional fields before submission
const filteredData: Record<string, unknown> = {};
Object.keys(formData).forEach(key => {
const value = formData[key];
// Include if: 1) required field, OR 2) has non-empty value
if (requiredFields.includes(key) || (value !== undefined && value !== "" && value !== null)) {
filteredData[key] = value;
}
});
onSubmit(filteredData);
}
} else {
onSubmit(formData);
@@ -330,27 +418,84 @@ export function WorkflowInputForm({
if (isEmbedded) {
return (
<form onSubmit={handleSubmit} className={className}>
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Simple input */}
{isSimpleInput && primaryField && (
{isSimpleInput && (
<FormField
name="Input"
schema={inputSchema}
value={formData.value}
onChange={(value) => updateField("value", value)}
isRequired={false}
/>
)}
{/* Complex form fields */}
{/* Complex form fields - Plan D: Required + Optional separation */}
{!isSimpleInput && (
<>
{fieldNames.map((fieldName) => (
{/* Required fields section */}
{requiredFieldNames.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={true}
/>
))}
{/* Separator between required and optional (only if both exist) */}
{hasRequiredFields && optionalFieldNames.length > 0 && (
<div className="sm:col-span-2 border-t border-border my-2"></div>
)}
{/* Visible optional fields */}
{visibleOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
{/* Collapsed optional fields toggle */}
{hasCollapsedFields && (
<div className="sm:col-span-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
className="w-full justify-center gap-2"
>
{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 - only show when toggled */}
{showAdvancedFields && collapsedOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
</>
@@ -440,15 +585,16 @@ export function WorkflowInputForm({
{/* Scrollable Form Content */}
<div className="px-8 py-6 overflow-y-auto flex-1 min-h-0">
<form id="workflow-modal-form" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-4 gap-8 max-w-none">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 md:gap-8 max-w-none">
{/* Simple input */}
{isSimpleInput && primaryField && (
<div className="md:col-span-3 xl:col-span-4">
{isSimpleInput && (
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
<FormField
name="Input"
schema={inputSchema}
value={formData.value}
onChange={(value) => updateField("value", value)}
isRequired={false}
/>
{inputSchema.description && (
<p className="text-sm text-muted-foreground mt-2">
@@ -458,16 +604,74 @@ export function WorkflowInputForm({
</div>
)}
{/* Complex form fields - Show all */}
{/* Complex form fields - Plan D: Required + Optional separation */}
{!isSimpleInput && (
<>
{fieldNames.map((fieldName) => (
{/* Required fields section */}
{requiredFieldNames.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={true}
/>
))}
{/* Separator between required and optional (only if both exist) */}
{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={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
{/* 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"
>
{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 - only show when toggled */}
{showAdvancedFields && collapsedOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
</>
@@ -11,12 +11,7 @@ import {
Play,
Settings,
RotateCcw,
ChevronDown,
Package,
FolderOpen,
Database,
Globe,
XCircle,
Info,
Workflow as WorkflowIcon,
} from "lucide-react";
import { LoadingState } from "@/components/ui/loading-state";
@@ -24,6 +19,7 @@ import { WorkflowInputForm } from "@/components/workflow/workflow-input-form";
import { Button } from "@/components/ui/button";
import { WorkflowFlow } from "@/components/workflow/workflow-flow";
import { useWorkflowEventCorrelation } from "@/hooks/useWorkflowEventCorrelation";
import { WorkflowDetailsModal } from "@/components/shared/workflow-details-modal";
import { apiClient } from "@/services/api";
import type {
WorkflowInfo,
@@ -190,11 +186,12 @@ function RunWorkflowButton({
variant={
buttonVariant === "destructive" ? "destructive" : "default"
}
size="icon"
className="rounded-l-none border-l-0 w-9"
title="Configure inputs"
size="default"
className="rounded-l-none border-l-0 px-3"
title="Configure workflow inputs - customize parameters before running"
>
<ChevronDown className="w-4 h-4" />
<Settings className="w-4 h-4" />
<span className="ml-1.5">Inputs</span>
</Button>
)}
</div>
@@ -273,7 +270,7 @@ export function WorkflowView({
const [workflowResult, setWorkflowResult] = useState<string>("");
const [workflowError, setWorkflowError] = useState<string>("");
const accumulatedText = useRef<string>("");
const [detailsExpanded, setDetailsExpanded] = useState(false);
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
// Panel resize state
const [bottomPanelHeight, setBottomPanelHeight] = useState(() => {
@@ -554,14 +551,11 @@ export function WorkflowView({
<Button
variant="ghost"
size="sm"
onClick={() => setDetailsExpanded(!detailsExpanded)}
onClick={() => setDetailsModalOpen(true)}
className="h-6 w-6 p-0 flex-shrink-0"
title="View workflow details"
>
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${
detailsExpanded ? "rotate-180" : ""
}`}
/>
<Info className="h-4 w-4" />
</Button>
</div>
@@ -593,78 +587,6 @@ export function WorkflowView({
{selectedWorkflow.description}
</p>
)}
{/* Executors - Always visible */}
{selectedWorkflow.executors.length > 0 && (
<div className="flex items-center gap-2 text-xs mt-2 mb-2">
<Package className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Executors:</span>
<span className="font-mono">
{selectedWorkflow.executors.slice(0, 3).join(", ")}
{selectedWorkflow.executors.length > 3 && "..."}
</span>
<span className="text-muted-foreground">
({selectedWorkflow.executors.length})
</span>
</div>
)}
{/* Collapsible Details Section */}
<div
className={`overflow-hidden transition-all duration-200 ease-in-out ${
detailsExpanded ? "max-h-40 mt-3" : "max-h-0"
}`}
>
<div className="space-y-2 text-xs">
{/* Start Executor */}
<div className="flex items-center gap-2">
<WorkflowIcon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Start:</span>
<span className="font-mono">
{selectedWorkflow.start_executor_id}
</span>
</div>
{/* Source */}
<div className="flex items-center gap-2">
{selectedWorkflow.source === "directory" ? (
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
) : selectedWorkflow.source === "in_memory" ? (
<Database className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="text-muted-foreground">Source:</span>
<span>
{selectedWorkflow.source === "directory"
? "Local"
: selectedWorkflow.source === "in_memory"
? "In-Memory"
: "Gallery"}
</span>
{selectedWorkflow.module_path && (
<span className="text-muted-foreground font-mono text-[11px]">
({selectedWorkflow.module_path})
</span>
)}
</div>
{/* Environment */}
<div className="flex items-center gap-2">
{selectedWorkflow.has_env ? (
<XCircle className="h-3.5 w-3.5 text-orange-500" />
) : (
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
)}
<span className="text-muted-foreground">Environment:</span>
<span>
{selectedWorkflow.has_env
? "Requires environment variables"
: "No environment variables required"}
</span>
</div>
</div>
</div>
</div>
{/* Workflow Visualization */}
@@ -954,6 +876,13 @@ export function WorkflowView({
)}
</div>
</div>
{/* Workflow Details Modal */}
<WorkflowDetailsModal
workflow={selectedWorkflow}
open={detailsModalOpen}
onOpenChange={setDetailsModalOpen}
/>
</div>
);
}
@@ -10,12 +10,14 @@ import type {
RunAgentRequest,
RunWorkflowRequest,
ThreadInfo,
WorkflowInfo,
} from "@/types";
import type { AgentFrameworkRequest } from "@/types/agent-framework";
import type { ExtendedResponseStreamEvent } from "@/types/openai";
// Backend API response types to match Python Pydantic models
interface EntityInfo {
// Backend API response type - polymorphic entity that can be agent or workflow
// This matches the Python Pydantic EntityInfo model which has all fields optional
interface BackendEntityInfo {
id: string;
type: "agent" | "workflow";
name: string;
@@ -25,6 +27,13 @@ interface EntityInfo {
metadata: Record<string, unknown>;
source?: string;
original_url?: string;
// Agent-specific fields (present when type === "agent")
instructions?: string;
model?: string;
chat_client_type?: string;
context_providers?: string[];
middleware?: string[];
// Workflow-specific fields (present when type === "workflow")
executors?: string[];
workflow_dump?: Record<string, unknown>;
input_schema?: Record<string, unknown>;
@@ -33,7 +42,7 @@ interface EntityInfo {
}
interface DiscoveryResponse {
entities: EntityInfo[];
entities: BackendEntityInfo[];
}
interface ThreadApiResponse {
@@ -119,15 +128,15 @@ class ApiClient {
// Entity discovery using new unified endpoint
async getEntities(): Promise<{
entities: (AgentInfo | import("@/types").WorkflowInfo)[];
entities: (AgentInfo | WorkflowInfo)[];
agents: AgentInfo[];
workflows: import("@/types").WorkflowInfo[];
workflows: WorkflowInfo[];
}> {
const response = await this.request<DiscoveryResponse>("/v1/entities");
// Separate agents and workflows
const agents: AgentInfo[] = [];
const workflows: import("@/types").WorkflowInfo[] = [];
const workflows: WorkflowInfo[] = [];
response.entities.forEach((entity) => {
if (entity.type === "agent") {
@@ -145,6 +154,12 @@ class ApiClient {
typeof entity.metadata?.module_path === "string"
? entity.metadata.module_path
: undefined,
// 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 if (entity.type === "workflow") {
const firstTool = entity.tools?.[0];
@@ -183,7 +198,7 @@ class ApiClient {
return agents;
}
async getWorkflows(): Promise<import("@/types").WorkflowInfo[]> {
async getWorkflows(): Promise<WorkflowInfo[]> {
const { workflows } = await this.getEntities();
return workflows;
}
@@ -467,8 +482,8 @@ class ApiClient {
}
// Add entity from URL
async addEntity(url: string, metadata?: Record<string, unknown>): Promise<EntityInfo> {
const response = await this.request<{ success: boolean; entity: EntityInfo }>("/v1/entities/add", {
async addEntity(url: string, metadata?: Record<string, unknown>): Promise<BackendEntityInfo> {
const response = await this.request<{ success: boolean; entity: BackendEntityInfo }>("/v1/entities/add", {
method: "POST",
body: JSON.stringify({ url, metadata }),
});
@@ -31,6 +31,12 @@ export interface AgentInfo {
has_env: boolean;
module_path?: string;
required_env_vars?: EnvVarRequirement[];
// Agent-specific fields
instructions?: string;
model?: string;
chat_client_type?: string;
context_providers?: string[];
middleware?: string[];
}
// JSON Schema types for workflow input
@@ -133,6 +139,11 @@ export interface ChatMessage {
author_name?: string;
message_id?: string;
error?: boolean; // Flag to indicate this is an error message
usage?: {
total_tokens: number;
prompt_tokens: number;
completion_tokens: number;
};
}
// UI State types