diff --git a/apps/web/src/app/agents/[id]/chat/[chatId]/page.tsx b/apps/web/src/app/agents/[id]/chat/[chatId]/page.tsx new file mode 100644 index 0000000..33f1d4b --- /dev/null +++ b/apps/web/src/app/agents/[id]/chat/[chatId]/page.tsx @@ -0,0 +1,834 @@ +'use client'; + +import type { AIProvider } from '@tpmjs/types/agent'; +import { Badge } from '@tpmjs/ui/Badge/Badge'; +import { Button } from '@tpmjs/ui/Button/Button'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; +import { AppHeader } from '~/components/AppHeader'; +import { LikeButton } from '~/components/LikeButton'; + +interface AgentTool { + id: string; + toolId: string; + position: number; + tool: { + id: string; + name: string; + description: string; + likeCount: number; + package: { + id: string; + npmPackageName: string; + category: string; + }; + }; +} + +interface AgentCollection { + id: string; + collectionId: string; + position: number; + collection: { + id: string; + name: string; + description: string | null; + toolCount: number; + }; +} + +interface Agent { + id: string; + uid: string; + name: string; + description: string | null; + provider: AIProvider; + modelId: string; + systemPrompt: string | null; + temperature: number; + maxToolCallsPerTurn: number; + likeCount: number; + toolCount: number; + collectionCount: number; + createdBy: { + id: string; + name: string; + image: string | null; + }; + tools: AgentTool[]; + collections: AgentCollection[]; +} + +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 router = useRouter(); + const agentId = params.id as string; + const conversationId = params.chatId as string; + + 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 [hasMoreMessages, setHasMoreMessages] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(true); + + // Track first item index for prepending (Virtuoso pattern) + const [firstItemIndex, setFirstItemIndex] = useState(10000); + + const virtuosoRef = 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 (initial load - gets most recent 50) + const fetchMessages = useCallback(async () => { + if (!agent) return; + + try { + const response = await fetch( + `/api/agents/${agent.uid}/conversation/${conversationId}?limit=50` + ); + if (response.status === 404) { + // Conversation doesn't exist yet, that's fine + setHasMoreMessages(false); + return; + } + const data = await response.json(); + + if (data.success) { + const msgs = data.data.messages || []; + setMessages(msgs); + setHasMoreMessages(data.pagination?.hasMore ?? false); + // Reset first item index when loading fresh + setFirstItemIndex(10000); + } + } catch (err) { + console.error('Failed to fetch messages:', err); + } + }, [agent, conversationId]); + + // Load older messages when scrolling up + const loadMoreMessages = useCallback(async () => { + if (!agent || isLoadingMore || !hasMoreMessages || messages.length === 0) return; + + setIsLoadingMore(true); + try { + // Get the timestamp of the oldest message we have + const oldestMessage = messages[0]; + const beforeTimestamp = oldestMessage?.createdAt; + + if (!beforeTimestamp) return; + + const response = await fetch( + `/api/agents/${agent.uid}/conversation/${conversationId}?limit=50&before=${encodeURIComponent(beforeTimestamp)}` + ); + + if (!response.ok) return; + + const data = await response.json(); + + if (data.success) { + const olderMessages = data.data.messages || []; + if (olderMessages.length > 0) { + // Prepend older messages and adjust firstItemIndex + setFirstItemIndex((prev) => prev - olderMessages.length); + setMessages((prev) => [...olderMessages, ...prev]); + } + setHasMoreMessages(data.pagination?.hasMore ?? false); + } + } catch (err) { + console.error('Failed to load more messages:', err); + } finally { + setIsLoadingMore(false); + } + }, [agent, conversationId, isLoadingMore, hasMoreMessages, messages]); + + useEffect(() => { + const init = async () => { + await fetchAgent(); + setIsLoading(false); + }; + init(); + }, [fetchAgent]); + + useEffect(() => { + if (agent) { + fetchMessages(); + } + }, [agent, fetchMessages]); + + 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 new chat URL + const newId = generateConversationId(); + router.push(`/agents/${agentId}/chat/${newId}`); + }; + + if (isLoading) { + return ( +
+ +
+ +
+
+ ); + } + + if (error && !agent) { + return ( +
+ +
+
+ +

Unable to Chat

+

{error}

+ + + +
+
+
+ ); + } + + if (!agent) { + return ( +
+ +
+ +
+
+ ); + } + + return ( +
+ + +
+ {/* Sidebar */} +
+
+ {/* Agent Header */} +
+ + + Back to Agent + +
+
+

{agent.name}

+ {agent.description && ( +

+ {agent.description} +

+ )} +
+
+
+ +
+
+ + {/* Configuration */} +
+

+ + Configuration +

+
+
+ Provider + + {PROVIDER_DISPLAY_NAMES[agent.provider]} + +
+
+ Model + {agent.modelId} +
+
+ Temperature + {agent.temperature} +
+
+ Max Tool Calls + {agent.maxToolCallsPerTurn} +
+
+ + {agent.systemPrompt && ( +
+ System Prompt +
+                    {agent.systemPrompt}
+                  
+
+ )} +
+ + {/* Tools */} +
+

+ + Tools ({agent.tools.length}) +

+ {agent.tools.length === 0 ? ( +

No tools configured

+ ) : ( +
+ {agent.tools.map((at) => ( + +
+
+

+ {at.tool.name} +

+

+ {at.tool.package.npmPackageName} +

+
+ + {at.tool.package.category} + +
+ + ))} +
+ )} +
+ + {/* Collections */} + {agent.collections.length > 0 && ( +
+

+ + Collections ({agent.collections.length}) +

+
+ {agent.collections.map((ac) => ( + +

+ {ac.collection.name} +

+

+ {ac.collection.toolCount} tools +

+ + ))} +
+
+ )} +
+
+ + {/* Main Chat Area */} +
+ {/* Chat Header */} +
+
+ +
+

{agent.name}

+

+ {PROVIDER_DISPLAY_NAMES[agent.provider]} • {agent.modelId} +

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

Start a conversation

+

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

+
+
+ ) : ( + + hasMoreMessages ? ( +
+ {isLoadingMore ? ( +
+ + Loading older messages... +
+ ) : ( + + )} +
+ ) : null, + Footer: () => ( +
+ {/* Live tool calls during streaming */} + {toolCalls.length > 0 && ( +
+ {toolCalls.map((tc) => ( +
+
+ toggleToolCall(tc.toolCallId)} + /> +
+
+ ))} +
+ )} + + {streamingContent && ( +
+
+

{streamingContent}

+ +
+
+ )} + + {isSending && !streamingContent && toolCalls.length === 0 && ( +
+
+
+ + Thinking... +
+
+
+ )} +
+ ), + }} + itemContent={(_index, 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}

+
+ )} +
+
+ ); + }} + /> + )} +
+ + {/* Error Message */} + {error && ( +
+

{error}

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