diff --git a/apps/studio/src/plugins/built-in/agent-playground-plugin.tsx b/apps/studio/src/plugins/built-in/agent-playground-plugin.tsx new file mode 100644 index 000000000..9318faf89 --- /dev/null +++ b/apps/studio/src/plugins/built-in/agent-playground-plugin.tsx @@ -0,0 +1,721 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Built-in Plugin: Agent Playground + * + * Interactive testing environment for AI agents. + * Provides embedded chat interface, agent metadata display, + * and conversation history management. + * + * Priority: 10 (higher than default inspector, lower than specialized designers) + */ + +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { defineStudioPlugin } from '@objectstack/spec/studio'; +import { useClient } from '@objectstack/client-react'; +import { useChat } from '@ai-sdk/react'; +import { DefaultChatTransport } from 'ai'; +import type { UIMessage } from 'ai'; +import type { StudioPlugin, MetadataViewerProps } from '../types'; +import type { Agent } from '@objectstack/spec/ai'; +import { + Bot, Send, Trash2, Sparkles, Wrench, + CheckCircle2, XCircle, Loader2, ShieldAlert, + ChevronDown, ChevronRight, Brain, Zap, Download, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { getApiBaseUrl } from '@/lib/config'; + +// Storage key for agent playground messages +const getAgentStorageKey = (agentName: string) => `objectstack:agent-playground:${agentName}`; + +/** + * Track active thinking/reasoning state during streaming. + */ +interface ThinkingState { + reasoning: string[]; + activeSteps: Map; + completedSteps: string[]; +} + +/** + * Extract the text content from a UIMessage's parts array. + */ +function getMessageText(msg: UIMessage): string { + return (msg.parts ?? []) + .filter((p): p is { type: 'text'; text: string } => p.type === 'text') + .map((p) => p.text) + .join(''); +} + +/** + * Convert a snake_case tool name to a human-readable label. + */ +function formatToolName(name: string): string { + return name + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** + * Render a concise summary of tool input arguments. + */ +function formatToolArgs(input: unknown): string { + if (!input || typeof input !== 'object') return ''; + const entries = Object.entries(input as Record); + if (entries.length === 0) return ''; + return entries + .slice(0, 4) + .map(([k, v]) => { + let val: string; + try { + val = typeof v === 'string' ? v : (JSON.stringify(v) ?? String(v)); + } catch { + val = String(v); + } + const display = val.length > 30 ? val.slice(0, 30) + '…' : val; + return `${k}: ${display}`; + }) + .join(', '); +} + +/** + * Type guard to check if a message part is a tool invocation (dynamic-tool). + */ +function isToolPart(part: UIMessage['parts'][number]): part is Extract { + return part.type === 'dynamic-tool'; +} + +/** + * Format tool output for display, truncating to a max length. + */ +function formatToolOutput(output: unknown, maxLen = 80): string { + let raw: string; + try { + raw = typeof output === 'string' ? output : (JSON.stringify(output) ?? ''); + } catch { + raw = String(output ?? ''); + } + return raw.length > maxLen ? raw.slice(0, maxLen) + '…' : raw; +} + +/** + * Display reasoning/thinking information in a collapsible section. + */ +interface ReasoningDisplayProps { + reasoning: string[]; +} + +function ReasoningDisplay({ reasoning }: ReasoningDisplayProps) { + const [isExpanded, setIsExpanded] = useState(false); + + if (reasoning.length === 0) return null; + + return ( +
+ + {isExpanded && ( +
+ {reasoning.map((step, idx) => ( +

+ {step} +

+ ))} +
+ )} +
+ ); +} + +/** + * Display active step progress indicators. + */ +interface StepProgressProps { + activeSteps: Map; + completedSteps: string[]; +} + +function StepProgress({ activeSteps, completedSteps }: StepProgressProps) { + if (activeSteps.size === 0) return null; + + const totalSteps = completedSteps.length + activeSteps.size; + const currentStep = completedSteps.length + 1; + + return ( +
+
+ + + Step {currentStep} of {totalSteps} + +
+ {Array.from(activeSteps.values()).map((step, idx) => ( +
+ + {step.stepName} +
+ ))} +
+ ); +} + +/** + * Tool Invocation Display Component + */ +interface ToolInvocationDisplayProps { + part: Extract; + onApprove?: (approvalId: string) => void; + onDeny?: (approvalId: string) => void; +} + +function ToolInvocationDisplay({ part, onApprove, onDeny }: ToolInvocationDisplayProps) { + const toolLabel = formatToolName(part.toolName); + const argsText = formatToolArgs(part.input); + + switch (part.state) { + case 'input-streaming': + return ( +
+ +
+ Planning to call {toolLabel} + {argsText && ( +

{argsText}

+ )} +
+
+ ); + + case 'input-available': + return ( +
+ +
+ Calling {toolLabel} + {argsText && ( +

{argsText}

+ )} +
+
+ ); + + case 'approval-requested': + return ( +
+
+ +
+ Confirm: {toolLabel} + {argsText && ( +

{argsText}

+ )} +
+
+ {part.approval && onApprove && onDeny && ( +
+ + +
+ )} +
+ ); + + case 'output-available': + return ( +
+ +
+ {toolLabel} +

+ {formatToolOutput(part.output)} +

+
+
+ ); + + case 'output-error': + return ( +
+ +
+ {toolLabel} failed +

{part.errorText}

+
+
+ ); + + case 'output-denied': + return ( +
+ + {toolLabel} — denied +
+ ); + + default: + return ( +
+ + {toolLabel} +
+ ); + } +} + +/** + * Agent Playground Viewer Component + */ +function AgentPlaygroundViewer({ metadataType, metadataName, data, packageId }: MetadataViewerProps) { + const client = useClient(); + const [agent, setAgent] = useState(data ?? null); + const [loading, setLoading] = useState(!data); + const [input, setInput] = useState(''); + const [thinkingState, setThinkingState] = useState({ + reasoning: [], + activeSteps: new Map(), + completedSteps: [], + }); + const [showMetadata, setShowMetadata] = useState(false); + const scrollRef = useRef(null); + const inputRef = useRef(null); + const baseUrl = getApiBaseUrl(); + + // Load agent metadata + useEffect(() => { + if (data) { + setAgent(data as Agent); + setLoading(false); + return; + } + + let mounted = true; + setLoading(true); + + async function load() { + try { + const result: any = await client.meta.getItem(metadataType, metadataName, packageId ? { packageId } : undefined); + if (mounted) { + setAgent(result?.item || result); + } + } catch (err) { + console.error(`[AgentPlayground] Failed to load agent ${metadataName}:`, err); + } finally { + if (mounted) setLoading(false); + } + } + load(); + return () => { mounted = false; }; + }, [client, metadataType, metadataName, data, packageId]); + + // Load persisted messages for this agent + const loadMessages = useCallback((): UIMessage[] => { + try { + const key = getAgentStorageKey(metadataName); + const stored = localStorage.getItem(key); + if (!stored) return []; + return JSON.parse(stored); + } catch { + return []; + } + }, [metadataName]); + + // Save messages to localStorage + const saveMessages = useCallback((msgs: UIMessage[]) => { + try { + const key = getAgentStorageKey(metadataName); + localStorage.setItem(key, JSON.stringify(msgs)); + } catch { + // silently ignore + } + }, [metadataName]); + + const initialMessages = useMemo(() => loadMessages(), [loadMessages]); + + const transport = useMemo( + () => new DefaultChatTransport({ api: `${baseUrl}/api/v1/ai/agents/${metadataName}/chat` }), + [baseUrl, metadataName], + ); + + const { messages, sendMessage, setMessages, status, error, addToolApprovalResponse } = useChat({ + transport, + messages: initialMessages, + onFinish: () => { + setThinkingState({ + reasoning: [], + activeSteps: new Map(), + completedSteps: [], + }); + }, + }); + + const isStreaming = status === 'streaming' || status === 'submitted'; + + // Extract reasoning and step progress + useEffect(() => { + if (!isStreaming || messages.length === 0) return; + + const lastMessage = messages[messages.length - 1]; + if (lastMessage.role !== 'assistant') return; + + const reasoning: string[] = []; + const activeSteps = new Map(); + const completedSteps: string[] = []; + + (lastMessage.parts || []).forEach((part: any) => { + if (part.type === 'reasoning-delta' || part.type === 'reasoning') { + reasoning.push(part.text); + } else if (part.type === 'step-start') { + activeSteps.set(part.stepId, { + stepName: part.stepName, + startedAt: Date.now(), + }); + } else if (part.type === 'step-finish') { + completedSteps.push(part.stepName); + } + }); + + setThinkingState({ reasoning, activeSteps, completedSteps }); + }, [messages, isStreaming]); + + // Persist messages + useEffect(() => { + if (messages.length > 0) { + saveMessages(messages); + } + }, [messages, saveMessages]); + + // Auto-scroll + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages]); + + const clearHistory = () => { + setMessages([]); + saveMessages([]); + }; + + const downloadHistory = () => { + const blob = new Blob([JSON.stringify(messages, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `agent-${metadataName}-conversation-${Date.now()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleSend = () => { + const text = input.trim(); + if (!text || isStreaming) return; + setInput(''); + sendMessage({ text }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + if (loading) { + return ( +
+
+ + Loading agent... +
+
+ ); + } + + if (!agent) { + return ( +
+ +

Agent not found: {metadataName}

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ +
+

{agent.label}

+

{agent.role}

+
+
+
+ + + + + +

{showMetadata ? 'Hide' : 'Show'} metadata

+
+
+ + + + + +

Download conversation

+
+
+ + + + + +

Clear history

+
+
+
+
+ + {/* Agent Metadata (collapsible) */} + {showMetadata && ( +
+
+
+ Instructions: +

{agent.instructions}

+
+ {agent.model && ( +
+ Model: + {agent.model.model} +
+ )} + {agent.skills && agent.skills.length > 0 && ( +
+ Skills: + {agent.skills.join(', ')} +
+ )} + {agent.tools && agent.tools.length > 0 && ( +
+ Tools: + {agent.tools.map(t => t.name).join(', ')} +
+ )} +
+
+ )} +
+ + {/* Messages */} + +
+ {messages.length === 0 && ( +
+ +

Start testing {agent.label}

+

Send a message to begin the conversation

+
+ )} + {messages.map((msg) => { + const text = getMessageText(msg); + const toolParts = (msg.parts ?? []).filter(isToolPart); + const hasContent = !!text || toolParts.length > 0; + if (!hasContent && msg.role !== 'user') return null; + return ( +
+ + {msg.role === 'user' ? 'You' : 'Assistant'} + + {text &&
{text}
} + {toolParts.map((toolPart) => ( + + addToolApprovalResponse({ id: approvalId, approved: true }) + } + onDeny={(approvalId) => + addToolApprovalResponse({ + id: approvalId, + approved: false, + reason: 'User denied the operation', + }) + } + /> + ))} +
+ ); + })} + {isStreaming && ( + <> + {thinkingState.reasoning.length > 0 && ( +
+ +
+ )} + {thinkingState.activeSteps.size > 0 && ( +
+ +
+ )} + {thinkingState.reasoning.length === 0 && thinkingState.activeSteps.size === 0 && ( +
+ + Thinking… +
+ )} + + )} + {error && ( +
+ +
+

Error

+

+ {error.message || 'Something went wrong'} +

+
+
+ )} +
+
+ + {/* Input */} +
+
+