mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python:DevUI Fixes (#1035)
* fix event reset on thread change, enable multiline input, enable pasting of files and screenshots * UI updates and improved remove discovery * ui and other fixes --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
042099009f
commit
fa9f5c1aed
@@ -10,6 +10,7 @@
|
||||
/build
|
||||
|
||||
.env.*
|
||||
claude.md
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
@@ -7,12 +7,14 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AppHeader } from "@/components/shared/app-header";
|
||||
import { DebugPanel } from "@/components/shared/debug-panel";
|
||||
import { AboutModal } from "@/components/shared/about-modal";
|
||||
import { SettingsModal } from "@/components/shared/settings-modal";
|
||||
import { GalleryView } from "@/components/gallery";
|
||||
import { AgentView } from "@/components/agent/agent-view";
|
||||
import { WorkflowView } from "@/components/workflow/workflow-view";
|
||||
import { LoadingState } from "@/components/ui/loading-state";
|
||||
import { apiClient } from "@/services/api";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { ChevronLeft, ChevronDown, ServerOff } from "lucide-react";
|
||||
import type { SampleEntity } from "@/data/gallery";
|
||||
import type {
|
||||
AgentInfo,
|
||||
WorkflowInfo,
|
||||
@@ -27,23 +29,23 @@ export default function App() {
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
const [debugEvents, setDebugEvents] = useState<ExtendedResponseStreamEvent[]>(
|
||||
[]
|
||||
);
|
||||
const [debugEvents, setDebugEvents] = useState<ExtendedResponseStreamEvent[]>([]);
|
||||
const [debugPanelOpen, setDebugPanelOpen] = useState(true);
|
||||
const [debugPanelWidth, setDebugPanelWidth] = useState(() => {
|
||||
// Initialize from localStorage or default to 320
|
||||
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);
|
||||
|
||||
// Initialize app - load agents and workflows
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// Load agents and workflows in parallel
|
||||
const [agents, workflows] = await Promise.all([
|
||||
apiClient.getAgents(),
|
||||
apiClient.getWorkflows(),
|
||||
@@ -135,6 +137,109 @@ export default function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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
|
||||
}));
|
||||
} 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
|
||||
}));
|
||||
}
|
||||
|
||||
// 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
|
||||
}));
|
||||
|
||||
// Clear debug events if we removed the selected entity
|
||||
if (appState.selectedAgent?.id === entityId) {
|
||||
setDebugEvents([]);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to remove entity:', error);
|
||||
}
|
||||
}, [appState.selectedAgent?.id]);
|
||||
|
||||
// Show loading state while initializing
|
||||
if (appState.isLoading) {
|
||||
return (
|
||||
@@ -167,54 +272,77 @@ export default function App() {
|
||||
workflows={[]}
|
||||
selectedItem={undefined}
|
||||
onSelect={() => {}}
|
||||
onRemove={handleRemoveEntity}
|
||||
isLoading={false}
|
||||
onSettingsClick={() => setShowAboutModal(true)}
|
||||
/>
|
||||
|
||||
{/* Error Content */}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-4 max-w-md">
|
||||
<div className="text-destructive text-lg font-medium">
|
||||
Failed to load entities
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="text-center space-y-6 max-w-2xl">
|
||||
{/* Icon */}
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-full bg-muted p-4 animate-pulse">
|
||||
<ServerOff className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">{appState.error}</p>
|
||||
<Button onClick={() => window.location.reload()} variant="outline">
|
||||
Retry
|
||||
|
||||
{/* Heading */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
Can't Connect to Backend
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-base">
|
||||
No worries! Just start the DevUI backend server and you'll be good to go.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Command Instructions */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-foreground">Start the backend:</p>
|
||||
<code className="block bg-background px-3 py-2 rounded border text-sm font-mono text-foreground">
|
||||
devui ./agents --port 8080
|
||||
</code>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Or launch programmatically with <code className="text-xs">serve(entities=[agent])</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default: <span className="font-mono">http://localhost:8080</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Details (Collapsible) */}
|
||||
{appState.error && (
|
||||
<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}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Retry Button */}
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="default"
|
||||
className="mt-2"
|
||||
>
|
||||
Retry Connection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show empty state if no agents or workflows are available
|
||||
if (
|
||||
!appState.isLoading &&
|
||||
appState.agents.length === 0 &&
|
||||
appState.workflows.length === 0
|
||||
) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-background">
|
||||
<AppHeader
|
||||
agents={[]}
|
||||
workflows={[]}
|
||||
selectedItem={undefined}
|
||||
onSelect={() => {}}
|
||||
isLoading={false}
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal
|
||||
open={showAboutModal}
|
||||
onOpenChange={setShowAboutModal}
|
||||
/>
|
||||
|
||||
{/* Empty State Content */}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-4 max-w-md">
|
||||
<div className="text-lg font-medium">No entities configured</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No agents or workflows were found in your configuration. Please
|
||||
check your setup and ensure entities are properly configured.
|
||||
</p>
|
||||
<Button onClick={() => window.location.reload()} variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -226,35 +354,63 @@ export default function App() {
|
||||
workflows={appState.workflows}
|
||||
selectedItem={appState.selectedAgent}
|
||||
onSelect={handleEntitySelect}
|
||||
onRemove={handleRemoveEntity}
|
||||
onBrowseGallery={() => setShowGallery(true)}
|
||||
isLoading={appState.isLoading}
|
||||
onSettingsClick={() => setShowAboutModal(true)}
|
||||
/>
|
||||
|
||||
{/* Main Content - Split Panel */}
|
||||
{/* Main Content - Split Panel or Gallery */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Panel - Main View */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{appState.selectedAgent ? (
|
||||
appState.selectedAgent.type === "agent" ? (
|
||||
<AgentView
|
||||
selectedAgent={appState.selectedAgent as AgentInfo}
|
||||
onDebugEvent={handleDebugEvent}
|
||||
/>
|
||||
) : (
|
||||
<WorkflowView
|
||||
selectedWorkflow={appState.selectedAgent as WorkflowInfo}
|
||||
onDebugEvent={handleDebugEvent}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
Select an agent or workflow to get started.
|
||||
{showGallery ? (
|
||||
// Show gallery full screen (w-full ensures it takes entire width)
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
) : appState.agents.length === 0 && appState.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}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Left Panel - Main View */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{appState.selectedAgent ? (
|
||||
appState.selectedAgent.type === "agent" ? (
|
||||
<AgentView
|
||||
selectedAgent={appState.selectedAgent as AgentInfo}
|
||||
onDebugEvent={handleDebugEvent}
|
||||
/>
|
||||
) : (
|
||||
<WorkflowView
|
||||
selectedWorkflow={appState.selectedAgent as WorkflowInfo}
|
||||
onDebugEvent={handleDebugEvent}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
Select an agent or workflow to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
{debugPanelOpen && (
|
||||
{/* Resize Handle */}
|
||||
{debugPanelOpen && (
|
||||
<div
|
||||
className={`w-1 cursor-col-resize flex-shrink-0 relative group transition-colors duration-200 ease-in-out ${
|
||||
isResizing ? "bg-primary/40" : "bg-border hover:bg-primary/20"
|
||||
@@ -288,22 +444,24 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Panel - Debug */}
|
||||
{debugPanelOpen && (
|
||||
<div
|
||||
className="flex-shrink-0"
|
||||
style={{ width: `${debugPanelWidth}px` }}
|
||||
>
|
||||
<DebugPanel
|
||||
events={debugEvents}
|
||||
isStreaming={false} // Each view manages its own streaming state
|
||||
/>
|
||||
</div>
|
||||
{/* Right Panel - Debug */}
|
||||
{debugPanelOpen && (
|
||||
<div
|
||||
className="flex-shrink-0"
|
||||
style={{ width: `${debugPanelWidth}px` }}
|
||||
>
|
||||
<DebugPanel
|
||||
events={debugEvents}
|
||||
isStreaming={false} // Each view manages its own streaming state
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* About Modal */}
|
||||
<AboutModal
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal
|
||||
open={showAboutModal}
|
||||
onOpenChange={setShowAboutModal}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { FileUpload } from "@/components/ui/file-upload";
|
||||
import {
|
||||
@@ -21,7 +21,22 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Send, User, Bot, Plus, AlertCircle } from "lucide-react";
|
||||
import {
|
||||
SendHorizontal,
|
||||
User,
|
||||
Bot,
|
||||
Plus,
|
||||
AlertCircle,
|
||||
Paperclip,
|
||||
FileText,
|
||||
ChevronDown,
|
||||
Package,
|
||||
FolderOpen,
|
||||
Database,
|
||||
Globe,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "@/services/api";
|
||||
import type {
|
||||
AgentInfo,
|
||||
@@ -36,7 +51,7 @@ interface ChatState {
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
type DebugEventHandler = (event: ExtendedResponseStreamEvent | 'clear') => void;
|
||||
type DebugEventHandler = (event: ExtendedResponseStreamEvent | "clear") => void;
|
||||
|
||||
interface AgentViewProps {
|
||||
selectedAgent: AgentInfo;
|
||||
@@ -136,10 +151,15 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const [loadingThreads, setLoadingThreads] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [dragCounter, setDragCounter] = useState(0);
|
||||
const [pasteNotification, setPasteNotification] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [detailsExpanded, setDetailsExpanded] = useState(false);
|
||||
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const accumulatedText = useRef<string>("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
@@ -163,7 +183,9 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
|
||||
// Load messages for the selected thread
|
||||
try {
|
||||
const threadMessages = await apiClient.getThreadMessages(mostRecentThread.id);
|
||||
const threadMessages = await apiClient.getThreadMessages(
|
||||
mostRecentThread.id
|
||||
);
|
||||
setChatState({
|
||||
messages: threadMessages,
|
||||
isStreaming: false,
|
||||
@@ -262,10 +284,137 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Paste handler
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
const items = Array.from(e.clipboardData.items);
|
||||
const files: File[] = [];
|
||||
let hasProcessedText = false;
|
||||
const TEXT_THRESHOLD = 8000; // Convert to file if text is larger than this
|
||||
|
||||
for (const item of items) {
|
||||
// Handle pasted images (screenshots)
|
||||
if (item.type.startsWith("image/")) {
|
||||
e.preventDefault();
|
||||
const blob = item.getAsFile();
|
||||
if (blob) {
|
||||
const timestamp = Date.now();
|
||||
files.push(
|
||||
new File([blob], `screenshot-${timestamp}.png`, { type: blob.type })
|
||||
);
|
||||
}
|
||||
}
|
||||
// Handle text - only process first text item (browsers often duplicate)
|
||||
else if (item.type === "text/plain" && !hasProcessedText) {
|
||||
hasProcessedText = true;
|
||||
|
||||
// We need to check the text synchronously to decide whether to prevent default
|
||||
// Unfortunately, getAsString is async, so we'll prevent default for all text
|
||||
// and then decide whether to actually create a file or manually insert the text
|
||||
e.preventDefault();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
item.getAsString((text) => {
|
||||
// Check if text should be converted to file
|
||||
const lineCount = (text.match(/\n/g) || []).length;
|
||||
const shouldConvert =
|
||||
text.length > TEXT_THRESHOLD ||
|
||||
lineCount > 50 || // Many lines suggests logs/data
|
||||
/^\s*[{[][\s\S]*[}\]]\s*$/.test(text) || // JSON-like
|
||||
/^<\?xml|^<html|^<!DOCTYPE/i.test(text); // XML/HTML
|
||||
|
||||
if (shouldConvert) {
|
||||
// Create file for large/complex text
|
||||
const extension = detectFileExtension(text);
|
||||
const timestamp = Date.now();
|
||||
const blob = new Blob([text], { type: "text/plain" });
|
||||
files.push(
|
||||
new File([blob], `pasted-text-${timestamp}${extension}`, {
|
||||
type: "text/plain",
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// For small text, manually insert into textarea since we prevented default
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const currentValue = textarea.value;
|
||||
const newValue = currentValue.slice(0, start) + text + currentValue.slice(end);
|
||||
setInputValue(newValue);
|
||||
|
||||
// Restore cursor position after the inserted text
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + text.length;
|
||||
textarea.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process collected files
|
||||
if (files.length > 0) {
|
||||
await handleFilesSelected(files);
|
||||
|
||||
// Show notification with appropriate icon
|
||||
const message =
|
||||
files.length === 1
|
||||
? files[0].name.includes("screenshot")
|
||||
? "Screenshot added as attachment"
|
||||
: "Large text converted to file"
|
||||
: `${files.length} files added`;
|
||||
|
||||
setPasteNotification(message);
|
||||
setTimeout(() => setPasteNotification(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// Detect file extension from content
|
||||
const detectFileExtension = (text: string): string => {
|
||||
const trimmed = text.trim();
|
||||
const lines = trimmed.split('\n');
|
||||
|
||||
// JSON detection
|
||||
if (/^{[\s\S]*}$|^\[[\s\S]*\]$/.test(trimmed)) return ".json";
|
||||
|
||||
// XML/HTML detection
|
||||
if (/^<\?xml|^<html|^<!DOCTYPE/i.test(trimmed)) return ".html";
|
||||
|
||||
// Markdown detection (code blocks)
|
||||
if (/^```/.test(trimmed)) return ".md";
|
||||
|
||||
// TSV detection (tabs with multiple lines)
|
||||
if (/\t/.test(text) && lines.length > 1) return ".tsv";
|
||||
|
||||
// CSV detection (more strict) - need multiple lines with consistent comma patterns
|
||||
if (lines.length > 2) {
|
||||
const commaLines = lines.filter(line => line.includes(','));
|
||||
const semicolonLines = lines.filter(line => line.includes(';'));
|
||||
|
||||
// If >50% of lines have commas and it looks tabular
|
||||
if (commaLines.length > lines.length * 0.5) {
|
||||
const avgCommas = commaLines.reduce((sum, line) => sum + (line.match(/,/g) || []).length, 0) / commaLines.length;
|
||||
if (avgCommas >= 2) return ".csv";
|
||||
}
|
||||
|
||||
// If >50% of lines have semicolons and it looks tabular
|
||||
if (semicolonLines.length > lines.length * 0.5) {
|
||||
const avgSemicolons = semicolonLines.reduce((sum, line) => sum + (line.match(/;/g) || []).length, 0) / semicolonLines.length;
|
||||
if (avgSemicolons >= 2) return ".csv";
|
||||
}
|
||||
}
|
||||
|
||||
return ".txt";
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
const getFileType = (file: File): AttachmentItem["type"] => {
|
||||
if (file.type.startsWith("image/")) return "image";
|
||||
if (file.type === "application/pdf") return "pdf";
|
||||
if (file.type.startsWith("audio/")) return "audio";
|
||||
return "other";
|
||||
};
|
||||
|
||||
@@ -304,6 +453,9 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
|
||||
setCurrentThread(thread);
|
||||
|
||||
// Clear debug panel when switching threads
|
||||
onDebugEvent("clear");
|
||||
|
||||
try {
|
||||
// Load thread messages from backend
|
||||
const threadMessages = await apiClient.getThreadMessages(threadId);
|
||||
@@ -354,10 +506,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
} as import("@/types/agent-framework").DataContent);
|
||||
} else if (contentItem.type === "input_file") {
|
||||
const dataUri = `data:application/octet-stream;base64,${contentItem.file_data}`;
|
||||
// Determine media type from filename
|
||||
const filename = (contentItem as import("@/types/agent-framework").ResponseInputFileParam).filename || "";
|
||||
let mediaType = "application/octet-stream";
|
||||
|
||||
if (filename.endsWith(".pdf")) mediaType = "application/pdf";
|
||||
else if (filename.endsWith(".txt")) mediaType = "text/plain";
|
||||
else if (filename.endsWith(".json")) mediaType = "application/json";
|
||||
else if (filename.endsWith(".csv")) mediaType = "text/csv";
|
||||
else if (filename.endsWith(".html")) mediaType = "text/html";
|
||||
else if (filename.endsWith(".md")) mediaType = "text/markdown";
|
||||
|
||||
attachmentContents.push({
|
||||
type: "data",
|
||||
uri: dataUri,
|
||||
media_type: "application/pdf", // Should be dynamic based on filename
|
||||
media_type: mediaType,
|
||||
} as import("@/types/agent-framework").DataContent);
|
||||
}
|
||||
}
|
||||
@@ -427,7 +590,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
accumulatedText.current = "";
|
||||
|
||||
// Clear debug panel events for new agent run
|
||||
onDebugEvent('clear');
|
||||
onDebugEvent("clear");
|
||||
|
||||
// Use OpenAI-compatible API streaming - direct event handling
|
||||
const streamGenerator = apiClient.streamAgentExecutionOpenAI(
|
||||
@@ -576,8 +739,24 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
type: "input_image",
|
||||
image_url: dataUri,
|
||||
} as import("@/types/agent-framework").ResponseInputImageParam);
|
||||
} else if (
|
||||
attachment.file.type === "text/plain" &&
|
||||
(attachment.file.name.includes("pasted-text-") ||
|
||||
attachment.file.name.endsWith(".txt") ||
|
||||
attachment.file.name.endsWith(".csv") ||
|
||||
attachment.file.name.endsWith(".json") ||
|
||||
attachment.file.name.endsWith(".html") ||
|
||||
attachment.file.name.endsWith(".md") ||
|
||||
attachment.file.name.endsWith(".tsv"))
|
||||
) {
|
||||
// Convert all text files (from pasted large text) back to input_text
|
||||
const text = await attachment.file.text();
|
||||
content.push({
|
||||
text: text,
|
||||
type: "input_text",
|
||||
} as import("@/types/agent-framework").ResponseInputTextParam);
|
||||
} else {
|
||||
// EXACT OpenAI ResponseInputFileParam (but we need to handle the required fields)
|
||||
// EXACT OpenAI ResponseInputFileParam for other files
|
||||
const base64Data = dataUri.split(",")[1]; // Extract base64 part
|
||||
content.push({
|
||||
type: "input_file",
|
||||
@@ -641,22 +820,36 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
<div className="flex h-[calc(100vh-3.5rem)] flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b pb-2 p-4 flex-shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-semibold text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
Chat with {selectedAgent.name || selectedAgent.id}
|
||||
</div>
|
||||
</h2>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h2 className="font-semibold text-sm truncate">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">Chat with {selectedAgent.name || selectedAgent.id}</span>
|
||||
</div>
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDetailsExpanded(!detailsExpanded)}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform duration-200 ${
|
||||
detailsExpanded ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Thread Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 flex-shrink-0">
|
||||
<Select
|
||||
value={currentThread?.id || ""}
|
||||
onValueChange={handleThreadSelect}
|
||||
disabled={loadingThreads || isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingThreads
|
||||
@@ -690,6 +883,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
size="lg"
|
||||
onClick={handleNewThread}
|
||||
disabled={!selectedAgent || isSubmitting}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Thread
|
||||
@@ -702,6 +896,68 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
{selectedAgent.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Collapsible Details Section */}
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-200 ease-in-out ${
|
||||
detailsExpanded ? "max-h-40 mt-3" : "max-h-0"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-2 text-xs">
|
||||
{/* Tools */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Tools:</span>
|
||||
<span className="font-mono">
|
||||
{selectedAgent.tools.length > 0
|
||||
? selectedAgent.tools.join(", ")
|
||||
: "No tools"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
({selectedAgent.tools.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedAgent.source === "directory" ? (
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
) : selectedAgent.source === "in_memory" ? (
|
||||
<Database className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-muted-foreground">Source:</span>
|
||||
<span>
|
||||
{selectedAgent.source === "directory"
|
||||
? "Local"
|
||||
: selectedAgent.source === "in_memory"
|
||||
? "In-Memory"
|
||||
: "Gallery"}
|
||||
</span>
|
||||
{selectedAgent.module_path && (
|
||||
<span className="text-muted-foreground font-mono text-[11px]">
|
||||
({selectedAgent.module_path})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Environment */}
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedAgent.has_env ? (
|
||||
<XCircle className="h-3.5 w-3.5 text-orange-500" />
|
||||
) : (
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||
)}
|
||||
<span className="text-muted-foreground">Environment:</span>
|
||||
<span>
|
||||
{selectedAgent.has_env
|
||||
? "Requires environment variables"
|
||||
: "No environment variables required"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
@@ -764,16 +1020,43 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paste notification */}
|
||||
{pasteNotification && (
|
||||
<div
|
||||
className="absolute bottom-24 left-1/2 -translate-x-1/2 z-20
|
||||
bg-blue-500 text-white px-4 py-2 rounded-full text-sm
|
||||
animate-in slide-in-from-bottom-2 fade-in duration-200
|
||||
flex items-center gap-2 shadow-lg"
|
||||
>
|
||||
{pasteNotification.includes("screenshot") ? (
|
||||
<Paperclip className="h-3 w-3" />
|
||||
) : (
|
||||
<FileText className="h-3 w-3" />
|
||||
)}
|
||||
{pasteNotification}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input form */}
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<Input
|
||||
<form onSubmit={handleSubmit} className="flex gap-2 items-end">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(e) => {
|
||||
// Submit on Enter (without shift)
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
placeholder={`Message ${
|
||||
selectedAgent.name || selectedAgent.id
|
||||
}...`}
|
||||
}... (Shift+Enter for new line)`}
|
||||
disabled={isSubmitting || chatState.isStreaming}
|
||||
className="flex-1"
|
||||
className="flex-1 min-h-[40px] max-h-[200px] resize-none"
|
||||
style={{ fieldSizing: "content" } as React.CSSProperties}
|
||||
/>
|
||||
<FileUpload
|
||||
onFilesSelected={handleFilesSelected}
|
||||
@@ -783,12 +1066,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={!canSendMessage}
|
||||
className="shrink-0"
|
||||
className="shrink-0 h-10"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<LoadingSpinner size="sm" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* GalleryView - Consolidated gallery component with card and grid logic
|
||||
* Supports inline (empty state) and modal variants
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Bot,
|
||||
Workflow,
|
||||
Plus,
|
||||
Loader2,
|
||||
User,
|
||||
TriangleAlert,
|
||||
AlertCircle,
|
||||
X,
|
||||
Key,
|
||||
ChevronDown,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
SAMPLE_ENTITIES,
|
||||
type SampleEntity,
|
||||
getDifficultyColor,
|
||||
} from "@/data/gallery";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 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}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs border",
|
||||
getDifficultyColor(sample.difficulty)
|
||||
)}
|
||||
>
|
||||
{sample.difficulty}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<CardTitle className="text-lg leading-tight">{sample.name}</CardTitle>
|
||||
<CardDescription className="text-sm line-clamp-3">
|
||||
{sample.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0 flex-1 min-w-0 overflow-hidden">
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 min-w-0">
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{sample.tags.slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{sample.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{sample.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Environment Variables Required - Collapsible */}
|
||||
{sample.requiredEnvVars && sample.requiredEnvVars.length > 0 && (
|
||||
<details className="group min-w-0 max-w-full overflow-hidden">
|
||||
<summary className="cursor-pointer list-none p-2 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-md hover:bg-amber-100 dark:hover:bg-amber-950/30 transition-colors flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Key className="h-3.5 w-3.5 text-amber-600 dark:text-amber-500 flex-shrink-0" />
|
||||
<span className="text-xs font-medium text-amber-900 dark:text-amber-100 truncate">
|
||||
Requires {sample.requiredEnvVars.length} env var
|
||||
{sample.requiredEnvVars.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="h-3 w-3 text-amber-600 dark:text-amber-500 flex-shrink-0 group-open:rotate-180 transition-transform" />
|
||||
</summary>
|
||||
<div className="mt-2 p-2 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-md space-y-2 min-w-0 max-w-full overflow-hidden">
|
||||
{sample.requiredEnvVars.map((envVar) => (
|
||||
<div key={envVar.name} className="text-xs min-w-0 max-w-full overflow-hidden">
|
||||
<div className="font-mono font-medium text-amber-900 dark:text-amber-100 break-words">
|
||||
{envVar.name}
|
||||
</div>
|
||||
<div className="text-amber-700 dark:text-amber-300 mt-0.5 break-words">
|
||||
{envVar.description}
|
||||
</div>
|
||||
{envVar.example && (
|
||||
<div className="font-mono text-amber-600 dark:text-amber-400 mt-0.5 break-all">
|
||||
{envVar.example}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Features */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Key Features:
|
||||
</div>
|
||||
<ul className="text-xs space-y-1">
|
||||
{sample.features.slice(0, 3).map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-1">
|
||||
<div className="w-1 h-1 rounded-full bg-current opacity-50" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="pt-3 flex-col gap-3">
|
||||
{/* Metadata */}
|
||||
<div className="w-full flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{sample.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}) {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main: Gallery View Component
|
||||
export function GalleryView({
|
||||
onAdd,
|
||||
addingEntityId,
|
||||
errorEntityId,
|
||||
errorMessage,
|
||||
onClearError,
|
||||
onClose,
|
||||
variant = "inline",
|
||||
hasExistingEntities = false,
|
||||
}: GalleryViewProps) {
|
||||
// Inline variant - for empty state in main app
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
{/* Info Banner */}
|
||||
<div className="mb-8 p-4 bg-muted/50 border border-border rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<TriangleAlert className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1">
|
||||
No agents or workflows configured yet!
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
You can configure agents or workflows by running{" "}
|
||||
<code className="px-1.5 py-0.5 bg-background rounded text-xs">
|
||||
devui
|
||||
</code>{" "}
|
||||
in a directory containing them.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can also import any of the sample agents and workflows
|
||||
below to get started quickly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sample Gallery */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Sample Gallery</h3>
|
||||
<SampleEntityGrid
|
||||
samples={SAMPLE_ENTITIES}
|
||||
onAdd={onAdd}
|
||||
addingEntityId={addingEntityId}
|
||||
errorEntityId={errorEntityId}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={onClearError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-12 pt-8 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Want to create your own agents or workflows? Check out the{" "}
|
||||
<a
|
||||
href="https://github.com/microsoft/agent-framework"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
documentation
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Route variant - for /gallery page
|
||||
if (variant === "route") {
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
{hasExistingEntities && (
|
||||
<div className="mb-4">
|
||||
<Button variant="ghost" onClick={onClose} className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-semibold mb-2">Sample Gallery</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Browse and add sample agents and workflows to learn the Agent
|
||||
Framework. These are curated examples ranging from beginner to
|
||||
advanced.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sample Gallery */}
|
||||
<SampleEntityGrid
|
||||
samples={SAMPLE_ENTITIES}
|
||||
onAdd={onAdd}
|
||||
addingEntityId={addingEntityId}
|
||||
errorEntityId={errorEntityId}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={onClearError}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-12 pt-8 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Want to create your own agents or workflows? Check out the{" "}
|
||||
<a
|
||||
href="https://github.com/microsoft/agent-framework"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
documentation
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Modal variant - for dropdown trigger (simplified, just the grid)
|
||||
return (
|
||||
<SampleEntityGrid
|
||||
samples={SAMPLE_ENTITIES}
|
||||
onAdd={onAdd}
|
||||
addingEntityId={addingEntityId}
|
||||
errorEntityId={errorEntityId}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={onClearError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Gallery component exports
|
||||
*/
|
||||
|
||||
export { GalleryView } from './gallery-view';
|
||||
+82
-19
@@ -3,7 +3,15 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Download, FileText, AlertCircle, Code } from "lucide-react";
|
||||
import {
|
||||
Download,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
Code,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Music,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { RenderProps } from "./types";
|
||||
import {
|
||||
@@ -13,14 +21,52 @@ import {
|
||||
} from "@/types/agent-framework";
|
||||
|
||||
function TextContentRenderer({ content, isStreaming, className }: RenderProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (!isTextContent(content)) return null;
|
||||
|
||||
const text = content.text;
|
||||
const TRUNCATE_LENGTH = 1600;
|
||||
const shouldTruncate = text.length > TRUNCATE_LENGTH && !isStreaming;
|
||||
const displayText =
|
||||
shouldTruncate && !isExpanded
|
||||
? text.slice(0, TRUNCATE_LENGTH) + "..."
|
||||
: text;
|
||||
|
||||
return (
|
||||
<div className={`whitespace-pre-wrap break-words ${className || ""}`}>
|
||||
{content.text}
|
||||
<div
|
||||
className={
|
||||
isExpanded && shouldTruncate ? "max-h-96 overflow-y-auto" : ""
|
||||
}
|
||||
>
|
||||
{displayText}
|
||||
</div>
|
||||
{isStreaming && (
|
||||
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +84,7 @@ function DataContentRenderer({ content, className }: RenderProps) {
|
||||
|
||||
const isImage = mediaType.startsWith("image/");
|
||||
const isPdf = mediaType === "application/pdf";
|
||||
const isAudio = mediaType.startsWith("audio/");
|
||||
|
||||
if (isImage && !imageError) {
|
||||
return (
|
||||
@@ -58,7 +105,23 @@ function DataContentRenderer({ content, className }: RenderProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for non-images or failed images
|
||||
if (isAudio) {
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-purple-50 dark:bg-purple-950/20 ${className || ""}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Music className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-purple-800 dark:text-purple-300">Audio File</span>
|
||||
<span className="text-xs text-muted-foreground">({mediaType})</span>
|
||||
</div>
|
||||
<audio controls className="w-full max-w-md">
|
||||
<source src={dataUri} type={mediaType} />
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for non-images/non-audio or failed images
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -115,9 +178,7 @@ function FunctionCallRenderer({ content, className }: RenderProps) {
|
||||
<span className="text-sm font-medium text-blue-800">
|
||||
Function Call: {content.name}
|
||||
</span>
|
||||
<span className="text-xs text-blue-600">
|
||||
{isExpanded ? "▼" : "▶"}
|
||||
</span>
|
||||
<span className="text-xs text-blue-600">{isExpanded ? "▼" : "▶"}</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white p-2 rounded border">
|
||||
@@ -137,7 +198,9 @@ function FunctionResultRenderer({ content, className }: RenderProps) {
|
||||
if (!isFunctionResultContent(content)) return null;
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-green-50 ${className || ""}`}>
|
||||
<div
|
||||
className={`my-2 p-3 border rounded-lg bg-green-50 ${className || ""}`}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
@@ -146,9 +209,7 @@ function FunctionResultRenderer({ content, className }: RenderProps) {
|
||||
<span className="text-sm font-medium text-green-800">
|
||||
Function Result
|
||||
</span>
|
||||
<span className="text-xs text-green-600">
|
||||
{isExpanded ? "▼" : "▶"}
|
||||
</span>
|
||||
<span className="text-xs text-green-600">{isExpanded ? "▼" : "▶"}</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white p-2 rounded border">
|
||||
@@ -230,7 +291,11 @@ function UriContentRenderer({ content, className }: RenderProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ContentRenderer({ content, isStreaming, className }: RenderProps) {
|
||||
export function ContentRenderer({
|
||||
content,
|
||||
isStreaming,
|
||||
className,
|
||||
}: RenderProps) {
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
return (
|
||||
@@ -245,19 +310,17 @@ export function ContentRenderer({ content, isStreaming, className }: RenderProps
|
||||
case "uri":
|
||||
return <UriContentRenderer content={content} className={className} />;
|
||||
case "function_call":
|
||||
return (
|
||||
<FunctionCallRenderer content={content} className={className} />
|
||||
);
|
||||
return <FunctionCallRenderer content={content} className={className} />;
|
||||
case "function_result":
|
||||
return (
|
||||
<FunctionResultRenderer content={content} className={className} />
|
||||
);
|
||||
return <FunctionResultRenderer content={content} className={className} />;
|
||||
case "error":
|
||||
return <ErrorContentRenderer content={content} className={className} />;
|
||||
default:
|
||||
// Fallback for unsupported content types
|
||||
return (
|
||||
<div className={`my-2 p-2 bg-gray-100 rounded text-xs ${className || ""}`}>
|
||||
<div
|
||||
className={`my-2 p-2 bg-gray-100 rounded text-xs ${className || ""}`}
|
||||
>
|
||||
<div>Unsupported content type: {content.type}</div>
|
||||
<pre className="mt-1 text-xs whitespace-pre-wrap">
|
||||
{JSON.stringify(content, null, 2)}
|
||||
@@ -265,4 +328,4 @@ export function ContentRenderer({ content, isStreaming, className }: RenderProps
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,13 @@ export function AboutModal({ open, onOpenChange }: AboutModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogHeader className="p-6 pb-4">
|
||||
<DialogTitle>About DevUI</DialogTitle>
|
||||
<DialogClose onClose={() => onOpenChange(false)} />
|
||||
</DialogHeader>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<DialogClose onClose={() => onOpenChange(false)} />
|
||||
|
||||
<div className="px-6 pb-6 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
DevUI is a sample app for getting started with Agent Framework.
|
||||
</p>
|
||||
|
||||
@@ -14,6 +14,8 @@ interface AppHeaderProps {
|
||||
workflows: WorkflowInfo[];
|
||||
selectedItem?: AgentInfo | WorkflowInfo;
|
||||
onSelect: (item: AgentInfo | WorkflowInfo) => void;
|
||||
onRemove?: (entityId: string) => void;
|
||||
onBrowseGallery?: () => void;
|
||||
isLoading?: boolean;
|
||||
onSettingsClick?: () => void;
|
||||
}
|
||||
@@ -23,6 +25,8 @@ export function AppHeader({
|
||||
workflows,
|
||||
selectedItem,
|
||||
onSelect,
|
||||
onRemove,
|
||||
onBrowseGallery,
|
||||
isLoading = false,
|
||||
onSettingsClick,
|
||||
}: AppHeaderProps) {
|
||||
@@ -34,6 +38,8 @@ export function AppHeader({
|
||||
workflows={workflows}
|
||||
selectedItem={selectedItem}
|
||||
onSelect={onSelect}
|
||||
onRemove={onRemove}
|
||||
onBrowseGallery={onBrowseGallery}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
|
||||
@@ -973,6 +973,10 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
<span className="font-mono bg-accent/10 px-1 rounded">
|
||||
ENABLE_OTEL=true
|
||||
</span>{" "}
|
||||
or restart devui with the tracing flag{" "}
|
||||
<div className="font-mono bg-accent/10 px-1 rounded">
|
||||
devui --enable-tracing
|
||||
</div>
|
||||
to enable tracing.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ChevronDown, Bot, Workflow, FolderOpen, Database } from "lucide-react";
|
||||
import { ChevronDown, Bot, Workflow, FolderOpen, Database, Globe, X, Plus } from "lucide-react";
|
||||
import type { AgentInfo, WorkflowInfo } from "@/types";
|
||||
|
||||
interface EntitySelectorProps {
|
||||
@@ -23,6 +23,8 @@ interface EntitySelectorProps {
|
||||
workflows: WorkflowInfo[];
|
||||
selectedItem?: AgentInfo | WorkflowInfo;
|
||||
onSelect: (item: AgentInfo | WorkflowInfo) => void;
|
||||
onRemove?: (entityId: string) => void;
|
||||
onBrowseGallery?: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
@@ -30,8 +32,22 @@ const getTypeIcon = (type: "agent" | "workflow") => {
|
||||
return type === "workflow" ? Workflow : Bot;
|
||||
};
|
||||
|
||||
const getSourceIcon = (source: "directory" | "in_memory") => {
|
||||
return source === "directory" ? FolderOpen : Database;
|
||||
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({
|
||||
@@ -39,6 +55,8 @@ export function EntitySelector({
|
||||
workflows,
|
||||
selectedItem,
|
||||
onSelect,
|
||||
onRemove,
|
||||
onBrowseGallery,
|
||||
isLoading = false,
|
||||
}: EntitySelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -53,7 +71,7 @@ export function EntitySelector({
|
||||
};
|
||||
|
||||
const TypeIcon = selectedItem ? getTypeIcon(selectedItem.type) : Bot;
|
||||
const displayName = selectedItem?.name || selectedItem?.id || "Select Entity";
|
||||
const displayName = selectedItem?.name || selectedItem?.id || "Select Agent or Workflow";
|
||||
const itemCount =
|
||||
selectedItem?.type === "workflow"
|
||||
? (selectedItem as WorkflowInfo).executors?.length || 0
|
||||
@@ -102,11 +120,13 @@ export function EntitySelector({
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={agent.id}
|
||||
onClick={() => handleSelect(agent)}
|
||||
className="cursor-pointer"
|
||||
className="cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<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">
|
||||
@@ -122,8 +142,26 @@ export function EntitySelector({
|
||||
<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>
|
||||
@@ -144,11 +182,13 @@ export function EntitySelector({
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={workflow.id}
|
||||
onClick={() => handleSelect(workflow)}
|
||||
className="cursor-pointer"
|
||||
className="cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<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">
|
||||
@@ -164,8 +204,26 @@ export function EntitySelector({
|
||||
<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>
|
||||
@@ -177,10 +235,23 @@ export function EntitySelector({
|
||||
{allItems.length === 0 && (
|
||||
<DropdownMenuItem disabled>
|
||||
<div className="text-center text-muted-foreground py-2">
|
||||
{isLoading ? "Loading entities..." : "No entities found"}
|
||||
{isLoading ? "Loading agents and workflows..." : "No agents or workflows found"}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Browse Gallery option */}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-primary"
|
||||
onClick={() => {
|
||||
onBrowseGallery?.();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Browse Gallery
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Settings Modal - Tabbed settings dialog with About and Settings tabs
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ExternalLink, RotateCcw } from "lucide-react";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onBackendUrlChange?: (url: string) => void;
|
||||
}
|
||||
|
||||
type Tab = "about" | "settings";
|
||||
|
||||
export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: SettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("about");
|
||||
|
||||
// Get current backend URL from localStorage or default
|
||||
const defaultUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8080";
|
||||
const [backendUrl, setBackendUrl] = useState(() => {
|
||||
return localStorage.getItem("devui_backend_url") || defaultUrl;
|
||||
});
|
||||
const [tempUrl, setTempUrl] = useState(backendUrl);
|
||||
|
||||
const handleSave = () => {
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(tempUrl);
|
||||
localStorage.setItem("devui_backend_url", tempUrl);
|
||||
setBackendUrl(tempUrl);
|
||||
onBackendUrlChange?.(tempUrl);
|
||||
onOpenChange(false);
|
||||
|
||||
// Reload to apply new backend URL
|
||||
window.location.reload();
|
||||
} catch {
|
||||
alert("Please enter a valid URL (e.g., http://localhost:8080)");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
localStorage.removeItem("devui_backend_url");
|
||||
setTempUrl(defaultUrl);
|
||||
setBackendUrl(defaultUrl);
|
||||
onBackendUrlChange?.(defaultUrl);
|
||||
|
||||
// Reload to apply default backend URL
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const isModified = tempUrl !== backendUrl;
|
||||
const isDefault = !localStorage.getItem("devui_backend_url");
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[600px] max-w-[90vw]">
|
||||
<DialogHeader className="p-6 pb-2">
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogClose onClose={() => onOpenChange(false)} />
|
||||
|
||||
{/* 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 ${
|
||||
activeTab === "settings"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Settings
|
||||
{activeTab === "settings" && (
|
||||
<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 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="backend-url" className="text-sm font-medium">
|
||||
Backend URL
|
||||
</Label>
|
||||
{!isDefault && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
className="h-7 text-xs"
|
||||
title="Reset to default"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="backend-url"
|
||||
type="url"
|
||||
value={tempUrl}
|
||||
onChange={(e) => setTempUrl(e.target.value)}
|
||||
placeholder="http://localhost:8080"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default: <span className="font-mono">{defaultUrl}</span>
|
||||
</p>
|
||||
|
||||
{/* Reserve space for buttons to prevent layout shift */}
|
||||
<div className="flex gap-2 pt-2 min-h-[36px]">
|
||||
{isModified && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
Apply & Reload
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setTempUrl(backendUrl)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileText, Image, Trash2 } from "lucide-react";
|
||||
import { FileText, Image, Trash2, Music } from "lucide-react";
|
||||
|
||||
export interface AttachmentItem {
|
||||
id: string;
|
||||
file: File;
|
||||
preview?: string; // Data URL for preview
|
||||
type: "image" | "pdf" | "other";
|
||||
type: "image" | "pdf" | "audio" | "other";
|
||||
}
|
||||
|
||||
interface AttachmentGalleryProps {
|
||||
@@ -69,6 +69,14 @@ function AttachmentPreview({ attachment, onRemove }: AttachmentPreviewProps) {
|
||||
</div>
|
||||
);
|
||||
|
||||
case "audio":
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full bg-purple-50">
|
||||
<Music className="h-6 w-6 text-purple-500 mb-1" />
|
||||
<span className="text-xs text-purple-600">AUDIO</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full bg-gray-100">
|
||||
|
||||
@@ -15,10 +15,17 @@ interface DialogContentProps {
|
||||
|
||||
interface DialogHeaderProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DialogTitleProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DialogDescriptionProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DialogFooterProps {
|
||||
@@ -46,30 +53,43 @@ export function DialogContent({
|
||||
children,
|
||||
className = "",
|
||||
}: DialogContentProps) {
|
||||
// Default width classes if none provided
|
||||
const hasWidthClass = className.includes('w-[') || className.includes('w-full') || className.includes('max-w-');
|
||||
const defaultWidthClasses = hasWidthClass ? '' : 'max-w-lg w-full';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative bg-background border rounded-lg shadow-lg max-w-lg w-full max-h-[90vh] overflow-hidden ${className}`}
|
||||
className={`relative bg-background border rounded-lg shadow-lg max-h-[90vh] overflow-hidden ${defaultWidthClasses} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogHeader({ children }: DialogHeaderProps) {
|
||||
export function DialogHeader({ children, className = "" }: DialogHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogTitle({ children }: DialogTitleProps) {
|
||||
return <h2 className="text-lg font-semibold">{children}</h2>;
|
||||
export function DialogTitle({ children, className = "" }: DialogTitleProps) {
|
||||
return <h2 className={`text-lg font-semibold ${className}`}>{children}</h2>;
|
||||
}
|
||||
|
||||
export function DialogDescription({ children, className = "" }: DialogDescriptionProps) {
|
||||
return <p className={`text-sm text-muted-foreground ${className}`}>{children}</p>;
|
||||
}
|
||||
|
||||
export function DialogClose({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<Button variant="ghost" size="sm" onClick={onClose} className="h-6 w-6 p-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 h-8 w-8 p-0 rounded-sm opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ interface FileUploadProps {
|
||||
|
||||
export function FileUpload({
|
||||
onFilesSelected,
|
||||
accept = "image/*,.pdf",
|
||||
accept = "image/*,.pdf,audio/*,.wav,.mp3,.m4a,.ogg",
|
||||
multiple = true,
|
||||
maxSize = 50 * 1024 * 1024, // 50MB default for local dev tool
|
||||
disabled = false,
|
||||
@@ -105,7 +105,7 @@ export function FileUpload({
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
className="shrink-0 transition-colors hover:bg-muted"
|
||||
title="Upload files (images, PDFs)"
|
||||
title="Upload files (images, PDFs, audio)"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Play,
|
||||
Flag,
|
||||
Workflow,
|
||||
Home,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -29,6 +24,7 @@ export interface ExecutorNodeData extends Record<string, unknown> {
|
||||
isSelected?: boolean;
|
||||
isStartNode?: boolean;
|
||||
isEndNode?: boolean;
|
||||
layoutDirection?: "LR" | "TB";
|
||||
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void;
|
||||
}
|
||||
|
||||
@@ -36,54 +32,34 @@ const getExecutorStateConfig = (state: ExecutorState) => {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return {
|
||||
icon: Loader2,
|
||||
text: "Running",
|
||||
borderColor: "border-blue-500 dark:border-blue-400",
|
||||
iconColor: "text-blue-600 dark:text-blue-400",
|
||||
statusColor: "bg-blue-500 dark:bg-blue-400",
|
||||
animate: "animate-spin",
|
||||
glow: "shadow-lg shadow-blue-500/20",
|
||||
borderColor: "border-[#643FB2] dark:border-[#8B5CF6]",
|
||||
glow: "shadow-lg shadow-[#643FB2]/20",
|
||||
badgeColor: "bg-[#643FB2] dark:bg-[#8B5CF6]",
|
||||
};
|
||||
case "completed":
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
text: "Completed",
|
||||
borderColor: "border-green-500 dark:border-green-400",
|
||||
iconColor: "text-green-600 dark:text-green-400",
|
||||
statusColor: "bg-green-500 dark:bg-green-400",
|
||||
animate: "",
|
||||
glow: "shadow-lg shadow-green-500/20",
|
||||
badgeColor: "bg-green-500 dark:bg-green-400",
|
||||
};
|
||||
case "failed":
|
||||
return {
|
||||
icon: XCircle,
|
||||
text: "Failed",
|
||||
borderColor: "border-red-500 dark:border-red-400",
|
||||
iconColor: "text-red-600 dark:text-red-400",
|
||||
statusColor: "bg-red-500 dark:bg-red-400",
|
||||
animate: "",
|
||||
glow: "shadow-lg shadow-red-500/20",
|
||||
badgeColor: "bg-red-500 dark:bg-red-400",
|
||||
};
|
||||
case "cancelled":
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
text: "Cancelled",
|
||||
borderColor: "border-orange-500 dark:border-orange-400",
|
||||
iconColor: "text-orange-600 dark:text-orange-400",
|
||||
statusColor: "bg-orange-500 dark:bg-orange-400",
|
||||
animate: "",
|
||||
glow: "shadow-lg shadow-orange-500/20",
|
||||
badgeColor: "bg-orange-500 dark:bg-orange-400",
|
||||
};
|
||||
case "pending":
|
||||
default:
|
||||
return {
|
||||
icon: Clock,
|
||||
text: "Pending",
|
||||
borderColor: "border-gray-300 dark:border-gray-600",
|
||||
iconColor: "text-gray-500 dark:text-gray-400",
|
||||
statusColor: "bg-gray-400 dark:bg-gray-500",
|
||||
animate: "",
|
||||
glow: "",
|
||||
badgeColor: "bg-gray-400 dark:bg-gray-500",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -91,11 +67,15 @@ const getExecutorStateConfig = (state: ExecutorState) => {
|
||||
export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
const nodeData = data as ExecutorNodeData;
|
||||
const config = getExecutorStateConfig(nodeData.state);
|
||||
const IconComponent = config.icon;
|
||||
|
||||
const hasData = nodeData.inputData || nodeData.outputData || nodeData.error;
|
||||
const isRunning = nodeData.state === "running";
|
||||
|
||||
// Determine handle positions based on layout direction
|
||||
const isVertical = nodeData.layoutDirection === "TB";
|
||||
const targetPosition = isVertical ? Position.Top : Position.Left;
|
||||
const sourcePosition = isVertical ? Position.Bottom : Position.Right;
|
||||
|
||||
// Helper to safely render data with full details
|
||||
const renderDataDetails = () => {
|
||||
const details = [];
|
||||
@@ -175,89 +155,67 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
isRunning ? config.glow : "shadow-sm",
|
||||
)}
|
||||
>
|
||||
{/* Start/End Badge */}
|
||||
{(nodeData.isStartNode || nodeData.isEndNode) && (
|
||||
<div className={cn(
|
||||
"absolute -top-6 left-2 px-2 py-1 rounded-t text-xs font-medium text-white flex items-center gap-1 z-10 shadow-sm",
|
||||
nodeData.isStartNode ? "bg-green-600" : "bg-red-600"
|
||||
)}>
|
||||
{nodeData.isStartNode ? (
|
||||
<>
|
||||
<Play className="w-3 h-3" />
|
||||
START
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Flag className="w-3 h-3" />
|
||||
END
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Only show target handle if not a start node */}
|
||||
{/* Small circular handles */}
|
||||
{!nodeData.isStartNode && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!w-2 !h-5 !rounded-r-sm !-ml-1 !border-0 transition-colors"
|
||||
position={targetPosition}
|
||||
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
|
||||
style={{
|
||||
backgroundColor: nodeData.state === "running" ? "#3b82f6" :
|
||||
backgroundColor: nodeData.state === "running" ? "#643FB2" :
|
||||
nodeData.state === "completed" ? "#10b981" :
|
||||
nodeData.state === "failed" ? "#ef4444" :
|
||||
nodeData.state === "cancelled" ? "#f97316" : "#9ca3af"
|
||||
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Only show source handle if not an end node */}
|
||||
{!nodeData.isEndNode && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!w-2 !h-5 !rounded-l-sm !-mr-1 !border-0 transition-colors"
|
||||
position={sourcePosition}
|
||||
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
|
||||
style={{
|
||||
backgroundColor: nodeData.state === "running" ? "#3b82f6" :
|
||||
backgroundColor: nodeData.state === "running" ? "#643FB2" :
|
||||
nodeData.state === "completed" ? "#10b981" :
|
||||
nodeData.state === "failed" ? "#ef4444" :
|
||||
nodeData.state === "cancelled" ? "#f97316" : "#9ca3af"
|
||||
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="p-4">
|
||||
<div className="p-3">
|
||||
{/* Header with icon and title */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<IconComponent
|
||||
className={cn("w-5 h-5", config.iconColor, config.animate)}
|
||||
/>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 relative">
|
||||
{/* Icon container with dark background */}
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-900/90 dark:bg-gray-800/90 flex items-center justify-center">
|
||||
{nodeData.isStartNode ? (
|
||||
<Home className="w-5 h-5 text-[#643FB2] dark:text-[#8B5CF6]" />
|
||||
) : (
|
||||
<Workflow className="w-5 h-5 text-gray-300 dark:text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
{/* Small status badge for running state */}
|
||||
{isRunning && (
|
||||
<div className={cn(
|
||||
"absolute -top-1 -right-1 w-3 h-3 rounded-full animate-pulse",
|
||||
config.badgeColor
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
|
||||
{nodeData.name || nodeData.executorId}
|
||||
</h3>
|
||||
{nodeData.executorType && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
|
||||
{nodeData.executorType}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* State indicator */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
config.statusColor,
|
||||
config.animate
|
||||
)}
|
||||
/>
|
||||
<span className={cn("text-xs font-medium", config.iconColor)}>
|
||||
{config.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Data details */}
|
||||
{hasData && (
|
||||
<div className="mt-3">
|
||||
@@ -267,7 +225,7 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
|
||||
|
||||
{/* Running animation overlay */}
|
||||
{isRunning && (
|
||||
<div className="absolute inset-0 rounded border-2 border-blue-500/30 dark:border-blue-400/30 animate-pulse pointer-events-none" />
|
||||
<div className="absolute inset-0 rounded border-2 border-[#643FB2]/30 dark:border-[#8B5CF6]/30 animate-pulse pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Maximize,
|
||||
Shuffle,
|
||||
Zap,
|
||||
ArrowDown,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -53,11 +54,15 @@ function ViewOptionsPanel({
|
||||
onNodeSelect,
|
||||
viewOptions,
|
||||
onToggleViewOption,
|
||||
layoutDirection,
|
||||
onLayoutDirectionChange,
|
||||
}: {
|
||||
workflowDump?: Workflow;
|
||||
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
|
||||
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean };
|
||||
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
|
||||
layoutDirection: "LR" | "TB";
|
||||
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
|
||||
}) {
|
||||
const { fitView, setViewport, setNodes } = useReactFlow();
|
||||
|
||||
@@ -71,9 +76,17 @@ function ViewOptionsPanel({
|
||||
|
||||
const handleAutoArrange = () => {
|
||||
if (!workflowDump) return;
|
||||
const currentNodes = convertWorkflowDumpToNodes(workflowDump, onNodeSelect);
|
||||
const currentNodes = convertWorkflowDumpToNodes(
|
||||
workflowDump,
|
||||
onNodeSelect,
|
||||
layoutDirection
|
||||
);
|
||||
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
|
||||
const layoutedNodes = applyDagreLayout(currentNodes, currentEdges, "LR");
|
||||
const layoutedNodes = applyDagreLayout(
|
||||
currentNodes,
|
||||
currentEdges,
|
||||
layoutDirection
|
||||
);
|
||||
setNodes(layoutedNodes);
|
||||
};
|
||||
|
||||
@@ -122,6 +135,35 @@ function ViewOptionsPanel({
|
||||
<Checkbox checked={viewOptions.animateRun} onChange={() => {}} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="flex items-center justify-between"
|
||||
onClick={() => {
|
||||
const newDirection = layoutDirection === "LR" ? "TB" : "LR";
|
||||
onLayoutDirectionChange?.(newDirection);
|
||||
// Re-apply layout with new direction
|
||||
if (workflowDump) {
|
||||
const currentNodes = convertWorkflowDumpToNodes(
|
||||
workflowDump,
|
||||
onNodeSelect,
|
||||
newDirection
|
||||
);
|
||||
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
|
||||
const layoutedNodes = applyDagreLayout(
|
||||
currentNodes,
|
||||
currentEdges,
|
||||
newDirection
|
||||
);
|
||||
setNodes(layoutedNodes);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<ArrowDown className="mr-2 h-4 w-4" />
|
||||
Vertical Layout
|
||||
</div>
|
||||
<Checkbox checked={layoutDirection === "TB"} onChange={() => {}} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleResetZoom}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reset Zoom
|
||||
@@ -154,6 +196,8 @@ interface WorkflowFlowProps {
|
||||
onToggleViewOption?: (
|
||||
key: keyof NonNullable<WorkflowFlowProps["viewOptions"]>
|
||||
) => void;
|
||||
layoutDirection?: "LR" | "TB";
|
||||
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
|
||||
}
|
||||
|
||||
// Animation handler component that runs inside ReactFlow context
|
||||
@@ -212,6 +256,8 @@ export function WorkflowFlow({
|
||||
className = "",
|
||||
viewOptions = { showMinimap: false, showGrid: true, animateRun: true },
|
||||
onToggleViewOption,
|
||||
layoutDirection = "LR",
|
||||
onLayoutDirectionChange,
|
||||
}: WorkflowFlowProps) {
|
||||
// Create initial nodes and edges from workflow dump
|
||||
const { initialNodes, initialEdges } = useMemo(() => {
|
||||
@@ -219,18 +265,24 @@ export function WorkflowFlow({
|
||||
return { initialNodes: [], initialEdges: [] };
|
||||
}
|
||||
|
||||
const nodes = convertWorkflowDumpToNodes(workflowDump, onNodeSelect);
|
||||
const nodes = convertWorkflowDumpToNodes(
|
||||
workflowDump,
|
||||
onNodeSelect,
|
||||
layoutDirection
|
||||
);
|
||||
const edges = convertWorkflowDumpToEdges(workflowDump);
|
||||
|
||||
// Apply auto-layout if we have nodes and edges
|
||||
const layoutedNodes =
|
||||
nodes.length > 0 ? applyDagreLayout(nodes, edges, "LR") : nodes;
|
||||
nodes.length > 0
|
||||
? applyDagreLayout(nodes, edges, layoutDirection)
|
||||
: nodes;
|
||||
|
||||
return {
|
||||
initialNodes: layoutedNodes,
|
||||
initialEdges: edges,
|
||||
};
|
||||
}, [workflowDump, onNodeSelect]);
|
||||
}, [workflowDump, onNodeSelect, layoutDirection]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] =
|
||||
useNodesState<Node<ExecutorNodeData>>(initialNodes);
|
||||
@@ -388,7 +440,7 @@ export function WorkflowFlow({
|
||||
const state = data?.state;
|
||||
switch (state) {
|
||||
case "running":
|
||||
return "#3b82f6";
|
||||
return "#643FB2";
|
||||
case "completed":
|
||||
return "#10b981";
|
||||
case "failed":
|
||||
@@ -420,6 +472,8 @@ export function WorkflowFlow({
|
||||
onNodeSelect={onNodeSelect}
|
||||
viewOptions={viewOptions}
|
||||
onToggleViewOption={onToggleViewOption}
|
||||
layoutDirection={layoutDirection}
|
||||
onLayoutDirectionChange={onLayoutDirectionChange}
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ import {
|
||||
Settings,
|
||||
RotateCcw,
|
||||
ChevronDown,
|
||||
Package,
|
||||
FolderOpen,
|
||||
Database,
|
||||
Globe,
|
||||
XCircle,
|
||||
Workflow as WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import { LoadingState } from "@/components/ui/loading-state";
|
||||
import { WorkflowInputForm } from "@/components/workflow/workflow-input-form";
|
||||
@@ -197,7 +203,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">
|
||||
<DialogHeader>
|
||||
<DialogHeader className="px-8 pt-6">
|
||||
<DialogTitle>Configure Workflow Inputs</DialogTitle>
|
||||
<DialogClose onClose={() => setShowModal(false)} />
|
||||
</DialogHeader>
|
||||
@@ -267,6 +273,7 @@ export function WorkflowView({
|
||||
const [workflowResult, setWorkflowResult] = useState<string>("");
|
||||
const [workflowError, setWorkflowError] = useState<string>("");
|
||||
const accumulatedText = useRef<string>("");
|
||||
const [detailsExpanded, setDetailsExpanded] = useState(false);
|
||||
|
||||
// Panel resize state
|
||||
const [bottomPanelHeight, setBottomPanelHeight] = useState(() => {
|
||||
@@ -287,6 +294,12 @@ export function WorkflowView({
|
||||
};
|
||||
});
|
||||
|
||||
// Layout direction state
|
||||
const [layoutDirection, setLayoutDirection] = useState<"LR" | "TB">(() => {
|
||||
const saved = localStorage.getItem("workflowLayoutDirection");
|
||||
return (saved as "LR" | "TB") || "LR";
|
||||
});
|
||||
|
||||
const { selectExecutor, getExecutorData } = useWorkflowEventCorrelation(
|
||||
openAIEvents,
|
||||
isStreaming
|
||||
@@ -297,6 +310,11 @@ export function WorkflowView({
|
||||
localStorage.setItem("workflowViewOptions", JSON.stringify(viewOptions));
|
||||
}, [viewOptions]);
|
||||
|
||||
// Save layout direction to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("workflowLayoutDirection", layoutDirection);
|
||||
}, [layoutDirection]);
|
||||
|
||||
// View option handlers
|
||||
const toggleViewOption = (key: keyof typeof viewOptions) => {
|
||||
setViewOptions((prev: typeof viewOptions) => ({
|
||||
@@ -467,7 +485,11 @@ export function WorkflowView({
|
||||
event_type?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
if (data.event_type === "WorkflowCompletedEvent" && data.data) {
|
||||
if (
|
||||
(data.event_type === "WorkflowCompletedEvent" ||
|
||||
data.event_type === "WorkflowOutputEvent") &&
|
||||
data.data
|
||||
) {
|
||||
setWorkflowResult(String(data.data));
|
||||
}
|
||||
}
|
||||
@@ -517,55 +539,149 @@ export function WorkflowView({
|
||||
|
||||
return (
|
||||
<div className="workflow-view flex flex-col h-full">
|
||||
{/* Top Panel - Workflow Visualization */}
|
||||
<div className="flex-1 min-h-0 p-4">
|
||||
{/* Workflow Diagram Section */}
|
||||
{workflowInfo?.workflow_dump && (
|
||||
<div className="border border-border rounded bg-card shadow-sm h-full flex flex-col">
|
||||
<div className="border-b border-border px-4 py-3 bg-muted rounded-t flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
Workflow Visualization
|
||||
</h3>
|
||||
|
||||
{/* Smart Run Workflow CTA - Show for all states */}
|
||||
{workflowInfo && (
|
||||
<div className="flex items-center gap-3">
|
||||
<RunWorkflowButton
|
||||
inputSchema={workflowInfo.input_schema}
|
||||
onRun={handleSendWorkflowData}
|
||||
isSubmitting={isStreaming}
|
||||
workflowState={
|
||||
isStreaming
|
||||
? "running"
|
||||
: workflowError
|
||||
? "error"
|
||||
: executorHistory.length > 0
|
||||
? "completed"
|
||||
: "ready"
|
||||
}
|
||||
executorHistory={executorHistory}
|
||||
workflowError={workflowError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status is now handled by the RunWorkflowButton component */}
|
||||
{/* Header */}
|
||||
<div className="border-b pb-2 p-4 flex-shrink-0">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h2 className="font-semibold text-sm truncate">
|
||||
<div className="flex items-center gap-2">
|
||||
<WorkflowIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{selectedWorkflow.name || selectedWorkflow.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<WorkflowFlow
|
||||
workflowDump={workflowInfo.workflow_dump}
|
||||
events={openAIEvents}
|
||||
isStreaming={isStreaming}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
className="h-full"
|
||||
viewOptions={viewOptions}
|
||||
onToggleViewOption={toggleViewOption}
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDetailsExpanded(!detailsExpanded)}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform duration-200 ${
|
||||
detailsExpanded ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Run Workflow Controls */}
|
||||
{workflowInfo && (
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 flex-shrink-0">
|
||||
<RunWorkflowButton
|
||||
inputSchema={workflowInfo.input_schema}
|
||||
onRun={handleSendWorkflowData}
|
||||
isSubmitting={isStreaming}
|
||||
workflowState={
|
||||
isStreaming
|
||||
? "running"
|
||||
: workflowError
|
||||
? "error"
|
||||
: executorHistory.length > 0
|
||||
? "completed"
|
||||
: "ready"
|
||||
}
|
||||
executorHistory={executorHistory}
|
||||
workflowError={workflowError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedWorkflow.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedWorkflow.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Executors - Always visible */}
|
||||
{selectedWorkflow.executors.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs mt-2 mb-2">
|
||||
<Package className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Executors:</span>
|
||||
<span className="font-mono">
|
||||
{selectedWorkflow.executors.slice(0, 3).join(", ")}
|
||||
{selectedWorkflow.executors.length > 3 && "..."}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
({selectedWorkflow.executors.length})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsible Details Section */}
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-200 ease-in-out ${
|
||||
detailsExpanded ? "max-h-40 mt-3" : "max-h-0"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-2 text-xs">
|
||||
{/* Start Executor */}
|
||||
<div className="flex items-center gap-2">
|
||||
<WorkflowIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Start:</span>
|
||||
<span className="font-mono">
|
||||
{selectedWorkflow.start_executor_id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedWorkflow.source === "directory" ? (
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
) : selectedWorkflow.source === "in_memory" ? (
|
||||
<Database className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-muted-foreground">Source:</span>
|
||||
<span>
|
||||
{selectedWorkflow.source === "directory"
|
||||
? "Local"
|
||||
: selectedWorkflow.source === "in_memory"
|
||||
? "In-Memory"
|
||||
: "Gallery"}
|
||||
</span>
|
||||
{selectedWorkflow.module_path && (
|
||||
<span className="text-muted-foreground font-mono text-[11px]">
|
||||
({selectedWorkflow.module_path})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Environment */}
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedWorkflow.has_env ? (
|
||||
<XCircle className="h-3.5 w-3.5 text-orange-500" />
|
||||
) : (
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||
)}
|
||||
<span className="text-muted-foreground">Environment:</span>
|
||||
<span>
|
||||
{selectedWorkflow.has_env
|
||||
? "Requires environment variables"
|
||||
: "No environment variables required"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow Visualization */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{workflowInfo?.workflow_dump && (
|
||||
<WorkflowFlow
|
||||
workflowDump={workflowInfo.workflow_dump}
|
||||
events={openAIEvents}
|
||||
isStreaming={isStreaming}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
className="h-full"
|
||||
viewOptions={viewOptions}
|
||||
onToggleViewOption={toggleViewOption}
|
||||
layoutDirection={layoutDirection}
|
||||
onLayoutDirectionChange={setLayoutDirection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
@@ -598,13 +714,17 @@ export function WorkflowView({
|
||||
executorHistory.length > 0 ||
|
||||
workflowResult ||
|
||||
workflowError ? (
|
||||
<div className="h-full space-y-4">
|
||||
<div className="h-full flex gap-4">
|
||||
{/* Current/Last Executor Panel */}
|
||||
{(selectedExecutor ||
|
||||
activeExecutors.length > 0 ||
|
||||
executorHistory.length > 0) && (
|
||||
<div className="border border-border rounded bg-card shadow-sm">
|
||||
<div className="border-b border-border px-4 py-3 bg-muted rounded-t">
|
||||
<div
|
||||
className={`border border-border rounded bg-card shadow-sm flex flex-col ${
|
||||
workflowResult || workflowError ? "flex-1" : "w-full"
|
||||
}`}
|
||||
>
|
||||
<div className="border-b border-border px-4 py-3 bg-muted rounded-t flex-shrink-0">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{selectedExecutor
|
||||
? `Executor: ${
|
||||
@@ -615,14 +735,14 @@ export function WorkflowView({
|
||||
: "Last Executor"}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="p-4 overflow-auto flex-1">
|
||||
{selectedExecutor ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
selectedExecutor.state === "running"
|
||||
? "bg-blue-500 dark:bg-blue-400 animate-pulse"
|
||||
? "bg-[#643FB2] dark:bg-[#8B5CF6] animate-pulse"
|
||||
: selectedExecutor.state === "completed"
|
||||
? "bg-green-500 dark:bg-green-400"
|
||||
: selectedExecutor.state === "failed"
|
||||
@@ -754,7 +874,7 @@ export function WorkflowView({
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
isStreaming
|
||||
? "bg-blue-500 dark:bg-blue-400 animate-pulse"
|
||||
? "bg-[#643FB2] dark:bg-[#8B5CF6] animate-pulse"
|
||||
: historyItem?.status === "completed"
|
||||
? "bg-green-500 dark:bg-green-400"
|
||||
: historyItem?.status === "error"
|
||||
@@ -791,8 +911,8 @@ export function WorkflowView({
|
||||
|
||||
{/* Enhanced Result Display */}
|
||||
{workflowResult && (
|
||||
<div className="border-2 border-emerald-300 dark:border-emerald-600 rounded bg-emerald-50 dark:bg-emerald-950/50 shadow">
|
||||
<div className="border-b border-emerald-300 dark:border-emerald-600 px-4 py-3 bg-emerald-100 dark:bg-emerald-900/50 rounded-t">
|
||||
<div className="border-2 border-emerald-300 dark:border-emerald-600 rounded bg-emerald-50 dark:bg-emerald-950/50 shadow flex-1 flex flex-col">
|
||||
<div className="border-b border-emerald-300 dark:border-emerald-600 px-4 py-3 bg-emerald-100 dark:bg-emerald-900/50 rounded-t flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
|
||||
<h4 className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
|
||||
@@ -800,7 +920,7 @@ export function WorkflowView({
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="p-4 overflow-auto flex-1">
|
||||
<div className="text-emerald-700 dark:text-emerald-300 whitespace-pre-wrap break-words text-sm">
|
||||
{workflowResult}
|
||||
</div>
|
||||
@@ -810,8 +930,8 @@ export function WorkflowView({
|
||||
|
||||
{/* Enhanced Error Display */}
|
||||
{workflowError && (
|
||||
<div className="border-2 border-destructive/70 rounded bg-destructive/5 shadow">
|
||||
<div className="border-b border-destructive/70 px-4 py-3 bg-destructive/10 rounded-t">
|
||||
<div className="border-2 border-destructive/70 rounded bg-destructive/5 shadow flex-1 flex flex-col">
|
||||
<div className="border-b border-destructive/70 px-4 py-3 bg-destructive/10 rounded-t flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="w-4 h-4 text-destructive" />
|
||||
<h4 className="text-sm font-semibold text-destructive">
|
||||
@@ -819,7 +939,7 @@ export function WorkflowView({
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="p-4 overflow-auto flex-1">
|
||||
<div className="text-destructive whitespace-pre-wrap break-words text-sm">
|
||||
{workflowError}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Gallery data exports
|
||||
*/
|
||||
|
||||
export * from './sample-entities';
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Sample entities for the gallery - curated examples to help users learn Agent Framework
|
||||
*/
|
||||
|
||||
export interface EnvVarRequirement {
|
||||
name: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
example?: string;
|
||||
}
|
||||
|
||||
export interface SampleEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: "agent" | "workflow";
|
||||
url: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
difficulty: "beginner" | "intermediate" | "advanced";
|
||||
features: string[];
|
||||
requiredEnvVars?: EnvVarRequirement[];
|
||||
}
|
||||
|
||||
export const SAMPLE_ENTITIES: SampleEntity[] = [
|
||||
// Beginner Agents
|
||||
{
|
||||
id: "weather-agent",
|
||||
name: "Weather Agent",
|
||||
description:
|
||||
"Simple weather agent with mock data demonstrating basic tool usage",
|
||||
type: "agent",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/weather_agent/agent.py",
|
||||
tags: ["openai", "tools", "basic"],
|
||||
author: "Microsoft",
|
||||
difficulty: "beginner",
|
||||
features: [
|
||||
"Function calling",
|
||||
"Mock weather data",
|
||||
"Simple tool integration",
|
||||
],
|
||||
requiredEnvVars: [
|
||||
{
|
||||
name: "OPENAI_API_KEY",
|
||||
description: "OpenAI API key for chat completions",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "OPENAI_CHAT_MODEL_ID",
|
||||
description: "OpenAI model ID (e.g., gpt-4o)",
|
||||
required: false,
|
||||
example: "gpt-4o",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: "foundry-weather-agent",
|
||||
name: "Azure AI Weather Agent",
|
||||
description:
|
||||
"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",
|
||||
type: "agent",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/foundry_agent/agent.py",
|
||||
tags: ["azure-ai", "foundry", "tools"],
|
||||
author: "Microsoft",
|
||||
difficulty: "beginner",
|
||||
features: [
|
||||
"Azure AI Agent integration",
|
||||
"Azure CLI authentication",
|
||||
"Mock weather tools",
|
||||
],
|
||||
requiredEnvVars: [
|
||||
{
|
||||
name: "AZURE_AI_PROJECT_ENDPOINT",
|
||||
description: "Azure AI Foundry project endpoint URL",
|
||||
required: true,
|
||||
example: "https://your-project.api.azureml.ms",
|
||||
},
|
||||
{
|
||||
name: "FOUNDRY_MODEL_DEPLOYMENT_NAME",
|
||||
description: "Name of the deployed model in Azure AI Foundry",
|
||||
required: true,
|
||||
example: "gpt-4o",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: "weather-agent-azure",
|
||||
name: "Azure OpenAI Weather Agent",
|
||||
description:
|
||||
"Weather agent using Azure OpenAI with API key authentication",
|
||||
type: "agent",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/weather_agent_azure/agent.py",
|
||||
tags: ["azure", "openai", "tools"],
|
||||
author: "Microsoft",
|
||||
difficulty: "beginner",
|
||||
features: [
|
||||
"Azure OpenAI integration",
|
||||
"API key authentication",
|
||||
"Function calling",
|
||||
"Mock weather tools",
|
||||
],
|
||||
requiredEnvVars: [
|
||||
{
|
||||
name: "AZURE_OPENAI_API_KEY",
|
||||
description: "Azure OpenAI API key",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME",
|
||||
description: "Name of the deployed model in Azure OpenAI",
|
||||
required: true,
|
||||
example: "gpt-4o",
|
||||
},
|
||||
{
|
||||
name: "AZURE_OPENAI_ENDPOINT",
|
||||
description: "Azure OpenAI endpoint URL",
|
||||
required: true,
|
||||
example: "https://your-resource.openai.azure.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Beginner Workflows
|
||||
{
|
||||
id: "spam-workflow",
|
||||
name: "Spam Detection Workflow",
|
||||
description:
|
||||
"5-step workflow demonstrating email spam detection with branching logic",
|
||||
type: "workflow",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/spam_workflow/workflow.py",
|
||||
tags: ["workflow", "branching", "multi-step"],
|
||||
author: "Microsoft",
|
||||
difficulty: "beginner",
|
||||
features: [
|
||||
"Sequential execution",
|
||||
"Conditional branching",
|
||||
"Mock spam detection",
|
||||
],
|
||||
},
|
||||
|
||||
// Advanced Workflows
|
||||
{
|
||||
id: "fanout-workflow",
|
||||
name: "Complex Fan-In/Fan-Out Workflow",
|
||||
description:
|
||||
"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",
|
||||
type: "workflow",
|
||||
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/fanout_workflow/workflow.py",
|
||||
tags: ["workflow", "fan-out", "fan-in", "parallel"],
|
||||
author: "Microsoft",
|
||||
difficulty: "advanced",
|
||||
features: [
|
||||
"Fan-out pattern",
|
||||
"Parallel execution",
|
||||
"Complex state management",
|
||||
"Multi-stage processing",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Group samples by category for better organization
|
||||
export const SAMPLE_CATEGORIES = {
|
||||
all: SAMPLE_ENTITIES,
|
||||
agents: SAMPLE_ENTITIES.filter((e) => e.type === "agent"),
|
||||
workflows: SAMPLE_ENTITIES.filter((e) => e.type === "workflow"),
|
||||
beginner: SAMPLE_ENTITIES.filter((e) => e.difficulty === "beginner"),
|
||||
intermediate: SAMPLE_ENTITIES.filter((e) => e.difficulty === "intermediate"),
|
||||
advanced: SAMPLE_ENTITIES.filter((e) => e.difficulty === "advanced"),
|
||||
};
|
||||
|
||||
// Get difficulty color for badges
|
||||
export const getDifficultyColor = (difficulty: SampleEntity["difficulty"]) => {
|
||||
switch (difficulty) {
|
||||
case "beginner":
|
||||
return "bg-green-100 text-green-700 border-green-200";
|
||||
case "intermediate":
|
||||
return "bg-yellow-100 text-yellow-700 border-yellow-200";
|
||||
case "advanced":
|
||||
return "bg-red-100 text-red-700 border-red-200";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700 border-gray-200";
|
||||
}
|
||||
};
|
||||
@@ -49,7 +49,7 @@
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary: oklch(0.48 0.18 290);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
@@ -83,8 +83,8 @@
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--primary: oklch(0.62 0.20 290);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import type {
|
||||
AgentInfo,
|
||||
AgentSource,
|
||||
HealthResponse,
|
||||
RunAgentRequest,
|
||||
RunWorkflowRequest,
|
||||
@@ -22,6 +23,8 @@ interface EntityInfo {
|
||||
framework: string;
|
||||
tools?: (string | Record<string, unknown>)[];
|
||||
metadata: Record<string, unknown>;
|
||||
source?: string;
|
||||
original_url?: string;
|
||||
executors?: string[];
|
||||
workflow_dump?: Record<string, unknown>;
|
||||
input_schema?: Record<string, unknown>;
|
||||
@@ -52,16 +55,30 @@ interface ThreadApiObject {
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
const API_BASE_URL =
|
||||
const DEFAULT_API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL !== undefined
|
||||
? import.meta.env.VITE_API_BASE_URL
|
||||
: "http://localhost:8080";
|
||||
|
||||
// Get backend URL from localStorage or default
|
||||
function getBackendUrl(): string {
|
||||
return localStorage.getItem("devui_backend_url") || DEFAULT_API_BASE_URL;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string = API_BASE_URL) {
|
||||
this.baseUrl = baseUrl;
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl || getBackendUrl();
|
||||
}
|
||||
|
||||
// Allow updating the base URL at runtime
|
||||
setBaseUrl(url: string) {
|
||||
this.baseUrl = url;
|
||||
}
|
||||
|
||||
getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
@@ -79,9 +96,17 @@ class ApiClient {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`API request failed: ${response.status} ${response.statusText}`
|
||||
);
|
||||
// Try to extract error message from response body
|
||||
let errorMessage = `API request failed: ${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.detail) {
|
||||
errorMessage = errorData.detail;
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, use default message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@@ -111,7 +136,7 @@ class ApiClient {
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
type: "agent",
|
||||
source: "directory", // Default source
|
||||
source: (entity.source as AgentSource) || "directory",
|
||||
tools: (entity.tools || []).map((tool) =>
|
||||
typeof tool === "string" ? tool : JSON.stringify(tool)
|
||||
),
|
||||
@@ -130,7 +155,7 @@ class ApiClient {
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
type: "workflow",
|
||||
source: "directory",
|
||||
source: (entity.source as AgentSource) || "directory",
|
||||
executors: (entity.tools || []).map((tool) =>
|
||||
typeof tool === "string" ? tool : JSON.stringify(tool)
|
||||
),
|
||||
@@ -440,6 +465,31 @@ class ApiClient {
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// Add entity from URL
|
||||
async addEntity(url: string, metadata?: Record<string, unknown>): Promise<EntityInfo> {
|
||||
const response = await this.request<{ success: boolean; entity: EntityInfo }>("/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
|
||||
|
||||
@@ -238,9 +238,10 @@ export interface AgentThread {
|
||||
|
||||
// Workflow events
|
||||
export interface WorkflowEvent {
|
||||
type?: string; // Event class name like "WorkflowCompletedEvent", "ExecutorInvokedEvent", etc.
|
||||
type?: string; // Event class name like "WorkflowOutputEvent", "WorkflowCompletedEvent", "ExecutorInvokedEvent", etc.
|
||||
data?: unknown;
|
||||
executor_id?: string; // Present for executor-related events
|
||||
source_executor_id?: string; // Present for WorkflowOutputEvent
|
||||
}
|
||||
|
||||
export interface WorkflowStartedEvent extends WorkflowEvent {
|
||||
@@ -249,10 +250,16 @@ export interface WorkflowStartedEvent extends WorkflowEvent {
|
||||
}
|
||||
|
||||
export interface WorkflowCompletedEvent extends WorkflowEvent {
|
||||
// Event-specific data for workflow completion
|
||||
// Event-specific data for workflow completion (legacy)
|
||||
readonly event_type: "workflow_completed";
|
||||
}
|
||||
|
||||
export interface WorkflowOutputEvent extends WorkflowEvent {
|
||||
// Event-specific data for workflow output (new)
|
||||
readonly event_type: "workflow_output";
|
||||
source_executor_id: string; // ID of executor that yielded the output
|
||||
}
|
||||
|
||||
export interface WorkflowWarningEvent extends WorkflowEvent {
|
||||
data: string; // Warning message
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
export type AgentType = "agent" | "workflow";
|
||||
export type AgentSource = "directory" | "in_memory";
|
||||
export type AgentSource = "directory" | "in_memory" | "remote_gallery";
|
||||
export type StreamEventType =
|
||||
| "agent_run_update"
|
||||
| "workflow_event"
|
||||
@@ -14,6 +14,13 @@ export type StreamEventType =
|
||||
| "debug_trace"
|
||||
| "trace_span";
|
||||
|
||||
export interface EnvVarRequirement {
|
||||
name: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
example?: string;
|
||||
}
|
||||
|
||||
export interface AgentInfo {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -23,6 +30,7 @@ export interface AgentInfo {
|
||||
tools: string[];
|
||||
has_env: boolean;
|
||||
module_path?: string;
|
||||
required_env_vars?: EnvVarRequirement[];
|
||||
}
|
||||
|
||||
// JSON Schema types for workflow input
|
||||
|
||||
@@ -45,7 +45,7 @@ export function applySimpleLayout(
|
||||
// Constants for spacing
|
||||
const NODE_WIDTH = 220;
|
||||
const NODE_HEIGHT = 120;
|
||||
const HORIZONTAL_SPACING = direction === "LR" ? 350 : 200;
|
||||
const HORIZONTAL_SPACING = direction === "LR" ? 350 : 280;
|
||||
const VERTICAL_SPACING = direction === "TB" ? 250 : 180;
|
||||
|
||||
// Track positioned nodes and level information
|
||||
|
||||
@@ -54,7 +54,8 @@ export interface NodeUpdate {
|
||||
*/
|
||||
export function convertWorkflowDumpToNodes(
|
||||
workflowDump: Workflow | Record<string, unknown> | undefined,
|
||||
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void
|
||||
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void,
|
||||
layoutDirection?: "LR" | "TB"
|
||||
): Node<ExecutorNodeData>[] {
|
||||
if (!workflowDump) {
|
||||
console.warn("convertWorkflowDumpToNodes: workflowDump is undefined");
|
||||
@@ -108,6 +109,7 @@ export function convertWorkflowDumpToNodes(
|
||||
name: executor.name || executor.id,
|
||||
state: "pending" as ExecutorState,
|
||||
isStartNode: executor.id === startExecutorId,
|
||||
layoutDirection: layoutDirection || "LR",
|
||||
onNodeClick,
|
||||
},
|
||||
}));
|
||||
@@ -339,7 +341,7 @@ export function processWorkflowEvents(
|
||||
error = typeof eventData === "string" ? eventData : "Execution failed";
|
||||
} else if (eventType?.includes("Cancel")) {
|
||||
state = "cancelled";
|
||||
} else if (eventType === "WorkflowCompletedEvent") {
|
||||
} else if (eventType === "WorkflowCompletedEvent" || eventType === "WorkflowOutputEvent") {
|
||||
state = "completed";
|
||||
}
|
||||
|
||||
@@ -376,6 +378,8 @@ export function updateNodesWithEvents(
|
||||
state: update.state,
|
||||
outputData: update.data,
|
||||
error: update.error,
|
||||
// Preserve layoutDirection
|
||||
layoutDirection: node.data.layoutDirection,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -478,7 +482,7 @@ export function updateEdgesWithSequenceAnalysis(
|
||||
// Active edge: source completed and target is currently executing
|
||||
if (sourceState?.completed && targetIsExecuting) {
|
||||
style = {
|
||||
stroke: "#3b82f6", // Blue
|
||||
stroke: "#643FB2", // Purple accent
|
||||
strokeWidth: 3,
|
||||
strokeDasharray: "5,5",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user