mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
85484c0259
commit
94eae24082
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user