mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-02 20:03:21 +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
|
|
@ -251,7 +251,7 @@ function normaliseAskHumanToolCall(message: z.infer<typeof AssistantMessage>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
||||||
if (id === "copilot") {
|
if (id === "copilot" || id === "rowboatx") {
|
||||||
return CopilotAgent;
|
return CopilotAgent;
|
||||||
}
|
}
|
||||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||||
|
|
@ -589,6 +589,7 @@ export async function* streamAgent({
|
||||||
yield *processEvent({
|
yield *processEvent({
|
||||||
runId,
|
runId,
|
||||||
type: "tool-invocation",
|
type: "tool-invocation",
|
||||||
|
toolCallId,
|
||||||
toolName: toolCall.toolName,
|
toolName: toolCall.toolName,
|
||||||
input: JSON.stringify(toolCall.arguments),
|
input: JSON.stringify(toolCall.arguments),
|
||||||
subflow: [],
|
subflow: [],
|
||||||
|
|
@ -624,6 +625,7 @@ export async function* streamAgent({
|
||||||
yield* processEvent({
|
yield* processEvent({
|
||||||
runId,
|
runId,
|
||||||
type: "tool-result",
|
type: "tool-result",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
toolName: toolCall.toolName,
|
toolName: toolCall.toolName,
|
||||||
result: result,
|
result: result,
|
||||||
subflow: [],
|
subflow: [],
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,14 @@ export const MessageEvent = BaseRunEvent.extend({
|
||||||
|
|
||||||
export const ToolInvocationEvent = BaseRunEvent.extend({
|
export const ToolInvocationEvent = BaseRunEvent.extend({
|
||||||
type: z.literal("tool-invocation"),
|
type: z.literal("tool-invocation"),
|
||||||
|
toolCallId: z.string().optional(),
|
||||||
toolName: z.string(),
|
toolName: z.string(),
|
||||||
input: z.string(),
|
input: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ToolResultEvent = BaseRunEvent.extend({
|
export const ToolResultEvent = BaseRunEvent.extend({
|
||||||
type: z.literal("tool-result"),
|
type: z.literal("tool-result"),
|
||||||
|
toolCallId: z.string().optional(),
|
||||||
toolName: z.string(),
|
toolName: z.string(),
|
||||||
result: z.any(),
|
result: z.any(),
|
||||||
});
|
});
|
||||||
|
|
@ -82,4 +84,4 @@ export const RunEvent = z.union([
|
||||||
ToolPermissionRequestEvent,
|
ToolPermissionRequestEvent,
|
||||||
ToolPermissionResponseEvent,
|
ToolPermissionResponseEvent,
|
||||||
RunErrorEvent,
|
RunErrorEvent,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
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,
|
PromptInputHeader,
|
||||||
type PromptInputMessage,
|
type PromptInputMessage,
|
||||||
} from "@/components/ai-elements/prompt-input";
|
} 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 { 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() {
|
export default function HomePage() {
|
||||||
const [text, setText] = useState<string>("");
|
const [text, setText] = useState<string>("");
|
||||||
|
|
@ -42,8 +75,264 @@ export default function HomePage() {
|
||||||
const [status, setStatus] = useState<
|
const [status, setStatus] = useState<
|
||||||
"submitted" | "streaming" | "ready" | "error"
|
"submitted" | "streaming" | "ready" | "error"
|
||||||
>("ready");
|
>("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 hasText = Boolean(message.text);
|
||||||
const hasAttachments = Boolean(message.files?.length);
|
const hasAttachments = Boolean(message.files?.length);
|
||||||
|
|
||||||
|
|
@ -51,18 +340,55 @@ export default function HomePage() {
|
||||||
return;
|
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");
|
setStatus("submitted");
|
||||||
console.log("Message submitted:", message);
|
|
||||||
|
|
||||||
// Reset after submission
|
|
||||||
setText("");
|
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 (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<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">
|
<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">
|
<div className="flex items-center gap-2 px-4">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
|
|
@ -84,62 +410,113 @@ export default function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="relative flex size-full flex-col overflow-hidden">
|
<div className="relative flex w-full flex-1 min-h-0 flex-col overflow-hidden">
|
||||||
{/* Blank canvas - main content area */}
|
{/* Messages area */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<Conversation className="flex-1 min-h-0 pb-48">
|
||||||
{/* Empty space for messages */}
|
<ConversationContent className="!flex !flex-col !items-center !gap-8 !p-4">
|
||||||
</div>
|
<div className="w-full max-w-3xl mx-auto space-y-4">
|
||||||
|
|
||||||
{/* Input area - centered and narrower */}
|
{/* Render conversation items in order */}
|
||||||
<div className="flex justify-center w-full px-4 pb-4">
|
{conversation.map((item) => {
|
||||||
<div className="w-full max-w-3xl">
|
if (item.type === 'message') {
|
||||||
<PromptInput globalDrop multiple onSubmit={handleSubmit}>
|
return (
|
||||||
<PromptInputHeader>
|
<Message
|
||||||
<PromptInputAttachments>
|
key={item.id}
|
||||||
{(attachment) => <PromptInputAttachment data={attachment} />}
|
from={item.role}
|
||||||
</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"}
|
|
||||||
>
|
>
|
||||||
<MicIcon size={16} />
|
<MessageContent>
|
||||||
<span className="sr-only">Microphone</span>
|
<MessageResponse>
|
||||||
</PromptInputButton>
|
{item.content}
|
||||||
<PromptInputButton
|
</MessageResponse>
|
||||||
onClick={() => setUseWebSearch(!useWebSearch)}
|
</MessageContent>
|
||||||
variant={useWebSearch ? "default" : "ghost"}
|
</Message>
|
||||||
>
|
);
|
||||||
<GlobeIcon size={16} />
|
} else if (item.type === 'tool') {
|
||||||
<span>Search</span>
|
const stateMap: Record<string, any> = {
|
||||||
</PromptInputButton>
|
'pending': 'input-streaming',
|
||||||
</PromptInputTools>
|
'running': 'input-available',
|
||||||
<PromptInputSubmit
|
'completed': 'output-available',
|
||||||
disabled={!(text.trim() || status) || status === "streaming"}
|
'error': 'output-error',
|
||||||
status={status}
|
};
|
||||||
/>
|
|
||||||
</PromptInputFooter>
|
return (
|
||||||
</PromptInput>
|
<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>
|
) : (
|
||||||
|
<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>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</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