"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, type ReactNode, useCallback } from "react"; import { MicIcon, Save, Loader2, Lock } from "lucide-react"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { JsonEditor } from "@/components/json-editor"; import { TiptapMarkdownEditor } from "@/components/tiptap-markdown-editor"; import { MarkdownViewer } from "@/components/markdown-viewer"; interface ChatMessage { id: string; type: 'message'; role: 'user' | 'assistant'; content: string; timestamp: number; } interface ToolCall { id: string; type: 'tool'; name: string; input: unknown; result?: unknown; 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; }; type ToolCallContentPart = { type: 'tool-call'; toolCallId: string; toolName: string; arguments: unknown; }; type RunEvent = { type: string; [key: string]: unknown; }; function PageBody() { const [apiBase, setApiBase] = useState("http://localhost:3000") const streamUrl = "/api/stream"; 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 [isRunProcessing, setIsRunProcessing] = useState(false); 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 [artifactFileType, setArtifactFileType] = useState<"json" | "markdown">("json"); const [agentOptions, setAgentOptions] = useState(["copilot"]); const [selectedAgent, setSelectedAgent] = useState("copilot"); const artifactDirty = !artifactReadOnly && artifactText !== artifactOriginal; const stripExtension = (name: string) => name.replace(/\.[^/.]+$/, ""); const detectFileType = (name: string): "json" | "markdown" => name.toLowerCase().match(/\.(md|markdown)$/) ? "markdown" : "json"; useEffect(() => { setApiBase(window.config.apiBase); }, []); const requestJson = useCallback(async ( url: string, options?: (RequestInit & { allow404?: boolean }) | undefined ) => { const fullUrl = new URL(url, apiBase).toString(); console.log('fullUrl', fullUrl); 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; } }, [apiBase]); 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(streamUrl); 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; }; }, [streamUrl]); // Handle different event types from the copilot const handleEvent = (event: RunEvent) => { console.log('Event received:', event.type, event); switch (event.type) { case 'run-processing-start': setIsRunProcessing(true); setStatus((prev) => (prev === 'error' ? prev : 'streaming')); break; case 'run-processing-end': setIsRunProcessing(false); setStatus('ready'); break; case 'start': setStatus('streaming'); setCurrentAssistantMessage(''); setCurrentReasoning(''); break; case 'llm-stream-event': { const llmEvent = (event.event as { type?: string; delta?: string; toolCallId?: string; toolName?: string; input?: unknown; }) || {}; console.log('LLM stream event type:', llmEvent.type); if (llmEvent.type === 'reasoning-delta' && llmEvent.delta) { setCurrentReasoning(prev => prev + llmEvent.delta); } else if (llmEvent.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 (llmEvent.type === 'text-delta' && llmEvent.delta) { setCurrentAssistantMessage(prev => prev + llmEvent.delta); setStatus('streaming'); } else if (llmEvent.type === 'text-end') { console.log('TEXT END received - waiting for message event'); } else if (llmEvent.type === 'tool-call') { // Add tool call to conversation immediately setConversation(prev => [...prev, { id: llmEvent.toolCallId || `tool-${Date.now()}`, type: 'tool', name: llmEvent.toolName || 'tool', input: llmEvent.input, status: 'running', timestamp: Date.now(), }]); } else if (llmEvent.type === 'finish-step') { console.log('FINISH STEP received - waiting for message event'); } } break; case 'message': { console.log('MESSAGE event received:', event); const message = (event.message as { role?: string; content?: unknown }) || {}; if (message.role !== 'assistant') { break; } if (Array.isArray(message.content)) { const toolCalls = message.content.filter( (part): part is ToolCallContentPart => (part as ToolCallContentPart)?.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) => 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 = typeof event.messageId === "string" ? event.messageId : `assistant-${Date.now()}`; if (committedMessageIds.current.has(messageId)) { console.log('⚠️ Message already committed, skipping:', messageId); break; } 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 { const errorMsg = typeof event.error === "string" ? event.error : ""; if (errorMsg && !errorMsg.includes('terminated')) { setStatus('error'); console.error('Agent error:', errorMsg); } else { console.log('Connection error (will auto-reconnect):', errorMsg); setStatus('ready'); } setIsRunProcessing(false); } 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 { let nextRunId = runId; if (!nextRunId) { const runData = await requestJson("/runs/new", { method: "POST", body: JSON.stringify({ agentId: selectedAgent, }), }); nextRunId = runData?.id; setRunId(nextRunId); } if (!nextRunId) { throw new Error("Run ID unavailable after creation"); } await requestJson(`/runs/${encodeURIComponent(nextRunId)}/messages/new`, { method: "POST", body: JSON.stringify({ message: userMessage, }), }); 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 { const title = selectedResource.name; let subtitle = ""; let text = ""; let readOnly = false; const detectedType = detectFileType(selectedResource.name); setArtifactFileType(detectedType); if (selectedResource.kind === "agent") { const raw = selectedResource.name; const isMarkdown = /\.(md|markdown)$/i.test(raw); if (isMarkdown) { subtitle = "Agent (Markdown)"; const response = await fetch( `/api/rowboat/agent?file=${encodeURIComponent(raw)}` ); if (!response.ok) { if (response.status === 404) { text = ""; } else { throw new Error(`Failed to load agent file: ${response.status}`); } } else { const data = await response.json(); text = data?.content || data?.raw || ""; } setArtifactFileType("markdown"); } else { const id = stripExtension(raw) || raw; const data = await requestJson(`/agents/${encodeURIComponent(id)}`); subtitle = "Agent"; text = JSON.stringify(data ?? {}, null, 2); setArtifactFileType("json"); } } else if (selectedResource.kind === "config") { const lower = selectedResource.name.toLowerCase(); if (lower.endsWith(".md") || lower.endsWith(".markdown")) { // Load markdown file as plain text from local API try { const response = await fetch( `/api/rowboat/config?file=${encodeURIComponent(selectedResource.name)}` ); if (!response.ok) { if (response.status === 404) { // File doesn't exist, start with empty content text = ""; } else { throw new Error(`Failed to load markdown file: ${response.status}`); } } else { const data = await response.json(); text = data.content || data.raw || ""; } subtitle = "Markdown"; setArtifactFileType("markdown"); } catch (error: unknown) { const err = error as Error; console.error("Error loading markdown file:", error); // Show error but still allow editing setArtifactError(err?.message || "Failed to load markdown file"); text = ""; subtitle = "Markdown"; setArtifactFileType("markdown"); } } else if (lower.includes("mcp")) { const data = await requestJson("/mcp"); subtitle = "MCP config"; text = JSON.stringify(data ?? {}, null, 2); setArtifactFileType("json"); } else if (lower.includes("model")) { const data = await requestJson("/models"); subtitle = "Models config"; text = JSON.stringify(data ?? {}, null, 2); setArtifactFileType("json"); } else { // Try to load as JSON by default try { const data = await requestJson(`/config/${encodeURIComponent(selectedResource.name)}`); subtitle = "Config"; text = JSON.stringify(data ?? {}, null, 2); setArtifactFileType("json"); } catch { throw new Error("Unsupported config file"); } } } else if (selectedResource.kind === "run") { subtitle = "Run (read-only)"; readOnly = true; setArtifactFileType(detectedType); 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: unknown) { if (!cancelled) { const err = error as Error; setArtifactError(err?.message || "Failed to load resource"); setArtifactText(""); } } finally { if (!cancelled) { setArtifactLoading(false); } } }; load(); return () => { cancelled = true; }; }, [selectedResource, requestJson]); 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(""); setIsRunProcessing(false); }, [selectedAgent]); const handleSave = async () => { if (!selectedResource || artifactReadOnly || !artifactDirty) return; setArtifactLoading(true); setArtifactError(null); try { if (selectedResource.kind === "agent") { if (artifactFileType === "markdown") { const response = await fetch( `/api/rowboat/agent?file=${encodeURIComponent(selectedResource.name)}`, { method: "PUT", headers: { "Content-Type": "text/plain" }, body: artifactText, } ); if (!response.ok) { throw new Error("Failed to save agent file"); } setArtifactOriginal(artifactText); } else { const parsed = JSON.parse(artifactText); const raw = selectedResource.name; const targetId = stripExtension(raw) || raw; await requestJson(`/agents/${encodeURIComponent(targetId)}`, { method: "PUT", body: JSON.stringify(parsed), }); setArtifactOriginal(JSON.stringify(parsed, null, 2)); } } else if (selectedResource.kind === "config") { const lower = selectedResource.name.toLowerCase(); if (lower.endsWith(".md") || lower.endsWith(".markdown")) { // Save markdown file as plain text via local API const response = await fetch( `/api/rowboat/config?file=${encodeURIComponent(selectedResource.name)}`, { method: "PUT", headers: { "Content-Type": "text/plain" }, body: artifactText, } ); if (!response.ok) { throw new Error("Failed to save markdown file"); } setArtifactOriginal(artifactText); } else { // Handle JSON config files const parsed = JSON.parse(artifactText); 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(parsed, null, 2)); } } } catch (error: unknown) { const err = error as Error; setArtifactError(err?.message || "Failed to save changes"); } finally { setArtifactLoading(false); } }; return ( <>
RowboatX Chat
{isRunProcessing && (
Working...
)} {/* 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 != null && ( )}
); } 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 ? ( artifactFileType === "markdown" ? ( ) : (
                            {artifactText}
                          
) ) : artifactFileType === "markdown" ? ( setArtifactText(newContent)} readOnly={false} placeholder="Start writing your markdown..." /> ) : ( setArtifactText(newContent)} readOnly={false} /> )} {artifactReadOnly && (

Runs are read-only; use the API to replay or inspect in detail.

)}
)}
)}
); } export default function HomePage() { return ( ); }