"use client"; import { AppSidebar } from "@/components/app-sidebar"; import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, SidebarTrigger, } from "@/components/ui/sidebar"; import { PromptInput, PromptInputBody, PromptInputTextarea, PromptInputFooter, PromptInputTools, PromptInputButton, PromptInputSubmit, PromptInputAttachments, PromptInputAttachment, PromptInputActionMenu, PromptInputActionMenuTrigger, PromptInputActionMenuContent, PromptInputActionAddAttachments, PromptInputHeader, type PromptInputMessage, } from "@/components/ai-elements/prompt-input"; import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message"; import { Conversation, ConversationContent } from "@/components/ai-elements/conversation"; import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from "@/components/ai-elements/tool"; import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-elements/reasoning"; import { Artifact, ArtifactAction, ArtifactActions, ArtifactClose, ArtifactContent, ArtifactDescription, ArtifactHeader, ArtifactTitle, } from "@/components/ai-elements/artifact"; import { useState, useEffect, useRef } from "react"; import { MicIcon, Save, Loader2, Lock } from "lucide-react"; import { RunEvent } from "@/lib/cli-client"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; interface ChatMessage { id: string; type: 'message'; role: 'user' | 'assistant'; content: string; timestamp: number; } interface ToolCall { id: string; type: 'tool'; name: string; input: any; result?: any; status: 'pending' | 'running' | 'completed' | 'error'; timestamp: number; } interface ReasoningBlock { id: string; type: 'reasoning'; content: string; isStreaming: boolean; timestamp: number; } type ConversationItem = ChatMessage | ToolCall | ReasoningBlock; type ResourceKind = "agent" | "config" | "run"; type SelectedResource = { kind: ResourceKind; name: string; }; function PageBody() { // Use local proxy to avoid CORS/port mismatches. const apiBase = "/api/cli"; const [text, setText] = useState(""); const [useMicrophone, setUseMicrophone] = useState(false); const [status, setStatus] = useState<"submitted" | "streaming" | "ready" | "error">("ready"); // Chat state const [runId, setRunId] = useState(null); const [conversation, setConversation] = useState([]); const [currentAssistantMessage, setCurrentAssistantMessage] = useState(""); const [currentReasoning, setCurrentReasoning] = useState(""); const eventSourceRef = useRef(null); const committedMessageIds = useRef>(new Set()); const isEmptyConversation = conversation.length === 0 && !currentAssistantMessage && !currentReasoning; const [selectedResource, setSelectedResource] = useState(null); const [artifactTitle, setArtifactTitle] = useState(""); const [artifactSubtitle, setArtifactSubtitle] = useState(""); const [artifactText, setArtifactText] = useState(""); const [artifactOriginal, setArtifactOriginal] = useState(""); const [artifactLoading, setArtifactLoading] = useState(false); const [artifactError, setArtifactError] = useState(null); const [artifactReadOnly, setArtifactReadOnly] = useState(false); const [agentOptions, setAgentOptions] = useState(["copilot"]); const [selectedAgent, setSelectedAgent] = useState("copilot"); const artifactDirty = !artifactReadOnly && artifactText !== artifactOriginal; const stripExtension = (name: string) => name.replace(/\.[^/.]+$/, ""); const requestJson = async ( url: string, options?: (RequestInit & { allow404?: boolean }) | undefined ) => { const isLocalApi = url.startsWith("/api/rowboat"); const fullUrl = url.startsWith("http://") || url.startsWith("https://") || isLocalApi ? url : apiBase ? `${apiBase}${url}` : url; const { allow404, ...rest } = options || {}; const res = await fetch(fullUrl, { ...rest, headers: { "Content-Type": "application/json", ...(rest.headers || {}), }, }); const contentType = res.headers.get("content-type")?.toLowerCase() ?? ""; const isJson = contentType.includes("application/json"); const text = await res.text(); if (!res.ok) { if (res.status === 404 && allow404) return null; if (isJson) { try { const errObj = JSON.parse(text); const errMsg = typeof errObj === "string" ? errObj : errObj?.message || errObj?.error || JSON.stringify(errObj); throw new Error(errMsg || `Request failed: ${res.status} ${res.statusText}`); } catch { /* fall through to generic error */ } } if (res.status === 404) { throw new Error("Resource not found on the CLI backend (404)"); } throw new Error(`Request failed: ${res.status} ${res.statusText}`); } if (!text) return null; if (!isJson) return null; try { return JSON.parse(text); } catch { return null; } }; const renderPromptInput = () => ( {(attachment) => } setText(event.target.value)} value={text} placeholder="Ask me anything..." className="min-h-[46px] max-h-[200px]" /> setUseMicrophone(!useMicrophone)} variant={useMicrophone ? "default" : "ghost"} > Microphone ); // Connect to SSE stream useEffect(() => { // Prevent multiple connections if (eventSourceRef.current) { console.log('⚠️ EventSource already exists, not creating new one'); return; } console.log('🔌 Creating new EventSource connection'); const eventSource = new EventSource('/api/stream'); eventSourceRef.current = eventSource; const handleMessage = (e: MessageEvent) => { try { const event: RunEvent = JSON.parse(e.data); handleEvent(event); } catch (error) { console.error('Failed to parse event:', error); } }; const handleError = (e: Event) => { const target = e.target as EventSource; // Only log if it's not a normal close if (target.readyState === EventSource.CLOSED) { console.log('SSE connection closed, will reconnect on next message'); } else if (target.readyState === EventSource.CONNECTING) { console.log('SSE reconnecting...'); } else { console.error('SSE error:', e); } }; eventSource.addEventListener('message', handleMessage); eventSource.addEventListener('error', handleError); return () => { console.log('🔌 Closing EventSource connection'); eventSource.removeEventListener('message', handleMessage); eventSource.removeEventListener('error', handleError); eventSource.close(); eventSourceRef.current = null; }; }, []); // Empty deps - only run once // Handle different event types from the copilot const handleEvent = (event: RunEvent) => { console.log('Event received:', event.type, event); switch (event.type) { case 'start': setStatus('streaming'); setCurrentAssistantMessage(''); setCurrentReasoning(''); break; case 'llm-stream-event': console.log('LLM stream event type:', event.event?.type); if (event.event?.type === 'reasoning-delta') { setCurrentReasoning(prev => prev + event.event.delta); } else if (event.event?.type === 'reasoning-end') { // Commit reasoning block if we have content setCurrentReasoning(reasoning => { if (reasoning) { setConversation(prev => [...prev, { id: `reasoning-${Date.now()}`, type: 'reasoning', content: reasoning, isStreaming: false, timestamp: Date.now(), }]); } return ''; }); } else if (event.event?.type === 'text-delta') { setCurrentAssistantMessage(prev => prev + event.event.delta); setStatus('streaming'); } else if (event.event?.type === 'text-end') { console.log('TEXT END received - waiting for message event'); } else if (event.event?.type === 'tool-call') { // Add tool call to conversation immediately setConversation(prev => [...prev, { id: event.event.toolCallId, type: 'tool', name: event.event.toolName, input: event.event.input, status: 'running', timestamp: Date.now(), }]); } else if (event.event?.type === 'finish-step') { console.log('FINISH STEP received - waiting for message event'); } break; case 'message': console.log('MESSAGE event received:', event); if (event.message?.role === 'assistant') { // If the final assistant message contains tool calls, sync them to conversation if (Array.isArray(event.message.content)) { const toolCalls = event.message.content.filter( (part: any) => part?.type === 'tool-call' ); if (toolCalls.length) { setConversation((prev) => { let updated: ConversationItem[] = prev.map((item) => { if (item.type !== 'tool') return item; const match = toolCalls.find( (part: any) => part.toolCallId === item.id ); return match ? { ...item, name: match.toolName, input: match.arguments, status: 'pending', } : item; }); for (const part of toolCalls) { const exists = updated.some( (item) => item.type === 'tool' && item.id === part.toolCallId ); if (!exists) { updated = [ ...updated, { id: part.toolCallId, type: 'tool', name: part.toolName, input: part.arguments, status: 'pending', timestamp: Date.now(), }, ]; } } return updated; }); } } const messageId = event.messageId || `assistant-${Date.now()}`; if (committedMessageIds.current.has(messageId)) { console.log('⚠️ Message already committed, skipping:', messageId); return; } committedMessageIds.current.add(messageId); setCurrentAssistantMessage(currentMsg => { console.log('✅ Committing message:', messageId, currentMsg); if (currentMsg) { setConversation(prev => { const exists = prev.some(m => m.id === messageId); if (exists) { console.log('⚠️ Message ID already in array, skipping:', messageId); return prev; } return [...prev, { id: messageId, type: 'message', role: 'assistant', content: currentMsg, timestamp: Date.now(), }]; }); } return ''; }); setStatus('ready'); console.log('Status set to ready'); } break; case 'tool-invocation': setConversation(prev => prev.map(item => item.type === 'tool' && (item.id === event.toolCallId || item.name === event.toolName) ? { ...item, status: 'running' as const } : item )); break; case 'tool-result': setConversation(prev => prev.map(item => item.type === 'tool' && (item.id === event.toolCallId || item.name === event.toolName) ? { ...item, result: event.result, status: 'completed' as const } : item )); break; case 'error': // Only set error status for actual errors, not connection issues if (event.error && !event.error.includes('terminated')) { setStatus('error'); console.error('Agent error:', event.error); } else { console.log('Connection error (will auto-reconnect):', event.error); setStatus('ready'); } break; default: console.log('Unhandled event type:', event.type); } }; const handleSubmit = async (message: PromptInputMessage) => { const hasText = Boolean(message.text); const hasAttachments = Boolean(message.files?.length); if (!(hasText || hasAttachments)) { return; } const userMessage = message.text || ''; // Add user message immediately with unique ID const userMessageId = `user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; setConversation(prev => [...prev, { id: userMessageId, type: 'message', role: 'user', content: userMessage, timestamp: Date.now(), }]); setStatus("submitted"); setText(""); try { // Send message to backend const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: userMessage, agentId: selectedAgent, runId: runId, }), }); if (!response.ok) { throw new Error('Failed to send message'); } const data = await response.json(); // Store runId for subsequent messages if (data.runId && !runId) { setRunId(data.runId); } setStatus('streaming'); } catch (error) { console.error('Failed to send message:', error); setStatus('error'); setTimeout(() => setStatus('ready'), 2000); } }; useEffect(() => { if (!selectedResource) return; let cancelled = false; const load = async () => { setArtifactLoading(true); setArtifactError(null); try { let title = selectedResource.name; let subtitle = ""; let text = ""; let readOnly = false; if (selectedResource.kind === "agent") { const raw = selectedResource.name; const id = stripExtension(raw) || raw; const data = await requestJson(`/agents/${encodeURIComponent(id)}`); subtitle = "Agent"; text = JSON.stringify(data ?? {}, null, 2); } else if (selectedResource.kind === "config") { const lower = selectedResource.name.toLowerCase(); if (lower.includes("mcp")) { const data = await requestJson("/mcp"); subtitle = "MCP config"; text = JSON.stringify(data ?? {}, null, 2); } else if (lower.includes("model")) { const data = await requestJson("/models"); subtitle = "Models config"; text = JSON.stringify(data ?? {}, null, 2); } else { throw new Error("Unsupported config file"); } } else if (selectedResource.kind === "run") { subtitle = "Run (read-only)"; readOnly = true; const local = await requestJson( `/api/rowboat/run?file=${encodeURIComponent(selectedResource.name)}` ); if (local?.parsed) { text = JSON.stringify(local.parsed, null, 2); } else if (local?.raw) { text = local.raw; } else { text = ""; } } if (cancelled) return; setArtifactTitle(title); setArtifactSubtitle(subtitle); setArtifactText(text); setArtifactOriginal(text); setArtifactReadOnly(readOnly); } catch (error: any) { if (!cancelled) { setArtifactError(error?.message || "Failed to load resource"); setArtifactText(""); } } finally { if (!cancelled) { setArtifactLoading(false); } } }; load(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedResource]); useEffect(() => { const loadAgents = async () => { try { const res = await fetch("/api/rowboat/summary"); if (!res.ok) return; const data = await res.json(); const agents = Array.isArray(data.agents) ? data.agents.map((a: string) => stripExtension(a)) : []; const merged = Array.from(new Set(["copilot", ...agents])); setAgentOptions(merged); } catch (e) { console.error("Failed to load agent list", e); } }; loadAgents(); }, []); useEffect(() => { // Changing agent starts a fresh conversation context setRunId(null); setConversation([]); setCurrentAssistantMessage(""); setCurrentReasoning(""); }, [selectedAgent]); const handleSave = async () => { if (!selectedResource || artifactReadOnly || !artifactDirty) return; setArtifactLoading(true); setArtifactError(null); try { const parsed = JSON.parse(artifactText); if (selectedResource.kind === "agent") { const raw = selectedResource.name; const targetId = stripExtension(raw) || raw; await requestJson(`/agents/${encodeURIComponent(targetId)}`, { method: "PUT", body: JSON.stringify(parsed), }); } else if (selectedResource.kind === "config") { const lower = selectedResource.name.toLowerCase(); const previous = artifactOriginal ? JSON.parse(artifactOriginal) : {}; if (lower.includes("model")) { const newProviders = parsed.providers || {}; const oldProviders = previous.providers || {}; const toDelete = Object.keys(oldProviders).filter( (name) => !Object.prototype.hasOwnProperty.call(newProviders, name) ); for (const name of toDelete) { await requestJson(`/models/providers/${encodeURIComponent(name)}`, { method: "DELETE", }); } for (const name of Object.keys(newProviders)) { await requestJson(`/models/providers/${encodeURIComponent(name)}`, { method: "PUT", body: JSON.stringify(newProviders[name]), }); } if (parsed.defaults) { await requestJson("/models/default", { method: "PUT", body: JSON.stringify(parsed.defaults), }); } } else if (lower.includes("mcp")) { const newServers = parsed.mcpServers || parsed || {}; const oldServers = previous.mcpServers || {}; const toDelete = Object.keys(oldServers).filter( (name) => !Object.prototype.hasOwnProperty.call(newServers, name) ); for (const name of toDelete) { await requestJson(`/mcp/${encodeURIComponent(name)}`, { method: "DELETE", }); } for (const name of Object.keys(newServers)) { await requestJson(`/mcp/${encodeURIComponent(name)}`, { method: "PUT", body: JSON.stringify(newServers[name]), }); } } else { throw new Error("Unsupported config file"); } } setArtifactOriginal(JSON.stringify(JSON.parse(artifactText), null, 2)); } catch (error: any) { setArtifactError(error?.message || "Failed to save changes"); } finally { setArtifactLoading(false); } }; return ( <>
RowboatX Chat
{/* Messages area */}
{/* Render conversation items in order */} {conversation.map((item) => { if (item.type === 'message') { return ( {item.content} ); } else if (item.type === 'tool') { const stateMap: Record = { 'pending': 'input-streaming', 'running': 'input-available', 'completed': 'output-available', 'error': 'output-error', }; return (
{item.result && ( )}
); } else if (item.type === 'reasoning') { return (
{item.content}
); } return null; })} {/* Streaming reasoning */} {currentReasoning && (
{currentReasoning}
)} {/* Streaming message */} {currentAssistantMessage && ( {currentAssistantMessage} )}
{/* Input area */} {isEmptyConversation ? (

RowboatX

{renderPromptInput()}
) : (
{renderPromptInput()}
)}
{selectedResource && (
{artifactTitle} {artifactSubtitle || selectedResource.kind} {artifactReadOnly && ( Read-only )}
{!artifactReadOnly && ( {artifactLoading ? ( ) : ( )} )} setSelectedResource(null)} />
{artifactLoading ? (
Loading
) : artifactError ? (
{artifactError}
) : (
{artifactReadOnly ? (
                          {artifactText}
                        
) : (