mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
connecting the copilot to the UI
This commit is contained in:
parent
5e31c637f0
commit
b1f6e64244
6 changed files with 785 additions and 60 deletions
72
apps/rowboatx/app/api/chat/route.ts
Normal file
72
apps/rowboatx/app/api/chat/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
113
apps/rowboatx/app/api/stream/route.ts
Normal file
113
apps/rowboatx/app/api/stream/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
159
apps/rowboatx/lib/cli-client.ts
Normal file
159
apps/rowboatx/lib/cli-client.ts
Normal 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();
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue