mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Add Function Approval UI to DevUI (#1401)
* ensure function aproval is parsed correctly * udpate ui, add deployment guide button, other debug panel fixes * feat(devui): Implement lazy loading architecture with enhanced security and state management Major architectural improvements to DevUI for better performance, security, and developer experience: Performance & Architecture: - Implement lazy loading for entity discovery - entities loaded on-demand instead of at startup - Add hot reload capability for development workflow via new reload endpoint - Reduce startup time and memory footprint by deferring module imports Security Enhancements: - Remove remote entity loading capabilities (POST /v1/entities/add, DELETE endpoints) - DevUI now strictly local development tool - no remote code execution - Add explicit security documentation and best practices in README Frontend Improvements: - Migrate to Zustand for centralized state management (replacing prop drilling) - Add lightweight zero-dependency markdown renderer with code block copy support - Improve gallery UX with setup instructions modal instead of direct URL loading - Enhanced message UI with copy functionality and better token usage display Testing & Quality: - Expand test coverage for lazy loading, type detection, and cache invalidation - Add comprehensive tests for new behaviors (+231 lines of test code) - Improve type safety and documentation throughout Breaking Changes: - Remote entity loading via URLs is no longer supported - Entities must be loaded from local filesystem only * update ui issues, uupdate test descripion
This commit is contained in:
committed by
GitHub
Unverified
parent
331c750515
commit
b64358df7e
@@ -26,7 +26,8 @@
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.12"
|
||||
"tailwindcss": "^4.1.12",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
|
||||
@@ -3,90 +3,105 @@
|
||||
* Features: Entity selection, layout management, debug coordination
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { AppHeader, DebugPanel, SettingsModal } from "@/components/layout";
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { AppHeader, DebugPanel, SettingsModal, DeploymentModal } from "@/components/layout";
|
||||
import { GalleryView } from "@/components/features/gallery";
|
||||
import { AgentView } from "@/components/features/agent";
|
||||
import { WorkflowView } from "@/components/features/workflow";
|
||||
import { LoadingState } from "@/components/ui/loading-state";
|
||||
import { Toast } from "@/components/ui/toast";
|
||||
import { apiClient } from "@/services/api";
|
||||
import { PanelRightOpen, ChevronDown, ServerOff } from "lucide-react";
|
||||
import type { SampleEntity } from "@/data/gallery";
|
||||
import { PanelRightOpen, ChevronDown, ServerOff, Rocket } from "lucide-react";
|
||||
import type {
|
||||
AgentInfo,
|
||||
WorkflowInfo,
|
||||
AppState,
|
||||
ExtendedResponseStreamEvent,
|
||||
} from "@/types";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
|
||||
export default function App() {
|
||||
const [appState, setAppState] = useState<AppState>({
|
||||
agents: [],
|
||||
workflows: [],
|
||||
isLoading: true,
|
||||
});
|
||||
// Entity state from Zustand
|
||||
const agents = useDevUIStore((state) => state.agents);
|
||||
const workflows = useDevUIStore((state) => state.workflows);
|
||||
const selectedAgent = useDevUIStore((state) => state.selectedAgent);
|
||||
const isLoadingEntities = useDevUIStore((state) => state.isLoadingEntities);
|
||||
const entityError = useDevUIStore((state) => state.entityError);
|
||||
|
||||
const [debugEvents, setDebugEvents] = useState<ExtendedResponseStreamEvent[]>(
|
||||
[]
|
||||
);
|
||||
const [showDebugPanel, setShowDebugPanel] = useState(() => {
|
||||
const saved = localStorage.getItem("showDebugPanel");
|
||||
return saved !== null ? saved === "true" : true;
|
||||
});
|
||||
const [debugPanelWidth, setDebugPanelWidth] = useState(() => {
|
||||
const savedWidth = localStorage.getItem("debugPanelWidth");
|
||||
return savedWidth ? parseInt(savedWidth, 10) : 320;
|
||||
});
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [showAboutModal, setShowAboutModal] = useState(false);
|
||||
const [showGallery, setShowGallery] = useState(false);
|
||||
const [addingEntityId, setAddingEntityId] = useState<string | null>(null);
|
||||
const [errorEntityId, setErrorEntityId] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [showEntityNotFoundToast, setShowEntityNotFoundToast] = useState(false);
|
||||
// Entity actions
|
||||
const setAgents = useDevUIStore((state) => state.setAgents);
|
||||
const setWorkflows = useDevUIStore((state) => state.setWorkflows);
|
||||
const selectEntity = useDevUIStore((state) => state.selectEntity);
|
||||
const updateAgent = useDevUIStore((state) => state.updateAgent);
|
||||
const updateWorkflow = useDevUIStore((state) => state.updateWorkflow);
|
||||
const setIsLoadingEntities = useDevUIStore((state) => state.setIsLoadingEntities);
|
||||
const setEntityError = useDevUIStore((state) => state.setEntityError);
|
||||
|
||||
// UI state from Zustand
|
||||
const showDebugPanel = useDevUIStore((state) => state.showDebugPanel);
|
||||
const debugPanelWidth = useDevUIStore((state) => state.debugPanelWidth);
|
||||
const debugEvents = useDevUIStore((state) => state.debugEvents);
|
||||
const isResizing = useDevUIStore((state) => state.isResizing);
|
||||
|
||||
// UI actions
|
||||
const setShowDebugPanel = useDevUIStore((state) => state.setShowDebugPanel);
|
||||
const setDebugPanelWidth = useDevUIStore((state) => state.setDebugPanelWidth);
|
||||
const addDebugEvent = useDevUIStore((state) => state.addDebugEvent);
|
||||
const clearDebugEvents = useDevUIStore((state) => state.clearDebugEvents);
|
||||
const setIsResizing = useDevUIStore((state) => state.setIsResizing);
|
||||
|
||||
// Modal state
|
||||
const showAboutModal = useDevUIStore((state) => state.showAboutModal);
|
||||
const showGallery = useDevUIStore((state) => state.showGallery);
|
||||
const showDeployModal = useDevUIStore((state) => state.showDeployModal);
|
||||
const showEntityNotFoundToast = useDevUIStore((state) => state.showEntityNotFoundToast);
|
||||
|
||||
// Modal actions
|
||||
const setShowAboutModal = useDevUIStore((state) => state.setShowAboutModal);
|
||||
const setShowGallery = useDevUIStore((state) => state.setShowGallery);
|
||||
const setShowDeployModal = useDevUIStore((state) => state.setShowDeployModal);
|
||||
const setShowEntityNotFoundToast = useDevUIStore((state) => state.setShowEntityNotFoundToast);
|
||||
|
||||
// Initialize app - load agents and workflows
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [agents, workflows] = await Promise.all([
|
||||
apiClient.getAgents(),
|
||||
apiClient.getWorkflows(),
|
||||
]);
|
||||
// Single API call instead of two parallel calls to same endpoint
|
||||
const { agents: agentList, workflows: workflowList } = await apiClient.getEntities();
|
||||
|
||||
setAgents(agentList);
|
||||
setWorkflows(workflowList);
|
||||
|
||||
// Check if there's an entity_id in the URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const entityId = urlParams.get("entity_id");
|
||||
|
||||
let selectedAgent: AgentInfo | WorkflowInfo | undefined;
|
||||
let selectedEntity: AgentInfo | WorkflowInfo | undefined;
|
||||
|
||||
// Try to find entity from URL parameter first
|
||||
if (entityId) {
|
||||
selectedAgent =
|
||||
agents.find((a) => a.id === entityId) ||
|
||||
workflows.find((w) => w.id === entityId);
|
||||
selectedEntity =
|
||||
agentList.find((a) => a.id === entityId) ||
|
||||
workflowList.find((w) => w.id === entityId);
|
||||
|
||||
// If entity not found but was requested, show notification
|
||||
if (!selectedAgent) {
|
||||
if (!selectedEntity) {
|
||||
setShowEntityNotFoundToast(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first available entity if URL entity not found
|
||||
if (!selectedAgent) {
|
||||
selectedAgent =
|
||||
agents.length > 0
|
||||
? agents[0]
|
||||
: workflows.length > 0
|
||||
? workflows[0]
|
||||
if (!selectedEntity) {
|
||||
selectedEntity =
|
||||
agentList.length > 0
|
||||
? agentList[0]
|
||||
: workflowList.length > 0
|
||||
? workflowList[0]
|
||||
: undefined;
|
||||
|
||||
// Update URL to match actual selected entity (or clear if none)
|
||||
if (selectedAgent) {
|
||||
if (selectedEntity) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("entity_id", selectedAgent.id);
|
||||
url.searchParams.set("entity_id", selectedEntity.id);
|
||||
window.history.replaceState({}, "", url);
|
||||
} else {
|
||||
// Clear entity_id if no entities available
|
||||
@@ -96,34 +111,44 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
setAppState((prev) => ({
|
||||
...prev,
|
||||
agents,
|
||||
workflows,
|
||||
selectedAgent,
|
||||
isLoading: false,
|
||||
}));
|
||||
if (selectedEntity) {
|
||||
selectEntity(selectedEntity);
|
||||
|
||||
// Load full info for the first entity immediately
|
||||
if (selectedEntity.metadata?.lazy_loaded === false) {
|
||||
try {
|
||||
if (selectedEntity.type === "agent") {
|
||||
const fullAgent = await apiClient.getAgentInfo(
|
||||
selectedEntity.id
|
||||
);
|
||||
updateAgent(fullAgent);
|
||||
} else {
|
||||
const fullWorkflow = await apiClient.getWorkflowInfo(
|
||||
selectedEntity.id
|
||||
);
|
||||
updateWorkflow(fullWorkflow);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to load full info for first entity ${selectedEntity.id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoadingEntities(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to load agents/workflows:", error);
|
||||
setAppState((prev) => ({
|
||||
...prev,
|
||||
error: error instanceof Error ? error.message : "Failed to load data",
|
||||
isLoading: false,
|
||||
}));
|
||||
setEntityError(
|
||||
error instanceof Error ? error.message : "Failed to load data"
|
||||
);
|
||||
setIsLoadingEntities(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Save debug panel state to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("showDebugPanel", showDebugPanel.toString());
|
||||
}, [showDebugPanel]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("debugPanelWidth", debugPanelWidth.toString());
|
||||
}, [debugPanelWidth]);
|
||||
}, [setAgents, setWorkflows, selectEntity, updateAgent, updateWorkflow, setIsLoadingEntities, setEntityError, setShowEntityNotFoundToast]);
|
||||
|
||||
// Handle resize drag
|
||||
const handleMouseDown = useCallback(
|
||||
@@ -155,161 +180,43 @@ export default function App() {
|
||||
[debugPanelWidth]
|
||||
);
|
||||
|
||||
// Handle entity selection
|
||||
const handleEntitySelect = useCallback((item: AgentInfo | WorkflowInfo) => {
|
||||
setAppState((prev) => ({
|
||||
...prev,
|
||||
selectedAgent: item,
|
||||
currentThread: undefined,
|
||||
}));
|
||||
// Handle entity selection - uses Zustand's selectEntity which handles ALL side effects
|
||||
const handleEntitySelect = useCallback(
|
||||
async (item: AgentInfo | WorkflowInfo) => {
|
||||
selectEntity(item); // This clears conversation state, debug events, and updates URL!
|
||||
|
||||
// Update URL with selected entity ID
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("entity_id", item.id);
|
||||
window.history.pushState({}, "", url);
|
||||
|
||||
// Clear debug events when switching entities
|
||||
setDebugEvents([]);
|
||||
}, []);
|
||||
// If entity is sparse (not fully loaded), load full details
|
||||
if (item.metadata?.lazy_loaded === false) {
|
||||
try {
|
||||
if (item.type === "agent") {
|
||||
const fullAgent = await apiClient.getAgentInfo(item.id);
|
||||
updateAgent(fullAgent);
|
||||
} else {
|
||||
const fullWorkflow = await apiClient.getWorkflowInfo(item.id);
|
||||
updateWorkflow(fullWorkflow);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load full info for ${item.id}:`, error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectEntity, updateAgent, updateWorkflow]
|
||||
);
|
||||
|
||||
// Handle debug events from active view
|
||||
const handleDebugEvent = useCallback(
|
||||
(event: ExtendedResponseStreamEvent | "clear") => {
|
||||
if (event === "clear") {
|
||||
setDebugEvents([]);
|
||||
clearDebugEvents();
|
||||
} else {
|
||||
setDebugEvents((prev) => [...prev, event]);
|
||||
addDebugEvent(event);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Handle adding sample entity
|
||||
const handleAddSample = useCallback(async (sample: SampleEntity) => {
|
||||
setAddingEntityId(sample.id);
|
||||
setErrorEntityId(null);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
// Call backend to fetch and add entity
|
||||
const newEntity = await apiClient.addEntity(sample.url, {
|
||||
source: "remote_gallery",
|
||||
originalUrl: sample.url,
|
||||
sampleId: sample.id,
|
||||
});
|
||||
|
||||
// Convert backend entity to frontend format
|
||||
const convertedEntity = {
|
||||
id: newEntity.id,
|
||||
name: newEntity.name,
|
||||
description: newEntity.description,
|
||||
type: newEntity.type,
|
||||
source:
|
||||
(newEntity.source as "directory" | "in_memory" | "remote_gallery") ||
|
||||
"remote_gallery",
|
||||
has_env: false,
|
||||
module_path: undefined,
|
||||
};
|
||||
|
||||
// Update app state
|
||||
if (newEntity.type === "agent") {
|
||||
const agentEntity = {
|
||||
...convertedEntity,
|
||||
tools: (newEntity.tools || []).map((tool) =>
|
||||
typeof tool === "string" ? tool : JSON.stringify(tool)
|
||||
),
|
||||
} as AgentInfo;
|
||||
|
||||
setAppState((prev) => ({
|
||||
...prev,
|
||||
agents: [...prev.agents, agentEntity],
|
||||
selectedAgent: agentEntity,
|
||||
}));
|
||||
|
||||
// Update URL with new entity
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("entity_id", agentEntity.id);
|
||||
window.history.pushState({}, "", url);
|
||||
} else {
|
||||
const workflowEntity = {
|
||||
...convertedEntity,
|
||||
executors: (newEntity.tools || []).map((tool) =>
|
||||
typeof tool === "string" ? tool : JSON.stringify(tool)
|
||||
),
|
||||
input_schema: { type: "string" },
|
||||
input_type_name: "Input",
|
||||
start_executor_id:
|
||||
newEntity.tools && newEntity.tools.length > 0
|
||||
? typeof newEntity.tools[0] === "string"
|
||||
? newEntity.tools[0]
|
||||
: JSON.stringify(newEntity.tools[0])
|
||||
: "unknown",
|
||||
} as WorkflowInfo;
|
||||
|
||||
setAppState((prev) => ({
|
||||
...prev,
|
||||
workflows: [...prev.workflows, workflowEntity],
|
||||
selectedAgent: workflowEntity,
|
||||
}));
|
||||
|
||||
// Update URL with new entity
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("entity_id", workflowEntity.id);
|
||||
window.history.pushState({}, "", url);
|
||||
}
|
||||
|
||||
// Close gallery and clear debug events
|
||||
setShowGallery(false);
|
||||
setDebugEvents([]);
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : "Failed to add sample entity";
|
||||
console.error("Failed to add sample entity:", errMsg);
|
||||
setErrorEntityId(sample.id);
|
||||
setErrorMessage(errMsg);
|
||||
} finally {
|
||||
setAddingEntityId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClearError = useCallback(() => {
|
||||
setErrorEntityId(null);
|
||||
setErrorMessage(null);
|
||||
}, []);
|
||||
|
||||
// Handle removing entity
|
||||
const handleRemoveEntity = useCallback(
|
||||
async (entityId: string) => {
|
||||
try {
|
||||
await apiClient.removeEntity(entityId);
|
||||
|
||||
// Update app state
|
||||
setAppState((prev) => ({
|
||||
...prev,
|
||||
agents: prev.agents.filter((a) => a.id !== entityId),
|
||||
workflows: prev.workflows.filter((w) => w.id !== entityId),
|
||||
selectedAgent:
|
||||
prev.selectedAgent?.id === entityId
|
||||
? undefined
|
||||
: prev.selectedAgent,
|
||||
}));
|
||||
|
||||
// Update URL - clear entity_id if we removed the selected entity
|
||||
if (appState.selectedAgent?.id === entityId) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("entity_id");
|
||||
window.history.pushState({}, "", url);
|
||||
setDebugEvents([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to remove entity:", error);
|
||||
}
|
||||
},
|
||||
[appState.selectedAgent?.id]
|
||||
[addDebugEvent, clearDebugEvents]
|
||||
);
|
||||
|
||||
// Show loading state while initializing
|
||||
if (appState.isLoading) {
|
||||
if (isLoadingEntities) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-background">
|
||||
{/* Top Bar - Skeleton */}
|
||||
@@ -322,17 +229,18 @@ export default function App() {
|
||||
</header>
|
||||
|
||||
{/* Loading Content */}
|
||||
<LoadingState
|
||||
message="Initializing DevUI..."
|
||||
description="Loading agents and workflows from your configuration"
|
||||
fullPage={true}
|
||||
/>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-medium">Initializing DevUI...</div>
|
||||
<div className="text-sm text-muted-foreground mt-2">Loading agents and workflows from your configuration</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state if loading failed
|
||||
if (appState.error) {
|
||||
if (entityError) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-background">
|
||||
<AppHeader
|
||||
@@ -340,7 +248,6 @@ export default function App() {
|
||||
workflows={[]}
|
||||
selectedItem={undefined}
|
||||
onSelect={() => {}}
|
||||
onRemove={handleRemoveEntity}
|
||||
isLoading={false}
|
||||
onSettingsClick={() => setShowAboutModal(true)}
|
||||
/>
|
||||
@@ -388,14 +295,14 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{/* Error Details (Collapsible) */}
|
||||
{appState.error && (
|
||||
{entityError && (
|
||||
<details className="text-left group">
|
||||
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2">
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
|
||||
Error details
|
||||
</summary>
|
||||
<p className="mt-2 text-xs text-muted-foreground font-mono bg-muted/30 p-3 rounded border">
|
||||
{appState.error}
|
||||
{entityError}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
@@ -420,13 +327,12 @@ export default function App() {
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-background max-h-screen">
|
||||
<AppHeader
|
||||
agents={appState.agents}
|
||||
workflows={appState.workflows}
|
||||
selectedItem={appState.selectedAgent}
|
||||
agents={agents}
|
||||
workflows={workflows}
|
||||
selectedItem={selectedAgent}
|
||||
onSelect={handleEntitySelect}
|
||||
onRemove={handleRemoveEntity}
|
||||
onBrowseGallery={() => setShowGallery(true)}
|
||||
isLoading={appState.isLoading}
|
||||
isLoading={isLoadingEntities}
|
||||
onSettingsClick={() => setShowAboutModal(true)}
|
||||
/>
|
||||
|
||||
@@ -437,40 +343,28 @@ export default function App() {
|
||||
<div className="flex-1 w-full">
|
||||
<GalleryView
|
||||
variant="route"
|
||||
onAdd={handleAddSample}
|
||||
addingEntityId={addingEntityId}
|
||||
errorEntityId={errorEntityId}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={handleClearError}
|
||||
onClose={() => setShowGallery(false)}
|
||||
hasExistingEntities={
|
||||
appState.agents.length > 0 || appState.workflows.length > 0
|
||||
agents.length > 0 || workflows.length > 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : appState.agents.length === 0 && appState.workflows.length === 0 ? (
|
||||
) : agents.length === 0 && workflows.length === 0 ? (
|
||||
// Empty state - show gallery inline (full width, no debug panel)
|
||||
<GalleryView
|
||||
variant="inline"
|
||||
onAdd={handleAddSample}
|
||||
addingEntityId={addingEntityId}
|
||||
errorEntityId={errorEntityId}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={handleClearError}
|
||||
/>
|
||||
<GalleryView variant="inline" />
|
||||
) : (
|
||||
<>
|
||||
{/* Left Panel - Main View */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{appState.selectedAgent ? (
|
||||
appState.selectedAgent.type === "agent" ? (
|
||||
{selectedAgent ? (
|
||||
selectedAgent.type === "agent" ? (
|
||||
<AgentView
|
||||
selectedAgent={appState.selectedAgent as AgentInfo}
|
||||
selectedAgent={selectedAgent as AgentInfo}
|
||||
onDebugEvent={handleDebugEvent}
|
||||
/>
|
||||
) : (
|
||||
<WorkflowView
|
||||
selectedWorkflow={appState.selectedAgent as WorkflowInfo}
|
||||
selectedWorkflow={selectedAgent as WorkflowInfo}
|
||||
onDebugEvent={handleDebugEvent}
|
||||
/>
|
||||
)
|
||||
@@ -503,7 +397,7 @@ export default function App() {
|
||||
|
||||
{/* Right Panel - Debug */}
|
||||
<div
|
||||
className="flex-shrink-0"
|
||||
className="flex-shrink-0 flex flex-col h-[calc(100vh-3.7rem)]"
|
||||
style={{ width: `${debugPanelWidth}px` }}
|
||||
>
|
||||
<DebugPanel
|
||||
@@ -511,6 +405,21 @@ export default function App() {
|
||||
isStreaming={false} // Each view manages its own streaming state
|
||||
onClose={() => setShowDebugPanel(false)}
|
||||
/>
|
||||
|
||||
{/* Deploy Footer - Pinned to bottom */}
|
||||
<div className="border-t bg-muted/30 px-3 py-2.5 flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => setShowDeployModal(true)}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Rocket className="h-3 w-3 mr-2 flex-shrink-0" />
|
||||
<span className="truncate text-xs">
|
||||
Deployment Guide for {selectedAgent?.name || "Agent"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -534,6 +443,13 @@ export default function App() {
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal open={showAboutModal} onOpenChange={setShowAboutModal} />
|
||||
|
||||
{/* Deployment Modal */}
|
||||
<DeploymentModal
|
||||
open={showDeployModal}
|
||||
onClose={() => setShowDeployModal(false)}
|
||||
agentName={selectedAgent?.name}
|
||||
/>
|
||||
|
||||
{/* Toast Notification */}
|
||||
{showEntityNotFoundToast && (
|
||||
<Toast
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
FileText,
|
||||
Check,
|
||||
X,
|
||||
Copy,
|
||||
CheckCheck,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "@/services/api";
|
||||
import type {
|
||||
@@ -41,13 +43,8 @@ import type {
|
||||
RunAgentRequest,
|
||||
Conversation,
|
||||
ExtendedResponseStreamEvent,
|
||||
PendingApproval,
|
||||
} from "@/types";
|
||||
|
||||
interface ChatState {
|
||||
items: import("@/types/openai").ConversationItem[]; // Pure OpenAI types - no legacy ChatMessage
|
||||
isStreaming: boolean;
|
||||
}
|
||||
import { useDevUIStore } from "@/stores";
|
||||
|
||||
type DebugEventHandler = (event: ExtendedResponseStreamEvent | "clear") => void;
|
||||
|
||||
@@ -61,14 +58,46 @@ interface ConversationItemBubbleProps {
|
||||
}
|
||||
|
||||
function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Extract text content from message for copying
|
||||
const getMessageText = () => {
|
||||
if (item.type === "message") {
|
||||
return item.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => (c as import("@/types/openai").MessageTextContent).text)
|
||||
.join("\n");
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = getMessageText();
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle different item types
|
||||
if (item.type === "message") {
|
||||
const isUser = item.role === "user";
|
||||
const isError = item.status === "incomplete";
|
||||
const Icon = isUser ? User : isError ? AlertCircle : Bot;
|
||||
const messageText = getMessageText();
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}>
|
||||
<div
|
||||
className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border ${
|
||||
isUser
|
||||
@@ -86,26 +115,48 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
|
||||
isUser ? "items-end" : "items-start"
|
||||
} max-w-[80%]`}
|
||||
>
|
||||
<div
|
||||
className={`rounded px-3 py-2 text-sm break-all ${
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isError
|
||||
? "bg-orange-50 dark:bg-orange-950/50 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
>
|
||||
{isError && (
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<AlertCircle className="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="font-medium text-sm">
|
||||
Unable to process request
|
||||
</span>
|
||||
<div className="relative group">
|
||||
<div
|
||||
className={`rounded px-3 py-2 text-sm break-all ${
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isError
|
||||
? "bg-orange-50 dark:bg-orange-950/50 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
>
|
||||
{isError && (
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<AlertCircle className="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="font-medium text-sm">
|
||||
Unable to process request
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={isError ? "text-xs leading-relaxed break-all" : ""}>
|
||||
<OpenAIMessageRenderer item={item} />
|
||||
</div>
|
||||
)}
|
||||
<div className={isError ? "text-xs leading-relaxed break-all" : ""}>
|
||||
<OpenAIMessageRenderer item={item} />
|
||||
</div>
|
||||
|
||||
{/* Copy button - appears on hover, always top-right inside */}
|
||||
{messageText && isHovered && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-1 right-1
|
||||
p-1.5 rounded-md border shadow-sm
|
||||
bg-background hover:bg-accent
|
||||
text-muted-foreground hover:text-foreground
|
||||
transition-all duration-200 ease-in-out
|
||||
opacity-0 group-hover:opacity-100"
|
||||
title={copied ? "Copied!" : "Copy message"}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckCheck className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
|
||||
@@ -115,10 +166,10 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
↓{item.usage.input_tokens}
|
||||
↑{item.usage.input_tokens}
|
||||
</span>
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
↑{item.usage.output_tokens}
|
||||
↓{item.usage.output_tokens}
|
||||
</span>
|
||||
<span>({item.usage.total_tokens} tokens)</span>
|
||||
</span>
|
||||
@@ -146,33 +197,35 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
|
||||
}
|
||||
|
||||
export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const [chatState, setChatState] = useState<ChatState>({
|
||||
items: [],
|
||||
isStreaming: false,
|
||||
});
|
||||
const [currentConversation, setCurrentConversation] = useState<
|
||||
Conversation | undefined
|
||||
>(undefined);
|
||||
const [availableConversations, setAvailableConversations] = useState<
|
||||
Conversation[]
|
||||
>([]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
|
||||
const [loadingConversations, setLoadingConversations] = useState(false);
|
||||
// Get conversation state from Zustand
|
||||
const currentConversation = useDevUIStore((state) => state.currentConversation);
|
||||
const availableConversations = useDevUIStore((state) => state.availableConversations);
|
||||
const chatItems = useDevUIStore((state) => state.chatItems);
|
||||
const isStreaming = useDevUIStore((state) => state.isStreaming);
|
||||
const isSubmitting = useDevUIStore((state) => state.isSubmitting);
|
||||
const loadingConversations = useDevUIStore((state) => state.loadingConversations);
|
||||
const inputValue = useDevUIStore((state) => state.inputValue);
|
||||
const attachments = useDevUIStore((state) => state.attachments);
|
||||
const conversationUsage = useDevUIStore((state) => state.conversationUsage);
|
||||
const pendingApprovals = useDevUIStore((state) => state.pendingApprovals);
|
||||
|
||||
// Get conversation actions from Zustand (only the ones we actually use)
|
||||
const setCurrentConversation = useDevUIStore((state) => state.setCurrentConversation);
|
||||
const setAvailableConversations = useDevUIStore((state) => state.setAvailableConversations);
|
||||
const setChatItems = useDevUIStore((state) => state.setChatItems);
|
||||
const setIsStreaming = useDevUIStore((state) => state.setIsStreaming);
|
||||
const setIsSubmitting = useDevUIStore((state) => state.setIsSubmitting);
|
||||
const setLoadingConversations = useDevUIStore((state) => state.setLoadingConversations);
|
||||
const setInputValue = useDevUIStore((state) => state.setInputValue);
|
||||
const setAttachments = useDevUIStore((state) => state.setAttachments);
|
||||
const updateConversationUsage = useDevUIStore((state) => state.updateConversationUsage);
|
||||
const setPendingApprovals = useDevUIStore((state) => state.setPendingApprovals);
|
||||
|
||||
// Local UI state (not in Zustand - component-specific)
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [dragCounter, setDragCounter] = useState(0);
|
||||
const [pasteNotification, setPasteNotification] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
|
||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||
const [conversationUsage, setConversationUsage] = useState<{
|
||||
total_tokens: number;
|
||||
message_count: number;
|
||||
}>({ total_tokens: 0, message_count: 0 });
|
||||
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -183,18 +236,48 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
} | null>(null);
|
||||
const userJustSentMessage = useRef<boolean>(false);
|
||||
|
||||
// Auto-scroll to bottom when new items arrive
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [chatState.items, chatState.isStreaming]);
|
||||
if (!messagesEndRef.current) return;
|
||||
|
||||
// Check if user is near bottom (within 100px)
|
||||
const scrollContainer = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]');
|
||||
|
||||
let shouldScroll = false;
|
||||
|
||||
if (scrollContainer) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
|
||||
|
||||
// Always scroll if user just sent a message, otherwise only if near bottom
|
||||
shouldScroll = userJustSentMessage.current || isNearBottom;
|
||||
} else {
|
||||
// Fallback if scroll container not found - always scroll
|
||||
shouldScroll = true;
|
||||
}
|
||||
|
||||
if (shouldScroll) {
|
||||
// Use instant scroll during streaming for smooth chunk additions
|
||||
// Use smooth scroll when not streaming (new messages)
|
||||
messagesEndRef.current.scrollIntoView({
|
||||
behavior: isStreaming ? "instant" : "smooth"
|
||||
});
|
||||
}
|
||||
|
||||
// Reset the flag after first scroll
|
||||
if (userJustSentMessage.current && !isStreaming) {
|
||||
userJustSentMessage.current = false;
|
||||
}
|
||||
}, [chatItems, isStreaming]);
|
||||
|
||||
// Return focus to input after streaming completes
|
||||
useEffect(() => {
|
||||
if (!chatState.isStreaming && !isSubmitting) {
|
||||
if (!isStreaming && !isSubmitting) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [chatState.isStreaming, isSubmitting]);
|
||||
}, [isStreaming, isSubmitting]);
|
||||
|
||||
// Load conversations when agent changes
|
||||
useEffect(() => {
|
||||
@@ -223,12 +306,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
);
|
||||
|
||||
// Use OpenAI ConversationItems directly (no conversion!)
|
||||
setChatState({
|
||||
items: items as import("@/types/openai").ConversationItem[],
|
||||
isStreaming: false
|
||||
});
|
||||
setChatItems(items as import("@/types/openai").ConversationItem[]);
|
||||
setIsStreaming(false);
|
||||
} catch {
|
||||
setChatState({ items: [], isStreaming: false });
|
||||
// 404 means conversation exists but has no items yet (newly created)
|
||||
// This is normal - just start with empty chat
|
||||
console.debug(`No items found for conversation ${mostRecent.id}, starting fresh`);
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
}
|
||||
|
||||
// Cache to localStorage for faster future loads
|
||||
@@ -252,11 +337,24 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const convs = JSON.parse(cached) as Conversation[];
|
||||
|
||||
if (convs.length > 0) {
|
||||
// Use most recent cached conversation
|
||||
setAvailableConversations(convs);
|
||||
setCurrentConversation(convs[0]);
|
||||
setChatState({ items: [], isStreaming: false });
|
||||
return;
|
||||
// Validate that cached conversations still exist in backend
|
||||
// Try to load items for the most recent one to verify it exists
|
||||
try {
|
||||
await apiClient.listConversationItems(convs[0].id);
|
||||
|
||||
// Success! Conversation exists in backend
|
||||
setAvailableConversations(convs);
|
||||
setCurrentConversation(convs[0]);
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
} catch {
|
||||
// Cached conversation doesn't exist anymore (server restarted)
|
||||
// Clear stale cache and create new conversation
|
||||
console.debug(`Cached conversation ${convs[0].id} no longer exists, clearing cache`);
|
||||
localStorage.removeItem(cachedKey);
|
||||
// Fall through to Step 3
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid cache - clear it
|
||||
@@ -271,28 +369,28 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
|
||||
setCurrentConversation(newConversation);
|
||||
setAvailableConversations([newConversation]);
|
||||
setChatState({ items: [], isStreaming: false });
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem(cachedKey, JSON.stringify([newConversation]));
|
||||
} catch {
|
||||
setAvailableConversations([]);
|
||||
setChatState({ items: [], isStreaming: false });
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
} finally {
|
||||
setLoadingConversations(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear chat when agent changes
|
||||
setChatState({
|
||||
items: [],
|
||||
isStreaming: false,
|
||||
});
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
setCurrentConversation(undefined);
|
||||
accumulatedText.current = "";
|
||||
|
||||
loadConversations();
|
||||
}, [selectedAgent]);
|
||||
}, [selectedAgent, setLoadingConversations, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]);
|
||||
|
||||
// Handle file uploads
|
||||
const handleFilesSelected = async (files: File[]) => {
|
||||
@@ -315,11 +413,11 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
});
|
||||
}
|
||||
|
||||
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||
setAttachments([...useDevUIStore.getState().attachments, ...newAttachments]);
|
||||
};
|
||||
|
||||
const handleRemoveAttachment = (id: string) => {
|
||||
setAttachments((prev) => prev.filter((att) => att.id !== id));
|
||||
setAttachments(useDevUIStore.getState().attachments.filter((att) => att.id !== id));
|
||||
};
|
||||
|
||||
// Drag and drop handlers
|
||||
@@ -353,7 +451,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
setIsDragOver(false);
|
||||
setDragCounter(0);
|
||||
|
||||
if (isSubmitting || chatState.isStreaming) return;
|
||||
if (isSubmitting || isStreaming) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
@@ -523,12 +621,11 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
agent_id: selectedAgent.id,
|
||||
});
|
||||
setCurrentConversation(newConversation);
|
||||
setAvailableConversations((prev) => [newConversation, ...prev]);
|
||||
setChatState({
|
||||
items: [],
|
||||
isStreaming: false,
|
||||
});
|
||||
setConversationUsage({ total_tokens: 0, message_count: 0 });
|
||||
setAvailableConversations([newConversation, ...useDevUIStore.getState().availableConversations]);
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
// Reset conversation usage by setting it to initial state
|
||||
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
|
||||
accumulatedText.current = "";
|
||||
|
||||
// Update localStorage cache with new conversation
|
||||
@@ -538,7 +635,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
} catch {
|
||||
// Failed to create conversation
|
||||
}
|
||||
}, [selectedAgent, availableConversations]);
|
||||
}, [selectedAgent, availableConversations, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
|
||||
|
||||
// Handle conversation deletion
|
||||
const handleDeleteConversation = useCallback(
|
||||
@@ -578,18 +675,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
// Select the most recent remaining conversation
|
||||
const nextConversation = updatedConversations[0];
|
||||
setCurrentConversation(nextConversation);
|
||||
setChatState({
|
||||
items: [],
|
||||
isStreaming: false,
|
||||
});
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
} else {
|
||||
// No conversations left, clear everything
|
||||
setCurrentConversation(undefined);
|
||||
setChatState({
|
||||
items: [],
|
||||
isStreaming: false,
|
||||
});
|
||||
setConversationUsage({ total_tokens: 0, message_count: 0 });
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
|
||||
accumulatedText.current = "";
|
||||
}
|
||||
}
|
||||
@@ -601,7 +694,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
alert("Failed to delete conversation. Please try again.");
|
||||
}
|
||||
},
|
||||
[availableConversations, currentConversation, selectedAgent, onDebugEvent]
|
||||
[availableConversations, currentConversation, selectedAgent, onDebugEvent, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]
|
||||
);
|
||||
|
||||
// Handle conversation selection
|
||||
@@ -624,28 +717,28 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
// Use OpenAI ConversationItems directly (no conversion!)
|
||||
const items = result.data as import("@/types/openai").ConversationItem[];
|
||||
|
||||
setChatState({
|
||||
items,
|
||||
isStreaming: false,
|
||||
});
|
||||
setChatItems(items);
|
||||
setIsStreaming(false);
|
||||
|
||||
// Calculate usage from loaded items
|
||||
setConversationUsage({
|
||||
total_tokens: 0, // We don't have usage info in stored items
|
||||
message_count: items.length,
|
||||
useDevUIStore.setState({
|
||||
conversationUsage: {
|
||||
total_tokens: 0, // We don't have usage info in stored items
|
||||
message_count: items.length,
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Fallback to clearing items
|
||||
setChatState({
|
||||
items: [],
|
||||
isStreaming: false,
|
||||
});
|
||||
setConversationUsage({ total_tokens: 0, message_count: 0 });
|
||||
// 404 means conversation doesn't exist or has no items yet
|
||||
// This can happen if server restarted (in-memory store cleared)
|
||||
console.debug(`No items found for conversation ${conversationId}, starting with empty chat`);
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
|
||||
}
|
||||
|
||||
accumulatedText.current = "";
|
||||
},
|
||||
[availableConversations, onDebugEvent]
|
||||
[availableConversations, onDebugEvent, setCurrentConversation, setChatItems, setIsStreaming]
|
||||
);
|
||||
|
||||
// Handle function approval responses
|
||||
@@ -677,8 +770,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
};
|
||||
|
||||
// Remove from pending immediately (will be confirmed by backend event)
|
||||
setPendingApprovals((prev) =>
|
||||
prev.filter((a) => a.request_id !== request_id)
|
||||
setPendingApprovals(
|
||||
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== request_id)
|
||||
);
|
||||
|
||||
// Trigger send (we'll call this from the UI button handler)
|
||||
@@ -729,11 +822,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
status: "completed",
|
||||
};
|
||||
|
||||
setChatState((prev) => ({
|
||||
...prev,
|
||||
items: [...prev.items, userMessage],
|
||||
isStreaming: true,
|
||||
}));
|
||||
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
|
||||
setIsStreaming(true);
|
||||
|
||||
// Create assistant message placeholder
|
||||
const assistantMessage: import("@/types/openai").ConversationMessage = {
|
||||
@@ -744,10 +834,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
status: "in_progress",
|
||||
};
|
||||
|
||||
setChatState((prev) => ({
|
||||
...prev,
|
||||
items: [...prev.items, assistantMessage],
|
||||
}));
|
||||
setChatItems([...useDevUIStore.getState().chatItems, assistantMessage]);
|
||||
|
||||
try {
|
||||
// If no conversation selected, create one automatically
|
||||
@@ -758,7 +845,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
agent_id: selectedAgent.id,
|
||||
});
|
||||
setCurrentConversation(conversationToUse);
|
||||
setAvailableConversations((prev) => [conversationToUse!, ...prev]);
|
||||
setAvailableConversations([conversationToUse, ...useDevUIStore.getState().availableConversations]);
|
||||
} catch {
|
||||
// Failed to create conversation
|
||||
}
|
||||
@@ -772,9 +859,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
// Clear text accumulator for new response
|
||||
accumulatedText.current = "";
|
||||
|
||||
// Clear debug panel events for new agent run
|
||||
onDebugEvent("clear");
|
||||
|
||||
// Use OpenAI-compatible API streaming - direct event handling
|
||||
const streamGenerator = apiClient.streamAgentExecutionOpenAI(
|
||||
selectedAgent.id,
|
||||
@@ -805,8 +889,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
|
||||
|
||||
// Add to pending approvals
|
||||
setPendingApprovals((prev) => [
|
||||
...prev,
|
||||
setPendingApprovals([
|
||||
...useDevUIStore.getState().pendingApprovals,
|
||||
{
|
||||
request_id: approvalEvent.request_id,
|
||||
function_call: approvalEvent.function_call,
|
||||
@@ -820,8 +904,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const responseEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRespondedEvent;
|
||||
|
||||
// Remove from pending approvals
|
||||
setPendingApprovals((prev) =>
|
||||
prev.filter((a) => a.request_id !== responseEvent.request_id)
|
||||
setPendingApprovals(
|
||||
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== responseEvent.request_id)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -834,24 +918,22 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const errorMessage = errorEvent.message || "An error occurred";
|
||||
|
||||
// Update assistant message with error and stop streaming
|
||||
setChatState((prev) => ({
|
||||
...prev,
|
||||
isStreaming: false,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: errorMessage,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "incomplete" as const,
|
||||
}
|
||||
: item
|
||||
),
|
||||
}));
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: errorMessage,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "incomplete" as const,
|
||||
}
|
||||
: item
|
||||
));
|
||||
setIsStreaming(false);
|
||||
return; // Exit stream processing early on error
|
||||
}
|
||||
|
||||
@@ -864,23 +946,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
accumulatedText.current += openAIEvent.delta;
|
||||
|
||||
// Update assistant message with accumulated content
|
||||
setChatState((prev) => ({
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: accumulatedText.current,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "in_progress" as const,
|
||||
}
|
||||
: item
|
||||
),
|
||||
}));
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: accumulatedText.current,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "in_progress" as const,
|
||||
}
|
||||
: item
|
||||
));
|
||||
}
|
||||
|
||||
// Handle completion/error by detecting when streaming stops
|
||||
@@ -891,56 +971,49 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
// Usage is provided via response.completed event (OpenAI standard)
|
||||
const finalUsage = currentMessageUsage.current;
|
||||
|
||||
setChatState((prev) => ({
|
||||
...prev,
|
||||
isStreaming: false,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
status: "completed" as const,
|
||||
usage: finalUsage || undefined,
|
||||
}
|
||||
: item
|
||||
),
|
||||
}));
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
status: "completed" as const,
|
||||
usage: finalUsage || undefined,
|
||||
}
|
||||
: item
|
||||
));
|
||||
setIsStreaming(false);
|
||||
|
||||
// Update conversation-level usage stats
|
||||
if (finalUsage) {
|
||||
setConversationUsage((prev) => ({
|
||||
total_tokens: prev.total_tokens + finalUsage.total_tokens,
|
||||
message_count: prev.message_count + 1,
|
||||
}));
|
||||
updateConversationUsage(finalUsage.total_tokens);
|
||||
}
|
||||
|
||||
// Reset usage for next message
|
||||
currentMessageUsage.current = null;
|
||||
} catch (error) {
|
||||
setChatState((prev) => ({
|
||||
...prev,
|
||||
isStreaming: false,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to get response"
|
||||
}`,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "incomplete" as const,
|
||||
}
|
||||
: item
|
||||
),
|
||||
}));
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to get response"
|
||||
}`,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "incomplete" as const,
|
||||
}
|
||||
: item
|
||||
));
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
[selectedAgent, currentConversation, onDebugEvent]
|
||||
[selectedAgent, currentConversation, onDebugEvent, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage]
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -953,6 +1026,9 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
)
|
||||
return;
|
||||
|
||||
// Set flag to force scroll when user sends message
|
||||
userJustSentMessage.current = true;
|
||||
|
||||
setIsSubmitting(true);
|
||||
const messageText = inputValue.trim();
|
||||
setInputValue("");
|
||||
@@ -1056,7 +1132,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const canSendMessage =
|
||||
selectedAgent &&
|
||||
!isSubmitting &&
|
||||
!chatState.isStreaming &&
|
||||
!isStreaming &&
|
||||
(inputValue.trim() || attachments.length > 0);
|
||||
|
||||
return (
|
||||
@@ -1183,7 +1259,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 p-4 h-0" ref={scrollAreaRef}>
|
||||
<div className="space-y-4">
|
||||
{chatState.items.length === 0 ? (
|
||||
{chatItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Start a conversation with{" "}
|
||||
@@ -1194,7 +1270,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
chatState.items.map((item) => (
|
||||
chatItems.map((item) => (
|
||||
<ConversationItemBubble key={item.id} item={item} />
|
||||
))
|
||||
)}
|
||||
@@ -1339,13 +1415,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
placeholder={`Message ${
|
||||
selectedAgent.name || selectedAgent.id
|
||||
}... (Shift+Enter for new line)`}
|
||||
disabled={isSubmitting || chatState.isStreaming}
|
||||
disabled={isSubmitting || isStreaming}
|
||||
className="flex-1 min-h-[40px] max-h-[200px] resize-none"
|
||||
style={{ fieldSizing: "content" } as React.CSSProperties}
|
||||
/>
|
||||
<FileUpload
|
||||
onFilesSelected={handleFilesSelected}
|
||||
disabled={isSubmitting || chatState.isStreaming}
|
||||
disabled={isSubmitting || isStreaming}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
+18
-45
@@ -9,10 +9,11 @@ import {
|
||||
FileText,
|
||||
Code,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronRight,
|
||||
Music,
|
||||
} from "lucide-react";
|
||||
import type { MessageContent } from "@/types/openai";
|
||||
import { MarkdownRenderer } from "@/components/ui/markdown-renderer";
|
||||
|
||||
interface ContentRendererProps {
|
||||
content: MessageContent;
|
||||
@@ -22,51 +23,15 @@ interface ContentRendererProps {
|
||||
|
||||
// Text content renderer
|
||||
function TextContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (content.type !== "text") return null;
|
||||
|
||||
const text = content.text;
|
||||
const TRUNCATE_LENGTH = 1600;
|
||||
const shouldTruncate = text.length > TRUNCATE_LENGTH;
|
||||
const displayText =
|
||||
shouldTruncate && !isExpanded
|
||||
? text.slice(0, TRUNCATE_LENGTH) + "..."
|
||||
: text;
|
||||
|
||||
return (
|
||||
<div className={`whitespace-pre-wrap break-words ${className || ""}`}>
|
||||
<div
|
||||
className={
|
||||
isExpanded && shouldTruncate ? "max-h-96 overflow-y-auto" : ""
|
||||
}
|
||||
>
|
||||
{displayText}
|
||||
{isStreaming && text.length > 0 && (
|
||||
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
</div>
|
||||
{shouldTruncate && (
|
||||
<div className="flex justify-end mt-1">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="inline-flex items-center gap-1 text-xs
|
||||
bg-background/80 hover:bg-background border border-border/50 hover:border-border
|
||||
text-muted-foreground hover:text-foreground
|
||||
transition-colors cursor-pointer px-2 py-1 rounded"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
less <ChevronUp className="h-3 w-3" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{(text.length - TRUNCATE_LENGTH).toLocaleString()} more{" "}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className={`break-words ${className || ""}`}>
|
||||
<MarkdownRenderer content={text} />
|
||||
{isStreaming && text.length > 0 && (
|
||||
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -223,7 +188,7 @@ export function FunctionCallRenderer({ name, arguments: args, className }: Funct
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-blue-50 dark:bg-blue-950/20 ${className || ""}`}>
|
||||
<div className={`my-2 p-3 border rounded bg-blue-50 dark:bg-blue-950/20 ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
@@ -232,7 +197,11 @@ export function FunctionCallRenderer({ name, arguments: args, className }: Funct
|
||||
<span className="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
Function Call: {name}
|
||||
</span>
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400">{isExpanded ? "▼" : "▶"}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
|
||||
@@ -264,7 +233,7 @@ export function FunctionResultRenderer({ output, call_id, className }: FunctionR
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-green-50 dark:bg-green-950/20 ${className || ""}`}>
|
||||
<div className={`my-2 p-3 border rounded bg-green-50 dark:bg-green-950/20 ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
@@ -273,7 +242,11 @@ export function FunctionResultRenderer({ output, call_id, className }: FunctionR
|
||||
<span className="text-sm font-medium text-green-800 dark:text-green-300">
|
||||
Function Result
|
||||
</span>
|
||||
<span className="text-xs text-green-600 dark:text-green-400">{isExpanded ? "▼" : "▶"}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-green-600 dark:text-green-400 ml-auto" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-green-600 dark:text-green-400 ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
|
||||
|
||||
@@ -17,15 +17,13 @@ import {
|
||||
import {
|
||||
Bot,
|
||||
Workflow,
|
||||
Plus,
|
||||
Loader2,
|
||||
User,
|
||||
TriangleAlert,
|
||||
AlertCircle,
|
||||
X,
|
||||
Key,
|
||||
ChevronDown,
|
||||
ArrowLeft,
|
||||
Download,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -33,13 +31,9 @@ import {
|
||||
type SampleEntity,
|
||||
getDifficultyColor,
|
||||
} from "@/data/gallery";
|
||||
import { SetupInstructionsModal } from "./setup-instructions-modal";
|
||||
|
||||
interface GalleryViewProps {
|
||||
onAdd: (sample: SampleEntity) => Promise<void>;
|
||||
addingEntityId?: string | null;
|
||||
errorEntityId?: string | null;
|
||||
errorMessage?: string | null;
|
||||
onClearError?: (sampleId: string) => void;
|
||||
onClose?: () => void;
|
||||
variant?: "inline" | "route" | "modal";
|
||||
hasExistingEntities?: boolean;
|
||||
@@ -48,91 +42,41 @@ interface GalleryViewProps {
|
||||
// Internal: Sample Entity Card Component
|
||||
function SampleEntityCard({
|
||||
sample,
|
||||
onAdd,
|
||||
isAdding = false,
|
||||
hasError = false,
|
||||
errorMessage,
|
||||
onClearError,
|
||||
}: {
|
||||
sample: SampleEntity;
|
||||
onAdd: (sample: SampleEntity) => Promise<void>;
|
||||
isAdding?: boolean;
|
||||
hasError?: boolean;
|
||||
errorMessage?: string | null;
|
||||
onClearError?: (sampleId: string) => void;
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (isLoading || isAdding) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onAdd(sample);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
const TypeIcon = sample.type === "workflow" ? Workflow : Bot;
|
||||
const isDisabled = isLoading || isAdding;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"hover:shadow-md transition-shadow duration-200 h-full flex flex-col overflow-hidden w-full",
|
||||
hasError && "border-destructive"
|
||||
)}
|
||||
>
|
||||
<CardHeader className="pb-3 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon className="h-5 w-5" />
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{sample.type}
|
||||
<>
|
||||
<Card className="hover:shadow-md transition-shadow duration-200 h-full flex flex-col overflow-hidden w-full">
|
||||
<CardHeader className="pb-3 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon className="h-5 w-5" />
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{sample.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs border",
|
||||
getDifficultyColor(sample.difficulty)
|
||||
)}
|
||||
>
|
||||
{sample.difficulty}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs border",
|
||||
getDifficultyColor(sample.difficulty)
|
||||
)}
|
||||
>
|
||||
{sample.difficulty}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<CardTitle className="text-lg leading-tight">{sample.name}</CardTitle>
|
||||
<CardDescription className="text-sm line-clamp-3">
|
||||
{sample.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardTitle className="text-lg leading-tight">{sample.name}</CardTitle>
|
||||
<CardDescription className="text-sm line-clamp-3">
|
||||
{sample.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0 flex-1 min-w-0 overflow-hidden">
|
||||
{/* Error Banner */}
|
||||
{hasError && errorMessage && (
|
||||
<div className="mb-3 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-destructive font-medium mb-1">
|
||||
Failed to add
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{errorMessage}</p>
|
||||
</div>
|
||||
{onClearError && (
|
||||
<button
|
||||
onClick={() => onClearError(sample.id)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="pt-0 flex-1 min-w-0 overflow-hidden">
|
||||
|
||||
<div className="space-y-3 min-w-0">
|
||||
{/* Tags */}
|
||||
@@ -199,73 +143,57 @@ function SampleEntityCard({
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="pt-3 flex-col gap-3">
|
||||
{/* Metadata */}
|
||||
<div className="w-full flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{sample.author}</span>
|
||||
<CardFooter className="pt-3 flex-col gap-3">
|
||||
{/* Metadata */}
|
||||
<div className="w-full flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{sample.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Button - Full width on its own line */}
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={isDisabled}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
variant={hasError ? "outline" : "default"}
|
||||
>
|
||||
{isDisabled ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Adding...
|
||||
</>
|
||||
) : hasError ? (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Sample
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
{/* Action Buttons */}
|
||||
<div className="w-full flex gap-2">
|
||||
<Button asChild className="flex-1" size="sm">
|
||||
<a
|
||||
href={sample.url}
|
||||
download={`${sample.id}.py`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
onClick={() => setShowInstructions(true)}
|
||||
>
|
||||
<BookOpen className="h-4 w-4 mr-2" />
|
||||
Setup Guide
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<SetupInstructionsModal
|
||||
sample={sample}
|
||||
open={showInstructions}
|
||||
onOpenChange={setShowInstructions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Internal: Sample Entity Grid Component
|
||||
function SampleEntityGrid({
|
||||
samples,
|
||||
onAdd,
|
||||
addingEntityId,
|
||||
errorEntityId,
|
||||
errorMessage,
|
||||
onClearError,
|
||||
}: {
|
||||
samples: SampleEntity[];
|
||||
onAdd: (sample: SampleEntity) => Promise<void>;
|
||||
addingEntityId?: string | null;
|
||||
errorEntityId?: string | null;
|
||||
errorMessage?: string | null;
|
||||
onClearError?: (sampleId: string) => void;
|
||||
}) {
|
||||
function SampleEntityGrid({ samples }: { samples: SampleEntity[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{samples.map((sample) => (
|
||||
<div key={sample.id} className="min-w-0">
|
||||
<SampleEntityCard
|
||||
sample={sample}
|
||||
onAdd={onAdd}
|
||||
isAdding={addingEntityId === sample.id}
|
||||
hasError={errorEntityId === sample.id}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={onClearError}
|
||||
/>
|
||||
<SampleEntityCard sample={sample} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -274,11 +202,6 @@ function SampleEntityGrid({
|
||||
|
||||
// Main: Gallery View Component
|
||||
export function GalleryView({
|
||||
onAdd,
|
||||
addingEntityId,
|
||||
errorEntityId,
|
||||
errorMessage,
|
||||
onClearError,
|
||||
onClose,
|
||||
variant = "inline",
|
||||
hasExistingEntities = false,
|
||||
@@ -304,8 +227,8 @@ export function GalleryView({
|
||||
in a directory containing them.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can also import any of the sample agents and workflows
|
||||
below to get started quickly.
|
||||
Browse the sample agents and workflows below. Download them,
|
||||
review the code, and run them locally to get started quickly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -314,14 +237,7 @@ export function GalleryView({
|
||||
{/* Sample Gallery */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Sample Gallery</h3>
|
||||
<SampleEntityGrid
|
||||
samples={SAMPLE_ENTITIES}
|
||||
onAdd={onAdd}
|
||||
addingEntityId={addingEntityId}
|
||||
errorEntityId={errorEntityId}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={onClearError}
|
||||
/>
|
||||
<SampleEntityGrid samples={SAMPLE_ENTITIES} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
@@ -362,22 +278,15 @@ export function GalleryView({
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-semibold mb-2">Sample Gallery</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Browse and add sample agents and workflows to learn the Agent
|
||||
Framework. These are curated examples ranging from beginner to
|
||||
advanced.
|
||||
Browse sample agents and workflows to learn the Agent
|
||||
Framework. Download these curated examples and run them locally.
|
||||
Examples range from beginner to advanced.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sample Gallery */}
|
||||
<SampleEntityGrid
|
||||
samples={SAMPLE_ENTITIES}
|
||||
onAdd={onAdd}
|
||||
addingEntityId={addingEntityId}
|
||||
errorEntityId={errorEntityId}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={onClearError}
|
||||
/>
|
||||
<SampleEntityGrid samples={SAMPLE_ENTITIES} />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-12 pt-8 border-t">
|
||||
@@ -399,14 +308,5 @@ export function GalleryView({
|
||||
}
|
||||
|
||||
// Modal variant - for dropdown trigger (simplified, just the grid)
|
||||
return (
|
||||
<SampleEntityGrid
|
||||
samples={SAMPLE_ENTITIES}
|
||||
onAdd={onAdd}
|
||||
addingEntityId={addingEntityId}
|
||||
errorEntityId={errorEntityId}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={onClearError}
|
||||
/>
|
||||
);
|
||||
return <SampleEntityGrid samples={SAMPLE_ENTITIES} />;
|
||||
}
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* SetupInstructionsModal - Shows step-by-step instructions for running a sample entity
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Download,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Check,
|
||||
Lightbulb,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
import type { SampleEntity } from "@/data/gallery";
|
||||
|
||||
interface SetupInstructionsModalProps {
|
||||
sample: SampleEntity;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function CodeBlock({ children, copyable = false }: { children: string; copyable?: boolean }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(children);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<pre className="bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
{copyable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2 h-6 w-6 p-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupStep({
|
||||
number,
|
||||
title,
|
||||
description,
|
||||
code,
|
||||
action,
|
||||
copyable = false,
|
||||
}: {
|
||||
number: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
code?: string;
|
||||
action?: React.ReactNode;
|
||||
copyable?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold">
|
||||
{number}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<h4 className="font-semibold">{title}</h4>
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
{code && <CodeBlock copyable={copyable}>{code}</CodeBlock>}
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupInstructionsModal({
|
||||
sample,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: SetupInstructionsModalProps) {
|
||||
const hasEnvVars = sample.requiredEnvVars && sample.requiredEnvVars.length > 0;
|
||||
const stepOffset = hasEnvVars ? 0 : -1;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader className="px-6 pt-6 pb-2">
|
||||
<DialogTitle>Setup: {sample.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Follow these steps to run this sample {sample.type} locally
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6">
|
||||
<ScrollArea className="h-[500px]">
|
||||
<div className="space-y-6 pr-4">
|
||||
{/* Step 1: Download */}
|
||||
<SetupStep
|
||||
number={1}
|
||||
title="Download the sample file"
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
<a
|
||||
href={sample.url}
|
||||
download={`${sample.id}.py`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download {sample.id}.py
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Step 2: Create folder */}
|
||||
<SetupStep
|
||||
number={2}
|
||||
title="Create a project folder"
|
||||
description="Create a dedicated folder for this sample and move the downloaded file there:"
|
||||
code={`mkdir -p ~/my-agents/${sample.id}\nmv ~/Downloads/${sample.id}.py ~/my-agents/${sample.id}/`}
|
||||
copyable
|
||||
/>
|
||||
|
||||
{/* Step 3: Environment variables (conditional) */}
|
||||
{hasEnvVars && (
|
||||
<SetupStep
|
||||
number={3}
|
||||
title="Set up environment variables"
|
||||
description="Create a .env file in the project folder with these required variables:"
|
||||
code={sample.requiredEnvVars!
|
||||
.map((v) => `${v.name}=${v.example || "your-value-here"}\n# ${v.description}`)
|
||||
.join("\n\n")}
|
||||
copyable
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 4: Run DevUI */}
|
||||
<SetupStep
|
||||
number={4 + stepOffset}
|
||||
title="Run with DevUI"
|
||||
description="Navigate to the folder and start DevUI:"
|
||||
code={`cd ~/my-agents/${sample.id}\ndevui .`}
|
||||
copyable
|
||||
/>
|
||||
|
||||
{/* Alternative: Direct run */}
|
||||
<Alert>
|
||||
<Lightbulb className="h-4 w-4" />
|
||||
<AlertTitle>Alternative: Run Programmatically</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
<p className="mb-2">You can also run the {sample.type} directly in Python:</p>
|
||||
<CodeBlock copyable>
|
||||
{`from ${sample.id} import ${sample.type}
|
||||
import asyncio
|
||||
|
||||
async def main():
|
||||
response = await ${sample.type}.run("Hello!")
|
||||
print(response)
|
||||
|
||||
asyncio.run(main())`}
|
||||
</CodeBlock>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Help links */}
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={sample.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
View Source
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href="https://github.com/microsoft/agent-framework#readme"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<BookOpen className="h-4 w-4 mr-2" />
|
||||
Documentation
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+17
-10
@@ -32,22 +32,29 @@ interface FormFieldProps {
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
// Helper: Determine if field is likely a short metadata field (not multiline text)
|
||||
function isShortField(fieldName: string): boolean {
|
||||
const shortFieldNames = ['name', 'title', 'id', 'key', 'label', 'type', 'status', 'tag', 'category', 'code', 'username', 'password'];
|
||||
return shortFieldNames.includes(fieldName.toLowerCase());
|
||||
}
|
||||
|
||||
function FormField({ name, schema, value, onChange, isRequired = false }: FormFieldProps) {
|
||||
const { type, description, enum: enumValues, default: defaultValue } = schema;
|
||||
|
||||
// For text/message/content fields, treat as textarea for better UX
|
||||
const isTextContentField = ['text', 'message', 'content', 'query', 'prompt'].includes(name.toLowerCase());
|
||||
// Determine if this should be a textarea based on JSON Schema format field
|
||||
// or heuristics (long descriptions, specific field types)
|
||||
const shouldBeTextarea =
|
||||
schema.format === "textarea" || // Explicit format from backend
|
||||
(description && description.length > 100) || // Long description suggests multiline
|
||||
(type === "string" && !enumValues && !isShortField(name)); // Default strings to textarea unless they're short metadata fields
|
||||
|
||||
// Determine if this field should span full width
|
||||
// Only span full if it's a textarea or has very long description
|
||||
const shouldSpanFullWidth =
|
||||
schema.format === "textarea" ||
|
||||
isTextContentField || // text/message fields span full width
|
||||
shouldBeTextarea ||
|
||||
(description && description.length > 150);
|
||||
|
||||
const shouldSpanTwoColumns =
|
||||
schema.format === "textarea" ||
|
||||
isTextContentField ||
|
||||
shouldBeTextarea ||
|
||||
(description && description.length > 80) ||
|
||||
type === "array"; // Arrays might need more space for comma-separated values
|
||||
|
||||
@@ -90,8 +97,7 @@ function FormField({ name, schema, value, onChange, isRequired = false }: FormFi
|
||||
</div>
|
||||
);
|
||||
} else if (
|
||||
schema.format === "textarea" ||
|
||||
isTextContentField ||
|
||||
shouldBeTextarea ||
|
||||
(description && description.length > 100)
|
||||
) {
|
||||
// Multi-line text (including text/message/content fields)
|
||||
@@ -110,7 +116,8 @@ function FormField({ name, schema, value, onChange, isRequired = false }: FormFi
|
||||
? defaultValue
|
||||
: `Enter ${name}`
|
||||
}
|
||||
rows={isTextContentField ? 4 : 2}
|
||||
rows={shouldBeTextarea ? 4 : 2}
|
||||
className="min-w-[300px] w-full"
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
|
||||
@@ -201,7 +201,7 @@ function RunWorkflowButton({
|
||||
|
||||
{/* Modal with proper Dialog component - matching WorkflowInputForm structure */}
|
||||
<Dialog open={showModal} onOpenChange={setShowModal}>
|
||||
<DialogContent className="w-full max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="w-full min-w-[400px] max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="px-8 pt-6">
|
||||
<DialogTitle>Configure Workflow Inputs</DialogTitle>
|
||||
<DialogClose onClose={() => setShowModal(false)} />
|
||||
|
||||
@@ -14,7 +14,6 @@ interface AppHeaderProps {
|
||||
workflows: WorkflowInfo[];
|
||||
selectedItem?: AgentInfo | WorkflowInfo;
|
||||
onSelect: (item: AgentInfo | WorkflowInfo) => void;
|
||||
onRemove?: (entityId: string) => void;
|
||||
onBrowseGallery?: () => void;
|
||||
isLoading?: boolean;
|
||||
onSettingsClick?: () => void;
|
||||
@@ -25,7 +24,6 @@ export function AppHeader({
|
||||
workflows,
|
||||
selectedItem,
|
||||
onSelect,
|
||||
onRemove,
|
||||
onBrowseGallery,
|
||||
isLoading = false,
|
||||
onSettingsClick,
|
||||
@@ -66,7 +64,6 @@ export function AppHeader({
|
||||
workflows={workflows}
|
||||
selectedItem={selectedItem}
|
||||
onSelect={onSelect}
|
||||
onRemove={onRemove}
|
||||
onBrowseGallery={onBrowseGallery}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
@@ -24,6 +24,40 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types";
|
||||
|
||||
// Simple visual separator component
|
||||
function MessageSeparator() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-3 px-2">
|
||||
<div className="flex-1 border-t border-border/50" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to add separators between message rounds
|
||||
function addSeparatorsToEvents(events: ExtendedResponseStreamEvent[]): (ExtendedResponseStreamEvent | { type: "separator"; id: string })[] {
|
||||
const result: (ExtendedResponseStreamEvent | { type: "separator"; id: string })[] = [];
|
||||
let lastWasResponseDone = false;
|
||||
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i];
|
||||
|
||||
// Add separator before first event after response.done
|
||||
if (lastWasResponseDone && event.type !== "response.done") {
|
||||
result.push({ type: "separator", id: `sep-${i}` });
|
||||
lastWasResponseDone = false;
|
||||
}
|
||||
|
||||
result.push(event);
|
||||
|
||||
// Track when we see response.done
|
||||
if (event.type === "response.done" || event.type === "response.completed") {
|
||||
lastWasResponseDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Type definitions for event data structures
|
||||
interface EventDataBase {
|
||||
call_id?: string;
|
||||
@@ -64,7 +98,7 @@ interface DebugPanelProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// Helper: Extract function result from DevUI custom format
|
||||
// Helper: Extract function result from DevUI custom event
|
||||
function getFunctionResultFromEvent(event: ExtendedResponseStreamEvent): {
|
||||
call_id: string;
|
||||
output: string;
|
||||
@@ -101,9 +135,18 @@ function processEventsForDisplay(
|
||||
let accumulatedText = "";
|
||||
|
||||
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"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle response.output_item.added - NEW! Extract function call metadata
|
||||
if (event.type === "response.output_item.added") {
|
||||
const outputEvent = event as import("@/types").ResponseOutputItemAddedEvent;
|
||||
const outputEvent =
|
||||
event as import("@/types").ResponseOutputItemAddedEvent;
|
||||
const item = outputEvent.item;
|
||||
|
||||
// If it's a function call item, extract metadata
|
||||
@@ -388,15 +431,17 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
|
||||
}
|
||||
return "Function arguments...";
|
||||
|
||||
case "response.function_result.complete": {
|
||||
const resultEvent =
|
||||
event as import("@/types").ResponseFunctionResultComplete;
|
||||
const truncated = resultEvent.output.slice(0, 40);
|
||||
return `Function result: ${truncated}${
|
||||
truncated.length >= 40 ? "..." : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
case "response.output_item.added": {
|
||||
const result = getFunctionResultFromEvent(event);
|
||||
if (result) {
|
||||
const truncated = result.output.slice(0, 40);
|
||||
return `Tool result: ${truncated}${
|
||||
truncated.length >= 40 ? "..." : ""
|
||||
}`;
|
||||
}
|
||||
// Could also be a function call
|
||||
// Could be a function call
|
||||
const addedEvent =
|
||||
event as import("@/types").ResponseOutputItemAddedEvent;
|
||||
if (addedEvent.item.type === "function_call") {
|
||||
@@ -422,7 +467,8 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
|
||||
|
||||
case "response.completed":
|
||||
if ("response" in event && event.response && "usage" in event.response) {
|
||||
const completedEvent = event as import("@/types").ResponseCompletedEvent;
|
||||
const completedEvent =
|
||||
event as import("@/types").ResponseCompletedEvent;
|
||||
const usage = completedEvent.response.usage;
|
||||
if (usage) {
|
||||
return `Response complete (${usage.total_tokens} tokens)`;
|
||||
@@ -453,6 +499,8 @@ function getEventIcon(type: string) {
|
||||
case "response.function_call.delta":
|
||||
case "response.function_call_arguments.delta":
|
||||
return Wrench;
|
||||
case "response.function_result.complete":
|
||||
return CheckCircle2;
|
||||
case "response.output_item.added":
|
||||
return CheckCircle2;
|
||||
case "response.workflow_event.complete":
|
||||
@@ -479,6 +527,8 @@ function getEventColor(type: string) {
|
||||
case "response.function_call.delta":
|
||||
case "response.function_call_arguments.delta":
|
||||
return "text-blue-600 dark:text-blue-400";
|
||||
case "response.function_result.complete":
|
||||
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":
|
||||
@@ -509,6 +559,7 @@ function EventItem({ event }: EventItemProps) {
|
||||
(event.type === "response.function_call.complete" &&
|
||||
"data" in event &&
|
||||
event.data) ||
|
||||
event.type === "response.function_result.complete" ||
|
||||
(event.type === "response.output_item.added" &&
|
||||
getFunctionResultFromEvent(event) !== null) ||
|
||||
(event.type === "response.workflow_event.complete" &&
|
||||
@@ -681,6 +732,53 @@ function EventExpandedContent({
|
||||
}
|
||||
break;
|
||||
|
||||
case "response.function_result.complete": {
|
||||
const resultEvent =
|
||||
event as import("@/types").ResponseFunctionResultComplete;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span className="font-semibold text-sm">Function Result</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Call ID:
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs">
|
||||
{resultEvent.call_id}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Status:
|
||||
</span>
|
||||
<span
|
||||
className={`ml-2 px-2 py-1 rounded text-xs font-medium ${
|
||||
resultEvent.status === "completed"
|
||||
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
|
||||
: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
|
||||
}`}
|
||||
>
|
||||
{resultEvent.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Output:
|
||||
</span>
|
||||
<div className="mt-1 max-h-32 overflow-auto">
|
||||
<pre className="text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all">
|
||||
{resultEvent.output}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "response.output_item.added": {
|
||||
const result = getFunctionResultFromEvent(event);
|
||||
if (result) {
|
||||
@@ -915,7 +1013,8 @@ function EventExpandedContent({
|
||||
|
||||
case "response.completed":
|
||||
if ("response" in event && event.response) {
|
||||
const completedEvent = event as import("@/types").ResponseCompletedEvent;
|
||||
const completedEvent =
|
||||
event as import("@/types").ResponseCompletedEvent;
|
||||
const response = completedEvent.response;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -1006,11 +1105,14 @@ function EventsTab({
|
||||
// Process events to accumulate tool calls and reduce noise
|
||||
const processedEvents = processEventsForDisplay(events);
|
||||
|
||||
// Add separators between message rounds
|
||||
const eventsWithSeparators = addSeparatorsToEvents(processedEvents);
|
||||
|
||||
// Reverse events so latest appears at top
|
||||
const reversedEvents = [...processedEvents].reverse();
|
||||
const reversedEvents = [...eventsWithSeparators].reverse();
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
@@ -1030,7 +1132,7 @@ function EventsTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea ref={scrollRef}>
|
||||
<ScrollArea ref={scrollRef} className="flex-1">
|
||||
<div className="p-3">
|
||||
{processedEvents.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground text-sm py-8">
|
||||
@@ -1040,9 +1142,12 @@ function EventsTab({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{reversedEvents.map((event, index) => (
|
||||
<EventItem key={`${event.type}-${index}`} event={event} />
|
||||
))}
|
||||
{reversedEvents.map((event, index) => {
|
||||
if ('type' in event && event.type === "separator") {
|
||||
return <MessageSeparator key={(event as { type: "separator"; id: string }).id} />;
|
||||
}
|
||||
return <EventItem key={`${event.type}-${index}`} event={event as ExtendedResponseStreamEvent} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1059,18 +1164,21 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
e.type === "response.trace.complete"
|
||||
);
|
||||
|
||||
// Add separators between message rounds
|
||||
const tracesWithSeparators = addSeparatorsToEvents(traceEvents);
|
||||
|
||||
// Reverse to show latest traces at the top
|
||||
const reversedTraceEvents = [...traceEvents].reverse();
|
||||
const reversedTraceEvents = [...tracesWithSeparators].reverse();
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center gap-2 p-3 border-b">
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="font-medium">Traces</span>
|
||||
<Badge variant="outline">{traceEvents.length}</Badge>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3">
|
||||
{traceEvents.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground text-sm py-8">
|
||||
@@ -1094,9 +1202,12 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{reversedTraceEvents.map((event, index) => (
|
||||
<TraceEventItem key={index} event={event} />
|
||||
))}
|
||||
{reversedTraceEvents.map((event, index) => {
|
||||
if ('type' in event && event.type === "separator") {
|
||||
return <MessageSeparator key={(event as { type: "separator"; id: string }).id} />;
|
||||
}
|
||||
return <TraceEventItem key={index} event={event as ExtendedResponseStreamEvent} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1165,7 +1276,7 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex-1">
|
||||
<div className="text-muted-foreground flex-1 break-all">
|
||||
<span className="font-medium">{operationName}</span>
|
||||
{entityId && <span className="ml-2 text-xs">({entityId})</span>}
|
||||
</div>
|
||||
@@ -1193,7 +1304,7 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Span ID:
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs">
|
||||
<span className="ml-2 font-mono text-xs break-all">
|
||||
{data.span_id}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1203,7 +1314,7 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Trace ID:
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs">
|
||||
<span className="ml-2 font-mono text-xs break-all">
|
||||
{data.trace_id}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1213,7 +1324,7 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Parent Span:
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs">
|
||||
<span className="ml-2 font-mono text-xs break-all">
|
||||
{data.parent_span_id}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1250,7 +1361,7 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
<span className="font-medium text-muted-foreground">
|
||||
Entity:
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs">
|
||||
<span className="ml-2 font-mono text-xs break-all">
|
||||
{data.entity_id}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1338,18 +1449,21 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
toolEvents.push(result);
|
||||
});
|
||||
|
||||
// Add separators between message rounds
|
||||
const toolsWithSeparators = addSeparatorsToEvents(toolEvents);
|
||||
|
||||
// Reverse to show latest tools at the top
|
||||
const reversedToolEvents = [...toolEvents].reverse();
|
||||
const reversedToolEvents = [...toolsWithSeparators].reverse();
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center gap-2 p-3 border-b">
|
||||
<Wrench className="h-4 w-4" />
|
||||
<span className="font-medium">Tools</span>
|
||||
<Badge variant="outline">{toolEvents.length}</Badge>
|
||||
</div>
|
||||
|
||||
<ScrollArea>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3">
|
||||
{toolEvents.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground text-sm py-8">
|
||||
@@ -1358,9 +1472,12 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{reversedToolEvents.map((event, index) => (
|
||||
<ToolEventItem key={index} event={event} />
|
||||
))}
|
||||
{reversedToolEvents.map((event, index) => {
|
||||
if ('type' in event && event.type === "separator") {
|
||||
return <MessageSeparator key={(event as { type: "separator"; id: string }).id} />;
|
||||
}
|
||||
return <ToolEventItem key={index} event={event as ExtendedResponseStreamEvent} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1370,21 +1487,21 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
}
|
||||
|
||||
function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
if (!("data" in event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = event.data as EventDataBase;
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
|
||||
// Check if this is a function call event
|
||||
// Check if this is a function call or result event
|
||||
const isFunctionCall = event.type === "response.function_call.complete";
|
||||
const isFunctionResult = getFunctionResultFromEvent(event) !== null;
|
||||
const resultData = getFunctionResultFromEvent(event);
|
||||
const isFunctionResult = resultData !== null;
|
||||
|
||||
if (!isFunctionCall && !isFunctionResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For function calls: extract data field
|
||||
const callData =
|
||||
isFunctionCall && "data" in event ? (event.data as EventDataBase) : null;
|
||||
|
||||
return (
|
||||
<div className="border rounded p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -1393,9 +1510,9 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
<span className="font-medium text-sm">
|
||||
{isFunctionCall ? "Tool Call" : "Tool Result"}
|
||||
</span>
|
||||
{isFunctionCall && data.name !== undefined && (
|
||||
{isFunctionCall && callData && callData.name !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({String(data.name)})
|
||||
({String(callData.name)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1405,7 +1522,7 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
</div>
|
||||
|
||||
{/* Function Calls */}
|
||||
{isFunctionCall && (
|
||||
{isFunctionCall && callData && (
|
||||
<div className="p-2 bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Wrench className="h-3 w-3 text-blue-600 dark:text-blue-400" />
|
||||
@@ -1413,19 +1530,19 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
CALL
|
||||
</span>
|
||||
<span className="font-medium text-sm">
|
||||
{String(data.name || "unknown")}
|
||||
{String(callData.name || "unknown")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{data.arguments !== undefined && (
|
||||
{callData.arguments !== undefined && (
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground mb-1 block">
|
||||
Arguments:
|
||||
</span>
|
||||
<pre className="p-2 bg-background border rounded text-xs overflow-auto max-h-32 max-w-full break-all whitespace-pre-wrap">
|
||||
{typeof data.arguments === "string"
|
||||
? data.arguments
|
||||
: JSON.stringify(data.arguments, null, 1)}
|
||||
{typeof callData.arguments === "string"
|
||||
? callData.arguments
|
||||
: JSON.stringify(callData.arguments, null, 1)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -1433,31 +1550,35 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
)}
|
||||
|
||||
{/* Function Results */}
|
||||
{isFunctionResult && (
|
||||
{isFunctionResult && resultData && (
|
||||
<div className="p-2 bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-800 rounded">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle2 className="h-3 w-3 text-green-600 dark:text-green-400" />
|
||||
<span className="text-xs font-mono bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded">
|
||||
RESULT
|
||||
</span>
|
||||
{/* Only show status badge for non-completed states (errors/incomplete) */}
|
||||
{resultData.status !== "completed" && (
|
||||
<span className="ml-auto px-2 py-1 rounded text-xs font-medium bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200">
|
||||
{resultData.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground mb-1 block">Result:</span>
|
||||
<pre className="p-2 bg-background border rounded text-xs overflow-auto max-h-32 max-w-full break-all whitespace-pre-wrap">
|
||||
{typeof data.result === "string"
|
||||
? data.result
|
||||
: JSON.stringify(data.result, null, 1)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{data.exception !== null && data.exception !== undefined && (
|
||||
<div className="mt-2 p-2 bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 rounded">
|
||||
<span className="text-xs text-red-600 dark:text-red-400">
|
||||
Error: {String(data.exception)}
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Call ID:</span>
|
||||
<span className="font-mono text-xs break-all">
|
||||
{resultData.call_id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">Output:</span>
|
||||
<pre className="p-2 bg-background border rounded text-xs overflow-auto max-h-32 break-all whitespace-pre-wrap">
|
||||
{resultData.output}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1470,9 +1591,9 @@ export function DebugPanel({
|
||||
onClose,
|
||||
}: DebugPanelProps) {
|
||||
return (
|
||||
<div className=" overflow-auto h-[calc(100vh-3.7rem)] border-l">
|
||||
<Tabs defaultValue="events" className="h-full flex flex-col">
|
||||
<div className="px-3 pt-3 flex items-center gap-2">
|
||||
<div className="flex-1 border-l flex flex-col min-h-0">
|
||||
<Tabs defaultValue="events" className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-3 pt-3 flex items-center gap-2 flex-shrink-0">
|
||||
<TabsList className="flex-1">
|
||||
<TabsTrigger value="events" className="flex-1">
|
||||
Events
|
||||
@@ -1497,15 +1618,15 @@ export function DebugPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="events" className="flex-1 mt-0">
|
||||
<TabsContent value="events" className="flex-1 mt-0 overflow-hidden">
|
||||
<EventsTab events={events} isStreaming={isStreaming} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="traces" className="flex-1 mt-0">
|
||||
<TabsContent value="traces" className="flex-1 mt-0 overflow-hidden">
|
||||
<TracesTab events={events} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tools" className="flex-1 mt-0">
|
||||
<TabsContent value="tools" className="flex-1 mt-0 overflow-hidden">
|
||||
<ToolsTab events={events} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* DeploymentModal - Shows Azure deployment instructions and Docker templates
|
||||
* Features: Docker setup files, Azure Container Apps deployment guide
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Rocket,
|
||||
Container,
|
||||
Cloud,
|
||||
Copy,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
|
||||
interface DeploymentModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
type Tab = "docker" | "azure";
|
||||
|
||||
export function DeploymentModal({
|
||||
open,
|
||||
onClose,
|
||||
agentName = "Agent",
|
||||
}: DeploymentModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("docker");
|
||||
const [copiedTemplate, setCopiedTemplate] = useState<string | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = async (template: string, templateName: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(template);
|
||||
setCopiedTemplate(templateName);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout with cleanup
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setCopiedTemplate(null);
|
||||
timeoutRef.current = null;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy template:", err);
|
||||
// Reset state on error
|
||||
setCopiedTemplate(null);
|
||||
}
|
||||
};
|
||||
|
||||
const dockerfileTemplate = `# Dockerfile for ${agentName}
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy agent/workflow directories
|
||||
COPY . .
|
||||
|
||||
# Expose DevUI default port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run DevUI server
|
||||
CMD ["devui", ".", "--port", "8080", "--host", "0.0.0.0"]
|
||||
`;
|
||||
|
||||
const dockerComposeTemplate = `# docker-compose.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
${agentName.toLowerCase().replace(/\s+/g, "-")}:
|
||||
build: .
|
||||
environment:
|
||||
# OpenAI
|
||||
- OPENAI_API_KEY=\${OPENAI_API_KEY}
|
||||
- OPENAI_CHAT_MODEL_ID=\${OPENAI_CHAT_MODEL_ID:-gpt-4o-mini}
|
||||
# Or Azure OpenAI
|
||||
- AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY}
|
||||
- AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT}
|
||||
- AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=\${AZURE_OPENAI_CHAT_DEPLOYMENT_NAME}
|
||||
# Optional: Enable tracing
|
||||
- ENABLE_OTEL=\${ENABLE_OTEL:-false}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
restart: unless-stopped
|
||||
`;
|
||||
|
||||
const requirementsTemplate = `# requirements.txt
|
||||
agent-framework-devui>=0.1.0
|
||||
agent-framework>=0.1.0
|
||||
# Chat clients (install what you need)
|
||||
openai>=1.0.0
|
||||
# azure-openai
|
||||
# anthropic
|
||||
`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="w-[800px] max-w-[90vw]">
|
||||
<DialogClose onClose={onClose} />
|
||||
<DialogHeader className="p-6 pb-2">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Rocket className="h-5 w-5" />
|
||||
Deploy {agentName}
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground pt-1">
|
||||
Get started with containerizing your agent for deployment.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b px-6">
|
||||
<button
|
||||
onClick={() => setActiveTab("docker")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
activeTab === "docker"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Container className="h-4 w-4 mr-2 inline" />
|
||||
Docker
|
||||
{activeTab === "docker" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("azure")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
activeTab === "azure"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Cloud className="h-4 w-4 mr-2 inline" />
|
||||
Azure
|
||||
{activeTab === "azure" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="px-6 pb-6 min-h-[400px]">
|
||||
<ScrollArea className="h-[500px]">
|
||||
<div className="pr-4">
|
||||
{activeTab === "docker" && (
|
||||
<div className="space-y-4 pt-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">
|
||||
Containerize with Docker
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Package your agent as a Docker container for consistent
|
||||
deployment anywhere.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dockerfile */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Dockerfile</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
handleCopy(dockerfileTemplate, "dockerfile")
|
||||
}
|
||||
>
|
||||
{copiedTemplate === "dockerfile" ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
|
||||
{dockerfileTemplate}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* docker-compose.yml */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
docker-compose.yml
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
handleCopy(dockerComposeTemplate, "compose")
|
||||
}
|
||||
>
|
||||
{copiedTemplate === "compose" ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
|
||||
{dockerComposeTemplate}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* requirements.txt */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
requirements.txt
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
handleCopy(requirementsTemplate, "requirements")
|
||||
}
|
||||
>
|
||||
{copiedTemplate === "requirements" ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
|
||||
{requirementsTemplate}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Quick Start */}
|
||||
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3">
|
||||
<h4 className="text-sm font-semibold mb-2">Quick Start</h4>
|
||||
<ol className="text-xs space-y-1 list-decimal list-inside text-muted-foreground">
|
||||
<li>Save the files above to your project directory</li>
|
||||
<li>
|
||||
Build:{" "}
|
||||
<code className="bg-muted px-1 rounded">
|
||||
docker build -t {agentName.toLowerCase()}-agent .
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Run:{" "}
|
||||
<code className="bg-muted px-1 rounded">
|
||||
docker-compose up
|
||||
</code>
|
||||
</li>
|
||||
<li>Your agent is now running in a container!</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Production Warnings */}
|
||||
<div className="bg-amber-50 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 rounded-md p-3">
|
||||
<h4 className="text-sm font-semibold mb-2 text-amber-900 dark:text-amber-100">
|
||||
⚠️ Production Considerations
|
||||
</h4>
|
||||
<ul className="text-xs space-y-1 list-disc list-inside text-amber-800 dark:text-amber-200">
|
||||
<li>
|
||||
<strong>In-memory state:</strong> Conversations are lost
|
||||
when container restarts
|
||||
</li>
|
||||
<li>
|
||||
<strong>No authentication:</strong> Add reverse proxy
|
||||
(nginx, Caddy) with auth for production
|
||||
</li>
|
||||
<li>
|
||||
<strong>Security:</strong> Use Azure Key Vault for
|
||||
secrets management
|
||||
</li>
|
||||
<li>
|
||||
<strong>Scaling:</strong> Single instance only due to
|
||||
in-memory conversation store
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Deployment Checklist */}
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="font-semibold text-sm mb-3">
|
||||
Pre-Deployment Checklist
|
||||
</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
Set environment variables (API keys, secrets)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
Test agent locally in container
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
Configure logging and monitoring
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
Set up error handling and retries
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "azure" && (
|
||||
<div className="space-y-4 pt-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">
|
||||
Deploy to Azure Container Apps
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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>
|
||||
<li>Docker installed and running</li>
|
||||
<li>
|
||||
Logged in to Azure:{" "}
|
||||
<code className="bg-muted px-1 rounded">az login</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Step-by-step */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Deployment Steps</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Step 1 */}
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
|
||||
1
|
||||
</div>
|
||||
<h5 className="font-medium text-sm">
|
||||
Create Azure Container Registry
|
||||
</h5>
|
||||
</div>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
|
||||
{`# Create resource group
|
||||
az group create --name myResourceGroup --location eastus
|
||||
|
||||
# Create container registry
|
||||
az acr create --resource-group myResourceGroup \\
|
||||
--name myregistry --sku Basic`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
|
||||
2
|
||||
</div>
|
||||
<h5 className="font-medium text-sm">
|
||||
Build and Push Docker Image
|
||||
</h5>
|
||||
</div>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
|
||||
{`# Build and push in one command
|
||||
az acr build --registry myregistry \\
|
||||
--image ${agentName.toLowerCase()}-agent:latest .`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
|
||||
3
|
||||
</div>
|
||||
<h5 className="font-medium text-sm">
|
||||
Create Container Apps Environment
|
||||
</h5>
|
||||
</div>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
|
||||
{`az containerapp env create --name myEnvironment \\
|
||||
--resource-group myResourceGroup \\
|
||||
--location eastus`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Step 4 */}
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
|
||||
4
|
||||
</div>
|
||||
<h5 className="font-medium text-sm">
|
||||
Deploy Container App
|
||||
</h5>
|
||||
</div>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
|
||||
{`az containerapp create --name ${agentName.toLowerCase()}-app \\
|
||||
--resource-group myResourceGroup \\
|
||||
--environment myEnvironment \\
|
||||
--image myregistry.azurecr.io/${agentName.toLowerCase()}-agent:latest \\
|
||||
--target-port 8080 \\
|
||||
--ingress 'external' \\
|
||||
--registry-server myregistry.azurecr.io \\
|
||||
--env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL_ID=gpt-4o-mini`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Step 5 */}
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
|
||||
5
|
||||
</div>
|
||||
<h5 className="font-medium text-sm">
|
||||
Get Application URL
|
||||
</h5>
|
||||
</div>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
|
||||
{`az containerapp show --name ${agentName.toLowerCase()}-app \\
|
||||
--resource-group myResourceGroup \\
|
||||
--query properties.configuration.ingress.fqdn`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Learn More */}
|
||||
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3">
|
||||
<h4 className="text-sm font-semibold mb-2">Learn More</h4>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Explore Azure Container Apps documentation for advanced
|
||||
features like scaling, monitoring, and CI/CD integration.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href="https://learn.microsoft.com/azure/container-apps/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
View Azure Container Apps Documentation
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* EntitySelector - High-quality dropdown for selecting agents/workflows
|
||||
* Features: Type indicators, tool counts, keyboard navigation, search
|
||||
* EntitySelector - Dropdown for selecting agents/workflows
|
||||
* Features: Loading states, descriptions, lazy loading indicators
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
@@ -13,9 +13,8 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ChevronDown, Bot, Workflow, FolderOpen, Database, Globe, X, Plus } from "lucide-react";
|
||||
import { ChevronDown, Bot, Workflow, Plus, Loader2 } from "lucide-react";
|
||||
import type { AgentInfo, WorkflowInfo } from "@/types";
|
||||
|
||||
interface EntitySelectorProps {
|
||||
@@ -23,7 +22,6 @@ interface EntitySelectorProps {
|
||||
workflows: WorkflowInfo[];
|
||||
selectedItem?: AgentInfo | WorkflowInfo;
|
||||
onSelect: (item: AgentInfo | WorkflowInfo) => void;
|
||||
onRemove?: (entityId: string) => void;
|
||||
onBrowseGallery?: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
@@ -32,30 +30,11 @@ const getTypeIcon = (type: "agent" | "workflow") => {
|
||||
return type === "workflow" ? Workflow : Bot;
|
||||
};
|
||||
|
||||
const getSourceIcon = (source: "directory" | "in_memory" | "remote_gallery") => {
|
||||
switch (source) {
|
||||
case "directory": return FolderOpen;
|
||||
case "in_memory": return Database;
|
||||
case "remote_gallery": return Globe;
|
||||
default: return Database;
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceLabel = (source: "directory" | "in_memory" | "remote_gallery") => {
|
||||
switch (source) {
|
||||
case "directory": return "Local";
|
||||
case "in_memory": return "Memory";
|
||||
case "remote_gallery": return "Gallery";
|
||||
default: return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
export function EntitySelector({
|
||||
agents,
|
||||
workflows,
|
||||
selectedItem,
|
||||
onSelect,
|
||||
onRemove,
|
||||
onBrowseGallery,
|
||||
isLoading = false,
|
||||
}: EntitySelectorProps) {
|
||||
@@ -72,11 +51,7 @@ export function EntitySelector({
|
||||
|
||||
const TypeIcon = selectedItem ? getTypeIcon(selectedItem.type) : Bot;
|
||||
const displayName = selectedItem?.name || selectedItem?.id || "Select Agent or Workflow";
|
||||
const itemCount =
|
||||
selectedItem?.type === "workflow"
|
||||
? (selectedItem as WorkflowInfo).executors?.length || 0
|
||||
: (selectedItem as AgentInfo)?.tools?.length || 0;
|
||||
const itemLabel = selectedItem?.type === "workflow" ? "executors" : "tools";
|
||||
const isLoaded = selectedItem?.metadata?.lazy_loaded !== false;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
@@ -96,10 +71,8 @@ export function EntitySelector({
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<TypeIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{displayName}</span>
|
||||
{selectedItem && (
|
||||
<Badge variant="secondary" className="ml-auto flex-shrink-0">
|
||||
{itemCount} {itemLabel}
|
||||
</Badge>
|
||||
{selectedItem && !isLoaded && (
|
||||
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin ml-auto flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
@@ -116,53 +89,29 @@ export function EntitySelector({
|
||||
Agents ({agents.length})
|
||||
</DropdownMenuLabel>
|
||||
{agents.map((agent) => {
|
||||
const SourceIcon = getSourceIcon(agent.source);
|
||||
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">
|
||||
<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">
|
||||
<div className="truncate font-medium">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-medium block">
|
||||
{agent.name || agent.id}
|
||||
</div>
|
||||
{agent.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
</span>
|
||||
{isAgentLoaded && agent.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{agent.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<SourceIcon className="h-3 w-3 opacity-60" />
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getSourceLabel(agent.source)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs ml-1">
|
||||
{agent.tools.length}
|
||||
</Badge>
|
||||
|
||||
{/* Remove button for gallery entities */}
|
||||
{agent.source === 'remote_gallery' && onRemove && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 ml-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(agent.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
@@ -178,53 +127,29 @@ export function EntitySelector({
|
||||
Workflows ({workflows.length})
|
||||
</DropdownMenuLabel>
|
||||
{workflows.map((workflow) => {
|
||||
const SourceIcon = getSourceIcon(workflow.source);
|
||||
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">
|
||||
<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">
|
||||
<div className="truncate font-medium">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-medium block">
|
||||
{workflow.name || workflow.id}
|
||||
</div>
|
||||
{workflow.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
</span>
|
||||
{isWorkflowLoaded && workflow.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{workflow.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<SourceIcon className="h-3 w-3 opacity-60" />
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getSourceLabel(workflow.source)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs ml-1">
|
||||
{workflow.executors.length}
|
||||
</Badge>
|
||||
|
||||
{/* Remove button for gallery entities */}
|
||||
{workflow.source === 'remote_gallery' && onRemove && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 ml-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(workflow.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -7,3 +7,4 @@ export { EntitySelector } from "./entity-selector";
|
||||
export { DebugPanel } from "./debug-panel";
|
||||
export { SettingsModal } from "./settings-modal";
|
||||
export { AboutModal } from "./about-modal";
|
||||
export { DeploymentModal } from "./deployment-modal";
|
||||
|
||||
@@ -24,7 +24,7 @@ interface SettingsModalProps {
|
||||
type Tab = "about" | "settings";
|
||||
|
||||
export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: SettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("about");
|
||||
const [activeTab, setActiveTab] = useState<Tab>("settings");
|
||||
|
||||
// Get current backend URL from localStorage or default
|
||||
const defaultUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8080";
|
||||
@@ -73,19 +73,6 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b px-6">
|
||||
<button
|
||||
onClick={() => setActiveTab("about")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
activeTab === "about"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
About
|
||||
{activeTab === "about" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("settings")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
@@ -99,35 +86,23 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("about")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
activeTab === "about"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
About
|
||||
{activeTab === "about" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="px-6 pb-6 min-h-[240px]">
|
||||
{activeTab === "about" && (
|
||||
<div className="space-y-4 pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
DevUI is a sample app for getting started with Agent Framework.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://github.com/microsoft/agent-framework",
|
||||
"_blank"
|
||||
)
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
Learn More about Agent Framework
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "settings" && (
|
||||
<div className="space-y-6 pt-4">
|
||||
{/* Backend URL Setting */}
|
||||
@@ -188,6 +163,31 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "about" && (
|
||||
<div className="space-y-4 pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
DevUI is a sample app for getting started with Agent Framework.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://github.com/microsoft/agent-framework",
|
||||
"_blank"
|
||||
)
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
Learn More about Agent Framework
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Alert component - Simple alert/callout component
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -0,0 +1,565 @@
|
||||
/**
|
||||
* Lightweight Markdown Renderer
|
||||
*
|
||||
* A minimal markdown renderer with zero dependencies for rendering LLM responses.
|
||||
* Handles the most common markdown patterns without bloating bundle size.
|
||||
*
|
||||
* Supported syntax:
|
||||
* - **bold** and __bold__
|
||||
* - *italic* and _italic_
|
||||
* - `inline code`
|
||||
* - ```code blocks``` (with copy button on hover)
|
||||
* - [links](url)
|
||||
* - **[bold links](url)** and *[italic links](url)*
|
||||
* - # Headers (H1-H6)
|
||||
* - Lists (ordered and unordered)
|
||||
* - > Blockquotes
|
||||
* - Tables (| col1 | col2 |)
|
||||
* - Horizontal rules (---)
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Code block component with copy button
|
||||
*/
|
||||
function CodeBlock({ code, language }: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout and store reference
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
timeoutRef.current = null;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy code:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<pre className="my-3 p-3 bg-foreground/5 dark:bg-foreground/10 rounded overflow-x-auto border border-foreground/10">
|
||||
<code className="text-xs font-mono block whitespace-pre-wrap break-words">
|
||||
{language && (
|
||||
<span className="opacity-60 text-[10px] mb-1 block uppercase">
|
||||
{language}
|
||||
</span>
|
||||
)}
|
||||
{code}
|
||||
</code>
|
||||
</pre>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 p-1.5 rounded-md border shadow-sm
|
||||
bg-background hover:bg-accent
|
||||
text-muted-foreground hover:text-foreground
|
||||
transition-all duration-200
|
||||
opacity-0 group-hover:opacity-100"
|
||||
title={copied ? "Copied!" : "Copy code"}
|
||||
>
|
||||
{copied ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-green-600 dark:text-green-400"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse markdown text into React elements
|
||||
*/
|
||||
export function MarkdownRenderer({
|
||||
content,
|
||||
className = "",
|
||||
}: MarkdownRendererProps) {
|
||||
const lines = content.split("\n");
|
||||
const elements: React.ReactNode[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Code blocks (multiline)
|
||||
if (line.trim().startsWith("```")) {
|
||||
const codeLines: string[] = [];
|
||||
const langMatch = line.trim().match(/^```(\w+)?/);
|
||||
const language = langMatch?.[1] || "";
|
||||
i++; // Skip opening ```
|
||||
|
||||
while (i < lines.length && !lines[i].trim().startsWith("```")) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
i++; // Skip closing ```
|
||||
|
||||
elements.push(
|
||||
<CodeBlock
|
||||
key={elements.length}
|
||||
code={codeLines.join("\n")}
|
||||
language={language}
|
||||
/>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headers
|
||||
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headerMatch) {
|
||||
const level = headerMatch[1].length;
|
||||
const text = headerMatch[2];
|
||||
const sizes = [
|
||||
"text-2xl",
|
||||
"text-xl",
|
||||
"text-lg",
|
||||
"text-base",
|
||||
"text-sm",
|
||||
"text-sm",
|
||||
];
|
||||
const className = `${
|
||||
sizes[level - 1]
|
||||
} font-semibold mt-4 mb-2 first:mt-0 break-words`;
|
||||
|
||||
// Render appropriate header level
|
||||
const header =
|
||||
level === 1 ? (
|
||||
<h1 key={elements.length} className={className}>
|
||||
{parseInlineMarkdown(text)}
|
||||
</h1>
|
||||
) : level === 2 ? (
|
||||
<h2 key={elements.length} className={className}>
|
||||
{parseInlineMarkdown(text)}
|
||||
</h2>
|
||||
) : level === 3 ? (
|
||||
<h3 key={elements.length} className={className}>
|
||||
{parseInlineMarkdown(text)}
|
||||
</h3>
|
||||
) : level === 4 ? (
|
||||
<h4 key={elements.length} className={className}>
|
||||
{parseInlineMarkdown(text)}
|
||||
</h4>
|
||||
) : level === 5 ? (
|
||||
<h5 key={elements.length} className={className}>
|
||||
{parseInlineMarkdown(text)}
|
||||
</h5>
|
||||
) : (
|
||||
<h6 key={elements.length} className={className}>
|
||||
{parseInlineMarkdown(text)}
|
||||
</h6>
|
||||
);
|
||||
|
||||
elements.push(header);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered lists
|
||||
if (line.match(/^[\s]*[-*+]\s+/)) {
|
||||
const listItems: string[] = [];
|
||||
|
||||
while (i < lines.length && lines[i].match(/^[\s]*[-*+]\s+/)) {
|
||||
const itemText = lines[i].replace(/^[\s]*[-*+]\s+/, "");
|
||||
listItems.push(itemText);
|
||||
i++;
|
||||
}
|
||||
|
||||
elements.push(
|
||||
<ul
|
||||
key={elements.length}
|
||||
className="my-2 ml-4 list-disc space-y-1 break-words"
|
||||
>
|
||||
{listItems.map((item, idx) => (
|
||||
<li key={idx} className="text-sm break-words">
|
||||
{parseInlineMarkdown(item)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered lists
|
||||
if (line.match(/^[\s]*\d+\.\s+/)) {
|
||||
const listItems: string[] = [];
|
||||
|
||||
while (i < lines.length && lines[i].match(/^[\s]*\d+\.\s+/)) {
|
||||
const itemText = lines[i].replace(/^[\s]*\d+\.\s+/, "");
|
||||
listItems.push(itemText);
|
||||
i++;
|
||||
}
|
||||
|
||||
elements.push(
|
||||
<ol
|
||||
key={elements.length}
|
||||
className="my-2 ml-4 list-decimal space-y-1 break-words"
|
||||
>
|
||||
{listItems.map((item, idx) => (
|
||||
<li key={idx} className="text-sm break-words">
|
||||
{parseInlineMarkdown(item)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tables
|
||||
if (line.trim().startsWith("|") && line.trim().endsWith("|")) {
|
||||
const tableLines: string[] = [];
|
||||
|
||||
// Collect all table lines
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim().startsWith("|") &&
|
||||
lines[i].trim().endsWith("|")
|
||||
) {
|
||||
tableLines.push(lines[i].trim());
|
||||
i++;
|
||||
}
|
||||
|
||||
// Parse table (need at least 2 lines: header + separator)
|
||||
if (tableLines.length >= 2) {
|
||||
const headerCells = tableLines[0]
|
||||
.split("|")
|
||||
.slice(1, -1)
|
||||
.map((cell) => cell.trim());
|
||||
|
||||
// Check if second line is a separator (contains dashes)
|
||||
const isSeparator = tableLines[1].match(/^\|[\s\-:|]+\|$/);
|
||||
|
||||
if (isSeparator) {
|
||||
const bodyRows = tableLines.slice(2).map((row) =>
|
||||
row
|
||||
.split("|")
|
||||
.slice(1, -1)
|
||||
.map((cell) => cell.trim())
|
||||
);
|
||||
|
||||
elements.push(
|
||||
<div key={elements.length} className="my-3 overflow-x-auto">
|
||||
<table className="min-w-full border border-foreground/10 text-sm">
|
||||
<thead className="bg-foreground/5">
|
||||
<tr>
|
||||
{headerCells.map((header, idx) => (
|
||||
<th
|
||||
key={idx}
|
||||
className="border-b border-foreground/10 px-3 py-2 text-left font-semibold break-words"
|
||||
>
|
||||
{parseInlineMarkdown(header)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bodyRows.map((row, rowIdx) => (
|
||||
<tr
|
||||
key={rowIdx}
|
||||
className="border-b border-foreground/5 last:border-b-0"
|
||||
>
|
||||
{row.map((cell, cellIdx) => (
|
||||
<td
|
||||
key={cellIdx}
|
||||
className="px-3 py-2 border-r border-foreground/5 last:border-r-0 break-words"
|
||||
>
|
||||
{parseInlineMarkdown(cell)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Not a valid table, render as regular paragraphs
|
||||
for (const tableLine of tableLines) {
|
||||
elements.push(
|
||||
<p key={elements.length} className="my-1">
|
||||
{parseInlineMarkdown(tableLine)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquotes
|
||||
if (line.trim().startsWith(">")) {
|
||||
const quoteLines: string[] = [];
|
||||
|
||||
while (i < lines.length && lines[i].trim().startsWith(">")) {
|
||||
quoteLines.push(lines[i].replace(/^>\s?/, ""));
|
||||
i++;
|
||||
}
|
||||
|
||||
elements.push(
|
||||
<blockquote
|
||||
key={elements.length}
|
||||
className="my-2 pl-4 border-l-4 border-current/30 opacity-80 italic break-words"
|
||||
>
|
||||
{quoteLines.map((quoteLine, idx) => (
|
||||
<div key={idx} className="break-words">
|
||||
{parseInlineMarkdown(quoteLine)}
|
||||
</div>
|
||||
))}
|
||||
</blockquote>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (line.match(/^[\s]*[-*_]{3,}[\s]*$/)) {
|
||||
elements.push(
|
||||
<hr key={elements.length} className="my-4 border-t border-border" />
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (line.trim() === "") {
|
||||
elements.push(<div key={elements.length} className="h-2" />);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
elements.push(
|
||||
<p key={elements.length} className="my-1 break-words">
|
||||
{parseInlineMarkdown(line)}
|
||||
</p>
|
||||
);
|
||||
i++;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`markdown-content break-words ${className}`}>
|
||||
{elements}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse inline markdown patterns (bold, italic, code, links)
|
||||
*/
|
||||
function parseInlineMarkdown(text: string): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let remaining = text;
|
||||
let key = 0;
|
||||
|
||||
// Pattern priority: code > bold > italic > links
|
||||
// This prevents conflicts between overlapping patterns
|
||||
|
||||
while (remaining.length > 0) {
|
||||
// Inline code (highest priority to avoid parsing inside code)
|
||||
const codeMatch = remaining.match(/`([^`]+)`/);
|
||||
if (codeMatch && codeMatch.index !== undefined) {
|
||||
// Add text before code
|
||||
if (codeMatch.index > 0) {
|
||||
parts.push(
|
||||
<span key={key++}>
|
||||
{parseBoldItalicLinks(remaining.slice(0, codeMatch.index))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Add code
|
||||
parts.push(
|
||||
<code
|
||||
key={key++}
|
||||
className="px-1.5 py-0.5 bg-foreground/10 rounded text-xs font-mono border border-foreground/20"
|
||||
>
|
||||
{codeMatch[1]}
|
||||
</code>
|
||||
);
|
||||
|
||||
remaining = remaining.slice(codeMatch.index + codeMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// No more special patterns, parse remaining text for bold/italic/links
|
||||
parts.push(<span key={key++}>{parseBoldItalicLinks(remaining)}</span>);
|
||||
break;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse bold, italic, and links (after code has been extracted)
|
||||
*/
|
||||
function parseBoldItalicLinks(text: string): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let remaining = text;
|
||||
let key = 0;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
// Try to match patterns in order
|
||||
// IMPORTANT: Handle **[link](url)** pattern first (bold markers around link)
|
||||
const patterns = [
|
||||
{ regex: /\*\*\[([^\]]+)\]\(([^)]+)\)\*\*/, component: "strong-link" }, // **[text](url)**
|
||||
{ regex: /__\[([^\]]+)\]\(([^)]+)\)__/, component: "strong-link" }, // __[text](url)__
|
||||
{ regex: /\*\[([^\]]+)\]\(([^)]+)\)\*/, component: "em-link" }, // *[text](url)*
|
||||
{ regex: /_\[([^\]]+)\]\(([^)]+)\)_/, component: "em-link" }, // _[text](url)_
|
||||
{ regex: /\[([^\]]+)\]\(([^)]+)\)/, component: "link" }, // [text](url)
|
||||
{ regex: /\*\*(.+?)\*\*/, component: "strong" }, // **bold**
|
||||
{ regex: /__(.+?)__/, component: "strong" }, // __bold__
|
||||
{ regex: /\*(.+?)\*/, component: "em" }, // *italic*
|
||||
{ regex: /_(.+?)_/, component: "em" }, // _italic_
|
||||
];
|
||||
|
||||
let matched = false;
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = remaining.match(pattern.regex);
|
||||
|
||||
if (match && match.index !== undefined) {
|
||||
// Add text before match
|
||||
if (match.index > 0) {
|
||||
parts.push(remaining.slice(0, match.index));
|
||||
}
|
||||
|
||||
// Add matched element
|
||||
if (pattern.component === "strong") {
|
||||
parts.push(
|
||||
<strong key={key++} className="font-semibold">
|
||||
{match[1]}
|
||||
</strong>
|
||||
);
|
||||
} else if (pattern.component === "em") {
|
||||
parts.push(
|
||||
<em key={key++} className="italic">
|
||||
{match[1]}
|
||||
</em>
|
||||
);
|
||||
} else if (pattern.component === "strong-link") {
|
||||
// **[text](url)** - Bold link
|
||||
const linkText = match[1];
|
||||
const linkUrl = match[2];
|
||||
const formattedLinkText = parseBoldItalicLinks(linkText);
|
||||
|
||||
parts.push(
|
||||
<strong key={key++} className="font-semibold">
|
||||
<a
|
||||
href={linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline break-words"
|
||||
>
|
||||
{formattedLinkText}
|
||||
</a>
|
||||
</strong>
|
||||
);
|
||||
} else if (pattern.component === "em-link") {
|
||||
// *[text](url)* - Italic link
|
||||
const linkText = match[1];
|
||||
const linkUrl = match[2];
|
||||
const formattedLinkText = parseBoldItalicLinks(linkText);
|
||||
|
||||
parts.push(
|
||||
<em key={key++} className="italic">
|
||||
<a
|
||||
href={linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline break-words"
|
||||
>
|
||||
{formattedLinkText}
|
||||
</a>
|
||||
</em>
|
||||
);
|
||||
} else if (pattern.component === "link") {
|
||||
// [text](url) - Regular link
|
||||
const linkText = match[1];
|
||||
const linkUrl = match[2];
|
||||
const formattedLinkText = parseBoldItalicLinks(linkText);
|
||||
|
||||
parts.push(
|
||||
<a
|
||||
key={key++}
|
||||
href={linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline break-words"
|
||||
>
|
||||
{formattedLinkText}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
remaining = remaining.slice(match.index + match[0].length);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// No pattern matched, add remaining text and exit
|
||||
if (!matched) {
|
||||
if (remaining.length > 0) {
|
||||
parts.push(remaining);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
@@ -26,7 +26,6 @@ interface BackendEntityInfo {
|
||||
tools?: (string | Record<string, unknown>)[];
|
||||
metadata: Record<string, unknown>;
|
||||
source?: string;
|
||||
original_url?: string;
|
||||
// Agent-specific fields (present when type === "agent")
|
||||
instructions?: string;
|
||||
model?: string;
|
||||
@@ -143,6 +142,7 @@ class ApiClient {
|
||||
typeof entity.metadata?.module_path === "string"
|
||||
? entity.metadata.module_path
|
||||
: undefined,
|
||||
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
|
||||
// Agent-specific fields
|
||||
instructions: entity.instructions,
|
||||
model: entity.model,
|
||||
@@ -168,6 +168,7 @@ class ApiClient {
|
||||
typeof entity.metadata?.module_path === "string"
|
||||
? entity.metadata.module_path
|
||||
: undefined,
|
||||
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
|
||||
input_schema:
|
||||
(entity.input_schema as unknown as import("@/types").JSONSchema) || {
|
||||
type: "string",
|
||||
@@ -504,31 +505,6 @@ class ApiClient {
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// Add entity from URL
|
||||
async addEntity(url: string, metadata?: Record<string, unknown>): Promise<BackendEntityInfo> {
|
||||
const response = await this.request<{ success: boolean; entity: BackendEntityInfo }>("/v1/entities/add", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url, metadata }),
|
||||
});
|
||||
|
||||
if (!response.success || !response.entity) {
|
||||
throw new Error("Failed to add entity");
|
||||
}
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
// Remove entity by ID
|
||||
async removeEntity(entityId: string): Promise<void> {
|
||||
const response = await this.request<{ success: boolean }>(`/v1/entities/${entityId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error("Failed to remove entity");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* DevUI Unified Store - Single source of truth for all app state
|
||||
* Organized into logical slices: entity, conversation, UI, gallery, modals
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { devtools, persist } from "zustand/middleware";
|
||||
import type {
|
||||
AgentInfo,
|
||||
WorkflowInfo,
|
||||
ExtendedResponseStreamEvent,
|
||||
Conversation,
|
||||
PendingApproval,
|
||||
} from "@/types";
|
||||
import type { ConversationItem } from "@/types/openai";
|
||||
import type { AttachmentItem } from "@/components/ui/attachment-gallery";
|
||||
|
||||
// ========================================
|
||||
// State Interface
|
||||
// ========================================
|
||||
|
||||
interface DevUIState {
|
||||
// Entity Management Slice
|
||||
agents: AgentInfo[];
|
||||
workflows: WorkflowInfo[];
|
||||
selectedAgent: AgentInfo | WorkflowInfo | undefined;
|
||||
isLoadingEntities: boolean;
|
||||
entityError: string | null;
|
||||
|
||||
// Conversation Slice (per-agent state)
|
||||
currentConversation: Conversation | undefined;
|
||||
availableConversations: Conversation[];
|
||||
chatItems: ConversationItem[];
|
||||
isStreaming: boolean;
|
||||
isSubmitting: boolean;
|
||||
loadingConversations: boolean;
|
||||
inputValue: string;
|
||||
attachments: AttachmentItem[];
|
||||
conversationUsage: {
|
||||
total_tokens: number;
|
||||
message_count: number;
|
||||
};
|
||||
pendingApprovals: PendingApproval[];
|
||||
|
||||
// UI Slice
|
||||
showDebugPanel: boolean;
|
||||
debugPanelWidth: number;
|
||||
debugEvents: ExtendedResponseStreamEvent[];
|
||||
isResizing: boolean;
|
||||
|
||||
// Modal Slice
|
||||
showAboutModal: boolean;
|
||||
showGallery: boolean;
|
||||
showDeployModal: boolean;
|
||||
showEntityNotFoundToast: boolean;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Actions Interface
|
||||
// ========================================
|
||||
|
||||
interface DevUIActions {
|
||||
// Entity Actions
|
||||
setAgents: (agents: AgentInfo[]) => void;
|
||||
setWorkflows: (workflows: WorkflowInfo[]) => void;
|
||||
setSelectedAgent: (agent: AgentInfo | WorkflowInfo | undefined) => void;
|
||||
addAgent: (agent: AgentInfo) => void;
|
||||
addWorkflow: (workflow: WorkflowInfo) => void;
|
||||
updateAgent: (agent: AgentInfo) => void;
|
||||
updateWorkflow: (workflow: WorkflowInfo) => void;
|
||||
removeEntity: (entityId: string) => void;
|
||||
setEntityError: (error: string | null) => void;
|
||||
setIsLoadingEntities: (loading: boolean) => void;
|
||||
|
||||
// Conversation Actions
|
||||
setCurrentConversation: (conv: Conversation | undefined) => void;
|
||||
setAvailableConversations: (convs: Conversation[]) => void;
|
||||
setChatItems: (items: ConversationItem[]) => void;
|
||||
setIsStreaming: (streaming: boolean) => void;
|
||||
setIsSubmitting: (submitting: boolean) => void;
|
||||
setLoadingConversations: (loading: boolean) => void;
|
||||
setInputValue: (value: string) => void;
|
||||
setAttachments: (files: AttachmentItem[]) => void;
|
||||
updateConversationUsage: (tokens: number) => void;
|
||||
setPendingApprovals: (approvals: PendingApproval[]) => void;
|
||||
|
||||
// UI Actions
|
||||
setShowDebugPanel: (show: boolean) => void;
|
||||
setDebugPanelWidth: (width: number) => void;
|
||||
addDebugEvent: (event: ExtendedResponseStreamEvent) => void;
|
||||
clearDebugEvents: () => void;
|
||||
setIsResizing: (resizing: boolean) => void;
|
||||
|
||||
// Modal Actions
|
||||
setShowAboutModal: (show: boolean) => void;
|
||||
setShowGallery: (show: boolean) => void;
|
||||
setShowDeployModal: (show: boolean) => void;
|
||||
setShowEntityNotFoundToast: (show: boolean) => void;
|
||||
|
||||
// Combined Actions (handle multiple state updates + side effects)
|
||||
selectEntity: (entity: AgentInfo | WorkflowInfo) => void;
|
||||
}
|
||||
|
||||
type DevUIStore = DevUIState & DevUIActions;
|
||||
|
||||
// ========================================
|
||||
// Store Implementation
|
||||
// ========================================
|
||||
|
||||
export const useDevUIStore = create<DevUIStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
// ========================================
|
||||
// Initial State
|
||||
// ========================================
|
||||
|
||||
// Entity State
|
||||
agents: [],
|
||||
workflows: [],
|
||||
selectedAgent: undefined,
|
||||
isLoadingEntities: true,
|
||||
entityError: null,
|
||||
|
||||
// Conversation State
|
||||
currentConversation: undefined,
|
||||
availableConversations: [],
|
||||
chatItems: [],
|
||||
isStreaming: false,
|
||||
isSubmitting: false,
|
||||
loadingConversations: false,
|
||||
inputValue: "",
|
||||
attachments: [],
|
||||
conversationUsage: { total_tokens: 0, message_count: 0 },
|
||||
pendingApprovals: [],
|
||||
|
||||
// UI State
|
||||
showDebugPanel: true,
|
||||
debugPanelWidth: 320,
|
||||
debugEvents: [],
|
||||
isResizing: false,
|
||||
|
||||
// Modal State
|
||||
showAboutModal: false,
|
||||
showGallery: false,
|
||||
showDeployModal: false,
|
||||
showEntityNotFoundToast: false,
|
||||
|
||||
// ========================================
|
||||
// Entity Actions
|
||||
// ========================================
|
||||
|
||||
setAgents: (agents) => set({ agents }),
|
||||
setWorkflows: (workflows) => set({ workflows }),
|
||||
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
|
||||
addAgent: (agent) =>
|
||||
set((state) => ({ agents: [...state.agents, agent] })),
|
||||
addWorkflow: (workflow) =>
|
||||
set((state) => ({ workflows: [...state.workflows, workflow] })),
|
||||
updateAgent: (updatedAgent) =>
|
||||
set((state) => ({
|
||||
agents: state.agents.map((a) =>
|
||||
a.id === updatedAgent.id ? updatedAgent : a
|
||||
),
|
||||
// Also update selectedAgent if it's the same one
|
||||
selectedAgent:
|
||||
state.selectedAgent?.id === updatedAgent.id &&
|
||||
state.selectedAgent.type === "agent"
|
||||
? updatedAgent
|
||||
: state.selectedAgent,
|
||||
})),
|
||||
updateWorkflow: (updatedWorkflow) =>
|
||||
set((state) => ({
|
||||
workflows: state.workflows.map((w) =>
|
||||
w.id === updatedWorkflow.id ? updatedWorkflow : w
|
||||
),
|
||||
// Also update selectedAgent if it's the same one
|
||||
selectedAgent:
|
||||
state.selectedAgent?.id === updatedWorkflow.id &&
|
||||
state.selectedAgent.type === "workflow"
|
||||
? updatedWorkflow
|
||||
: state.selectedAgent,
|
||||
})),
|
||||
removeEntity: (entityId) =>
|
||||
set((state) => ({
|
||||
agents: state.agents.filter((a) => a.id !== entityId),
|
||||
workflows: state.workflows.filter((w) => w.id !== entityId),
|
||||
selectedAgent:
|
||||
state.selectedAgent?.id === entityId
|
||||
? undefined
|
||||
: state.selectedAgent,
|
||||
})),
|
||||
setEntityError: (error) => set({ entityError: error }),
|
||||
setIsLoadingEntities: (loading) => set({ isLoadingEntities: loading }),
|
||||
|
||||
// ========================================
|
||||
// Conversation Actions
|
||||
// ========================================
|
||||
|
||||
setCurrentConversation: (conv) => set({ currentConversation: conv }),
|
||||
setAvailableConversations: (convs) =>
|
||||
set({ availableConversations: convs }),
|
||||
setChatItems: (items) => set({ chatItems: items }),
|
||||
setIsStreaming: (streaming) => set({ isStreaming: streaming }),
|
||||
setIsSubmitting: (submitting) => set({ isSubmitting: submitting }),
|
||||
setLoadingConversations: (loading) =>
|
||||
set({ loadingConversations: loading }),
|
||||
setInputValue: (value) => set({ inputValue: value }),
|
||||
setAttachments: (files) => set({ attachments: files }),
|
||||
updateConversationUsage: (tokens) =>
|
||||
set((state) => ({
|
||||
conversationUsage: {
|
||||
total_tokens: state.conversationUsage.total_tokens + tokens,
|
||||
message_count: state.conversationUsage.message_count + 1,
|
||||
},
|
||||
})),
|
||||
setPendingApprovals: (approvals) => set({ pendingApprovals: approvals }),
|
||||
|
||||
// ========================================
|
||||
// UI Actions
|
||||
// ========================================
|
||||
|
||||
setShowDebugPanel: (show) => set({ showDebugPanel: show }),
|
||||
setDebugPanelWidth: (width) => set({ debugPanelWidth: width }),
|
||||
addDebugEvent: (event) =>
|
||||
set((state) => ({ debugEvents: [...state.debugEvents, event] })),
|
||||
clearDebugEvents: () => set({ debugEvents: [] }),
|
||||
setIsResizing: (resizing) => set({ isResizing: resizing }),
|
||||
|
||||
// ========================================
|
||||
// Modal Actions
|
||||
// ========================================
|
||||
|
||||
setShowAboutModal: (show) => set({ showAboutModal: show }),
|
||||
setShowGallery: (show) => set({ showGallery: show }),
|
||||
setShowDeployModal: (show) => set({ showDeployModal: show }),
|
||||
setShowEntityNotFoundToast: (show) =>
|
||||
set({ showEntityNotFoundToast: show }),
|
||||
|
||||
// ========================================
|
||||
// Combined Actions
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Select an entity (agent/workflow) and handle all side effects:
|
||||
* - Update selected entity
|
||||
* - Clear conversation state (FIXES THE BUG!)
|
||||
* - Clear debug events
|
||||
* - Update URL
|
||||
*/
|
||||
selectEntity: (entity) => {
|
||||
set({
|
||||
selectedAgent: entity,
|
||||
// CRITICAL: Clear all conversation state when switching entities
|
||||
currentConversation: undefined,
|
||||
availableConversations: [], // Let AgentView reload conversations
|
||||
chatItems: [],
|
||||
inputValue: "",
|
||||
attachments: [],
|
||||
conversationUsage: { total_tokens: 0, message_count: 0 },
|
||||
isStreaming: false,
|
||||
isSubmitting: false,
|
||||
pendingApprovals: [],
|
||||
// Clear debug events when switching
|
||||
debugEvents: [],
|
||||
});
|
||||
|
||||
// Update URL with selected entity ID
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("entity_id", entity.id);
|
||||
window.history.pushState({}, "", url);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "devui-storage",
|
||||
// Only persist UI preferences, not runtime state
|
||||
partialize: (state) => ({
|
||||
showDebugPanel: state.showDebugPanel,
|
||||
debugPanelWidth: state.debugPanelWidth,
|
||||
}),
|
||||
}
|
||||
),
|
||||
{ name: "DevUI Store" }
|
||||
)
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// Usage Notes
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* How to use the store:
|
||||
*
|
||||
* 1. For state access, use direct selectors:
|
||||
* const agents = useDevUIStore((state) => state.agents);
|
||||
*
|
||||
* 2. For actions, extract them:
|
||||
* const setAgents = useDevUIStore((state) => state.setAgents);
|
||||
*
|
||||
* 3. For combined state access (use sparingly, can cause unnecessary re-renders):
|
||||
* const { agents, workflows } = useDevUIStore((state) => ({
|
||||
* agents: state.agents,
|
||||
* workflows: state.workflows
|
||||
* }));
|
||||
*
|
||||
* 4. To access state outside React components:
|
||||
* useDevUIStore.getState().agents
|
||||
* useDevUIStore.getState().setAgents([...])
|
||||
*/
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Store exports - single entry point for all store hooks
|
||||
*/
|
||||
|
||||
export { useDevUIStore } from "./devuiStore";
|
||||
@@ -31,6 +31,7 @@ export interface AgentInfo {
|
||||
has_env: boolean;
|
||||
module_path?: string;
|
||||
required_env_vars?: EnvVarRequirement[];
|
||||
metadata?: Record<string, unknown>; // Backend metadata including lazy_loaded flag
|
||||
// Agent-specific fields
|
||||
instructions?: string;
|
||||
model?: string;
|
||||
@@ -103,8 +104,8 @@ export type {
|
||||
ResponseWorkflowEventComplete,
|
||||
ResponseTraceEventComplete,
|
||||
ResponseOutputItemAddedEvent,
|
||||
ResponseFunctionResultComplete,
|
||||
ResponseCompletedEvent,
|
||||
ResponseFunctionResultComplete,
|
||||
StructuredEvent,
|
||||
} from "./openai";
|
||||
|
||||
|
||||
@@ -36,17 +36,6 @@ export interface ResponseWorkflowEventComplete {
|
||||
sequence_number: number;
|
||||
}
|
||||
|
||||
// Custom DevUI: Function result event
|
||||
// This is a DevUI extension - OpenAI doesn't stream function execution results
|
||||
export interface ResponseFunctionResultComplete {
|
||||
type: "response.function_result.complete";
|
||||
call_id: string;
|
||||
output: string;
|
||||
status: "in_progress" | "completed" | "incomplete";
|
||||
item_id: string;
|
||||
output_index: number;
|
||||
sequence_number: number;
|
||||
}
|
||||
|
||||
// Function call event types - matching actual backend output
|
||||
export interface ResponseFunctionCallComplete {
|
||||
@@ -173,6 +162,24 @@ export interface ResponseFunctionApprovalRespondedEvent {
|
||||
sequence_number: number;
|
||||
}
|
||||
|
||||
// DevUI Extension: Function Result Complete
|
||||
export interface ResponseFunctionResultComplete {
|
||||
type: "response.function_result.complete";
|
||||
call_id: string;
|
||||
output: string;
|
||||
status: "in_progress" | "completed" | "incomplete";
|
||||
item_id: string;
|
||||
output_index: number;
|
||||
sequence_number: number;
|
||||
}
|
||||
|
||||
// DevUI Extension: Turn Separator (UI-only event for grouping)
|
||||
export interface TurnSeparatorEvent {
|
||||
type: "debug.turn_separator";
|
||||
timestamp: string;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
// Union type for all structured events
|
||||
export type StructuredEvent =
|
||||
| ResponseCompletedEvent
|
||||
@@ -180,13 +187,14 @@ export type StructuredEvent =
|
||||
| ResponseTraceEventComplete
|
||||
| ResponseTraceComplete
|
||||
| ResponseOutputItemAddedEvent
|
||||
| ResponseFunctionResultComplete
|
||||
| ResponseFunctionCallComplete
|
||||
| ResponseFunctionCallDelta
|
||||
| ResponseFunctionCallArgumentsDelta
|
||||
| ResponseFunctionResultComplete
|
||||
| ResponseErrorEvent
|
||||
| ResponseFunctionApprovalRequestedEvent
|
||||
| ResponseFunctionApprovalRespondedEvent;
|
||||
| ResponseFunctionApprovalRespondedEvent
|
||||
| TurnSeparatorEvent;
|
||||
|
||||
// Extended stream event that includes our structured events
|
||||
export type ExtendedResponseStreamEvent = ResponseStreamEvent | StructuredEvent;
|
||||
|
||||
@@ -16,19 +16,9 @@ export default defineConfig({
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Minimize to just 2 files: main app + CSS
|
||||
manualChunks: undefined,
|
||||
// Ensure everything goes into a single JS file
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Ensure proper tree-shaking
|
||||
optimizeDeps: {
|
||||
include: ["lucide-react", "@xyflow/react"],
|
||||
},
|
||||
// Enable aggressive tree-shaking
|
||||
esbuild: {
|
||||
treeShaking: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2063,7 +2063,7 @@ natural-compare@^1.4.0:
|
||||
|
||||
next-themes@^0.4.6:
|
||||
version "0.4.6"
|
||||
resolved "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz"
|
||||
resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.4.6.tgz#8d7e92d03b8fea6582892a50a928c9b23502e8b6"
|
||||
integrity sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==
|
||||
|
||||
node-releases@^2.0.19:
|
||||
@@ -2453,3 +2453,8 @@ zustand@^4.4.0:
|
||||
integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==
|
||||
dependencies:
|
||||
use-sync-external-store "^1.2.2"
|
||||
|
||||
zustand@^5.0.8:
|
||||
version "5.0.8"
|
||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.8.tgz#b998a0c088c7027a20f2709141a91cb07ac57f8a"
|
||||
integrity sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==
|
||||
|
||||
Reference in New Issue
Block a user