chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
.env.*
claude.md
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+81
View File
@@ -0,0 +1,81 @@
# DevUI Frontend
## Build Instructions
```bash
cd frontend
yarn install
# 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
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
@@ -0,0 +1,31 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
// Allow exporting constants alongside components in specific patterns
// This is common for shadcn/ui components (buttonVariants) and form utilities
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true }
],
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<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>
<body>
<div id="root"></div>
<script type="module" src="src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@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",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.3.1",
"@xyflow/react": "^12.8.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.540.0",
"next-themes": "^0.4.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.12",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/node": "^24.3.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.2.0",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"tw-animate-css": "^1.3.7",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.1",
"vite": "^8.0.16"
}
}
@@ -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

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+733
View File
@@ -0,0 +1,733 @@
/**
* DevUI App - Minimal orchestrator for agent/workflow interactions
* Features: Entity selection, layout management, debug coordination
*/
import { useEffect, useCallback, useRef, 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,
ResponseTextDeltaEvent,
} from "@/types";
import { Button } from "./components/ui/button";
import { Input } from "./components/ui/input";
import { useDevUIStore } from "@/stores";
const DEBUG_TEXT_EVENT_FLUSH_INTERVAL_MS = 50;
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("");
const bufferedDebugTextRef = useRef<ResponseTextDeltaEvent | null>(null);
const lastBufferedDebugFlushAtRef = useRef(0);
// 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]
);
const flushBufferedDebugText = useCallback(() => {
const bufferedEvent = bufferedDebugTextRef.current;
if (!bufferedEvent) {
return;
}
bufferedDebugTextRef.current = null;
lastBufferedDebugFlushAtRef.current = performance.now();
addDebugEvent(bufferedEvent);
}, [addDebugEvent]);
// Handle debug events from active view
const handleDebugEvent = useCallback(
(event: ExtendedResponseStreamEvent | "clear") => {
if (event === "clear") {
bufferedDebugTextRef.current = null;
clearDebugEvents();
return;
}
if (
event.type === "response.output_text.delta" &&
"delta" in event &&
typeof event.delta === "string" &&
event.delta.length > 0
) {
const bufferedEvent = bufferedDebugTextRef.current;
const isSameOutput =
bufferedEvent !== null &&
bufferedEvent.item_id === event.item_id &&
bufferedEvent.output_index === event.output_index &&
bufferedEvent.content_index === event.content_index;
if (isSameOutput && bufferedEvent) {
bufferedDebugTextRef.current = {
...bufferedEvent,
delta: bufferedEvent.delta + event.delta,
sequence_number: event.sequence_number ?? bufferedEvent.sequence_number,
};
} else {
flushBufferedDebugText();
bufferedDebugTextRef.current = { ...event } as ResponseTextDeltaEvent;
}
if (
performance.now() - lastBufferedDebugFlushAtRef.current >=
DEBUG_TEXT_EVENT_FLUSH_INTERVAL_MS
) {
flushBufferedDebugText();
}
return;
}
flushBufferedDebugText();
addDebugEvent(event);
},
[addDebugEvent, clearDebugEvents, flushBufferedDebugText]
);
// 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>
);
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,215 @@
/**
* 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_id || 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_id && (
<div className="font-mono text-foreground">{agent.model_id}</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 MiddlewareTypes Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Tools */}
{agent.tools && agent.tools.length > 0 && (
<DetailCard
title={`Tools (${agent.tools.length})`}
icon={<Package className="h-4 w-4 text-muted-foreground" />}
>
<ul className="space-y-1">
{agent.tools.map((tool, index) => (
<li key={index} className="font-mono text-xs text-foreground">
{tool}
</li>
))}
</ul>
</DetailCard>
)}
{/* Middlewares */}
{agent.middleware && agent.middleware.length > 0 && (
<DetailCard
title={`Middlewares (${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 Provider */}
{agent.context_provider && (
<DetailCard
title="Context Provider"
icon={<Database className="h-4 w-4 text-muted-foreground" />}
className={!agent.middleware || agent.middleware.length === 0 ? "md:col-start-2" : ""}
>
<div className="font-mono text-xs text-foreground">
{agent.context_provider}
</div>
</DetailCard>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,949 @@
/**
* ContextInspector - Token usage visualization and context analysis
*
* Features:
* - Stacked bar chart showing input/output tokens per turn
* - Composition view showing what fills the context (system, user, assistant, tools)
* - Per-turn vs cumulative modes
* - Summary statistics (total, average, peak)
* - Pure CSS visualization (no external charting library)
*/
import { useState, useMemo } from "react";
import { useDevUIStore } from "@/stores/devuiStore";
import {
BarChart3,
Layers,
Info,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ExtendedResponseStreamEvent } from "@/types";
import {
TraceAttributes,
type TypedTraceAttributes,
type TraceMessage,
parseTraceMessages,
isTextPart,
isToolCallPart,
isToolResultPart,
} from "@/types/openai";
// Trace data interface matching debug-panel types
interface TraceEventData {
operation_name?: string;
duration_ms?: number;
status?: string;
attributes?: TypedTraceAttributes;
span_id?: string;
trace_id?: string;
parent_span_id?: string | null;
start_time?: number;
end_time?: number;
entity_id?: string;
response_id?: string | null;
}
// Context composition breakdown
interface ContextComposition {
system: number; // character count
user: number;
assistant: number;
toolCalls: number; // function definitions + arguments
toolResults: number; // function outputs
total: number;
}
// Turn data extracted from traces
interface TurnData {
response_id: string;
timestamp: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
model?: string;
entity_id?: string;
duration_ms: number;
composition: ContextComposition;
}
// Props for the component
interface ContextInspectorProps {
events: ExtendedResponseStreamEvent[];
}
// Parse message content to extract composition using typed TraceMessage format
function parseComposition(messagesJson: string | unknown): ContextComposition {
const composition: ContextComposition = {
system: 0,
user: 0,
assistant: 0,
toolCalls: 0,
toolResults: 0,
total: 0,
};
try {
// Use the typed parser for string input
let messages: TraceMessage[];
if (typeof messagesJson === "string") {
messages = parseTraceMessages(messagesJson);
} else if (Array.isArray(messagesJson)) {
messages = messagesJson as TraceMessage[];
} else {
return composition;
}
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const role = message.role;
const parts = message.parts;
// Calculate character count for this message
let charCount = 0;
// Handle parts array (Agent Framework format)
// Using type guards for type-safe access to part properties
if (Array.isArray(parts)) {
for (const part of parts) {
if (!part || typeof part !== "object") continue;
if (isTextPart(part)) {
// Text content can be in either 'content' or 'text' field
const text = part.content || part.text || "";
charCount += text.length;
} else if (isToolCallPart(part)) {
// Tool call includes name and arguments
const name = part.name || "";
const args = part.arguments || "";
composition.toolCalls += name.length + args.length;
} else if (isToolResultPart(part)) {
// Tool result - check both 'result' and 'response' fields
const result = part.result || part.response || "";
composition.toolResults += result.length;
}
}
}
// Categorize by role
if (role === "system") {
composition.system += charCount;
} else if (role === "user") {
composition.user += charCount;
} else if (role === "assistant") {
composition.assistant += charCount;
} else if (role === "tool") {
composition.toolResults += charCount;
}
}
composition.total =
composition.system +
composition.user +
composition.assistant +
composition.toolCalls +
composition.toolResults;
} catch {
// Parsing failed, return empty composition
}
return composition;
}
// Extract turn data from trace events
function extractTurnData(events: ExtendedResponseStreamEvent[]): TurnData[] {
const traceEvents = events.filter(e => e.type === "response.trace.completed");
// Group by response_id
const byResponseId = new Map<string, TraceEventData[]>();
for (const event of traceEvents) {
if (!("data" in event)) continue;
const data = event.data as TraceEventData;
const responseId = data.response_id || "unknown";
if (!byResponseId.has(responseId)) {
byResponseId.set(responseId, []);
}
byResponseId.get(responseId)!.push(data);
}
const turns: TurnData[] = [];
for (const [responseId, traces] of byResponseId) {
let inputTokens = 0;
let outputTokens = 0;
let model: string | undefined;
let timestamp = Date.now() / 1000;
let entity_id: string | undefined;
let totalDuration = 0;
let composition: ContextComposition = {
system: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0
};
for (const trace of traces) {
const attrs = trace.attributes || {};
// Get token counts using typed attribute keys
const traceInput = attrs[TraceAttributes.INPUT_TOKENS];
const traceOutput = attrs[TraceAttributes.OUTPUT_TOKENS];
if (traceInput !== undefined) {
inputTokens += Number(traceInput);
}
if (traceOutput !== undefined) {
outputTokens += Number(traceOutput);
}
// Get model using typed attribute key
if (attrs[TraceAttributes.MODEL]) {
model = String(attrs[TraceAttributes.MODEL]);
}
// Get timestamp
if (trace.start_time && trace.start_time < timestamp) {
timestamp = trace.start_time;
}
// Get entity_id
if (trace.entity_id) {
entity_id = trace.entity_id;
}
// Sum durations
if (trace.duration_ms) {
totalDuration += Number(trace.duration_ms);
}
// Parse composition from input messages using typed attribute key
const inputMessages = attrs[TraceAttributes.INPUT_MESSAGES];
if (inputMessages && composition.total === 0) {
composition = parseComposition(inputMessages);
}
// Also check for system instructions using typed attribute key
const systemInstructions = attrs[TraceAttributes.SYSTEM_INSTRUCTIONS];
if (systemInstructions && typeof systemInstructions === "string" && composition.system === 0) {
composition.system = systemInstructions.length;
composition.total += systemInstructions.length;
}
}
// Only include turns that have token data
if (inputTokens > 0 || outputTokens > 0) {
turns.push({
response_id: responseId,
timestamp,
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: inputTokens + outputTokens,
model,
entity_id,
duration_ms: totalDuration,
composition,
});
}
}
// Sort by timestamp (oldest first)
turns.sort((a, b) => a.timestamp - b.timestamp);
return turns;
}
// Calculate summary stats
function calculateStats(turns: TurnData[]) {
if (turns.length === 0) {
return {
totalInput: 0,
totalOutput: 0,
totalTokens: 0,
avgInput: 0,
avgOutput: 0,
avgTotal: 0,
peakInput: 0,
peakOutput: 0,
peakTotal: 0,
turnCount: 0,
};
}
const totalInput = turns.reduce((sum, t) => sum + t.input_tokens, 0);
const totalOutput = turns.reduce((sum, t) => sum + t.output_tokens, 0);
const totalTokens = totalInput + totalOutput;
const peakInput = Math.max(...turns.map(t => t.input_tokens));
const peakOutput = Math.max(...turns.map(t => t.output_tokens));
const peakTotal = Math.max(...turns.map(t => t.total_tokens));
return {
totalInput,
totalOutput,
totalTokens,
avgInput: Math.round(totalInput / turns.length),
avgOutput: Math.round(totalOutput / turns.length),
avgTotal: Math.round(totalTokens / turns.length),
peakInput,
peakOutput,
peakTotal,
turnCount: turns.length,
};
}
// Aggregate composition across all turns
function aggregateComposition(turns: TurnData[]): ContextComposition {
return turns.reduce(
(acc, turn) => ({
system: acc.system + turn.composition.system,
user: acc.user + turn.composition.user,
assistant: acc.assistant + turn.composition.assistant,
toolCalls: acc.toolCalls + turn.composition.toolCalls,
toolResults: acc.toolResults + turn.composition.toolResults,
total: acc.total + turn.composition.total,
}),
{ system: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0 }
);
}
// Format large numbers with K suffix
function formatTokenCount(n: number): string {
if (n >= 1000) {
return `${(n / 1000).toFixed(1)}k`;
}
return String(n);
}
// Color constants - single source of truth for all visualizations
const SEGMENT_COLORS = {
// Token segments
input: "bg-blue-500 dark:bg-blue-600",
output: "bg-emerald-500 dark:bg-emerald-600",
// Composition segments
system: "bg-purple-500 dark:bg-purple-600",
user: "bg-blue-500 dark:bg-blue-600",
assistant: "bg-emerald-500 dark:bg-emerald-600",
toolCalls: "bg-amber-500 dark:bg-amber-600",
toolResults: "bg-orange-500 dark:bg-orange-600",
} as const;
// Segment definition for the unified bar component
interface BarSegment {
key: string;
value: number;
color: string;
label: string;
}
// Unified segmented bar component with tooltips
// Replaces both TokenBar and CompositionBar for consistency and maintainability
function SegmentedBar({
segments,
maxValue,
height = 20,
renderLabel,
}: {
segments: BarSegment[];
maxValue: number;
height?: number;
renderLabel?: (total: number, segments: BarSegment[]) => React.ReactNode;
}) {
const total = segments.reduce((sum, s) => sum + s.value, 0);
if (total === 0) {
return (
<div className="flex items-center gap-2 w-full">
<div
className="rounded bg-muted/30 flex-1"
style={{ height: `${height}px` }}
/>
</div>
);
}
// When maxValue is 0, use full width (100%) - focus on ratios within the bar
// When maxValue > 0, scale relative to max - focus on size comparison
const widthPercent = maxValue > 0 ? (total / maxValue) * 100 : 100;
// Pre-compute segment metadata for tooltips
const segmentsWithMeta = segments
.filter(s => s.value > 0)
.map(seg => ({
...seg,
percent: Math.round((seg.value / total) * 100),
}));
return (
<div className="flex items-center gap-2 w-full">
<div
className="relative rounded overflow-hidden bg-muted/30 flex-1"
style={{ height: `${height}px` }}
>
<TooltipProvider delayDuration={150}>
<div
className="h-full flex transition-all duration-300"
style={{ width: `${widthPercent}%` }}
>
{segmentsWithMeta.map((seg) => (
<Tooltip key={seg.key}>
<TooltipTrigger asChild>
<div
className={`h-full ${seg.color} transition-all duration-150 hover:brightness-110 hover:scale-y-[1.15] origin-bottom cursor-default`}
style={{ width: `${(seg.value / total) * 100}%` }}
/>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-sm ${seg.color} flex-shrink-0`} />
<span className="font-medium">{seg.label}</span>
<span className="opacity-80">{formatTokenCount(seg.value)} ({seg.percent}%)</span>
</div>
</TooltipContent>
</Tooltip>
))}
</div>
</TooltipProvider>
</div>
{renderLabel?.(total, segments)}
</div>
);
}
// Helper to create token segments (input/output)
function createTokenSegments(input: number, output: number): BarSegment[] {
return [
{ key: "input", value: input, color: SEGMENT_COLORS.input, label: "Input" },
{ key: "output", value: output, color: SEGMENT_COLORS.output, label: "Output" },
];
}
// Helper to create composition segments
function createCompositionSegments(composition: ContextComposition): BarSegment[] {
return [
{ key: "system", value: composition.system, color: SEGMENT_COLORS.system, label: "System" },
{ key: "user", value: composition.user, color: SEGMENT_COLORS.user, label: "User" },
{ key: "assistant", value: composition.assistant, color: SEGMENT_COLORS.assistant, label: "Assistant" },
{ key: "toolCalls", value: composition.toolCalls, color: SEGMENT_COLORS.toolCalls, label: "Tool Calls" },
{ key: "toolResults", value: composition.toolResults, color: SEGMENT_COLORS.toolResults, label: "Tool Results" },
];
}
// Composition breakdown list
function CompositionBreakdown({
composition,
className = "",
}: {
composition: ContextComposition;
className?: string;
}) {
const { system, user, assistant, toolCalls, toolResults, total } = composition;
if (total === 0) {
return (
<div className={`text-xs text-muted-foreground ${className}`}>
No composition data available
</div>
);
}
const items = [
{ label: "System", value: system, color: SEGMENT_COLORS.system },
{ label: "User", value: user, color: SEGMENT_COLORS.user },
{ label: "Assistant", value: assistant, color: SEGMENT_COLORS.assistant },
{ label: "Tool Calls", value: toolCalls, color: SEGMENT_COLORS.toolCalls },
{ label: "Tool Results", value: toolResults, color: SEGMENT_COLORS.toolResults },
].filter(item => item.value > 0);
return (
<div className={`space-y-1.5 ${className}`}>
{items.map((item) => {
const percent = Math.round((item.value / total) * 100);
return (
<div key={item.label} className="flex items-center gap-2 text-xs">
<div className={`w-2 h-2 rounded-sm ${item.color}`} />
<span className="text-muted-foreground w-20">{item.label}</span>
<div className="flex-1 h-3 bg-muted/30 rounded overflow-hidden">
<div
className={`h-full ${item.color} transition-all duration-300`}
style={{ width: `${percent}%` }}
/>
</div>
<span className="font-mono w-10 text-right text-muted-foreground">
{percent}%
</span>
</div>
);
})}
</div>
);
}
// Turn row component
function TurnRow({
turn,
index,
maxValue,
maxCompositionValue,
cumulativeInput,
cumulativeOutput,
cumulativeComposition,
showCumulative,
viewMode,
}: {
turn: TurnData;
index: number;
maxValue: number;
maxCompositionValue: number;
cumulativeInput: number;
cumulativeOutput: number;
cumulativeComposition: ContextComposition;
showCumulative: boolean;
viewMode: "tokens" | "composition";
}) {
const [isExpanded, setIsExpanded] = useState(false);
const displayInput = showCumulative ? cumulativeInput : turn.input_tokens;
const displayOutput = showCumulative ? cumulativeOutput : turn.output_tokens;
const displayComposition = showCumulative ? cumulativeComposition : turn.composition;
const timestamp = new Date(turn.timestamp * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
return (
<div className="border-b border-muted/50 last:border-0">
<div
className="flex items-center gap-3 py-2 px-2 hover:bg-muted/30 cursor-pointer transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
{/* Turn number */}
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs font-medium flex-shrink-0">
{index + 1}
</div>
{/* Bar */}
<div className="flex-1 min-w-0">
{viewMode === "tokens" ? (
<SegmentedBar
segments={createTokenSegments(displayInput, displayOutput)}
maxValue={maxValue}
height={20}
renderLabel={(_, segs) => (
<div className="flex items-center gap-1 text-xs font-mono text-muted-foreground min-w-[80px] justify-end">
<span className="text-blue-600 dark:text-blue-400">{formatTokenCount(segs[0]?.value || 0)}</span>
<span>/</span>
<span className="text-emerald-600 dark:text-emerald-400">{formatTokenCount(segs[1]?.value || 0)}</span>
</div>
)}
/>
) : (
<SegmentedBar
segments={createCompositionSegments(displayComposition)}
maxValue={maxCompositionValue}
height={20}
renderLabel={(total) => (
<div className="text-xs font-mono text-muted-foreground min-w-[50px] text-right">
{formatTokenCount(Math.round(total / 4))}~
</div>
)}
/>
)}
</div>
{/* Expand icon */}
<div className="text-muted-foreground flex-shrink-0">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</div>
{/* Expanded details */}
{isExpanded && (
<div className="pb-3">
{/* Connector line */}
<div className="flex items-start gap-3 px-2">
<div className="w-6 flex justify-center flex-shrink-0">
<div className="w-px h-full bg-muted" />
</div>
<div className="flex-1 min-w-0">
{/* L-connector and composition */}
<div className="flex items-start gap-2">
<div className="text-muted-foreground text-xs mt-1"></div>
<div className="flex-1 space-y-3">
{/* Basic info */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground">
<div>Time: <span className="font-mono text-foreground">{timestamp}</span></div>
<div>Duration: <span className="font-mono text-foreground">{turn.duration_ms.toFixed(0)}ms</span></div>
{turn.model && (
<div>Model: <span className="font-mono text-foreground">{turn.model}</span></div>
)}
{turn.entity_id && (
<div>Entity: <span className="font-mono text-foreground">{turn.entity_id}</span></div>
)}
</div>
{/* Token counts - shown in tokens mode */}
{viewMode === "tokens" && (
<div className="flex gap-4 text-xs">
<div>
<span className="text-blue-600 dark:text-blue-400">Input:</span>{" "}
<span className="font-mono">{turn.input_tokens.toLocaleString()}</span>
</div>
<div>
<span className="text-emerald-600 dark:text-emerald-400">Output:</span>{" "}
<span className="font-mono">{turn.output_tokens.toLocaleString()}</span>
</div>
<div>
<span className="text-muted-foreground">Total:</span>{" "}
<span className="font-mono">{turn.total_tokens.toLocaleString()}</span>
</div>
</div>
)}
{/* Composition breakdown - shown in composition mode */}
{viewMode === "composition" && turn.composition.total > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
<Info className="h-3 w-3" />
Context Composition (estimated from ~{formatTokenCount(Math.round(turn.composition.total / 4))} tokens)
</div>
<CompositionBreakdown composition={turn.composition} />
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}
// Summary stats card
function StatCard({
label,
value,
icon: Icon,
color = "default",
}: {
label: string;
value: string | number;
icon: typeof BarChart3;
color?: "default" | "blue" | "green";
}) {
const colorClass = {
default: "text-muted-foreground",
blue: "text-blue-600 dark:text-blue-400",
green: "text-emerald-600 dark:text-emerald-400",
}[color];
return (
<div className="flex items-center gap-2 p-2 bg-muted/30 rounded">
<Icon className={`h-4 w-4 ${colorClass}`} />
<div className="flex-1 min-w-0">
<div className="text-xs text-muted-foreground truncate">{label}</div>
<div className="font-mono text-sm font-medium">{value}</div>
</div>
</div>
);
}
// Main component
export function ContextInspector({ events }: ContextInspectorProps) {
// Use persisted store state instead of local useState
const viewMode = useDevUIStore((state) => state.contextInspectorViewMode);
const setViewMode = useDevUIStore((state) => state.setContextInspectorViewMode);
const showCumulative = useDevUIStore((state) => state.contextInspectorCumulative);
const setShowCumulative = useDevUIStore((state) => state.setContextInspectorCumulative);
// Extract turn data from traces
const turns = useMemo(() => extractTurnData(events), [events]);
// Calculate stats
const stats = useMemo(() => calculateStats(turns), [turns]);
// Aggregate composition
const totalComposition = useMemo(() => aggregateComposition(turns), [turns]);
// Calculate max value for bar scaling (tokens)
// In non-cumulative mode, use 0 to signal full-width bars (focus on ratios)
// In cumulative mode, scale relative to total (focus on growth)
const maxValue = useMemo(() => {
if (turns.length === 0) return 0;
if (showCumulative) {
return stats.totalTokens;
} else {
// Return 0 to signal "use full width" - each bar shows its own ratio
return 0;
}
}, [turns, showCumulative, stats.totalTokens]);
// Calculate max value for composition bar scaling
// Same logic: full-width in non-cumulative, scaled in cumulative
const maxCompositionValue = useMemo(() => {
if (turns.length === 0) return 0;
if (showCumulative) {
return totalComposition.total;
} else {
// Return 0 to signal "use full width"
return 0;
}
}, [turns, showCumulative, totalComposition.total]);
// Calculate cumulative values for tokens and composition
const cumulativeData = useMemo(() => {
let cumInput = 0;
let cumOutput = 0;
let cumComposition: ContextComposition = {
system: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0
};
return turns.map(t => {
cumInput += t.input_tokens;
cumOutput += t.output_tokens;
cumComposition = {
system: cumComposition.system + t.composition.system,
user: cumComposition.user + t.composition.user,
assistant: cumComposition.assistant + t.composition.assistant,
toolCalls: cumComposition.toolCalls + t.composition.toolCalls,
toolResults: cumComposition.toolResults + t.composition.toolResults,
total: cumComposition.total + t.composition.total,
};
return {
input: cumInput,
output: cumOutput,
composition: { ...cumComposition }
};
});
}, [turns]);
// No data state
if (turns.length === 0) {
return (
<div className="flex flex-col items-center text-center p-6 pt-9">
<BarChart3 className="h-8 w-8 text-muted-foreground mb-3" />
<div className="text-sm font-medium mb-1">No Data</div>
<div className="text-xs text-muted-foreground max-w-[200px]">
Run{" "}
<span className="font-mono bg-accent/10 px-1 rounded">
devui --instrumentation
</span>{" "}
and start a conversation.
</div>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-3 border-b flex-shrink-0 space-y-2">
{/* Title row */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
<span className="font-medium text-sm">Context Inspector</span>
<Badge variant="outline" className="text-xs">
{turns.length} turn{turns.length !== 1 ? "s" : ""}
</Badge>
</div>
{/* Cumulative checkbox */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={showCumulative}
onCheckedChange={(checked) => setShowCumulative(checked === true)}
className="h-3.5 w-3.5"
/>
<span>Cumulative</span>
</label>
</div>
{/* View mode segmented control */}
<div className="flex items-center bg-muted rounded-md p-1">
<button
onClick={() => setViewMode("tokens")}
className={`flex-1 px-3 py-1.5 text-xs rounded transition-colors ${
viewMode === "tokens"
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
Tokens
</button>
<button
onClick={() => setViewMode("composition")}
className={`flex-1 px-3 py-1.5 text-xs rounded transition-colors ${
viewMode === "composition"
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
Composition
</button>
</div>
{/* View mode description */}
<div className="text-xs text-muted-foreground">
{viewMode === "tokens"
? "Token usage per turn"
: "Context breakdown by message type (chars)"}
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-3 space-y-4">
{/* Legend */}
<div className="flex items-center gap-4 text-xs px-1 flex-wrap">
{viewMode === "tokens" ? (
<>
<div className="flex items-center gap-1.5">
<div className={`w-3 h-3 rounded ${SEGMENT_COLORS.input}`} />
<span className="text-muted-foreground">Input ()</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-3 h-3 rounded ${SEGMENT_COLORS.output}`} />
<span className="text-muted-foreground">Output ()</span>
</div>
</>
) : (
<>
<div className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.system}`} />
<span className="text-muted-foreground">System</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.user}`} />
<span className="text-muted-foreground">User</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.assistant}`} />
<span className="text-muted-foreground">Assistant</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.toolCalls}`} />
<span className="text-muted-foreground">Tools</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.toolResults}`} />
<span className="text-muted-foreground">Results</span>
</div>
</>
)}
<div className="flex-1" />
<div className="flex items-center gap-1 text-muted-foreground">
<Info className="h-3 w-3" />
<span>Click for details</span>
</div>
</div>
{/* Turn bars */}
<div className="border rounded-lg overflow-hidden">
{turns.map((turn, index) => (
<TurnRow
key={turn.response_id}
turn={turn}
index={index}
maxValue={maxValue}
maxCompositionValue={maxCompositionValue}
cumulativeInput={cumulativeData[index]?.input || 0}
cumulativeOutput={cumulativeData[index]?.output || 0}
cumulativeComposition={cumulativeData[index]?.composition || turn.composition}
showCumulative={showCumulative}
viewMode={viewMode}
/>
))}
</div>
{/* Session summary */}
<div className="border rounded-lg overflow-hidden">
<div className="p-3 bg-muted/30 border-b">
<span className="text-xs font-medium">Session Summary</span>
</div>
<div className="p-3 space-y-3">
{/* Token summary cards */}
<div className="grid grid-cols-3 gap-2">
<StatCard
label="Total Tokens"
value={formatTokenCount(stats.totalTokens)}
icon={Layers}
/>
<StatCard
label="Input"
value={formatTokenCount(stats.totalInput)}
icon={BarChart3}
color="blue"
/>
<StatCard
label="Output"
value={formatTokenCount(stats.totalOutput)}
icon={BarChart3}
color="green"
/>
</div>
{/* Per-turn statistics (only for multi-turn sessions) */}
{turns.length > 1 && (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs pt-2 border-t border-muted/50">
<div className="flex justify-between">
<span className="text-muted-foreground">Avg per turn:</span>
<span className="font-mono">{formatTokenCount(stats.avgTotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Peak turn:</span>
<span className="font-mono">{formatTokenCount(stats.peakTotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Avg input:</span>
<span className="font-mono text-blue-600 dark:text-blue-400">{formatTokenCount(stats.avgInput)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Avg output:</span>
<span className="font-mono text-emerald-600 dark:text-emerald-400">{formatTokenCount(stats.avgOutput)}</span>
</div>
</div>
)}
{/* Total composition */}
{totalComposition.total > 0 && (
<div className="pt-3 border-t border-muted/50">
<div className="flex items-start gap-2">
<div className="text-muted-foreground text-xs mt-0.5"></div>
<div className="flex-1">
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
<Info className="h-3 w-3" />
Total Composition (all turns)
</div>
<CompositionBreakdown composition={totalComposition} />
</div>
</div>
</div>
)}
</div>
</div>
</div>
</ScrollArea>
</div>
);
}
@@ -0,0 +1,7 @@
/**
* Agent Feature - Exports
*/
export { AgentView } from "./agent-view";
export { AgentDetailsModal } from "./agent-details-modal";
export * from "./message-renderers";
@@ -0,0 +1,481 @@
/**
* OpenAI Content Renderer - Renders OpenAI Conversations API content types
* This is the CORRECT implementation that works with OpenAI types only
*/
import { useState, useEffect } from "react";
import {
Download,
FileText,
Code,
ChevronDown,
ChevronRight,
Music,
Check,
X,
Clock,
} from "lucide-react";
import type { MessageContent } from "@/types/openai";
import { MarkdownRenderer } from "@/components/ui/markdown-renderer";
interface ContentRendererProps {
content: MessageContent;
className?: string;
isStreaming?: boolean;
}
// Text content renderer
function TextContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
if (content.type !== "text" && content.type !== "input_text" && content.type !== "output_text") return null;
const text = content.text;
return (
<div className={`break-words ${className || ""}`}>
<MarkdownRenderer content={text} />
{isStreaming && text.length > 0 && (
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
)}
</div>
);
}
// Image content renderer (handles both input and output images)
function ImageContentRenderer({ content, className }: ContentRendererProps) {
const [imageError, setImageError] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "input_image" && content.type !== "output_image") return null;
const imageUrl = content.image_url;
if (imageError) {
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="h-4 w-4" />
<span>Image could not be loaded</span>
</div>
</div>
);
}
return (
<div className={`my-2 ${className || ""}`}>
<img
src={imageUrl}
alt="Uploaded image"
className={`rounded-lg border max-w-full transition-all cursor-pointer ${
isExpanded ? "max-h-none" : "max-h-64"
}`}
onClick={() => setIsExpanded(!isExpanded)}
onError={() => setImageError(true)}
/>
{isExpanded && (
<div className="text-xs text-muted-foreground mt-1">
Click to collapse
</div>
)}
</div>
);
}
// Helper to convert base64 (or data URI) to blob URL for better browser compatibility
function useBase64ToBlobUrl(data: string | undefined, mimeType: string): string | null {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
useEffect(() => {
if (!data) {
setBlobUrl(null);
return;
}
try {
// Handle both data URI format and raw base64
let base64Data: string;
if (data.startsWith('data:')) {
// Extract base64 from data URI (e.g., "data:application/pdf;base64,...")
const parts = data.split(',');
if (parts.length !== 2) {
setBlobUrl(null);
return;
}
base64Data = parts[1];
} else {
// Raw base64 data
base64Data = data;
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
setBlobUrl(url);
// Cleanup on unmount or when data changes
return () => {
URL.revokeObjectURL(url);
};
} catch (error) {
console.error('Failed to convert base64 to blob URL:', error);
setBlobUrl(null);
}
}, [data, mimeType]);
return blobUrl;
}
// File content renderer (handles both input and output files)
function FileContentRenderer({ content, className }: ContentRendererProps) {
const [isExpanded, setIsExpanded] = useState(true);
// Determine file properties (must be before hooks for conditional logic)
const isFileContent = content.type === "input_file" || content.type === "output_file";
const fileUrl = isFileContent ? (content.file_url || content.file_data) : undefined;
const filename = isFileContent ? (content.filename || "file") : undefined;
// Determine file type from filename or data URI
const isPdf = filename?.toLowerCase().endsWith(".pdf") || fileUrl?.includes("application/pdf");
const isAudio = filename?.toLowerCase().match(/\.(mp3|wav|m4a|ogg|flac|aac)$/);
// Convert base64 to blob URL for PDFs (better browser compatibility)
// Use file_data (raw base64) if available, otherwise try file_url
// Hook must be called unconditionally - pass undefined if not a PDF
const pdfData = (isFileContent && isPdf) ? (content.file_data || content.file_url) : undefined;
const pdfBlobUrl = useBase64ToBlobUrl(pdfData, 'application/pdf');
// Early return after all hooks
if (!isFileContent) return null;
// Use blob URL if available, otherwise fall back to original URL
const effectivePdfUrl = pdfBlobUrl || fileUrl;
// Helper to open PDF in new tab
const openPdfInNewTab = () => {
if (effectivePdfUrl) {
window.open(effectivePdfUrl, '_blank');
}
};
// For PDFs - show a clean card with actions (inline preview is unreliable across browsers)
if (isPdf && fileUrl) {
return (
<div className={`my-2 ${className || ""}`}>
{/* Header with filename and controls */}
<div className="flex items-center gap-2 mb-2 px-1">
<FileText className="h-4 w-4 text-red-500" />
<span className="text-sm font-medium truncate flex-1">{filename}</span>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
>
{isExpanded ? (
<>
<ChevronDown className="h-3 w-3" />
Collapse
</>
) : (
<>
<ChevronRight className="h-3 w-3" />
Expand
</>
)}
</button>
</div>
{/* PDF Card with actions */}
{isExpanded && (
<div className="border rounded-lg p-6 bg-muted/50 flex flex-col items-center justify-center gap-4">
<FileText className="h-16 w-16 text-red-400" />
<div className="text-center">
<p className="text-sm font-medium mb-1">{filename}</p>
<p className="text-xs text-muted-foreground">PDF Document</p>
</div>
<div className="flex gap-3">
<button
onClick={openPdfInNewTab}
className="text-sm bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-2 px-4 py-2 rounded-md transition-colors"
>
Open in new tab
</button>
<a
href={effectivePdfUrl || fileUrl}
download={filename}
className="text-sm text-foreground hover:bg-accent flex items-center gap-2 px-4 py-2 border rounded-md transition-colors"
>
<Download className="h-4 w-4" />
Download
</a>
</div>
</div>
)}
</div>
);
}
// For audio files
if (isAudio && fileUrl) {
return (
<div className={`my-2 p-3 border rounded-lg ${className || ""}`}>
<div className="flex items-center gap-2 mb-2">
<Music className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{filename}</span>
</div>
<audio controls className="w-full">
<source src={fileUrl} />
Your browser does not support audio playback.
</audio>
</div>
);
}
// Generic file display
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{filename}</span>
</div>
{fileUrl && (
<a
href={fileUrl}
download={filename}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<Download className="h-3 w-3" />
Download
</a>
)}
</div>
</div>
);
}
// 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 - compact version
function FunctionApprovalRequestRenderer({ content, className }: ContentRendererProps) {
// Hooks must be called unconditionally
const [isExpanded, setIsExpanded] = useState(false);
// Early return after hooks
if (content.type !== "function_approval_request") return null;
const { status, function_call } = content;
// Status styling - compact
const statusConfig = {
pending: {
icon: Clock,
label: "Awaiting approval",
iconClass: "text-amber-600 dark:text-amber-400",
},
approved: {
icon: Check,
label: "Approved",
iconClass: "text-green-600 dark:text-green-400",
},
rejected: {
icon: X,
label: "Rejected",
iconClass: "text-red-600 dark:text-red-400",
},
};
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={className}>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 px-2 py-1 text-xs rounded hover:bg-muted/50 transition-colors w-fit"
>
<StatusIcon className={`h-3 w-3 ${config.iconClass}`} />
<span className="text-muted-foreground font-mono">{function_call.name}</span>
<span className={`text-xs ${config.iconClass}`}>{config.label}</span>
{isExpanded ? (
<span className="text-xs text-muted-foreground"></span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</button>
{isExpanded && (
<div className="ml-5 mt-1 text-xs font-mono text-muted-foreground border-l-2 border-muted pl-3">
<pre className="whitespace-pre-wrap break-all">{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) {
case "text":
case "input_text":
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;
}
}
// Function call renderer (for displaying function calls in chat)
interface FunctionCallRendererProps {
name: string;
arguments: string;
className?: string;
}
export function FunctionCallRenderer({ name, arguments: args, className }: FunctionCallRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
let parsedArgs;
try {
parsedArgs = typeof args === "string" ? JSON.parse(args) : args;
} catch {
parsedArgs = args;
}
return (
<div className={`my-2 p-3 border rounded bg-blue-50 dark:bg-blue-950/20 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<Code className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-blue-800 dark:text-blue-300">
Function Call: {name}
</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto" />
) : (
<ChevronRight className="h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto" />
)}
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
<div className="text-blue-600 dark:text-blue-400 mb-1">Arguments:</div>
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedArgs, null, 2)}
</pre>
</div>
)}
</div>
);
}
// Function result renderer
interface FunctionResultRendererProps {
output: string;
call_id: string;
className?: string;
}
export function FunctionResultRenderer({ output, call_id, className }: FunctionResultRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
let parsedOutput;
try {
parsedOutput = typeof output === "string" ? JSON.parse(output) : output;
} catch {
parsedOutput = output;
}
return (
<div className={`my-2 p-3 border rounded bg-green-50 dark:bg-green-950/20 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<Code className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-green-800 dark:text-green-300">
Function Result
</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-green-600 dark:text-green-400 ml-auto" />
) : (
<ChevronRight className="h-4 w-4 text-green-600 dark:text-green-400 ml-auto" />
)}
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
<div className="text-green-600 dark:text-green-400 mb-1">Output:</div>
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedOutput, null, 2)}
</pre>
<div className="text-gray-500 text-[10px] mt-2">Call ID: {call_id}</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,77 @@
/**
* OpenAI Message Renderer - Renders OpenAI ConversationItem types
* This replaces the legacy AgentFramework-based renderer
*/
import type { ConversationItem } from "@/types/openai";
import {
OpenAIContentRenderer,
FunctionCallRenderer,
FunctionResultRenderer,
} from "./OpenAIContentRenderer";
interface OpenAIMessageRendererProps {
item: ConversationItem;
className?: string;
}
export function OpenAIMessageRenderer({
item,
className,
}: OpenAIMessageRendererProps) {
// Handle message items (user/assistant with content)
if (item.type === "message") {
// Determine if message is actively streaming
const isStreaming = item.status === "in_progress";
const hasContent = item.content.length > 0;
return (
<div className={className}>
{item.content.map((content, index) => (
<OpenAIContentRenderer
key={index}
content={content}
className={index > 0 ? "mt-2" : ""}
isStreaming={isStreaming}
/>
))}
{/* Show typing indicator when streaming with no content yet */}
{isStreaming && !hasContent && (
<div className="flex items-center space-x-1">
<div className="flex space-x-1">
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-current" />
</div>
</div>
)}
</div>
);
}
// Handle function call items
if (item.type === "function_call") {
return (
<FunctionCallRenderer
name={item.name}
arguments={item.arguments}
className={className}
/>
);
}
// Handle function result items
if (item.type === "function_call_output") {
return (
<FunctionResultRenderer
output={item.output}
call_id={item.call_id}
className={className}
/>
);
}
// Unknown item type
return null;
}
@@ -0,0 +1,7 @@
/**
* Message Renderer - Exports
* Uses OpenAI Responses API types exclusively
*/
export { OpenAIMessageRenderer } from "./OpenAIMessageRenderer";
export { OpenAIContentRenderer, FunctionCallRenderer, FunctionResultRenderer } from "./OpenAIContentRenderer";
@@ -0,0 +1,312 @@
/**
* GalleryView - Consolidated gallery component with card and grid logic
* Supports inline (empty state) and modal variants
*/
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Bot,
Workflow,
User,
TriangleAlert,
Key,
ChevronDown,
ArrowLeft,
Download,
BookOpen,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
SAMPLE_ENTITIES,
type SampleEntity,
getDifficultyColor,
} from "@/data/gallery";
import { SetupInstructionsModal } from "./setup-instructions-modal";
interface GalleryViewProps {
onClose?: () => void;
variant?: "inline" | "route" | "modal";
hasExistingEntities?: boolean;
}
// Internal: Sample Entity Card Component
function SampleEntityCard({
sample,
}: {
sample: SampleEntity;
}) {
const [showInstructions, setShowInstructions] = useState(false);
const TypeIcon = sample.type === "workflow" ? Workflow : Bot;
return (
<>
<Card className="hover:shadow-md transition-shadow duration-200 h-full flex flex-col overflow-hidden w-full">
<CardHeader className="pb-3 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<TypeIcon className="h-5 w-5" />
<Badge variant="secondary" className="text-xs">
{sample.type}
</Badge>
</div>
<Badge
variant="outline"
className={cn(
"text-xs border",
getDifficultyColor(sample.difficulty)
)}
>
{sample.difficulty}
</Badge>
</div>
<CardTitle className="text-lg leading-tight">{sample.name}</CardTitle>
<CardDescription className="text-sm line-clamp-3">
{sample.description}
</CardDescription>
</CardHeader>
<CardContent className="pt-0 flex-1 min-w-0 overflow-hidden">
<div className="space-y-3 min-w-0">
{/* Tags */}
<div className="flex flex-wrap gap-1">
{sample.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{sample.tags.length > 3 && (
<Badge variant="outline" className="text-xs">
+{sample.tags.length - 3}
</Badge>
)}
</div>
{/* Environment Variables Required - Collapsible */}
{sample.requiredEnvVars && sample.requiredEnvVars.length > 0 && (
<details className="group min-w-0 max-w-full overflow-hidden">
<summary className="cursor-pointer list-none p-2 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-md hover:bg-amber-100 dark:hover:bg-amber-950/30 transition-colors flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Key className="h-3.5 w-3.5 text-amber-600 dark:text-amber-500 flex-shrink-0" />
<span className="text-xs font-medium text-amber-900 dark:text-amber-100 truncate">
Requires {sample.requiredEnvVars.length} env var
{sample.requiredEnvVars.length > 1 ? "s" : ""}
</span>
</div>
<ChevronDown className="h-3 w-3 text-amber-600 dark:text-amber-500 flex-shrink-0 group-open:rotate-180 transition-transform" />
</summary>
<div className="mt-2 p-2 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-md space-y-2 min-w-0 max-w-full overflow-hidden">
{sample.requiredEnvVars.map((envVar) => (
<div key={envVar.name} className="text-xs min-w-0 max-w-full overflow-hidden">
<div className="font-mono font-medium text-amber-900 dark:text-amber-100 break-words">
{envVar.name}
</div>
<div className="text-amber-700 dark:text-amber-300 mt-0.5 break-words">
{envVar.description}
</div>
{envVar.example && (
<div className="font-mono text-amber-600 dark:text-amber-400 mt-0.5 break-all">
{envVar.example}
</div>
)}
</div>
))}
</div>
</details>
)}
{/* Features */}
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
Key Features:
</div>
<ul className="text-xs space-y-1">
{sample.features.slice(0, 3).map((feature) => (
<li key={feature} className="flex items-center gap-1">
<div className="w-1 h-1 rounded-full bg-current opacity-50" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
</div>
</CardContent>
<CardFooter className="pt-3 flex-col gap-3">
{/* Metadata */}
<div className="w-full flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span>{sample.author}</span>
</div>
</div>
{/* Action Buttons */}
<div className="w-full flex gap-2">
<Button asChild className="flex-1" size="sm">
<a
href={sample.url}
download={`${sample.id}.py`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-4 w-4 mr-2" />
Download
</a>
</Button>
<Button
variant="outline"
className="flex-1"
size="sm"
onClick={() => setShowInstructions(true)}
>
<BookOpen className="h-4 w-4 mr-2" />
Setup Guide
</Button>
</div>
</CardFooter>
</Card>
<SetupInstructionsModal
sample={sample}
open={showInstructions}
onOpenChange={setShowInstructions}
/>
</>
);
}
// Internal: Sample Entity Grid Component
function SampleEntityGrid({ samples }: { samples: SampleEntity[] }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{samples.map((sample) => (
<div key={sample.id} className="min-w-0">
<SampleEntityCard sample={sample} />
</div>
))}
</div>
);
}
// Main: Gallery View Component
export function GalleryView({
onClose,
variant = "inline",
hasExistingEntities = false,
}: GalleryViewProps) {
// Inline variant - for empty state in main app
if (variant === "inline") {
return (
<div className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto px-6 py-8">
{/* Info Banner */}
<div className="mb-8 p-4 bg-muted/50 border border-border rounded-lg">
<div className="flex items-start gap-3">
<TriangleAlert className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-semibold mb-1">
No agents or workflows configured yet!
</h3>
<p className="text-sm text-muted-foreground mb-2">
You can configure agents or workflows by running{" "}
<code className="px-1.5 py-0.5 bg-background rounded text-xs">
devui
</code>{" "}
in a directory containing them.
</p>
<p className="text-sm text-muted-foreground">
Browse the sample agents and workflows below. Download them,
review the code, and run them locally to get started quickly.
</p>
</div>
</div>
</div>
{/* Sample Gallery */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-4">Sample Gallery</h3>
<SampleEntityGrid samples={SAMPLE_ENTITIES} />
</div>
{/* Footer */}
<div className="text-center mt-12 pt-8 border-t">
<p className="text-sm text-muted-foreground">
Want to create your own agents or workflows? Check out the{" "}
<a
href="https://github.com/microsoft/agent-framework"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
documentation
</a>
</p>
</div>
</div>
</div>
);
}
// Route variant - for /gallery page
if (variant === "route") {
return (
<div className="h-full overflow-auto">
<div className="max-w-7xl mx-auto px-6 py-8">
{/* Header */}
<div className="mb-8">
{hasExistingEntities && (
<div className="mb-4">
<Button variant="ghost" onClick={onClose} className="gap-2">
<ArrowLeft className="h-4 w-4" />
Back
</Button>
</div>
)}
<div className="text-center">
<h2 className="text-2xl font-semibold mb-2">Sample Gallery</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Browse sample agents and workflows to learn the Agent
Framework. Download these curated examples and run them locally.
Examples range from beginner to advanced.
</p>
</div>
</div>
{/* Sample Gallery */}
<SampleEntityGrid samples={SAMPLE_ENTITIES} />
{/* Footer */}
<div className="text-center mt-12 pt-8 border-t">
<p className="text-sm text-muted-foreground">
Want to create your own agents or workflows? Check out the{" "}
<a
href="https://github.com/microsoft/agent-framework"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
documentation
</a>
</p>
</div>
</div>
</div>
);
}
// Modal variant - for dropdown trigger (simplified, just the grid)
return <SampleEntityGrid samples={SAMPLE_ENTITIES} />;
}
@@ -0,0 +1,5 @@
/**
* Gallery component exports
*/
export { GalleryView } from './gallery-view';
@@ -0,0 +1,207 @@
/**
* SetupInstructionsModal - Shows step-by-step instructions for running a sample entity
*/
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Download,
ExternalLink,
Copy,
Check,
Lightbulb,
BookOpen,
} from "lucide-react";
import type { SampleEntity } from "@/data/gallery";
interface SetupInstructionsModalProps {
sample: SampleEntity;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function CodeBlock({ children, copyable = false }: { children: string; copyable?: boolean }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(children);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative">
<pre className="bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono">
<code>{children}</code>
</pre>
{copyable && (
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 h-6 w-6 p-0"
onClick={handleCopy}
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</Button>
)}
</div>
);
}
function SetupStep({
number,
title,
description,
code,
action,
copyable = false,
}: {
number: number;
title: string;
description?: string;
code?: string;
action?: React.ReactNode;
copyable?: boolean;
}) {
return (
<div className="flex gap-4">
<div className="flex-shrink-0">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold">
{number}
</div>
</div>
<div className="flex-1 space-y-2">
<h4 className="font-semibold">{title}</h4>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
{code && <CodeBlock copyable={copyable}>{code}</CodeBlock>}
{action && <div>{action}</div>}
</div>
</div>
);
}
export function SetupInstructionsModal({
sample,
open,
onOpenChange,
}: SetupInstructionsModalProps) {
const hasEnvVars = sample.requiredEnvVars && sample.requiredEnvVars.length > 0;
const stepOffset = hasEnvVars ? 0 : -1;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader className="px-6 pt-6 pb-2">
<DialogTitle>Setup: {sample.name}</DialogTitle>
<DialogDescription>
Follow these steps to run this sample {sample.type} locally
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-6">
<ScrollArea className="h-[500px]">
<div className="space-y-6 pr-4">
{/* Step 1: Download */}
<SetupStep
number={1}
title="Download the sample file"
action={
<Button asChild size="sm">
<a
href={sample.url}
download={`${sample.id}.py`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-4 w-4 mr-2" />
Download {sample.id}.py
</a>
</Button>
}
/>
{/* Step 2: Create folder */}
<SetupStep
number={2}
title="Create a project folder"
description="Create a dedicated folder for this sample and move the downloaded file there:"
code={`mkdir -p ~/my-agents/${sample.id}\nmv ~/Downloads/${sample.id}.py ~/my-agents/${sample.id}/`}
copyable
/>
{/* Step 3: Environment variables (conditional) */}
{hasEnvVars && (
<SetupStep
number={3}
title="Set up environment variables"
description="Create a .env file in the project folder with these required variables:"
code={sample.requiredEnvVars!
.map((v) => `${v.name}=${v.example || "your-value-here"}\n# ${v.description}`)
.join("\n\n")}
copyable
/>
)}
{/* Step 4: Run DevUI */}
<SetupStep
number={4 + stepOffset}
title="Run with DevUI"
description="Navigate to the folder and start DevUI:"
code={`cd ~/my-agents/${sample.id}\ndevui .`}
copyable
/>
{/* Alternative: Direct run */}
<Alert>
<Lightbulb className="h-4 w-4" />
<AlertTitle>Alternative: Run Programmatically</AlertTitle>
<AlertDescription className="mt-2">
<p className="mb-2">You can also run the {sample.type} directly in Python:</p>
<CodeBlock copyable>
{`from ${sample.id} import ${sample.type}
import asyncio
async def main():
response = await ${sample.type}.run("Hello!")
print(response)
asyncio.run(main())`}
</CodeBlock>
</AlertDescription>
</Alert>
{/* Help links */}
<div className="flex gap-2 pt-4 border-t">
<Button variant="outline" size="sm" asChild>
<a href={sample.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-2" />
View Source
</a>
</Button>
<Button variant="outline" size="sm" asChild>
<a
href="https://github.com/microsoft/agent-framework#readme"
target="_blank"
rel="noopener noreferrer"
>
<BookOpen className="h-4 w-4 mr-2" />
Documentation
</a>
</Button>
</div>
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,395 @@
/**
* CheckpointInfoModal - Timeline view of workflow checkpoints
*/
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Clock,
MessageSquare,
AlertCircle,
Loader2,
Package,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/services/api";
import type { CheckpointItem, WorkflowSession, FullCheckpoint, PendingRequestInfoEvent } from "@/types";
interface CheckpointInfoModalProps {
session: WorkflowSession | null;
checkpoints: CheckpointItem[];
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CheckpointInfoModal({
session,
checkpoints,
open,
onOpenChange,
}: CheckpointInfoModalProps) {
const [selectedCheckpointId, setSelectedCheckpointId] = useState<string | null>(null);
const [fullCheckpoint, setFullCheckpoint] = useState<FullCheckpoint | null>(null);
const [loading, setLoading] = useState(false);
const [jsonExpanded, setJsonExpanded] = useState(true);
// Select first checkpoint when modal opens or checkpoints change
useEffect(() => {
if (open && checkpoints.length > 0) {
// Only reset selection if current selection is invalid
const currentSelectionValid = checkpoints.some(
cp => cp.checkpoint_id === selectedCheckpointId
);
if (!currentSelectionValid) {
setSelectedCheckpointId(checkpoints[0].checkpoint_id);
}
}
}, [open, checkpoints]);
// Load full checkpoint details
useEffect(() => {
if (!selectedCheckpointId || !session) return;
const loadDetails = async () => {
// Don't clear the previous checkpoint to avoid UI flash
setLoading(true);
try {
const item = await apiClient.getConversationItem(
session.conversation_id,
`checkpoint_${selectedCheckpointId}`
);
setFullCheckpoint((item as CheckpointItem).metadata?.full_checkpoint ?? null);
} catch (error) {
console.error("Failed to load checkpoint:", error);
setFullCheckpoint(null);
} finally {
setLoading(false);
}
};
loadDetails();
}, [selectedCheckpointId, session]);
if (!session) return null;
const selectedCheckpoint = checkpoints.find(
(cp) => cp.checkpoint_id === selectedCheckpointId
);
const executorIds = fullCheckpoint?.state?._executor_state
? Object.keys(fullCheckpoint.state._executor_state)
: [];
const messageExecutors = fullCheckpoint?.messages
? Object.keys(fullCheckpoint.messages)
: [];
// Format checkpoint size for display
const formatSize = (bytes?: number): string => {
if (!bytes) return "";
const kb = bytes / 1024;
if (kb < 1) {
return `${bytes} B`;
} else if (kb < 1024) {
return `${kb.toFixed(1)} KB`;
} else {
return `${(kb / 1024).toFixed(1)} MB`;
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[90vw] max-w-6xl min-w-[800px] h-[85vh] flex flex-col p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex-1">
<DialogTitle>{session.metadata.name}</DialogTitle>
<div className="text-sm text-muted-foreground mt-1">
{checkpoints.length} checkpoint{checkpoints.length !== 1 ? "s" : ""}
</div>
<div className="text-xs text-muted-foreground mt-2 max-w-2xl">
This is a read only view of the current checkpoint ids in the checkpoint storage for this workflow run.
</div>
</div>
<DialogClose onClose={() => onOpenChange(false)} />
</div>
</DialogHeader>
{/* Main Content - Timeline + Details */}
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Timeline Sidebar */}
<div className="w-80 border-r flex flex-col">
<ScrollArea className="flex-1">
<div className="p-4 space-y-2">
{checkpoints.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-8">
No checkpoints yet
</div>
) : (
checkpoints.map((checkpoint, index) => {
const isSelected = checkpoint.checkpoint_id === selectedCheckpointId;
const hasHil = checkpoint.metadata.has_pending_hil;
return (
<div key={checkpoint.checkpoint_id} className="relative">
<button
onClick={() => setSelectedCheckpointId(checkpoint.checkpoint_id)}
className={cn(
"relative w-full text-left p-3 rounded-lg border transition-colors",
isSelected
? "bg-primary/10 border-primary"
: "hover:bg-muted/50 border-transparent"
)}
>
<div className="flex items-start gap-3">
{/* Timeline Dot */}
<div className="flex flex-col items-center pt-1">
<div
className={cn(
"w-2 h-2 rounded-full z-10",
hasHil
? "bg-blue-500 ring-2 ring-blue-500/20"
: "bg-muted-foreground/30"
)}
/>
</div>
{/* Checkpoint Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{checkpoint.metadata.iteration_count === 0 ? "Initial State" : `Step ${checkpoint.metadata.iteration_count}`}
</span>
<span className="text-[10px] font-mono text-muted-foreground/70" title={checkpoint.checkpoint_id}>
{checkpoint.checkpoint_id.slice(0, 8)}
</span>
{index === 0 && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">
Latest
</Badge>
)}
{hasHil && (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5">
{checkpoint.metadata.pending_hil_count} HIL
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
<span>{new Date(checkpoint.timestamp).toLocaleTimeString()}</span>
{checkpoint.metadata.size_bytes && (
<>
<span></span>
<span>{formatSize(checkpoint.metadata.size_bytes)}</span>
</>
)}
</div>
</div>
</div>
</button>
{/* Connecting Line - positioned absolutely */}
{index < checkpoints.length - 1 && (
<div className="absolute left-[18px] top-[30px] w-px h-[calc(100%+8px)] bg-border" />
)}
</div>
);
})
)}
</div>
</ScrollArea>
</div>
{/* Details Panel */}
<div className="flex-1 flex flex-col overflow-hidden">
{!fullCheckpoint && !loading ? (
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Select a checkpoint to view details
</div>
) : (
<ScrollArea className="flex-1">
<div className="p-6 space-y-6 relative">
{/* Loading overlay */}
{loading && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center z-10">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{/* Header */}
<div className="flex items-start justify-between pb-4 border-b">
<div>
<div className="flex items-center gap-2 mb-1">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{selectedCheckpoint?.metadata.iteration_count === 0
? "Initial State"
: `Step ${selectedCheckpoint?.metadata.iteration_count}`}
</span>
{selectedCheckpoint?.metadata.size_bytes && (
<span className="text-xs text-muted-foreground">
{formatSize(selectedCheckpoint.metadata.size_bytes)}
</span>
)}
</div>
<div className="text-sm text-muted-foreground">
{selectedCheckpoint &&
new Date(selectedCheckpoint.timestamp).toLocaleString()}
</div>
{selectedCheckpoint && (
<div className="text-xs font-mono text-muted-foreground/70 mt-1">
ID: {selectedCheckpoint.checkpoint_id}
</div>
)}
</div>
{selectedCheckpoint?.metadata.has_pending_hil && (
<Badge variant="secondary">
{selectedCheckpoint.metadata.pending_hil_count} HIL Pending
</Badge>
)}
</div>
{/* Executors */}
{executorIds.length > 0 && (
<div>
<div className="text-sm font-medium mb-3 flex items-center gap-2">
<Package className="h-4 w-4" />
Active Executors ({executorIds.length})
</div>
<div className="flex flex-wrap gap-2">
{executorIds.map((execId) => (
<Badge key={execId} variant="outline" className="font-mono text-xs">
{execId}
</Badge>
))}
</div>
</div>
)}
{/* Messages */}
{messageExecutors.length > 0 && fullCheckpoint && (
<div>
<div className="text-sm font-medium mb-3 flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Messages
</div>
<div className="grid grid-cols-2 gap-3">
{messageExecutors.map((execId) => {
const count = (fullCheckpoint.messages[execId] as unknown[])?.length;
return (
<div key={execId} className="bg-muted/50 p-3 rounded-lg">
<div className="text-xs font-mono text-muted-foreground mb-1">
{execId}
</div>
<div className="font-medium">
{count} message{count !== 1 ? "s" : ""}
</div>
</div>
);
})}
</div>
</div>
)}
{/* HIL Requests */}
{fullCheckpoint?.pending_request_info_events &&
Object.keys(fullCheckpoint.pending_request_info_events).length > 0 && (
<div>
<div className="text-sm font-medium mb-3 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
Pending HIL Requests (
{Object.keys(fullCheckpoint.pending_request_info_events).length})
</div>
<div className="space-y-2">
{Object.entries(fullCheckpoint.pending_request_info_events).map(
([reqId, reqData]: [string, PendingRequestInfoEvent]) => (
<div
key={reqId}
className="bg-muted/50 border border-border p-3 rounded-lg"
>
<div className="flex items-center justify-between mb-2">
<code className="text-xs bg-background px-2 py-1 rounded">
{reqId.slice(0, 24)}...
</code>
<Badge variant="outline" className="text-xs">
{reqData.source_executor_id}
</Badge>
</div>
<div className="text-xs space-y-1">
<div>
<span className="text-muted-foreground">Request:</span>{" "}
<code className="bg-background px-1 py-0.5 rounded">
{reqData.request_type?.split(".").pop() || reqData.request_type}
</code>
</div>
<div>
<span className="text-muted-foreground">Response:</span>{" "}
<code className="bg-background px-1 py-0.5 rounded">
{reqData.response_type?.split(".").pop() || reqData.response_type}
</code>
</div>
</div>
</div>
)
)}
</div>
</div>
)}
{/* Workflow State */}
<div>
<div className="text-sm font-medium mb-3">Workflow State</div>
{fullCheckpoint?.state && Object.keys(fullCheckpoint.state).filter(
(k) => k !== "_executor_state"
).length > 0 ? (
<div className="flex flex-wrap gap-2">
{Object.keys(fullCheckpoint.state)
.filter((k) => k !== "_executor_state")
.map((key) => (
<Badge key={key} variant="secondary" className="font-mono text-xs">
{key}
</Badge>
))}
</div>
) : (
<div className="text-sm text-muted-foreground">No custom state</div>
)}
</div>
{/* Raw JSON (Collapsible) */}
<div className="border-t pt-6">
<button
onClick={() => setJsonExpanded(!jsonExpanded)}
className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors w-full"
>
{jsonExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
Raw JSON
</button>
{jsonExpanded && (
<pre className="mt-3 text-[10px] font-mono bg-muted p-4 rounded overflow-x-auto">
{JSON.stringify(fullCheckpoint, null, 2)}
</pre>
)}
</div>
</div>
</ScrollArea>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,714 @@
/**
* 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 { HilTimelineItem } from "./hil-timeline-item";
import { RunWorkflowButton } from "./run-workflow-button";
import { ChatMessageInput } from "@/components/ui/chat-message-input";
import { isChatMessageSchema } from "@/utils/workflow-utils";
import {
Loader2,
CheckCircle,
XCircle,
AlertCircle,
ChevronDown,
ChevronRight,
Copy,
Check,
Square,
} from "lucide-react";
import type { ExtendedResponseStreamEvent, JSONSchemaProperty } from "@/types";
import type { ResponseInputContent } from "@/types/agent-framework";
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;
// HIL support
pendingHilRequests?: Array<{
request_id: string;
request_data: Record<string, unknown>;
request_schema: import("@/types").JSONSchemaProperty;
}>;
hilResponses?: Record<string, Record<string, unknown>>;
onHilResponseChange?: (requestId: string, values: Record<string, unknown>) => void;
onHilSubmit?: () => void;
isSubmittingHil?: boolean;
// Workflow control props for bottom bar
inputSchema?: JSONSchemaProperty;
onRun?: (data: Record<string, unknown>, checkpointId?: string) => void;
onCancel?: () => void;
isCancelling?: boolean;
workflowState?: "ready" | "running" | "completed" | "error" | "cancelled";
wasCancelled?: boolean;
checkpoints?: import("@/types").CheckpointItem[];
}
function getStateIcon(state: ExecutorState, isStreaming: boolean = true) {
switch (state) {
case "running":
return <Loader2 className={`w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] ${isStreaming ? '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 getMessageText(item: unknown): string {
const content = (item as { content?: Array<{ type: string; text?: string }> }).content;
return content
?.filter((content) => content.type === "output_text" && content.text)
.map((content) => content.text)
.join("\n") ?? "";
}
function ExecutorRunItem({
run,
isExpanded,
onToggle,
onClick,
isSelected,
isStreaming,
}: {
run: ExecutorRun;
isExpanded: boolean;
onToggle: () => void;
onClick: () => void;
isSelected: boolean;
isStreaming: 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, isStreaming)}</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,
pendingHilRequests = [],
hilResponses = {},
onHilResponseChange,
onHilSubmit,
isSubmittingHil = false,
// New props
inputSchema,
onRun,
onCancel,
isCancelling = false,
workflowState = "ready",
wasCancelled = false,
checkpoints = [],
}: 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);
const hilFormRef = 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 import("@/types/openai").ResponseOutputItemAddedEvent).item;
// Handle both executor_action items AND message items from Magentic agents
if (item && item.type === "executor_action" && "executor_id" in item && item.id) {
const executorId = String(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" && "metadata" in item && item.id) {
// Handle message items from Magentic agents
const metadata = item.metadata as {
agent_id?: string;
executor_id?: string;
source?: string;
workflow_output_kind?: string;
} | undefined;
if (metadata?.agent_id && metadata?.source === "magentic") {
const executorId = 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,
});
} else if (metadata?.executor_id && metadata.workflow_output_kind === "intermediate") {
const executorId = metadata.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: item.status === "completed" ? "completed" : "running",
output: itemOutputs[itemId] || getMessageText(item),
timestamp: uiTimestamp,
runNumber,
});
}
}
}
// Handle completion events
if (event.type === "response.output_item.done") {
const item = (event as import("@/types/openai").ResponseOutputItemDoneEvent).item;
// Handle both executor_action items AND message items from Magentic agents
if (item && item.type === "executor_action" && "executor_id" in item && 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" && "error" in item && item.error) {
existingRun.error = String(item.error);
}
}
} else if (item && item.type === "message" && "metadata" in item && item.id) {
// Handle message completion from Magentic agents
const metadata = item.metadata as {
agent_id?: string;
executor_id?: string;
source?: string;
workflow_output_kind?: string;
} | undefined;
if (metadata?.agent_id && metadata?.source === "magentic") {
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] || "";
}
} else if (metadata?.executor_id && metadata.workflow_output_kind === "intermediate") {
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] || getMessageText(item);
}
}
}
}
// 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 using the run counter for guaranteed uniqueness.
// Using uiTimestamp here caused collisions when the same executor ran
// twice within the same second (both fallback entries would share an ID).
const syntheticItemId = `fallback_${executorId}_run${runNumber}`;
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]);
// Auto-scroll when streaming ends to show final executor state
// (Ensures last executor's completion is visible, even if no workflow result)
useEffect(() => {
// Only scroll when streaming ends AND no HIL requests are pending
// (HIL has its own scroll handler below)
if (!isStreaming && executorRuns.length > 0 && pendingHilRequests.length === 0) {
setTimeout(() => {
timelineEndRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}, 100);
}
}, [isStreaming, pendingHilRequests.length, executorRuns.length]);
// Auto-scroll to HIL form when it appears
useEffect(() => {
if (pendingHilRequests.length > 0 && hilFormRef.current) {
// Small delay to ensure the form is rendered
setTimeout(() => {
hilFormRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
// Add highlight animation
hilFormRef.current?.classList.add('highlight-attention');
setTimeout(() => {
hilFormRef.current?.classList.remove('highlight-attention');
}, 1000);
}, 100);
}
}, [pendingHilRequests.length]);
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">
{isStreaming ? "Workflow is running..." : "Ready to run workflow"}
</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}
isStreaming={isStreaming}
/>
);
})}
{/* HIL Request Items */}
{pendingHilRequests.length > 0 && (
<div ref={hilFormRef} data-hil-form className="transition-all duration-300">
{pendingHilRequests.map((request) => (
<HilTimelineItem
key={request.request_id}
request={request}
response={hilResponses[request.request_id] || {}}
onResponseChange={(values) => onHilResponseChange?.(request.request_id, values)}
onSubmit={() => onHilSubmit?.()}
isSubmitting={isSubmittingHil}
/>
))}
</div>
)}
</>
)}
{/* Workflow final output card */}
{workflowResult && workflowResult.trim().length > 0 && !isStreaming && !wasCancelled && (
<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>
)}
{/* Workflow cancelled card */}
{wasCancelled && !isStreaming && (
<div className="border rounded-lg border-orange-500/40 bg-orange-500/5 dark:bg-orange-500/10">
<div className="px-4 py-3 flex items-center gap-2">
<Square className="w-4 h-4 text-orange-500 dark:text-orange-400 fill-current" />
<span className="font-medium text-sm text-orange-700 dark:text-orange-300">Execution stopped by user</span>
</div>
</div>
)}
{/* Invisible element at the end for scroll target */}
<div ref={timelineEndRef} />
</div>
</ScrollArea>
{/* Bottom Control Bar - Sticky (hidden when HIL is active) */}
{(onRun || onCancel) && pendingHilRequests.length === 0 && (
<div className="border-t p-3 bg-background flex-shrink-0">
{inputSchema && isChatMessageSchema(inputSchema) ? (
<ChatMessageInput
onSubmit={async (content: ResponseInputContent[]) => {
// Wrap in OpenAI message format (same as run-workflow-button modal)
const openaiInput = [
{ type: "message", role: "user", content },
];
onRun?.(openaiInput as unknown as Record<string, unknown>);
}}
isSubmitting={workflowState === "running"}
isStreaming={workflowState === "running"}
onCancel={onCancel}
isCancelling={isCancelling}
placeholder="Message workflow..."
showFileUpload={true}
entityName="workflow"
/>
) : (
<RunWorkflowButton
inputSchema={inputSchema}
onRun={onRun || (() => {})}
onCancel={onCancel}
isSubmitting={workflowState === "running"}
isCancelling={isCancelling}
workflowState={workflowState}
checkpoints={checkpoints}
showCheckpoints={false}
/>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,220 @@
import { memo, useState } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import {
Workflow,
Home,
Loader2,
ChevronRight,
ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { truncateText } from "@/utils/workflow-utils";
export type ExecutorState =
| "pending"
| "running"
| "completed"
| "failed"
| "cancelled";
export interface ExecutorNodeData extends Record<string, unknown> {
executorId: string;
executorType?: string;
name?: string;
state: ExecutorState;
inputData?: unknown;
outputData?: unknown;
error?: string;
isSelected?: boolean;
isStartNode?: boolean;
isEndNode?: boolean;
layoutDirection?: "LR" | "TB";
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void;
isStreaming?: boolean;
}
const getExecutorStateConfig = (state: ExecutorState) => {
switch (state) {
case "running":
return {
borderColor: "border-[#643FB2] dark:border-[#8B5CF6]",
glow: "shadow-lg shadow-[#643FB2]/20",
badgeColor: "bg-[#643FB2] dark:bg-[#8B5CF6]",
};
case "completed":
return {
borderColor: "border-green-500 dark:border-green-400",
glow: "shadow-lg shadow-green-500/20",
badgeColor: "bg-green-500 dark:bg-green-400",
};
case "failed":
return {
borderColor: "border-red-500 dark:border-red-400",
glow: "shadow-lg shadow-red-500/20",
badgeColor: "bg-red-500 dark:bg-red-400",
};
case "cancelled":
return {
borderColor: "border-orange-500 dark:border-orange-400",
glow: "shadow-lg shadow-orange-500/20",
badgeColor: "bg-orange-500 dark:bg-orange-400",
};
case "pending":
default:
return {
borderColor: "border-gray-300 dark:border-gray-600",
glow: "",
badgeColor: "bg-gray-400 dark:bg-gray-500",
};
}
};
export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
const nodeData = data as ExecutorNodeData;
const config = getExecutorStateConfig(nodeData.state);
const [isOutputExpanded, setIsOutputExpanded] = useState(false);
const hasOutput = nodeData.outputData || nodeData.error;
const isRunning = nodeData.state === "running";
const shouldAnimate = isRunning && (nodeData.isStreaming ?? true); // Default to true for backwards compatibility
// Determine handle positions based on layout direction
const isVertical = nodeData.layoutDirection === "TB";
const targetPosition = isVertical ? Position.Top : Position.Left;
const sourcePosition = isVertical ? Position.Bottom : Position.Right;
// Helper to render output/error details when expanded
const renderDataDetails = () => {
if (nodeData.error && typeof nodeData.error === "string") {
const truncatedError = truncateText(nodeData.error, 200);
return (
<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 max-h-32 overflow-auto">
{truncatedError}
</div>
);
}
if (nodeData.outputData) {
try {
const outputStr =
typeof nodeData.outputData === "string"
? nodeData.outputData
: JSON.stringify(nodeData.outputData, null, 2);
return (
<div className="text-xs text-gray-700 dark:text-gray-300 bg-muted/50 p-2 rounded border max-h-32 overflow-auto">
<pre className="whitespace-pre-wrap font-mono">{outputStr}</pre>
</div>
);
} catch {
return (
<div className="text-xs text-gray-600 dark:text-gray-400 bg-muted/50 p-2 rounded border">
[Unable to display output]
</div>
);
}
}
return null;
};
return (
<div
className={cn(
"group relative w-64 bg-card dark:bg-card rounded border-2 transition-all duration-200",
config.borderColor,
selected ? "ring-2 ring-blue-500 ring-offset-2" : "",
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"
}}
/>
<Handle
type="source"
position={sourcePosition}
id="source"
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
<div className="p-3">
{/* Header with icon and title */}
<div className="flex items-start gap-3">
<div className="flex-shrink-0 relative">
{/* Icon container with dark background */}
<div className="w-10 h-10 rounded-lg bg-gray-900/90 dark:bg-gray-800/90 flex items-center justify-center">
{nodeData.isStartNode ? (
<Home className="w-5 h-5 text-[#643FB2] dark:text-[#8B5CF6]" />
) : (
<Workflow className="w-5 h-5 text-gray-300 dark:text-gray-400" />
)}
</div>
</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] ${shouldAnimate ? 'animate-spin' : ''} flex-shrink-0`} />
)}
</div>
{nodeData.executorType && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
{nodeData.executorType}
</p>
)}
</div>
</div>
{/* Collapsible output section */}
{hasOutput && (
<div className="mt-2 border-t border-border/50 pt-2">
<button
onClick={(e) => {
e.stopPropagation();
setIsOutputExpanded(!isOutputExpanded);
}}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-full"
>
{isOutputExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
<span>{nodeData.error ? "Show error" : "Show output"}</span>
</button>
{isOutputExpanded && (
<div className="mt-2">
{renderDataDetails()}
</div>
)}
</div>
)}
{/* Running animation overlay */}
{isRunning && (
<div className="absolute inset-0 rounded border-2 border-[#643FB2]/30 dark:border-[#8B5CF6]/30 animate-pulse pointer-events-none" />
)}
</div>
</div>
);
});
ExecutorNode.displayName = "ExecutorNode";
@@ -0,0 +1,153 @@
/**
* HilTimelineItem - Inline HIL request form for the ExecutionTimeline
* Shows HIL requests as part of the workflow execution flow
*/
import { useState } from "react";
import { Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { SchemaFormRenderer, validateSchemaForm } from "./schema-form-renderer";
import type { JSONSchemaProperty } from "@/types";
export interface HilRequest {
request_id: string;
request_data: Record<string, unknown>;
request_schema: JSONSchemaProperty;
}
interface HilTimelineItemProps {
request: HilRequest;
response: Record<string, unknown>;
onResponseChange: (values: Record<string, unknown>) => void;
onSubmit: () => void;
isSubmitting: boolean;
}
export function HilTimelineItem({
request,
response,
onResponseChange,
onSubmit,
isSubmitting,
}: HilTimelineItemProps) {
const [isExpanded, setIsExpanded] = useState(true);
const handleResponseChange = (values: Record<string, unknown>) => {
onResponseChange(values);
};
const isValid = validateSchemaForm(request.request_schema, response);
return (
<div className="relative group">
{/* Main content - removed icon and adjusted layout */}
<div>
{/* Content area - removed pb-4 padding */}
<div className="flex-1">
<div className="border border-orange-200 dark:border-orange-800 bg-orange-50/50 dark:bg-orange-950/20 overflow-hidden rounded-lg">
{/* Header */}
<div
className="px-4 py-3 bg-orange-100/50 dark:bg-orange-950/30 border-b border-orange-200 dark:border-orange-800 flex items-center justify-between cursor-pointer hover:bg-orange-100 dark:hover:bg-orange-950/40 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-orange-900 dark:text-orange-100">
Workflow needs your input
</span>
<Badge
variant="outline"
className="text-xs font-mono border-orange-300 dark:border-orange-700 text-orange-700 dark:text-orange-300"
>
{request.request_id.slice(0, 8)}
</Badge>
{!isExpanded && (
<span className="text-xs text-orange-600 dark:text-orange-400 animate-pulse">
Click to respond
</span>
)}
</div>
{isSubmitting && (
<Badge variant="secondary" className="animate-pulse">
Submitting...
</Badge>
)}
</div>
{/* Expanded content */}
{isExpanded && (
<div className="p-4 space-y-4">
{/* Request context - scrollable */}
{Object.keys(request.request_data).length > 0 && (
<div className="bg-white/60 dark:bg-gray-900/30 rounded-md p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Context
</p>
<div className="max-h-48 overflow-y-auto space-y-1 pr-2">
{Object.entries(request.request_data)
.filter(
([key]) =>
!["request_id", "source_executor_id"].includes(key)
)
.map(([key, value]) => (
<div key={key} className="text-sm">
<span className="font-medium text-muted-foreground">
{key}:
</span>{" "}
<span className="text-foreground break-all">
{typeof value === "object"
? JSON.stringify(value, null, 2)
: String(value)}
</span>
</div>
))}
</div>
</div>
)}
{/* Description hint */}
{request.request_schema?.description && (
<div className="text-sm text-muted-foreground bg-blue-50 dark:bg-blue-950/20 p-3 rounded-md border border-blue-200 dark:border-blue-800">
<p className="font-medium text-blue-900 dark:text-blue-100 mb-1">
What's needed:
</p>
<p className="text-blue-800 dark:text-blue-200">
{request.request_schema.description}
</p>
</div>
)}
{/* Input form */}
<div className="space-y-3">
<SchemaFormRenderer
schema={request.request_schema}
values={response}
onChange={handleResponseChange}
/>
</div>
{/* Actions */}
<div className="space-y-2 pt-2">
<Button
size="default"
onClick={onSubmit}
disabled={!isValid || isSubmitting}
className="w-full gap-2"
>
<Send className="w-4 h-4" />
Submit Response
</Button>
{!isValid && (
<div className="text-xs text-muted-foreground text-center">
Please fill in all required fields
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,22 @@
/**
* Workflow Feature - Exports
*/
export { WorkflowView } from "./workflow-view";
export { WorkflowDetailsModal } from "./workflow-details-modal";
export { WorkflowFlow } from "./workflow-flow";
export { WorkflowInputForm } from "./workflow-input-form";
export { ExecutorNode } from "./executor-node";
export {
SchemaFormRenderer,
validateSchemaForm,
filterEmptyOptionalFields,
resolveSchemaType,
isShortField,
shouldFieldBeTextarea,
getFieldColumnSpan,
detectChatMessagePattern,
} from "./schema-form-renderer";
export { CheckpointInfoModal } from "./checkpoint-info-modal";
export { RunWorkflowButton } from "./run-workflow-button";
export type { RunWorkflowButtonProps } from "./run-workflow-button";
@@ -0,0 +1,427 @@
/**
* RunWorkflowButton - Shared component for running workflows with checkpoint support
* Features: Split button with dropdown for checkpoint selection, input validation, modal dialog
*/
import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { WorkflowInputForm } from "./workflow-input-form";
import { ChatMessageInput } from "@/components/ui/chat-message-input";
import { isChatMessageSchema } from "@/utils/workflow-utils";
import {
ChevronDown,
Clock,
Loader2,
Play,
RotateCcw,
Settings,
Square,
RefreshCw,
} from "lucide-react";
import type { JSONSchemaProperty, CheckpointItem } from "@/types";
import type { ResponseInputContent } from "@/types/agent-framework";
export interface RunWorkflowButtonProps {
inputSchema?: JSONSchemaProperty;
onRun: (data: Record<string, unknown>, checkpointId?: string) => void;
onCancel?: () => void;
isSubmitting: boolean;
isCancelling?: boolean;
workflowState: "ready" | "running" | "completed" | "error" | "cancelled";
checkpoints?: CheckpointItem[];
// Optional prop to control whether to show checkpoints dropdown
showCheckpoints?: boolean;
}
export function RunWorkflowButton({
inputSchema,
onRun,
onCancel,
isSubmitting,
isCancelling = false,
workflowState,
checkpoints = [],
showCheckpoints = true,
}: RunWorkflowButtonProps) {
const [showModal, setShowModal] = useState(false);
// Handle escape key to close modal
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && showModal) {
setShowModal(false);
}
};
if (showModal) {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}
}, [showModal]);
// Analyze input requirements
const inputAnalysis = useMemo(() => {
// Check if this is a Message schema (for AgentExecutor workflows)
const isChatMessage = isChatMessageSchema(inputSchema);
if (!inputSchema)
return {
needsInput: false,
hasDefaults: false,
fieldCount: 0,
canRunDirectly: true,
isChatMessage: false,
};
if (inputSchema.type === "string") {
return {
needsInput: !inputSchema.default,
hasDefaults: !!inputSchema.default,
fieldCount: 1,
canRunDirectly: !!inputSchema.default,
isChatMessage: false,
};
}
if (inputSchema.type === "object" && inputSchema.properties) {
const properties = inputSchema.properties;
const fields = Object.entries(properties);
const fieldsWithDefaults = fields.filter(
([, schema]: [string, JSONSchemaProperty]) =>
schema.default !== undefined ||
(schema.enum && schema.enum.length > 0)
);
return {
needsInput: fields.length > 0,
hasDefaults: fieldsWithDefaults.length > 0,
fieldCount: fields.length,
canRunDirectly:
fields.length === 0 || fieldsWithDefaults.length === fields.length,
isChatMessage,
};
}
return {
needsInput: false,
hasDefaults: false,
fieldCount: 0,
canRunDirectly: true,
isChatMessage: false,
};
}, [inputSchema]);
const handleDirectRun = () => {
if (workflowState === "running" && onCancel) {
onCancel();
} else if (inputAnalysis.canRunDirectly) {
// Build default data
const defaultData: Record<string, unknown> = {};
if (inputSchema?.type === "string" && inputSchema.default) {
defaultData.input = inputSchema.default;
} else if (inputSchema?.type === "object" && inputSchema.properties) {
Object.entries(inputSchema.properties).forEach(
([key, schema]: [string, JSONSchemaProperty]) => {
if (schema.default !== undefined) {
defaultData[key] = schema.default;
} else if (schema.enum && schema.enum.length > 0) {
defaultData[key] = schema.enum[0];
}
}
);
}
onRun(defaultData);
} else {
setShowModal(true);
}
};
const handleRunFromCheckpoint = (checkpointId: string) => {
if (inputAnalysis.canRunDirectly) {
// Build default data
const defaultData: Record<string, unknown> = {};
if (inputSchema?.type === "string" && inputSchema.default) {
defaultData.input = inputSchema.default;
} else if (inputSchema?.type === "object" && inputSchema.properties) {
Object.entries(inputSchema.properties).forEach(
([key, schema]: [string, JSONSchemaProperty]) => {
if (schema.default !== undefined) {
defaultData[key] = schema.default;
} else if (schema.enum && schema.enum.length > 0) {
defaultData[key] = schema.enum[0];
}
}
);
}
onRun(defaultData, checkpointId);
} else {
// TODO: Pass checkpoint ID to modal for custom inputs
setShowModal(true);
}
};
const hasCheckpoints = showCheckpoints && checkpoints.length > 0;
// Format checkpoint size for display
const formatSize = (bytes?: number): string => {
if (!bytes) return "";
const kb = bytes / 1024;
if (kb < 1) {
return `${bytes} B`;
} else if (kb < 1024) {
return `${kb.toFixed(1)} KB`;
} else {
return `${(kb / 1024).toFixed(1)} MB`;
}
};
// Build the button content based on state
const getButtonContent = () => {
const icon = isCancelling ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : workflowState === "running" && onCancel ? (
<Square className="w-4 h-4 fill-current" />
) : workflowState === "running" ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : workflowState === "error" ? (
<RotateCcw className="w-4 h-4" />
) : inputAnalysis.needsInput && !inputAnalysis.canRunDirectly ? (
<Settings className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
);
const text = isCancelling
? "Stopping..."
: workflowState === "running" && onCancel
? "Stop"
: workflowState === "running"
? "Running..."
: workflowState === "completed"
? "Run Again"
: workflowState === "error"
? "Retry"
: inputAnalysis.fieldCount === 0
? "Run Workflow"
: inputAnalysis.canRunDirectly
? "Run Workflow"
: "Configure & Run";
return { icon, text };
};
const { icon, text } = getButtonContent();
const isDisabled = (workflowState === "running" && !onCancel) || isCancelling;
const buttonVariant = workflowState === "error" ? "destructive" : "default";
// Unified layout for both variants
const renderButton = () => {
// Always show split button if there are checkpoints OR if inputs need configuration
const showDropdown = hasCheckpoints || inputAnalysis.needsInput;
if (!showDropdown) {
// Simple button - no dropdown needed
return (
<Button
onClick={handleDirectRun}
disabled={isDisabled}
variant={buttonVariant}
className="gap-2 w-full"
title={
workflowState === "running" && onCancel
? "Stop workflow execution"
: undefined
}
>
{icon}
{text}
</Button>
);
}
// Split button with dropdown
return (
<DropdownMenu>
<div className="flex w-full">
<Button
onClick={handleDirectRun}
disabled={isDisabled}
variant={buttonVariant}
className="gap-2 rounded-r-none flex-1"
title={
workflowState === "running" && onCancel
? "Stop workflow execution"
: undefined
}
>
{icon}
{text}
</Button>
<DropdownMenuTrigger asChild>
<Button
disabled={isDisabled}
variant={buttonVariant}
className="rounded-l-none border-l-0 px-2"
title="More options"
>
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent
align="end"
className="w-80 max-h-[400px] overflow-y-auto"
>
{/* Run Fresh option - only show when checkpoints are enabled */}
{hasCheckpoints && (
<DropdownMenuItem onClick={handleDirectRun}>
<Play className="w-4 h-4 mr-2" />
Run Fresh
</DropdownMenuItem>
)}
{/* Configure inputs option */}
{inputAnalysis.needsInput && (
<DropdownMenuItem onClick={() => setShowModal(true)}>
<Settings className="w-4 h-4 mr-2" />
Configure Inputs
</DropdownMenuItem>
)}
{/* Checkpoint options */}
{hasCheckpoints && (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-xs text-muted-foreground">
Resume from checkpoint
</div>
{checkpoints.map((checkpoint, index) => (
<DropdownMenuItem
key={checkpoint.checkpoint_id}
onClick={() =>
handleRunFromCheckpoint(checkpoint.checkpoint_id)
}
className="flex flex-col items-start py-2"
>
<div className="flex items-center gap-2 w-full">
<RefreshCw className="w-4 h-4 flex-shrink-0" />
<span className="font-medium">
{checkpoint.metadata.iteration_count === 0
? "Initial State"
: `Step ${checkpoint.metadata.iteration_count}`}
</span>
{index === 0 && (
<Badge
variant="secondary"
className="text-[10px] h-4 px-1 ml-auto"
>
Latest
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground ml-6 mt-0.5">
<Clock className="w-3 h-3" />
<span>
{new Date(checkpoint.timestamp).toLocaleTimeString()}
</span>
{checkpoint.metadata.size_bytes && (
<>
<span></span>
<span>
{formatSize(checkpoint.metadata.size_bytes)}
</span>
</>
)}
</div>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
};
return (
<>
{renderButton()}
{/* Modal for input configuration */}
{inputSchema && (
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="w-full min-w-[400px] max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
<DialogHeader className="px-8 pt-6">
<DialogTitle>Configure Workflow Inputs</DialogTitle>
<DialogClose onClose={() => setShowModal(false)} />
</DialogHeader>
<div className="px-8 py-4 border-b flex-shrink-0">
<div className="text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span className="font-medium">Input Type:</span>
<Badge variant="secondary">
{inputAnalysis.isChatMessage
? "Chat Message"
: inputSchema.type === "string"
? "Simple Text"
: "Structured Data"}
</Badge>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto py-4 px-8">
{inputAnalysis.isChatMessage ? (
<ChatMessageInput
onSubmit={async (content: ResponseInputContent[]) => {
// Wrap in OpenAI message format (same structure as agent-view)
// This preserves multimodal content (images, files) for the backend
const openaiInput = [
{ type: "message", role: "user", content },
];
onRun(openaiInput as unknown as Record<string, unknown>);
setShowModal(false);
}}
isSubmitting={isSubmitting}
placeholder="Enter your message..."
entityName="workflow"
showFileUpload={true}
/>
) : (
<WorkflowInputForm
inputSchema={inputSchema}
inputTypeName="Input"
onSubmit={(values) => {
onRun(values as Record<string, unknown>);
setShowModal(false);
}}
isSubmitting={isSubmitting}
className="embedded"
/>
)}
</div>
</DialogContent>
</Dialog>
)}
</>
);
}
@@ -0,0 +1,604 @@
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 Helpers (exported for reuse)
// ============================================================================
export 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());
}
// Helper: Resolve anyOf/oneOf union types to get the primary type
// Pydantic generates these for Optional[T] as: anyOf: [{type: T}, {type: "null"}]
export function resolveSchemaType(schema: JSONSchemaProperty): JSONSchemaProperty {
// If schema has a direct type, return as-is
if (schema.type) {
return schema;
}
// Handle anyOf (common for Optional[T])
if (schema.anyOf && schema.anyOf.length > 0) {
// Filter out null type and get the first non-null type
const nonNullTypes = schema.anyOf.filter(
(s) => s.type !== "null" && s.type !== undefined
);
if (nonNullTypes.length > 0) {
// Merge the resolved type with original schema's metadata (default, description, etc.)
return {
...nonNullTypes[0],
default: schema.default ?? nonNullTypes[0].default,
description: schema.description ?? nonNullTypes[0].description,
title: schema.title ?? nonNullTypes[0].title,
};
}
}
// Handle oneOf similarly
if (schema.oneOf && schema.oneOf.length > 0) {
const nonNullTypes = schema.oneOf.filter(
(s) => s.type !== "null" && s.type !== undefined
);
if (nonNullTypes.length > 0) {
return {
...nonNullTypes[0],
default: schema.default ?? nonNullTypes[0].default,
description: schema.description ?? nonNullTypes[0].description,
title: schema.title ?? nonNullTypes[0].title,
};
}
}
// Fallback: return original schema (will render as JSON textarea)
return schema;
}
export function shouldFieldBeTextarea(
fieldName: string,
schema: JSONSchemaProperty
): boolean {
return (
schema.format === "textarea" ||
(!!schema.description && schema.description.length > 100) ||
(schema.type === "string" && !schema.enum && !isShortField(fieldName))
);
}
export 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 "";
}
// ============================================================================
// Message Pattern Detection (exported for reuse)
// ============================================================================
export 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 (internal - used by SchemaFormRenderer)
// ============================================================================
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: rawSchema,
value,
onChange,
isRequired = false,
isReadOnly = false,
}: FormFieldProps) {
// Resolve anyOf/oneOf union types (e.g., Optional[int] → int)
const schema = resolveSchemaType(rawSchema);
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[]; // Fields to display but not edit (for HIL)
hideFields?: string[]; // Fields to completely hide
showCollapsedByDefault?: boolean; // Control initial collapsed state
layout?: "stack" | "grid"; // Layout mode: "stack" (vertical) or "grid" (responsive 4-col)
}
export function SchemaFormRenderer({
schema,
values,
onChange,
disabled = false,
readOnlyFields = [],
hideFields = [],
showCollapsedByDefault = false,
layout = "stack",
}: SchemaFormRendererProps) {
const [showAdvancedFields, setShowAdvancedFields] = useState(
showCollapsedByDefault
);
// Container class based on layout mode
const containerClass =
layout === "grid"
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
: "space-y-3";
const properties = schema.properties || {};
const allFieldNames = Object.keys(properties).filter(
(name) => !hideFields.includes(name)
);
const requiredFields = (schema.required || []).filter(
(name) => !hideFields.includes(name)
);
// Detect Message 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 Message: 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,
});
};
// Full-width class for separator and toggle button in grid mode
const fullWidthClass =
layout === "grid" ? "md:col-span-2 lg:col-span-3 xl:col-span-4" : "";
return (
<div className={containerClass}>
{/* 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={fullWidthClass}>
<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={fullWidthClass}>
<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;
}
@@ -0,0 +1,56 @@
import { memo, type CSSProperties } from "react";
import { BaseEdge, useInternalNode } from "@xyflow/react";
interface SelfLoopEdgeProps {
id: string;
source: string;
markerEnd?: string;
style?: CSSProperties;
}
/**
* Custom edge for self-referencing nodes. Renders a bezier loop from output back to input.
*/
export const SelfLoopEdge = memo(function SelfLoopEdge({
id,
source,
markerEnd,
style,
}: SelfLoopEdgeProps) {
const sourceNode = useInternalNode(source);
if (!sourceNode) return null;
const { width, height } = sourceNode.measured;
const { x, y } = sourceNode.internals.positionAbsolute;
if (!width || !height) return null;
const nodeData = sourceNode.data as Record<string, unknown> | undefined;
const isVertical = nodeData?.layoutDirection === "TB";
const loopOffset = 100;
const riseOffset = 40;
let edgePath: string;
if (isVertical) {
// TB: bottom center → curves right → top center
const startX = x + width / 2;
const startY = y + height;
const endX = x + width / 2;
const endY = y;
const cpX = x + width + loopOffset;
edgePath = `M ${startX} ${startY} C ${startX} ${startY + riseOffset}, ${cpX} ${startY + riseOffset}, ${cpX} ${y + height / 2} C ${cpX} ${endY - riseOffset}, ${endX} ${endY - riseOffset}, ${endX} ${endY}`;
} else {
// LR: right center → curves down → left center
const startX = x + width;
const startY = y + height / 2;
const endX = x;
const endY = y + height / 2;
const cpY = y + height + loopOffset;
edgePath = `M ${startX} ${startY} C ${startX + riseOffset} ${startY}, ${startX + riseOffset} ${cpY}, ${x + width / 2} ${cpY} C ${endX - riseOffset} ${cpY}, ${endX - riseOffset} ${endY}, ${endX} ${endY}`;
}
return <BaseEdge id={id} path={edgePath} markerEnd={markerEnd} style={style} />;
});
@@ -0,0 +1,169 @@
/**
* 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 truncate"
title={executor}
>
{executor}
</div>
))}
</div>
) : (
<div className="text-muted-foreground">No executors configured</div>
)}
</DetailCard>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,592 @@
import { useMemo, useCallback, useEffect, memo } from "react";
import {
MoreVertical,
Map,
Grid3X3,
RotateCcw,
Maximize,
Shuffle,
Zap,
ArrowDown,
ArrowLeftRight,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
useReactFlow,
BackgroundVariant,
type NodeTypes,
type Node,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { ExecutorNode, type ExecutorNodeData } from "./executor-node";
import { SelfLoopEdge } from "./self-loop-edge";
import {
convertWorkflowDumpToNodes,
convertWorkflowDumpToEdges,
applyDagreLayout,
processWorkflowEvents,
updateNodesWithEvents,
updateEdgesWithSequenceAnalysis,
consolidateBidirectionalEdges,
type NodeUpdate,
} from "@/utils/workflow-utils";
import type { ExtendedResponseStreamEvent } from "@/types";
import type { Workflow } from "@/types/workflow";
const nodeTypes: NodeTypes = {
executor: ExecutorNode,
};
const edgeTypes = {
selfLoop: SelfLoopEdge,
};
// ViewOptions panel component that renders inside ReactFlow
function ViewOptionsPanel({
workflowDump,
onNodeSelect,
viewOptions,
onToggleViewOption,
layoutDirection,
onLayoutDirectionChange,
}: {
workflowDump?: Workflow;
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
viewOptions: {
showMinimap: boolean;
showGrid: boolean;
animateRun: boolean;
consolidateBidirectionalEdges: boolean;
};
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
layoutDirection: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
}) {
const { fitView, setViewport, setNodes } = useReactFlow();
const handleResetZoom = () => {
setViewport({ x: 0, y: 0, zoom: 1 });
};
const handleFitToScreen = () => {
fitView({ padding: 0.2 });
};
const handleAutoArrange = () => {
if (!workflowDump) return;
const currentNodes = convertWorkflowDumpToNodes(
workflowDump,
onNodeSelect,
layoutDirection
);
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
const layoutedNodes = applyDagreLayout(
currentNodes,
currentEdges,
layoutDirection
);
setNodes(layoutedNodes);
};
return (
<div className="absolute top-4 right-4 z-10">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0 bg-white/90 backdrop-blur-sm border-gray-200 shadow-sm hover:bg-white dark:bg-gray-800/90 dark:border-gray-600 dark:hover:bg-gray-800"
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">View options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("showMinimap")}
>
<div className="flex items-center">
<Map className="mr-2 h-4 w-4" />
Show Minimap
</div>
<Checkbox checked={viewOptions.showMinimap} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("showGrid")}
>
<div className="flex items-center">
<Grid3X3 className="mr-2 h-4 w-4" />
Show Grid
</div>
<Checkbox checked={viewOptions.showGrid} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("animateRun")}
>
<div className="flex items-center">
<Zap className="mr-2 h-4 w-4" />
Animate Run
</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"
onClick={() => {
const newDirection = layoutDirection === "LR" ? "TB" : "LR";
onLayoutDirectionChange?.(newDirection);
// Re-apply layout with new direction
if (workflowDump) {
const currentNodes = convertWorkflowDumpToNodes(
workflowDump,
onNodeSelect,
newDirection
);
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
const layoutedNodes = applyDagreLayout(
currentNodes,
currentEdges,
newDirection
);
setNodes(layoutedNodes);
}
}}
>
<div className="flex items-center">
<ArrowDown className="mr-2 h-4 w-4" />
Vertical Layout
</div>
<Checkbox checked={layoutDirection === "TB"} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleResetZoom}>
<RotateCcw className="mr-2 h-4 w-4" />
Reset Zoom
</DropdownMenuItem>
<DropdownMenuItem onClick={handleFitToScreen}>
<Maximize className="mr-2 h-4 w-4" />
Fit to Screen
</DropdownMenuItem>
<DropdownMenuItem onClick={handleAutoArrange}>
<Shuffle className="mr-2 h-4 w-4" />
Auto-arrange
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
interface WorkflowFlowProps {
workflowDump?: Workflow;
events: ExtendedResponseStreamEvent[];
isStreaming: boolean;
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
className?: string;
viewOptions?: {
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
function WorkflowAnimationHandler({
nodes,
nodeUpdates,
isStreaming,
animateRun,
}: {
nodes: Node<ExecutorNodeData>[];
nodeUpdates: Record<string, NodeUpdate>;
isStreaming: boolean;
animateRun: boolean;
}) {
const { fitView } = useReactFlow();
// Smooth animation to center on running node when workflow starts/progresses
useEffect(() => {
if (!animateRun) return;
if (isStreaming) {
// Zoom in on running nodes during execution
const runningNodes = nodes.filter(
(node) => node.data.state === "running"
);
if (runningNodes.length > 0) {
const targetNode = runningNodes[0];
// Use fitView to smoothly focus on the running node with animation
fitView({
nodes: [targetNode],
duration: 800,
padding: 0.3,
minZoom: 0.8,
maxZoom: 1.5,
});
}
} else if (nodes.length > 0) {
// Zoom back out to show full workflow when execution completes
fitView({
duration: 1000,
padding: 0.2,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeUpdates, isStreaming, animateRun, nodes]);
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,
},
onToggleViewOption,
layoutDirection = "TB",
onLayoutDirectionChange,
timelineVisible = false,
}: WorkflowFlowProps) {
// Create initial nodes and edges from workflow dump
const { initialNodes, initialEdges } = useMemo(() => {
if (!workflowDump) {
return { initialNodes: [], initialEdges: [] };
}
const nodes = convertWorkflowDumpToNodes(
workflowDump,
onNodeSelect,
layoutDirection
);
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)
: nodes;
return {
initialNodes: layoutedNodes,
initialEdges: finalEdges,
};
}, [
workflowDump,
onNodeSelect,
layoutDirection,
viewOptions.consolidateBidirectionalEdges,
]);
const [nodes, setNodes, onNodesChange] =
useNodesState<Node<ExecutorNodeData>>(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
// Process events and update node/edge states
const nodeUpdates = useMemo(() => {
return processWorkflowEvents(events, workflowDump?.start_executor_id);
}, [events, workflowDump?.start_executor_id]);
// Update nodes and edges with real-time state from events
useMemo(() => {
if (Object.keys(nodeUpdates).length > 0) {
setNodes((currentNodes) =>
updateNodesWithEvents(currentNodes, nodeUpdates, isStreaming)
);
} else if (events.length === 0) {
// Reset all nodes to pending state when events are cleared
setNodes((currentNodes) =>
currentNodes.map((node) => ({
...node,
data: {
...node.data,
state: "pending" as const,
outputData: undefined,
error: undefined,
isStreaming: false,
},
}))
);
}
}, [nodeUpdates, setNodes, events.length, isStreaming]);
// Update edges with sequence-based analysis (separate from nodeUpdates)
useMemo(() => {
if (events.length > 0) {
setEdges((currentEdges) => {
const updatedEdges = updateEdgesWithSequenceAnalysis(
currentEdges,
events
);
// Apply consolidation if enabled (preserves updated styling from sequence analysis)
return viewOptions.consolidateBidirectionalEdges
? consolidateBidirectionalEdges(updatedEdges)
: updatedEdges;
});
} else {
// Reset all edges to default state when events are cleared
setEdges((currentEdges) => {
const resetEdges = currentEdges.map((edge) => ({
...edge,
animated: false,
style: {
stroke: "#6b7280", // Gray
strokeWidth: 2,
},
}));
// Apply consolidation if enabled
return viewOptions.consolidateBidirectionalEdges
? consolidateBidirectionalEdges(resetEdges)
: resetEdges;
});
}
}, [events, setEdges, viewOptions.consolidateBidirectionalEdges]);
// Initialize nodes and edges when workflow structure OR consolidation setting changes
useEffect(() => {
if (initialNodes.length > 0) {
setNodes(initialNodes);
setEdges(initialEdges);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflowDump, viewOptions.consolidateBidirectionalEdges]); // Re-initialize when workflow or consolidation toggle changes
const onNodeClick = useCallback(
(event: React.MouseEvent, node: Node<ExecutorNodeData>) => {
event.stopPropagation();
onNodeSelect?.(node.data.executorId, node.data);
},
[onNodeSelect]
);
if (!workflowDump) {
return (
<div
className={`flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 ${className}`}
>
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="text-lg font-medium mb-2">No Workflow Data</div>
<div className="text-sm">Workflow dump is not available.</div>
</div>
</div>
);
}
if (initialNodes.length === 0) {
return (
<div
className={`flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 ${className}`}
>
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="text-lg font-medium mb-2">No Executors Found</div>
<div className="text-sm">
Could not extract executors from workflow dump.
</div>
<details className="mt-2 text-xs">
<summary className="cursor-pointer">Debug Info</summary>
<pre className="mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded text-left overflow-auto">
{JSON.stringify(workflowDump, null, 2)}
</pre>
</details>
</div>
</div>
);
}
return (
<div className={`h-full w-full ${className}`}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={1.5}
defaultEdgeOptions={{
type: "default",
animated: false,
style: { stroke: "#6b7280", strokeWidth: 2 },
}}
nodesDraggable={!isStreaming} // Disable dragging during execution
nodesConnectable={false} // Disable connecting nodes
elementsSelectable={true}
proOptions={{ hideAttribution: true }}
>
{viewOptions.showGrid && (
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="#e5e7eb"
className="dark:opacity-30"
/>
)}
<Controls
position="bottom-left"
showInteractive={false}
style={{
backgroundColor: "rgba(255, 255, 255, 0.9)",
border: "1px solid #e5e7eb",
borderRadius: "3px",
}}
className="dark:!bg-gray-800/90 dark:!border-gray-600"
/>
{viewOptions.showMinimap && (
<MiniMap
nodeColor={(node: Node) => {
const data = node.data as ExecutorNodeData;
const state = data?.state;
switch (state) {
case "running":
return "#643FB2";
case "completed":
return "#10b981";
case "failed":
return "#ef4444";
case "cancelled":
return "#f97316";
default:
return "#6b7280";
}
}}
maskColor="rgba(0, 0, 0, 0.1)"
position="bottom-right"
style={{
backgroundColor: "rgba(255, 255, 255, 0.9)",
border: "1px solid #e5e7eb",
borderRadius: "8px",
}}
className="dark:!bg-gray-800/90 dark:!border-gray-600"
/>
)}
<WorkflowAnimationHandler
nodes={nodes}
nodeUpdates={nodeUpdates}
isStreaming={isStreaming}
animateRun={viewOptions.animateRun}
/>
<TimelineResizeHandler timelineVisible={timelineVisible} />
<ViewOptionsPanel
workflowDump={workflowDump}
onNodeSelect={onNodeSelect}
viewOptions={viewOptions}
onToggleViewOption={onToggleViewOption}
layoutDirection={layoutDirection}
onLayoutDirectionChange={onLayoutDirectionChange}
/>
</ReactFlow>
{/* CSS for custom edge animations and dark theme controls */}
<style>{`
.react-flow__edge-path {
transition: stroke 0.3s ease, stroke-width 0.3s ease;
}
.react-flow__edge.animated .react-flow__edge-path {
stroke-dasharray: 5 5;
animation: dash 1s linear infinite;
}
@keyframes dash {
0% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -10; }
}
/* Dark theme styles for React Flow controls */
.dark .react-flow__controls {
background-color: rgba(31, 41, 55, 0.9) !important;
border-color: rgb(75, 85, 99) !important;
}
.dark .react-flow__controls-button {
background-color: rgba(31, 41, 55, 0.9) !important;
border-color: rgb(75, 85, 99) !important;
color: rgb(229, 231, 235) !important;
}
.dark .react-flow__controls-button:hover {
background-color: rgba(55, 65, 81, 0.9) !important;
color: rgb(255, 255, 255) !important;
}
.dark .react-flow__controls-button svg {
fill: rgb(229, 231, 235) !important;
}
.dark .react-flow__controls-button:hover svg {
fill: rgb(255, 255, 255) !important;
}
`}</style>
</div>
);
});
@@ -0,0 +1,289 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
DialogFooter,
} from "@/components/ui/dialog";
import { Send } from "lucide-react";
import { cn } from "@/lib/utils";
import type { JSONSchemaProperty } from "@/types";
import {
SchemaFormRenderer,
filterEmptyOptionalFields,
detectChatMessagePattern,
} from "./schema-form-renderer";
interface WorkflowInputFormProps {
inputSchema: JSONSchemaProperty;
inputTypeName: string;
onSubmit: (formData: unknown) => void;
isSubmitting?: boolean;
className?: string;
}
export function WorkflowInputForm({
inputSchema,
inputTypeName,
onSubmit,
isSubmitting = false,
className,
}: WorkflowInputFormProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
// Check if we're in embedded mode (being used inside another modal)
const isEmbedded = className?.includes("embedded");
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(false);
// Determine field info
const properties = inputSchema.properties || {};
const fieldNames = Object.keys(properties);
const requiredFields = inputSchema.required || [];
const isSimpleInput = inputSchema.type === "string" && !inputSchema.enum;
// Detect Message-like pattern for auto-filling role
const isChatMessageLike = detectChatMessagePattern(inputSchema, requiredFields);
// Validation: check if required fields are filled
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 with defaults
useEffect(() => {
if (inputSchema.type === "string") {
setFormData({ value: inputSchema.default || "" });
} else if (inputSchema.type === "object" && inputSchema.properties) {
const initialData: Record<string, unknown> = {};
Object.entries(inputSchema.properties).forEach(([key, fieldSchema]) => {
if (fieldSchema.default !== undefined) {
initialData[key] = fieldSchema.default;
} else if (fieldSchema.enum && fieldSchema.enum.length > 0) {
initialData[key] = fieldSchema.enum[0];
}
});
// Auto-fill role="user" for Message-like inputs
if (isChatMessageLike && !initialData["role"]) {
initialData["role"] = "user";
}
setFormData(initialData);
}
}, [inputSchema, isChatMessageLike]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
// Simplified submission logic
if (inputSchema.type === "string") {
onSubmit({ input: formData.value || "" });
} else if (inputSchema.type === "object") {
const properties = inputSchema.properties || {};
const fieldNames = Object.keys(properties);
if (fieldNames.length === 1) {
const fieldName = fieldNames[0];
onSubmit({ [fieldName]: formData[fieldName] || "" });
} else {
// Filter out empty optional fields before submission
const filteredData = filterEmptyOptionalFields(inputSchema, formData);
onSubmit(filteredData);
}
} else {
onSubmit(formData);
}
// Only close modal if not embedded
if (!isEmbedded) {
setIsModalOpen(false);
}
setLoading(false);
};
const handleFormChange = (newValues: Record<string, unknown>) => {
setFormData(newValues);
};
// Simple string input renderer (for non-object schemas)
const renderSimpleInput = () => (
<div className="space-y-2">
<Label htmlFor="simple-input">Input</Label>
<Textarea
id="simple-input"
value={typeof formData.value === "string" ? formData.value : ""}
onChange={(e) => setFormData({ value: e.target.value })}
placeholder={
typeof inputSchema.default === "string"
? inputSchema.default
: "Enter input"
}
rows={4}
className="min-w-[300px] w-full"
/>
{inputSchema.description && (
<p className="text-sm text-muted-foreground">{inputSchema.description}</p>
)}
</div>
);
// If embedded, just show the form directly
if (isEmbedded) {
return (
<form onSubmit={handleSubmit} className={className}>
{/* Simple input */}
{isSimpleInput && renderSimpleInput()}
{/* Complex form fields using SchemaFormRenderer */}
{!isSimpleInput && (
<SchemaFormRenderer
schema={inputSchema}
values={formData}
onChange={handleFormChange}
disabled={loading}
hideFields={isChatMessageLike ? ["role"] : []}
layout="grid"
/>
)}
<div className="flex gap-2 mt-4 justify-end">
<Button type="submit" disabled={loading || !canSubmit} size="default">
<Send className="h-4 w-4" />
{loading ? "Running..." : "Run Workflow"}
</Button>
</div>
</form>
);
}
return (
<>
{/* Sidebar Form Component */}
<div className={cn("flex flex-col", className)}>
{/* Header with Run Button */}
<div className="border-b border-border px-4 py-3 bg-muted">
<CardTitle className="text-sm mb-3">Run Workflow</CardTitle>
{/* Run Button - Opens Modal */}
<Button
onClick={() => setIsModalOpen(true)}
disabled={isSubmitting}
className="w-full"
size="default"
>
<Send className="h-4 w-4 mr-2" />
{isSubmitting ? "Running..." : "Run Workflow"}
</Button>
</div>
{/* Info Section */}
<div className="px-4 py-3">
<div className="text-sm text-muted-foreground">
<strong>Input Type:</strong>{" "}
<code className="bg-muted px-1 py-0.5 rounded">
{inputTypeName}
</code>
{inputSchema.type === "object" && inputSchema.properties && (
<span className="ml-2">
({Object.keys(inputSchema.properties).length} field
{Object.keys(inputSchema.properties).length !== 1 ? "s" : ""})
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-2">
Click "Run Workflow" to configure inputs and execute
</p>
</div>
</div>
{/* Modal with the actual form */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="w-full max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Run Workflow</DialogTitle>
<DialogClose onClose={() => setIsModalOpen(false)} />
</DialogHeader>
{/* Form Info */}
<div className="px-8 py-4 border-b flex-shrink-0">
<div className="text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span className="font-medium">Input Type:</span>
<code className="bg-muted px-3 py-1 text-xs font-mono">
{inputTypeName}
</code>
{inputSchema.type === "object" && (
<span className="text-xs text-muted-foreground">
{fieldNames.length} field
{fieldNames.length !== 1 ? "s" : ""}
</span>
)}
</div>
</div>
</div>
{/* Scrollable Form Content */}
<div className="px-8 py-6 overflow-y-auto flex-1 min-h-0">
<form id="workflow-modal-form" onSubmit={handleSubmit}>
{/* Simple input */}
{isSimpleInput && renderSimpleInput()}
{/* Complex form fields using SchemaFormRenderer */}
{!isSimpleInput && (
<SchemaFormRenderer
schema={inputSchema}
values={formData}
onChange={handleFormChange}
disabled={loading}
hideFields={isChatMessageLike ? ["role"] : []}
layout="grid"
/>
)}
</form>
</div>
{/* Footer */}
<div className="px-8 py-4 border-t flex-shrink-0">
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
form="workflow-modal-form"
disabled={loading || !canSubmit}
>
<Send className="h-4 w-4 mr-2" />
{loading ? "Running..." : "Run Workflow"}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,225 @@
/**
* 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: `Checkpoint Storage ${new Date().toLocaleString()}`,
});
setAvailableSessions([newSession]);
setCurrentSession(newSession);
onSessionChange?.(newSession);
addToast({
message: "Default checkpoint storage 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: `Checkpoint Storage ${new Date().toLocaleString()}`,
});
addSession(newSession);
setCurrentSession(newSession);
onSessionChange?.(newSession);
addToast({
message: "New checkpoint storage created",
type: "success",
});
} catch (error) {
console.error("Failed to create checkpoint storage:", error);
addToast({
message: "Failed to create checkpoint storage",
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>
);
};
@@ -0,0 +1,111 @@
/**
* AppHeader - Global application header
* Features: Entity selection, global settings, theme toggle
*/
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 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;
isLoading?: boolean;
onSettingsClick?: () => void;
}
export function AppHeader({
agents,
workflows,
entities,
selectedItem,
onSelect,
onBrowseGallery,
isLoading = false,
onSettingsClick,
}: AppHeaderProps) {
const { oaiMode, serverVersion } = useDevUIStore();
return (
<header className="flex h-14 items-center gap-4 border-b px-4">
<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
{serverVersion && (
<span className="text-xs text-muted-foreground ml-1">
v{serverVersion}
</span>
)}
{/* 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>
<div className="flex items-center gap-2 ml-auto">
<ModeToggle />
<Button
variant="ghost"
size="sm"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onSettingsClick?.();
}}
>
<Settings className="h-4 w-4" />
</Button>
</div>
</header>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,861 @@
/**
* DeploymentModal - Shows Azure deployment instructions and Docker templates
* Features: Docker setup files, Azure Container Apps deployment guide
*/
import { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Rocket,
Container,
Cloud,
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";
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 [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(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
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);
setCopiedTemplate(templateName);
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout with cleanup
timeoutRef.current = setTimeout(() => {
setCopiedTemplate(null);
timeoutRef.current = null;
}, 2000);
} catch {
// Reset state on error - clipboard write failed
setCopiedTemplate(null);
}
};
const dockerfileTemplate = `# Dockerfile for ${agentName}
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy agent/workflow directories
COPY . .
# Expose DevUI default port
EXPOSE 8080
# Run DevUI server
CMD ["devui", ".", "--port", "8080", "--host", "0.0.0.0"]
`;
const dockerComposeTemplate = `# docker-compose.yml
version: '3.8'
services:
${agentName.toLowerCase().replace(/\s+/g, "-")}:
build: .
environment:
# OpenAI
- OPENAI_API_KEY=\${OPENAI_API_KEY}
- OPENAI_CHAT_COMPLETION_MODEL=\${OPENAI_CHAT_COMPLETION_MODEL:-gpt-4o-mini}
# Or Azure OpenAI
- AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY}
- AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT}
- AZURE_OPENAI_MODEL=\${AZURE_OPENAI_MODEL}
# Optional: Enable instrumentation
- ENABLE_INSTRUMENTATION=\${ENABLE_INSTRUMENTATION:-false}
ports:
- "8080:8080"
restart: unless-stopped
`;
const requirementsTemplate = `# requirements.txt
agent-framework-devui>=0.1.0
agent-framework>=0.1.0
# Chat clients (install what you need)
openai>=1.0.0
# azure-openai
# anthropic
`;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="w-[800px] max-w-[90vw]">
<DialogClose onClose={onClose} />
<DialogHeader className="p-6 pb-2">
<DialogTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5" />
Deploy {agentName}
</DialogTitle>
<p className="text-sm text-muted-foreground pt-1">
Get started with containerizing your agent for deployment.
</p>
</DialogHeader>
{/* Tabs */}
<div className="flex border-b px-6">
<button
onClick={() => setActiveTab("docker")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${activeTab === "docker"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Container className="h-4 w-4 mr-2 inline" />
Docker
{activeTab === "docker" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
{deploymentSupported && (
<button
onClick={() => setActiveTab("azure")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${activeTab === "azure"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Cloud className="h-4 w-4 mr-2 inline" />
Azure
{activeTab === "azure" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
)}
</div>
{/* Tab Content */}
<div className="px-6 pb-6 min-h-[400px]">
<ScrollArea className="h-[500px]">
<div className="pr-4">
{activeTab === "docker" && (
<div className="space-y-4 pt-4">
<div>
<h3 className="font-semibold mb-2">
Containerize with Docker
</h3>
<p className="text-sm text-muted-foreground">
Package your agent as a Docker container for consistent
deployment anywhere.
</p>
</div>
{/* Dockerfile */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Dockerfile</span>
<Button
size="sm"
variant="ghost"
onClick={() =>
handleCopy(dockerfileTemplate, "dockerfile")
}
>
{copiedTemplate === "dockerfile" ? (
<>
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
</div>
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
{dockerfileTemplate}
</pre>
</div>
{/* docker-compose.yml */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">
docker-compose.yml
</span>
<Button
size="sm"
variant="ghost"
onClick={() =>
handleCopy(dockerComposeTemplate, "compose")
}
>
{copiedTemplate === "compose" ? (
<>
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
</div>
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
{dockerComposeTemplate}
</pre>
</div>
{/* requirements.txt */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">
requirements.txt
</span>
<Button
size="sm"
variant="ghost"
onClick={() =>
handleCopy(requirementsTemplate, "requirements")
}
>
{copiedTemplate === "requirements" ? (
<>
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
</div>
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
{requirementsTemplate}
</pre>
</div>
{/* Quick Start */}
<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">Quick Start</h4>
<ol className="text-xs space-y-1 list-decimal list-inside text-muted-foreground">
<li>Save the files above to your project directory</li>
<li>
Build:{" "}
<code className="bg-muted px-1 rounded">
docker build -t {agentName.toLowerCase()}-agent .
</code>
</li>
<li>
Run:{" "}
<code className="bg-muted px-1 rounded">
docker-compose up
</code>
</li>
<li>Your agent is now running in a container!</li>
</ol>
</div>
{/* Production Warnings */}
<div className="bg-amber-50 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 rounded-md p-3">
<h4 className="text-sm font-semibold mb-2 text-amber-900 dark:text-amber-100">
Production Considerations
</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-amber-800 dark:text-amber-200">
<li>
<strong>In-memory state:</strong> Conversations are lost
when container restarts
</li>
<li>
<strong>No authentication:</strong> Add reverse proxy
(nginx, Caddy) with auth for production
</li>
<li>
<strong>Security:</strong> Use Azure Key Vault for
secrets management
</li>
<li>
<strong>Scaling:</strong> Single instance only due to
in-memory conversation store
</li>
</ul>
</div>
{/* Deployment Checklist */}
<div className="border-t pt-4">
<h4 className="font-semibold text-sm mb-3">
Pre-Deployment Checklist
</h4>
<div className="space-y-2 text-sm">
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
Set environment variables (API keys, secrets)
</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
Test agent locally in container
</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
Configure logging and monitoring
</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
Set up error handling and retries
</span>
</div>
</div>
</div>
</div>
)}
{activeTab === "azure" && (
<div className="space-y-4 pt-4">
<div>
<h3 className="font-semibold mb-2">
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."}
</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>
<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>
</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>
<div className="space-y-3">
{/* Step 1 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
1
</div>
<h5 className="font-medium text-sm">
Create Azure Container Registry
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`# Create resource group
az group create --name myResourceGroup --location eastus
# Create container registry
az acr create --resource-group myResourceGroup \\
--name myregistry --sku Basic`}
</pre>
</div>
{/* Step 2 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
2
</div>
<h5 className="font-medium text-sm">
Build and Push Docker Image
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`# Build and push in one command
az acr build --registry myregistry \\
--image ${agentName.toLowerCase()}-agent:latest .`}
</pre>
</div>
{/* Step 3 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
3
</div>
<h5 className="font-medium text-sm">
Create Container Apps Environment
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`az containerapp env create --name myEnvironment \\
--resource-group myResourceGroup \\
--location eastus`}
</pre>
</div>
{/* Step 4 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
4
</div>
<h5 className="font-medium text-sm">
Deploy Container App
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`az containerapp create --name ${agentName.toLowerCase()}-app \\
--resource-group myResourceGroup \\
--environment myEnvironment \\
--image myregistry.azurecr.io/${agentName.toLowerCase()}-agent:latest \\
--target-port 8080 \\
--ingress 'external' \\
--registry-server myregistry.azurecr.io \\
--env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_COMPLETION_MODEL=gpt-4o-mini`}
</pre>
</div>
{/* Step 5 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
5
</div>
<h5 className="font-medium text-sm">
Get Application URL
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`az containerapp show --name ${agentName.toLowerCase()}-app \\
--resource-group myResourceGroup \\
--query properties.configuration.ingress.fqdn`}
</pre>
</div>
</div>
</div>
{/* Learn More */}
<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">Learn More</h4>
<p className="text-xs text-muted-foreground mb-3">
Explore Azure Container Apps documentation for advanced
features like scaling, monitoring, and CI/CD integration.
</p>
<Button
size="sm"
variant="outline"
className="w-full"
asChild
>
<a
href="https://learn.microsoft.com/azure/container-apps/"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-3 w-3 mr-1" />
View Azure Container Apps Documentation
</a>
</Button>
</div>
</>
)}
</div>
)}
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,229 @@
/**
* EntitySelector - Dropdown for selecting agents/workflows
* Features: Loading states, descriptions, lazy loading indicators
*/
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ChevronDown, Bot, Workflow, Plus, Loader2 } from "lucide-react";
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;
isLoading?: boolean;
}
const getTypeIcon = (type: "agent" | "workflow") => {
return type === "workflow" ? Workflow : Bot;
};
export function EntitySelector({
agents,
workflows,
entities,
selectedItem,
onSelect,
onBrowseGallery,
isLoading = false,
}: EntitySelectorProps) {
const [open, setOpen] = useState(false);
// Use entities if provided (preserves backend order), otherwise combine agents and workflows
const allItems = entities || [...agents, ...workflows];
const handleSelect = (item: AgentInfo | WorkflowInfo) => {
onSelect(item);
setOpen(false);
};
const TypeIcon = selectedItem ? getTypeIcon(selectedItem.type) : Bot;
const displayName = selectedItem?.name || selectedItem?.id || "Select Agent or Workflow";
const isLoaded = selectedItem?.metadata?.lazy_loaded !== false;
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-64 justify-between font-mono text-sm"
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center gap-2">
<LoadingSpinner size="sm" />
<span className="text-muted-foreground">Loading...</span>
</div>
) : (
<>
<div className="flex items-center gap-2 min-w-0">
<TypeIcon className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{displayName}</span>
{selectedItem && !isLoaded && (
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin ml-auto flex-shrink-0" />
)}
</div>
<ChevronDown className="h-4 w-4 opacity-50" />
</>
)}
</Button>
</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>
)}
</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>
)}
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
{/* Show workflows last if agents appear first */}
{firstItemType === "agent" && workflowItems.length > 0 && (
<>
{agentItems.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="flex items-center gap-2">
<Workflow className="h-4 w-4" />
Workflows ({workflowItems.length})
</DropdownMenuLabel>
{workflowItems.map((item) => {
const isLoaded = item.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={item.id}
className="cursor-pointer group"
onClick={() => handleSelect(item)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{item.name || item.id}
</span>
{isLoaded && item.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{item.description}
</div>
)}
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
</>
);
})()}
{allItems.length === 0 && (
<DropdownMenuItem disabled>
<div className="text-center text-muted-foreground py-2">
{isLoading ? "Loading agents and workflows..." : "No agents or workflows found"}
</div>
</DropdownMenuItem>
)}
{/* Browse Gallery option */}
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer text-primary"
onClick={() => {
onBrowseGallery?.();
setOpen(false);
}}
>
<Plus className="h-4 w-4 mr-2" />
Browse Gallery
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,9 @@
/**
* Layout Components - Exports
*/
export { AppHeader } from "./app-header";
export { EntitySelector } from "./entity-selector";
export { DebugPanel } from "./debug-panel";
export { SettingsModal } from "./settings-modal";
export { DeploymentModal } from "./deployment-modal";
@@ -0,0 +1,720 @@
/**
* Settings Modal - Tabbed settings dialog with About and Settings tabs
*/
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
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";
interface SettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onBackendUrlChange?: (url: string) => void;
}
type Tab = "general" | "proxy" | "about";
// 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, server capabilities, streaming, and version from store
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired, serverCapabilities, serverVersion, runtime, uiMode, streamingEnabled, setStreamingEnabled } = useDevUIStore();
// Get current backend URL from localStorage or default
const defaultUrl = import.meta.env.VITE_API_BASE_URL !== undefined ? import.meta.env.VITE_API_BASE_URL : "";
const [backendUrl, setBackendUrl] = useState(() => {
return localStorage.getItem("devui_backend_url") || defaultUrl;
});
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 {
new URL(tempUrl);
localStorage.setItem("devui_backend_url", tempUrl);
setBackendUrl(tempUrl);
onBackendUrlChange?.(tempUrl);
onOpenChange(false);
// Reload to apply new backend URL
window.location.reload();
} catch {
alert("Please enter a valid URL (e.g., http://localhost:8080)");
}
};
const handleReset = () => {
localStorage.removeItem("devui_backend_url");
setTempUrl(defaultUrl);
setBackendUrl(defaultUrl);
onBackendUrlChange?.(defaultUrl);
// Reload to apply default backend URL
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">
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<DialogClose onClose={() => onOpenChange(false)} />
{/* Tabs */}
<div className="flex border-b px-6 flex-shrink-0">
<button
onClick={() => setActiveTab("general")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "general"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
General
{activeTab === "general" && (
<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 ${
activeTab === "about"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
About
{activeTab === "about" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
</div>
{/* Tab Content - Scrollable with min-height */}
<div className="px-6 pb-6 overflow-y-auto flex-1 min-h-[400px]">
{activeTab === "general" && (
<div className="space-y-6 pt-4">
{/* Backend URL Setting */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="backend-url" className="text-sm font-medium">
Backend URL
</Label>
{!isDefault && (
<Button
variant="ghost"
size="sm"
onClick={handleReset}
className="h-7 text-xs"
title="Reset to default"
>
<RotateCcw className="h-3 w-3 mr-1" />
Reset
</Button>
)}
</div>
<Input
id="backend-url"
type="url"
value={tempUrl}
onChange={(e) => setTempUrl(e.target.value)}
placeholder="http://localhost:8080"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Default: <span className="font-mono">{defaultUrl}</span>
</p>
{/* Reserve space for buttons to prevent layout shift */}
<div className="flex gap-2 pt-2 min-h-[36px]">
{isModified && (
<>
<Button onClick={handleSave} size="sm" className="flex-1">
Apply & Reload
</Button>
<Button
onClick={() => setTempUrl(backendUrl)}
variant="outline"
size="sm"
className="flex-1"
>
Cancel
</Button>
</>
)}
</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>
)}
{/* UI Settings */}
<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">
Show Tool Calls
</Label>
<p className="text-xs text-muted-foreground">
Display function/tool calls and results in chat messages
</p>
</div>
<Switch
checked={useDevUIStore.getState().showToolCalls}
onCheckedChange={(checked) => useDevUIStore.getState().setShowToolCalls(checked)}
/>
</div>
</div>
{/* Streaming Mode Setting */}
<div className="space-y-3 border-t pt-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
Streaming Mode
</Label>
<p className="text-xs text-muted-foreground">
Stream responses token-by-token as they're generated
</p>
</div>
<Switch
checked={streamingEnabled}
onCheckedChange={setStreamingEnabled}
/>
</div>
{!streamingEnabled && (
<div className="flex items-start gap-2 text-xs text-amber-600 dark:text-amber-400 bg-amber-500/10 p-3 rounded">
<Info className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium">Non-streaming mode limitations:</p>
<ul className="mt-1 space-y-0.5 list-disc list-inside text-amber-600/80 dark:text-amber-400/80">
<li>Tool calls won't display in real-time</li>
<li>No typing indicator during generation</li>
<li>Response appears all at once when complete</li>
</ul>
</div>
</div>
)}
</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>
)}
{activeTab === "about" && (
<div className="space-y-4 pt-4">
<p className="text-sm text-muted-foreground">
DevUI is a sample app for getting started with Agent Framework.
</p>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Version:</span>
<span className="font-mono">{serverVersion || 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Runtime:</span>
<span className="font-mono capitalize">{runtime || 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">UI Mode:</span>
<span className="font-mono capitalize">{uiMode || 'Unknown'}</span>
</div>
</div>
{/* Capabilities section - only show if we have capability data */}
{(serverCapabilities || authRequired !== undefined) && (
<div className="space-y-2 pt-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Capabilities</p>
<div className="space-y-1 text-sm">
{serverCapabilities?.instrumentation !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Instrumentation:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.instrumentation ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
{serverCapabilities.instrumentation ? 'Enabled' : 'Disabled'}
</span>
</div>
)}
{serverCapabilities?.openai_proxy !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">OpenAI Proxy:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.openai_proxy ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
{serverCapabilities.openai_proxy ? 'Available' : 'Not Configured'}
</span>
</div>
)}
{serverCapabilities?.deployment !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Deployment:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.deployment ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
{serverCapabilities.deployment ? 'Available' : 'Disabled'}
</span>
</div>
)}
{authRequired !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Authentication:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${authRequired ? 'bg-blue-500/10 text-blue-600 dark:text-blue-400' : 'bg-muted text-muted-foreground'}`}>
{authRequired ? 'Required' : 'Not Required'}
</span>
</div>
)}
</div>
</div>
)}
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(
"https://github.com/microsoft/agent-framework",
"_blank"
)
}
className="text-xs"
>
<ExternalLink className="h-3 w-3 mr-1" />
Learn More about Agent Framework
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,39 @@
"use client"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
interface ThemeProviderProps {
children: React.ReactNode
attribute?: "class" | "data-theme" | "data-mode"
defaultTheme?: string
enableSystem?: boolean
disableTransitionOnChange?: boolean
}
export function ThemeProvider({
children,
attribute = "class",
defaultTheme = "dark",
enableSystem = true,
disableTransitionOnChange = true,
...props
}: ThemeProviderProps) {
return (
<NextThemesProvider
attribute={attribute}
defaultTheme={defaultTheme}
enableSystem={enableSystem}
disableTransitionOnChange={disableTransitionOnChange}
{...props}
>
{children}
</NextThemesProvider>
)
}
@@ -0,0 +1,48 @@
/**
* Alert component - Simple alert/callout component
*/
import * as React from "react";
import { cn } from "@/lib/utils";
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
className
)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
@@ -0,0 +1,123 @@
/**
* AttachmentGallery - Shows uploaded files with thumbnails and remove options
*/
import { useState } from "react";
import { FileText, Image, Trash2, Music } from "lucide-react";
export interface AttachmentItem {
id: string;
file: File;
preview?: string; // Data URL for preview
type: "image" | "pdf" | "audio" | "other";
}
interface AttachmentGalleryProps {
attachments: AttachmentItem[];
onRemoveAttachment: (id: string) => void;
className?: string;
}
export function AttachmentGallery({
attachments,
onRemoveAttachment,
className = "",
}: AttachmentGalleryProps) {
if (attachments.length === 0) return null;
return (
<div className={`flex flex-wrap gap-2 p-2 bg-muted rounded-lg ${className}`}>
{attachments.map((attachment) => (
<AttachmentPreview
key={attachment.id}
attachment={attachment}
onRemove={() => onRemoveAttachment(attachment.id)}
/>
))}
</div>
);
}
interface AttachmentPreviewProps {
attachment: AttachmentItem;
onRemove: () => void;
}
function AttachmentPreview({ attachment, onRemove }: AttachmentPreviewProps) {
const [isHovered, setIsHovered] = useState(false);
const renderPreview = () => {
switch (attachment.type) {
case "image":
return attachment.preview ? (
<img
src={attachment.preview}
alt={attachment.file.name}
className="w-full h-full object-cover"
/>
) : (
<div className="flex items-center justify-center w-full h-full bg-gray-200">
<Image className="h-6 w-6 text-gray-400" />
</div>
);
case "pdf":
return (
<div className="flex flex-col items-center justify-center w-full h-full bg-red-50">
<FileText className="h-6 w-6 text-red-500 mb-1" />
<span className="text-xs text-red-600">PDF</span>
</div>
);
case "audio":
return (
<div className="flex flex-col items-center justify-center w-full h-full bg-purple-50">
<Music className="h-6 w-6 text-purple-500 mb-1" />
<span className="text-xs text-purple-600">AUDIO</span>
</div>
);
default:
return (
<div className="flex flex-col items-center justify-center w-full h-full bg-gray-100">
<FileText className="h-6 w-6 text-gray-500 mb-1" />
<span className="text-xs text-gray-600">FILE</span>
</div>
);
}
};
return (
<div
className="relative w-16 h-16 rounded border overflow-hidden group cursor-pointer"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
title={attachment.file.name}
>
{renderPreview()}
{/* Dark overlay with centered delete icon on hover */}
<div
className={`absolute inset-0 bg-black/60 flex items-center justify-center transition-all duration-200 ease-in-out ${
isHovered
? 'opacity-100 backdrop-blur-sm'
: 'opacity-0 pointer-events-none'
}`}
onClick={onRemove}
>
<div className={`transition-all duration-200 ease-in-out ${
isHovered
? 'scale-100 opacity-100'
: 'scale-75 opacity-0'
}`}>
<Trash2 className="h-5 w-5 text-white drop-shadow-lg" />
</div>
</div>
{/* File name tooltip */}
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-75 text-white text-xs p-1 truncate opacity-0 group-hover:opacity-100 transition-opacity duration-200">
{attachment.file.name}
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge };
@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,471 @@
/**
* ChatMessageInput - Reusable chat input component with file upload and rich text support
* Features: Text input, file upload, drag & drop, paste handling, attachments
*/
import { useState, useRef, useCallback, useEffect } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { FileUpload } from "@/components/ui/file-upload";
import {
AttachmentGallery,
type AttachmentItem,
} from "@/components/ui/attachment-gallery";
import {
SendHorizontal,
Square,
FileText,
Paperclip,
} from "lucide-react";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { ResponseInputContent, ResponseInputTextParam, ResponseInputImageParam, ResponseInputFileParam } from "@/types/agent-framework";
export interface ChatMessageInputProps {
onSubmit: (content: ResponseInputContent[]) => Promise<void>;
isSubmitting?: boolean;
isStreaming?: boolean;
onCancel?: () => void;
isCancelling?: boolean;
placeholder?: string;
showFileUpload?: boolean;
maxAttachments?: number;
className?: string;
disabled?: boolean;
entityName?: string; // For placeholder text
/** Files dropped from parent (via useDragDrop hook) */
externalFiles?: File[];
/** Called after external files have been processed */
onExternalFilesProcessed?: () => void;
}
export function ChatMessageInput({
onSubmit,
isSubmitting = false,
isStreaming = false,
onCancel,
isCancelling = false,
placeholder,
showFileUpload = true,
maxAttachments = 10,
className = "",
disabled = false,
entityName = "assistant",
externalFiles,
onExternalFilesProcessed,
}: ChatMessageInputProps) {
const [inputValue, setInputValue] = useState("");
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Process external files from parent (via useDragDrop hook)
useEffect(() => {
if (externalFiles && externalFiles.length > 0) {
handleFilesSelected(externalFiles);
onExternalFilesProcessed?.();
}
}, [externalFiles, onExternalFilesProcessed]);
// Constants for text-to-file conversion
const TEXT_THRESHOLD = 10000; // 10KB threshold for converting to file
// Helper functions
const getFileType = (file: File): AttachmentItem["type"] => {
if (file.type.startsWith("image/")) return "image";
if (file.type === "application/pdf") return "pdf";
if (file.type.startsWith("audio/")) return "audio";
return "other";
};
const readFileAsDataURL = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
// Detect file extension from content
const detectFileExtension = (text: string): string => {
const trimmed = text.trim();
const lines = trimmed.split("\n");
// JSON detection
if (/^{[\s\S]*}$|^\[[\s\S]*\]$/.test(trimmed)) return ".json";
// XML/HTML detection
if (/^<\?xml|^<html|^<!DOCTYPE/i.test(trimmed)) return ".html";
// Markdown detection (code blocks)
if (/^```/.test(trimmed)) return ".md";
// TSV detection (tabs with multiple lines)
if (/\t/.test(text) && lines.length > 1) return ".tsv";
// CSV detection (more strict) - need multiple lines with consistent comma patterns
if (lines.length > 2) {
const commaLines = lines.filter((line) => line.includes(","));
const semicolonLines = lines.filter((line) => line.includes(";"));
// If >50% of lines have commas and it looks tabular
if (commaLines.length > lines.length * 0.5) {
const avgCommas =
commaLines.reduce(
(sum, line) => sum + (line.match(/,/g) || []).length,
0
) / commaLines.length;
if (avgCommas >= 2) return ".csv";
}
// If >50% of lines have semicolons and it looks tabular
if (semicolonLines.length > lines.length * 0.5) {
const avgSemicolons =
semicolonLines.reduce(
(sum, line) => sum + (line.match(/;/g) || []).length,
0
) / semicolonLines.length;
if (avgSemicolons >= 2) return ".csv";
}
}
return ".txt";
};
// Handle file selection
const handleFilesSelected = useCallback(
async (files: File[]) => {
if (attachments.length + files.length > maxAttachments) {
console.warn(`Cannot add more than ${maxAttachments} attachments`);
return;
}
const newAttachments: AttachmentItem[] = [];
for (const file of files) {
const attachment: AttachmentItem = {
id: `${Date.now()}-${Math.random()}`,
file,
type: getFileType(file),
};
// Generate preview for images
if (file.type.startsWith("image/")) {
try {
attachment.preview = await readFileAsDataURL(file);
} catch (error) {
console.error("Failed to generate preview:", error);
}
}
newAttachments.push(attachment);
}
setAttachments((prev) => [...prev, ...newAttachments]);
},
[attachments.length, maxAttachments]
);
// Handle attachment removal
const handleRemoveAttachment = (id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
};
// Handle drag and drop
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
await handleFilesSelected(files);
}
};
// Handle paste events
const handlePaste = async (e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.items);
const files: File[] = [];
let hasProcessedText = false;
for (const item of items) {
// Handle images (including screenshots)
if (item.type.startsWith("image/")) {
e.preventDefault();
const blob = item.getAsFile();
if (blob) {
const timestamp = Date.now();
files.push(
new File([blob], `screenshot-${timestamp}.png`, { type: blob.type })
);
}
}
// Handle text - only process first text item (browsers often duplicate)
else if (item.type === "text/plain" && !hasProcessedText) {
hasProcessedText = true;
// We need to check the text synchronously to decide whether to prevent default
// Unfortunately, getAsString is async, so we'll prevent default for all text
// and then decide whether to actually create a file or manually insert the text
e.preventDefault();
await new Promise<void>((resolve) => {
item.getAsString((text) => {
// Check if text should be converted to file
const lineCount = (text.match(/\n/g) || []).length;
const shouldConvert =
text.length > TEXT_THRESHOLD ||
lineCount > 50 || // Many lines suggests logs/data
/^\s*[{[][\s\S]*[}\]]\s*$/.test(text) || // JSON-like
/^<\?xml|^<html|^<!DOCTYPE/i.test(text); // XML/HTML
if (shouldConvert) {
// Create file for large/complex text
const extension = detectFileExtension(text);
const timestamp = Date.now();
const blob = new Blob([text], { type: "text/plain" });
files.push(
new File([blob], `pasted-text-${timestamp}${extension}`, {
type: "text/plain",
})
);
} else {
// For small text, manually insert into textarea since we prevented default
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const currentValue = textarea.value;
const newValue =
currentValue.slice(0, start) + text + currentValue.slice(end);
setInputValue(newValue);
// Restore cursor position after the inserted text
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd =
start + text.length;
textarea.focus();
}, 0);
}
}
resolve();
});
});
}
}
// Process collected files
if (files.length > 0) {
await handleFilesSelected(files);
// Show notification with appropriate icon
const message =
files.length === 1
? files[0].name.includes("screenshot")
? "Screenshot added as attachment"
: "Large text converted to file"
: `${files.length} files added`;
setPasteNotification(message);
setTimeout(() => setPasteNotification(null), 3000);
}
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (
(!inputValue.trim() && attachments.length === 0) ||
isSubmitting ||
disabled
)
return;
const messageText = inputValue.trim();
const content: ResponseInputContent[] = [];
// Add text content if present
if (messageText) {
content.push({
text: messageText,
type: "input_text",
} as ResponseInputTextParam);
}
// Add attachments
for (const attachment of attachments) {
const dataUri = await readFileAsDataURL(attachment.file);
if (attachment.file.type.startsWith("image/")) {
// Image attachment
content.push({
detail: "auto",
type: "input_image",
image_url: dataUri,
} as ResponseInputImageParam);
} else if (
attachment.file.type === "text/plain" &&
(attachment.file.name.includes("pasted-text-") ||
attachment.file.name.endsWith(".txt") ||
attachment.file.name.endsWith(".csv") ||
attachment.file.name.endsWith(".json") ||
attachment.file.name.endsWith(".html") ||
attachment.file.name.endsWith(".md") ||
attachment.file.name.endsWith(".tsv"))
) {
// Convert text files back to input_text
const text = await attachment.file.text();
content.push({
text: text,
type: "input_text",
} as ResponseInputTextParam);
} else {
// Other file types
const base64Data = dataUri.split(",")[1]; // Extract base64 part
content.push({
type: "input_file",
file_data: base64Data,
file_url: dataUri,
filename: attachment.file.name,
} as ResponseInputFileParam);
}
}
// Call the onSubmit callback
await onSubmit(content);
// Clear input and attachments after successful submission
setInputValue("");
setAttachments([]);
};
const canSendMessage =
!disabled &&
!isSubmitting &&
!isStreaming &&
(inputValue.trim() || attachments.length > 0);
return (
<div
className={`relative ${className}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drag overlay */}
{isDragOver && (
<div className="absolute inset-2 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-lg bg-blue-50/80 dark:bg-blue-950/40 backdrop-blur-sm flex items-center justify-center transition-all duration-200 ease-in-out z-10">
<div className="text-center">
<div className="text-blue-600 dark:text-blue-400 text-sm font-medium mb-1">
Drop files here
</div>
<div className="text-blue-500 dark:text-blue-500 text-xs">
Images, PDFs, and other files
</div>
</div>
</div>
)}
{/* Attachment gallery */}
{attachments.length > 0 && (
<div className="mb-3">
<AttachmentGallery
attachments={attachments}
onRemoveAttachment={handleRemoveAttachment}
/>
</div>
)}
{/* Paste notification */}
{pasteNotification && (
<div
className="absolute bottom-24 left-1/2 -translate-x-1/2 z-20
bg-blue-500 text-white px-4 py-2 rounded-full text-sm
animate-in slide-in-from-bottom-2 fade-in duration-200
flex items-center gap-2 shadow-lg"
>
{pasteNotification.includes("screenshot") ? (
<Paperclip className="h-3 w-3" />
) : (
<FileText className="h-3 w-3" />
)}
{pasteNotification}
</div>
)}
{/* Input form */}
<form onSubmit={handleSubmit} className="flex gap-2 items-end">
<Textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPaste={handlePaste}
onKeyDown={(e) => {
// Submit on Enter (without shift)
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
placeholder={placeholder || `Message ${entityName}... (Shift+Enter for new line)`}
disabled={disabled || isSubmitting || isStreaming}
className="flex-1 min-h-[40px] max-h-[200px] resize-none"
style={{ fieldSizing: "content" } as React.CSSProperties}
/>
{showFileUpload && (
<FileUpload
onFilesSelected={handleFilesSelected}
disabled={disabled || isSubmitting || isStreaming}
/>
)}
{isStreaming && onCancel ? (
<Button
type="button"
size="icon"
onClick={onCancel}
disabled={isCancelling}
className="shrink-0 h-10 transition-all"
title="Stop generating"
aria-label="Stop generating response"
>
{isCancelling ? (
<LoadingSpinner size="sm" />
) : (
<Square className="h-4 w-4 fill-current" />
)}
</Button>
) : (
<Button
type="submit"
size="icon"
disabled={!canSendMessage}
className="shrink-0 h-10 transition-all"
title="Send message"
aria-label="Send message"
>
{isSubmitting ? (
<LoadingSpinner size="sm" />
) : (
<SendHorizontal className="h-4 w-4" />
)}
</Button>
)}
</form>
</div>
);
}
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
@@ -0,0 +1,126 @@
import React from "react";
import { X } from "lucide-react";
import { Button } from "./button";
interface DialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}
interface DialogContentProps {
children: React.ReactNode;
className?: string;
}
interface DialogHeaderProps {
children: React.ReactNode;
className?: string;
}
interface DialogTitleProps {
children: React.ReactNode;
className?: string;
}
interface DialogDescriptionProps {
children: React.ReactNode;
className?: string;
}
interface DialogFooterProps {
children: React.ReactNode;
}
export function Dialog({ open, onOpenChange, children }: DialogProps) {
if (!open) return null;
const handleBackdropClick = () => {
// Close the modal when backdrop is clicked
onOpenChange(false);
};
const handleContentClick = (e: React.MouseEvent) => {
// Stop any clicks inside the content from bubbling to backdrop
e.stopPropagation();
};
const handleContentMouseDown = (e: React.MouseEvent) => {
// Prevent mousedown from bubbling during text selection
e.stopPropagation();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop - handles clicks to close */}
<div
className="absolute inset-0 bg-black/50"
onClick={handleBackdropClick}
/>
{/* Modal content - positioned above backdrop with z-index */}
<div
className="relative z-10"
onClick={handleContentClick}
onMouseDown={handleContentMouseDown}
onMouseUp={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
}
export function DialogContent({
children,
className = "",
}: DialogContentProps) {
// Default width classes if none provided
const hasWidthClass = className.includes('w-[') || className.includes('w-full') || className.includes('max-w-');
const defaultWidthClasses = hasWidthClass ? '' : 'max-w-lg w-full';
return (
<div
className={`relative bg-background border rounded-lg shadow-lg max-h-[90vh] overflow-hidden ${defaultWidthClasses} ${className}`}
>
{children}
</div>
);
}
export function DialogHeader({ children, className = "" }: DialogHeaderProps) {
return (
<div className={`space-y-2 ${className}`}>
{children}
</div>
);
}
export function DialogTitle({ children, className = "" }: DialogTitleProps) {
return <h2 className={`text-lg font-semibold ${className}`}>{children}</h2>;
}
export function DialogDescription({ children, className = "" }: DialogDescriptionProps) {
return <p className={`text-sm text-muted-foreground ${className}`}>{children}</p>;
}
export function DialogClose({ onClose }: { onClose: () => void }) {
return (
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="absolute top-4 right-4 h-8 w-8 p-0 rounded-sm opacity-70 hover:opacity-100"
>
<X className="h-4 w-4" />
</Button>
);
}
export function DialogFooter({ children }: DialogFooterProps) {
return (
<div className="flex justify-end gap-2 p-4 border-t bg-muted/50">
{children}
</div>
);
}
@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -0,0 +1,141 @@
/**
* FileUpload - Upload button with drag & drop support
*/
import { useRef } from "react";
import { Upload } from "lucide-react";
import { Button } from "./button";
interface FileUploadProps {
onFilesSelected: (files: File[]) => void;
accept?: string;
multiple?: boolean;
maxSize?: number; // in bytes
disabled?: boolean;
className?: string;
}
export function FileUpload({
onFilesSelected,
accept = "image/*,.pdf,audio/*,.wav,.mp3,.m4a,.ogg",
multiple = true,
maxSize = 50 * 1024 * 1024, // 50MB default for local dev tool
disabled = false,
className = "",
}: FileUploadProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (files: FileList | null) => {
if (!files || files.length === 0) return;
const validFiles: File[] = [];
const errors: string[] = [];
Array.from(files).forEach((file) => {
// Size validation
if (file.size > maxSize) {
errors.push(`${file.name} is too large (max ${formatFileSize(maxSize)})`);
return;
}
// Type validation (basic)
if (accept && !isFileAccepted(file, accept)) {
errors.push(`${file.name} is not an accepted file type`);
return;
}
validFiles.push(file);
});
if (errors.length > 0) {
console.warn("File upload errors:", errors);
// In a production app, you might want to show these errors to the user
}
if (validFiles.length > 0) {
onFilesSelected(validFiles);
}
};
const handleButtonClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
handleFileSelect(e.target.files);
// Reset input to allow selecting the same file again
e.target.value = "";
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
const files = e.dataTransfer.files;
handleFileSelect(files);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
return (
<div className={className}>
<input
ref={fileInputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleFileInputChange}
className="hidden"
disabled={disabled}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleButtonClick}
disabled={disabled}
onDrop={handleDrop}
onDragOver={handleDragOver}
className="shrink-0 transition-colors hover:bg-muted"
title="Upload files (images, PDFs, audio)"
>
<Upload className="h-4 w-4" />
</Button>
</div>
);
}
// Helper functions
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
function isFileAccepted(file: File, accept: string): boolean {
const acceptPatterns = accept.split(",").map((pattern) => pattern.trim());
return acceptPatterns.some((pattern) => {
if (pattern.startsWith(".")) {
// File extension check
return file.name.toLowerCase().endsWith(pattern.toLowerCase());
} else if (pattern.includes("/*")) {
// MIME type wildcard check (e.g., "image/*")
const [mainType] = pattern.split("/");
return file.type.startsWith(mainType + "/");
} else {
// Exact MIME type check
return file.type === pattern;
}
});
}
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,23 @@
import { Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
interface LoadingSpinnerProps {
size?: "sm" | "md" | "lg"
className?: string
}
export function LoadingSpinner({ size = "md", className }: LoadingSpinnerProps) {
return (
<Loader2
className={cn(
"animate-spin",
{
"h-4 w-4": size === "sm",
"h-6 w-6": size === "md",
"h-8 w-8": size === "lg",
},
className
)}
/>
)
}
@@ -0,0 +1,52 @@
import { LoadingSpinner } from "./loading-spinner"
import { cn } from "@/lib/utils"
interface LoadingStateProps {
message?: string
description?: string
size?: "sm" | "md" | "lg"
className?: string
fullPage?: boolean
}
export function LoadingState({
message = "Loading...",
description,
size = "md",
className,
fullPage = false
}: LoadingStateProps) {
const content = (
<div className={cn(
"flex flex-col items-center justify-center gap-3",
fullPage ? "min-h-[50vh]" : "py-8",
className
)}>
<LoadingSpinner size={size} className="text-muted-foreground" />
<div className="text-center space-y-1">
<p className={cn(
"font-medium text-muted-foreground",
size === "sm" && "text-sm",
size === "lg" && "text-lg"
)}>
{message}
</p>
{description && (
<p className="text-sm text-muted-foreground/80">
{description}
</p>
)}
</div>
</div>
)
if (fullPage) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
{content}
</div>
)
}
return content
}
@@ -0,0 +1,565 @@
/**
* Lightweight Markdown Renderer
*
* A minimal markdown renderer with zero dependencies for rendering LLM responses.
* Handles the most common markdown patterns without bloating bundle size.
*
* Supported syntax:
* - **bold** and __bold__
* - *italic* and _italic_
* - `inline code`
* - ```code blocks``` (with copy button on hover)
* - [links](url)
* - **[bold links](url)** and *[italic links](url)*
* - # Headers (H1-H6)
* - Lists (ordered and unordered)
* - > Blockquotes
* - Tables (| col1 | col2 |)
* - Horizontal rules (---)
*/
import React, { useState, useRef, useEffect } from "react";
interface MarkdownRendererProps {
content: string;
className?: string;
}
interface CodeBlockProps {
code: string;
language?: string;
}
/**
* Code block component with copy button
*/
function CodeBlock({ code, language }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout and store reference
timeoutRef.current = setTimeout(() => {
setCopied(false);
timeoutRef.current = null;
}, 2000);
} catch (err) {
console.error("Failed to copy code:", err);
}
};
return (
<div className="relative group">
<pre className="my-3 p-3 bg-foreground/5 dark:bg-foreground/10 rounded overflow-x-auto border border-foreground/10">
<code className="text-xs font-mono block whitespace-pre-wrap break-words">
{language && (
<span className="opacity-60 text-[10px] mb-1 block uppercase">
{language}
</span>
)}
{code}
</code>
</pre>
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md border shadow-sm
bg-background hover:bg-accent
text-muted-foreground hover:text-foreground
transition-all duration-200
opacity-0 group-hover:opacity-100"
title={copied ? "Copied!" : "Copy code"}
>
{copied ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-green-600 dark:text-green-400"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
)}
</button>
</div>
);
}
/**
* Parse markdown text into React elements
*/
export function MarkdownRenderer({
content,
className = "",
}: MarkdownRendererProps) {
const lines = content.split("\n");
const elements: React.ReactNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Code blocks (multiline)
if (line.trim().startsWith("```")) {
const codeLines: string[] = [];
const langMatch = line.trim().match(/^```(\w+)?/);
const language = langMatch?.[1] || "";
i++; // Skip opening ```
while (i < lines.length && !lines[i].trim().startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
i++; // Skip closing ```
elements.push(
<CodeBlock
key={elements.length}
code={codeLines.join("\n")}
language={language}
/>
);
continue;
}
// Headers
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
const text = headerMatch[2];
const sizes = [
"text-2xl",
"text-xl",
"text-lg",
"text-base",
"text-sm",
"text-sm",
];
const className = `${
sizes[level - 1]
} font-semibold mt-4 mb-2 first:mt-0 break-words`;
// Render appropriate header level
const header =
level === 1 ? (
<h1 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h1>
) : level === 2 ? (
<h2 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h2>
) : level === 3 ? (
<h3 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h3>
) : level === 4 ? (
<h4 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h4>
) : level === 5 ? (
<h5 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h5>
) : (
<h6 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h6>
);
elements.push(header);
i++;
continue;
}
// Unordered lists
if (line.match(/^[\s]*[-*+]\s+/)) {
const listItems: string[] = [];
while (i < lines.length && lines[i].match(/^[\s]*[-*+]\s+/)) {
const itemText = lines[i].replace(/^[\s]*[-*+]\s+/, "");
listItems.push(itemText);
i++;
}
elements.push(
<ul
key={elements.length}
className="my-2 ml-4 list-disc space-y-1 break-words"
>
{listItems.map((item, idx) => (
<li key={idx} className="text-sm break-words">
{parseInlineMarkdown(item)}
</li>
))}
</ul>
);
continue;
}
// Ordered lists
if (line.match(/^[\s]*\d+\.\s+/)) {
const listItems: string[] = [];
while (i < lines.length && lines[i].match(/^[\s]*\d+\.\s+/)) {
const itemText = lines[i].replace(/^[\s]*\d+\.\s+/, "");
listItems.push(itemText);
i++;
}
elements.push(
<ol
key={elements.length}
className="my-2 ml-4 list-decimal space-y-1 break-words"
>
{listItems.map((item, idx) => (
<li key={idx} className="text-sm break-words">
{parseInlineMarkdown(item)}
</li>
))}
</ol>
);
continue;
}
// Tables
if (line.trim().startsWith("|") && line.trim().endsWith("|")) {
const tableLines: string[] = [];
// Collect all table lines
while (
i < lines.length &&
lines[i].trim().startsWith("|") &&
lines[i].trim().endsWith("|")
) {
tableLines.push(lines[i].trim());
i++;
}
// Parse table (need at least 2 lines: header + separator)
if (tableLines.length >= 2) {
const headerCells = tableLines[0]
.split("|")
.slice(1, -1)
.map((cell) => cell.trim());
// Check if second line is a separator (contains dashes)
const isSeparator = tableLines[1].match(/^\|[\s\-:|]+\|$/);
if (isSeparator) {
const bodyRows = tableLines.slice(2).map((row) =>
row
.split("|")
.slice(1, -1)
.map((cell) => cell.trim())
);
elements.push(
<div key={elements.length} className="my-3 overflow-x-auto">
<table className="min-w-full border border-foreground/10 text-sm">
<thead className="bg-foreground/5">
<tr>
{headerCells.map((header, idx) => (
<th
key={idx}
className="border-b border-foreground/10 px-3 py-2 text-left font-semibold break-words"
>
{parseInlineMarkdown(header)}
</th>
))}
</tr>
</thead>
<tbody>
{bodyRows.map((row, rowIdx) => (
<tr
key={rowIdx}
className="border-b border-foreground/5 last:border-b-0"
>
{row.map((cell, cellIdx) => (
<td
key={cellIdx}
className="px-3 py-2 border-r border-foreground/5 last:border-r-0 break-words"
>
{parseInlineMarkdown(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
continue;
}
}
// Not a valid table, render as regular paragraphs
for (const tableLine of tableLines) {
elements.push(
<p key={elements.length} className="my-1">
{parseInlineMarkdown(tableLine)}
</p>
);
}
continue;
}
// Blockquotes
if (line.trim().startsWith(">")) {
const quoteLines: string[] = [];
while (i < lines.length && lines[i].trim().startsWith(">")) {
quoteLines.push(lines[i].replace(/^>\s?/, ""));
i++;
}
elements.push(
<blockquote
key={elements.length}
className="my-2 pl-4 border-l-4 border-current/30 opacity-80 italic break-words"
>
{quoteLines.map((quoteLine, idx) => (
<div key={idx} className="break-words">
{parseInlineMarkdown(quoteLine)}
</div>
))}
</blockquote>
);
continue;
}
// Horizontal rule
if (line.match(/^[\s]*[-*_]{3,}[\s]*$/)) {
elements.push(
<hr key={elements.length} className="my-4 border-t border-border" />
);
i++;
continue;
}
// Empty line
if (line.trim() === "") {
elements.push(<div key={elements.length} className="h-2" />);
i++;
continue;
}
// Regular paragraph
elements.push(
<p key={elements.length} className="my-1 break-words">
{parseInlineMarkdown(line)}
</p>
);
i++;
}
return (
<div className={`markdown-content break-words ${className}`}>
{elements}
</div>
);
}
/**
* Parse inline markdown patterns (bold, italic, code, links)
*/
function parseInlineMarkdown(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
// Pattern priority: code > bold > italic > links
// This prevents conflicts between overlapping patterns
while (remaining.length > 0) {
// Inline code (highest priority to avoid parsing inside code)
const codeMatch = remaining.match(/`([^`]+)`/);
if (codeMatch && codeMatch.index !== undefined) {
// Add text before code
if (codeMatch.index > 0) {
parts.push(
<span key={key++}>
{parseBoldItalicLinks(remaining.slice(0, codeMatch.index))}
</span>
);
}
// Add code
parts.push(
<code
key={key++}
className="px-1.5 py-0.5 bg-foreground/10 rounded text-xs font-mono border border-foreground/20"
>
{codeMatch[1]}
</code>
);
remaining = remaining.slice(codeMatch.index + codeMatch[0].length);
continue;
}
// No more special patterns, parse remaining text for bold/italic/links
parts.push(<span key={key++}>{parseBoldItalicLinks(remaining)}</span>);
break;
}
return parts;
}
/**
* Parse bold, italic, and links (after code has been extracted)
*/
function parseBoldItalicLinks(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
while (remaining.length > 0) {
// Try to match patterns in order
// IMPORTANT: Handle **[link](url)** pattern first (bold markers around link)
const patterns = [
{ regex: /\*\*\[([^\]]+)\]\(([^)]+)\)\*\*/, component: "strong-link" }, // **[text](url)**
{ regex: /__\[([^\]]+)\]\(([^)]+)\)__/, component: "strong-link" }, // __[text](url)__
{ regex: /\*\[([^\]]+)\]\(([^)]+)\)\*/, component: "em-link" }, // *[text](url)*
{ regex: /_\[([^\]]+)\]\(([^)]+)\)_/, component: "em-link" }, // _[text](url)_
{ regex: /\[([^\]]+)\]\(([^)]+)\)/, component: "link" }, // [text](url)
{ regex: /\*\*(.+?)\*\*/, component: "strong" }, // **bold**
{ regex: /__(.+?)__/, component: "strong" }, // __bold__
{ regex: /\*(.+?)\*/, component: "em" }, // *italic*
{ regex: /_(.+?)_/, component: "em" }, // _italic_
];
let matched = false;
for (const pattern of patterns) {
const match = remaining.match(pattern.regex);
if (match && match.index !== undefined) {
// Add text before match
if (match.index > 0) {
parts.push(remaining.slice(0, match.index));
}
// Add matched element
if (pattern.component === "strong") {
parts.push(
<strong key={key++} className="font-semibold">
{match[1]}
</strong>
);
} else if (pattern.component === "em") {
parts.push(
<em key={key++} className="italic">
{match[1]}
</em>
);
} else if (pattern.component === "strong-link") {
// **[text](url)** - Bold link
const linkText = match[1];
const linkUrl = match[2];
const formattedLinkText = parseBoldItalicLinks(linkText);
parts.push(
<strong key={key++} className="font-semibold">
<a
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-words"
>
{formattedLinkText}
</a>
</strong>
);
} else if (pattern.component === "em-link") {
// *[text](url)* - Italic link
const linkText = match[1];
const linkUrl = match[2];
const formattedLinkText = parseBoldItalicLinks(linkText);
parts.push(
<em key={key++} className="italic">
<a
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-words"
>
{formattedLinkText}
</a>
</em>
);
} else if (pattern.component === "link") {
// [text](url) - Regular link
const linkText = match[1];
const linkUrl = match[2];
const formattedLinkText = parseBoldItalicLinks(linkText);
parts.push(
<a
key={key++}
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-words"
>
{formattedLinkText}
</a>
);
}
remaining = remaining.slice(match.index + match[0].length);
matched = true;
break;
}
}
// No pattern matched, add remaining text and exit
if (!matched) {
if (remaining.length > 0) {
parts.push(remaining);
}
break;
}
}
return parts;
}
@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
@@ -0,0 +1,183 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
@@ -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>
);
}
@@ -0,0 +1,30 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,5 @@
/**
* Gallery data exports
*/
export * from './sample-entities';
@@ -0,0 +1,155 @@
/**
* Sample entities for the gallery - curated examples to help users learn Agent Framework
*/
export interface EnvVarRequirement {
name: string;
description: string;
required: boolean;
example?: string;
}
export interface SampleEntity {
id: string;
name: string;
description: string;
type: "agent" | "workflow";
url: string;
tags: string[];
author: string;
difficulty: "beginner" | "intermediate" | "advanced";
features: string[];
requiredEnvVars?: EnvVarRequirement[];
}
export const SAMPLE_ENTITIES: SampleEntity[] = [
// Beginner Agents
{
id: "foundry-weather-agent",
name: "Azure AI Weather Agent",
description:
"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",
type: "agent",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/foundry_agent/agent.py",
tags: ["azure-ai", "foundry", "tools"],
author: "Microsoft",
difficulty: "beginner",
features: [
"Azure AI Agent integration",
"Azure CLI authentication",
"Mock weather tools",
],
requiredEnvVars: [
{
name: "FOUNDRY_PROJECT_ENDPOINT",
description: "Azure AI Foundry project endpoint URL",
required: true,
example: "https://your-project.api.azureml.ms",
},
{
name: "FOUNDRY_MODEL",
description: "Name of the deployed model in Azure AI Foundry",
required: true,
example: "gpt-4o",
},
],
},
{
id: "weather-agent-azure",
name: "Azure OpenAI Weather Agent",
description:
"Weather agent using Azure OpenAI with API key authentication",
type: "agent",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/weather_agent_azure/agent.py",
tags: ["azure", "openai", "tools"],
author: "Microsoft",
difficulty: "beginner",
features: [
"Azure OpenAI integration",
"API key authentication",
"Function calling",
"Mock weather tools",
],
requiredEnvVars: [
{
name: "AZURE_OPENAI_API_KEY",
description: "Azure OpenAI API key",
required: true,
},
{
name: "AZURE_OPENAI_MODEL",
description: "Name of the deployed model in Azure OpenAI",
required: true,
example: "gpt-4o",
},
{
name: "AZURE_OPENAI_ENDPOINT",
description: "Azure OpenAI endpoint URL",
required: true,
example: "https://your-resource.openai.azure.com",
},
],
},
// Beginner Workflows
{
id: "spam-workflow",
name: "Spam Detection Workflow",
description:
"5-step workflow demonstrating email spam detection with branching logic",
type: "workflow",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/spam_workflow/workflow.py",
tags: ["workflow", "branching", "multi-step"],
author: "Microsoft",
difficulty: "beginner",
features: [
"Sequential execution",
"Conditional branching",
"Mock spam detection",
],
},
// Advanced Workflows
{
id: "fanout-workflow",
name: "Complex Fan-In/Fan-Out Workflow",
description:
"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",
type: "workflow",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/fanout_workflow/workflow.py",
tags: ["workflow", "fan-out", "fan-in", "parallel"],
author: "Microsoft",
difficulty: "advanced",
features: [
"Fan-out pattern",
"Parallel execution",
"Complex state management",
"Multi-stage processing",
],
},
];
// Group samples by category for better organization
export const SAMPLE_CATEGORIES = {
all: SAMPLE_ENTITIES,
agents: SAMPLE_ENTITIES.filter((e) => e.type === "agent"),
workflows: SAMPLE_ENTITIES.filter((e) => e.type === "workflow"),
beginner: SAMPLE_ENTITIES.filter((e) => e.difficulty === "beginner"),
intermediate: SAMPLE_ENTITIES.filter((e) => e.difficulty === "intermediate"),
advanced: SAMPLE_ENTITIES.filter((e) => e.difficulty === "advanced"),
};
// Get difficulty color for badges
export const getDifficultyColor = (difficulty: SampleEntity["difficulty"]) => {
switch (difficulty) {
case "beginner":
return "bg-green-100 text-green-700 border-green-200";
case "intermediate":
return "bg-yellow-100 text-yellow-700 border-yellow-200";
case "advanced":
return "bg-red-100 text-red-700 border-red-200";
default:
return "bg-gray-100 text-gray-700 border-gray-200";
}
};
@@ -0,0 +1,3 @@
export { useCancellableRequest, isAbortError } from './useCancellableRequest';
export { useDragDrop } from './use-drag-drop';
export type { UseDragDropOptions, UseDragDropReturn } from './use-drag-drop';
@@ -0,0 +1,98 @@
/**
* useDragDrop - Hook for handling drag and drop file uploads at parent level
* Provides drag state and handlers that can be spread on a container element
*/
import { useState, useCallback, useRef } from "react";
export interface UseDragDropOptions {
/** Called when files are dropped */
onDrop?: (files: File[]) => void;
/** Whether drag/drop is disabled */
disabled?: boolean;
}
export interface UseDragDropReturn {
/** Whether a drag is currently over the drop zone */
isDragOver: boolean;
/** Files that were dropped (cleared after processing) */
droppedFiles: File[];
/** Clear the dropped files after they've been processed */
clearDroppedFiles: () => void;
/** Event handlers to spread on the container element */
dragHandlers: {
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
};
}
export function useDragDrop(options: UseDragDropOptions = {}): UseDragDropReturn {
const { onDrop, disabled = false } = options;
const [isDragOver, setIsDragOver] = useState(false);
const [droppedFiles, setDroppedFiles] = useState<File[]>([]);
const dragCounterRef = useRef(0);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current++;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragOver(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDragOver(false);
}
}, [disabled]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
dragCounterRef.current = 0;
if (disabled) return;
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
setDroppedFiles(files);
onDrop?.(files);
}
}, [disabled, onDrop]);
const clearDroppedFiles = useCallback(() => {
setDroppedFiles([]);
}, []);
return {
isDragOver,
droppedFiles,
clearDroppedFiles,
dragHandlers: {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
},
};
}
@@ -0,0 +1,70 @@
/**
* Custom hook for managing cancellable requests with AbortController
* Reduces duplication across agent and workflow views
*/
import { useState, useRef, useCallback } from "react";
/**
* Hook for managing cancellable requests with AbortController
* @returns Object with cancellation state and methods
*/
export function useCancellableRequest() {
const [isCancelling, setIsCancelling] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
/**
* Creates a new AbortController and returns its signal
* Resets the cancelling state
*/
const createAbortSignal = useCallback((): AbortSignal => {
abortControllerRef.current = new AbortController();
setIsCancelling(false);
return abortControllerRef.current.signal;
}, []);
/**
* Cancels the current request if one exists
*/
const handleCancel = useCallback(() => {
if (abortControllerRef.current) {
setIsCancelling(true);
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
/**
* Resets the cancelling state - useful in error handlers
*/
const resetCancelling = useCallback(() => {
setIsCancelling(false);
}, []);
/**
* Cleanup function to be called when component unmounts
*/
const cleanup = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
return {
isCancelling,
createAbortSignal,
handleCancel,
resetCancelling,
cleanup,
};
}
/**
* Utility function to check if an error is an AbortError
* @param error - The error to check
* @returns true if the error is an AbortError
*/
export function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError';
}
@@ -0,0 +1,184 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.48 0.18 290);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.62 0.20 290);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* Mermaid diagram styles removed - visualization coming soon */
/* Style workflow completion/error states */
.workflow-chat-view .border-green-200 {
@apply border-emerald-200;
}
.workflow-chat-view .bg-green-50 {
@apply bg-emerald-50;
}
.workflow-chat-view .bg-green-100 {
@apply bg-emerald-100;
}
.workflow-chat-view .text-green-600 {
@apply text-emerald-600;
}
.workflow-chat-view .text-green-700 {
@apply text-emerald-700;
}
.workflow-chat-view .text-green-800 {
@apply text-emerald-800;
}
/* HIL Timeline Item Animations */
.highlight-attention {
animation: highlight-flash 1s ease-out;
}
@keyframes highlight-flash {
0% {
background-color: rgb(251 146 60 / 0.3);
transform: scale(1.02);
}
100% {
background-color: transparent;
transform: scale(1);
}
}
/* Pulsing glow effect for HIL waiting state */
.hil-waiting-glow {
box-shadow:
0 0 0 0 rgb(251 146 60 / 0.4),
inset 0 0 0 1px rgb(251 146 60 / 0.2);
animation: pulse-glow 2s infinite;
}
@keyframes pulse-glow {
0%, 100% {
box-shadow:
0 0 0 0 rgb(251 146 60 / 0.4),
inset 0 0 0 1px rgb(251 146 60 / 0.2);
}
50% {
box-shadow:
0 0 20px 5px rgb(251 146 60 / 0.2),
inset 0 0 0 2px rgb(251 146 60 / 0.3);
}
}
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -0,0 +1,22 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { ThemeProvider } from "./components/theme-provider"
import { initStreamingState } from "./services/api"
// Initialize streaming state management (clears expired states)
initStreamingState();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<App />
</ThemeProvider>
</StrictMode>,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,239 @@
/**
* Streaming State Persistence
*
* Manages browser storage of streaming response state to enable:
* - Resume interrupted streams after page refresh
* - Graceful recovery from network disconnections
*/
import type { ExtendedResponseStreamEvent } from "@/types/openai";
export interface StreamingState {
conversationId: string;
responseId: string;
lastMessageId?: string;
lastSequenceNumber: number;
timestamp: number; // When this state was last updated
completed: boolean; // Whether the stream completed successfully
accumulatedText?: string; // Bounded tail preview for refresh restoration
accumulatedTextIsPreview?: boolean;
}
const STORAGE_KEY_PREFIX = "devui_streaming_state_";
const STATE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
const MAX_ACCUMULATED_TEXT_PREVIEW_CHARS = 16 * 1024;
interface CreateStreamingStateOptions {
conversationId: string;
responseId: string;
lastMessageId?: string;
lastSequenceNumber?: number;
accumulatedText?: string;
accumulatedTextIsPreview?: boolean;
}
/**
* Storage key for a specific conversation
*/
function getStorageKey(conversationId: string): string {
return `${STORAGE_KEY_PREFIX}${conversationId}`;
}
function normalizeAccumulatedTextPreview(state: StreamingState): StreamingState {
if (
state.accumulatedText === undefined ||
state.accumulatedText.length <= MAX_ACCUMULATED_TEXT_PREVIEW_CHARS
) {
return state;
}
return {
...state,
accumulatedText: state.accumulatedText.slice(-MAX_ACCUMULATED_TEXT_PREVIEW_CHARS),
accumulatedTextIsPreview: true,
};
}
/**
* Read raw streaming state from storage, including completed entries.
*/
function readStreamingState(conversationId: string): StreamingState | null {
const key = getStorageKey(conversationId);
const data = localStorage.getItem(key);
if (!data) {
return null;
}
const state: StreamingState = JSON.parse(data);
// Check if state has expired
const age = Date.now() - state.timestamp;
if (age > STATE_EXPIRY_MS) {
clearStreamingState(conversationId);
return null;
}
return normalizeAccumulatedTextPreview(state);
}
/**
* Create an initial streaming state snapshot.
*/
export function createStreamingState({
conversationId,
responseId,
lastMessageId,
lastSequenceNumber = -1,
accumulatedText,
accumulatedTextIsPreview = false,
}: CreateStreamingStateOptions): StreamingState {
return normalizeAccumulatedTextPreview({
conversationId,
responseId,
lastMessageId,
lastSequenceNumber,
timestamp: Date.now(),
completed: false,
accumulatedText,
accumulatedTextIsPreview,
});
}
/**
* Apply an incoming stream event to an in-memory streaming state snapshot.
*/
export function applyStreamingEventToState(
state: StreamingState,
event: ExtendedResponseStreamEvent,
responseId: string,
lastMessageId?: string
): StreamingState {
const sequenceNumber = "sequence_number" in event ? event.sequence_number : undefined;
const nextState: StreamingState = {
...state,
responseId,
lastMessageId,
timestamp: Date.now(),
completed: event.type === "response.completed" || event.type === "response.failed",
};
if (sequenceNumber !== undefined) {
nextState.lastSequenceNumber = sequenceNumber;
}
if (
event.type === "response.output_text.delta" &&
"delta" in event &&
typeof event.delta === "string" &&
event.delta.length > 0
) {
const accumulatedText = `${state.accumulatedText ?? ""}${event.delta}`;
const isPreview =
state.accumulatedTextIsPreview ||
accumulatedText.length > MAX_ACCUMULATED_TEXT_PREVIEW_CHARS;
nextState.accumulatedText = isPreview
? accumulatedText.slice(-MAX_ACCUMULATED_TEXT_PREVIEW_CHARS)
: accumulatedText;
nextState.accumulatedTextIsPreview = isPreview;
}
return nextState;
}
/**
* Save streaming state to browser storage
*/
export function saveStreamingState(state: StreamingState): void {
try {
const key = getStorageKey(state.conversationId);
const data = JSON.stringify(normalizeAccumulatedTextPreview(state));
localStorage.setItem(key, data);
} catch (error) {
console.error("Failed to save streaming state:", error);
// If storage is full, try to clear old states
try {
clearExpiredStreamingStates();
// Try again
const key = getStorageKey(state.conversationId);
const data = JSON.stringify(normalizeAccumulatedTextPreview(state));
localStorage.setItem(key, data);
} catch {
console.error("Failed to save streaming state even after cleanup");
}
}
}
/**
* Load streaming state from browser storage
*/
export function loadStreamingState(conversationId: string): StreamingState | null {
try {
const state = readStreamingState(conversationId);
if (!state) {
return null;
}
// If stream was completed, no need to resume
if (state.completed) {
return null;
}
return state;
} catch (error) {
console.error("Failed to load streaming state:", error);
return null;
}
}
/**
* Clear streaming state for a conversation
*/
export function clearStreamingState(conversationId: string): void {
try {
const key = getStorageKey(conversationId);
localStorage.removeItem(key);
} catch (error) {
console.error("Failed to clear streaming state:", error);
}
}
/**
* Clear all expired streaming states
*/
export function clearExpiredStreamingStates(): void {
try {
const keys = Object.keys(localStorage);
const now = Date.now();
for (const key of keys) {
if (key.startsWith(STORAGE_KEY_PREFIX)) {
try {
const data = localStorage.getItem(key);
if (data) {
const state: StreamingState = JSON.parse(data);
const age = now - state.timestamp;
if (age > STATE_EXPIRY_MS || state.completed) {
localStorage.removeItem(key);
}
}
} catch {
// Invalid state, remove it
localStorage.removeItem(key);
}
}
}
} catch (error) {
console.error("Failed to clear expired streaming states:", error);
}
}
/**
* Initialize streaming state management (call on app startup)
*/
export function initStreamingState(): void {
// Clear expired states on startup
clearExpiredStreamingStates();
}
@@ -0,0 +1,698 @@
/**
* DevUI Unified Store - Single source of truth for all app state
* Organized into logical slices: entity, conversation, UI, gallery, modals
*/
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import type {
AgentInfo,
WorkflowInfo,
ExtendedResponseStreamEvent,
Conversation,
PendingApproval,
OAIProxyMode,
WorkflowSession,
CheckpointInfo,
} from "@/types";
import type { ConversationItem } from "@/types/openai";
import type { AttachmentItem } from "@/components/ui/attachment-gallery";
const MAX_DEBUG_EVENTS = 1000;
const MAX_DEBUG_TEXT_DELTA_CHARS = 2048;
function prepareDebugEvent(event: ExtendedResponseStreamEvent): ExtendedResponseStreamEvent {
if (
event.type !== "response.output_text.delta" ||
!("delta" in event) ||
typeof event.delta !== "string" ||
event.delta.length <= MAX_DEBUG_TEXT_DELTA_CHARS
) {
return event;
}
const omittedChars = event.delta.length - MAX_DEBUG_TEXT_DELTA_CHARS;
return {
...event,
delta: `${event.delta.slice(0, MAX_DEBUG_TEXT_DELTA_CHARS)}\n...[${omittedChars} chars omitted from debug view]`,
};
}
// ========================================
// State Interface
// ========================================
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;
// Conversation Slice (per-agent state)
currentConversation: Conversation | undefined;
availableConversations: Conversation[];
chatItems: ConversationItem[];
isStreaming: boolean;
isSubmitting: boolean;
loadingConversations: boolean;
inputValue: string;
attachments: AttachmentItem[];
conversationUsage: {
total_tokens: number;
message_count: number;
};
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;
showToolCalls: boolean; // UI setting to show/hide tool calls in chat
streamingEnabled: boolean; // Whether to use streaming mode for responses
// Debug Panel Preferences (persisted)
debugPanelTab: "events" | "traces" | "tools"; // Main debug panel tab
debugTraceSubTab: "spans" | "context"; // OTel Spans vs Context Inspector
contextInspectorViewMode: "tokens" | "composition";
contextInspectorCumulative: boolean;
// Modal Slice
showAboutModal: boolean;
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: {
instrumentation: boolean;
openai_proxy: boolean;
deployment: boolean;
};
authRequired: boolean;
serverVersion: string | null;
// Deployment Slice
isDeploying: boolean;
deploymentLogs: string[];
lastDeployment: {
url: string;
authToken: string;
} | null;
azureDeploymentEnabled: boolean; // Feature flag for Azure deployment
}
// ========================================
// Actions Interface
// ========================================
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;
updateAgent: (agent: AgentInfo) => void;
updateWorkflow: (workflow: WorkflowInfo) => void;
removeEntity: (entityId: string) => void;
setEntityError: (error: string | null) => void;
setIsLoadingEntities: (loading: boolean) => void;
// Conversation Actions
setCurrentConversation: (conv: Conversation | undefined) => void;
setAvailableConversations: (convs: Conversation[]) => void;
setChatItems: (items: ConversationItem[]) => void;
setIsStreaming: (streaming: boolean) => void;
setIsSubmitting: (submitting: boolean) => void;
setLoadingConversations: (loading: boolean) => void;
setInputValue: (value: string) => void;
setAttachments: (files: AttachmentItem[]) => void;
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;
setIsResizing: (resizing: boolean) => void;
setShowToolCalls: (show: boolean) => void;
setStreamingEnabled: (enabled: boolean) => void;
// Debug Panel Preference Actions
setDebugPanelTab: (tab: "events" | "traces" | "tools") => void;
setDebugTraceSubTab: (tab: "spans" | "context") => void;
setContextInspectorViewMode: (mode: "tokens" | "composition") => void;
setContextInspectorCumulative: (cumulative: boolean) => void;
// Modal Actions
setShowAboutModal: (show: boolean) => void;
setShowGallery: (show: boolean) => void;
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: { instrumentation: boolean; openai_proxy: boolean; deployment: boolean }; authRequired: boolean; version?: string }) => 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;
}
type DevUIStore = DevUIState & DevUIActions;
// ========================================
// Store Implementation
// ========================================
export const useDevUIStore = create<DevUIStore>()(
devtools(
persist(
(set) => ({
// ========================================
// Initial State
// ========================================
// Entity State
agents: [],
workflows: [],
entities: [],
selectedAgent: undefined,
isLoadingEntities: true,
entityError: null,
// Conversation State
currentConversation: undefined,
availableConversations: [],
chatItems: [],
isStreaming: false,
isSubmitting: false,
loadingConversations: false,
inputValue: "",
attachments: [],
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,
showToolCalls: true, // Default to showing tool calls
streamingEnabled: true, // Default to streaming mode (recommended)
// Debug Panel Preferences (persisted)
debugPanelTab: "events", // Default to events tab
debugTraceSubTab: "spans", // Default to spans sub-tab
contextInspectorViewMode: "tokens", // Default to tokens view
contextInspectorCumulative: false, // Default to per-message view
// Modal State
showAboutModal: false,
showGallery: false,
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: {
instrumentation: false,
openai_proxy: false,
deployment: false,
},
authRequired: false,
serverVersion: null,
// 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] })),
addWorkflow: (workflow) =>
set((state) => ({ workflows: [...state.workflows, workflow] })),
updateAgent: (updatedAgent) =>
set((state) => ({
agents: state.agents.map((a) =>
a.id === updatedAgent.id ? updatedAgent : a
),
// Also update selectedAgent if it's the same one
selectedAgent:
state.selectedAgent?.id === updatedAgent.id &&
state.selectedAgent.type === "agent"
? updatedAgent
: state.selectedAgent,
})),
updateWorkflow: (updatedWorkflow) =>
set((state) => ({
workflows: state.workflows.map((w) =>
w.id === updatedWorkflow.id ? updatedWorkflow : w
),
// Also update selectedAgent if it's the same one
selectedAgent:
state.selectedAgent?.id === updatedWorkflow.id &&
state.selectedAgent.type === "workflow"
? updatedWorkflow
: state.selectedAgent,
})),
removeEntity: (entityId) =>
set((state) => ({
agents: state.agents.filter((a) => a.id !== entityId),
workflows: state.workflows.filter((w) => w.id !== entityId),
selectedAgent:
state.selectedAgent?.id === entityId
? undefined
: state.selectedAgent,
})),
setEntityError: (error) => set({ entityError: error }),
setIsLoadingEntities: (loading) => set({ isLoadingEntities: loading }),
// ========================================
// Conversation Actions
// ========================================
setCurrentConversation: (conv) => set({ currentConversation: conv }),
setAvailableConversations: (convs) =>
set({ availableConversations: convs }),
setChatItems: (items) => set({ chatItems: items }),
setIsStreaming: (streaming) => set({ isStreaming: streaming }),
setIsSubmitting: (submitting) => set({ isSubmitting: submitting }),
setLoadingConversations: (loading) =>
set({ loadingConversations: loading }),
setInputValue: (value) => set({ inputValue: value }),
setAttachments: (files) => set({ attachments: files }),
updateConversationUsage: (tokens) =>
set((state) => ({
conversationUsage: {
total_tokens: state.conversationUsage.total_tokens + tokens,
message_count: state.conversationUsage.message_count + 1,
},
})),
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 }),
setShowToolCalls: (show) => set({ showToolCalls: show }),
setStreamingEnabled: (enabled) => set({ streamingEnabled: enabled }),
addDebugEvent: (event) =>
set((state) => {
const eventForStorage = prepareDebugEvent(event);
// 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 lastEvent = state.debugEvents.length > 0
? state.debugEvents[state.debugEvents.length - 1] as { _uiTimestamp?: number }
: null;
const lastTimestamp = lastEvent?._uiTimestamp ?? 0;
// Ensure new timestamp is always greater than the last one
const uniqueTimestamp = Math.max(baseTimestamp, lastTimestamp + 1);
const retainedEvents = state.debugEvents.length >= MAX_DEBUG_EVENTS
? state.debugEvents.slice(-(MAX_DEBUG_EVENTS - 1))
: state.debugEvents;
return {
debugEvents: [
...retainedEvents,
{
...eventForStorage,
// Add UI display timestamp when event is received (Unix seconds)
// Each event gets a unique timestamp to preserve chronological order
_uiTimestamp: ('created_at' in eventForStorage && eventForStorage.created_at)
? eventForStorage.created_at
: uniqueTimestamp,
} as ExtendedResponseStreamEvent & { _uiTimestamp: number },
],
};
}),
clearDebugEvents: () => set({ debugEvents: [] }),
setIsResizing: (resizing) => set({ isResizing: resizing }),
// Debug Panel Preference Actions
setDebugPanelTab: (tab) => set({ debugPanelTab: tab }),
setDebugTraceSubTab: (tab) => set({ debugTraceSubTab: tab }),
setContextInspectorViewMode: (mode) => set({ contextInspectorViewMode: mode }),
setContextInspectorCumulative: (cumulative) => set({ contextInspectorCumulative: cumulative }),
// ========================================
// Modal Actions
// ========================================
setShowAboutModal: (show) => set({ showAboutModal: show }),
setShowGallery: (show) => set({ showGallery: show }),
setShowDeployModal: (show) => set({ showDeployModal: show }),
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,
serverVersion: meta.version || null,
}),
// ========================================
// 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
// ========================================
/**
* 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
*/
selectEntity: (entity) => {
set({
selectedAgent: entity,
// CRITICAL: Clear all conversation state when switching entities
currentConversation: undefined,
availableConversations: [], // Let AgentView reload conversations
chatItems: [],
inputValue: "",
attachments: [],
conversationUsage: { total_tokens: 0, message_count: 0 },
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: [],
});
// Update URL with selected entity ID
const url = new URL(window.location.href);
url.searchParams.set("entity_id", entity.id);
window.history.pushState({}, "", url);
},
}),
{
name: "devui-storage",
// Only persist UI preferences, not runtime state
partialize: (state) => ({
showDebugPanel: state.showDebugPanel,
debugPanelMinimized: state.debugPanelMinimized,
debugPanelWidth: state.debugPanelWidth,
showToolCalls: state.showToolCalls, // Persist tool calls visibility preference
streamingEnabled: state.streamingEnabled, // Persist streaming mode preference
oaiMode: state.oaiMode, // Persist OpenAI proxy mode settings
azureDeploymentEnabled: state.azureDeploymentEnabled, // Persist Azure deployment preference
// Debug panel tab preferences
debugPanelTab: state.debugPanelTab,
debugTraceSubTab: state.debugTraceSubTab,
contextInspectorViewMode: state.contextInspectorViewMode,
contextInspectorCumulative: state.contextInspectorCumulative,
}),
}
),
{ name: "DevUI Store" }
)
);
// ========================================
// Usage Notes
// ========================================
/**
* How to use the store:
*
* 1. For state access, use direct selectors:
* const agents = useDevUIStore((state) => state.agents);
*
* 2. For actions, extract them:
* const setAgents = useDevUIStore((state) => state.setAgents);
*
* 3. For combined state access (use sparingly, can cause unnecessary re-renders):
* const { agents, workflows } = useDevUIStore((state) => ({
* agents: state.agents,
* workflows: state.workflows
* }));
*
* 4. To access state outside React components:
* useDevUIStore.getState().agents
* useDevUIStore.getState().setAgents([...])
*/
@@ -0,0 +1,5 @@
/**
* Store exports - single entry point for all store hooks
*/
export { useDevUIStore } from "./devuiStore";
@@ -0,0 +1,347 @@
/**
* TypeScript interfaces matching OpenAI Responses API and Agent Framework Python types
* Generated from OpenAI SDK and Agent Framework _types.py, _threads.py, and _events.py
*/
// OpenAI Responses API Types - EXACT match to OpenAI SDK
export interface ResponseInputTextParam {
text: string;
/** The type of the input item. Always `input_text`. */
type: "input_text";
}
export interface ResponseInputImageParam {
/** The detail level of the image to be sent to the model. One of `high`, `low`, or `auto`. Defaults to `auto`. */
detail: "low" | "high" | "auto";
/** The type of the input item. Always `input_image`. */
type: "input_image";
/** The ID of the file to be sent to the model. */
file_id?: string;
/** The URL of the image to be sent to the model. A fully qualified URL or base64 encoded image in a data URL. */
image_url?: string;
}
export interface ResponseInputFileParam {
/** The type of the input item. Always `input_file`. */
type: "input_file";
/** The content of the file to be sent to the model. */
file_data: string;
/** The ID of the file to be sent to the model. */
file_id?: string;
/** The URL of the file to be sent to the model. */
file_url: string;
/** The name of the file to be sent to the model. */
filename: string;
}
// DevUI Extension: Function Approval Response Input
export interface ResponseInputFunctionApprovalParam {
/** The type of the input item. Always `function_approval_response`. */
type: "function_approval_response";
/** The ID of the approval request being responded to. */
request_id: string;
/** Whether the function call is approved. */
approved: boolean;
/** The function call being approved/rejected. */
function_call: {
id: string;
name: string;
arguments: Record<string, unknown>;
};
}
export type ResponseInputContent =
| ResponseInputTextParam
| ResponseInputImageParam
| ResponseInputFileParam
| ResponseInputFunctionApprovalParam;
export interface EasyInputMessage {
type?: "message";
role: "user" | "assistant" | "system" | "developer";
content: string | ResponseInputContent[];
}
export type ResponseInputItem = EasyInputMessage;
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;
input: string | ResponseInputParam | Record<string, unknown>; // Union type matching OpenAI + dict for workflows
stream?: boolean;
// OpenAI conversation parameter (standard!)
conversation?: string | { id: string };
// Common OpenAI optional fields
instructions?: string;
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
}
// Base types
export type Role = "system" | "user" | "assistant" | "tool";
export type FinishReason = "content_filter" | "length" | "stop" | "tool_calls";
export type CreatedAtT = string; // ISO timestamp
// Content type discriminator
export type ContentType =
| "text"
| "function_call"
| "function_result"
| "text_reasoning"
| "data"
| "uri"
| "error"
| "usage"
| "hosted_file"
| "hosted_vector_store";
// Base content interface
export interface BaseContent {
type: ContentType;
annotations?: unknown[];
additional_properties?: Record<string, unknown>;
raw_representation?: unknown;
}
// Specific content types
export interface TextContent extends BaseContent {
type: "text";
text: string;
}
export interface FunctionCallContent extends BaseContent {
type: "function_call";
call_id: string;
name: string;
arguments?: string | Record<string, unknown>;
exception?: unknown;
}
export interface FunctionResultContent extends BaseContent {
type: "function_result";
call_id: string;
result?: unknown;
exception?: unknown;
}
export interface TextReasoningContent extends BaseContent {
type: "text_reasoning";
text: string;
reasoning: string;
}
export interface DataContent extends BaseContent {
type: "data";
uri: string;
media_type?: string;
}
export interface UriContent extends BaseContent {
type: "uri";
uri: string;
media_type?: string;
}
export interface ErrorContent extends BaseContent {
type: "error";
error: string;
error_code?: string;
}
export interface UsageContent extends BaseContent {
type: "usage";
usage_data: unknown;
}
export interface HostedFileContent extends BaseContent {
type: "hosted_file";
file_id: string;
}
export interface HostedVectorStoreContent extends BaseContent {
type: "hosted_vector_store";
vector_store_id: string;
}
// Union type for all content
export type Content =
| TextContent
| FunctionCallContent
| FunctionResultContent
| TextReasoningContent
| DataContent
| UriContent
| ErrorContent
| UsageContent
| HostedFileContent
| HostedVectorStoreContent;
// Usage details
export interface UsageDetails {
completion_tokens?: number;
prompt_tokens?: number;
total_tokens?: number;
additional_properties?: Record<string, unknown>;
}
// Agent run response update (streaming)
export interface AgentResponseUpdate {
contents: Content[];
role?: Role;
author_name?: string;
response_id?: string;
message_id?: string;
created_at?: CreatedAtT;
additional_properties?: Record<string, unknown>;
raw_representation?: unknown;
// Additional property that may be present (concatenated text from all TextContent)
text?: string;
}
// Agent run response (final)
export interface AgentResponse {
messages: Message[];
response_id?: string;
created_at?: CreatedAtT;
usage_details?: UsageDetails;
raw_representation?: unknown;
additional_properties?: Record<string, unknown>;
}
// Chat message
export interface Message {
contents: Content[];
role?: Role;
author_name?: string;
message_id?: string;
created_at?: CreatedAtT;
additional_properties?: Record<string, unknown>;
raw_representation?: unknown;
}
// Chat response update (model client streaming)
export interface ChatResponseUpdate {
contents: Content[];
role?: Role;
author_name?: string;
response_id?: string;
message_id?: string;
conversation_id?: string;
model_id?: string;
created_at?: CreatedAtT;
finish_reason?: FinishReason;
additional_properties?: Record<string, unknown>;
raw_representation?: unknown;
}
// Agent thread (internal AgentFramework type - not exposed via DevUI API)
// Note: DevUI uses OpenAI Conversations API. This type represents the internal
// AgentThread used by the framework for execution, wrapped by ConversationStore.
export interface AgentThread {
service_thread_id?: string;
message_store?: unknown; // ChatMessageStore - could be typed further if needed
}
// Workflow events
export interface WorkflowEvent {
type?: string; // Event class name like "WorkflowOutputEvent", "WorkflowCompletedEvent", "ExecutorInvokedEvent", etc.
data?: unknown;
executor_id?: string; // Present for executor-related events and WorkflowOutputEvent
}
export interface WorkflowStartedEvent extends WorkflowEvent {
// Event-specific data for workflow start
readonly event_type: "workflow_started";
}
export interface WorkflowCompletedEvent extends WorkflowEvent {
// Event-specific data for workflow completion (legacy)
readonly event_type: "workflow_completed";
}
export interface WorkflowOutputEvent extends WorkflowEvent {
// Event-specific data for workflow output (new)
readonly event_type: "workflow_output";
executor_id: string; // ID of executor that yielded the output
}
export interface WorkflowWarningEvent extends WorkflowEvent {
data: string; // Warning message
}
export interface WorkflowErrorEvent extends WorkflowEvent {
data: Error; // Exception
}
export interface ExecutorEvent extends WorkflowEvent {
executor_id: string;
}
export interface AgentRunUpdateEvent extends ExecutorEvent {
data?: AgentResponseUpdate;
}
export interface AgentRunEvent extends ExecutorEvent {
data?: AgentResponse;
}
// Span event structure (from OpenTelemetry)
export interface SpanEvent {
name: string;
timestamp: number;
attributes: Record<string, unknown>;
}
// Trace span for streaming
export interface TraceSpan {
span_id: string;
parent_span_id?: string;
operation_name: string;
start_time: number;
end_time?: number;
duration_ms?: number;
attributes: Record<string, unknown>;
events: SpanEvent[];
status: string;
raw_span?: Record<string, unknown>;
}
// Helper type guards for Agent Framework content types
export function isTextContent(content: Content): content is TextContent {
return content.type === "text";
}
export function isFunctionCallContent(
content: Content
): content is FunctionCallContent {
return content.type === "function_call";
}
export function isFunctionResultContent(
content: Content
): content is FunctionResultContent {
return content.type === "function_result";
}
@@ -0,0 +1,327 @@
/**
* Core TypeScript types for DevUI Frontend
* Matches backend API models for strict type safety
*/
export type AgentType = "agent" | "workflow";
export type AgentSource = "directory" | "in_memory" | "remote_gallery";
export type StreamEventType =
| "agent_run_update"
| "workflow_event"
| "workflow_structure"
| "completion"
| "error"
| "debug_trace"
| "trace_span";
export interface EnvVarRequirement {
name: string;
description: string;
required: boolean;
example?: string;
}
export interface AgentInfo {
id: string;
name?: string;
description?: string;
type: AgentType;
source: AgentSource;
tools: string[];
has_env: boolean;
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_id?: string;
chat_client_type?: string;
context_provider?: string | undefined;
middleware?: string[] | undefined;
}
// JSON Schema types for workflow input
export interface JSONSchemaProperty {
type?:
| "string"
| "number"
| "integer"
| "boolean"
| "array"
| "object"
| "null";
description?: string;
default?: unknown;
enum?: string[];
format?: string;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
items?: JSONSchemaProperty;
// Union types (Pydantic generates these for Optional[T], Union[T1, T2], etc.)
anyOf?: JSONSchemaProperty[];
oneOf?: JSONSchemaProperty[];
allOf?: JSONSchemaProperty[];
// Additional JSON Schema properties
title?: string;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
}
export interface JSONSchema {
type: "string" | "number" | "integer" | "boolean" | "array" | "object";
description?: string;
default?: unknown;
enum?: string[];
format?: string;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
items?: JSONSchemaProperty;
}
export interface WorkflowInfo extends Omit<AgentInfo, "tools"> {
executors: string[]; // List of executor IDs in this workflow
workflow_dump?: import("./workflow").Workflow; // Typed workflow structure
mermaid_diagram?: string;
// Input specification for dynamic form generation
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)
export interface Conversation {
id: string;
object: "conversation";
created_at: number;
metadata?: Record<string, unknown>;
}
export interface RunAgentRequest {
input: import("./agent-framework").ResponseInputParam;
conversation_id?: string; // OpenAI standard conversation parameter
}
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
// Re-export OpenAI types
export type {
ResponseStreamEvent,
ResponseTextDeltaEvent,
OpenAIResponse,
OpenAIError,
// New structured event types
ExtendedResponseStreamEvent,
ResponseWorkflowEventComplete,
ResponseTraceEventComplete,
ResponseOutputItemAddedEvent,
ResponseOutputItemDoneEvent,
ResponseCreatedEvent,
ResponseInProgressEvent,
ResponseCompletedEvent,
ResponseFailedEvent,
ResponseFunctionResultComplete,
ResponseFunctionToolCall,
StructuredEvent,
WorkflowItem,
ExecutorActionItem,
} from "./openai";
export { isExecutorAction } from "./openai";
// Re-export Agent Framework types
export type {
AgentFrameworkRequest,
AgentFrameworkExtraBody,
ResponseInputParam,
ResponseInputTextParam,
ResponseInputImageParam,
ResponseInputFileParam,
} from "./agent-framework";
export interface HealthResponse {
status: "healthy";
agents_dir?: string;
version: string;
}
export interface MetaResponse {
ui_mode: "developer" | "user";
version: string;
framework: string;
runtime: "python" | "dotnet";
capabilities: {
instrumentation: boolean;
openai_proxy: boolean;
deployment: boolean;
};
auth_required: boolean;
}
// Chat message types matching Agent Framework
export interface Message {
id: string;
role: "user" | "assistant" | "system" | "tool";
contents: import("./agent-framework").Content[];
timestamp: string;
streaming?: boolean;
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
export interface AppState {
selectedAgent?: AgentInfo | WorkflowInfo;
currentConversation?: Conversation;
agents: AgentInfo[];
workflows: WorkflowInfo[];
isLoading: boolean;
error?: string;
}
export interface ChatState {
messages: Message[];
isStreaming: boolean;
// streamEvents removed - use OpenAI events directly instead
}
// DevUI-specific: Pending approval state
export interface PendingApproval {
request_id: string;
function_call: {
id: string;
name: string;
arguments: Record<string, unknown>;
};
}
// 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";
checkpoint_summary?: {
count: number;
latest_iteration: number;
has_pending_hil: boolean;
pending_hil_count: number;
};
[key: string]: unknown;
};
}
export interface CheckpointInfo {
checkpoint_id: string;
workflow_id: string;
timestamp: number;
iteration_count: number;
metadata?: Record<string, unknown>;
}
// Full checkpoint data structure
export interface FullCheckpoint {
checkpoint_id: string;
workflow_id: string;
timestamp: string;
messages: Record<string, unknown[]>;
state: Record<string, unknown>;
pending_request_info_events: Record<string, PendingRequestInfoEvent>;
iteration_count: number;
metadata: Record<string, unknown>;
version: string;
}
// Pending request info event data
export interface PendingRequestInfoEvent {
source_executor_id: string;
request_type?: string;
response_type?: string;
request_data?: Record<string, unknown>;
request_schema?: Record<string, unknown>;
timestamp?: string;
}
// Checkpoint item from conversation items API
export interface CheckpointItem {
id: string;
type: "checkpoint";
checkpoint_id: string;
workflow_id: string;
timestamp: string;
status: "completed";
metadata: {
iteration_count: number;
pending_hil_count: number;
has_pending_hil: boolean;
message_count: number;
size_bytes?: number;
version: string;
full_checkpoint?: FullCheckpoint;
};
}
@@ -0,0 +1,841 @@
/**
* OpenAI Response API types for Agent Framework Server
* Based on OpenAI's official response types
*/
// OpenAI Response Error (from response_error.py)
export type ResponseErrorCode =
| "server_error"
| "rate_limit_exceeded"
| "invalid_prompt"
| "vector_store_timeout"
| "invalid_image"
| "invalid_image_format"
| "invalid_base64_image"
| "invalid_image_url"
| "image_too_large"
| "image_too_small"
| "image_parse_error"
| "image_content_policy_violation"
| "invalid_image_mode"
| "image_file_too_large"
| "unsupported_image_media_type"
| "empty_image_file"
| "failed_to_download_image"
| "image_file_not_found";
export interface ResponseError {
code: ResponseErrorCode;
message: string;
}
// OpenAI Response Usage (from response_usage.py)
export interface ResponseUsage {
input_tokens: number;
output_tokens: number;
total_tokens: number;
input_tokens_details?: {
cached_tokens: number;
};
output_tokens_details?: {
reasoning_tokens: number;
};
}
// Core OpenAI Response Stream Event
export interface ResponseStreamEvent {
type: string;
item_id?: string;
output_index?: number;
content_index?: number;
sequence_number?: number;
// Different event types
delta?: string; // For text delta events
logprobs?: Record<string, unknown>[];
// Meta info
id?: string;
object?: string;
created_at?: number;
}
// Standard OpenAI Response Lifecycle Events
export interface ResponseCreatedEvent {
type: "response.created";
response: {
id: string;
status: "in_progress";
created_at: number;
output?: ResponseOutputItem[];
};
sequence_number?: number;
}
export interface ResponseInProgressEvent {
type: "response.in_progress";
response: {
id: string;
status: "in_progress";
};
sequence_number?: number;
}
export interface ResponseCompletedEvent {
type: "response.completed";
response: {
id: string;
status?: "completed";
usage?: ResponseUsage; // Optional usage information
model?: string; // Optional model information
output?: ResponseOutputItem[]; // Output items
error?: ResponseError; // Error if failed
metadata?: Record<string, unknown>; // Additional metadata
};
sequence_number?: number;
}
export interface ResponseFailedEvent {
type: "response.failed";
response: {
id: string;
status: "failed";
error?: ResponseError;
};
sequence_number?: number;
}
// Custom Agent Framework OpenAI event types with structured data
export interface ResponseWorkflowEventComplete {
type: "response.workflow_event.completed";
data: {
event_type: string;
data?: Record<string, unknown>;
executor_id?: string;
timestamp: string;
};
executor_id?: string;
item_id: string;
output_index: number;
sequence_number: number;
}
// Function call event types - matching actual backend output
export interface ResponseFunctionCallComplete {
type: "response.function_call.complete";
data: {
name: string;
arguments: string | object;
call_id: string;
};
item_id?: string;
output_index?: number;
sequence_number?: number;
}
export interface ResponseFunctionCallDelta {
type: "response.function_call.delta";
data: {
name?: string;
call_id?: string;
};
item_id?: string;
output_index?: number;
sequence_number?: number;
}
export interface ResponseFunctionCallArgumentsDelta {
type: "response.function_call_arguments.delta";
delta: string;
data?: {
call_id?: string;
arguments?: string;
};
item_id?: string;
output_index?: number;
sequence_number?: number;
}
// OpenAI Responses API - Function Tool Call Item
export interface ResponseFunctionToolCall {
id: string; // Item ID
call_id: string; // Call ID for pairing with results
name: string; // Function name
arguments: string; // JSON arguments
type: "function_call";
status?: "in_progress" | "completed" | "incomplete";
}
// 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
id: string;
status?: "in_progress" | "completed" | "failed" | "cancelled";
[key: string]: unknown; // Allow any additional fields with unknown type
}
// Executor Action Item (DevUI specific)
export interface ExecutorActionItem extends WorkflowItem {
type: "executor_action";
executor_id: string;
metadata?: Record<string, unknown>;
result?: unknown;
error?: unknown;
}
// Type guard for executor actions
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)
export interface ResponseOutputItemAddedEvent {
type: "response.output_item.added";
item: ResponseOutputItem;
output_index: number;
sequence_number?: number;
}
export interface ResponseOutputItemDoneEvent {
type: "response.output_item.done";
item: ResponseOutputItem;
output_index: number;
sequence_number?: number;
}
// Trace event - matching actual backend output
export interface ResponseTraceEventComplete {
type: "response.trace.completed";
data: {
operation_name?: string;
duration_ms?: number;
status?: string;
attributes?: Record<string, unknown>;
timestamp: string;
};
item_id?: string;
output_index?: number;
sequence_number?: number;
}
// New trace event format from backend
export interface ResponseTraceComplete {
type: "response.trace.completed";
data: {
type?: string;
span_id?: string;
trace_id?: string;
parent_span_id?: string | null;
operation_name?: string;
start_time?: number;
end_time?: number;
duration_ms?: number;
attributes?: Record<string, unknown>;
status?: string;
session_id?: string | null;
entity_id?: string;
timestamp?: string;
};
item_id?: string;
output_index?: number;
sequence_number?: number;
}
// Error event - matching backend ResponseErrorEvent
export interface ResponseErrorEvent extends ResponseStreamEvent {
type: "error";
message: string;
code?: string;
param?: string;
sequence_number: number;
}
// DevUI Extension: Function Approval Events
export interface ResponseFunctionApprovalRequestedEvent {
type: "response.function_approval.requested";
request_id: string;
function_call: {
id: string;
name: string;
arguments: Record<string, unknown>;
};
item_id: string;
output_index: number;
sequence_number: number;
}
export interface ResponseFunctionApprovalRespondedEvent {
type: "response.function_approval.responded";
request_id: string;
approved: boolean;
item_id: string;
output_index: number;
sequence_number: number;
}
// DevUI Extension: Function Result Complete
export interface ResponseFunctionResultComplete {
type: "response.function_result.complete";
call_id: string;
output: string;
status: "in_progress" | "completed" | "incomplete";
item_id: string;
output_index: number;
sequence_number: number;
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";
timestamp: string;
collapsed?: boolean;
}
// Union type for all structured events
export type StructuredEvent =
| ResponseCreatedEvent
| ResponseInProgressEvent
| ResponseCompletedEvent
| ResponseFailedEvent
| ResponseWorkflowEventComplete
| ResponseTraceEventComplete
| ResponseTraceComplete
| ResponseOutputItemAddedEvent
| ResponseOutputItemDoneEvent
| ResponseFunctionCallComplete
| ResponseFunctionCallDelta
| ResponseFunctionCallArgumentsDelta
| ResponseFunctionResultComplete
| ResponseRequestInfoEvent
| ResponseErrorEvent
| ResponseFunctionApprovalRequestedEvent
| ResponseFunctionApprovalRespondedEvent
| TurnSeparatorEvent;
// Extended stream event that includes our structured events
export type ExtendedResponseStreamEvent = ResponseStreamEvent | StructuredEvent;
// Text delta event - the main one we'll use
export interface ResponseTextDeltaEvent extends ResponseStreamEvent {
type: "response.output_text.delta";
delta: string;
item_id: string;
output_index: number;
content_index: number;
sequence_number: number;
logprobs: Record<string, unknown>[];
metadata?: Record<string, unknown>;
}
// OpenAI Response for non-streaming
export interface OpenAIResponse {
id: string;
object: "response";
created_at: number;
model: string;
output: ResponseOutputMessage[];
usage: ResponseUsage;
parallel_tool_calls: boolean;
tool_choice: string;
tools: Record<string, unknown>[];
}
export interface ResponseOutputMessage {
type: "message";
role: "assistant";
content: ResponseOutputText[];
id: string;
status: "completed" | "failed" | "in_progress";
metadata?: Record<string, unknown>;
}
export interface ResponseOutputText {
type: "output_text";
text: string;
annotations: Record<string, unknown>[];
}
// Note: ResponseUsage is defined at the top of this file
// Request format for Agent Framework
// AgentFrameworkRequest moved to agent-framework.ts to avoid conflicts
// Error response
export interface OpenAIError {
error: {
message: string;
type: string;
code?: string;
};
}
// ============================================================================
// OpenAI Conversations API Types - for conversation history
// ============================================================================
// Message content types (what goes inside Message.content[])
export interface MessageTextContent {
type: "text";
text: string;
}
export interface MessageInputTextContent {
type: "input_text";
text: string;
}
// Annotation types for output text (from response_output_text.py)
export interface AnnotationFileCitation {
type: "file_citation";
file_id: string;
filename: string;
index: number;
}
export interface AnnotationURLCitation {
type: "url_citation";
url: string;
title: string;
start_index: number;
end_index: number;
}
export interface AnnotationContainerFileCitation {
type: "container_file_citation";
container_id: string;
file_id: string;
filename: string;
start_index: number;
end_index: number;
}
export interface AnnotationFilePath {
type: "file_path";
file_id: string;
index: number;
}
export type OutputTextAnnotation =
| AnnotationFileCitation
| AnnotationURLCitation
| AnnotationContainerFileCitation
| AnnotationFilePath;
// Logprob types for output text
export interface LogprobTopLogprob {
token: string;
bytes: number[];
logprob: number;
}
export interface Logprob {
token: string;
bytes: number[];
logprob: number;
top_logprobs: LogprobTopLogprob[];
}
export interface MessageOutputTextContent {
type: "output_text";
text: string;
annotations?: OutputTextAnnotation[];
logprobs?: Logprob[];
}
export interface MessageInputImage {
type: "input_image";
image_url: string;
detail?: "low" | "high" | "auto";
file_id?: string;
}
export interface MessageInputFile {
type: "input_file";
file_url?: string;
file_data?: string;
file_id?: string;
filename?: string;
}
// DevUI Extension: Function approval 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";
request_id: string;
approved: boolean;
function_call: {
id: string;
name: string;
arguments: Record<string, unknown>;
};
}
// ============================================================================
// 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)
export interface ConversationMessage {
id: string;
type: "message";
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;
total_tokens: number;
};
}
// Function call item (separate from message)
export interface ConversationFunctionCall {
id: string;
type: "function_call";
call_id: string;
name: string;
arguments: string;
status: "in_progress" | "completed" | "incomplete";
created_at?: number; // Unix timestamp in seconds - when this function call was made
}
// Function call output item
export interface ConversationFunctionCallOutput {
id: string;
type: "function_call_output";
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
export type ConversationItem =
| ConversationMessage
| ConversationFunctionCall
| ConversationFunctionCallOutput;
// Conversation metadata
export interface Conversation {
id: string;
object: "conversation";
created_at: number;
metadata?: Record<string, unknown>;
}
// ============================================================================
// OpenTelemetry Trace Attribute Keys
// Mirrored from Python: agent_framework/observability.py ObservabilityAttributes
// ============================================================================
/**
* Standard attribute keys for OpenTelemetry traces.
* These match the Python ObservabilityAttributes enum exactly.
*/
export const TraceAttributes = {
// Request attributes
MODEL: "gen_ai.request.model",
MAX_TOKENS: "gen_ai.request.max_tokens",
TEMPERATURE: "gen_ai.request.temperature",
TOP_P: "gen_ai.request.top_p",
SEED: "gen_ai.request.seed",
FREQUENCY_PENALTY: "gen_ai.request.frequency_penalty",
PRESENCE_PENALTY: "gen_ai.request.presence_penalty",
STOP_SEQUENCES: "gen_ai.request.stop_sequences",
// Response attributes
FINISH_REASONS: "gen_ai.response.finish_reasons",
RESPONSE_ID: "gen_ai.response.id",
// Usage attributes
INPUT_TOKENS: "gen_ai.usage.input_tokens",
OUTPUT_TOKENS: "gen_ai.usage.output_tokens",
// Content attributes (messages sent/received)
INPUT_MESSAGES: "gen_ai.input.messages",
OUTPUT_MESSAGES: "gen_ai.output.messages",
SYSTEM_INSTRUCTIONS: "gen_ai.system_instructions",
OUTPUT_TYPE: "gen_ai.output.type",
// Tool attributes
TOOL_CALL_ID: "gen_ai.tool.call.id",
TOOL_NAME: "gen_ai.tool.name",
TOOL_TYPE: "gen_ai.tool.type",
TOOL_DEFINITIONS: "gen_ai.tool.definitions",
TOOL_ARGUMENTS: "gen_ai.tool.call.arguments",
TOOL_RESULT: "gen_ai.tool.call.result",
// Agent attributes
AGENT_ID: "gen_ai.agent.id",
AGENT_NAME: "gen_ai.agent.name",
AGENT_DESCRIPTION: "gen_ai.agent.description",
CONVERSATION_ID: "gen_ai.conversation.id",
// Workflow attributes
WORKFLOW_ID: "workflow.id",
WORKFLOW_NAME: "workflow.name",
EXECUTOR_ID: "executor.id",
EXECUTOR_TYPE: "executor.type",
} as const;
/**
* Type for trace attribute keys - ensures type safety when accessing attributes
*/
export type TraceAttributeKey = (typeof TraceAttributes)[keyof typeof TraceAttributes];
/**
* Typed interface for known trace attributes.
* Using this instead of Record<string, unknown> provides compile-time safety.
*/
export interface TypedTraceAttributes {
// Request attributes
[TraceAttributes.MODEL]?: string;
[TraceAttributes.MAX_TOKENS]?: number;
[TraceAttributes.TEMPERATURE]?: number;
[TraceAttributes.TOP_P]?: number;
[TraceAttributes.SEED]?: number;
// Usage attributes
[TraceAttributes.INPUT_TOKENS]?: number;
[TraceAttributes.OUTPUT_TOKENS]?: number;
// Content attributes (JSON strings that need parsing)
[TraceAttributes.INPUT_MESSAGES]?: string;
[TraceAttributes.OUTPUT_MESSAGES]?: string;
[TraceAttributes.SYSTEM_INSTRUCTIONS]?: string;
// Tool attributes
[TraceAttributes.TOOL_NAME]?: string;
[TraceAttributes.TOOL_DEFINITIONS]?: string;
[TraceAttributes.TOOL_ARGUMENTS]?: string;
[TraceAttributes.TOOL_RESULT]?: string;
// Agent/workflow attributes
[TraceAttributes.AGENT_NAME]?: string;
[TraceAttributes.WORKFLOW_NAME]?: string;
[TraceAttributes.EXECUTOR_ID]?: string;
// Allow additional unknown attributes
[key: string]: unknown;
}
/**
* Message part types used in gen_ai.input.messages / gen_ai.output.messages
*
* Source: Python agent_framework/observability.py _to_otel_part()
*
* Python produces:
* - text: {"type": "text", "content": "..."}
* - function_call: {"type": "tool_call", "id": "...", "name": "...", "arguments": "..."}
* - function_result: {"type": "tool_call_response", "id": "...", "response": "..."}
*/
// Text content part
// Python: {"type": "text", "content": content.text}
export interface TraceTextPart {
type: "text";
content?: string; // Agent Framework format (from Python)
text?: string; // Alternative field name (OpenAI format)
}
// Tool/function call part (from assistant)
// Python: {"type": "tool_call", "id": content.call_id, "name": content.name, "arguments": content.arguments}
export interface TraceToolCallPart {
type: "tool_call" | "function_call";
id?: string; // Tool call ID for correlation
name?: string; // Function name
arguments?: string; // JSON string of arguments
}
// Tool/function result part (response to tool call)
// Python: {"type": "tool_call_response", "id": content.call_id, "response": response}
export interface TraceToolResultPart {
type: "tool_call_response" | "tool_result" | "function_result";
id?: string; // Tool call ID for correlation
response?: string; // Agent Framework format (from Python)
result?: string; // Alternative field name (other formats)
}
// Union type for all message parts
export type TraceMessagePart = TraceTextPart | TraceToolCallPart | TraceToolResultPart;
// Helper type guard functions
export function isTextPart(part: TraceMessagePart): part is TraceTextPart {
return part.type === "text";
}
export function isToolCallPart(part: TraceMessagePart): part is TraceToolCallPart {
return part.type === "tool_call" || part.type === "function_call";
}
export function isToolResultPart(part: TraceMessagePart): part is TraceToolResultPart {
return (
part.type === "tool_result" ||
part.type === "function_result" ||
part.type === "tool_call_response"
);
}
/**
* Message structure in gen_ai.input.messages / gen_ai.output.messages
* Format: [{role: "system"|"user"|"assistant"|"tool", parts: [...]}]
*/
export interface TraceMessage {
role: "system" | "user" | "assistant" | "tool";
parts: TraceMessagePart[];
}
/**
* Helper to safely get a typed attribute value
*/
export function getTraceAttribute<K extends keyof TypedTraceAttributes>(
attributes: TypedTraceAttributes,
key: K
): TypedTraceAttributes[K] {
return attributes[key];
}
/**
* Helper to parse JSON message array from trace attributes
*/
export function parseTraceMessages(jsonString: string | undefined): TraceMessage[] {
if (!jsonString) return [];
try {
return JSON.parse(jsonString) as TraceMessage[];
} catch {
return [];
}
}
// Stored trace span (from conversation metadata)
export interface TraceSpan {
type?: string;
span_id: string;
trace_id: string;
parent_span_id?: string | null;
operation_name: string;
start_time: number;
end_time?: number;
duration_ms?: number;
attributes: TypedTraceAttributes;
status: string;
response_id?: string | null;
entity_id?: string;
events?: Array<{
name: string;
timestamp: number;
attributes?: Record<string, unknown>;
}>;
error?: string;
}
// List response with trace metadata (DevUI extension)
export interface ConversationItemsListResponse {
object: "list";
data: ConversationItem[];
has_more: boolean;
metadata?: {
traces?: TraceSpan[];
};
}
@@ -0,0 +1,167 @@
// TypeScript types that mirror the agent_framework_workflow structure
// for better type safety and consistency with the backend
/**
* Base executor interface that mirrors agent_framework_workflow._executor.Executor
*/
export interface Executor {
id: string;
type: string; // The executor class name (AgentExecutor, FunctionExecutor, etc.)
[key: string]: unknown; // Additional executor-specific properties
}
/**
* Specific executor types that extend the base Executor
*/
export interface AgentExecutor extends Executor {
type: "AgentExecutor";
agent_protocol?: unknown; // The wrapped agent
streaming: boolean;
}
export interface FunctionExecutor extends Executor {
type: "FunctionExecutor";
function_name?: string;
}
export interface RequestInfoExecutor extends Executor {
type: "RequestInfoExecutor";
}
export interface WorkflowExecutor extends Executor {
type: "WorkflowExecutor";
workflow: Workflow; // Nested workflow
}
export interface MagenticOrchestratorExecutor extends Executor {
type: "MagenticOrchestratorExecutor";
}
export interface MagenticAgentExecutor extends Executor {
type: "MagenticAgentExecutor";
}
/**
* Edge interface that mirrors agent_framework_workflow._edge.Edge
*/
export interface Edge {
source_id: string;
target_id: string;
condition_name?: string; // Name of condition function for serialization
}
/**
* Base edge group interface that mirrors agent_framework_workflow._edge.EdgeGroup
*/
export interface EdgeGroup {
id: string;
type: string; // The edge group class name
edges: Edge[];
}
/**
* Specific edge group types
*/
export interface SingleEdgeGroup extends EdgeGroup {
type: "SingleEdgeGroup";
}
export interface FanOutEdgeGroup extends EdgeGroup {
type: "FanOutEdgeGroup";
selection_func_name?: string; // Name of selection function
}
export interface FanInEdgeGroup extends EdgeGroup {
type: "FanInEdgeGroup";
}
export interface SwitchCaseEdgeGroup extends EdgeGroup {
type: "SwitchCaseEdgeGroup";
cases: Array<{
condition_name?: string;
target_id: string;
}>;
}
/**
* Main Workflow interface that mirrors agent_framework_workflow._workflow.Workflow
* This provides strong typing for the workflow_dump field
*/
export interface Workflow {
id: string;
edge_groups: EdgeGroup[];
executors: Record<string, Executor>;
start_executor_id: string;
max_iterations: number;
}
/**
* Type guards for runtime type checking
*/
export function isWorkflow(obj: unknown): obj is Workflow {
if (typeof obj !== "object" || obj === null) return false;
const record = obj as Record<string, unknown>;
return (
"id" in record &&
"edge_groups" in record &&
"executors" in record &&
"start_executor_id" in record &&
"max_iterations" in record &&
typeof record.id === "string" &&
Array.isArray(record.edge_groups) &&
typeof record.executors === "object" &&
typeof record.start_executor_id === "string" &&
typeof record.max_iterations === "number"
);
}
export function isExecutor(obj: unknown): obj is Executor {
if (typeof obj !== "object" || obj === null) return false;
const record = obj as Record<string, unknown>;
return (
"id" in record &&
"type" in record &&
typeof record.id === "string" &&
typeof record.type === "string"
);
}
export function isEdge(obj: unknown): obj is Edge {
if (typeof obj !== "object" || obj === null) return false;
const record = obj as Record<string, unknown>;
return (
"source_id" in record &&
"target_id" in record &&
typeof record.source_id === "string" &&
typeof record.target_id === "string"
);
}
export function isEdgeGroup(obj: unknown): obj is EdgeGroup {
if (typeof obj !== "object" || obj === null) return false;
const record = obj as Record<string, unknown>;
return (
"id" in record &&
"type" in record &&
"edges" in record &&
typeof record.id === "string" &&
typeof record.type === "string" &&
Array.isArray(record.edges)
);
}
/**
* Utility type for workflow dump that can be either a properly typed Workflow
* or a generic object (for backwards compatibility during transition)
*/
export type WorkflowDump = Workflow | Record<string, unknown>;
/**
* Helper function to safely access workflow dump as a typed Workflow
*/
export function getTypedWorkflow(workflowDump: WorkflowDump): Workflow | null {
if (isWorkflow(workflowDump)) {
return workflowDump;
}
return null;
}
@@ -0,0 +1,139 @@
import type { Node, Edge } from "@xyflow/react";
import type { ExecutorNodeData } from "@/components/features/workflow/executor-node";
/**
* Lightweight auto-layout algorithm to replace dagre
* Handles fan-out nodes properly by spacing siblings
*/
export function applySimpleLayout(
nodes: Node<ExecutorNodeData>[],
edges: Edge[],
direction: "TB" | "LR" = "LR"
): Node<ExecutorNodeData>[] {
if (nodes.length === 0) return nodes;
if (nodes.length === 1) {
return nodes.map((node) => ({
...node,
position: { x: 0, y: 0 },
}));
}
// Create adjacency maps
const outgoingEdges = new Map<string, string[]>();
const incomingEdges = new Map<string, string[]>();
nodes.forEach((node) => {
outgoingEdges.set(node.id, []);
incomingEdges.set(node.id, []);
});
edges.forEach((edge) => {
outgoingEdges.get(edge.source)?.push(edge.target);
incomingEdges.get(edge.target)?.push(edge.source);
});
// Find root nodes (nodes with no incoming edges)
const rootNodes = nodes.filter(
(node) => (incomingEdges.get(node.id) || []).length === 0
);
if (rootNodes.length === 0) {
// Fallback: use first node as root if no clear root
rootNodes.push(nodes[0]);
}
// Constants for spacing
const NODE_WIDTH = 220;
const NODE_HEIGHT = 120;
const HORIZONTAL_SPACING = direction === "LR" ? 350 : 280;
const VERTICAL_SPACING = direction === "TB" ? 250 : 180;
// Track positioned nodes and level information
const positioned = new Map<string, { x: number; y: number; level: number }>();
const levelGroups = new Map<number, string[]>();
// Build level groups using BFS
const queue: Array<{ nodeId: string; level: number }> = [];
const visited = new Set<string>();
// Start with root nodes at level 0
rootNodes.forEach((node) => {
queue.push({ nodeId: node.id, level: 0 });
});
// BFS to assign levels
while (queue.length > 0) {
const { nodeId, level } = queue.shift()!;
if (visited.has(nodeId)) continue;
visited.add(nodeId);
// Add to level group
if (!levelGroups.has(level)) {
levelGroups.set(level, []);
}
levelGroups.get(level)!.push(nodeId);
// Add children to next level
const children = outgoingEdges.get(nodeId) || [];
children.forEach((childId) => {
if (!visited.has(childId)) {
queue.push({ nodeId: childId, level: level + 1 });
}
});
}
// Handle orphaned nodes (not connected to root)
nodes.forEach((node) => {
if (!visited.has(node.id)) {
const maxLevel = Math.max(...Array.from(levelGroups.keys()), -1);
const orphanLevel = maxLevel + 1;
if (!levelGroups.has(orphanLevel)) {
levelGroups.set(orphanLevel, []);
}
levelGroups.get(orphanLevel)!.push(node.id);
}
});
// Position nodes level by level
levelGroups.forEach((nodeIds, level) => {
const nodeCount = nodeIds.length;
nodeIds.forEach((nodeId, index) => {
let x: number, y: number;
if (direction === "LR") {
// Horizontal layout: X increases with level, Y centers siblings
x = level * HORIZONTAL_SPACING;
// Center siblings vertically
const totalHeight = (nodeCount - 1) * VERTICAL_SPACING;
const startY = -totalHeight / 2;
y = startY + index * VERTICAL_SPACING;
} else {
// Vertical layout: Y increases with level, X centers siblings
y = level * VERTICAL_SPACING;
// Center siblings horizontally
const totalWidth = (nodeCount - 1) * HORIZONTAL_SPACING;
const startX = -totalWidth / 2;
x = startX + index * HORIZONTAL_SPACING;
}
positioned.set(nodeId, { x, y, level });
});
});
// Apply positions to nodes (centering them on their calculated positions)
return nodes.map((node) => {
const pos = positioned.get(node.id) || { x: 0, y: 0 };
return {
...node,
position: {
x: pos.x - NODE_WIDTH / 2, // Center the node
y: pos.y - NODE_HEIGHT / 2,
},
};
});
}
@@ -0,0 +1,771 @@
import { applySimpleLayout } from "./simple-layout";
import type { Node, Edge } from "@xyflow/react";
import type {
ExecutorNodeData,
ExecutorState,
} from "@/components/features/workflow/executor-node";
import type {
ExtendedResponseStreamEvent,
ResponseWorkflowEventComplete,
ResponseOutputItemAddedEvent,
ResponseOutputItemDoneEvent,
JSONSchemaProperty,
} from "@/types";
import type { Workflow } from "@/types/workflow";
import { getTypedWorkflow } from "@/types/workflow";
/**
* Detects if a JSON schema represents a Message input type.
* Message schemas typically have:
* - type: "object"
* - properties with "text" (required string) and "role" (optional string)
*
* This is used to determine whether to show the rich ChatMessageInput
* component for workflows that start with an AgentExecutor.
*
* @param schema - The JSON schema to check
* @returns true if the schema represents a Message-like input
*/
export function isChatMessageSchema(schema: JSONSchemaProperty | undefined): boolean {
if (!schema) return false;
// Must be an object type
if (schema.type !== "object") return false;
// Must have properties
if (!schema.properties) return false;
const props = schema.properties;
// Message has "text" property (the main content)
const hasText = "text" in props && props.text?.type === "string";
// Message has "role" property (user, assistant, system)
const hasRole = "role" in props && props.role?.type === "string";
// If it has both text and role, it's likely a Message
if (hasText && hasRole) {
return true;
}
// Also check for simpler chat-like schemas (just text field)
// This covers cases where the schema might be simplified
const propKeys = Object.keys(props);
if (propKeys.length === 1 && hasText) {
return true;
}
return false;
}
/**
* 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;
name?: string;
description?: string;
config?: Record<string, unknown>;
}
interface RawExecutorData {
type_?: string;
type?: string;
name?: string;
description?: string;
config?: Record<string, unknown>;
}
export interface WorkflowDumpConnection {
source: string;
target: string;
condition?: string;
}
export interface WorkflowDump {
executors?: WorkflowDumpExecutor[];
connections?: WorkflowDumpConnection[];
start_executor?: string;
end_executors?: string[];
[key: string]: unknown; // Allow for additional properties
}
export interface NodeUpdate {
nodeId: string;
state: ExecutorState;
data?: unknown;
error?: string;
timestamp: string;
}
/**
* Convert workflow dump data to React Flow nodes
*/
export function convertWorkflowDumpToNodes(
workflowDump: Workflow | Record<string, unknown> | undefined,
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void,
layoutDirection?: "LR" | "TB"
): Node<ExecutorNodeData>[] {
if (!workflowDump) {
console.warn("convertWorkflowDumpToNodes: workflowDump is undefined");
return [];
}
// Try to get typed workflow first, then fall back to generic handling
const typedWorkflow = getTypedWorkflow(workflowDump);
let executors: WorkflowDumpExecutor[];
let startExecutorId: string | undefined;
if (typedWorkflow) {
// Use typed workflow structure
executors = Object.values(typedWorkflow.executors).map((executor) => ({
id: executor.id,
type: executor.type,
name:
((executor as Record<string, unknown>).name as string) || executor.id,
description: (executor as Record<string, unknown>).description as string,
config: (executor as Record<string, unknown>).config as Record<
string,
unknown
>,
}));
startExecutorId = typedWorkflow.start_executor_id;
} else {
// Fall back to generic handling for backwards compatibility
executors = getExecutorsFromDump(workflowDump as Record<string, unknown>);
const workflowDumpRecord = workflowDump as Record<string, unknown>;
startExecutorId = workflowDumpRecord?.start_executor_id as
| string
| undefined;
}
if (!executors || !Array.isArray(executors) || executors.length === 0) {
console.warn(
"No executors found in workflow dump. Available keys:",
Object.keys(workflowDump)
);
return [];
}
const nodes = executors.map((executor) => ({
id: executor.id,
type: "executor",
position: { x: 0, y: 0 }, // Will be set by layout algorithm
data: {
executorId: executor.id,
executorType: executor.type,
name: executor.name || executor.id,
state: "pending" as ExecutorState,
isStartNode: executor.id === startExecutorId,
layoutDirection: layoutDirection || "LR",
onNodeClick,
},
}));
return nodes;
}
/**
* Convert workflow dump data to React Flow edges
*/
export function convertWorkflowDumpToEdges(
workflowDump: Workflow | Record<string, unknown> | undefined
): Edge[] {
if (!workflowDump) {
console.warn("convertWorkflowDumpToEdges: workflowDump is undefined");
return [];
}
// Try to get typed workflow first, then fall back to generic handling
const typedWorkflow = getTypedWorkflow(workflowDump);
let connections: WorkflowDumpConnection[];
if (typedWorkflow) {
// Use typed workflow structure to extract connections from edge_groups
connections = [];
typedWorkflow.edge_groups.forEach((group) => {
group.edges.forEach((edge) => {
connections.push({
source: edge.source_id,
target: edge.target_id,
condition: edge.condition_name,
});
});
});
} else {
// Fall back to generic handling for backwards compatibility
connections = getConnectionsFromDump(
workflowDump as Record<string, unknown>
);
}
if (!connections || !Array.isArray(connections) || connections.length === 0) {
console.warn(
"No connections found in workflow dump. Available keys:",
Object.keys(workflowDump)
);
return [];
}
const edges = connections.map((connection) => {
const isSelfLoop = connection.source === connection.target;
return {
id: `${connection.source}-${connection.target}`,
source: connection.source,
target: connection.target,
sourceHandle: "source",
targetHandle: "target",
type: isSelfLoop ? "selfLoop" : "default",
animated: false,
style: {
stroke: "#6b7280",
strokeWidth: 2,
},
};
});
return edges;
}
/**
* Extract executors from workflow dump - handles different possible structures
*/
function getExecutorsFromDump(
workflowDump: Record<string, unknown>
): WorkflowDumpExecutor[] {
// First check if executors is an object (like in the actual dump structure)
if (
workflowDump.executors &&
typeof workflowDump.executors === "object" &&
!Array.isArray(workflowDump.executors)
) {
const executorsObj = workflowDump.executors as Record<
string,
RawExecutorData
>;
return Object.entries(executorsObj).map(([id, executor]) => ({
id,
type: executor.type_ || executor.type || "executor",
name: executor.name || id,
description: executor.description,
config: executor.config,
}));
}
// Try different possible keys where executors might be stored as arrays
const possibleKeys = ["executors", "agents", "steps", "nodes"];
for (const key of possibleKeys) {
if (workflowDump[key] && Array.isArray(workflowDump[key])) {
return workflowDump[key] as WorkflowDumpExecutor[];
}
}
// If no direct array, try to extract from nested structures
if (workflowDump.config && typeof workflowDump.config === "object") {
return getExecutorsFromDump(workflowDump.config as Record<string, unknown>);
}
// Fallback: create executors from any object keys that look like executor IDs
const executors: WorkflowDumpExecutor[] = [];
Object.entries(workflowDump).forEach(([key, value]) => {
if (
typeof value === "object" &&
value !== null &&
("type" in value || "type_" in value)
) {
const rawExecutor = value as RawExecutorData;
executors.push({
id: key,
type: rawExecutor.type_ || rawExecutor.type || "executor",
name: rawExecutor.name || key,
description: rawExecutor.description,
config: rawExecutor.config,
});
}
});
return executors;
}
/**
* Extract connections from workflow dump - handles different possible structures
*/
function getConnectionsFromDump(
workflowDump: Record<string, unknown>
): WorkflowDumpConnection[] {
// Handle edge_groups structure (actual dump format)
if (workflowDump.edge_groups && Array.isArray(workflowDump.edge_groups)) {
const connections: WorkflowDumpConnection[] = [];
workflowDump.edge_groups.forEach((group: unknown) => {
if (typeof group === "object" && group !== null && "edges" in group) {
const edges = (group as { edges: unknown }).edges;
if (Array.isArray(edges)) {
edges.forEach((edge: unknown) => {
if (
typeof edge === "object" &&
edge !== null &&
"source_id" in edge &&
"target_id" in edge
) {
const edgeObj = edge as {
source_id: string;
target_id: string;
condition_name?: string;
};
connections.push({
source: edgeObj.source_id,
target: edgeObj.target_id,
condition: edgeObj.condition_name || undefined,
});
}
});
}
}
});
return connections;
}
// Try different possible keys where connections might be stored
const possibleKeys = ["connections", "edges", "transitions", "links"];
for (const key of possibleKeys) {
if (workflowDump[key] && Array.isArray(workflowDump[key])) {
return workflowDump[key] as WorkflowDumpConnection[];
}
}
// If no direct array, try to extract from nested structures
if (workflowDump.config && typeof workflowDump.config === "object") {
return getConnectionsFromDump(
workflowDump.config as Record<string, unknown>
);
}
return [];
}
/**
* Apply auto-layout to nodes using a lightweight algorithm
* Replaces dagre to eliminate 4.88MB lodash dependency
*/
export function applyDagreLayout(
nodes: Node<ExecutorNodeData>[],
edges: Edge[],
direction: "TB" | "LR" = "LR"
): Node<ExecutorNodeData>[] {
return applySimpleLayout(nodes, edges, direction);
}
/**
* Process workflow events and extract node updates
* Handles both standard OpenAI events and fallback workflow_event format
*/
export function processWorkflowEvents(
events: ExtendedResponseStreamEvent[],
startExecutorId?: string
): Record<string, NodeUpdate> {
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 outputEvent = event as ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent;
const item = outputEvent.item;
if (item && item.type === "executor_action" && "executor_id" in item) {
const executorId = item.executor_id as string;
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;
if (event.type === "response.output_item.added") {
state = "running";
} else if (event.type === "response.output_item.done") {
if (item.status === "completed") {
state = "completed";
} else if (item.status === "failed") {
state = "failed";
error = item.error ? (typeof item.error === "string" ? item.error : JSON.stringify(item.error)) : "Execution failed";
} else if (item.status === "cancelled") {
state = "cancelled";
}
}
nodeUpdates[executorId] = {
nodeId: executorId,
state,
data: item.result,
error,
timestamp: new Date().toISOString(),
};
}
}
// Handle workflow lifecycle events
else if (event.type === "response.created" || event.type === "response.in_progress") {
hasWorkflowStarted = true;
}
// Handle workflow event format
else if (
event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data
) {
const workflowEvent = event as ResponseWorkflowEventComplete;
const data = workflowEvent.data;
const executorId = data.executor_id;
const eventType = data.event_type;
const eventData = data.data;
let state: ExecutorState = "pending";
let error: string | undefined;
// Map event types to executor states
if (eventType === "ExecutorInvokedEvent") {
state = "running";
} else if (eventType === "ExecutorCompletedEvent") {
state = "completed";
} else if (
eventType?.includes("Error") ||
eventType?.includes("Failed")
) {
state = "failed";
error = typeof eventData === "string" ? eventData : "Execution failed";
} else if (eventType?.includes("Cancel")) {
state = "cancelled";
} else if (eventType === "WorkflowCompletedEvent" || eventType === "WorkflowOutputEvent") {
state = "completed";
} else if (eventType === "WorkflowStartedEvent") {
// Mark that workflow has started - we'll set start node to running
hasWorkflowStarted = true;
}
// Update the node state (keep most recent update per executor)
if (executorId) {
nodeUpdates[executorId] = {
nodeId: executorId,
state,
data: eventData,
error,
timestamp: new Date().toISOString(),
};
}
}
});
// 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 (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 outputEvent = event as ResponseOutputItemDoneEvent;
const item = outputEvent.item;
return item && item.type === "executor_action" && "executor_id" in item && item.executor_id === startExecutorId;
}
if (event.type === "response.workflow_event.completed" && "data" in event && event.data) {
const data = event.data as Record<string, unknown>;
return data.executor_id === startExecutorId &&
(data.event_type === "ExecutorCompletedEvent" ||
data.event_type === "ExecutorFailedEvent" ||
(typeof data.event_type === "string" && data.event_type.includes("Error")) ||
(typeof data.event_type === "string" && 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(),
};
}
}
return nodeUpdates;
}
/**
* Update node states based on event processing
*/
export function updateNodesWithEvents(
nodes: Node<ExecutorNodeData>[],
nodeUpdates: Record<string, NodeUpdate>,
isStreaming: boolean = true
): Node<ExecutorNodeData>[] {
return nodes.map((node) => {
const update = nodeUpdates[node.id];
if (update) {
return {
...node,
data: {
...node.data,
state: update.state,
outputData: update.data,
error: update.error,
// Add isStreaming to control spinning animation
isStreaming: isStreaming,
// Preserve layoutDirection
layoutDirection: node.data.layoutDirection,
},
};
}
return node;
});
}
/**
* Get executors that are currently in execution (invoked but not yet completed)
*/
export function getCurrentlyExecutingExecutors(
events: ExtendedResponseStreamEvent[]
): string[] {
const executorTimeline: Record<
string,
{ lastEvent: string; timestamp: string }
> = {};
// Process events to find the most recent event for each executor
events.forEach((event) => {
// Handle new standard OpenAI events
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
const outputEvent = event as ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent;
const item = outputEvent.item;
if (item && item.type === "executor_action" && "executor_id" in item) {
const executorId = item.executor_id as string;
executorTimeline[executorId] = {
lastEvent: event.type === "response.output_item.added" ? "ExecutorInvokedEvent" : "ExecutorCompletedEvent",
timestamp: new Date().toISOString(),
};
}
}
// Handle workflow event format
else if (
event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data
) {
const workflowEvent = event as ResponseWorkflowEventComplete;
const data = workflowEvent.data;
const executorId = data.executor_id;
const eventType = data.event_type;
if (
executorId &&
(eventType === "ExecutorInvokedEvent" ||
eventType === "ExecutorCompletedEvent")
) {
executorTimeline[executorId] = {
lastEvent: eventType,
timestamp: new Date().toISOString(),
};
}
}
});
// Find executors that were invoked but haven't completed yet
const currentlyExecuting = Object.entries(executorTimeline)
.filter(([, timeline]) => timeline.lastEvent === "ExecutorInvokedEvent")
.map(([executorId]) => executorId);
return currentlyExecuting;
}
/**
* Update edges with sequence-based animation
*/
export function updateEdgesWithSequenceAnalysis(
edges: Edge[],
events: ExtendedResponseStreamEvent[]
): Edge[] {
const currentlyExecuting = getCurrentlyExecutingExecutors(events);
// Build simple state tracking for each executor
const executorStates: Record<
string,
{ completed: boolean; invoked: boolean }
> = {};
events.forEach((event) => {
if (
event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data
) {
const workflowEvent = event as ResponseWorkflowEventComplete;
const data = workflowEvent.data;
const executorId = data.executor_id;
const eventType = data.event_type;
if (executorId && eventType) {
if (!executorStates[executorId]) {
executorStates[executorId] = { completed: false, invoked: false };
}
if (eventType === "ExecutorInvokedEvent") {
executorStates[executorId].invoked = true;
} else if (eventType === "ExecutorCompletedEvent") {
executorStates[executorId].completed = true;
}
}
}
});
return edges.map((edge) => {
const sourceState = executorStates[edge.source];
const targetState = executorStates[edge.target];
const targetIsExecuting = currentlyExecuting.includes(edge.target);
let style = { ...edge.style };
let animated = false;
// Active edge: source completed and target is currently executing
if (sourceState?.completed && targetIsExecuting) {
style = {
stroke: "#643FB2", // Purple accent
strokeWidth: 3,
strokeDasharray: "5,5",
};
animated = true;
}
// Completed edge: both source and target have completed
else if (sourceState?.completed && targetState?.completed) {
style = {
stroke: "#10b981", // Green
strokeWidth: 2,
};
}
// Invoked edge: source completed and target invoked (but not necessarily executing)
else if (sourceState?.completed && targetState?.invoked) {
style = {
stroke: "#f59e0b", // Orange
strokeWidth: 2,
};
}
// Default: Not traversed
else {
style = {
stroke: "#6b7280", // Gray
strokeWidth: 2,
};
}
return {
...edge,
style,
animated,
};
});
}
/**
* 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}`;
// Self-loops (source === target) should always be preserved as-is
// They are not bidirectional edges, just a node pointing to itself
if (edge.source === edge.target) {
edgeMap.set(forwardKey, edge);
return;
}
// 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());
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,33 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
// https://vite.dev/config/
export default defineConfig({
base: "",
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
commonjsOptions: {
// Enable deterministic builds, as per https://github.com/vitejs/vite/issues/13672#issuecomment-1784110536
strictRequires: true,
},
outDir: "../agent_framework_devui/ui",
emptyOutDir: true,
rollupOptions: {
output: {
manualChunks: undefined,
inlineDynamicImports: true,
// Use static filenames instead of content hashes
entryFileNames: "assets/index.js",
chunkFileNames: "assets/[name].js",
assetFileNames: "assets/[name].[ext]",
},
},
},
});
File diff suppressed because it is too large Load Diff