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>
This commit is contained in:
Victor Dibia
2025-11-07 15:28:32 -08:00
committed by GitHub
Unverified
parent 85484c0259
commit 94eae24082
52 changed files with 10178 additions and 1599 deletions
@@ -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, and auth status from store
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired } = 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,30 +81,63 @@ 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>
<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>
@@ -101,9 +156,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 +197,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 +212,371 @@ 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 */}
<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" && (
<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>
)}