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
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:
@@ -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*
|
||||
@@ -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 }
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
+4898
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;
|
||||
}
|
||||
@@ -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 />
|
||||
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";
|
||||
+481
@@ -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>
|
||||
);
|
||||
}
|
||||
+77
@@ -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;
|
||||
}
|
||||
+7
@@ -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';
|
||||
+207
@@ -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>
|
||||
);
|
||||
}
|
||||
+395
@@ -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>
|
||||
);
|
||||
}
|
||||
+714
@@ -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";
|
||||
+153
@@ -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";
|
||||
+427
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+604
@@ -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} />;
|
||||
});
|
||||
+169
@@ -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>
|
||||
);
|
||||
});
|
||||
+289
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+225
@@ -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>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user