mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: [Feature Branch] Merge from main to Azure AI branch (#2111)
* Do not build DevUI assets during .NET project build (#2010) * .NET: Add unit tests for declarative executor SetMultipleVariables (#2016) * Add unit tests for create conversation executor * Update indentation and comment typo. * Added unit tests for declarative executor SetMultipleVariablesExecutor * Updated comments and syntactic sugar * Python: DevUI: Use metadata.entity_id instead of model field (#1984) * DevUI: Use metadata.entity_id for agent/workflow name instead of model field * OpenAI Responses: add explicit request validation * Review feedback * .NET: DevUI - Do not automatically add/map OpenAI services/endpoints (#2014) * Don't add OpenAIResponses as part of Dev UI You should be able to add and remove Dev UI without impacting your other production endpoints. * Remove `AddDevUI()` and do not map OpenAI endpoints from `MapDevUI()` * Fix comment wording * Revise documentation --------- Co-authored-by: Daniel Roth <daroth@microsoft.com> * Python: DevUI: Add OpenAI Responses API proxy support + HIL for Workflows (#1737) * DevUI: Add OpenAI Responses API proxy support with enhanced UI features This commit adds support for proxying requests to OpenAI's Responses API, allowing DevUI to route conversations to OpenAI models when configured to enable testing. Backend changes: - Add OpenAI proxy executor with conversation routing logic - Enhance event mapper to support OpenAI Responses API format - Extend server endpoints to handle OpenAI proxy mode - Update models with OpenAI-specific response types - Remove emojis from logging and CLI output for cleaner text Frontend changes: - Add settings modal with OpenAI proxy configuration UI - Enhance agent and workflow views with improved state management - Add new UI components (separator, switch) for settings - Update debug panel with better event filtering - Improve message renderers for OpenAI content types - Update types and API client for OpenAI integration * update ui, settings modal and workflow input form, add register cleanup hooks. * add workflow HIL support, user mode, other fixes * feat(devui): add human-in-the-loop (HIL) support with dynamic response schemas Implement HIL workflow support allowing workflows to pause for user input with dynamically generated JSON schemas based on response handler type hints. Key Features: - Automatic response schema extraction from @response_handler decorators - Dynamic form generation in UI based on Pydantic/dataclass response types - Checkpoint-based conversation storage for HIL requests/responses - Resume workflow execution after user provides HIL response Backend Changes: - Add extract_response_type_from_executor() to introspect response handlers - Enrich RequestInfoEvent with response_schema via _enrich_request_info_event_with_response_schema() - Map RequestInfoEvent to response.input.requested OpenAI event format - Store HIL responses in conversation history and restore checkpoints Frontend Changes: - Add HILInputModal component with SchemaFormRenderer for dynamic forms - Support Pydantic BaseModel and dataclass response types - Render enum fields as dropdowns, strings as text/textarea, numbers, booleans, arrays, objects - Display original request context alongside response form Testing: - Add tests for checkpoint storage (test_checkpoints.py) - Add schema generation tests for all input types (test_schema_generation.py) - Validate end-to-end HIL flow with spam workflow sample This enables workflows to seamlessly pause execution and request structured user input with type-safe, validated forms generated automatically from response type annotations. * improve HIL support, improve workflow execution view * ui updates * ui updates * improve HIL for workflows, add auth and view modes * update workflow * security improvements , ui fixes * fix mypy error * update loading spinner in ui --------- Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com> * .NET: Remove launchSettings.json from .gitignore in dotnet/samples (#2006) * Remove launchSettings.json from .gitignore in dotnet/samples * Update dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Properties/launchSettings.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/samples/AGUIClientServer/AGUIServer/Properties/launchSettings.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * DevUI: Serialize workflow input as string to maintain conformance with OpenAI Responses format (#2021) Co-authored-by: Victor Dibia <chuvidi2003@gmail.com> * Add Microsoft Agent Framework logo to assets (#2007) * Updated package versions (#2027) * DevUI: Prevent line breaks within words in the agent view (#2024) Co-authored-by: Victor Dibia <chuvidi2003@gmail.com> * .NET [AG-UI]: Adds support for shared state. (#1996) * Product changes * Tests * Dojo project * Cleanups * Python: Fix underlying tool choice bug and all for return to previous Handoff subagent (#2037) * Fix tool_choice override bug and add enable_return_to_previous support * Add unit test for handoff checkpointing * Handle tools when we have them * added missing chatAgent params (#2044) * .NET: fix ChatCompletions Tools serialization (#2043) * fix serialization in chat completions on tools * nit * .NET: assign AgentCard's URL to mapped-endpoint if not defined explicitly (#2047) * fix serialization in chat completions on tools * nit * write e2e test for agent card resolve + adjust behavior * nit * Version 1.0.0-preview.251110.1 (#2048) * .NET: Remove moved OpenAPI sample and point to SK one. (#1997) * Remove moved OpenAPI sample and point to SK one. * Update dotnet/samples/GettingStarted/Agents/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Bump AWSSDK.Extensions.Bedrock.MEAI from 4.0.4.2 to 4.0.4.6 (#2031) --- updated-dependencies: - dependency-name: AWSSDK.Extensions.Bedrock.MEAI dependency-version: 4.0.4.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * .NET: Separate all memory and rag samples into their own folders (#2000) * Separate all memory and rag samples into their own folders * Fix broken link. * Python: .Net: Dotnet devui compatibility fixes (#2026) * DevUI: Add OpenAI Responses API proxy support with enhanced UI features This commit adds support for proxying requests to OpenAI's Responses API, allowing DevUI to route conversations to OpenAI models when configured to enable testing. Backend changes: - Add OpenAI proxy executor with conversation routing logic - Enhance event mapper to support OpenAI Responses API format - Extend server endpoints to handle OpenAI proxy mode - Update models with OpenAI-specific response types - Remove emojis from logging and CLI output for cleaner text Frontend changes: - Add settings modal with OpenAI proxy configuration UI - Enhance agent and workflow views with improved state management - Add new UI components (separator, switch) for settings - Update debug panel with better event filtering - Improve message renderers for OpenAI content types - Update types and API client for OpenAI integration * update ui, settings modal and workflow input form, add register cleanup hooks. * add workflow HIL support, user mode, other fixes * feat(devui): add human-in-the-loop (HIL) support with dynamic response schemas Implement HIL workflow support allowing workflows to pause for user input with dynamically generated JSON schemas based on response handler type hints. Key Features: - Automatic response schema extraction from @response_handler decorators - Dynamic form generation in UI based on Pydantic/dataclass response types - Checkpoint-based conversation storage for HIL requests/responses - Resume workflow execution after user provides HIL response Backend Changes: - Add extract_response_type_from_executor() to introspect response handlers - Enrich RequestInfoEvent with response_schema via _enrich_request_info_event_with_response_schema() - Map RequestInfoEvent to response.input.requested OpenAI event format - Store HIL responses in conversation history and restore checkpoints Frontend Changes: - Add HILInputModal component with SchemaFormRenderer for dynamic forms - Support Pydantic BaseModel and dataclass response types - Render enum fields as dropdowns, strings as text/textarea, numbers, booleans, arrays, objects - Display original request context alongside response form Testing: - Add tests for checkpoint storage (test_checkpoints.py) - Add schema generation tests for all input types (test_schema_generation.py) - Validate end-to-end HIL flow with spam workflow sample This enables workflows to seamlessly pause execution and request structured user input with type-safe, validated forms generated automatically from response type annotations. * improve HIL support, improve workflow execution view * ui updates * ui updates * improve HIL for workflows, add auth and view modes * update workflow * security improvements , ui fixes * fix mypy error * update loading spinner in ui * DevUI: Serialize workflow input as string to maintain conformance with OpenAI Responses format * Phase 1: Add /meta endpoint and fix workflow event naming for .NET DevUI compatibility * additional fixes for .NET DevUI workflow visualization item ID tracking **Problem:** .NET DevUI was generating different item IDs for ExecutorInvokedEvent and ExecutorCompletedEvent, causing only the first executor to highlight in the workflow graph. Long executor names and error messages also broke UI layout. **Changes:** - Add ExecutorActionItemResource to match Python DevUI implementation - Track item IDs per executor using dictionary in AgentRunResponseUpdateExtensions - Reuse same item ID across invoked/completed/failed events for proper pairing - Add truncateText() utility to workflow-utils.ts - Truncate executor names to 35 chars in execution timeline - Truncate error messages to 150 chars in workflow graph nodes ** Details:** - ExecutorActionItemResource registered with JSON source generation context - Dictionary cleaned up after executor completion/failure to prevent memory leaks - Frontend item tracking by unique item.id supports multiple executor runs - All changes follow existing codebase patterns and conventions Tested with review-workflow showing correct executor highlighting and state transitions for sequential and concurrent executors. * format fixes, remove cors tests * remove unecessary attributes --------- Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com> Co-authored-by: Reuben Bond <reuben.bond@gmail.com> * DevUI: support having both an agent and a workflow with the same id in discovery (#2023) * Python: Fix Model ID attribute not showing up in `invoke_agent` span (#2061) * Best effort to surface the model id to invoke agent span * Fix tests * Fix tests * Version 1.0.0-preview.251107.2 (#2065) * Version 1.0.0-preview.251110.2 (#2067) * Update README.md to change Grafana links to Azure portal links for dashboard access (#1983) * .NET - Enable build & test on branch `feature-foundry-agents` (#2068) * Tests good, mkay * Update .github/workflows/dotnet-build-and-test.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Enable feature build pipelines --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> * Python: Add concrete AGUIChatClient (#2072) * Add concrete AGUIChatClient * Update logging docstrings and conventions * PR feedback * Updates to support client-side tool calls * .NET: Move catalog samples to the HostedAgents folder (#2090) * move catalog samples to the HostedAgents folder * move the catalog samples' projects to the HostedAgents folder * Bump OpenTelemetry.Instrumentation.Runtime from 1.12.0 to 1.13.0 (#1856) --- updated-dependencies: - dependency-name: OpenTelemetry.Instrumentation.Runtime dependency-version: 1.13.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * .NET: Bump Microsoft.SemanticKernel.Agents.Abstractions from 1.66.0 to 1.67.0 (#1962) * Bump Microsoft.SemanticKernel.Agents.Abstractions from 1.66.0 to 1.67.0 --- updated-dependencies: - dependency-name: Microsoft.SemanticKernel.Agents.Abstractions dependency-version: 1.67.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> * .NET: Bump all Microsoft.SemanticKernel packages from 1.66.* to 1.67.* (#1969) * Initial plan * Update all Microsoft.SemanticKernel packages to 1.67.* Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> * Remove unrelated changes to package-lock.json and yarn.lock Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com> * .NET: fix: WorkflowAsAgent Sample (#1787) * fix: WorkflowAsAgent Sample * Also makes ChatForwardingExecutor public * feat: Expand ChatForwardingExecutor handled types Make ChatForwardingExecutor match the input types of ChatProtocolExecutor. * fix: Update for the new AgentRunResponseUpdate merge logic AIAgent always sends out List<ChatMessage> now. * Updated (#2076) * Bump vite in /python/samples/demos/chatkit-integration/frontend (#1918) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.9 to 7.1.12. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v7.1.12/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v7.1.12/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 7.1.12 dependency-type: direct:development ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump Roslynator.Analyzers from 4.14.0 to 4.14.1 (#1857) --- updated-dependencies: - dependency-name: Roslynator.Analyzers dependency-version: 4.14.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump MishaKav/pytest-coverage-comment from 1.1.57 to 1.1.59 (#2034) Bumps [MishaKav/pytest-coverage-comment](https://github.com/mishakav/pytest-coverage-comment) from 1.1.57 to 1.1.59. - [Release notes](https://github.com/mishakav/pytest-coverage-comment/releases) - [Changelog](https://github.com/MishaKav/pytest-coverage-comment/blob/main/CHANGELOG.md) - [Commits](https://github.com/mishakav/pytest-coverage-comment/compare/v1.1.57...v1.1.59) --- updated-dependencies: - dependency-name: MishaKav/pytest-coverage-comment dependency-version: 1.1.59 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> * Python: Handle agent user input request in AgentExecutor (#2022) * Handle agent user input request in AgentExecutor * fix test * Address comments * Fix tests * Fix tests * Address comments * Address comments * Python: OpenAI Responses Image Generation Stream Support, Sample and Unit Tests (#1853) * support for image gen streaming * small fixes * fixes * added comment * Python: Fix MCP Tool Parameter Descriptions Not Propagated to LLMs (#1978) * mcp tool description fix * small fix * .NET: Allow extending agent run options via additional properties (#1872) * Allow extending agent run options via additional properties This mirrors the M.E.AI model in ChatOptions.AdditionalProperties which is very useful when building functionality pipelines. Fixes https://github.com/microsoft/agent-framework/issues/1815 * Expand XML documentation Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add AdditionalProperties tests to AgentRunOptions Co-authored-by: kzu <169707+kzu@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kzu <169707+kzu@users.noreply.github.com> * Python: Use the last entry in the task history to avoid empty responses (#2101) * Use the last entry in the task history to avoid empty responses * History only contains Messages * Updated package versions (#2104) --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Reuben Bond <203839+ReubenBond@users.noreply.github.com> Co-authored-by: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Co-authored-by: Jeff Handley <jeffhandley@users.noreply.github.com> Co-authored-by: Daniel Roth <daroth@microsoft.com> Co-authored-by: Victor Dibia <chuvidi2003@gmail.com> Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Shawn Henry <sphenry@gmail.com> Co-authored-by: Javier Calvarro Nelson <jacalvar@microsoft.com> Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com> Co-authored-by: Korolev Dmitry <deagle.gross@gmail.com> Co-authored-by: westey <164392973+westey-m@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Reuben Bond <reuben.bond@gmail.com> Co-authored-by: Tao Chen <taochen@microsoft.com> Co-authored-by: wuweng <wuweng@microsoft.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Jacob Alber <jaalber@microsoft.com> Co-authored-by: Giles Odigwe <79032838+giles17@users.noreply.github.com> Co-authored-by: Daniel Cazzulino <daniel@cazzulino.com> Co-authored-by: kzu <169707+kzu@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
85fcd230bf
commit
361c47f30f
@@ -15,7 +15,9 @@
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
|
||||
@@ -3,33 +3,49 @@
|
||||
* Features: Entity selection, layout management, debug coordination
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { useEffect, useCallback, useState } from "react";
|
||||
import { AppHeader, DebugPanel, SettingsModal, DeploymentModal } from "@/components/layout";
|
||||
import { GalleryView } from "@/components/features/gallery";
|
||||
import { AgentView } from "@/components/features/agent";
|
||||
import { WorkflowView } from "@/components/features/workflow";
|
||||
import { Toast } from "@/components/ui/toast";
|
||||
import { Toast, ToastContainer } from "@/components/ui/toast";
|
||||
import { apiClient } from "@/services/api";
|
||||
import { PanelRightOpen, ChevronDown, ServerOff, Rocket } from "lucide-react";
|
||||
import { PanelRightOpen, ChevronLeft, ChevronDown, ServerOff, Rocket, Lock } from "lucide-react";
|
||||
import type {
|
||||
AgentInfo,
|
||||
WorkflowInfo,
|
||||
ExtendedResponseStreamEvent,
|
||||
} from "@/types";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { Input } from "./components/ui/input";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
|
||||
export default function App() {
|
||||
// Local state for auth handling
|
||||
const [authRequired, setAuthRequired] = useState(false);
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
const [isTestingToken, setIsTestingToken] = useState(false);
|
||||
const [authError, setAuthError] = useState("");
|
||||
|
||||
// Entity state from Zustand
|
||||
const agents = useDevUIStore((state) => state.agents);
|
||||
const workflows = useDevUIStore((state) => state.workflows);
|
||||
const entities = useDevUIStore((state) => state.entities);
|
||||
const selectedAgent = useDevUIStore((state) => state.selectedAgent);
|
||||
const azureDeploymentEnabled = useDevUIStore((state) => state.azureDeploymentEnabled);
|
||||
const isLoadingEntities = useDevUIStore((state) => state.isLoadingEntities);
|
||||
const entityError = useDevUIStore((state) => state.entityError);
|
||||
|
||||
// OpenAI proxy mode
|
||||
const oaiMode = useDevUIStore((state) => state.oaiMode);
|
||||
|
||||
// UI mode
|
||||
const uiMode = useDevUIStore((state) => state.uiMode);
|
||||
|
||||
// Entity actions
|
||||
const setAgents = useDevUIStore((state) => state.setAgents);
|
||||
const setWorkflows = useDevUIStore((state) => state.setWorkflows);
|
||||
const setEntities = useDevUIStore((state) => state.setEntities);
|
||||
const selectEntity = useDevUIStore((state) => state.selectEntity);
|
||||
const updateAgent = useDevUIStore((state) => state.updateAgent);
|
||||
const updateWorkflow = useDevUIStore((state) => state.updateWorkflow);
|
||||
@@ -38,12 +54,14 @@ export default function App() {
|
||||
|
||||
// UI state from Zustand
|
||||
const showDebugPanel = useDevUIStore((state) => state.showDebugPanel);
|
||||
const debugPanelMinimized = useDevUIStore((state) => state.debugPanelMinimized);
|
||||
const debugPanelWidth = useDevUIStore((state) => state.debugPanelWidth);
|
||||
const debugEvents = useDevUIStore((state) => state.debugEvents);
|
||||
const isResizing = useDevUIStore((state) => state.isResizing);
|
||||
|
||||
// UI actions
|
||||
const setShowDebugPanel = useDevUIStore((state) => state.setShowDebugPanel);
|
||||
const setDebugPanelMinimized = useDevUIStore((state) => state.setDebugPanelMinimized);
|
||||
const setDebugPanelWidth = useDevUIStore((state) => state.setDebugPanelWidth);
|
||||
const addDebugEvent = useDevUIStore((state) => state.addDebugEvent);
|
||||
const clearDebugEvents = useDevUIStore((state) => state.clearDebugEvents);
|
||||
@@ -61,13 +79,40 @@ export default function App() {
|
||||
const setShowDeployModal = useDevUIStore((state) => state.setShowDeployModal);
|
||||
const setShowEntityNotFoundToast = useDevUIStore((state) => state.setShowEntityNotFoundToast);
|
||||
|
||||
// Toast state and actions
|
||||
const toasts = useDevUIStore((state) => state.toasts);
|
||||
const removeToast = useDevUIStore((state) => state.removeToast);
|
||||
|
||||
// Initialize app - load agents and workflows
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// Single API call instead of two parallel calls to same endpoint
|
||||
const { agents: agentList, workflows: workflowList } = await apiClient.getEntities();
|
||||
// Fetch server metadata first (ui_mode, capabilities, auth status)
|
||||
const meta = await apiClient.getMeta();
|
||||
|
||||
// Check if auth is required
|
||||
if (meta.auth_required) {
|
||||
setAuthRequired(true);
|
||||
|
||||
// If we don't have a token, stop here and show auth UI
|
||||
if (!apiClient.getAuthToken()) {
|
||||
setEntityError("UNAUTHORIZED");
|
||||
setIsLoadingEntities(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
useDevUIStore.getState().setServerMeta({
|
||||
uiMode: meta.ui_mode,
|
||||
runtime: meta.runtime,
|
||||
capabilities: meta.capabilities,
|
||||
authRequired: meta.auth_required,
|
||||
});
|
||||
|
||||
// Single API call instead of two parallel calls to same endpoint
|
||||
const { entities: allEntities, agents: agentList, workflows: workflowList } = await apiClient.getEntities();
|
||||
|
||||
setEntities(allEntities);
|
||||
setAgents(agentList);
|
||||
setWorkflows(workflowList);
|
||||
|
||||
@@ -79,9 +124,7 @@ export default function App() {
|
||||
|
||||
// Try to find entity from URL parameter first
|
||||
if (entityId) {
|
||||
selectedEntity =
|
||||
agentList.find((a) => a.id === entityId) ||
|
||||
workflowList.find((w) => w.id === entityId);
|
||||
selectedEntity = allEntities.find((e) => e.id === entityId);
|
||||
|
||||
// If entity not found but was requested, show notification
|
||||
if (!selectedEntity) {
|
||||
@@ -91,12 +134,9 @@ export default function App() {
|
||||
|
||||
// Fallback to first available entity if URL entity not found
|
||||
if (!selectedEntity) {
|
||||
selectedEntity =
|
||||
agentList.length > 0
|
||||
? agentList[0]
|
||||
: workflowList.length > 0
|
||||
? workflowList[0]
|
||||
: undefined;
|
||||
// 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) {
|
||||
@@ -140,9 +180,14 @@ export default function App() {
|
||||
setIsLoadingEntities(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to load agents/workflows:", error);
|
||||
setEntityError(
|
||||
error instanceof Error ? error.message : "Failed to load data"
|
||||
);
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -150,6 +195,47 @@ export default function App() {
|
||||
loadData();
|
||||
}, [setAgents, setWorkflows, selectEntity, updateAgent, updateWorkflow, setIsLoadingEntities, setEntityError, setShowEntityNotFoundToast]);
|
||||
|
||||
// Handle auth token submission
|
||||
const handleAuthTokenSubmit = useCallback(async () => {
|
||||
if (!authToken.trim()) return;
|
||||
|
||||
setIsTestingToken(true);
|
||||
setAuthError("");
|
||||
|
||||
try {
|
||||
// Set token in API client (stores in localStorage)
|
||||
apiClient.setAuthToken(authToken.trim());
|
||||
|
||||
// Test the token with an actual PROTECTED endpoint (not /meta which is public)
|
||||
await apiClient.getEntities();
|
||||
|
||||
// If successful, reload to initialize with new token
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
// Token is invalid - clear it and show error
|
||||
apiClient.clearAuthToken();
|
||||
setIsTestingToken(false);
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
||||
if (errorMsg === "UNAUTHORIZED") {
|
||||
setAuthError("Invalid token. Please check and try again.");
|
||||
} else {
|
||||
setAuthError(`Failed to connect: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
}, [authToken]);
|
||||
|
||||
// Auto-switch from workflow to agent when OpenAI proxy mode is enabled
|
||||
useEffect(() => {
|
||||
if (oaiMode.enabled && selectedAgent?.type === "workflow") {
|
||||
// Workflows don't work with OpenAI proxy - switch to first available agent
|
||||
const firstAgent = agents[0];
|
||||
if (firstAgent) {
|
||||
selectEntity(firstAgent);
|
||||
}
|
||||
}
|
||||
}, [oaiMode.enabled, selectedAgent, agents, selectEntity]);
|
||||
|
||||
// Handle resize drag
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -242,12 +328,14 @@ export default function App() {
|
||||
// Show error state if loading failed
|
||||
if (entityError) {
|
||||
const currentBackendUrl = apiClient.getBaseUrl();
|
||||
const isAuthError = entityError === "UNAUTHORIZED" || authRequired;
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-background">
|
||||
<AppHeader
|
||||
agents={[]}
|
||||
workflows={[]}
|
||||
entities={[]}
|
||||
selectedItem={undefined}
|
||||
onSelect={() => {}}
|
||||
isLoading={false}
|
||||
@@ -260,63 +348,124 @@ export default function App() {
|
||||
{/* Icon */}
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-full bg-muted p-4 animate-pulse">
|
||||
<ServerOff className="h-12 w-12 text-muted-foreground" />
|
||||
{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">
|
||||
Can't Connect to Backend
|
||||
{isAuthError ? "Authentication Required" : "Can't Connect to Backend"}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-base">
|
||||
No worries! Just start the DevUI backend server and you'll be
|
||||
good to go.
|
||||
{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>
|
||||
|
||||
{/* Command Instructions */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Start the backend:
|
||||
</p>
|
||||
<code className="block bg-background px-3 py-2 rounded border text-sm font-mono text-foreground">
|
||||
devui ./agents --port 8080
|
||||
</code>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Or launch programmatically with{" "}
|
||||
<code className="text-xs">serve(entities=[agent])</code>
|
||||
</p>
|
||||
{/* 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 8080
|
||||
</code>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Or launch programmatically with{" "}
|
||||
<code className="text-xs">serve(entities=[agent])</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default:{" "}
|
||||
<span className="font-mono">{currentBackendUrl}</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default:{" "}
|
||||
<span className="font-mono">{currentBackendUrl}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Details (Collapsible) */}
|
||||
{entityError && (
|
||||
<details className="text-left group">
|
||||
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2">
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
|
||||
Error details
|
||||
</summary>
|
||||
<p className="mt-2 text-xs text-muted-foreground font-mono bg-muted/30 p-3 rounded border">
|
||||
{entityError}
|
||||
</p>
|
||||
</details>
|
||||
{/* 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Retry Button */}
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="default"
|
||||
className="mt-2"
|
||||
>
|
||||
Retry Connection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -331,6 +480,7 @@ export default function App() {
|
||||
<AppHeader
|
||||
agents={agents}
|
||||
workflows={workflows}
|
||||
entities={entities}
|
||||
selectedItem={selectedAgent}
|
||||
onSelect={handleEntitySelect}
|
||||
onBrowseGallery={() => setShowGallery(true)}
|
||||
@@ -377,7 +527,7 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDebugPanel ? (
|
||||
{uiMode === "developer" && showDebugPanel ? (
|
||||
<>
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
@@ -400,31 +550,68 @@ export default function App() {
|
||||
{/* Right Panel - Debug */}
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col h-[calc(100vh-3.7rem)]"
|
||||
style={{ width: `${debugPanelWidth}px` }}
|
||||
style={{ width: debugPanelMinimized ? '2.5rem' : `${debugPanelWidth}px` }}
|
||||
>
|
||||
<DebugPanel
|
||||
events={debugEvents}
|
||||
isStreaming={false} // Each view manages its own streaming state
|
||||
onClose={() => setShowDebugPanel(false)}
|
||||
/>
|
||||
|
||||
{/* Deploy Footer - Pinned to bottom */}
|
||||
<div className="border-t bg-muted/30 px-3 py-2.5 flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => setShowDeployModal(true)}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
{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"
|
||||
>
|
||||
<Rocket className="h-3 w-3 mr-2 flex-shrink-0" />
|
||||
<span className="truncate text-xs">
|
||||
Deployment Guide for {selectedAgent?.name || "Agent"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/* 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
|
||||
@@ -437,7 +624,7 @@ export default function App() {
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -450,6 +637,7 @@ export default function App() {
|
||||
open={showDeployModal}
|
||||
onClose={() => setShowDeployModal(false)}
|
||||
agentName={selectedAgent?.name}
|
||||
entity={selectedAgent}
|
||||
/>
|
||||
|
||||
{/* Toast Notification */}
|
||||
@@ -460,6 +648,9 @@ export default function App() {
|
||||
onClose={() => setShowEntityNotFoundToast(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toast Container for reload and other notifications */}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
X,
|
||||
Copy,
|
||||
CheckCheck,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "@/services/api";
|
||||
import type {
|
||||
@@ -118,7 +119,7 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
|
||||
>
|
||||
<div className="relative group">
|
||||
<div
|
||||
className={`rounded px-3 py-2 text-sm break-all ${
|
||||
className={`rounded px-3 py-2 text-sm ${
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isError
|
||||
@@ -161,7 +162,12 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
|
||||
<span>{new Date().toLocaleTimeString()}</span>
|
||||
<span>
|
||||
{item.created_at
|
||||
? new Date(item.created_at * 1000).toLocaleTimeString()
|
||||
: new Date().toLocaleTimeString() // Fallback for legacy items without timestamp
|
||||
}
|
||||
</span>
|
||||
{!isUser && item.usage && (
|
||||
<>
|
||||
<span>•</span>
|
||||
@@ -207,8 +213,10 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const loadingConversations = useDevUIStore((state) => state.loadingConversations);
|
||||
const inputValue = useDevUIStore((state) => state.inputValue);
|
||||
const attachments = useDevUIStore((state) => state.attachments);
|
||||
const uiMode = useDevUIStore((state) => state.uiMode);
|
||||
const conversationUsage = useDevUIStore((state) => state.conversationUsage);
|
||||
const pendingApprovals = useDevUIStore((state) => state.pendingApprovals);
|
||||
const oaiMode = useDevUIStore((state) => state.oaiMode);
|
||||
|
||||
// Get conversation actions from Zustand (only the ones we actually use)
|
||||
const setCurrentConversation = useDevUIStore((state) => state.setCurrentConversation);
|
||||
@@ -227,6 +235,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const [dragCounter, setDragCounter] = useState(0);
|
||||
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
|
||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||
const [conversationError, setConversationError] = useState<{
|
||||
message: string;
|
||||
code?: string;
|
||||
type?: string;
|
||||
} | null>(null);
|
||||
const [isReloading, setIsReloading] = useState(false);
|
||||
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -604,10 +618,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
setAvailableConversations([newConversation]);
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
} catch {
|
||||
setConversationError(null); // Clear any previous errors
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem(cachedKey, JSON.stringify([newConversation]));
|
||||
} catch (error) {
|
||||
setAvailableConversations([]);
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
|
||||
// Extract error details for display
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
|
||||
setConversationError({
|
||||
message: errorMessage,
|
||||
type: "conversation_creation_error",
|
||||
});
|
||||
} finally {
|
||||
setLoadingConversations(false);
|
||||
}
|
||||
@@ -856,11 +881,22 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
setAvailableConversations([newConversation, ...useDevUIStore.getState().availableConversations]);
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
setConversationError(null); // Clear any previous errors
|
||||
// Reset conversation usage by setting it to initial state
|
||||
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
|
||||
accumulatedTextRef.current = "";
|
||||
} catch {
|
||||
// Failed to create conversation
|
||||
|
||||
// Update localStorage cache with new conversation
|
||||
const cachedKey = `devui_convs_${selectedAgent.id}`;
|
||||
const updated = [newConversation, ...availableConversations];
|
||||
localStorage.setItem(cachedKey, JSON.stringify(updated));
|
||||
} catch (error) {
|
||||
// Failed to create conversation - show error to user
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
|
||||
setConversationError({
|
||||
message: errorMessage,
|
||||
type: "conversation_creation_error",
|
||||
});
|
||||
}
|
||||
}, [selectedAgent, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
|
||||
|
||||
@@ -915,6 +951,42 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
[availableConversations, currentConversation, onDebugEvent, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]
|
||||
);
|
||||
|
||||
// Handle entity reload (hot reload)
|
||||
const handleReloadEntity = useCallback(async () => {
|
||||
if (isReloading || !selectedAgent) return;
|
||||
|
||||
setIsReloading(true);
|
||||
const addToast = useDevUIStore.getState().addToast;
|
||||
const updateAgent = useDevUIStore.getState().updateAgent;
|
||||
|
||||
try {
|
||||
// Call backend reload endpoint
|
||||
await apiClient.reloadEntity(selectedAgent.id);
|
||||
|
||||
// Fetch updated entity info
|
||||
const updatedAgent = await apiClient.getAgentInfo(selectedAgent.id);
|
||||
|
||||
// Update store with fresh metadata
|
||||
updateAgent(updatedAgent);
|
||||
|
||||
// Show success toast
|
||||
addToast({
|
||||
message: `${selectedAgent.name} has been reloaded successfully`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
// Show error toast
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to reload entity";
|
||||
addToast({
|
||||
message: `Failed to reload: ${errorMessage}`,
|
||||
type: "error",
|
||||
duration: 6000,
|
||||
});
|
||||
} finally {
|
||||
setIsReloading(false);
|
||||
}
|
||||
}, [isReloading, selectedAgent]);
|
||||
|
||||
// Handle conversation selection
|
||||
const handleConversationSelect = useCallback(
|
||||
async (conversationId: string) => {
|
||||
@@ -1002,6 +1074,27 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const approval = pendingApprovals.find((a) => a.request_id === request_id);
|
||||
if (!approval) return;
|
||||
|
||||
// Add user's decision as a visible message in the chat
|
||||
const messageTimestamp = Math.floor(Date.now() / 1000);
|
||||
const userDecisionMessage: import("@/types/openai").ConversationMessage = {
|
||||
id: `user-approval-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "function_approval_request",
|
||||
request_id: request_id,
|
||||
status: approved ? "approved" : "rejected",
|
||||
function_call: approval.function_call,
|
||||
} as import("@/types/openai").MessageFunctionApprovalRequestContent,
|
||||
],
|
||||
status: "completed",
|
||||
created_at: messageTimestamp,
|
||||
};
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems([...currentItems, userDecisionMessage]);
|
||||
|
||||
// Create approval response in OpenAI-compatible format
|
||||
const approvalInput: import("@/types/agent-framework").ResponseInputParam = [
|
||||
{
|
||||
@@ -1019,13 +1112,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
];
|
||||
|
||||
// Send approval response through the conversation
|
||||
// We'll call handleSendMessage directly when invoked (it's defined below)
|
||||
const request: RunAgentRequest = {
|
||||
input: approvalInput,
|
||||
conversation_id: currentConversation?.id,
|
||||
};
|
||||
|
||||
// Remove from pending immediately (will be confirmed by backend event)
|
||||
// Remove from pending immediately
|
||||
setPendingApprovals(
|
||||
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== request_id)
|
||||
);
|
||||
@@ -1039,6 +1131,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
async (request: RunAgentRequest) => {
|
||||
if (!selectedAgent) return;
|
||||
|
||||
// Check if this is a function approval response (internal, don't show in chat)
|
||||
const isApprovalResponse = request.input.some(
|
||||
(inputItem) =>
|
||||
inputItem.type === "message" &&
|
||||
Array.isArray(inputItem.content) &&
|
||||
inputItem.content.some((c) => c.type === "function_approval_response")
|
||||
);
|
||||
|
||||
// Extract content from OpenAI format to create ConversationMessage
|
||||
const messageContent: import("@/types/openai").MessageContent[] = [];
|
||||
|
||||
@@ -1069,16 +1169,23 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add user message to UI state (OpenAI ConversationMessage)
|
||||
const userMessage: import("@/types/openai").ConversationMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: messageContent,
|
||||
status: "completed",
|
||||
};
|
||||
// Capture timestamp once for both user and assistant messages
|
||||
const messageTimestamp = Math.floor(Date.now() / 1000); // Unix seconds
|
||||
|
||||
// Only add user message to UI if it's not an approval response (internal messages)
|
||||
if (!isApprovalResponse && messageContent.length > 0) {
|
||||
const userMessage: import("@/types/openai").ConversationMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: messageContent,
|
||||
status: "completed",
|
||||
created_at: messageTimestamp,
|
||||
};
|
||||
|
||||
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
|
||||
}
|
||||
|
||||
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
|
||||
setIsStreaming(true);
|
||||
|
||||
// Create assistant message placeholder
|
||||
@@ -1088,6 +1195,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
role: "assistant",
|
||||
content: [], // Will be filled during streaming
|
||||
status: "in_progress",
|
||||
created_at: messageTimestamp,
|
||||
};
|
||||
|
||||
setChatItems([...useDevUIStore.getState().chatItems, assistantMessage]);
|
||||
@@ -1102,8 +1210,17 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
});
|
||||
setCurrentConversation(conversationToUse);
|
||||
setAvailableConversations([conversationToUse, ...useDevUIStore.getState().availableConversations]);
|
||||
} catch {
|
||||
// Failed to create conversation
|
||||
setConversationError(null); // Clear any previous errors
|
||||
} catch (error) {
|
||||
// Failed to create conversation - show error and stop execution
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
|
||||
setConversationError({
|
||||
message: errorMessage,
|
||||
type: "conversation_creation_error",
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
setIsStreaming(false);
|
||||
return; // Stop execution - can't send message without conversation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1145,16 +1262,25 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
continue; // Continue processing other events
|
||||
}
|
||||
|
||||
// Handle response.failed event
|
||||
// Handle response.failed event (OpenAI standard)
|
||||
if (openAIEvent.type === "response.failed") {
|
||||
const failedEvent = openAIEvent as import("@/types/openai").ResponseFailedEvent;
|
||||
const error = failedEvent.response?.error;
|
||||
const errorMessage = error
|
||||
? typeof error === "object" && "message" in error
|
||||
? (error as any).message
|
||||
: JSON.stringify(error)
|
||||
: "Request failed";
|
||||
|
||||
// Format error message with details
|
||||
let errorMessage = "Request failed";
|
||||
if (error) {
|
||||
if (typeof error === "object" && "message" in error) {
|
||||
errorMessage = error.message as string;
|
||||
if ("code" in error && error.code) {
|
||||
errorMessage += ` (Code: ${error.code})`;
|
||||
}
|
||||
} else if (typeof error === "string") {
|
||||
errorMessage = error;
|
||||
}
|
||||
}
|
||||
|
||||
// Update assistant message with error
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
@@ -1171,14 +1297,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
: item
|
||||
));
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
return; // Exit stream processing on failure
|
||||
}
|
||||
|
||||
// Handle function approval request events
|
||||
if (openAIEvent.type === "response.function_approval.requested") {
|
||||
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
|
||||
|
||||
// Add to pending approvals
|
||||
// Add to pending approvals (for popup)
|
||||
setPendingApprovals([
|
||||
...useDevUIStore.getState().pendingApprovals,
|
||||
{
|
||||
@@ -1186,17 +1312,46 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
function_call: approvalEvent.function_call,
|
||||
},
|
||||
]);
|
||||
continue; // Don't add approval requests to chat UI
|
||||
|
||||
// Also add to chat UI to show function call progress
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) => {
|
||||
if (item.id === assistantMessage.id && item.type === "message") {
|
||||
return {
|
||||
...item,
|
||||
content: [
|
||||
...item.content,
|
||||
{
|
||||
type: "function_approval_request",
|
||||
request_id: approvalEvent.request_id,
|
||||
status: "pending",
|
||||
function_call: approvalEvent.function_call,
|
||||
} as import("@/types/openai").MessageFunctionApprovalRequestContent,
|
||||
],
|
||||
status: "in_progress" as const,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle function approval response events
|
||||
if (openAIEvent.type === "response.function_approval.responded") {
|
||||
const responseEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRespondedEvent;
|
||||
// Handle function result events (after function execution)
|
||||
if (openAIEvent.type === "response.function_result.complete") {
|
||||
const resultEvent = openAIEvent as import("@/types/openai").ResponseFunctionResultComplete;
|
||||
|
||||
// Remove from pending approvals
|
||||
setPendingApprovals(
|
||||
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== responseEvent.request_id)
|
||||
);
|
||||
// Add function result as a separate conversation item for clear visibility
|
||||
const functionResultItem: import("@/types/openai").ConversationFunctionCallOutput = {
|
||||
id: `result-${Date.now()}`,
|
||||
type: "function_call_output",
|
||||
call_id: resultEvent.call_id,
|
||||
output: resultEvent.output,
|
||||
status: resultEvent.status === "completed" ? "completed" : "incomplete",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems([...currentItems, functionResultItem]);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1227,6 +1382,57 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
return; // Exit stream processing early on error
|
||||
}
|
||||
|
||||
// Handle output item added events (images, files, data)
|
||||
if (openAIEvent.type === "response.output_item.added") {
|
||||
const outputItemEvent = openAIEvent as import("@/types/openai").ResponseOutputItemAddedEvent;
|
||||
const item = outputItemEvent.item;
|
||||
|
||||
// Add output items to assistant message content
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((chatItem) => {
|
||||
if (chatItem.id === assistantMessage.id && chatItem.type === "message") {
|
||||
const existingContent = chatItem.content;
|
||||
let newContent: import("@/types/openai").MessageContent | null = null;
|
||||
|
||||
// Map output items to message content
|
||||
if (item.type === "output_image") {
|
||||
newContent = {
|
||||
type: "output_image",
|
||||
image_url: item.image_url,
|
||||
alt_text: item.alt_text,
|
||||
mime_type: item.mime_type,
|
||||
} as import("@/types/openai").MessageOutputImage;
|
||||
} else if (item.type === "output_file") {
|
||||
newContent = {
|
||||
type: "output_file",
|
||||
filename: item.filename,
|
||||
file_url: item.file_url,
|
||||
file_data: item.file_data,
|
||||
mime_type: item.mime_type,
|
||||
} as import("@/types/openai").MessageOutputFile;
|
||||
} else if (item.type === "output_data") {
|
||||
newContent = {
|
||||
type: "output_data",
|
||||
data: item.data,
|
||||
mime_type: item.mime_type,
|
||||
description: item.description,
|
||||
} as import("@/types/openai").MessageOutputData;
|
||||
}
|
||||
|
||||
// If we created new content, append it
|
||||
if (newContent) {
|
||||
return {
|
||||
...chatItem,
|
||||
content: [...existingContent, newContent],
|
||||
status: "in_progress" as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
return chatItem;
|
||||
}));
|
||||
continue; // Continue to next event
|
||||
}
|
||||
|
||||
// Handle text delta events for chat
|
||||
if (
|
||||
openAIEvent.type === "response.output_text.delta" &&
|
||||
@@ -1236,21 +1442,26 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
accumulatedTextRef.current += openAIEvent.delta;
|
||||
|
||||
// Update assistant message with accumulated content
|
||||
// Preserve any existing non-text content (images, files, data)
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: accumulatedTextRef.current,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "in_progress" as const,
|
||||
}
|
||||
: item
|
||||
));
|
||||
setChatItems(currentItems.map((item) => {
|
||||
if (item.id === assistantMessage.id && item.type === "message") {
|
||||
// Keep existing non-text content, update text content
|
||||
const existingNonTextContent = item.content.filter(c => c.type !== "text");
|
||||
return {
|
||||
...item,
|
||||
content: [
|
||||
...existingNonTextContent,
|
||||
{
|
||||
type: "text",
|
||||
text: accumulatedTextRef.current,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "in_progress" as const,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
|
||||
// Handle completion/error by detecting when streaming stops
|
||||
@@ -1435,19 +1646,42 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
Chat with {selectedAgent.name || selectedAgent.id}
|
||||
{oaiMode.enabled
|
||||
? `Chat with ${oaiMode.model}`
|
||||
: `Chat with ${selectedAgent.name || selectedAgent.id}`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDetailsModalOpen(true)}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
title="View agent details"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
{!oaiMode.enabled && uiMode === "developer" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDetailsModalOpen(true)}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
title="View agent details"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReloadEntity}
|
||||
disabled={isReloading || selectedAgent.metadata?.source === "in_memory"}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
title={
|
||||
selectedAgent.metadata?.source === "in_memory"
|
||||
? "In-memory entities cannot be reloaded"
|
||||
: isReloading
|
||||
? "Reloading..."
|
||||
: "Reload entity code (hot reload)"
|
||||
}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isReloading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conversation Controls */}
|
||||
@@ -1539,13 +1773,46 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAgent.description && (
|
||||
{oaiMode.enabled ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedAgent.description}
|
||||
Using OpenAI model directly. Local agent tools and instructions are not applied.
|
||||
</p>
|
||||
) : (
|
||||
selectedAgent.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedAgent.description}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{conversationError && (
|
||||
<div className="mx-4 mt-2 p-3 bg-destructive/10 border border-destructive/30 rounded-md flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-destructive">
|
||||
Failed to Create Conversation
|
||||
</div>
|
||||
<div className="text-xs text-destructive/90 mt-1 break-words">
|
||||
{conversationError.message}
|
||||
</div>
|
||||
{conversationError.code && (
|
||||
<div className="text-xs text-destructive/70 mt-1">
|
||||
Error Code: {conversationError.code}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setConversationError(null)}
|
||||
className="text-destructive hover:text-destructive/80 flex-shrink-0"
|
||||
title="Dismiss error"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 p-4 h-0" ref={scrollAreaRef}>
|
||||
<div className="space-y-4">
|
||||
|
||||
+136
-4
@@ -11,6 +11,9 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Music,
|
||||
Check,
|
||||
X,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import type { MessageContent } from "@/types/openai";
|
||||
import { MarkdownRenderer } from "@/components/ui/markdown-renderer";
|
||||
@@ -37,12 +40,12 @@ function TextContentRenderer({ content, className, isStreaming }: ContentRendere
|
||||
);
|
||||
}
|
||||
|
||||
// Image content renderer
|
||||
// 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") return null;
|
||||
if (content.type !== "input_image" && content.type !== "output_image") return null;
|
||||
|
||||
const imageUrl = content.image_url;
|
||||
|
||||
@@ -77,9 +80,9 @@ function ImageContentRenderer({ content, className }: ContentRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// File content renderer
|
||||
// File content renderer (handles both input and output files)
|
||||
function FileContentRenderer({ content, className }: ContentRendererProps) {
|
||||
if (content.type !== "input_file") return null;
|
||||
if (content.type !== "input_file" && content.type !== "output_file") return null;
|
||||
|
||||
const fileUrl = content.file_url || content.file_data;
|
||||
const filename = content.filename || "file";
|
||||
@@ -156,6 +159,129 @@ function FileContentRenderer({ content, className }: ContentRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Data content renderer (for generic structured data outputs)
|
||||
function DataContentRenderer({ content, className }: ContentRendererProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (content.type !== "output_data") return null;
|
||||
|
||||
const data = content.data;
|
||||
const mimeType = content.mime_type;
|
||||
const description = content.description;
|
||||
|
||||
// Try to parse as JSON for pretty printing
|
||||
let displayData = data;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
displayData = JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
// Not JSON, display as-is
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{description || "Data Output"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{mimeType}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<pre className="mt-2 text-xs overflow-auto max-h-64 bg-background p-2 rounded border font-mono">
|
||||
{displayData}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Function approval request renderer
|
||||
function FunctionApprovalRequestRenderer({ content, className }: ContentRendererProps) {
|
||||
if (content.type !== "function_approval_request") return null;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { status, function_call } = content;
|
||||
|
||||
// Status styling
|
||||
const statusConfig = {
|
||||
pending: {
|
||||
icon: Clock,
|
||||
color: "amber",
|
||||
label: "Awaiting Approval",
|
||||
bgClass: "bg-amber-50 dark:bg-amber-950/20",
|
||||
borderClass: "border-amber-200 dark:border-amber-800",
|
||||
iconClass: "text-amber-600 dark:text-amber-400",
|
||||
textClass: "text-amber-800 dark:text-amber-300",
|
||||
},
|
||||
approved: {
|
||||
icon: Check,
|
||||
color: "green",
|
||||
label: "Approved",
|
||||
bgClass: "bg-green-50 dark:bg-green-950/20",
|
||||
borderClass: "border-green-200 dark:border-green-800",
|
||||
iconClass: "text-green-600 dark:text-green-400",
|
||||
textClass: "text-green-800 dark:text-green-300",
|
||||
},
|
||||
rejected: {
|
||||
icon: X,
|
||||
color: "red",
|
||||
label: "Rejected",
|
||||
bgClass: "bg-red-50 dark:bg-red-950/20",
|
||||
borderClass: "border-red-200 dark:border-red-800",
|
||||
iconClass: "text-red-600 dark:text-red-400",
|
||||
textClass: "text-red-800 dark:text-red-300",
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
let parsedArgs;
|
||||
try {
|
||||
parsedArgs = typeof function_call.arguments === "string"
|
||||
? JSON.parse(function_call.arguments)
|
||||
: function_call.arguments;
|
||||
} catch {
|
||||
parsedArgs = function_call.arguments;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded ${config.bgClass} ${config.borderClass} ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<StatusIcon className={`h-4 w-4 ${config.iconClass}`} />
|
||||
<span className={`text-sm font-medium ${config.textClass}`}>
|
||||
{config.label}: {function_call.name}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className={`h-4 w-4 ${config.iconClass} ml-auto`} />
|
||||
) : (
|
||||
<ChevronRight className={`h-4 w-4 ${config.iconClass} ml-auto`} />
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
|
||||
<div className={`${config.textClass} mb-1`}>Arguments:</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{JSON.stringify(parsedArgs, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main content renderer that delegates to specific renderers
|
||||
export function OpenAIContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
|
||||
switch (content.type) {
|
||||
@@ -164,9 +290,15 @@ export function OpenAIContentRenderer({ content, className, isStreaming }: Conte
|
||||
case "output_text":
|
||||
return <TextContentRenderer content={content} className={className} isStreaming={isStreaming} />;
|
||||
case "input_image":
|
||||
case "output_image":
|
||||
return <ImageContentRenderer content={content} className={className} />;
|
||||
case "input_file":
|
||||
case "output_file":
|
||||
return <FileContentRenderer content={content} className={className} />;
|
||||
case "output_data":
|
||||
return <DataContentRenderer content={content} className={className} />;
|
||||
case "function_approval_request":
|
||||
return <FunctionApprovalRequestRenderer content={content} className={className} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
+528
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* ExecutionTimeline - Vertical timeline showing workflow executor runs
|
||||
* Features: Chronological executor execution, expandable output, bidirectional graph highlighting
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
import type { ExecutorState } from "./executor-node";
|
||||
import { truncateText } from "@/utils/workflow-utils";
|
||||
|
||||
interface ExecutorRun {
|
||||
executorId: string;
|
||||
executorName: string;
|
||||
itemId: string; // Unique ID for this specific run
|
||||
state: ExecutorState;
|
||||
output: string;
|
||||
error?: string;
|
||||
timestamp: number;
|
||||
runNumber: number; // For multiple runs of same executor
|
||||
}
|
||||
|
||||
interface ExecutionTimelineProps {
|
||||
events: ExtendedResponseStreamEvent[];
|
||||
itemOutputs: Record<string, string>;
|
||||
currentExecutorId: string | null;
|
||||
isStreaming: boolean;
|
||||
onExecutorClick?: (executorId: string) => void;
|
||||
selectedExecutorId?: string | null;
|
||||
workflowResult?: string;
|
||||
}
|
||||
|
||||
function getStateIcon(state: ExecutorState) {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return <Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin" />;
|
||||
case "completed":
|
||||
return <CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />;
|
||||
case "failed":
|
||||
return <XCircle className="w-4 h-4 text-red-500 dark:text-red-400" />;
|
||||
case "cancelled":
|
||||
return <AlertCircle className="w-4 h-4 text-orange-500 dark:text-orange-400" />;
|
||||
default:
|
||||
return <div className="w-4 h-4 rounded-full border-2 border-gray-400 dark:border-gray-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getStateBadgeClass(state: ExecutorState) {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return "bg-[#643FB2]/10 text-[#643FB2] dark:bg-[#8B5CF6]/10 dark:text-[#8B5CF6] border-[#643FB2]/20 dark:border-[#8B5CF6]/20";
|
||||
case "completed":
|
||||
return "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20";
|
||||
case "failed":
|
||||
return "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20";
|
||||
case "cancelled":
|
||||
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20";
|
||||
default:
|
||||
return "bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20";
|
||||
}
|
||||
}
|
||||
|
||||
function ExecutorRunItem({
|
||||
run,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onClick,
|
||||
isSelected,
|
||||
}: {
|
||||
run: ExecutorRun;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onClick: () => void;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
const timestamp = new Date(run.timestamp).toLocaleTimeString();
|
||||
const hasOutput = run.output.trim().length > 0;
|
||||
const canExpand = hasOutput || run.error;
|
||||
const outputRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
// Auto-scroll output to bottom when content changes (during streaming)
|
||||
useEffect(() => {
|
||||
if (isExpanded && run.state === "running" && outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||
}
|
||||
}, [run.output, isExpanded, run.state]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border rounded-lg transition-all ${
|
||||
isSelected
|
||||
? "border-blue-500 dark:border-blue-400 bg-blue-500/5 dark:bg-blue-500/10"
|
||||
: "border-border hover:border-muted-foreground/30"
|
||||
}`}
|
||||
>
|
||||
{/* Header - Always Visible */}
|
||||
<div
|
||||
className="p-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
onClick();
|
||||
if (canExpand) onToggle();
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[auto_auto_1fr_auto] items-center gap-2 mb-1">
|
||||
<div className="w-3 text-muted-foreground">
|
||||
{canExpand && (
|
||||
<>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>{getStateIcon(run.state)}</div>
|
||||
<span className="font-medium text-sm truncate overflow-hidden">
|
||||
{run.executorName}
|
||||
</span>
|
||||
{run.runNumber > 1 ? (
|
||||
<Badge variant="outline" className="text-xs whitespace-nowrap">
|
||||
Run #{run.runNumber}
|
||||
</Badge>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground ml-5">
|
||||
<span className="font-mono">{timestamp}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs border ${getStateBadgeClass(run.state)}`}
|
||||
>
|
||||
{run.state}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable Content */}
|
||||
{isExpanded && canExpand && (
|
||||
<div className="border-t px-3 py-2 bg-muted/30">
|
||||
{run.error ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-red-600 dark:text-red-400">
|
||||
Error:
|
||||
</div>
|
||||
<pre className="text-xs bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded p-2 overflow-y-auto overflow-x-hidden max-h-40 whitespace-pre-wrap break-all">
|
||||
{run.error}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Output:
|
||||
</div>
|
||||
<pre
|
||||
ref={outputRef}
|
||||
className="text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all"
|
||||
>
|
||||
{run.output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExecutionTimeline({
|
||||
events,
|
||||
itemOutputs,
|
||||
currentExecutorId,
|
||||
isStreaming,
|
||||
onExecutorClick,
|
||||
selectedExecutorId,
|
||||
workflowResult,
|
||||
}: ExecutionTimelineProps) {
|
||||
const [expandedRuns, setExpandedRuns] = useState<Set<string>>(new Set());
|
||||
const [updateTrigger, setUpdateTrigger] = useState(0);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const lastScrolledRunRef = useRef<string | null>(null);
|
||||
const timelineEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Force re-render when streaming to show updated outputs from itemOutputs ref
|
||||
// Note: itemOutputs is a ref (not state), so changes don't trigger re-renders automatically.
|
||||
// This polling approach ensures the UI updates during streaming. Could be optimized by:
|
||||
// 1. Converting itemOutputs to state (increases re-renders)
|
||||
// 2. Using requestAnimationFrame instead of setInterval
|
||||
// 3. Having parent component trigger updates via callback
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
const interval = setInterval(() => {
|
||||
setUpdateTrigger((prev) => prev + 1);
|
||||
}, 100); // Update 10 times per second during streaming
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
// Process events to extract executor runs - memoized to prevent recalculation
|
||||
const { executorRuns, executorRunCount } = useMemo(() => {
|
||||
const runs: ExecutorRun[] = [];
|
||||
const runCount = new Map<string, number>();
|
||||
|
||||
events.forEach((event) => {
|
||||
// Extract UI timestamp (captured when event arrived, won't change on re-render)
|
||||
const uiTimestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
|
||||
? event._uiTimestamp * 1000
|
||||
: Date.now();
|
||||
|
||||
// Handle new standard OpenAI events
|
||||
if (event.type === "response.output_item.added") {
|
||||
const item = (event as { item?: { type?: string; executor_id?: string; id?: string; created_at?: number; metadata?: any } }).item;
|
||||
|
||||
// Handle both executor_action items AND message items from Magentic agents
|
||||
if (item && item.type === "executor_action" && item.executor_id && item.id) {
|
||||
const executorId = item.executor_id;
|
||||
const itemId = item.id;
|
||||
const runNumber = (runCount.get(executorId) || 0) + 1;
|
||||
runCount.set(executorId, runNumber);
|
||||
|
||||
runs.push({
|
||||
executorId,
|
||||
executorName: truncateText(executorId, 35),
|
||||
itemId,
|
||||
state: "running",
|
||||
output: itemOutputs[itemId] || "",
|
||||
timestamp: uiTimestamp,
|
||||
runNumber,
|
||||
});
|
||||
} else if (item && item.type === "message" && item.metadata?.agent_id && item.metadata?.source === "magentic" && item.id) {
|
||||
// Handle message items from Magentic agents
|
||||
const executorId = item.metadata.agent_id;
|
||||
const itemId = item.id;
|
||||
const runNumber = (runCount.get(executorId) || 0) + 1;
|
||||
runCount.set(executorId, runNumber);
|
||||
|
||||
runs.push({
|
||||
executorId,
|
||||
executorName: truncateText(executorId, 35),
|
||||
itemId,
|
||||
state: "running",
|
||||
output: itemOutputs[itemId] || "",
|
||||
timestamp: uiTimestamp,
|
||||
runNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle completion events
|
||||
if (event.type === "response.output_item.done") {
|
||||
const item = (event as { item?: { type?: string; executor_id?: string; id?: string; status?: string; error?: string; metadata?: any } }).item;
|
||||
|
||||
// Handle both executor_action items AND message items from Magentic agents
|
||||
if (item && item.type === "executor_action" && item.executor_id && item.id) {
|
||||
const itemId = item.id;
|
||||
// Find the run by ITEM ID (not executor ID!) to handle multiple runs correctly
|
||||
const existingRun = runs.find((r) => r.itemId === itemId);
|
||||
|
||||
if (existingRun) {
|
||||
existingRun.state =
|
||||
item.status === "completed"
|
||||
? "completed"
|
||||
: item.status === "failed"
|
||||
? "failed"
|
||||
: "completed";
|
||||
// Use item-specific output, not executor-wide output
|
||||
existingRun.output = itemOutputs[itemId] || "";
|
||||
if (item.status === "failed" && item.error) {
|
||||
existingRun.error = item.error;
|
||||
}
|
||||
}
|
||||
} else if (item && item.type === "message" && item.metadata?.agent_id && item.metadata?.source === "magentic" && item.id) {
|
||||
// Handle message completion from Magentic agents
|
||||
const itemId = item.id;
|
||||
const existingRun = runs.find((r) => r.itemId === itemId);
|
||||
|
||||
if (existingRun) {
|
||||
existingRun.state = item.status === "completed" ? "completed" : "failed";
|
||||
existingRun.output = itemOutputs[itemId] || "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback support for workflow_event format (used for unhandled event types and status/warning/error events)
|
||||
if (
|
||||
event.type === "response.workflow_event.completed" &&
|
||||
"data" in event &&
|
||||
event.data
|
||||
) {
|
||||
const data = event.data as { executor_id?: string; event_type?: string; data?: unknown; timestamp?: string };
|
||||
const executorId = data.executor_id;
|
||||
if (!executorId) return;
|
||||
|
||||
const eventType = data.event_type;
|
||||
|
||||
if (eventType === "ExecutorInvokedEvent") {
|
||||
const runNumber = (runCount.get(executorId) || 0) + 1;
|
||||
runCount.set(executorId, runNumber);
|
||||
|
||||
// Create synthetic item ID for fallback format (no real item.id from backend)
|
||||
const syntheticItemId = `fallback_${executorId}_${uiTimestamp}`;
|
||||
|
||||
runs.push({
|
||||
executorId,
|
||||
executorName: truncateText(executorId, 35),
|
||||
itemId: syntheticItemId,
|
||||
state: "running",
|
||||
output: itemOutputs[syntheticItemId] || "",
|
||||
timestamp: uiTimestamp,
|
||||
runNumber,
|
||||
});
|
||||
} else if (eventType === "ExecutorCompletedEvent") {
|
||||
// Find the most recent running instance of this executor (search from end)
|
||||
let existingRun: ExecutorRun | undefined;
|
||||
for (let i = runs.length - 1; i >= 0; i--) {
|
||||
if (runs[i].executorId === executorId && runs[i].state === "running") {
|
||||
existingRun = runs[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (existingRun) {
|
||||
existingRun.state = "completed";
|
||||
existingRun.output = itemOutputs[existingRun.itemId] || "";
|
||||
}
|
||||
} else if (
|
||||
eventType?.includes("Error") ||
|
||||
eventType?.includes("Failed")
|
||||
) {
|
||||
// Find the most recent running instance of this executor (search from end)
|
||||
let existingRun: ExecutorRun | undefined;
|
||||
for (let i = runs.length - 1; i >= 0; i--) {
|
||||
if (runs[i].executorId === executorId && runs[i].state === "running") {
|
||||
existingRun = runs[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (existingRun) {
|
||||
existingRun.state = "failed";
|
||||
existingRun.error =
|
||||
typeof data.data === "string" ? data.data : "Execution failed";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update outputs for running executors using item-specific outputs
|
||||
// This ensures each run gets its own output, even for multiple runs of the same executor
|
||||
runs.forEach((run) => {
|
||||
if (run.state === "running" && itemOutputs[run.itemId]) {
|
||||
run.output = itemOutputs[run.itemId];
|
||||
}
|
||||
});
|
||||
|
||||
return { executorRuns: runs, executorRunCount: runCount };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [events, itemOutputs, updateTrigger]);
|
||||
|
||||
// Auto-expand running executors
|
||||
useEffect(() => {
|
||||
if (currentExecutorId) {
|
||||
setExpandedRuns((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(`${currentExecutorId}-${executorRunCount.get(currentExecutorId) || 1}`);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [currentExecutorId, executorRunCount]);
|
||||
|
||||
// Auto-scroll to newest executor when it appears or changes
|
||||
useEffect(() => {
|
||||
if (executorRuns.length > 0 && isStreaming) {
|
||||
const latestRun = executorRuns[executorRuns.length - 1];
|
||||
const latestRunKey = `${latestRun.executorId}-${latestRun.runNumber}`;
|
||||
|
||||
// Only scroll if this is a new run we haven't scrolled to yet
|
||||
if (latestRunKey !== lastScrolledRunRef.current) {
|
||||
lastScrolledRunRef.current = latestRunKey;
|
||||
|
||||
// Scroll to the end of the timeline
|
||||
if (timelineEndRef.current) {
|
||||
timelineEndRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [executorRuns, isStreaming]);
|
||||
|
||||
// Auto-scroll to show workflow result when it appears (after streaming completes)
|
||||
useEffect(() => {
|
||||
if (workflowResult && !isStreaming && timelineEndRef.current) {
|
||||
// Small delay to ensure the result card is rendered before scrolling
|
||||
setTimeout(() => {
|
||||
timelineEndRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end'
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}, [workflowResult, isStreaming]);
|
||||
|
||||
const handleCopyAll = () => {
|
||||
const text = executorRuns
|
||||
.map((run) => {
|
||||
const timestamp = new Date(run.timestamp).toLocaleTimeString();
|
||||
const header = `[${timestamp}] ${run.executorName} (${run.state})`;
|
||||
const content = run.error || run.output || "(no output)";
|
||||
return `${header}\n${content}\n`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col border-l bg-muted/30">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b bg-background flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">Execution Timeline</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{executorRuns.length}
|
||||
</Badge>
|
||||
{isStreaming && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-[#643FB2] dark:bg-[#8B5CF6]" />
|
||||
<span>Running</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{executorRuns.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyAll}
|
||||
className={`h-7 px-2 text-xs ${copied ? "text-green-600 dark:text-green-400" : ""}`}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 mr-1" />
|
||||
Copy All
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3 space-y-2">
|
||||
{executorRuns.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground text-sm py-8">
|
||||
No executor runs yet. Start the workflow to see execution timeline.
|
||||
</div>
|
||||
) : (
|
||||
executorRuns.map((run, index) => {
|
||||
const runKey = `${run.executorId}-${run.runNumber}`;
|
||||
return (
|
||||
<ExecutorRunItem
|
||||
key={`${runKey}-${index}`}
|
||||
run={run}
|
||||
isExpanded={expandedRuns.has(runKey)}
|
||||
onToggle={() => {
|
||||
setExpandedRuns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(runKey)) {
|
||||
next.delete(runKey);
|
||||
} else {
|
||||
next.add(runKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onClick={() => onExecutorClick?.(run.executorId)}
|
||||
isSelected={selectedExecutorId === run.executorId}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{/* Workflow final output card */}
|
||||
{workflowResult && workflowResult.trim().length > 0 && !isStreaming && (
|
||||
<div className="border rounded-lg border-green-500/40 bg-green-500/5 dark:bg-green-500/10">
|
||||
<div className="p-3 bg-green-500/10 border-b border-green-500/20">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />
|
||||
<span className="font-medium text-sm">Workflow Complete</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t px-3 py-2 bg-muted/30">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Final Output:
|
||||
</div>
|
||||
<pre className="text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all">
|
||||
{workflowResult}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Invisible element at the end for scroll target */}
|
||||
<div ref={timelineEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import {
|
||||
Workflow,
|
||||
Home,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { truncateText } from "@/utils/workflow-utils";
|
||||
|
||||
export type ExecutorState =
|
||||
| "pending"
|
||||
@@ -81,11 +83,13 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
const details = [];
|
||||
|
||||
if (nodeData.error && typeof nodeData.error === "string") {
|
||||
// Truncate error to first 150 characters for node display
|
||||
const truncatedError = truncateText(nodeData.error, 150);
|
||||
details.push(
|
||||
<div key="error" className="mb-2">
|
||||
<div className="text-xs font-medium text-red-600 dark:text-red-400 mb-1">Error:</div>
|
||||
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800">
|
||||
{nodeData.error}
|
||||
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800 break-words">
|
||||
{truncatedError}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -155,34 +159,32 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
isRunning ? config.glow : "shadow-sm",
|
||||
)}
|
||||
>
|
||||
{/* Small circular handles */}
|
||||
{!nodeData.isStartNode && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={targetPosition}
|
||||
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
|
||||
style={{
|
||||
backgroundColor: nodeData.state === "running" ? "#643FB2" :
|
||||
nodeData.state === "completed" ? "#10b981" :
|
||||
nodeData.state === "failed" ? "#ef4444" :
|
||||
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* 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"
|
||||
}}
|
||||
/>
|
||||
|
||||
{!nodeData.isEndNode && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={sourcePosition}
|
||||
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
|
||||
style={{
|
||||
backgroundColor: nodeData.state === "running" ? "#643FB2" :
|
||||
nodeData.state === "completed" ? "#10b981" :
|
||||
nodeData.state === "failed" ? "#ef4444" :
|
||||
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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 */}
|
||||
@@ -196,18 +198,16 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
<Workflow className="w-5 h-5 text-gray-300 dark:text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
{/* Small status badge for running state */}
|
||||
{isRunning && (
|
||||
<div className={cn(
|
||||
"absolute -top-1 -right-1 w-3 h-3 rounded-full animate-pulse",
|
||||
config.badgeColor
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
|
||||
{nodeData.name || nodeData.executorId}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
|
||||
{nodeData.name || nodeData.executorId}
|
||||
</h3>
|
||||
{isRunning && (
|
||||
<Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{nodeData.executorType && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
|
||||
{nodeData.executorType}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { MessageCircle, Send, Loader2 } from "lucide-react";
|
||||
import { SchemaFormRenderer, validateSchemaForm } from "./schema-form-renderer";
|
||||
import type { JSONSchemaProperty } from "@/types";
|
||||
|
||||
interface HilRequest {
|
||||
request_id: string;
|
||||
request_data: Record<string, unknown>;
|
||||
request_schema: JSONSchemaProperty;
|
||||
}
|
||||
|
||||
interface HilInputModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
requests: HilRequest[];
|
||||
responses: Record<string, Record<string, unknown>>;
|
||||
onResponseChange: (requestId: string, values: Record<string, unknown>) => void;
|
||||
onSubmit: () => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export function HilInputModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
requests,
|
||||
responses,
|
||||
onResponseChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: HilInputModalProps) {
|
||||
// Check if all required fields are filled
|
||||
const areAllRequiredFieldsFilled = () => {
|
||||
return requests.every((req) => {
|
||||
const response = responses[req.request_id] || {};
|
||||
return validateSchemaForm(req.request_schema, response);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageCircle className="w-5 h-5" />
|
||||
Workflow Requires Input ({requests.length} request
|
||||
{requests.length > 1 ? "s" : ""})
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
The workflow is paused and needs your input to continue.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{requests.map((req, index) => (
|
||||
<Card key={req.request_id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
Request {index + 1}
|
||||
<Badge variant="outline" className="ml-2 font-mono text-xs">
|
||||
{req.request_id.slice(0, 8)}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Show request data as readonly context */}
|
||||
{Object.keys(req.request_data).length > 0 && (
|
||||
<div className="mb-4 p-3 bg-muted rounded-md max-h-48 overflow-y-auto">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Request Context:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(req.request_data)
|
||||
.filter(([key]) => !["request_id", "source_executor_id"].includes(key))
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="text-xs">
|
||||
<span className="font-medium">{key}:</span>{" "}
|
||||
<span className="text-muted-foreground break-all">
|
||||
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show expected response hint if available */}
|
||||
{req.request_schema?.description && (
|
||||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-md">
|
||||
<p className="text-xs font-medium text-blue-900 dark:text-blue-100 mb-1">
|
||||
Expected Response:
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
{req.request_schema.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Use schema-based form renderer for RESPONSE (not request) */}
|
||||
<SchemaFormRenderer
|
||||
schema={req.request_schema}
|
||||
values={responses[req.request_id] || {}}
|
||||
onChange={(values) => onResponseChange(req.request_id, values)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex gap-2 w-full justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !areAllRequiredFieldsFilled()}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Submit & Continue
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -7,3 +7,5 @@ export { WorkflowDetailsModal } from "./workflow-details-modal";
|
||||
export { WorkflowFlow } from "./workflow-flow";
|
||||
export { WorkflowInputForm } from "./workflow-input-form";
|
||||
export { ExecutorNode } from "./executor-node";
|
||||
export { SchemaFormRenderer, validateSchemaForm, filterEmptyOptionalFields } from "./schema-form-renderer";
|
||||
export { HilInputModal } from "./hil-input-modal";
|
||||
|
||||
+546
@@ -0,0 +1,546 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { JSONSchemaProperty } from "@/types";
|
||||
|
||||
// ============================================================================
|
||||
// Field Type Detection (from WorkflowInputForm)
|
||||
// ============================================================================
|
||||
|
||||
function isShortField(fieldName: string): boolean {
|
||||
const shortFieldNames = [
|
||||
"name",
|
||||
"title",
|
||||
"id",
|
||||
"key",
|
||||
"label",
|
||||
"type",
|
||||
"status",
|
||||
"tag",
|
||||
"category",
|
||||
"code",
|
||||
"username",
|
||||
"password",
|
||||
"email",
|
||||
];
|
||||
return shortFieldNames.includes(fieldName.toLowerCase());
|
||||
}
|
||||
|
||||
function shouldFieldBeTextarea(
|
||||
fieldName: string,
|
||||
schema: JSONSchemaProperty
|
||||
): boolean {
|
||||
return (
|
||||
schema.format === "textarea" ||
|
||||
(!!schema.description && schema.description.length > 100) ||
|
||||
(schema.type === "string" && !schema.enum && !isShortField(fieldName))
|
||||
);
|
||||
}
|
||||
|
||||
function getFieldColumnSpan(
|
||||
fieldName: string,
|
||||
schema: JSONSchemaProperty
|
||||
): string {
|
||||
const isTextarea = shouldFieldBeTextarea(fieldName, schema);
|
||||
const hasLongDescription =
|
||||
!!schema.description && schema.description.length > 150;
|
||||
|
||||
if (isTextarea || hasLongDescription) {
|
||||
return "md:col-span-2 lg:col-span-3 xl:col-span-4";
|
||||
}
|
||||
|
||||
if (
|
||||
schema.type === "array" ||
|
||||
(!!schema.description && schema.description.length > 80)
|
||||
) {
|
||||
return "xl:col-span-2";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ChatMessage Pattern Detection (from WorkflowInputForm)
|
||||
// ============================================================================
|
||||
|
||||
function detectChatMessagePattern(
|
||||
schema: JSONSchemaProperty,
|
||||
requiredFields: string[]
|
||||
): boolean {
|
||||
if (schema.type !== "object" || !schema.properties) return false;
|
||||
|
||||
const properties = schema.properties;
|
||||
const optionalFields = Object.keys(properties).filter(
|
||||
(name) => !requiredFields.includes(name)
|
||||
);
|
||||
|
||||
return (
|
||||
requiredFields.includes("role") &&
|
||||
optionalFields.some((f) => ["text", "message", "content"].includes(f)) &&
|
||||
properties["role"]?.type === "string"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Form Field Component (from WorkflowInputForm)
|
||||
// ============================================================================
|
||||
|
||||
interface FormFieldProps {
|
||||
name: string;
|
||||
schema: JSONSchemaProperty;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
isRequired?: boolean;
|
||||
isReadOnly?: boolean; // NEW: for HIL display-only fields
|
||||
}
|
||||
|
||||
function FormField({
|
||||
name,
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
isRequired = false,
|
||||
isReadOnly = false,
|
||||
}: FormFieldProps) {
|
||||
const { type, description, enum: enumValues, default: defaultValue } = schema;
|
||||
const isTextarea = shouldFieldBeTextarea(name, schema);
|
||||
|
||||
const renderInput = () => {
|
||||
// Read-only display (for HIL request context)
|
||||
if (isReadOnly) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name} className="text-muted-foreground">
|
||||
{name}
|
||||
</Label>
|
||||
<div className="text-sm p-2 bg-muted rounded border">
|
||||
{typeof value === "object"
|
||||
? JSON.stringify(value, null, 2)
|
||||
: String(value)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "string":
|
||||
if (enumValues) {
|
||||
// Enum select
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Select
|
||||
value={
|
||||
typeof value === "string" && value
|
||||
? value
|
||||
: typeof defaultValue === "string"
|
||||
? defaultValue
|
||||
: enumValues[0]
|
||||
}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`Select ${name}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enumValues.map((option: string) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (isTextarea) {
|
||||
// Multi-line text
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={name}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={
|
||||
typeof defaultValue === "string"
|
||||
? defaultValue
|
||||
: `Enter ${name}`
|
||||
}
|
||||
rows={4}
|
||||
className="min-w-[300px] w-full"
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// Single-line text
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
type="text"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={
|
||||
typeof defaultValue === "string"
|
||||
? defaultValue
|
||||
: `Enter ${name}`
|
||||
}
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "integer":
|
||||
case "number":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
type="number"
|
||||
step={type === "integer" ? "1" : "any"}
|
||||
value={typeof value === "number" ? value : ""}
|
||||
onChange={(e) => {
|
||||
const val =
|
||||
type === "integer"
|
||||
? parseInt(e.target.value)
|
||||
: parseFloat(e.target.value);
|
||||
onChange(isNaN(val) ? "" : val);
|
||||
}}
|
||||
placeholder={
|
||||
typeof defaultValue === "number"
|
||||
? defaultValue.toString()
|
||||
: `Enter ${name}`
|
||||
}
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "boolean":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={name}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(checked) => onChange(checked)}
|
||||
/>
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "array":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={name}
|
||||
value={
|
||||
Array.isArray(value)
|
||||
? value.join(", ")
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const arrayValue = e.target.value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
onChange(arrayValue);
|
||||
}}
|
||||
placeholder="Enter items separated by commas"
|
||||
rows={2}
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "object":
|
||||
default:
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{name}
|
||||
{isRequired && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={name}
|
||||
value={
|
||||
typeof value === "object" && value !== null
|
||||
? JSON.stringify(value, null, 2)
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
onChange(parsed);
|
||||
} catch {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
placeholder='{"key": "value"}'
|
||||
rows={3}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className={getFieldColumnSpan(name, schema)}>{renderInput()}</div>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Schema Form Renderer Component
|
||||
// ============================================================================
|
||||
|
||||
export interface SchemaFormRendererProps {
|
||||
schema: JSONSchemaProperty;
|
||||
values: Record<string, unknown>;
|
||||
onChange: (values: Record<string, unknown>) => void;
|
||||
disabled?: boolean;
|
||||
readOnlyFields?: string[]; // NEW: Fields to display but not edit (for HIL)
|
||||
hideFields?: string[]; // NEW: Fields to completely hide
|
||||
showCollapsedByDefault?: boolean; // NEW: Control initial collapsed state
|
||||
}
|
||||
|
||||
export function SchemaFormRenderer({
|
||||
schema,
|
||||
values,
|
||||
onChange,
|
||||
disabled = false,
|
||||
readOnlyFields = [],
|
||||
hideFields = [],
|
||||
showCollapsedByDefault = false,
|
||||
}: SchemaFormRendererProps) {
|
||||
const [showAdvancedFields, setShowAdvancedFields] = useState(
|
||||
showCollapsedByDefault
|
||||
);
|
||||
|
||||
const properties = schema.properties || {};
|
||||
const allFieldNames = Object.keys(properties).filter(
|
||||
(name) => !hideFields.includes(name)
|
||||
);
|
||||
const requiredFields = (schema.required || []).filter(
|
||||
(name) => !hideFields.includes(name)
|
||||
);
|
||||
|
||||
// Detect ChatMessage pattern
|
||||
const isChatMessageLike = detectChatMessagePattern(schema, requiredFields);
|
||||
|
||||
// Separate required and optional fields
|
||||
const requiredFieldNames = allFieldNames.filter(
|
||||
(name) =>
|
||||
requiredFields.includes(name) && !(isChatMessageLike && name === "role")
|
||||
);
|
||||
|
||||
const optionalFieldNames = allFieldNames.filter(
|
||||
(name) => !requiredFields.includes(name)
|
||||
);
|
||||
|
||||
// For ChatMessage: prioritize text/message/content
|
||||
const sortedOptionalFields = isChatMessageLike
|
||||
? [...optionalFieldNames].sort((a, b) => {
|
||||
const priority = (name: string) =>
|
||||
["text", "message", "content"].includes(name) ? 1 : 0;
|
||||
return priority(b) - priority(a);
|
||||
})
|
||||
: optionalFieldNames;
|
||||
|
||||
// Show minimum visible fields
|
||||
const MIN_VISIBLE_FIELDS = isChatMessageLike ? 1 : 6;
|
||||
const visibleOptionalCount = Math.max(
|
||||
0,
|
||||
MIN_VISIBLE_FIELDS - requiredFieldNames.length
|
||||
);
|
||||
const visibleOptionalFields = sortedOptionalFields.slice(
|
||||
0,
|
||||
visibleOptionalCount
|
||||
);
|
||||
const collapsedOptionalFields = sortedOptionalFields.slice(
|
||||
visibleOptionalCount
|
||||
);
|
||||
|
||||
const hasCollapsedFields = collapsedOptionalFields.length > 0;
|
||||
const hasRequiredFields = requiredFieldNames.length > 0;
|
||||
|
||||
const updateField = (fieldName: string, value: unknown) => {
|
||||
onChange({
|
||||
...values,
|
||||
[fieldName]: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-6">
|
||||
{/* Required fields section */}
|
||||
{requiredFieldNames.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={values[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={true}
|
||||
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Separator between required and optional */}
|
||||
{hasRequiredFields && optionalFieldNames.length > 0 && (
|
||||
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
|
||||
<div className="border-t border-border"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visible optional fields */}
|
||||
{visibleOptionalFields.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={values[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={false}
|
||||
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Collapsed optional fields toggle */}
|
||||
{hasCollapsedFields && (
|
||||
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
|
||||
className="w-full justify-center gap-2"
|
||||
disabled={disabled}
|
||||
>
|
||||
{showAdvancedFields ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
Hide {collapsedOptionalFields.length} optional field
|
||||
{collapsedOptionalFields.length !== 1 ? "s" : ""}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
Show {collapsedOptionalFields.length} optional field
|
||||
{collapsedOptionalFields.length !== 1 ? "s" : ""}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed optional fields */}
|
||||
{showAdvancedFields &&
|
||||
collapsedOptionalFields.map((fieldName) => (
|
||||
<FormField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={properties[fieldName] as JSONSchemaProperty}
|
||||
value={values[fieldName]}
|
||||
onChange={(value) => updateField(fieldName, value)}
|
||||
isRequired={false}
|
||||
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export helper functions for validation
|
||||
// ============================================================================
|
||||
|
||||
export function validateSchemaForm(
|
||||
schema: JSONSchemaProperty,
|
||||
values: Record<string, unknown>
|
||||
): boolean {
|
||||
const requiredFields = schema.required || [];
|
||||
|
||||
return requiredFields.every((fieldName) => {
|
||||
const value = values[fieldName];
|
||||
return value !== undefined && value !== "" && value !== null;
|
||||
});
|
||||
}
|
||||
|
||||
export function filterEmptyOptionalFields(
|
||||
schema: JSONSchemaProperty,
|
||||
values: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const requiredFields = schema.required || [];
|
||||
const filtered: Record<string, unknown> = {};
|
||||
|
||||
Object.keys(values).forEach((key) => {
|
||||
const value = values[key];
|
||||
// Include if: 1) required field, OR 2) has non-empty value
|
||||
if (
|
||||
requiredFields.includes(key) ||
|
||||
(value !== undefined && value !== "" && value !== null)
|
||||
) {
|
||||
filtered[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Shuffle,
|
||||
Zap,
|
||||
ArrowDown,
|
||||
ArrowLeftRight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
processWorkflowEvents,
|
||||
updateNodesWithEvents,
|
||||
updateEdgesWithSequenceAnalysis,
|
||||
consolidateBidirectionalEdges,
|
||||
type NodeUpdate,
|
||||
} from "@/utils/workflow-utils";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
@@ -59,7 +61,7 @@ function ViewOptionsPanel({
|
||||
}: {
|
||||
workflowDump?: Workflow;
|
||||
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
|
||||
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean };
|
||||
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean; consolidateBidirectionalEdges: boolean };
|
||||
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
|
||||
layoutDirection: "LR" | "TB";
|
||||
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
|
||||
@@ -134,6 +136,16 @@ function ViewOptionsPanel({
|
||||
</div>
|
||||
<Checkbox checked={viewOptions.animateRun} onChange={() => {}} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center justify-between"
|
||||
onClick={() => onToggleViewOption?.("consolidateBidirectionalEdges")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<ArrowLeftRight className="mr-2 h-4 w-4" />
|
||||
Merge Bidirectional Edges
|
||||
</div>
|
||||
<Checkbox checked={viewOptions.consolidateBidirectionalEdges} onChange={() => {}} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="flex items-center justify-between"
|
||||
@@ -192,12 +204,14 @@ interface WorkflowFlowProps {
|
||||
showMinimap: boolean;
|
||||
showGrid: boolean;
|
||||
animateRun: boolean;
|
||||
consolidateBidirectionalEdges: boolean;
|
||||
};
|
||||
onToggleViewOption?: (
|
||||
key: keyof NonNullable<WorkflowFlowProps["viewOptions"]>
|
||||
) => void;
|
||||
layoutDirection?: "LR" | "TB";
|
||||
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
|
||||
timelineVisible?: boolean;
|
||||
}
|
||||
|
||||
// Animation handler component that runs inside ReactFlow context
|
||||
@@ -248,16 +262,35 @@ function WorkflowAnimationHandler({
|
||||
return null; // This component doesn't render anything
|
||||
}
|
||||
|
||||
// Timeline resize handler component that runs inside ReactFlow context
|
||||
const TimelineResizeHandler = memo(({ timelineVisible }: { timelineVisible: boolean }) => {
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
// Trigger fitView when timeline visibility changes to adjust ReactFlow viewport
|
||||
useEffect(() => {
|
||||
// Delay fitView to let CSS transition complete (timeline animation is 300ms)
|
||||
const timeoutId = setTimeout(() => {
|
||||
fitView({ padding: 0.2, duration: 300 });
|
||||
}, 350); // Slightly longer than timeline animation duration
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timelineVisible]); // Only trigger when timelineVisible changes, not fitView reference
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
});
|
||||
|
||||
export const WorkflowFlow = memo(function WorkflowFlow({
|
||||
workflowDump,
|
||||
events,
|
||||
isStreaming,
|
||||
onNodeSelect,
|
||||
className = "",
|
||||
viewOptions = { showMinimap: false, showGrid: true, animateRun: true },
|
||||
viewOptions = { showMinimap: false, showGrid: true, animateRun: true, consolidateBidirectionalEdges: true },
|
||||
onToggleViewOption,
|
||||
layoutDirection = "LR",
|
||||
onLayoutDirectionChange,
|
||||
timelineVisible = false,
|
||||
}: WorkflowFlowProps) {
|
||||
// Create initial nodes and edges from workflow dump
|
||||
const { initialNodes, initialEdges } = useMemo(() => {
|
||||
@@ -272,17 +305,22 @@ export const WorkflowFlow = memo(function WorkflowFlow({
|
||||
);
|
||||
const edges = convertWorkflowDumpToEdges(workflowDump);
|
||||
|
||||
// Apply bidirectional edge consolidation if enabled
|
||||
const finalEdges = viewOptions.consolidateBidirectionalEdges
|
||||
? consolidateBidirectionalEdges(edges)
|
||||
: edges;
|
||||
|
||||
// Apply auto-layout if we have nodes and edges
|
||||
const layoutedNodes =
|
||||
nodes.length > 0
|
||||
? applyDagreLayout(nodes, edges, layoutDirection)
|
||||
? applyDagreLayout(nodes, finalEdges, layoutDirection)
|
||||
: nodes;
|
||||
|
||||
return {
|
||||
initialNodes: layoutedNodes,
|
||||
initialEdges: edges,
|
||||
initialEdges: finalEdges,
|
||||
};
|
||||
}, [workflowDump, onNodeSelect, layoutDirection]);
|
||||
}, [workflowDump, onNodeSelect, layoutDirection, viewOptions.consolidateBidirectionalEdges]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] =
|
||||
useNodesState<Node<ExecutorNodeData>>(initialNodes);
|
||||
@@ -323,31 +361,38 @@ export const WorkflowFlow = memo(function WorkflowFlow({
|
||||
currentEdges,
|
||||
events
|
||||
);
|
||||
return updatedEdges;
|
||||
// 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) =>
|
||||
currentEdges.map((edge) => ({
|
||||
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]);
|
||||
}, [events, setEdges, viewOptions.consolidateBidirectionalEdges]);
|
||||
|
||||
// Initialize nodes only when workflow structure changes (not on state updates)
|
||||
// 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]); // Only re-initialize when workflowDump changes
|
||||
}, [workflowDump, viewOptions.consolidateBidirectionalEdges]); // Re-initialize when workflow or consolidation toggle changes
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
(event: React.MouseEvent, node: Node<ExecutorNodeData>) => {
|
||||
@@ -467,6 +512,7 @@ export const WorkflowFlow = memo(function WorkflowFlow({
|
||||
isStreaming={isStreaming}
|
||||
animateRun={viewOptions.animateRun}
|
||||
/>
|
||||
<TimelineResizeHandler timelineVisible={timelineVisible} />
|
||||
<ViewOptionsPanel
|
||||
workflowDump={workflowDump}
|
||||
onNodeSelect={onNodeSelect}
|
||||
|
||||
+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: `Conversation ${new Date().toLocaleString()}`,
|
||||
});
|
||||
setAvailableSessions([newSession]);
|
||||
setCurrentSession(newSession);
|
||||
onSessionChange?.(newSession);
|
||||
addToast({
|
||||
message: "Default conversation created",
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
// Conversations exist - set available and auto-select the first one
|
||||
setAvailableSessions(response.data);
|
||||
|
||||
// Auto-select first conversation if no current selection
|
||||
if (!currentSession) {
|
||||
const firstSession = response.data[0];
|
||||
setCurrentSession(firstSession);
|
||||
onSessionChange?.(firstSession);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load workflow conversations:", error);
|
||||
|
||||
// Silently handle for .NET backend (doesn't support conversations yet)
|
||||
// Only show error for Python backend where this is unexpected
|
||||
if (runtime !== "dotnet") {
|
||||
addToast({
|
||||
message: "Failed to load workflow conversations",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
}, [workflowId, currentSession, runtime, setLoadingSessions, setAvailableSessions, setCurrentSession, onSessionChange, addToast]);
|
||||
|
||||
// Load sessions on mount
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
setCreatingSession(true);
|
||||
try {
|
||||
const newSession = await apiClient.createWorkflowSession(workflowId, {
|
||||
name: `Conversation ${new Date().toLocaleString()}`,
|
||||
});
|
||||
addSession(newSession);
|
||||
setCurrentSession(newSession);
|
||||
onSessionChange?.(newSession);
|
||||
addToast({
|
||||
message: "New conversation created",
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create conversation:", error);
|
||||
addToast({
|
||||
message: "Failed to create conversation",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setCreatingSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSession = (session: WorkflowSession) => {
|
||||
setCurrentSession(session);
|
||||
onSessionChange?.(session);
|
||||
};
|
||||
|
||||
const handleDeleteSession = async (
|
||||
sessionId: string,
|
||||
event: React.MouseEvent
|
||||
) => {
|
||||
event.stopPropagation(); // Prevent session selection when clicking delete
|
||||
|
||||
if (!confirm("Delete this conversation? All checkpoints will be lost.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingSession(sessionId);
|
||||
try {
|
||||
await apiClient.deleteWorkflowSession(workflowId, sessionId);
|
||||
removeSession(sessionId);
|
||||
addToast({
|
||||
message: "Conversation deleted",
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete conversation:", error);
|
||||
addToast({
|
||||
message: "Failed to delete conversation",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setDeletingSession(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
if (loadingSessions) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full" />
|
||||
<span className="ml-2 text-sm text-gray-600">Loading sessions...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workflow-session-manager space-y-3">
|
||||
{/* Header with Create Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Conversations
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCreateSession}
|
||||
disabled={creatingSession}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Create new conversation"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Conversation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Conversation List */}
|
||||
{availableSessions.length === 0 ? (
|
||||
<div className="text-center py-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading conversations...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{availableSessions.map((session) => (
|
||||
<div
|
||||
key={session.conversation_id}
|
||||
onClick={() => handleSelectSession(session)}
|
||||
className={`
|
||||
flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-all
|
||||
${
|
||||
currentSession?.conversation_id === session.conversation_id
|
||||
? "bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700"
|
||||
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{session.metadata.name || "Unnamed Conversation"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatTimestamp(session.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleDeleteSession(session.conversation_id, e)}
|
||||
disabled={deletingSession === session.conversation_id}
|
||||
className="ml-3 p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50"
|
||||
title="Delete conversation"
|
||||
>
|
||||
{deletingSession === session.conversation_id ? (
|
||||
<div className="animate-spin h-4 w-4 border-2 border-red-500 border-t-transparent rounded-full" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+960
-662
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,17 @@
|
||||
*/
|
||||
|
||||
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 } from "lucide-react";
|
||||
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;
|
||||
@@ -22,12 +25,15 @@ interface AppHeaderProps {
|
||||
export function AppHeader({
|
||||
agents,
|
||||
workflows,
|
||||
entities,
|
||||
selectedItem,
|
||||
onSelect,
|
||||
onBrowseGallery,
|
||||
isLoading = false,
|
||||
onSettingsClick,
|
||||
}: AppHeaderProps) {
|
||||
const { oaiMode } = useDevUIStore();
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center gap-4 border-b px-4">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
@@ -58,15 +64,29 @@ export function AppHeader({
|
||||
</defs>
|
||||
</svg>
|
||||
Dev UI
|
||||
{/* Mode Badge */}
|
||||
{oaiMode.enabled && (
|
||||
<Badge variant="secondary" className="gap-1 ml-2">
|
||||
<Zap className="h-3 w-3" />
|
||||
OpenAI: {oaiMode.model}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<EntitySelector
|
||||
agents={agents}
|
||||
workflows={workflows}
|
||||
selectedItem={selectedItem}
|
||||
onSelect={onSelect}
|
||||
onBrowseGallery={onBrowseGallery}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* 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 />
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Info,
|
||||
PanelRightClose,
|
||||
} from "lucide-react";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
|
||||
@@ -95,7 +94,7 @@ interface TraceEventData extends EventDataBase {
|
||||
interface DebugPanelProps {
|
||||
events: ExtendedResponseStreamEvent[];
|
||||
isStreaming?: boolean;
|
||||
onClose?: () => void;
|
||||
onMinimize?: () => void;
|
||||
}
|
||||
|
||||
// Helper: Extract function result from DevUI custom event
|
||||
@@ -116,39 +115,6 @@ function getFunctionResultFromEvent(event: ExtendedResponseStreamEvent): {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to get a stable timestamp for an event
|
||||
// Uses event's own timestamp fields if available
|
||||
function getEventTimestamp(event: ExtendedResponseStreamEvent): string {
|
||||
// Priority 1: Check for top-level timestamp (DevUI custom events like function_result.complete)
|
||||
if ('timestamp' in event && typeof event.timestamp === 'string') {
|
||||
return new Date(event.timestamp).toLocaleTimeString();
|
||||
}
|
||||
|
||||
// Priority 2: Check for nested data.timestamp (workflow/trace events)
|
||||
if ('data' in event && event.data && typeof event.data === 'object' && 'timestamp' in event.data) {
|
||||
const dataTimestamp = (event.data as any).timestamp;
|
||||
if (typeof dataTimestamp === 'string') {
|
||||
return new Date(dataTimestamp).toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Check for created_at in response object (lifecycle events)
|
||||
if ('response' in event && event.response && typeof event.response === 'object' && 'created_at' in event.response) {
|
||||
const createdAt = (event.response as any).created_at;
|
||||
if (typeof createdAt === 'number') {
|
||||
return new Date(createdAt * 1000).toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use sequence number as label (better than showing same time for all)
|
||||
if ('sequence_number' in event && typeof event.sequence_number === 'number') {
|
||||
return `#${event.sequence_number}`;
|
||||
}
|
||||
|
||||
// Last resort: hide timestamp by returning empty string
|
||||
return '';
|
||||
}
|
||||
|
||||
// Helper function to accumulate OpenAI events into meaningful units
|
||||
function processEventsForDisplay(
|
||||
events: ExtendedResponseStreamEvent[]
|
||||
@@ -170,8 +136,8 @@ function processEventsForDisplay(
|
||||
for (const event of events) {
|
||||
// Skip trace events - they belong in the Traces tab only
|
||||
if (
|
||||
event.type === "response.trace_event.complete" ||
|
||||
event.type === "response.trace.complete"
|
||||
event.type === "response.trace.completed" ||
|
||||
event.type === "response.trace.completed"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -212,9 +178,9 @@ function processEventsForDisplay(
|
||||
event.type === "response.completed" ||
|
||||
event.type === "response.done" ||
|
||||
event.type === "error" ||
|
||||
event.type === "response.workflow_event.complete" ||
|
||||
event.type === "response.trace_event.complete" ||
|
||||
event.type === "response.trace.complete" ||
|
||||
event.type === "response.workflow_event.completed" ||
|
||||
event.type === "response.trace.completed" ||
|
||||
event.type === "response.trace.completed" ||
|
||||
isFunctionResult
|
||||
) {
|
||||
// Flush any accumulated text before showing these events
|
||||
@@ -228,8 +194,8 @@ function processEventsForDisplay(
|
||||
|
||||
// Extract function names from trace events
|
||||
if (
|
||||
(event.type === "response.trace_event.complete" ||
|
||||
event.type === "response.trace.complete") &&
|
||||
(event.type === "response.trace.completed" ||
|
||||
event.type === "response.trace.completed") &&
|
||||
"data" in event
|
||||
) {
|
||||
const traceData = event.data as TraceEventData;
|
||||
@@ -483,15 +449,14 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
|
||||
return "Output item added";
|
||||
}
|
||||
|
||||
case "response.workflow_event.complete":
|
||||
case "response.workflow_event.completed":
|
||||
if ("data" in event && event.data) {
|
||||
const data = event.data as WorkflowEventData;
|
||||
return `Executor: ${data.executor_id || "unknown"}`;
|
||||
}
|
||||
return "Workflow event";
|
||||
|
||||
case "response.trace_event.complete":
|
||||
case "response.trace.complete":
|
||||
case "response.trace.completed":
|
||||
if ("data" in event && event.data) {
|
||||
const data = event.data as TraceEventData;
|
||||
return `Trace: ${data.operation_name || "unknown"}`;
|
||||
@@ -536,10 +501,9 @@ function getEventIcon(type: string) {
|
||||
return CheckCircle2;
|
||||
case "response.output_item.added":
|
||||
return CheckCircle2;
|
||||
case "response.workflow_event.complete":
|
||||
case "response.workflow_event.completed":
|
||||
return Activity;
|
||||
case "response.trace_event.complete":
|
||||
case "response.trace.complete":
|
||||
case "response.trace.completed":
|
||||
return Search;
|
||||
case "response.completed":
|
||||
return CheckCircle2;
|
||||
@@ -564,10 +528,9 @@ function getEventColor(type: string) {
|
||||
return "text-green-600 dark:text-green-400";
|
||||
case "response.output_item.added":
|
||||
return "text-green-600 dark:text-green-400";
|
||||
case "response.workflow_event.complete":
|
||||
case "response.workflow_event.completed":
|
||||
return "text-purple-600 dark:text-purple-400";
|
||||
case "response.trace_event.complete":
|
||||
case "response.trace.complete":
|
||||
case "response.trace.completed":
|
||||
return "text-orange-600 dark:text-orange-400";
|
||||
case "response.completed":
|
||||
return "text-green-600 dark:text-green-400";
|
||||
@@ -582,9 +545,15 @@ function getEventColor(type: string) {
|
||||
|
||||
function EventItem({ event }: EventItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const Icon = getEventIcon(event.type);
|
||||
const colorClass = getEventColor(event.type);
|
||||
const timestamp = getEventTimestamp(event);
|
||||
const eventType = event.type || "unknown";
|
||||
const Icon = getEventIcon(eventType);
|
||||
const colorClass = getEventColor(eventType);
|
||||
|
||||
// Use stored UI timestamp if available, otherwise compute from event data
|
||||
const timestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
|
||||
? new Date(event._uiTimestamp * 1000).toLocaleTimeString()
|
||||
: new Date().toLocaleTimeString();
|
||||
|
||||
const summary = getEventSummary(event);
|
||||
|
||||
// Determine if this event has expandable content
|
||||
@@ -595,13 +564,13 @@ function EventItem({ event }: EventItemProps) {
|
||||
event.type === "response.function_result.complete" ||
|
||||
(event.type === "response.output_item.added" &&
|
||||
getFunctionResultFromEvent(event) !== null) ||
|
||||
(event.type === "response.workflow_event.complete" &&
|
||||
(event.type === "response.workflow_event.completed" &&
|
||||
"data" in event &&
|
||||
event.data) ||
|
||||
(event.type === "response.trace_event.complete" &&
|
||||
(event.type === "response.trace.completed" &&
|
||||
"data" in event &&
|
||||
event.data) ||
|
||||
(event.type === "response.trace.complete" &&
|
||||
(event.type === "response.trace.completed" &&
|
||||
"data" in event &&
|
||||
event.data) ||
|
||||
(event.type === "response.output_text.delta" &&
|
||||
@@ -620,7 +589,7 @@ function EventItem({ event }: EventItemProps) {
|
||||
<Icon className={`h-3 w-3 ${colorClass}`} />
|
||||
<span className="font-mono">{timestamp}</span>
|
||||
<Badge variant="outline" className="text-xs py-0">
|
||||
{event.type.replace("response.", "")}
|
||||
{event.type ? event.type.replace("response.", "") : "unknown"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -859,7 +828,7 @@ function EventExpandedContent({
|
||||
break;
|
||||
}
|
||||
|
||||
case "response.workflow_event.complete":
|
||||
case "response.workflow_event.completed":
|
||||
if ("data" in event && event.data) {
|
||||
const data = event.data as WorkflowEventData;
|
||||
return (
|
||||
@@ -915,8 +884,7 @@ function EventExpandedContent({
|
||||
}
|
||||
break;
|
||||
|
||||
case "response.trace_event.complete":
|
||||
case "response.trace.complete":
|
||||
case "response.trace.completed":
|
||||
if ("data" in event && event.data) {
|
||||
const data = event.data as TraceEventData;
|
||||
return (
|
||||
@@ -1193,8 +1161,8 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
// ONLY show actual trace events - handle both event type formats
|
||||
const traceEvents = events.filter(
|
||||
(e) =>
|
||||
e.type === "response.trace_event.complete" ||
|
||||
e.type === "response.trace.complete"
|
||||
e.type === "response.trace.completed" ||
|
||||
e.type === "response.trace.completed"
|
||||
);
|
||||
|
||||
// Add separators between message rounds
|
||||
@@ -1253,8 +1221,8 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (
|
||||
(event.type !== "response.trace_event.complete" &&
|
||||
event.type !== "response.trace.complete") ||
|
||||
(event.type !== "response.trace.completed" &&
|
||||
event.type !== "response.trace.completed") ||
|
||||
!("data" in event)
|
||||
) {
|
||||
return (
|
||||
@@ -1266,14 +1234,19 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
|
||||
const data = event.data as TraceEventData;
|
||||
|
||||
// Use actual trace timestamp if available, fallback to current time
|
||||
let timestamp = new Date().toLocaleTimeString();
|
||||
if (data.end_time) {
|
||||
// Use stored UI timestamp first, then trace timestamps, then fallback to current time
|
||||
let timestamp: string;
|
||||
if ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number') {
|
||||
// Use stored UI timestamp from when event was received
|
||||
timestamp = new Date(event._uiTimestamp * 1000).toLocaleTimeString();
|
||||
} else if (data.end_time) {
|
||||
timestamp = new Date(data.end_time * 1000).toLocaleTimeString();
|
||||
} else if (data.start_time) {
|
||||
timestamp = new Date(data.start_time * 1000).toLocaleTimeString();
|
||||
} else if (data.timestamp) {
|
||||
timestamp = new Date(data.timestamp).toLocaleTimeString();
|
||||
} else {
|
||||
timestamp = new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
const operationName = data.operation_name || "Unknown Operation";
|
||||
@@ -1520,7 +1493,10 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
}
|
||||
|
||||
function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
const timestamp = getEventTimestamp(event);
|
||||
// Use stored UI timestamp if available, otherwise compute from current time
|
||||
const timestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
|
||||
? new Date(event._uiTimestamp * 1000).toLocaleTimeString()
|
||||
: new Date().toLocaleTimeString();
|
||||
|
||||
// Check if this is a function call or result event
|
||||
const isFunctionCall = event.type === "response.function_call.complete";
|
||||
@@ -1621,7 +1597,7 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
export function DebugPanel({
|
||||
events,
|
||||
isStreaming = false,
|
||||
onClose,
|
||||
onMinimize,
|
||||
}: DebugPanelProps) {
|
||||
return (
|
||||
<div className="flex-1 border-l flex flex-col min-h-0">
|
||||
@@ -1638,15 +1614,15 @@ export function DebugPanel({
|
||||
Tools
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{onClose && (
|
||||
{onMinimize && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
onClick={onMinimize}
|
||||
className="h-8 w-8 p-0 flex-shrink-0"
|
||||
title="Hide debug panel"
|
||||
title="Minimize debug panel"
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,12 +20,18 @@ import {
|
||||
Copy,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
import { apiClient } from "@/services/api";
|
||||
import type { AgentInfo, WorkflowInfo } from "@/types";
|
||||
|
||||
interface DeploymentModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
agentName?: string;
|
||||
entity?: AgentInfo | WorkflowInfo;
|
||||
}
|
||||
|
||||
type Tab = "docker" | "azure";
|
||||
@@ -34,10 +40,108 @@ export function DeploymentModal({
|
||||
open,
|
||||
onClose,
|
||||
agentName = "Agent",
|
||||
entity,
|
||||
}: DeploymentModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("docker");
|
||||
// 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(() => {
|
||||
@@ -48,6 +152,48 @@ export function DeploymentModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!entity?.id || !resourceGroup || !appName) return;
|
||||
|
||||
// Trim whitespace from inputs
|
||||
const trimmedResourceGroup = resourceGroup.trim();
|
||||
const trimmedAppName = appName.trim();
|
||||
|
||||
// Validate trimmed app name before deployment
|
||||
const nameError = validateAppName(trimmedAppName);
|
||||
if (nameError) {
|
||||
setAppNameError(nameError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
startDeployment();
|
||||
|
||||
for await (const event of apiClient.streamDeployment({
|
||||
entity_id: entity.id,
|
||||
resource_group: trimmedResourceGroup,
|
||||
app_name: trimmedAppName,
|
||||
region,
|
||||
ui_mode: "user",
|
||||
})) {
|
||||
addDeploymentLog(event.message);
|
||||
|
||||
if (event.type === "deploy.completed" && event.url && event.auth_token) {
|
||||
setDeploymentResult({
|
||||
url: event.url,
|
||||
authToken: event.auth_token,
|
||||
});
|
||||
} else if (event.type === "deploy.failed") {
|
||||
// Stop deploying but keep logs visible
|
||||
stopDeployment();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
addDeploymentLog(`Error: ${error instanceof Error ? error.message : "Deployment failed"}`);
|
||||
stopDeployment();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async (template: string, templateName: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(template);
|
||||
@@ -64,8 +210,7 @@ export function DeploymentModal({
|
||||
timeoutRef.current = null;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy template:", err);
|
||||
// Reset state on error
|
||||
// Reset state on error - clipboard write failed
|
||||
setCopiedTemplate(null);
|
||||
}
|
||||
};
|
||||
@@ -149,20 +294,22 @@ openai>=1.0.0
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("azure")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
activeTab === "azure"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Cloud className="h-4 w-4 mr-2 inline" />
|
||||
Azure
|
||||
{activeTab === "azure" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
{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 */}
|
||||
@@ -360,34 +507,230 @@ openai>=1.0.0
|
||||
Deploy to Azure Container Apps
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Azure Container Apps provides serverless containers with
|
||||
auto-scaling and integrated monitoring.
|
||||
{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 */}
|
||||
<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>
|
||||
{/* 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>
|
||||
Logged in to Azure:{" "}
|
||||
<code className="bg-muted px-1 rounded">az login</code>
|
||||
<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>
|
||||
|
||||
{/* Step-by-step */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Deployment Steps</h4>
|
||||
{/* 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 */}
|
||||
@@ -508,6 +851,8 @@ az acr build --registry myregistry \\
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ 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;
|
||||
@@ -33,6 +34,7 @@ const getTypeIcon = (type: "agent" | "workflow") => {
|
||||
export function EntitySelector({
|
||||
agents,
|
||||
workflows,
|
||||
entities,
|
||||
selectedItem,
|
||||
onSelect,
|
||||
onBrowseGallery,
|
||||
@@ -40,9 +42,8 @@ export function EntitySelector({
|
||||
}: EntitySelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const allItems = [...agents, ...workflows].sort(
|
||||
(a, b) => a.name?.localeCompare(b.name || a.id) || a.id.localeCompare(b.id)
|
||||
);
|
||||
// Use entities if provided (preserves backend order), otherwise combine agents and workflows
|
||||
const allItems = entities || [...agents, ...workflows];
|
||||
|
||||
const handleSelect = (item: AgentInfo | WorkflowInfo) => {
|
||||
onSelect(item);
|
||||
@@ -82,80 +83,125 @@ export function EntitySelector({
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-80 font-mono">
|
||||
{agents.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
Agents ({agents.length})
|
||||
</DropdownMenuLabel>
|
||||
{agents.map((agent) => {
|
||||
const isAgentLoaded = agent.metadata?.lazy_loaded !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={agent.id}
|
||||
className="cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 min-w-0 flex-1"
|
||||
onClick={() => handleSelect(agent)}
|
||||
>
|
||||
<Bot className="h-4 w-4 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-medium block">
|
||||
{agent.name || agent.id}
|
||||
</span>
|
||||
{isAgentLoaded && agent.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{agent.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{/* 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");
|
||||
|
||||
{workflows.length > 0 && (
|
||||
<>
|
||||
{agents.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel className="flex items-center gap-2">
|
||||
<Workflow className="h-4 w-4" />
|
||||
Workflows ({workflows.length})
|
||||
</DropdownMenuLabel>
|
||||
{workflows.map((workflow) => {
|
||||
const isWorkflowLoaded = workflow.metadata?.lazy_loaded !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={workflow.id}
|
||||
className="cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 min-w-0 flex-1"
|
||||
onClick={() => handleSelect(workflow)}
|
||||
>
|
||||
<Workflow className="h-4 w-4 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-medium block">
|
||||
{workflow.name || workflow.id}
|
||||
</span>
|
||||
{isWorkflowLoaded && workflow.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{workflow.description}
|
||||
// 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>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ExternalLink, RotateCcw } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ExternalLink, RotateCcw, Info, ChevronRight } from "lucide-react";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
@@ -21,10 +23,26 @@ interface SettingsModalProps {
|
||||
onBackendUrlChange?: (url: string) => void;
|
||||
}
|
||||
|
||||
type Tab = "about" | "settings";
|
||||
type Tab = "general" | "proxy" | "about";
|
||||
|
||||
export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: SettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("settings");
|
||||
// Preset OpenAI models for quick selection
|
||||
const PRESET_MODELS = [
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-mini",
|
||||
"o1",
|
||||
"o1-mini",
|
||||
"o3-mini",
|
||||
] as const;
|
||||
|
||||
export function SettingsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onBackendUrlChange,
|
||||
}: SettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("general");
|
||||
|
||||
// OpenAI proxy mode, Azure deployment, auth status, and server capabilities from store
|
||||
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired, serverCapabilities } = useDevUIStore();
|
||||
|
||||
// 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 : "";
|
||||
@@ -33,6 +51,10 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
|
||||
});
|
||||
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 {
|
||||
@@ -59,33 +81,68 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
|
||||
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]">
|
||||
<DialogHeader className="p-6 pb-2">
|
||||
<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">
|
||||
<div className="flex border-b px-6 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setActiveTab("settings")}
|
||||
onClick={() => setActiveTab("general")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
activeTab === "settings"
|
||||
activeTab === "general"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Settings
|
||||
{activeTab === "settings" && (
|
||||
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 ${
|
||||
@@ -101,9 +158,9 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="px-6 pb-6 min-h-[240px]">
|
||||
{activeTab === "settings" && (
|
||||
{/* 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">
|
||||
@@ -142,11 +199,7 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
|
||||
<div className="flex gap-2 pt-2 min-h-[36px]">
|
||||
{isModified && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
<Button onClick={handleSave} size="sm" className="flex-1">
|
||||
Apply & Reload
|
||||
</Button>
|
||||
<Button
|
||||
@@ -161,6 +214,373 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth Token Setting - Only show if backend requires auth OR token is already stored */}
|
||||
{(authRequired || authTokenStored) && (
|
||||
<div className="space-y-3 border-t pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
Authentication Token
|
||||
</Label>
|
||||
{!authRequired && authTokenStored && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(Not required by current backend)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{authTokenStored ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value="••••••••••••••••••••"
|
||||
disabled
|
||||
className="font-mono text-sm flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleClearAuthToken}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
✓ Token configured and stored locally
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="password"
|
||||
value={newAuthToken}
|
||||
onChange={(e) => setNewAuthToken(e.target.value)}
|
||||
placeholder="Enter bearer token"
|
||||
className="font-mono text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newAuthToken.trim()) {
|
||||
handleAuthTokenSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAuthTokenSave}
|
||||
size="sm"
|
||||
disabled={!newAuthToken.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
Save & Reload
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{authRequired
|
||||
? "Required by backend (started with --auth flag)"
|
||||
: "Not required by current backend"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deployment Setting - Only show if backend supports deployment */}
|
||||
{serverCapabilities.deployment && (
|
||||
<div className="space-y-3 border-t pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">
|
||||
Azure Deployment
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable one-click deployment to Azure Container Apps
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={azureDeploymentEnabled}
|
||||
onCheckedChange={setAzureDeploymentEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expandable info section */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
|
||||
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
|
||||
Learn more about Azure deployment
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3 pl-4">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
When enabled, agents that support deployment will show a "Deploy to Azure"
|
||||
button. This allows you to deploy your agent to Azure Container Apps directly
|
||||
from DevUI.
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium">When enabled:</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
|
||||
<li>Shows "Deploy to Azure" for supported agents</li>
|
||||
<li>Requires Azure CLI and proper authentication</li>
|
||||
<li>Backend must have deployment capabilities enabled</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium">When disabled:</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
|
||||
<li>Shows "Deployment Guide" for all agents</li>
|
||||
<li>Provides Docker templates and manual deployment instructions</li>
|
||||
<li>No backend deployment capabilities required</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "proxy" && serverCapabilities.openai_proxy && (
|
||||
<div className="space-y-6 pt-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base font-medium">
|
||||
OpenAI Proxy Mode
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Route requests through DevUI backend to OpenAI API
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={oaiMode.enabled}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setOAIMode({ ...oaiMode, enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info box when disabled - prominent */}
|
||||
{!oaiMode.enabled && (
|
||||
<div className="bordder border-muted bg-muted/30 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="h-4 w-4 flex-shrink-0 mt-0.5 text-blue-600 dark:text-blue-400" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
About OpenAI Proxy Mode
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
When enabled, your chat requests are sent to your
|
||||
DevUI backend{" "}
|
||||
<span className="font-mono font-semibold">
|
||||
({backendUrl})
|
||||
</span>
|
||||
, which then forwards them to OpenAI's API. This keeps
|
||||
your{" "}
|
||||
<span className="font-mono font-semibold">
|
||||
OPENAI_API_KEY
|
||||
</span>{" "}
|
||||
secure on the server instead of exposing it in the
|
||||
browser.
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<p className="text-xs font-medium">Requirements:</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
|
||||
<li>
|
||||
Backend must have{" "}
|
||||
<span className="font-mono">OPENAI_API_KEY</span>{" "}
|
||||
configured
|
||||
</li>
|
||||
<li>
|
||||
Backend must support OpenAI Responses API proxying
|
||||
(DevUI does)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<p className="text-xs font-medium">Why use this?</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Quickly test and compare OpenAI models directly
|
||||
through the DevUI interface without creating custom
|
||||
agents or exposing API keys in the browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oaiMode.enabled && (
|
||||
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
||||
{/* Model ID Input - Primary control */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Model</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={oaiMode.model}
|
||||
onChange={(e) =>
|
||||
setOAIMode({ ...oaiMode, model: e.target.value })
|
||||
}
|
||||
placeholder="gpt-4.1-mini"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter any OpenAI model ID (e.g., gpt-4.1, o1, o3-mini)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Preset Buttons */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Common presets
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESET_MODELS.map((model) => (
|
||||
<Button
|
||||
key={model}
|
||||
variant={
|
||||
oaiMode.model === model ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => setOAIMode({ ...oaiMode, model })}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
{model}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Parameters */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
|
||||
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
|
||||
Advanced Parameters (optional)
|
||||
</summary>
|
||||
<div className="space-y-3 mt-3 pl-4">
|
||||
{/* Temperature */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Temperature</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="2"
|
||||
value={oaiMode.temperature ?? ""}
|
||||
onChange={(e) =>
|
||||
setOAIMode({
|
||||
...oaiMode,
|
||||
temperature: e.target.value
|
||||
? parseFloat(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="1.0 (default)"
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Controls randomness (0-2)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Max Output Tokens */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Max Output Tokens</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={oaiMode.max_output_tokens ?? ""}
|
||||
onChange={(e) =>
|
||||
setOAIMode({
|
||||
...oaiMode,
|
||||
max_output_tokens: e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="Auto"
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum tokens in response
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Top P */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Top P</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={oaiMode.top_p ?? ""}
|
||||
onChange={(e) =>
|
||||
setOAIMode({
|
||||
...oaiMode,
|
||||
top_p: e.target.value
|
||||
? parseFloat(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="1.0 (default)"
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Nucleus sampling (0-1)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reasoning Effort */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Reasoning Effort (o-series models)</Label>
|
||||
<select
|
||||
value={oaiMode.reasoning_effort ?? ""}
|
||||
onChange={(e) =>
|
||||
setOAIMode({
|
||||
...oaiMode,
|
||||
reasoning_effort: e.target.value
|
||||
? (e.target.value as "minimal" | "low" | "medium" | "high")
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">Auto (default)</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Constrains reasoning effort (faster/cheaper vs thorough)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapsed info at bottom when enabled */}
|
||||
{oaiMode.enabled && (
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground bg-muted/50 p-3 rounded">
|
||||
<Info className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p>
|
||||
Requests route through{" "}
|
||||
<span className="font-mono font-semibold">
|
||||
{backendUrl}
|
||||
</span>{" "}
|
||||
to OpenAI API. Server must have{" "}
|
||||
<span className="font-mono font-semibold">
|
||||
OPENAI_API_KEY
|
||||
</span>{" "}
|
||||
configured.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -1,126 +0,0 @@
|
||||
import { useMemo, useState, useCallback } from "react";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
// import type { ExecutorNodeData } from "@/components/workflow/executor-node";
|
||||
|
||||
// Type for executor input/output data - can be various types based on workflow events
|
||||
export type ExecutorData =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Record<string, unknown>
|
||||
| null;
|
||||
|
||||
// State tracking for a specific executor
|
||||
interface ExecutorState {
|
||||
executorId: string;
|
||||
state: "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||
inputData?: ExecutorData;
|
||||
outputData?: ExecutorData;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
|
||||
interface WorkflowEventCorrelationResult {
|
||||
// State access
|
||||
isWorkflowRunning: boolean;
|
||||
selectedExecutorId: string | null;
|
||||
recentlyActive: string[];
|
||||
|
||||
// Actions
|
||||
selectExecutor: (executorId: string) => void;
|
||||
getExecutorData: (executorId: string) => ExecutorState | null;
|
||||
getExecutorEvents: (executorId: string) => ExtendedResponseStreamEvent[];
|
||||
}
|
||||
|
||||
// Hook for correlating workflow events with executor states
|
||||
export function useWorkflowEventCorrelation(
|
||||
events: ExtendedResponseStreamEvent[],
|
||||
isStreaming: boolean
|
||||
): WorkflowEventCorrelationResult {
|
||||
const [selectedExecutorId, setSelectedExecutorId] = useState<string | null>(null);
|
||||
|
||||
// Process events into executor states
|
||||
const { executors, recentlyActive, isWorkflowRunning } = useMemo(() => {
|
||||
const executorMap: Record<string, ExecutorState> = {};
|
||||
const activeExecutors: string[] = [];
|
||||
let workflowActive = isStreaming;
|
||||
|
||||
// Process workflow events
|
||||
events.forEach((event) => {
|
||||
if (event.type === "response.workflow_event.complete" && "data" in event && event.data) {
|
||||
const data = event.data as any;
|
||||
const executorId = data.executor_id;
|
||||
|
||||
if (!executorId) return;
|
||||
|
||||
// Initialize executor if not exists
|
||||
if (!executorMap[executorId]) {
|
||||
executorMap[executorId] = {
|
||||
executorId,
|
||||
state: "pending",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const executor = executorMap[executorId];
|
||||
const eventType = data.event_type;
|
||||
|
||||
// Update state based on event type
|
||||
if (eventType === "ExecutorInvokedEvent") {
|
||||
executor.state = "running";
|
||||
executor.inputData = data.data;
|
||||
if (!activeExecutors.includes(executorId)) {
|
||||
activeExecutors.push(executorId);
|
||||
}
|
||||
} else if (eventType === "ExecutorCompletedEvent") {
|
||||
executor.state = "completed";
|
||||
executor.outputData = data.data;
|
||||
} else if (eventType?.includes("Error") || eventType?.includes("Failed")) {
|
||||
executor.state = "failed";
|
||||
executor.error = typeof data.data === "string" ? data.data : "Execution failed";
|
||||
} else if (eventType?.includes("Cancel")) {
|
||||
executor.state = "cancelled";
|
||||
}
|
||||
|
||||
executor.timestamp = new Date().toISOString();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
executors: executorMap,
|
||||
recentlyActive: activeExecutors.slice(-3), // Keep last 3 active executors
|
||||
isWorkflowRunning: workflowActive,
|
||||
};
|
||||
}, [events, isStreaming]);
|
||||
|
||||
const selectExecutor = useCallback((executorId: string) => {
|
||||
setSelectedExecutorId(executorId);
|
||||
}, []);
|
||||
|
||||
const getExecutorData = useCallback((executorId: string): ExecutorState | null => {
|
||||
return executors[executorId] || null;
|
||||
}, [executors]);
|
||||
|
||||
const getExecutorEvents = useCallback(
|
||||
(executorId: string): ExtendedResponseStreamEvent[] => {
|
||||
return events.filter((event) => {
|
||||
if (event.type === "response.workflow_event.complete" && "data" in event && event.data) {
|
||||
const data = event.data as any;
|
||||
return data.executor_id === executorId;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
},
|
||||
[events]
|
||||
);
|
||||
|
||||
return {
|
||||
isWorkflowRunning,
|
||||
selectedExecutorId,
|
||||
recentlyActive,
|
||||
selectExecutor,
|
||||
getExecutorData,
|
||||
getExecutorEvents,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AgentSource,
|
||||
Conversation,
|
||||
HealthResponse,
|
||||
MetaResponse,
|
||||
RunAgentRequest,
|
||||
RunWorkflowRequest,
|
||||
WorkflowInfo,
|
||||
@@ -32,6 +33,9 @@ interface BackendEntityInfo {
|
||||
tools?: (string | Record<string, unknown>)[];
|
||||
metadata: Record<string, unknown>;
|
||||
source?: string;
|
||||
// Deployment support
|
||||
deployment_supported?: boolean;
|
||||
deployment_reason?: string;
|
||||
// Agent-specific fields (present when type === "agent")
|
||||
instructions?: string;
|
||||
model?: string;
|
||||
@@ -64,8 +68,8 @@ const DEFAULT_API_BASE_URL =
|
||||
: ""; // Default to relative URLs (same host as frontend)
|
||||
|
||||
// Retry configuration for streaming
|
||||
const RETRY_INTERVAL_MS = 1000; // Retry every second
|
||||
const MAX_RETRY_ATTEMPTS = 600; // Max 600 retries (10 minutes total)
|
||||
const RETRY_INTERVAL_MS = 1000; // Base retry interval (will use exponential backoff)
|
||||
const MAX_RETRY_ATTEMPTS = 10; // Max 10 retries (~30 seconds with exponential backoff)
|
||||
|
||||
// Get backend URL from localStorage or default
|
||||
function getBackendUrl(): string {
|
||||
@@ -82,9 +86,12 @@ function sleep(ms: number): Promise<void> {
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
private authToken: string | null = null;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl || getBackendUrl();
|
||||
// Load auth token from localStorage on initialization
|
||||
this.authToken = localStorage.getItem("devui_auth_token");
|
||||
}
|
||||
|
||||
// Allow updating the base URL at runtime
|
||||
@@ -96,27 +103,68 @@ class ApiClient {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
// Set auth token and persist to localStorage
|
||||
setAuthToken(token: string | null): void {
|
||||
this.authToken = token;
|
||||
if (token) {
|
||||
localStorage.setItem("devui_auth_token", token);
|
||||
} else {
|
||||
localStorage.removeItem("devui_auth_token");
|
||||
}
|
||||
}
|
||||
|
||||
// Get current auth token
|
||||
getAuthToken(): string | null {
|
||||
return this.authToken;
|
||||
}
|
||||
|
||||
// Clear auth token
|
||||
clearAuthToken(): void {
|
||||
this.setAuthToken(null);
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
|
||||
// Build headers with auth token if available
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (this.authToken) {
|
||||
headers["Authorization"] = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle 401 Unauthorized - clear invalid token
|
||||
if (response.status === 401) {
|
||||
this.clearAuthToken();
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
// Try to extract error message from response body
|
||||
let errorMessage = `API request failed: ${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
// Handle detail as string or object
|
||||
if (errorData.detail) {
|
||||
errorMessage = errorData.detail;
|
||||
if (typeof errorData.detail === "string") {
|
||||
errorMessage = errorData.detail;
|
||||
} else if (typeof errorData.detail === "object" && errorData.detail.error?.message) {
|
||||
// Backend returns detail: { error: { message: "...", type: "...", code: "..." } }
|
||||
errorMessage = errorData.detail.error.message;
|
||||
}
|
||||
} else if (errorData.error?.message) {
|
||||
errorMessage = errorData.error.message;
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, use default message
|
||||
@@ -132,6 +180,11 @@ class ApiClient {
|
||||
return this.request<HealthResponse>("/health");
|
||||
}
|
||||
|
||||
// Server metadata
|
||||
async getMeta(): Promise<MetaResponse> {
|
||||
return this.request<MetaResponse>("/meta");
|
||||
}
|
||||
|
||||
// Entity discovery using new unified endpoint
|
||||
async getEntities(): Promise<{
|
||||
entities: (AgentInfo | WorkflowInfo)[];
|
||||
@@ -140,17 +193,14 @@ class ApiClient {
|
||||
}> {
|
||||
const response = await this.request<DiscoveryResponse>("/v1/entities");
|
||||
|
||||
// Separate agents and workflows
|
||||
const agents: AgentInfo[] = [];
|
||||
const workflows: WorkflowInfo[] = [];
|
||||
|
||||
response.entities.forEach((entity) => {
|
||||
// Transform entities while preserving backend order
|
||||
const entities: (AgentInfo | WorkflowInfo)[] = response.entities.map((entity) => {
|
||||
if (entity.type === "agent") {
|
||||
agents.push({
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
type: "agent",
|
||||
type: "agent" as const,
|
||||
source: (entity.source as AgentSource) || "directory",
|
||||
tools: (entity.tools || []).map((tool) =>
|
||||
typeof tool === "string" ? tool : JSON.stringify(tool)
|
||||
@@ -161,22 +211,26 @@ class ApiClient {
|
||||
? entity.metadata.module_path
|
||||
: undefined,
|
||||
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
|
||||
// Deployment support
|
||||
deployment_supported: entity.deployment_supported,
|
||||
deployment_reason: entity.deployment_reason,
|
||||
// Agent-specific fields
|
||||
instructions: entity.instructions,
|
||||
model: entity.model,
|
||||
chat_client_type: entity.chat_client_type,
|
||||
context_providers: entity.context_providers,
|
||||
middleware: entity.middleware,
|
||||
});
|
||||
} else if (entity.type === "workflow") {
|
||||
};
|
||||
} else {
|
||||
// Workflow
|
||||
const firstTool = entity.tools?.[0];
|
||||
const startExecutorId = typeof firstTool === "string" ? firstTool : "";
|
||||
|
||||
workflows.push({
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
type: "workflow",
|
||||
type: "workflow" as const,
|
||||
source: (entity.source as AgentSource) || "directory",
|
||||
executors: (entity.tools || []).map((tool) =>
|
||||
typeof tool === "string" ? tool : JSON.stringify(tool)
|
||||
@@ -187,17 +241,24 @@ class ApiClient {
|
||||
? entity.metadata.module_path
|
||||
: undefined,
|
||||
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
|
||||
// Deployment support
|
||||
deployment_supported: entity.deployment_supported,
|
||||
deployment_reason: entity.deployment_reason,
|
||||
input_schema:
|
||||
(entity.input_schema as unknown as import("@/types").JSONSchema) || {
|
||||
type: "string",
|
||||
}, // Default schema
|
||||
input_type_name: entity.input_type_name || "Input",
|
||||
start_executor_id: startExecutorId,
|
||||
});
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return { entities: [...agents, ...workflows], agents, workflows };
|
||||
// Create filtered arrays for backward compatibility
|
||||
const agents = entities.filter((e): e is AgentInfo => e.type === "agent");
|
||||
const workflows = entities.filter((e): e is WorkflowInfo => e.type === "workflow");
|
||||
|
||||
return { entities, agents, workflows };
|
||||
}
|
||||
|
||||
// Legacy methods for compatibility
|
||||
@@ -213,7 +274,7 @@ class ApiClient {
|
||||
|
||||
async getAgentInfo(agentId: string): Promise<AgentInfo> {
|
||||
// Get detailed entity info from unified endpoint
|
||||
return this.request<AgentInfo>(`/v1/entities/${agentId}/info`);
|
||||
return this.request<AgentInfo>(`/v1/entities/${agentId}/info?type=agent`);
|
||||
}
|
||||
|
||||
async getWorkflowInfo(
|
||||
@@ -221,7 +282,17 @@ class ApiClient {
|
||||
): Promise<import("@/types").WorkflowInfo> {
|
||||
// Get detailed entity info from unified endpoint
|
||||
return this.request<import("@/types").WorkflowInfo>(
|
||||
`/v1/entities/${workflowId}/info`
|
||||
`/v1/entities/${workflowId}/info?type=workflow`
|
||||
);
|
||||
}
|
||||
|
||||
async reloadEntity(entityId: string): Promise<{ success: boolean; message: string }> {
|
||||
// Hot reload entity - clears cache and forces reimport on next access
|
||||
return this.request<{ success: boolean; message: string }>(
|
||||
`/v1/entities/${entityId}/reload`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,10 +303,23 @@ class ApiClient {
|
||||
async createConversation(
|
||||
metadata?: Record<string, string>
|
||||
): Promise<Conversation> {
|
||||
// Check if OAI proxy mode is enabled
|
||||
const { oaiMode } = await import("@/stores").then((m) => ({
|
||||
oaiMode: m.useDevUIStore.getState().oaiMode,
|
||||
}));
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Add proxy mode header if enabled
|
||||
if (oaiMode.enabled) {
|
||||
headers["X-Proxy-Backend"] = "openai";
|
||||
}
|
||||
|
||||
const response = await this.request<ConversationApiResponse>(
|
||||
"/v1/conversations",
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ metadata }),
|
||||
}
|
||||
);
|
||||
@@ -315,6 +399,19 @@ class ApiClient {
|
||||
return this.request<{ data: unknown[]; has_more: boolean }>(url);
|
||||
}
|
||||
|
||||
async deleteConversationItem(
|
||||
conversationId: string,
|
||||
itemId: string
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/v1/conversations/${conversationId}/items/${itemId}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete item: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI-compatible streaming methods using /v1/responses endpoint
|
||||
|
||||
// Private helper method that handles the actual streaming with retry logic
|
||||
@@ -323,6 +420,35 @@ class ApiClient {
|
||||
conversationId?: string,
|
||||
resumeResponseId?: string
|
||||
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
|
||||
// Check if OpenAI proxy mode is enabled
|
||||
const { oaiMode } = await import("@/stores").then((m) => ({
|
||||
oaiMode: m.useDevUIStore.getState().oaiMode,
|
||||
}));
|
||||
|
||||
// Modify request if OAI mode is enabled
|
||||
if (oaiMode.enabled) {
|
||||
// Override model with OAI model
|
||||
openAIRequest.model = oaiMode.model;
|
||||
|
||||
// Merge optional OpenAI parameters
|
||||
if (oaiMode.temperature !== undefined) {
|
||||
openAIRequest.temperature = oaiMode.temperature;
|
||||
}
|
||||
if (oaiMode.max_output_tokens !== undefined) {
|
||||
openAIRequest.max_output_tokens = oaiMode.max_output_tokens;
|
||||
}
|
||||
if (oaiMode.top_p !== undefined) {
|
||||
openAIRequest.top_p = oaiMode.top_p;
|
||||
}
|
||||
if (oaiMode.instructions !== undefined) {
|
||||
openAIRequest.instructions = oaiMode.instructions;
|
||||
}
|
||||
// Reasoning parameters (for o-series models)
|
||||
if (oaiMode.reasoning_effort !== undefined) {
|
||||
openAIRequest.reasoning = { effort: oaiMode.reasoning_effort };
|
||||
}
|
||||
}
|
||||
|
||||
let lastSequenceNumber = -1;
|
||||
let retryCount = 0;
|
||||
let hasYieldedAnyEvent = false;
|
||||
@@ -367,26 +493,68 @@ class ApiClient {
|
||||
params.set("starting_after", lastSequenceNumber.toString());
|
||||
}
|
||||
const url = `${this.baseUrl}/v1/responses/${currentResponseId}?${params.toString()}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "text/event-stream",
|
||||
};
|
||||
|
||||
// Add auth token if available
|
||||
if (this.authToken) {
|
||||
headers["Authorization"] = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
headers,
|
||||
});
|
||||
} else {
|
||||
const url = `${this.baseUrl}/v1/responses`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
};
|
||||
|
||||
// Add proxy header if OAI mode is enabled
|
||||
if (oaiMode.enabled) {
|
||||
headers["X-Proxy-Backend"] = "openai";
|
||||
}
|
||||
|
||||
// Add auth token if available
|
||||
if (this.authToken) {
|
||||
headers["Authorization"] = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify(openAIRequest),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to extract detailed error message from response body
|
||||
// Handle authentication errors - don't retry these
|
||||
if (response.status === 401) {
|
||||
this.clearAuthToken(); // Clear invalid token
|
||||
throw new Error("UNAUTHORIZED"); // Special error that won't be retried
|
||||
}
|
||||
|
||||
// Handle other client errors (400-499) - don't retry these either
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
let errorMessage = `Client error ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
if (errorBody.error && errorBody.error.message) {
|
||||
errorMessage = errorBody.error.message;
|
||||
} else if (errorBody.detail) {
|
||||
errorMessage = errorBody.detail;
|
||||
}
|
||||
} catch {
|
||||
// Fallback to generic message
|
||||
}
|
||||
throw new Error(`CLIENT_ERROR: ${errorMessage}`);
|
||||
}
|
||||
|
||||
// Server errors (500-599) - these can be retried
|
||||
let errorMessage = `Request failed with status ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
@@ -519,18 +687,26 @@ class ApiClient {
|
||||
reader.releaseLock();
|
||||
}
|
||||
} catch (error) {
|
||||
// Network error occurred - prepare to retry
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Don't retry on auth errors or client errors
|
||||
if (errorMessage === "UNAUTHORIZED" || errorMessage.startsWith("CLIENT_ERROR:")) {
|
||||
throw error; // Re-throw without retrying
|
||||
}
|
||||
|
||||
// Network error or server error occurred - prepare to retry
|
||||
retryCount++;
|
||||
|
||||
if (retryCount > MAX_RETRY_ATTEMPTS) {
|
||||
// Max retries exceeded - give up
|
||||
throw new Error(
|
||||
`Connection failed after ${MAX_RETRY_ATTEMPTS} retry attempts: ${error instanceof Error ? error.message : String(error)}`
|
||||
`Connection failed after ${MAX_RETRY_ATTEMPTS} retry attempts: ${errorMessage}`
|
||||
);
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
await sleep(RETRY_INTERVAL_MS);
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
|
||||
const retryDelay = Math.min(RETRY_INTERVAL_MS * Math.pow(2, retryCount - 1), 30000);
|
||||
await sleep(retryDelay);
|
||||
// Loop will retry with GET if we have response_id, otherwise POST
|
||||
}
|
||||
}
|
||||
@@ -543,7 +719,7 @@ class ApiClient {
|
||||
resumeResponseId?: string
|
||||
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
|
||||
const openAIRequest: AgentFrameworkRequest = {
|
||||
model: agentId, // Model IS the entity_id (simplified routing!)
|
||||
metadata: { entity_id: agentId }, // Entity ID in metadata for routing
|
||||
input: request.input, // Direct OpenAI ResponseInputParam
|
||||
stream: true,
|
||||
conversation: request.conversation_id, // OpenAI standard conversation param
|
||||
@@ -559,6 +735,7 @@ class ApiClient {
|
||||
conversationId?: string,
|
||||
resumeResponseId?: string
|
||||
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
|
||||
// Proxy mode handling is now inside streamOpenAIResponse
|
||||
yield* this.streamOpenAIResponse(openAIRequest, conversationId, resumeResponseId);
|
||||
}
|
||||
|
||||
@@ -567,12 +744,15 @@ class ApiClient {
|
||||
workflowId: string,
|
||||
request: RunWorkflowRequest
|
||||
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
|
||||
// Convert to OpenAI format - use model field for entity_id (same as agents)
|
||||
// Convert to OpenAI format - use metadata.entity_id for routing
|
||||
const openAIRequest: AgentFrameworkRequest = {
|
||||
model: workflowId, // Use workflow ID in model field (matches agent pattern)
|
||||
input: request.input_data || "", // Send dict directly, no stringification needed
|
||||
metadata: { entity_id: workflowId }, // Entity ID in metadata for routing
|
||||
input: JSON.stringify(request.input_data || {}), // Serialize workflow input as JSON string
|
||||
stream: true,
|
||||
conversation: request.conversation_id, // Include conversation if present
|
||||
extra_body: request.checkpoint_id
|
||||
? { entity_id: workflowId, checkpoint_id: request.checkpoint_id }
|
||||
: undefined, // Pass checkpoint_id if provided
|
||||
};
|
||||
|
||||
yield* this.streamOpenAIResponse(openAIRequest, request.conversation_id);
|
||||
@@ -613,6 +793,139 @@ class ApiClient {
|
||||
clearStreamingState(conversationId: string): void {
|
||||
clearStreamingState(conversationId);
|
||||
}
|
||||
|
||||
// Deployment methods
|
||||
async* streamDeployment(config: {
|
||||
entity_id: string;
|
||||
resource_group: string;
|
||||
app_name: string;
|
||||
region?: string;
|
||||
ui_mode?: string;
|
||||
}): AsyncGenerator<{
|
||||
type: string;
|
||||
message: string;
|
||||
url?: string;
|
||||
auth_token?: string;
|
||||
}> {
|
||||
const response = await fetch(`${this.baseUrl}/v1/deployments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ...config, stream: true }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Deployment failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6);
|
||||
if (data === "[DONE]") return;
|
||||
try {
|
||||
yield JSON.parse(data);
|
||||
} catch (e) {
|
||||
// Emit error event for parsing failures
|
||||
yield {
|
||||
type: "deploy.error",
|
||||
message: `Failed to parse deployment event: ${e instanceof Error ? e.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Emit error event before throwing
|
||||
yield {
|
||||
type: "deploy.failed",
|
||||
message: `Stream interrupted: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
throw error;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Workflow Session Management (uses /conversations API)
|
||||
// ============================================================================
|
||||
|
||||
async listWorkflowSessions(entityId: string): Promise<{ data: import("@/types").WorkflowSession[] }> {
|
||||
// Workflow sessions are conversations with entity_id and type metadata
|
||||
const url = `/v1/conversations?entity_id=${encodeURIComponent(entityId)}&type=workflow_session`;
|
||||
const response = await this.request<{
|
||||
object: "list";
|
||||
data: ConversationApiResponse[];
|
||||
has_more: boolean;
|
||||
}>(url);
|
||||
|
||||
// Transform conversations to WorkflowSession format (no checkpoint counting)
|
||||
const sessions = response.data.map((conv) => ({
|
||||
conversation_id: conv.id,
|
||||
entity_id: conv.metadata?.entity_id || entityId,
|
||||
created_at: conv.created_at,
|
||||
metadata: {
|
||||
name: conv.metadata?.name || `Session ${new Date(conv.created_at * 1000).toLocaleString()}`,
|
||||
description: conv.metadata?.description,
|
||||
type: "workflow_session" as const,
|
||||
},
|
||||
}));
|
||||
|
||||
return { data: sessions };
|
||||
}
|
||||
|
||||
async createWorkflowSession(
|
||||
entityId: string,
|
||||
params?: { name?: string; description?: string }
|
||||
): Promise<import("@/types").WorkflowSession> {
|
||||
// Create conversation with workflow session metadata
|
||||
const metadata = {
|
||||
entity_id: entityId,
|
||||
type: "workflow_session" as const,
|
||||
name: params?.name || `Session ${new Date().toLocaleString()}`,
|
||||
...(params?.description && { description: params.description }),
|
||||
};
|
||||
|
||||
const conversation = await this.createConversation(metadata);
|
||||
|
||||
return {
|
||||
conversation_id: conversation.id,
|
||||
entity_id: entityId,
|
||||
created_at: conversation.created_at,
|
||||
metadata: {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
type: "workflow_session" as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async deleteWorkflowSession(_entityId: string, conversationId: string): Promise<void> {
|
||||
// Delete conversation (this also deletes all associated items/checkpoints)
|
||||
const success = await this.deleteConversation(conversationId);
|
||||
if (!success) {
|
||||
throw new Error("Failed to delete workflow session");
|
||||
}
|
||||
}
|
||||
|
||||
// Checkpoint operations now handled through standard conversation items API
|
||||
// Checkpoints are conversation items with type="checkpoint"
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
||||
@@ -11,6 +11,9 @@ import type {
|
||||
ExtendedResponseStreamEvent,
|
||||
Conversation,
|
||||
PendingApproval,
|
||||
OAIProxyMode,
|
||||
WorkflowSession,
|
||||
CheckpointInfo,
|
||||
} from "@/types";
|
||||
import type { ConversationItem } from "@/types/openai";
|
||||
import type { AttachmentItem } from "@/components/ui/attachment-gallery";
|
||||
@@ -23,6 +26,7 @@ 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;
|
||||
@@ -42,8 +46,16 @@ interface DevUIState {
|
||||
};
|
||||
pendingApprovals: PendingApproval[];
|
||||
|
||||
// Workflow Session Slice (workflow-specific session management)
|
||||
currentSession: WorkflowSession | undefined;
|
||||
availableSessions: WorkflowSession[];
|
||||
sessionCheckpoints: CheckpointInfo[];
|
||||
loadingSessions: boolean;
|
||||
loadingCheckpoints: boolean;
|
||||
|
||||
// UI Slice
|
||||
showDebugPanel: boolean;
|
||||
debugPanelMinimized: boolean;
|
||||
debugPanelWidth: number;
|
||||
debugEvents: ExtendedResponseStreamEvent[];
|
||||
isResizing: boolean;
|
||||
@@ -53,6 +65,36 @@ interface DevUIState {
|
||||
showGallery: boolean;
|
||||
showDeployModal: boolean;
|
||||
showEntityNotFoundToast: boolean;
|
||||
|
||||
// Toast Slice
|
||||
toasts: Array<{
|
||||
id: string;
|
||||
message: string;
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
duration?: number;
|
||||
}>;
|
||||
|
||||
// OpenAI Proxy Mode Slice
|
||||
oaiMode: OAIProxyMode;
|
||||
|
||||
// Server Meta Slice
|
||||
uiMode: "developer" | "user";
|
||||
runtime: "python" | "dotnet";
|
||||
serverCapabilities: {
|
||||
tracing: boolean;
|
||||
openai_proxy: boolean;
|
||||
deployment: boolean;
|
||||
};
|
||||
authRequired: boolean;
|
||||
|
||||
// Deployment Slice
|
||||
isDeploying: boolean;
|
||||
deploymentLogs: string[];
|
||||
lastDeployment: {
|
||||
url: string;
|
||||
authToken: string;
|
||||
} | null;
|
||||
azureDeploymentEnabled: boolean; // Feature flag for Azure deployment
|
||||
}
|
||||
|
||||
// ========================================
|
||||
@@ -63,6 +105,7 @@ 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;
|
||||
@@ -84,8 +127,18 @@ interface DevUIActions {
|
||||
updateConversationUsage: (tokens: number) => void;
|
||||
setPendingApprovals: (approvals: PendingApproval[]) => void;
|
||||
|
||||
// Workflow Session Actions
|
||||
setCurrentSession: (session: WorkflowSession | undefined) => void;
|
||||
setAvailableSessions: (sessions: WorkflowSession[]) => void;
|
||||
setSessionCheckpoints: (checkpoints: CheckpointInfo[]) => void;
|
||||
setLoadingSessions: (loading: boolean) => void;
|
||||
setLoadingCheckpoints: (loading: boolean) => void;
|
||||
addSession: (session: WorkflowSession) => void;
|
||||
removeSession: (conversationId: string) => void;
|
||||
|
||||
// UI Actions
|
||||
setShowDebugPanel: (show: boolean) => void;
|
||||
setDebugPanelMinimized: (minimized: boolean) => void;
|
||||
setDebugPanelWidth: (width: number) => void;
|
||||
addDebugEvent: (event: ExtendedResponseStreamEvent) => void;
|
||||
clearDebugEvents: () => void;
|
||||
@@ -97,6 +150,29 @@ interface DevUIActions {
|
||||
setShowDeployModal: (show: boolean) => void;
|
||||
setShowEntityNotFoundToast: (show: boolean) => void;
|
||||
|
||||
// Toast Actions
|
||||
addToast: (toast: {
|
||||
message: string;
|
||||
type?: "info" | "success" | "warning" | "error";
|
||||
duration?: number;
|
||||
}) => void;
|
||||
removeToast: (id: string) => void;
|
||||
|
||||
// OpenAI Proxy Mode Actions
|
||||
setOAIMode: (config: OAIProxyMode) => void;
|
||||
toggleOAIMode: () => void;
|
||||
|
||||
// Server Meta Actions
|
||||
setServerMeta: (meta: { uiMode: "developer" | "user"; runtime: "python" | "dotnet"; capabilities: { tracing: boolean; openai_proxy: boolean; deployment: boolean }; authRequired: boolean }) => void;
|
||||
|
||||
// Deployment Actions
|
||||
startDeployment: () => void;
|
||||
addDeploymentLog: (log: string) => void;
|
||||
setDeploymentResult: (result: { url: string; authToken: string }) => void;
|
||||
stopDeployment: () => void;
|
||||
clearDeploymentState: () => void;
|
||||
setAzureDeploymentEnabled: (enabled: boolean) => void;
|
||||
|
||||
// Combined Actions (handle multiple state updates + side effects)
|
||||
selectEntity: (entity: AgentInfo | WorkflowInfo) => void;
|
||||
}
|
||||
@@ -118,6 +194,7 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
// Entity State
|
||||
agents: [],
|
||||
workflows: [],
|
||||
entities: [],
|
||||
selectedAgent: undefined,
|
||||
isLoadingEntities: true,
|
||||
entityError: null,
|
||||
@@ -134,8 +211,16 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
conversationUsage: { total_tokens: 0, message_count: 0 },
|
||||
pendingApprovals: [],
|
||||
|
||||
// Workflow Session State
|
||||
currentSession: undefined,
|
||||
availableSessions: [],
|
||||
sessionCheckpoints: [],
|
||||
loadingSessions: false,
|
||||
loadingCheckpoints: false,
|
||||
|
||||
// UI State
|
||||
showDebugPanel: true,
|
||||
debugPanelMinimized: false,
|
||||
debugPanelWidth: 320,
|
||||
debugEvents: [],
|
||||
isResizing: false,
|
||||
@@ -146,12 +231,38 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
showDeployModal: false,
|
||||
showEntityNotFoundToast: false,
|
||||
|
||||
// Toast State
|
||||
toasts: [],
|
||||
|
||||
// OpenAI Proxy Mode State
|
||||
oaiMode: {
|
||||
enabled: false,
|
||||
model: "gpt-4o-mini", // Default to cheaper model
|
||||
},
|
||||
|
||||
// Server Meta State
|
||||
uiMode: "developer", // Default to developer mode
|
||||
runtime: "python", // Default to Python runtime
|
||||
serverCapabilities: {
|
||||
tracing: false,
|
||||
openai_proxy: false,
|
||||
deployment: false,
|
||||
},
|
||||
authRequired: false,
|
||||
|
||||
// Deployment State
|
||||
isDeploying: false,
|
||||
deploymentLogs: [],
|
||||
lastDeployment: null,
|
||||
azureDeploymentEnabled: false, // Default to disabled for safety
|
||||
|
||||
// ========================================
|
||||
// Entity Actions
|
||||
// ========================================
|
||||
|
||||
setAgents: (agents) => set({ agents }),
|
||||
setWorkflows: (workflows) => set({ workflows }),
|
||||
setEntities: (entities) => set({ entities }),
|
||||
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
|
||||
addAgent: (agent) =>
|
||||
set((state) => ({ agents: [...state.agents, agent] })),
|
||||
@@ -216,14 +327,69 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
})),
|
||||
setPendingApprovals: (approvals) => set({ pendingApprovals: approvals }),
|
||||
|
||||
// ========================================
|
||||
// Workflow Session Actions
|
||||
// ========================================
|
||||
|
||||
setCurrentSession: (session) => set({ currentSession: session }),
|
||||
setAvailableSessions: (sessions) => set({ availableSessions: sessions }),
|
||||
setSessionCheckpoints: (checkpoints) =>
|
||||
set({ sessionCheckpoints: checkpoints }),
|
||||
setLoadingSessions: (loading) => set({ loadingSessions: loading }),
|
||||
setLoadingCheckpoints: (loading) => set({ loadingCheckpoints: loading }),
|
||||
addSession: (session) =>
|
||||
set((state) => ({
|
||||
availableSessions: [session, ...state.availableSessions],
|
||||
})),
|
||||
removeSession: (conversationId) =>
|
||||
set((state) => ({
|
||||
availableSessions: state.availableSessions.filter(
|
||||
(s) => s.conversation_id !== conversationId
|
||||
),
|
||||
// Clear current session if it's the one being deleted
|
||||
currentSession:
|
||||
state.currentSession?.conversation_id === conversationId
|
||||
? undefined
|
||||
: state.currentSession,
|
||||
// Clear checkpoints if they belong to deleted session
|
||||
sessionCheckpoints:
|
||||
state.currentSession?.conversation_id === conversationId
|
||||
? []
|
||||
: state.sessionCheckpoints,
|
||||
})),
|
||||
|
||||
// ========================================
|
||||
// UI Actions
|
||||
// ========================================
|
||||
|
||||
setShowDebugPanel: (show) => set({ showDebugPanel: show }),
|
||||
setDebugPanelMinimized: (minimized) => set({ debugPanelMinimized: minimized }),
|
||||
setDebugPanelWidth: (width) => set({ debugPanelWidth: width }),
|
||||
addDebugEvent: (event) =>
|
||||
set((state) => ({ debugEvents: [...state.debugEvents, event] })),
|
||||
set((state) => {
|
||||
// Generate unique timestamp for each event
|
||||
// Use current time + small increment to ensure uniqueness even for rapid events
|
||||
const baseTimestamp = Math.floor(Date.now() / 1000);
|
||||
const lastTimestamp = state.debugEvents.length > 0
|
||||
? (state.debugEvents[state.debugEvents.length - 1] as any)._uiTimestamp || 0
|
||||
: 0;
|
||||
// Ensure new timestamp is always greater than the last one
|
||||
const uniqueTimestamp = Math.max(baseTimestamp, lastTimestamp + 1);
|
||||
|
||||
return {
|
||||
debugEvents: [
|
||||
...state.debugEvents,
|
||||
{
|
||||
...event,
|
||||
// Add UI display timestamp when event is received (Unix seconds)
|
||||
// Each event gets a unique timestamp to preserve chronological order
|
||||
_uiTimestamp: ('created_at' in event && event.created_at)
|
||||
? event.created_at
|
||||
: uniqueTimestamp,
|
||||
} as ExtendedResponseStreamEvent & { _uiTimestamp: number },
|
||||
],
|
||||
};
|
||||
}),
|
||||
clearDebugEvents: () => set({ debugEvents: [] }),
|
||||
setIsResizing: (resizing) => set({ isResizing: resizing }),
|
||||
|
||||
@@ -237,6 +403,154 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
setShowEntityNotFoundToast: (show) =>
|
||||
set({ showEntityNotFoundToast: show }),
|
||||
|
||||
// ========================================
|
||||
// Toast Actions
|
||||
// ========================================
|
||||
|
||||
addToast: (toast) =>
|
||||
set((state) => ({
|
||||
toasts: [
|
||||
...state.toasts,
|
||||
{
|
||||
id: `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: toast.type || "info",
|
||||
duration: toast.duration || 4000,
|
||||
...toast,
|
||||
},
|
||||
],
|
||||
})),
|
||||
|
||||
removeToast: (id) =>
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
})),
|
||||
|
||||
// ========================================
|
||||
// OpenAI Proxy Mode Actions
|
||||
// ========================================
|
||||
|
||||
setOAIMode: (config) =>
|
||||
set((state) => {
|
||||
// If enabling OAI mode, clear conversation state
|
||||
if (config.enabled && !state.oaiMode.enabled) {
|
||||
// Clear ALL conversation localStorage caches
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
if (key.startsWith('devui_convs_')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
oaiMode: config,
|
||||
// Clear conversation state when switching to OAI mode
|
||||
currentConversation: undefined,
|
||||
availableConversations: [],
|
||||
chatItems: [],
|
||||
inputValue: "",
|
||||
attachments: [],
|
||||
conversationUsage: { total_tokens: 0, message_count: 0 },
|
||||
isStreaming: false,
|
||||
isSubmitting: false,
|
||||
pendingApprovals: [],
|
||||
debugEvents: [],
|
||||
};
|
||||
}
|
||||
// If disabling OAI mode, also clear state
|
||||
if (!config.enabled && state.oaiMode.enabled) {
|
||||
// Clear ALL conversation localStorage caches
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
if (key.startsWith('devui_convs_')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
oaiMode: config,
|
||||
// Clear conversation state when switching back to local mode
|
||||
currentConversation: undefined,
|
||||
availableConversations: [],
|
||||
chatItems: [],
|
||||
inputValue: "",
|
||||
attachments: [],
|
||||
conversationUsage: { total_tokens: 0, message_count: 0 },
|
||||
isStreaming: false,
|
||||
isSubmitting: false,
|
||||
pendingApprovals: [],
|
||||
debugEvents: [],
|
||||
};
|
||||
}
|
||||
// Just update config (model, temperature, etc.) without clearing state
|
||||
return { oaiMode: config };
|
||||
}),
|
||||
|
||||
toggleOAIMode: () =>
|
||||
set((state) => {
|
||||
const newEnabled = !state.oaiMode.enabled;
|
||||
return {
|
||||
oaiMode: { ...state.oaiMode, enabled: newEnabled },
|
||||
// Clear conversation state when toggling
|
||||
currentConversation: undefined,
|
||||
availableConversations: [],
|
||||
chatItems: [],
|
||||
inputValue: "",
|
||||
attachments: [],
|
||||
conversationUsage: { total_tokens: 0, message_count: 0 },
|
||||
isStreaming: false,
|
||||
isSubmitting: false,
|
||||
pendingApprovals: [],
|
||||
debugEvents: [],
|
||||
};
|
||||
}),
|
||||
|
||||
// ========================================
|
||||
// Server Meta Actions
|
||||
// ========================================
|
||||
|
||||
setServerMeta: (meta) =>
|
||||
set({
|
||||
uiMode: meta.uiMode,
|
||||
runtime: meta.runtime,
|
||||
serverCapabilities: meta.capabilities,
|
||||
authRequired: meta.authRequired,
|
||||
}),
|
||||
|
||||
// ========================================
|
||||
// Deployment Actions
|
||||
// ========================================
|
||||
|
||||
startDeployment: () =>
|
||||
set({
|
||||
isDeploying: true,
|
||||
deploymentLogs: [],
|
||||
lastDeployment: null,
|
||||
}),
|
||||
|
||||
addDeploymentLog: (log) =>
|
||||
set((state) => ({
|
||||
deploymentLogs: [...state.deploymentLogs, log],
|
||||
})),
|
||||
|
||||
setDeploymentResult: (result) =>
|
||||
set({
|
||||
isDeploying: false,
|
||||
lastDeployment: result,
|
||||
}),
|
||||
|
||||
stopDeployment: () =>
|
||||
set({
|
||||
isDeploying: false,
|
||||
}),
|
||||
|
||||
clearDeploymentState: () =>
|
||||
set({
|
||||
isDeploying: false,
|
||||
deploymentLogs: [],
|
||||
lastDeployment: null,
|
||||
}),
|
||||
|
||||
setAzureDeploymentEnabled: (enabled) =>
|
||||
set({ azureDeploymentEnabled: enabled }),
|
||||
|
||||
// ========================================
|
||||
// Combined Actions
|
||||
// ========================================
|
||||
@@ -245,6 +559,7 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
* Select an entity (agent/workflow) and handle all side effects:
|
||||
* - Update selected entity
|
||||
* - Clear conversation state (FIXES THE BUG!)
|
||||
* - Clear session state (for workflows)
|
||||
* - Clear debug events
|
||||
* - Update URL
|
||||
*/
|
||||
@@ -261,6 +576,10 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
isStreaming: false,
|
||||
isSubmitting: false,
|
||||
pendingApprovals: [],
|
||||
// Clear workflow session state when switching entities
|
||||
currentSession: undefined,
|
||||
availableSessions: [], // Let WorkflowView reload sessions
|
||||
sessionCheckpoints: [],
|
||||
// Clear debug events when switching
|
||||
debugEvents: [],
|
||||
});
|
||||
@@ -276,7 +595,10 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
// Only persist UI preferences, not runtime state
|
||||
partialize: (state) => ({
|
||||
showDebugPanel: state.showDebugPanel,
|
||||
debugPanelMinimized: state.debugPanelMinimized,
|
||||
debugPanelWidth: state.debugPanelWidth,
|
||||
oaiMode: state.oaiMode, // Persist OpenAI proxy mode settings
|
||||
azureDeploymentEnabled: state.azureDeploymentEnabled, // Persist Azure deployment preference
|
||||
}),
|
||||
}
|
||||
),
|
||||
|
||||
@@ -68,12 +68,13 @@ export type ResponseInputParam = ResponseInputItem[];
|
||||
// Agent Framework extension fields (matches backend AgentFrameworkExtraBody)
|
||||
export interface AgentFrameworkExtraBody {
|
||||
entity_id: string;
|
||||
checkpoint_id?: string; // Optional checkpoint ID for workflow resume
|
||||
// input_data removed - now using standard input field for all data
|
||||
}
|
||||
|
||||
// Agent Framework Request - OpenAI ResponseCreateParams with extensions
|
||||
export interface AgentFrameworkRequest {
|
||||
model: string;
|
||||
model?: string;
|
||||
input: string | ResponseInputParam | Record<string, unknown>; // Union type matching OpenAI + dict for workflows
|
||||
stream?: boolean;
|
||||
|
||||
@@ -85,8 +86,15 @@ export interface AgentFrameworkRequest {
|
||||
metadata?: Record<string, unknown>;
|
||||
temperature?: number;
|
||||
max_output_tokens?: number;
|
||||
top_p?: number;
|
||||
tools?: Record<string, unknown>[];
|
||||
|
||||
// Reasoning parameters (for o-series models)
|
||||
reasoning?: {
|
||||
effort?: "minimal" | "low" | "medium" | "high";
|
||||
summary?: "auto" | "concise" | "detailed";
|
||||
};
|
||||
|
||||
// Agent Framework extension - strongly typed
|
||||
extra_body?: AgentFrameworkExtraBody;
|
||||
entity_id?: string; // Allow entity_id as top-level field too
|
||||
|
||||
@@ -32,6 +32,9 @@ export interface AgentInfo {
|
||||
module_path?: string;
|
||||
required_env_vars?: EnvVarRequirement[];
|
||||
metadata?: Record<string, unknown>; // Backend metadata including lazy_loaded flag
|
||||
// Deployment support
|
||||
deployment_supported?: boolean;
|
||||
deployment_reason?: string;
|
||||
// Agent-specific fields
|
||||
instructions?: string;
|
||||
model?: string;
|
||||
@@ -71,6 +74,7 @@ export interface WorkflowInfo extends Omit<AgentInfo, "tools"> {
|
||||
input_schema: JSONSchema; // JSON Schema for workflow input
|
||||
input_type_name: string; // Human-readable input type name
|
||||
start_executor_id: string; // Entry point executor ID
|
||||
// Note: DevUI provides runtime checkpoint storage for ALL workflows via conversations
|
||||
}
|
||||
|
||||
// OpenAI Conversations API (standard)
|
||||
@@ -89,6 +93,22 @@ export interface RunAgentRequest {
|
||||
export interface RunWorkflowRequest {
|
||||
input_data: Record<string, unknown>;
|
||||
conversation_id?: string;
|
||||
checkpoint_id?: string;
|
||||
}
|
||||
|
||||
// OpenAI Proxy Mode Configuration
|
||||
export interface OAIProxyMode {
|
||||
enabled: boolean;
|
||||
model: string; // Model ID like "gpt-4o", "gpt-4o-mini", or custom
|
||||
|
||||
// Optional OpenAI Responses API parameters
|
||||
temperature?: number;
|
||||
max_output_tokens?: number;
|
||||
top_p?: number;
|
||||
instructions?: string;
|
||||
|
||||
// Reasoning parameters (for o-series models)
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
}
|
||||
|
||||
// Legacy types - DEPRECATED - use new structured events from openai.ts instead
|
||||
@@ -133,6 +153,19 @@ export interface HealthResponse {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface MetaResponse {
|
||||
ui_mode: "developer" | "user";
|
||||
version: string;
|
||||
framework: string;
|
||||
runtime: "python" | "dotnet";
|
||||
capabilities: {
|
||||
tracing: boolean;
|
||||
openai_proxy: boolean;
|
||||
deployment: boolean;
|
||||
};
|
||||
auth_required: boolean;
|
||||
}
|
||||
|
||||
// Chat message types matching Agent Framework
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
@@ -175,3 +208,54 @@ export interface PendingApproval {
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
// Deployment types
|
||||
export interface DeploymentConfig {
|
||||
entity_id: string;
|
||||
resource_group: string;
|
||||
app_name: string;
|
||||
region?: string;
|
||||
ui_mode?: string;
|
||||
ui_enabled?: boolean;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
export interface DeploymentEvent {
|
||||
type: string;
|
||||
message: string;
|
||||
url?: string;
|
||||
auth_token?: string;
|
||||
}
|
||||
|
||||
export interface Deployment {
|
||||
id: string;
|
||||
entity_id: string;
|
||||
resource_group: string;
|
||||
app_name: string;
|
||||
region: string;
|
||||
url: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Workflow Session Management Types
|
||||
export interface WorkflowSession {
|
||||
conversation_id: string;
|
||||
entity_id: string;
|
||||
created_at: number;
|
||||
metadata: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
type: "workflow_session";
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CheckpointInfo {
|
||||
checkpoint_id: string;
|
||||
workflow_id: string;
|
||||
timestamp: number;
|
||||
iteration_count: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export interface ResponseFailedEvent {
|
||||
|
||||
// Custom Agent Framework OpenAI event types with structured data
|
||||
export interface ResponseWorkflowEventComplete {
|
||||
type: "response.workflow_event.complete";
|
||||
type: "response.workflow_event.completed";
|
||||
data: {
|
||||
event_type: string;
|
||||
data?: Record<string, unknown>;
|
||||
@@ -125,6 +125,32 @@ export interface ResponseFunctionToolCall {
|
||||
status?: "in_progress" | "completed" | "incomplete";
|
||||
}
|
||||
|
||||
// DevUI Extension: Output item types for response.output_item.added events
|
||||
export interface ResponseOutputImageItem {
|
||||
id: string;
|
||||
type: "output_image";
|
||||
image_url: string;
|
||||
alt_text?: string;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
export interface ResponseOutputFileItem {
|
||||
id: string;
|
||||
type: "output_file";
|
||||
filename: string;
|
||||
file_url?: string;
|
||||
file_data?: string;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
export interface ResponseOutputDataItem {
|
||||
id: string;
|
||||
type: "output_data";
|
||||
data: string;
|
||||
mime_type: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Workflow Item Types - flexible interface for any workflow item
|
||||
export interface WorkflowItem {
|
||||
type: string; // "executor_action", "workflow_action", "message", or any future type
|
||||
@@ -147,24 +173,34 @@ export function isExecutorAction(item: WorkflowItem): item is ExecutorActionItem
|
||||
return item.type === "executor_action" && "executor_id" in item;
|
||||
}
|
||||
|
||||
// OpenAI Responses API - Output Item Events
|
||||
// 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: WorkflowItem | ResponseFunctionToolCall | any; // Flexible to support various item types
|
||||
item: ResponseOutputItem;
|
||||
output_index: number;
|
||||
sequence_number?: number;
|
||||
}
|
||||
|
||||
export interface ResponseOutputItemDoneEvent {
|
||||
type: "response.output_item.done";
|
||||
item: WorkflowItem | ResponseFunctionToolCall | any;
|
||||
item: ResponseOutputItem;
|
||||
output_index: number;
|
||||
sequence_number?: number;
|
||||
}
|
||||
|
||||
// Trace event - matching actual backend output
|
||||
export interface ResponseTraceEventComplete {
|
||||
type: "response.trace_event.complete";
|
||||
type: "response.trace.completed";
|
||||
data: {
|
||||
operation_name?: string;
|
||||
duration_ms?: number;
|
||||
@@ -179,7 +215,7 @@ export interface ResponseTraceEventComplete {
|
||||
|
||||
// New trace event format from backend
|
||||
export interface ResponseTraceComplete {
|
||||
type: "response.trace.complete";
|
||||
type: "response.trace.completed";
|
||||
data: {
|
||||
type?: string;
|
||||
span_id?: string;
|
||||
@@ -244,6 +280,20 @@ export interface ResponseFunctionResultComplete {
|
||||
timestamp?: string; // Optional ISO timestamp for UI display
|
||||
}
|
||||
|
||||
// DevUI Extension: Workflow Requests Human Input (HIL)
|
||||
export interface ResponseRequestInfoEvent {
|
||||
type: "response.request_info.requested";
|
||||
request_id: string;
|
||||
source_executor_id: string;
|
||||
request_type: string;
|
||||
request_data: Record<string, unknown>;
|
||||
request_schema: Record<string, unknown>;
|
||||
item_id: string;
|
||||
output_index: number;
|
||||
sequence_number: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// DevUI Extension: Turn Separator (UI-only event for grouping)
|
||||
export interface TurnSeparatorEvent {
|
||||
type: "debug.turn_separator";
|
||||
@@ -266,6 +316,7 @@ export type StructuredEvent =
|
||||
| ResponseFunctionCallDelta
|
||||
| ResponseFunctionCallArgumentsDelta
|
||||
| ResponseFunctionResultComplete
|
||||
| ResponseRequestInfoEvent
|
||||
| ResponseErrorEvent
|
||||
| ResponseFunctionApprovalRequestedEvent
|
||||
| ResponseFunctionApprovalRespondedEvent
|
||||
@@ -374,6 +425,18 @@ export interface MessageInputFile {
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
// DevUI Extension: Function approval request content (shown in chat)
|
||||
export interface MessageFunctionApprovalRequestContent {
|
||||
type: "function_approval_request";
|
||||
request_id: string;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
function_call: {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
// DevUI Extension: Function approval response content
|
||||
export interface MessageFunctionApprovalResponseContent {
|
||||
type: "function_approval_response";
|
||||
@@ -386,12 +449,45 @@ export interface MessageFunctionApprovalResponseContent {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DevUI Extension: Output Content Types (Agent-Generated Media/Data)
|
||||
// ============================================================================
|
||||
// These extend the OpenAI Responses API to support rich content outputs
|
||||
// that aren't natively supported (images, files, data). They mirror the
|
||||
// input types but for agent outputs.
|
||||
|
||||
export interface MessageOutputImage {
|
||||
type: "output_image";
|
||||
image_url: string; // URL or data URI (data:image/png;base64,...)
|
||||
alt_text?: string;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
export interface MessageOutputFile {
|
||||
type: "output_file";
|
||||
filename: string;
|
||||
file_url?: string;
|
||||
file_data?: string; // base64
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
export interface MessageOutputData {
|
||||
type: "output_data";
|
||||
data: string;
|
||||
mime_type: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type MessageContent =
|
||||
| MessageTextContent
|
||||
| MessageInputTextContent
|
||||
| MessageOutputTextContent
|
||||
| MessageInputImage
|
||||
| MessageInputFile
|
||||
| MessageOutputImage
|
||||
| MessageOutputFile
|
||||
| MessageOutputData
|
||||
| MessageFunctionApprovalRequestContent
|
||||
| MessageFunctionApprovalResponseContent;
|
||||
|
||||
// Message item (user/assistant messages with content)
|
||||
@@ -401,6 +497,7 @@ export interface ConversationMessage {
|
||||
role: "user" | "assistant" | "system" | "tool";
|
||||
content: MessageContent[];
|
||||
status: "in_progress" | "completed" | "incomplete";
|
||||
created_at?: number; // Unix timestamp in seconds - when this message was created
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
@@ -416,6 +513,7 @@ export interface ConversationFunctionCall {
|
||||
name: string;
|
||||
arguments: string;
|
||||
status: "in_progress" | "completed" | "incomplete";
|
||||
created_at?: number; // Unix timestamp in seconds - when this function call was made
|
||||
}
|
||||
|
||||
// Function call output item
|
||||
@@ -425,6 +523,7 @@ export interface ConversationFunctionCallOutput {
|
||||
call_id: string;
|
||||
output: string;
|
||||
status?: "in_progress" | "completed" | "incomplete";
|
||||
created_at?: number; // Unix timestamp in seconds - when this function result was received
|
||||
}
|
||||
|
||||
// Union of all conversation item types
|
||||
|
||||
@@ -11,6 +11,23 @@ import type {
|
||||
import type { Workflow } from "@/types/workflow";
|
||||
import { getTypedWorkflow } from "@/types/workflow";
|
||||
|
||||
/**
|
||||
* Truncates text that exceeds the maximum length and appends ellipsis
|
||||
* @param text - The text to truncate
|
||||
* @param maxLength - Maximum length before truncation (default: 50)
|
||||
* @param ellipsis - String to append when truncated (default: '...')
|
||||
* @returns Truncated text with ellipsis if it exceeds maxLength, otherwise original text
|
||||
*
|
||||
* @example
|
||||
* truncateText('Hello World', 5) // 'Hello...'
|
||||
* truncateText('Short', 10) // 'Short'
|
||||
* truncateText('workflow_assistant_43ca50a006aa425e96e8fcf54206a7e3', 35) // 'workflow_assistant_43ca50a006aa4...'
|
||||
*/
|
||||
export function truncateText(text: string, maxLength: number = 50, ellipsis: string = '...'): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + ellipsis;
|
||||
}
|
||||
|
||||
export interface WorkflowDumpExecutor {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -164,6 +181,8 @@ export function convertWorkflowDumpToEdges(
|
||||
id: `${connection.source}-${connection.target}`,
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
sourceHandle: "source",
|
||||
targetHandle: "target",
|
||||
type: "default",
|
||||
animated: false,
|
||||
style: {
|
||||
@@ -307,7 +326,7 @@ export function applyDagreLayout(
|
||||
|
||||
/**
|
||||
* Process workflow events and extract node updates
|
||||
* Handles both new standard OpenAI events and legacy workflow events
|
||||
* Handles both standard OpenAI events and fallback workflow_event format
|
||||
*/
|
||||
export function processWorkflowEvents(
|
||||
events: ExtendedResponseStreamEvent[],
|
||||
@@ -316,12 +335,29 @@ export function processWorkflowEvents(
|
||||
const nodeUpdates: Record<string, NodeUpdate> = {};
|
||||
let hasWorkflowStarted = false;
|
||||
|
||||
// Track the latest item ID for each executor to handle multiple runs
|
||||
const latestItemIds: Record<string, string> = {};
|
||||
|
||||
events.forEach((event) => {
|
||||
// Handle new standard OpenAI events
|
||||
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
|
||||
const item = (event as any).item;
|
||||
if (item && item.type === "executor_action" && item.executor_id) {
|
||||
const executorId = item.executor_id;
|
||||
const itemId = item.id;
|
||||
|
||||
// Track the latest item ID for this executor
|
||||
if (event.type === "response.output_item.added") {
|
||||
latestItemIds[executorId] = itemId;
|
||||
}
|
||||
|
||||
// Only process this event if it's for the latest item ID of this executor
|
||||
// This prevents older "done" events from overwriting newer "added" events
|
||||
const isLatestItem = latestItemIds[executorId] === itemId;
|
||||
|
||||
if (!isLatestItem && event.type === "response.output_item.done") {
|
||||
return; // Skip this old completion event
|
||||
}
|
||||
|
||||
let state: ExecutorState = "pending";
|
||||
let error: string | undefined;
|
||||
@@ -352,9 +388,9 @@ export function processWorkflowEvents(
|
||||
else if (event.type === "response.created" || event.type === "response.in_progress") {
|
||||
hasWorkflowStarted = true;
|
||||
}
|
||||
// Legacy support for older backends
|
||||
// Handle workflow event format
|
||||
else if (
|
||||
event.type === "response.workflow_event.complete" &&
|
||||
event.type === "response.workflow_event.completed" &&
|
||||
"data" in event &&
|
||||
event.data
|
||||
) {
|
||||
@@ -400,16 +436,38 @@ export function processWorkflowEvents(
|
||||
}
|
||||
});
|
||||
|
||||
// If workflow has started and we have a start executor, set it to running
|
||||
// (unless it already has a specific state from an ExecutorInvokedEvent)
|
||||
// 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]) {
|
||||
nodeUpdates[startExecutorId] = {
|
||||
nodeId: startExecutorId,
|
||||
state: "running",
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
// Additional check: only set to running if we don't have completion/failure events for this executor
|
||||
// This prevents setting to "running" after the executor has already completed
|
||||
const hasCompletionEvent = events.some((event) => {
|
||||
if (event.type === "response.output_item.done") {
|
||||
const item = (event as any).item;
|
||||
return item && item.type === "executor_action" && item.executor_id === startExecutorId;
|
||||
}
|
||||
if (event.type === "response.workflow_event.completed" && "data" in event && event.data) {
|
||||
const data = event.data as any;
|
||||
return data.executor_id === startExecutorId &&
|
||||
(data.event_type === "ExecutorCompletedEvent" ||
|
||||
data.event_type === "ExecutorFailedEvent" ||
|
||||
data.event_type?.includes("Error") ||
|
||||
data.event_type?.includes("Failed"));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Only set to running if the executor hasn't completed yet
|
||||
if (!hasCompletionEvent) {
|
||||
nodeUpdates[startExecutorId] = {
|
||||
nodeId: startExecutorId,
|
||||
state: "running",
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return nodeUpdates;
|
||||
@@ -466,9 +524,9 @@ export function getCurrentlyExecutingExecutors(
|
||||
};
|
||||
}
|
||||
}
|
||||
// Legacy support for older backends
|
||||
// Handle workflow event format
|
||||
else if (
|
||||
event.type === "response.workflow_event.complete" &&
|
||||
event.type === "response.workflow_event.completed" &&
|
||||
"data" in event &&
|
||||
event.data
|
||||
) {
|
||||
@@ -515,7 +573,7 @@ export function updateEdgesWithSequenceAnalysis(
|
||||
|
||||
events.forEach((event) => {
|
||||
if (
|
||||
event.type === "response.workflow_event.complete" &&
|
||||
event.type === "response.workflow_event.completed" &&
|
||||
"data" in event &&
|
||||
event.data
|
||||
) {
|
||||
@@ -584,3 +642,67 @@ export function updateEdgesWithSequenceAnalysis(
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidate bidirectional edges into single edges with arrows on both ends
|
||||
* This reduces visual clutter when edges go in both directions between nodes
|
||||
*
|
||||
* Smart handle selection algorithm:
|
||||
* The current implementation keeps whichever edge was encountered first in the array.
|
||||
* Since edges are typically created in workflow definition order (following the primary flow),
|
||||
* this naturally keeps the "forward" edge and discards the "backward" one.
|
||||
*
|
||||
* For example, if the workflow defines:
|
||||
* 1. coordinator → planner (primary flow)
|
||||
* 2. planner → coordinator (feedback loop)
|
||||
*
|
||||
* We keep edge #1 and add bidirectional arrows. This ensures the edge follows
|
||||
* the natural output→input handle connection of the primary flow direction.
|
||||
*
|
||||
* React Flow will automatically route the edge to avoid overlaps, and the
|
||||
* bidirectional arrows indicate that communication flows both ways.
|
||||
*/
|
||||
export function consolidateBidirectionalEdges(edges: Edge[]): Edge[] {
|
||||
const edgeMap = new Map<string, Edge>();
|
||||
const bidirectionalKeys = new Set<string>();
|
||||
|
||||
edges.forEach(edge => {
|
||||
const forwardKey = `${edge.source}-${edge.target}`;
|
||||
const reverseKey = `${edge.target}-${edge.source}`;
|
||||
|
||||
// Check if we already have the reverse edge
|
||||
if (edgeMap.has(reverseKey)) {
|
||||
// Mark both keys as bidirectional
|
||||
bidirectionalKeys.add(reverseKey);
|
||||
bidirectionalKeys.add(forwardKey);
|
||||
|
||||
// Update the existing reverse edge to be bidirectional
|
||||
const existingEdge = edgeMap.get(reverseKey)!;
|
||||
|
||||
// Keep the existing edge's handles (they follow the primary workflow direction)
|
||||
// Add bidirectional arrows to show two-way communication
|
||||
edgeMap.set(reverseKey, {
|
||||
...existingEdge,
|
||||
markerStart: {
|
||||
type: 'arrow' as const,
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
markerEnd: {
|
||||
type: 'arrow' as const,
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
data: {
|
||||
...existingEdge.data,
|
||||
isBidirectional: true,
|
||||
},
|
||||
});
|
||||
} else if (!bidirectionalKeys.has(forwardKey)) {
|
||||
// Only add if this isn't the reverse of a bidirectional pair
|
||||
edgeMap.set(forwardKey, edge);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(edgeMap.values());
|
||||
}
|
||||
|
||||
@@ -584,13 +584,33 @@
|
||||
aria-hidden "^1.2.4"
|
||||
react-remove-scroll "^2.6.3"
|
||||
|
||||
"@radix-ui/react-slot@^1.2.3", "@radix-ui/react-slot@1.2.3":
|
||||
"@radix-ui/react-separator@^1.1.7":
|
||||
version "1.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-separator/-/react-separator-1.1.7.tgz#a18bd7fd07c10fda1bba14f2a3032e7b1a2b3470"
|
||||
integrity sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==
|
||||
dependencies:
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
|
||||
"@radix-ui/react-slot@1.2.3", "@radix-ui/react-slot@^1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz"
|
||||
integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
|
||||
"@radix-ui/react-switch@^1.2.6":
|
||||
version "1.2.6"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz#ff79acb831f0d5ea9216cfcc5b939912571358e3"
|
||||
integrity sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
"@radix-ui/react-use-previous" "1.1.1"
|
||||
"@radix-ui/react-use-size" "1.1.1"
|
||||
|
||||
"@radix-ui/react-tabs@^1.1.13":
|
||||
version "1.1.13"
|
||||
resolved "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz"
|
||||
|
||||
Reference in New Issue
Block a user