mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Add Function Approval UI to DevUI (#1401)
* ensure function aproval is parsed correctly * udpate ui, add deployment guide button, other debug panel fixes * feat(devui): Implement lazy loading architecture with enhanced security and state management Major architectural improvements to DevUI for better performance, security, and developer experience: Performance & Architecture: - Implement lazy loading for entity discovery - entities loaded on-demand instead of at startup - Add hot reload capability for development workflow via new reload endpoint - Reduce startup time and memory footprint by deferring module imports Security Enhancements: - Remove remote entity loading capabilities (POST /v1/entities/add, DELETE endpoints) - DevUI now strictly local development tool - no remote code execution - Add explicit security documentation and best practices in README Frontend Improvements: - Migrate to Zustand for centralized state management (replacing prop drilling) - Add lightweight zero-dependency markdown renderer with code block copy support - Improve gallery UX with setup instructions modal instead of direct URL loading - Enhanced message UI with copy functionality and better token usage display Testing & Quality: - Expand test coverage for lazy loading, type detection, and cache invalidation - Add comprehensive tests for new behaviors (+231 lines of test code) - Improve type safety and documentation throughout Breaking Changes: - Remote entity loading via URLs is no longer supported - Entities must be loaded from local filesystem only * update ui issues, uupdate test descripion
This commit is contained in:
committed by
GitHub
Unverified
parent
331c750515
commit
b64358df7e
@@ -34,6 +34,8 @@ import {
|
||||
FileText,
|
||||
Check,
|
||||
X,
|
||||
Copy,
|
||||
CheckCheck,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "@/services/api";
|
||||
import type {
|
||||
@@ -41,13 +43,8 @@ import type {
|
||||
RunAgentRequest,
|
||||
Conversation,
|
||||
ExtendedResponseStreamEvent,
|
||||
PendingApproval,
|
||||
} from "@/types";
|
||||
|
||||
interface ChatState {
|
||||
items: import("@/types/openai").ConversationItem[]; // Pure OpenAI types - no legacy ChatMessage
|
||||
isStreaming: boolean;
|
||||
}
|
||||
import { useDevUIStore } from "@/stores";
|
||||
|
||||
type DebugEventHandler = (event: ExtendedResponseStreamEvent | "clear") => void;
|
||||
|
||||
@@ -61,14 +58,46 @@ interface ConversationItemBubbleProps {
|
||||
}
|
||||
|
||||
function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Extract text content from message for copying
|
||||
const getMessageText = () => {
|
||||
if (item.type === "message") {
|
||||
return item.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => (c as import("@/types/openai").MessageTextContent).text)
|
||||
.join("\n");
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = getMessageText();
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle different item types
|
||||
if (item.type === "message") {
|
||||
const isUser = item.role === "user";
|
||||
const isError = item.status === "incomplete";
|
||||
const Icon = isUser ? User : isError ? AlertCircle : Bot;
|
||||
const messageText = getMessageText();
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}>
|
||||
<div
|
||||
className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border ${
|
||||
isUser
|
||||
@@ -86,26 +115,48 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
|
||||
isUser ? "items-end" : "items-start"
|
||||
} max-w-[80%]`}
|
||||
>
|
||||
<div
|
||||
className={`rounded px-3 py-2 text-sm break-all ${
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isError
|
||||
? "bg-orange-50 dark:bg-orange-950/50 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
>
|
||||
{isError && (
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<AlertCircle className="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="font-medium text-sm">
|
||||
Unable to process request
|
||||
</span>
|
||||
<div className="relative group">
|
||||
<div
|
||||
className={`rounded px-3 py-2 text-sm break-all ${
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isError
|
||||
? "bg-orange-50 dark:bg-orange-950/50 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
>
|
||||
{isError && (
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<AlertCircle className="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="font-medium text-sm">
|
||||
Unable to process request
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={isError ? "text-xs leading-relaxed break-all" : ""}>
|
||||
<OpenAIMessageRenderer item={item} />
|
||||
</div>
|
||||
)}
|
||||
<div className={isError ? "text-xs leading-relaxed break-all" : ""}>
|
||||
<OpenAIMessageRenderer item={item} />
|
||||
</div>
|
||||
|
||||
{/* Copy button - appears on hover, always top-right inside */}
|
||||
{messageText && isHovered && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-1 right-1
|
||||
p-1.5 rounded-md border shadow-sm
|
||||
bg-background hover:bg-accent
|
||||
text-muted-foreground hover:text-foreground
|
||||
transition-all duration-200 ease-in-out
|
||||
opacity-0 group-hover:opacity-100"
|
||||
title={copied ? "Copied!" : "Copy message"}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckCheck className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
|
||||
@@ -115,10 +166,10 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
↓{item.usage.input_tokens}
|
||||
↑{item.usage.input_tokens}
|
||||
</span>
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
↑{item.usage.output_tokens}
|
||||
↓{item.usage.output_tokens}
|
||||
</span>
|
||||
<span>({item.usage.total_tokens} tokens)</span>
|
||||
</span>
|
||||
@@ -146,33 +197,35 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
|
||||
}
|
||||
|
||||
export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const [chatState, setChatState] = useState<ChatState>({
|
||||
items: [],
|
||||
isStreaming: false,
|
||||
});
|
||||
const [currentConversation, setCurrentConversation] = useState<
|
||||
Conversation | undefined
|
||||
>(undefined);
|
||||
const [availableConversations, setAvailableConversations] = useState<
|
||||
Conversation[]
|
||||
>([]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
|
||||
const [loadingConversations, setLoadingConversations] = useState(false);
|
||||
// Get conversation state from Zustand
|
||||
const currentConversation = useDevUIStore((state) => state.currentConversation);
|
||||
const availableConversations = useDevUIStore((state) => state.availableConversations);
|
||||
const chatItems = useDevUIStore((state) => state.chatItems);
|
||||
const isStreaming = useDevUIStore((state) => state.isStreaming);
|
||||
const isSubmitting = useDevUIStore((state) => state.isSubmitting);
|
||||
const loadingConversations = useDevUIStore((state) => state.loadingConversations);
|
||||
const inputValue = useDevUIStore((state) => state.inputValue);
|
||||
const attachments = useDevUIStore((state) => state.attachments);
|
||||
const conversationUsage = useDevUIStore((state) => state.conversationUsage);
|
||||
const pendingApprovals = useDevUIStore((state) => state.pendingApprovals);
|
||||
|
||||
// Get conversation actions from Zustand (only the ones we actually use)
|
||||
const setCurrentConversation = useDevUIStore((state) => state.setCurrentConversation);
|
||||
const setAvailableConversations = useDevUIStore((state) => state.setAvailableConversations);
|
||||
const setChatItems = useDevUIStore((state) => state.setChatItems);
|
||||
const setIsStreaming = useDevUIStore((state) => state.setIsStreaming);
|
||||
const setIsSubmitting = useDevUIStore((state) => state.setIsSubmitting);
|
||||
const setLoadingConversations = useDevUIStore((state) => state.setLoadingConversations);
|
||||
const setInputValue = useDevUIStore((state) => state.setInputValue);
|
||||
const setAttachments = useDevUIStore((state) => state.setAttachments);
|
||||
const updateConversationUsage = useDevUIStore((state) => state.updateConversationUsage);
|
||||
const setPendingApprovals = useDevUIStore((state) => state.setPendingApprovals);
|
||||
|
||||
// Local UI state (not in Zustand - component-specific)
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [dragCounter, setDragCounter] = useState(0);
|
||||
const [pasteNotification, setPasteNotification] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
|
||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||
const [conversationUsage, setConversationUsage] = useState<{
|
||||
total_tokens: number;
|
||||
message_count: number;
|
||||
}>({ total_tokens: 0, message_count: 0 });
|
||||
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -183,18 +236,48 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
} | null>(null);
|
||||
const userJustSentMessage = useRef<boolean>(false);
|
||||
|
||||
// Auto-scroll to bottom when new items arrive
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [chatState.items, chatState.isStreaming]);
|
||||
if (!messagesEndRef.current) return;
|
||||
|
||||
// Check if user is near bottom (within 100px)
|
||||
const scrollContainer = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]');
|
||||
|
||||
let shouldScroll = false;
|
||||
|
||||
if (scrollContainer) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
|
||||
|
||||
// Always scroll if user just sent a message, otherwise only if near bottom
|
||||
shouldScroll = userJustSentMessage.current || isNearBottom;
|
||||
} else {
|
||||
// Fallback if scroll container not found - always scroll
|
||||
shouldScroll = true;
|
||||
}
|
||||
|
||||
if (shouldScroll) {
|
||||
// Use instant scroll during streaming for smooth chunk additions
|
||||
// Use smooth scroll when not streaming (new messages)
|
||||
messagesEndRef.current.scrollIntoView({
|
||||
behavior: isStreaming ? "instant" : "smooth"
|
||||
});
|
||||
}
|
||||
|
||||
// Reset the flag after first scroll
|
||||
if (userJustSentMessage.current && !isStreaming) {
|
||||
userJustSentMessage.current = false;
|
||||
}
|
||||
}, [chatItems, isStreaming]);
|
||||
|
||||
// Return focus to input after streaming completes
|
||||
useEffect(() => {
|
||||
if (!chatState.isStreaming && !isSubmitting) {
|
||||
if (!isStreaming && !isSubmitting) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [chatState.isStreaming, isSubmitting]);
|
||||
}, [isStreaming, isSubmitting]);
|
||||
|
||||
// Load conversations when agent changes
|
||||
useEffect(() => {
|
||||
@@ -223,12 +306,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
);
|
||||
|
||||
// Use OpenAI ConversationItems directly (no conversion!)
|
||||
setChatState({
|
||||
items: items as import("@/types/openai").ConversationItem[],
|
||||
isStreaming: false
|
||||
});
|
||||
setChatItems(items as import("@/types/openai").ConversationItem[]);
|
||||
setIsStreaming(false);
|
||||
} catch {
|
||||
setChatState({ items: [], isStreaming: false });
|
||||
// 404 means conversation exists but has no items yet (newly created)
|
||||
// This is normal - just start with empty chat
|
||||
console.debug(`No items found for conversation ${mostRecent.id}, starting fresh`);
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
}
|
||||
|
||||
// Cache to localStorage for faster future loads
|
||||
@@ -252,11 +337,24 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const convs = JSON.parse(cached) as Conversation[];
|
||||
|
||||
if (convs.length > 0) {
|
||||
// Use most recent cached conversation
|
||||
setAvailableConversations(convs);
|
||||
setCurrentConversation(convs[0]);
|
||||
setChatState({ items: [], isStreaming: false });
|
||||
return;
|
||||
// Validate that cached conversations still exist in backend
|
||||
// Try to load items for the most recent one to verify it exists
|
||||
try {
|
||||
await apiClient.listConversationItems(convs[0].id);
|
||||
|
||||
// Success! Conversation exists in backend
|
||||
setAvailableConversations(convs);
|
||||
setCurrentConversation(convs[0]);
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
} catch {
|
||||
// Cached conversation doesn't exist anymore (server restarted)
|
||||
// Clear stale cache and create new conversation
|
||||
console.debug(`Cached conversation ${convs[0].id} no longer exists, clearing cache`);
|
||||
localStorage.removeItem(cachedKey);
|
||||
// Fall through to Step 3
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid cache - clear it
|
||||
@@ -271,28 +369,28 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
|
||||
setCurrentConversation(newConversation);
|
||||
setAvailableConversations([newConversation]);
|
||||
setChatState({ items: [], isStreaming: false });
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem(cachedKey, JSON.stringify([newConversation]));
|
||||
} catch {
|
||||
setAvailableConversations([]);
|
||||
setChatState({ items: [], isStreaming: false });
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
} finally {
|
||||
setLoadingConversations(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear chat when agent changes
|
||||
setChatState({
|
||||
items: [],
|
||||
isStreaming: false,
|
||||
});
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
setCurrentConversation(undefined);
|
||||
accumulatedText.current = "";
|
||||
|
||||
loadConversations();
|
||||
}, [selectedAgent]);
|
||||
}, [selectedAgent, setLoadingConversations, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]);
|
||||
|
||||
// Handle file uploads
|
||||
const handleFilesSelected = async (files: File[]) => {
|
||||
@@ -315,11 +413,11 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
});
|
||||
}
|
||||
|
||||
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||
setAttachments([...useDevUIStore.getState().attachments, ...newAttachments]);
|
||||
};
|
||||
|
||||
const handleRemoveAttachment = (id: string) => {
|
||||
setAttachments((prev) => prev.filter((att) => att.id !== id));
|
||||
setAttachments(useDevUIStore.getState().attachments.filter((att) => att.id !== id));
|
||||
};
|
||||
|
||||
// Drag and drop handlers
|
||||
@@ -353,7 +451,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
setIsDragOver(false);
|
||||
setDragCounter(0);
|
||||
|
||||
if (isSubmitting || chatState.isStreaming) return;
|
||||
if (isSubmitting || isStreaming) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
@@ -523,12 +621,11 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
agent_id: selectedAgent.id,
|
||||
});
|
||||
setCurrentConversation(newConversation);
|
||||
setAvailableConversations((prev) => [newConversation, ...prev]);
|
||||
setChatState({
|
||||
items: [],
|
||||
isStreaming: false,
|
||||
});
|
||||
setConversationUsage({ total_tokens: 0, message_count: 0 });
|
||||
setAvailableConversations([newConversation, ...useDevUIStore.getState().availableConversations]);
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
// Reset conversation usage by setting it to initial state
|
||||
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
|
||||
accumulatedText.current = "";
|
||||
|
||||
// Update localStorage cache with new conversation
|
||||
@@ -538,7 +635,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
} catch {
|
||||
// Failed to create conversation
|
||||
}
|
||||
}, [selectedAgent, availableConversations]);
|
||||
}, [selectedAgent, availableConversations, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
|
||||
|
||||
// Handle conversation deletion
|
||||
const handleDeleteConversation = useCallback(
|
||||
@@ -578,18 +675,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
// Select the most recent remaining conversation
|
||||
const nextConversation = updatedConversations[0];
|
||||
setCurrentConversation(nextConversation);
|
||||
setChatState({
|
||||
items: [],
|
||||
isStreaming: false,
|
||||
});
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
} else {
|
||||
// No conversations left, clear everything
|
||||
setCurrentConversation(undefined);
|
||||
setChatState({
|
||||
items: [],
|
||||
isStreaming: false,
|
||||
});
|
||||
setConversationUsage({ total_tokens: 0, message_count: 0 });
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
|
||||
accumulatedText.current = "";
|
||||
}
|
||||
}
|
||||
@@ -601,7 +694,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
alert("Failed to delete conversation. Please try again.");
|
||||
}
|
||||
},
|
||||
[availableConversations, currentConversation, selectedAgent, onDebugEvent]
|
||||
[availableConversations, currentConversation, selectedAgent, onDebugEvent, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]
|
||||
);
|
||||
|
||||
// Handle conversation selection
|
||||
@@ -624,28 +717,28 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
// Use OpenAI ConversationItems directly (no conversion!)
|
||||
const items = result.data as import("@/types/openai").ConversationItem[];
|
||||
|
||||
setChatState({
|
||||
items,
|
||||
isStreaming: false,
|
||||
});
|
||||
setChatItems(items);
|
||||
setIsStreaming(false);
|
||||
|
||||
// Calculate usage from loaded items
|
||||
setConversationUsage({
|
||||
total_tokens: 0, // We don't have usage info in stored items
|
||||
message_count: items.length,
|
||||
useDevUIStore.setState({
|
||||
conversationUsage: {
|
||||
total_tokens: 0, // We don't have usage info in stored items
|
||||
message_count: items.length,
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Fallback to clearing items
|
||||
setChatState({
|
||||
items: [],
|
||||
isStreaming: false,
|
||||
});
|
||||
setConversationUsage({ total_tokens: 0, message_count: 0 });
|
||||
// 404 means conversation doesn't exist or has no items yet
|
||||
// This can happen if server restarted (in-memory store cleared)
|
||||
console.debug(`No items found for conversation ${conversationId}, starting with empty chat`);
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
|
||||
}
|
||||
|
||||
accumulatedText.current = "";
|
||||
},
|
||||
[availableConversations, onDebugEvent]
|
||||
[availableConversations, onDebugEvent, setCurrentConversation, setChatItems, setIsStreaming]
|
||||
);
|
||||
|
||||
// Handle function approval responses
|
||||
@@ -677,8 +770,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
};
|
||||
|
||||
// Remove from pending immediately (will be confirmed by backend event)
|
||||
setPendingApprovals((prev) =>
|
||||
prev.filter((a) => a.request_id !== request_id)
|
||||
setPendingApprovals(
|
||||
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== request_id)
|
||||
);
|
||||
|
||||
// Trigger send (we'll call this from the UI button handler)
|
||||
@@ -729,11 +822,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
status: "completed",
|
||||
};
|
||||
|
||||
setChatState((prev) => ({
|
||||
...prev,
|
||||
items: [...prev.items, userMessage],
|
||||
isStreaming: true,
|
||||
}));
|
||||
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
|
||||
setIsStreaming(true);
|
||||
|
||||
// Create assistant message placeholder
|
||||
const assistantMessage: import("@/types/openai").ConversationMessage = {
|
||||
@@ -744,10 +834,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
status: "in_progress",
|
||||
};
|
||||
|
||||
setChatState((prev) => ({
|
||||
...prev,
|
||||
items: [...prev.items, assistantMessage],
|
||||
}));
|
||||
setChatItems([...useDevUIStore.getState().chatItems, assistantMessage]);
|
||||
|
||||
try {
|
||||
// If no conversation selected, create one automatically
|
||||
@@ -758,7 +845,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
agent_id: selectedAgent.id,
|
||||
});
|
||||
setCurrentConversation(conversationToUse);
|
||||
setAvailableConversations((prev) => [conversationToUse!, ...prev]);
|
||||
setAvailableConversations([conversationToUse, ...useDevUIStore.getState().availableConversations]);
|
||||
} catch {
|
||||
// Failed to create conversation
|
||||
}
|
||||
@@ -772,9 +859,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
// Clear text accumulator for new response
|
||||
accumulatedText.current = "";
|
||||
|
||||
// Clear debug panel events for new agent run
|
||||
onDebugEvent("clear");
|
||||
|
||||
// Use OpenAI-compatible API streaming - direct event handling
|
||||
const streamGenerator = apiClient.streamAgentExecutionOpenAI(
|
||||
selectedAgent.id,
|
||||
@@ -805,8 +889,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
|
||||
|
||||
// Add to pending approvals
|
||||
setPendingApprovals((prev) => [
|
||||
...prev,
|
||||
setPendingApprovals([
|
||||
...useDevUIStore.getState().pendingApprovals,
|
||||
{
|
||||
request_id: approvalEvent.request_id,
|
||||
function_call: approvalEvent.function_call,
|
||||
@@ -820,8 +904,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const responseEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRespondedEvent;
|
||||
|
||||
// Remove from pending approvals
|
||||
setPendingApprovals((prev) =>
|
||||
prev.filter((a) => a.request_id !== responseEvent.request_id)
|
||||
setPendingApprovals(
|
||||
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== responseEvent.request_id)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -834,24 +918,22 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const errorMessage = errorEvent.message || "An error occurred";
|
||||
|
||||
// Update assistant message with error and stop streaming
|
||||
setChatState((prev) => ({
|
||||
...prev,
|
||||
isStreaming: false,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: errorMessage,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "incomplete" as const,
|
||||
}
|
||||
: item
|
||||
),
|
||||
}));
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: errorMessage,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "incomplete" as const,
|
||||
}
|
||||
: item
|
||||
));
|
||||
setIsStreaming(false);
|
||||
return; // Exit stream processing early on error
|
||||
}
|
||||
|
||||
@@ -864,23 +946,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
accumulatedText.current += openAIEvent.delta;
|
||||
|
||||
// Update assistant message with accumulated content
|
||||
setChatState((prev) => ({
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: accumulatedText.current,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "in_progress" as const,
|
||||
}
|
||||
: item
|
||||
),
|
||||
}));
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: accumulatedText.current,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "in_progress" as const,
|
||||
}
|
||||
: item
|
||||
));
|
||||
}
|
||||
|
||||
// Handle completion/error by detecting when streaming stops
|
||||
@@ -891,56 +971,49 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
// Usage is provided via response.completed event (OpenAI standard)
|
||||
const finalUsage = currentMessageUsage.current;
|
||||
|
||||
setChatState((prev) => ({
|
||||
...prev,
|
||||
isStreaming: false,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
status: "completed" as const,
|
||||
usage: finalUsage || undefined,
|
||||
}
|
||||
: item
|
||||
),
|
||||
}));
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
status: "completed" as const,
|
||||
usage: finalUsage || undefined,
|
||||
}
|
||||
: item
|
||||
));
|
||||
setIsStreaming(false);
|
||||
|
||||
// Update conversation-level usage stats
|
||||
if (finalUsage) {
|
||||
setConversationUsage((prev) => ({
|
||||
total_tokens: prev.total_tokens + finalUsage.total_tokens,
|
||||
message_count: prev.message_count + 1,
|
||||
}));
|
||||
updateConversationUsage(finalUsage.total_tokens);
|
||||
}
|
||||
|
||||
// Reset usage for next message
|
||||
currentMessageUsage.current = null;
|
||||
} catch (error) {
|
||||
setChatState((prev) => ({
|
||||
...prev,
|
||||
isStreaming: false,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to get response"
|
||||
}`,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "incomplete" as const,
|
||||
}
|
||||
: item
|
||||
),
|
||||
}));
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to get response"
|
||||
}`,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "incomplete" as const,
|
||||
}
|
||||
: item
|
||||
));
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
[selectedAgent, currentConversation, onDebugEvent]
|
||||
[selectedAgent, currentConversation, onDebugEvent, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage]
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -953,6 +1026,9 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
)
|
||||
return;
|
||||
|
||||
// Set flag to force scroll when user sends message
|
||||
userJustSentMessage.current = true;
|
||||
|
||||
setIsSubmitting(true);
|
||||
const messageText = inputValue.trim();
|
||||
setInputValue("");
|
||||
@@ -1056,7 +1132,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const canSendMessage =
|
||||
selectedAgent &&
|
||||
!isSubmitting &&
|
||||
!chatState.isStreaming &&
|
||||
!isStreaming &&
|
||||
(inputValue.trim() || attachments.length > 0);
|
||||
|
||||
return (
|
||||
@@ -1183,7 +1259,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 p-4 h-0" ref={scrollAreaRef}>
|
||||
<div className="space-y-4">
|
||||
{chatState.items.length === 0 ? (
|
||||
{chatItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Start a conversation with{" "}
|
||||
@@ -1194,7 +1270,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
chatState.items.map((item) => (
|
||||
chatItems.map((item) => (
|
||||
<ConversationItemBubble key={item.id} item={item} />
|
||||
))
|
||||
)}
|
||||
@@ -1339,13 +1415,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
placeholder={`Message ${
|
||||
selectedAgent.name || selectedAgent.id
|
||||
}... (Shift+Enter for new line)`}
|
||||
disabled={isSubmitting || chatState.isStreaming}
|
||||
disabled={isSubmitting || isStreaming}
|
||||
className="flex-1 min-h-[40px] max-h-[200px] resize-none"
|
||||
style={{ fieldSizing: "content" } as React.CSSProperties}
|
||||
/>
|
||||
<FileUpload
|
||||
onFilesSelected={handleFilesSelected}
|
||||
disabled={isSubmitting || chatState.isStreaming}
|
||||
disabled={isSubmitting || isStreaming}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
+18
-45
@@ -9,10 +9,11 @@ import {
|
||||
FileText,
|
||||
Code,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronRight,
|
||||
Music,
|
||||
} from "lucide-react";
|
||||
import type { MessageContent } from "@/types/openai";
|
||||
import { MarkdownRenderer } from "@/components/ui/markdown-renderer";
|
||||
|
||||
interface ContentRendererProps {
|
||||
content: MessageContent;
|
||||
@@ -22,51 +23,15 @@ interface ContentRendererProps {
|
||||
|
||||
// Text content renderer
|
||||
function TextContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (content.type !== "text") return null;
|
||||
|
||||
const text = content.text;
|
||||
const TRUNCATE_LENGTH = 1600;
|
||||
const shouldTruncate = text.length > TRUNCATE_LENGTH;
|
||||
const displayText =
|
||||
shouldTruncate && !isExpanded
|
||||
? text.slice(0, TRUNCATE_LENGTH) + "..."
|
||||
: text;
|
||||
|
||||
return (
|
||||
<div className={`whitespace-pre-wrap break-words ${className || ""}`}>
|
||||
<div
|
||||
className={
|
||||
isExpanded && shouldTruncate ? "max-h-96 overflow-y-auto" : ""
|
||||
}
|
||||
>
|
||||
{displayText}
|
||||
{isStreaming && text.length > 0 && (
|
||||
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
</div>
|
||||
{shouldTruncate && (
|
||||
<div className="flex justify-end mt-1">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="inline-flex items-center gap-1 text-xs
|
||||
bg-background/80 hover:bg-background border border-border/50 hover:border-border
|
||||
text-muted-foreground hover:text-foreground
|
||||
transition-colors cursor-pointer px-2 py-1 rounded"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
less <ChevronUp className="h-3 w-3" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{(text.length - TRUNCATE_LENGTH).toLocaleString()} more{" "}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className={`break-words ${className || ""}`}>
|
||||
<MarkdownRenderer content={text} />
|
||||
{isStreaming && text.length > 0 && (
|
||||
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -223,7 +188,7 @@ export function FunctionCallRenderer({ name, arguments: args, className }: Funct
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-blue-50 dark:bg-blue-950/20 ${className || ""}`}>
|
||||
<div className={`my-2 p-3 border rounded bg-blue-50 dark:bg-blue-950/20 ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
@@ -232,7 +197,11 @@ export function FunctionCallRenderer({ name, arguments: args, className }: Funct
|
||||
<span className="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
Function Call: {name}
|
||||
</span>
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400">{isExpanded ? "▼" : "▶"}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
|
||||
@@ -264,7 +233,7 @@ export function FunctionResultRenderer({ output, call_id, className }: FunctionR
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded-lg bg-green-50 dark:bg-green-950/20 ${className || ""}`}>
|
||||
<div className={`my-2 p-3 border rounded bg-green-50 dark:bg-green-950/20 ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
@@ -273,7 +242,11 @@ export function FunctionResultRenderer({ output, call_id, className }: FunctionR
|
||||
<span className="text-sm font-medium text-green-800 dark:text-green-300">
|
||||
Function Result
|
||||
</span>
|
||||
<span className="text-xs text-green-600 dark:text-green-400">{isExpanded ? "▼" : "▶"}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-green-600 dark:text-green-400 ml-auto" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-green-600 dark:text-green-400 ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
|
||||
|
||||
@@ -17,15 +17,13 @@ import {
|
||||
import {
|
||||
Bot,
|
||||
Workflow,
|
||||
Plus,
|
||||
Loader2,
|
||||
User,
|
||||
TriangleAlert,
|
||||
AlertCircle,
|
||||
X,
|
||||
Key,
|
||||
ChevronDown,
|
||||
ArrowLeft,
|
||||
Download,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -33,13 +31,9 @@ import {
|
||||
type SampleEntity,
|
||||
getDifficultyColor,
|
||||
} from "@/data/gallery";
|
||||
import { SetupInstructionsModal } from "./setup-instructions-modal";
|
||||
|
||||
interface GalleryViewProps {
|
||||
onAdd: (sample: SampleEntity) => Promise<void>;
|
||||
addingEntityId?: string | null;
|
||||
errorEntityId?: string | null;
|
||||
errorMessage?: string | null;
|
||||
onClearError?: (sampleId: string) => void;
|
||||
onClose?: () => void;
|
||||
variant?: "inline" | "route" | "modal";
|
||||
hasExistingEntities?: boolean;
|
||||
@@ -48,91 +42,41 @@ interface GalleryViewProps {
|
||||
// Internal: Sample Entity Card Component
|
||||
function SampleEntityCard({
|
||||
sample,
|
||||
onAdd,
|
||||
isAdding = false,
|
||||
hasError = false,
|
||||
errorMessage,
|
||||
onClearError,
|
||||
}: {
|
||||
sample: SampleEntity;
|
||||
onAdd: (sample: SampleEntity) => Promise<void>;
|
||||
isAdding?: boolean;
|
||||
hasError?: boolean;
|
||||
errorMessage?: string | null;
|
||||
onClearError?: (sampleId: string) => void;
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (isLoading || isAdding) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onAdd(sample);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
const TypeIcon = sample.type === "workflow" ? Workflow : Bot;
|
||||
const isDisabled = isLoading || isAdding;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"hover:shadow-md transition-shadow duration-200 h-full flex flex-col overflow-hidden w-full",
|
||||
hasError && "border-destructive"
|
||||
)}
|
||||
>
|
||||
<CardHeader className="pb-3 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon className="h-5 w-5" />
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{sample.type}
|
||||
<>
|
||||
<Card className="hover:shadow-md transition-shadow duration-200 h-full flex flex-col overflow-hidden w-full">
|
||||
<CardHeader className="pb-3 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon className="h-5 w-5" />
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{sample.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs border",
|
||||
getDifficultyColor(sample.difficulty)
|
||||
)}
|
||||
>
|
||||
{sample.difficulty}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs border",
|
||||
getDifficultyColor(sample.difficulty)
|
||||
)}
|
||||
>
|
||||
{sample.difficulty}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<CardTitle className="text-lg leading-tight">{sample.name}</CardTitle>
|
||||
<CardDescription className="text-sm line-clamp-3">
|
||||
{sample.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardTitle className="text-lg leading-tight">{sample.name}</CardTitle>
|
||||
<CardDescription className="text-sm line-clamp-3">
|
||||
{sample.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0 flex-1 min-w-0 overflow-hidden">
|
||||
{/* Error Banner */}
|
||||
{hasError && errorMessage && (
|
||||
<div className="mb-3 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-destructive font-medium mb-1">
|
||||
Failed to add
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{errorMessage}</p>
|
||||
</div>
|
||||
{onClearError && (
|
||||
<button
|
||||
onClick={() => onClearError(sample.id)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="pt-0 flex-1 min-w-0 overflow-hidden">
|
||||
|
||||
<div className="space-y-3 min-w-0">
|
||||
{/* Tags */}
|
||||
@@ -199,73 +143,57 @@ function SampleEntityCard({
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="pt-3 flex-col gap-3">
|
||||
{/* Metadata */}
|
||||
<div className="w-full flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{sample.author}</span>
|
||||
<CardFooter className="pt-3 flex-col gap-3">
|
||||
{/* Metadata */}
|
||||
<div className="w-full flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{sample.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Button - Full width on its own line */}
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={isDisabled}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
variant={hasError ? "outline" : "default"}
|
||||
>
|
||||
{isDisabled ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Adding...
|
||||
</>
|
||||
) : hasError ? (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Sample
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
{/* Action Buttons */}
|
||||
<div className="w-full flex gap-2">
|
||||
<Button asChild className="flex-1" size="sm">
|
||||
<a
|
||||
href={sample.url}
|
||||
download={`${sample.id}.py`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
onClick={() => setShowInstructions(true)}
|
||||
>
|
||||
<BookOpen className="h-4 w-4 mr-2" />
|
||||
Setup Guide
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<SetupInstructionsModal
|
||||
sample={sample}
|
||||
open={showInstructions}
|
||||
onOpenChange={setShowInstructions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Internal: Sample Entity Grid Component
|
||||
function SampleEntityGrid({
|
||||
samples,
|
||||
onAdd,
|
||||
addingEntityId,
|
||||
errorEntityId,
|
||||
errorMessage,
|
||||
onClearError,
|
||||
}: {
|
||||
samples: SampleEntity[];
|
||||
onAdd: (sample: SampleEntity) => Promise<void>;
|
||||
addingEntityId?: string | null;
|
||||
errorEntityId?: string | null;
|
||||
errorMessage?: string | null;
|
||||
onClearError?: (sampleId: string) => void;
|
||||
}) {
|
||||
function SampleEntityGrid({ samples }: { samples: SampleEntity[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{samples.map((sample) => (
|
||||
<div key={sample.id} className="min-w-0">
|
||||
<SampleEntityCard
|
||||
sample={sample}
|
||||
onAdd={onAdd}
|
||||
isAdding={addingEntityId === sample.id}
|
||||
hasError={errorEntityId === sample.id}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={onClearError}
|
||||
/>
|
||||
<SampleEntityCard sample={sample} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -274,11 +202,6 @@ function SampleEntityGrid({
|
||||
|
||||
// Main: Gallery View Component
|
||||
export function GalleryView({
|
||||
onAdd,
|
||||
addingEntityId,
|
||||
errorEntityId,
|
||||
errorMessage,
|
||||
onClearError,
|
||||
onClose,
|
||||
variant = "inline",
|
||||
hasExistingEntities = false,
|
||||
@@ -304,8 +227,8 @@ export function GalleryView({
|
||||
in a directory containing them.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can also import any of the sample agents and workflows
|
||||
below to get started quickly.
|
||||
Browse the sample agents and workflows below. Download them,
|
||||
review the code, and run them locally to get started quickly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -314,14 +237,7 @@ export function GalleryView({
|
||||
{/* Sample Gallery */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Sample Gallery</h3>
|
||||
<SampleEntityGrid
|
||||
samples={SAMPLE_ENTITIES}
|
||||
onAdd={onAdd}
|
||||
addingEntityId={addingEntityId}
|
||||
errorEntityId={errorEntityId}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={onClearError}
|
||||
/>
|
||||
<SampleEntityGrid samples={SAMPLE_ENTITIES} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
@@ -362,22 +278,15 @@ export function GalleryView({
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-semibold mb-2">Sample Gallery</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Browse and add sample agents and workflows to learn the Agent
|
||||
Framework. These are curated examples ranging from beginner to
|
||||
advanced.
|
||||
Browse sample agents and workflows to learn the Agent
|
||||
Framework. Download these curated examples and run them locally.
|
||||
Examples range from beginner to advanced.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sample Gallery */}
|
||||
<SampleEntityGrid
|
||||
samples={SAMPLE_ENTITIES}
|
||||
onAdd={onAdd}
|
||||
addingEntityId={addingEntityId}
|
||||
errorEntityId={errorEntityId}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={onClearError}
|
||||
/>
|
||||
<SampleEntityGrid samples={SAMPLE_ENTITIES} />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-12 pt-8 border-t">
|
||||
@@ -399,14 +308,5 @@ export function GalleryView({
|
||||
}
|
||||
|
||||
// Modal variant - for dropdown trigger (simplified, just the grid)
|
||||
return (
|
||||
<SampleEntityGrid
|
||||
samples={SAMPLE_ENTITIES}
|
||||
onAdd={onAdd}
|
||||
addingEntityId={addingEntityId}
|
||||
errorEntityId={errorEntityId}
|
||||
errorMessage={errorMessage}
|
||||
onClearError={onClearError}
|
||||
/>
|
||||
);
|
||||
return <SampleEntityGrid samples={SAMPLE_ENTITIES} />;
|
||||
}
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* SetupInstructionsModal - Shows step-by-step instructions for running a sample entity
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Download,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Check,
|
||||
Lightbulb,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
import type { SampleEntity } from "@/data/gallery";
|
||||
|
||||
interface SetupInstructionsModalProps {
|
||||
sample: SampleEntity;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function CodeBlock({ children, copyable = false }: { children: string; copyable?: boolean }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(children);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<pre className="bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
{copyable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2 h-6 w-6 p-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupStep({
|
||||
number,
|
||||
title,
|
||||
description,
|
||||
code,
|
||||
action,
|
||||
copyable = false,
|
||||
}: {
|
||||
number: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
code?: string;
|
||||
action?: React.ReactNode;
|
||||
copyable?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold">
|
||||
{number}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<h4 className="font-semibold">{title}</h4>
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
{code && <CodeBlock copyable={copyable}>{code}</CodeBlock>}
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupInstructionsModal({
|
||||
sample,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: SetupInstructionsModalProps) {
|
||||
const hasEnvVars = sample.requiredEnvVars && sample.requiredEnvVars.length > 0;
|
||||
const stepOffset = hasEnvVars ? 0 : -1;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader className="px-6 pt-6 pb-2">
|
||||
<DialogTitle>Setup: {sample.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Follow these steps to run this sample {sample.type} locally
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6">
|
||||
<ScrollArea className="h-[500px]">
|
||||
<div className="space-y-6 pr-4">
|
||||
{/* Step 1: Download */}
|
||||
<SetupStep
|
||||
number={1}
|
||||
title="Download the sample file"
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
<a
|
||||
href={sample.url}
|
||||
download={`${sample.id}.py`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download {sample.id}.py
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Step 2: Create folder */}
|
||||
<SetupStep
|
||||
number={2}
|
||||
title="Create a project folder"
|
||||
description="Create a dedicated folder for this sample and move the downloaded file there:"
|
||||
code={`mkdir -p ~/my-agents/${sample.id}\nmv ~/Downloads/${sample.id}.py ~/my-agents/${sample.id}/`}
|
||||
copyable
|
||||
/>
|
||||
|
||||
{/* Step 3: Environment variables (conditional) */}
|
||||
{hasEnvVars && (
|
||||
<SetupStep
|
||||
number={3}
|
||||
title="Set up environment variables"
|
||||
description="Create a .env file in the project folder with these required variables:"
|
||||
code={sample.requiredEnvVars!
|
||||
.map((v) => `${v.name}=${v.example || "your-value-here"}\n# ${v.description}`)
|
||||
.join("\n\n")}
|
||||
copyable
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 4: Run DevUI */}
|
||||
<SetupStep
|
||||
number={4 + stepOffset}
|
||||
title="Run with DevUI"
|
||||
description="Navigate to the folder and start DevUI:"
|
||||
code={`cd ~/my-agents/${sample.id}\ndevui .`}
|
||||
copyable
|
||||
/>
|
||||
|
||||
{/* Alternative: Direct run */}
|
||||
<Alert>
|
||||
<Lightbulb className="h-4 w-4" />
|
||||
<AlertTitle>Alternative: Run Programmatically</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
<p className="mb-2">You can also run the {sample.type} directly in Python:</p>
|
||||
<CodeBlock copyable>
|
||||
{`from ${sample.id} import ${sample.type}
|
||||
import asyncio
|
||||
|
||||
async def main():
|
||||
response = await ${sample.type}.run("Hello!")
|
||||
print(response)
|
||||
|
||||
asyncio.run(main())`}
|
||||
</CodeBlock>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Help links */}
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={sample.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
View Source
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href="https://github.com/microsoft/agent-framework#readme"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<BookOpen className="h-4 w-4 mr-2" />
|
||||
Documentation
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+17
-10
@@ -32,22 +32,29 @@ interface FormFieldProps {
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
// Helper: Determine if field is likely a short metadata field (not multiline text)
|
||||
function isShortField(fieldName: string): boolean {
|
||||
const shortFieldNames = ['name', 'title', 'id', 'key', 'label', 'type', 'status', 'tag', 'category', 'code', 'username', 'password'];
|
||||
return shortFieldNames.includes(fieldName.toLowerCase());
|
||||
}
|
||||
|
||||
function FormField({ name, schema, value, onChange, isRequired = false }: FormFieldProps) {
|
||||
const { type, description, enum: enumValues, default: defaultValue } = schema;
|
||||
|
||||
// For text/message/content fields, treat as textarea for better UX
|
||||
const isTextContentField = ['text', 'message', 'content', 'query', 'prompt'].includes(name.toLowerCase());
|
||||
// Determine if this should be a textarea based on JSON Schema format field
|
||||
// or heuristics (long descriptions, specific field types)
|
||||
const shouldBeTextarea =
|
||||
schema.format === "textarea" || // Explicit format from backend
|
||||
(description && description.length > 100) || // Long description suggests multiline
|
||||
(type === "string" && !enumValues && !isShortField(name)); // Default strings to textarea unless they're short metadata fields
|
||||
|
||||
// Determine if this field should span full width
|
||||
// Only span full if it's a textarea or has very long description
|
||||
const shouldSpanFullWidth =
|
||||
schema.format === "textarea" ||
|
||||
isTextContentField || // text/message fields span full width
|
||||
shouldBeTextarea ||
|
||||
(description && description.length > 150);
|
||||
|
||||
const shouldSpanTwoColumns =
|
||||
schema.format === "textarea" ||
|
||||
isTextContentField ||
|
||||
shouldBeTextarea ||
|
||||
(description && description.length > 80) ||
|
||||
type === "array"; // Arrays might need more space for comma-separated values
|
||||
|
||||
@@ -90,8 +97,7 @@ function FormField({ name, schema, value, onChange, isRequired = false }: FormFi
|
||||
</div>
|
||||
);
|
||||
} else if (
|
||||
schema.format === "textarea" ||
|
||||
isTextContentField ||
|
||||
shouldBeTextarea ||
|
||||
(description && description.length > 100)
|
||||
) {
|
||||
// Multi-line text (including text/message/content fields)
|
||||
@@ -110,7 +116,8 @@ function FormField({ name, schema, value, onChange, isRequired = false }: FormFi
|
||||
? defaultValue
|
||||
: `Enter ${name}`
|
||||
}
|
||||
rows={isTextContentField ? 4 : 2}
|
||||
rows={shouldBeTextarea ? 4 : 2}
|
||||
className="min-w-[300px] w-full"
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
|
||||
@@ -201,7 +201,7 @@ function RunWorkflowButton({
|
||||
|
||||
{/* Modal with proper Dialog component - matching WorkflowInputForm structure */}
|
||||
<Dialog open={showModal} onOpenChange={setShowModal}>
|
||||
<DialogContent className="w-full max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="w-full min-w-[400px] max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="px-8 pt-6">
|
||||
<DialogTitle>Configure Workflow Inputs</DialogTitle>
|
||||
<DialogClose onClose={() => setShowModal(false)} />
|
||||
|
||||
Reference in New Issue
Block a user