From 8e572714ae0a0bca3c1ccb49e1b6a9ab2fdaa49d Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Wed, 7 Jan 2026 23:06:05 +1000 Subject: [PATCH] feat: add public chat page for agents - Create public chat page at /agents/[id]/chat with unique conversation ID per page load - Add Chat column to public agents table with link to start new conversations - Full chat UI with streaming responses, tool call visualization, and error handling - Uses agent owner's API keys so no authentication required for users --- apps/web/src/app/agents/[id]/chat/page.tsx | 584 +++++++++++++++++++++ apps/web/src/app/agents/page.tsx | 10 + 2 files changed, 594 insertions(+) create mode 100644 apps/web/src/app/agents/[id]/chat/page.tsx diff --git a/apps/web/src/app/agents/[id]/chat/page.tsx b/apps/web/src/app/agents/[id]/chat/page.tsx new file mode 100644 index 0000000..fa1916a --- /dev/null +++ b/apps/web/src/app/agents/[id]/chat/page.tsx @@ -0,0 +1,584 @@ +'use client'; + +import type { AIProvider } from '@tpmjs/types/agent'; +import { Button } from '@tpmjs/ui/Button/Button'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import Link from 'next/link'; +import { useParams, useSearchParams } from 'next/navigation'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { AppHeader } from '~/components/AppHeader'; + +interface Agent { + id: string; + uid: string; + name: string; + description: string | null; + provider: AIProvider; + modelId: string; + createdBy: { + id: string; + name: string; + image: string | null; + }; +} + +interface Message { + id: string; + role: 'USER' | 'ASSISTANT' | 'TOOL'; + content: string; + toolName?: string; + toolCallId?: string; + toolResult?: unknown; + createdAt: string; +} + +interface ToolCall { + toolCallId: string; + toolName: string; + input?: unknown; + output?: unknown; + status: 'pending' | 'running' | 'success' | 'error'; +} + +/** + * Tool call debug card component + */ +function ToolCallCard({ + toolCall, + isExpanded, + onToggle, +}: { + toolCall: ToolCall; + isExpanded: boolean; + onToggle: () => void; +}) { + const statusColors = { + pending: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', + running: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + success: 'bg-green-500/20 text-green-400 border-green-500/30', + error: 'bg-red-500/20 text-red-400 border-red-500/30', + }; + + const statusIcons: Record = { + pending: 'info', + running: 'loader', + success: 'check', + error: 'alertCircle', + }; + + const formatJson = (data: unknown): React.ReactNode => { + try { + return JSON.stringify(data, null, 2); + } catch { + return String(data); + } + }; + + return ( +
+ {/* Header */} + + + {/* Expanded Content */} + {isExpanded && ( +
+ {/* Input Section */} + {toolCall.input !== undefined && toolCall.input !== null ? ( +
+
+ + Input + +
+
+
+                {formatJson(toolCall.input)}
+              
+
+ ) : null} + + {/* Output Section */} + {toolCall.output !== undefined && toolCall.output !== null ? ( +
+
+ + Output + +
+
+
+                {formatJson(toolCall.output)}
+              
+
+ ) : null} + + {/* Status indicator for running */} + {toolCall.status === 'running' && !toolCall.output && ( +
+ + Executing... +
+ )} +
+ )} +
+ ); +} + +const PROVIDER_DISPLAY_NAMES: Record = { + OPENAI: 'OpenAI', + ANTHROPIC: 'Anthropic', + GOOGLE: 'Google', + GROQ: 'Groq', + MISTRAL: 'Mistral', +}; + +function generateConversationId(): string { + return `conv-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; +} + +export default function PublicAgentChatPage(): React.ReactElement { + const params = useParams(); + const searchParams = useSearchParams(); + const agentId = params.id as string; + + // Generate a unique conversation ID on mount, or use one from URL + const conversationId = useMemo(() => { + return searchParams.get('c') || generateConversationId(); + }, [searchParams]); + + const [agent, setAgent] = useState(null); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isSending, setIsSending] = useState(false); + const [streamingContent, setStreamingContent] = useState(''); + const [error, setError] = useState(null); + const [toolCalls, setToolCalls] = useState([]); + const [expandedToolCalls, setExpandedToolCalls] = useState>(new Set()); + + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + const toggleToolCall = (toolCallId: string) => { + setExpandedToolCalls((prev) => { + const next = new Set(prev); + if (next.has(toolCallId)) { + next.delete(toolCallId); + } else { + next.add(toolCallId); + } + return next; + }); + }; + + // Fetch public agent data + const fetchAgent = useCallback(async () => { + try { + const response = await fetch(`/api/public/agents/${agentId}`); + const data = await response.json(); + + if (data.success) { + setAgent(data.data); + } else { + setError(data.error?.message || 'Agent not found or is private'); + } + } catch (err) { + console.error('Failed to fetch agent:', err); + setError('Failed to fetch agent'); + } + }, [agentId]); + + // Fetch messages for conversation + const fetchMessages = useCallback(async () => { + if (!agent) return; + + try { + const response = await fetch(`/api/agents/${agent.uid}/conversation/${conversationId}`); + if (response.status === 404) { + // Conversation doesn't exist yet, that's fine + return; + } + const data = await response.json(); + + if (data.success) { + setMessages(data.data.messages || []); + } + } catch (err) { + console.error('Failed to fetch messages:', err); + } + }, [agent, conversationId]); + + useEffect(() => { + const init = async () => { + await fetchAgent(); + setIsLoading(false); + }; + init(); + }, [fetchAgent]); + + useEffect(() => { + if (agent) { + fetchMessages(); + } + }, [agent, fetchMessages]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }); + + const handleSend = async () => { + if (!input.trim() || !agent || isSending) return; + + const messageContent = input.trim(); + setInput(''); + setIsSending(true); + setStreamingContent(''); + setError(null); + setToolCalls([]); + + // Optimistically add user message + const userMessage: Message = { + id: `temp-${Date.now()}`, + role: 'USER', + content: messageContent, + createdAt: new Date().toISOString(), + }; + setMessages((prev) => [...prev, userMessage]); + + try { + const response = await fetch(`/api/agents/${agent.uid}/conversation/${conversationId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: messageContent }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to send message'); + } + + // Handle SSE stream + const reader = response.body?.getReader(); + if (!reader) throw new Error('No response body'); + + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Parse SSE events + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep incomplete line in buffer + + let eventType = ''; + for (const line of lines) { + if (line.startsWith('event: ')) { + eventType = line.slice(7); + } else if (line.startsWith('data: ')) { + const data = JSON.parse(line.slice(6)); + + switch (eventType) { + case 'chunk': + setStreamingContent((prev) => prev + data.text); + break; + case 'tool_call': + // Add tool call to tracking + setToolCalls((prev) => [ + ...prev, + { + toolCallId: data.toolCallId, + toolName: data.toolName, + input: data.input, + status: 'running', + }, + ]); + // Auto-expand new tool calls + setExpandedToolCalls((prev) => new Set([...prev, data.toolCallId])); + break; + case 'tool_result': + // Update tool call with result + setToolCalls((prev) => + prev.map((tc) => + tc.toolCallId === data.toolCallId + ? { ...tc, output: data.output, status: 'success' as const } + : tc + ) + ); + break; + case 'complete': + // Refresh messages + await fetchMessages(); + setStreamingContent(''); + setToolCalls([]); + break; + case 'error': + throw new Error(data.message); + } + } + } + } + } catch (err) { + console.error('Failed to send message:', err); + setError(err instanceof Error ? err.message : 'Failed to send message'); + // Remove optimistic message on error + setMessages((prev) => prev.filter((m) => m.id !== userMessage.id)); + } finally { + setIsSending(false); + setStreamingContent(''); + inputRef.current?.focus(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const startNewConversation = () => { + // Navigate to same page with new conversation ID + const newId = generateConversationId(); + window.location.href = `/agents/${agentId}/chat?c=${newId}`; + }; + + if (isLoading) { + return ( +
+ +
+ +
+
+ ); + } + + if (error && !agent) { + return ( +
+ +
+
+ +

Unable to Chat

+

{error}

+ + + +
+
+
+ ); + } + + if (!agent) { + return ( +
+ +
+ +
+
+ ); + } + + return ( +
+ + + {/* Chat Header */} +
+
+
+ + + +
+

{agent.name}

+

+ {PROVIDER_DISPLAY_NAMES[agent.provider]} • Created by {agent.createdBy.name} +

+
+
+ +
+
+ + {/* Chat Area */} +
+ {/* Messages */} +
+ {messages.length === 0 && !streamingContent && ( +
+
+
+ +
+

Start a conversation

+

+ Send a message to start chatting with {agent.name}. +

+
+
+ )} + + {messages.map((message) => { + // Parse tool output safely + const getToolOutput = () => { + if (message.toolResult) return message.toolResult; + try { + return JSON.parse(message.content || '{}'); + } catch { + return { result: message.content }; + } + }; + + return ( +
+ {message.role === 'TOOL' ? ( +
+ toggleToolCall(message.toolCallId || message.id)} + /> +
+ ) : ( +
+

{message.content}

+
+ )} +
+ ); + })} + + {/* Live tool calls during streaming */} + {toolCalls.length > 0 && ( +
+ {toolCalls.map((tc) => ( +
+
+ toggleToolCall(tc.toolCallId)} + /> +
+
+ ))} +
+ )} + + {streamingContent && ( +
+
+

{streamingContent}

+ +
+
+ )} + + {isSending && !streamingContent && toolCalls.length === 0 && ( +
+
+
+ + Thinking... +
+
+
+ )} + +
+
+ + {/* Error Message */} + {error && ( +
+

{error}

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