connecting the copilot to the UI

This commit is contained in:
tusharmagar 2025-12-11 13:43:47 +05:30 committed by Ramnique Singh
parent 5e31c637f0
commit b1f6e64244
6 changed files with 785 additions and 60 deletions

View file

@ -0,0 +1,72 @@
import { cliClient, RunEvent } from '@/lib/cli-client';
import { NextRequest } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* POST /api/chat
* Creates a new conversation or sends a message to existing one
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { message, runId } = body;
if (!message || typeof message !== 'string') {
return Response.json(
{ error: 'Message is required' },
{ status: 400 }
);
}
let currentRunId = runId;
// Create new run if no runId provided
if (!currentRunId) {
const run = await cliClient.createRun({
agentId: 'copilot',
});
currentRunId = run.id;
}
// Always send the message (this triggers the agent runtime)
await cliClient.sendMessage(currentRunId, message);
// Return the run ID
return Response.json({ runId: currentRunId });
} catch (error) {
console.error('Chat API error:', error);
return Response.json(
{ error: 'Failed to process message' },
{ status: 500 }
);
}
}
/**
* GET /api/chat?runId=xxx
* Get a specific run's details
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const runId = searchParams.get('runId');
if (!runId) {
// List all runs
const result = await cliClient.listRuns();
return Response.json(result);
}
// Get specific run
const run = await cliClient.getRun(runId);
return Response.json(run);
} catch (error) {
console.error('Chat API error:', error);
return Response.json(
{ error: 'Failed to fetch run' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,113 @@
import { NextRequest } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
const CLI_BASE_URL = process.env.CLI_BACKEND_URL || 'http://localhost:3000';
/**
* GET /api/stream
* Proxy SSE stream from CLI backend to frontend
*/
export async function GET(request: NextRequest) {
const encoder = new TextEncoder();
const customReadable = new ReadableStream({
async start(controller) {
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
let isClosed = false;
// Handle client disconnect
request.signal.addEventListener('abort', () => {
isClosed = true;
reader?.cancel();
try {
controller.close();
} catch (e) {
// Already closed, ignore
}
});
try {
// Connect to CLI backend SSE stream
const response = await fetch(`${CLI_BASE_URL}/stream`, {
headers: {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
signal: request.signal, // Forward abort signal
});
if (!response.ok) {
throw new Error(`Failed to connect to backend: ${response.statusText}`);
}
reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
// Read and forward stream
while (!isClosed) {
const { done, value } = await reader.read();
if (done) {
break;
}
// Only enqueue if controller is still open
if (!isClosed) {
try {
controller.enqueue(value);
} catch (e) {
// Controller closed, stop reading
break;
}
}
}
} catch (error: any) {
// Only log non-abort errors
if (error.name !== 'AbortError') {
console.error('Stream error:', error);
}
// Try to send error message if controller is still open
if (!isClosed) {
try {
const errorMessage = `data: ${JSON.stringify({ type: 'error', error: String(error) })}\n\n`;
controller.enqueue(encoder.encode(errorMessage));
} catch (e) {
// Controller already closed, ignore
}
}
} finally {
// Clean up
if (reader) {
try {
await reader.cancel();
} catch (e) {
// Ignore cancel errors
}
}
if (!isClosed) {
try {
controller.close();
} catch (e) {
// Already closed, ignore
}
}
}
},
});
return new Response(customReadable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}

View file

@ -32,8 +32,41 @@ import {
PromptInputHeader,
type PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import { useState } from "react";
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 { useState, useEffect, useRef } from "react";
import { GlobeIcon, MicIcon } from "lucide-react";
import { RunEvent } from "@/lib/cli-client";
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;
export default function HomePage() {
const [text, setText] = useState<string>("");
@ -42,8 +75,264 @@ export default function HomePage() {
const [status, setStatus] = useState<
"submitted" | "streaming" | "ready" | "error"
>("ready");
// Chat state
const [runId, setRunId] = useState<string | null>(null);
const [conversation, setConversation] = useState<ConversationItem[]>([]);
const [currentAssistantMessage, setCurrentAssistantMessage] = useState<string>("");
const [currentReasoning, setCurrentReasoning] = useState<string>("");
const eventSourceRef = useRef<EventSource | null>(null);
const committedMessageIds = useRef<Set<string>>(new Set());
const isEmptyConversation =
conversation.length === 0 && !currentAssistantMessage && !currentReasoning;
const handleSubmit = (message: PromptInputMessage) => {
const renderPromptInput = () => (
<PromptInput globalDrop multiple onSubmit={handleSubmit}>
<PromptInputHeader>
<PromptInputAttachments>
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
</PromptInputHeader>
<PromptInputBody>
<PromptInputTextarea
onChange={(event) => setText(event.target.value)}
value={text}
placeholder="Ask me anything..."
className="min-h-[46px] max-h-[200px]"
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
<PromptInputButton
onClick={() => setUseMicrophone(!useMicrophone)}
variant={useMicrophone ? "default" : "ghost"}
>
<MicIcon size={16} />
<span className="sr-only">Microphone</span>
</PromptInputButton>
<PromptInputButton
onClick={() => setUseWebSearch(!useWebSearch)}
variant={useWebSearch ? "default" : "ghost"}
>
<GlobeIcon size={16} />
<span>Search</span>
</PromptInputButton>
</PromptInputTools>
<PromptInputSubmit
disabled={!(text.trim() || status) || status === "streaming"}
status={status}
/>
</PromptInputFooter>
</PromptInput>
);
// 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) => {
const updated = [...prev];
for (const part of toolCalls) {
const idx = updated.findIndex(
(item) => item.type === 'tool' && item.id === part.toolCallId
);
if (idx >= 0) {
updated[idx] = {
...updated[idx],
name: part.toolName,
input: part.arguments,
status: 'pending',
};
} else {
updated.push({
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);
@ -51,18 +340,55 @@ export default function HomePage() {
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");
console.log("Message submitted:", message);
// Reset after submission
setText("");
setTimeout(() => setStatus("ready"), 500);
try {
// Send message to backend
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: userMessage,
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);
}
};
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<SidebarInset className="h-svh">
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
@ -84,62 +410,113 @@ export default function HomePage() {
</div>
</header>
<div className="relative flex size-full flex-col overflow-hidden">
{/* Blank canvas - main content area */}
<div className="flex-1 overflow-y-auto">
{/* Empty space for messages */}
</div>
<div className="relative flex w-full flex-1 min-h-0 flex-col overflow-hidden">
{/* Messages area */}
<Conversation className="flex-1 min-h-0 pb-48">
<ConversationContent className="!flex !flex-col !items-center !gap-8 !p-4">
<div className="w-full max-w-3xl mx-auto space-y-4">
{/* Input area - centered and narrower */}
<div className="flex justify-center w-full px-4 pb-4">
<div className="w-full max-w-3xl">
<PromptInput globalDrop multiple onSubmit={handleSubmit}>
<PromptInputHeader>
<PromptInputAttachments>
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
</PromptInputHeader>
<PromptInputBody>
<PromptInputTextarea
onChange={(event) => setText(event.target.value)}
value={text}
placeholder="Ask me anything..."
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
<PromptInputButton
onClick={() => setUseMicrophone(!useMicrophone)}
variant={useMicrophone ? "default" : "ghost"}
{/* Render conversation items in order */}
{conversation.map((item) => {
if (item.type === 'message') {
return (
<Message
key={item.id}
from={item.role}
>
<MicIcon size={16} />
<span className="sr-only">Microphone</span>
</PromptInputButton>
<PromptInputButton
onClick={() => setUseWebSearch(!useWebSearch)}
variant={useWebSearch ? "default" : "ghost"}
>
<GlobeIcon size={16} />
<span>Search</span>
</PromptInputButton>
</PromptInputTools>
<PromptInputSubmit
disabled={!(text.trim() || status) || status === "streaming"}
status={status}
/>
</PromptInputFooter>
</PromptInput>
<MessageContent>
<MessageResponse>
{item.content}
</MessageResponse>
</MessageContent>
</Message>
);
} else if (item.type === 'tool') {
const stateMap: Record<string, any> = {
'pending': 'input-streaming',
'running': 'input-available',
'completed': 'output-available',
'error': 'output-error',
};
return (
<div key={item.id} className="mb-2">
<Tool>
<ToolHeader
title={item.name}
type="tool-call"
state={stateMap[item.status] || 'input-streaming'}
/>
<ToolContent>
<ToolInput input={item.input} />
{item.result && (
<ToolOutput output={item.result} errorText={undefined} />
)}
</ToolContent>
</Tool>
</div>
);
} else if (item.type === 'reasoning') {
return (
<div key={item.id} className="mb-2">
<Reasoning isStreaming={item.isStreaming}>
<ReasoningTrigger />
<ReasoningContent>
{item.content}
</ReasoningContent>
</Reasoning>
</div>
);
}
return null;
})}
{/* Streaming reasoning */}
{currentReasoning && (
<div className="mb-2">
<Reasoning isStreaming={true}>
<ReasoningTrigger />
<ReasoningContent>
{currentReasoning}
</ReasoningContent>
</Reasoning>
</div>
)}
{/* Streaming message */}
{currentAssistantMessage && (
<Message from="assistant">
<MessageContent>
<MessageResponse>
{currentAssistantMessage}
</MessageResponse>
<span className="inline-block w-2 h-4 ml-1 bg-current animate-pulse" />
</MessageContent>
</Message>
)}
</div>
</ConversationContent>
</Conversation>
{/* Input area */}
{isEmptyConversation ? (
<div className="absolute inset-0 flex items-center justify-center px-4 pb-16">
<div className="w-full max-w-3xl space-y-3 text-center">
<h2 className="text-4xl font-semibold text-foreground/80">
RowboatX
</h2>
{renderPromptInput()}
</div>
</div>
</div>
) : (
<div className="absolute bottom-2 left-0 right-0 flex justify-center w-full px-4 pb-5 pt-1 bg-background/95 backdrop-blur-sm">
<div className="w-full max-w-3xl">
{renderPromptInput()}
</div>
</div>
)}
</div>
</SidebarInset>
</SidebarProvider>
);
}

View file

@ -0,0 +1,159 @@
/**
* Type-safe client for the Rowboat CLI backend
*/
const CLI_BASE_URL = process.env.CLI_BACKEND_URL || 'http://localhost:3000';
export interface Run {
id: string;
createdAt: string;
agentId: string;
log: RunEvent[];
}
export interface RunEvent {
type: string;
[key: string]: any;
}
export interface CreateRunOptions {
agentId: string;
}
export interface Agent {
name: string;
description: string;
instructions: string;
tools: Record<string, any>;
}
/**
* CLI Backend Client
*/
export class CliClient {
private baseUrl: string;
constructor(baseUrl: string = CLI_BASE_URL) {
this.baseUrl = baseUrl;
}
/**
* Create a new run (conversation)
*/
async createRun(options: CreateRunOptions): Promise<Run> {
const response = await fetch(`${this.baseUrl}/runs/new`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options),
});
if (!response.ok) {
throw new Error(`Failed to create run: ${response.statusText}`);
}
return response.json();
}
/**
* Send a message to an existing run
*/
async sendMessage(runId: string, message: string): Promise<{ messageId: string }> {
const response = await fetch(`${this.baseUrl}/runs/${runId}/messages/new`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
});
if (!response.ok) {
throw new Error(`Failed to send message: ${response.statusText}`);
}
return response.json();
}
/**
* Get a run by ID
*/
async getRun(runId: string): Promise<Run> {
const response = await fetch(`${this.baseUrl}/runs/${runId}`);
if (!response.ok) {
throw new Error(`Failed to get run: ${response.statusText}`);
}
return response.json();
}
/**
* List all runs
*/
async listRuns(cursor?: string): Promise<{ runs: Run[]; nextCursor?: string }> {
const url = new URL(`${this.baseUrl}/runs`);
if (cursor) url.searchParams.set('cursor', cursor);
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`Failed to list runs: ${response.statusText}`);
}
return response.json();
}
/**
* Get an agent by ID
*/
async getAgent(agentId: string): Promise<Agent> {
const response = await fetch(`${this.baseUrl}/agents/${agentId}`);
if (!response.ok) {
throw new Error(`Failed to get agent: ${response.statusText}`);
}
return response.json();
}
/**
* List all agents
*/
async listAgents(): Promise<Agent[]> {
const response = await fetch(`${this.baseUrl}/agents`);
if (!response.ok) {
throw new Error(`Failed to list agents: ${response.statusText}`);
}
return response.json();
}
/**
* Create an SSE connection to receive real-time events
*/
createEventStream(onEvent: (event: RunEvent) => void, onError?: (error: Error) => void): () => void {
const eventSource = new EventSource(`${this.baseUrl}/stream`);
eventSource.addEventListener('message', (e) => {
try {
const event = JSON.parse(e.data) as RunEvent;
onEvent(event);
} catch (error) {
console.error('Failed to parse event:', error);
onError?.(error as Error);
}
});
eventSource.addEventListener('error', (e) => {
console.error('SSE error:', e);
onError?.(new Error('SSE connection error'));
});
// Return cleanup function
return () => {
eventSource.close();
};
}
}
// Singleton instance
export const cliClient = new CliClient();