'use client' import * as React from 'react' import { useQuery } from '@tanstack/react-query' import { Bot, BookOpen, Loader2, Play, RefreshCcw } from 'lucide-react' import { useT } from '@open-mercato/shared/lib/i18n/context' import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert' import { Button } from '@open-mercato/ui/primitives/button' import { IconButton } from '@open-mercato/ui/primitives/icon-button' import { Label } from '@open-mercato/ui/primitives/label' import { Switch } from '@open-mercato/ui/primitives/switch' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@open-mercato/ui/primitives/tabs' import { Textarea } from '@open-mercato/ui/primitives/textarea' import { EmptyState } from '@open-mercato/ui/backend/EmptyState' import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall' import { AiChat, createAiUiPartRegistry, LoopDisabledBanner, useAiShortcuts } from '@open-mercato/ui/ai' import type { AiChatDebugPromptSection, AiChatDebugTool } from '@open-mercato/ui/ai' import { ConversationShareButton } from '../../../../components/ConversationShareButton' type PlaygroundAgentTool = { name: string displayName?: string isMutation?: boolean registered?: boolean requiredFeatures?: string[] } type PlaygroundAgent = { id: string moduleId: string label: string description: string executionMode: 'chat' | 'object' mutationPolicy: string allowedTools: string[] requiredFeatures: string[] acceptedMediaTypes: string[] hasOutputSchema: boolean systemPrompt?: string readOnly?: boolean maxSteps?: number | null tools?: PlaygroundAgentTool[] } type AgentsResponse = { agents: PlaygroundAgent[] total: number } type RunObjectResponse = { object: unknown finishReason?: string usage?: { inputTokens?: number; outputTokens?: number } } type RunObjectError = { error: string code?: string issues?: unknown } async function fetchAgents(): Promise { const { result, status } = await apiCallOrThrow( '/api/ai_assistant/ai/agents', { method: 'GET', credentials: 'include' }, { errorMessage: 'Failed to load agents' }, ) if (!result) throw new Error(`Failed to load agents (${status})`) return result } function PlaygroundLoading({ message }: { message: string }) { return (
{message}
) } function PlaygroundNoAgents() { const t = useT() return ( } title={t( 'ai_assistant.playground.empty.title', 'No AI agents are registered for your role yet.', )} description={t( 'ai_assistant.playground.empty.description', 'Declare agents inside `packages//src/modules//ai-agents.ts`, run `yarn generate`, and ensure the caller holds the agent\'s required features.', )} >
{t( 'ai_assistant.playground.empty.docLabel', 'See packages/ai-assistant/AGENTS.md for the agent definition reference.', )}
) } function AgentDetails({ agent }: { agent: PlaygroundAgent }) { const t = useT() return (
{agent.label}

{agent.description}

{t('ai_assistant.playground.meta.module', 'Module')}
{agent.moduleId}
{t('ai_assistant.playground.meta.executionMode', 'Execution mode')}
{agent.executionMode}
{t('ai_assistant.playground.meta.mutationPolicy', 'Mutation policy')}
{agent.mutationPolicy}
{t('ai_assistant.playground.meta.tools', 'Allowed tools')}
{agent.allowedTools.length}
) } function buildDebugTools(agent: PlaygroundAgent): AiChatDebugTool[] { if (agent.tools && agent.tools.length > 0) { return agent.tools.map((tool) => ({ name: tool.name, displayName: tool.displayName ?? tool.name, isMutation: Boolean(tool.isMutation), requiredFeatures: tool.requiredFeatures ?? [], })) } return agent.allowedTools.map((toolName) => ({ name: toolName })) } function buildDebugPromptSections(agent: PlaygroundAgent): AiChatDebugPromptSection[] { const sections: AiChatDebugPromptSection[] = [] if (agent.systemPrompt) { sections.push({ id: 'role', source: 'default', text: agent.systemPrompt }) } const placeholderIds = [ 'scope', 'data', 'tools', 'attachments', 'mutationPolicy', 'responseStyle', 'overrides', ] as const for (const id of placeholderIds) { sections.push({ id, source: 'placeholder' }) } return sections } type AgentModelResolution = { agentId: string providerId: string modelId: string baseURL: string | null source: string } type SettingsAgentResolutionResponse = { agents: AgentModelResolution[] } async function fetchAgentResolutions(): Promise { const result = await apiCall('/api/ai_assistant/settings') if (!result.ok || !result.result) return { agents: [] } return { agents: result.result.agents ?? [] } } async function fetchLoopOverrideForAgent( agentId: string, ): Promise<{ agentId: string; override: { loopDisabled?: boolean | null } | null }> { const result = await apiCall<{ agentId: string; override: { loopDisabled?: boolean | null } | null }>( `/api/ai_assistant/ai/agents/${encodeURIComponent(agentId)}/loop-override`, { method: 'GET', credentials: 'include' }, ) if (!result.ok || !result.result) return { agentId, override: null } return result.result } function LoopDisabledPlaygroundBanner({ agentId }: { agentId: string }) { const { data } = useQuery({ queryKey: ['ai_assistant', 'loop_override', agentId], queryFn: () => fetchLoopOverrideForAgent(agentId), staleTime: 30000, }) if (!data?.override?.loopDisabled) return null return } function ModelResolutionPanel({ agentId }: { agentId: string }) { const t = useT() const { data } = useQuery({ queryKey: ['ai_assistant', 'settings', 'agents'], queryFn: fetchAgentResolutions, staleTime: 30000, }) const resolution = data?.agents.find((agent) => agent.agentId === agentId) if (!resolution) return null return (
{t('ai_assistant.playground.resolution.provider', 'Provider')}
{resolution.providerId}
{t('ai_assistant.playground.resolution.model', 'Model')}
{resolution.modelId}
{t('ai_assistant.playground.resolution.baseUrl', 'Base URL')}
{resolution.baseURL ?? t('ai_assistant.playground.resolution.none', '—')}
{t('ai_assistant.playground.resolution.source', 'Source')}
{resolution.source}
) } type PlaygroundUiPartSeed = { componentId: string pendingActionId?: string payload?: unknown } function readPlaygroundUiPartSeeds(): PlaygroundUiPartSeed[] { if (typeof window === 'undefined') return [] try { const params = new URLSearchParams(window.location.search) const componentId = params.get('uiPart') if (!componentId) return [] const pendingActionId = params.get('pendingActionId') ?? undefined return [{ componentId, pendingActionId }] } catch { return [] } } function ChatLane({ agent, debug }: { agent: PlaygroundAgent; debug: boolean }) { const t = useT() // Scoped registry so repeated mounts do not share state with other pages. // Step 5.10: opt in to the LIVE mutation-approval cards so the playground // exercises the real cards when the chat response surfaces a pending // action (via the `?uiPart=...` debug seed for Playwright). const registry = React.useMemo( () => createAiUiPartRegistry({ seedLiveApprovalCards: true }), [], ) const debugTools = React.useMemo(() => buildDebugTools(agent), [agent]) const debugPromptSections = React.useMemo( () => buildDebugPromptSections(agent), [agent], ) const [uiParts, setUiParts] = React.useState([]) const [conversationId, setConversationId] = React.useState(null) // Step 5.10: the dispatcher does not yet surface `AiUiPart` entries through // the plain-text stream consumed by `useAiChat`. For now the playground // reads a `?uiPart=&pendingActionId=...` seed from the URL // so Playwright + operator debug flows can render the approval cards // against a stubbed `/api/ai_assistant/ai/actions/:id` endpoint. When the // dispatcher switches to the UIMessageChunk format this effect swaps over // to the streamed `uiParts` payload. React.useEffect(() => { const seeds = readPlaygroundUiPartSeeds() if (seeds.length > 0) setUiParts(seeds) }, []) if (agent.executionMode !== 'chat') { return ( {t( 'ai_assistant.playground.chat.notSupportedTitle', 'Chat mode is not available for this agent.', )} {t( 'ai_assistant.playground.chat.notSupportedBody', 'Pick an agent whose execution mode is "chat", or switch to the object-mode tab.', )} ) } return (
: null} />
) } function ObjectLane({ agent }: { agent: PlaygroundAgent }) { const t = useT() const [prompt, setPrompt] = React.useState('') const [isRunning, setIsRunning] = React.useState(false) const [result, setResult] = React.useState(null) const [error, setError] = React.useState(null) const [lastRequest, setLastRequest] = React.useState(null) const isSupported = agent.executionMode === 'object' const canRun = isSupported && prompt.trim().length > 0 && !isRunning const runObject = React.useCallback(async () => { if (!canRun) return const body = { agent: agent.id, messages: [{ role: 'user' as const, content: prompt }], pageContext: { source: 'playground', pageId: 'ai_assistant.playground' }, } setLastRequest(body) setIsRunning(true) setResult(null) setError(null) try { const { ok, status, result } = await apiCall( '/api/ai_assistant/ai/run-object', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'include', body: JSON.stringify(body), }, ) if (!ok) { const payload = (result as RunObjectError | null) ?? { error: `HTTP ${status}` } setError(payload) return } setResult((result as RunObjectResponse | null) ?? { object: null }) } catch (err) { setError({ error: err instanceof Error ? err.message : String(err), code: 'network_error', }) } finally { setIsRunning(false) } }, [agent.id, canRun, prompt]) const { handleKeyDown } = useAiShortcuts({ onSubmit: () => { void runObject() }, onCancel: () => { setError(null) }, }) if (!isSupported) { return ( {t( 'ai_assistant.playground.object.notSupportedTitle', 'Object mode is not available for this agent.', )} {t( 'ai_assistant.playground.object.notSupportedBody', 'This agent declares executionMode = "chat". Pick an object-mode agent to preview structured output, or switch to the chat tab.', )} ) } return (