mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-26 00:46:23 +02:00
add chat-widget to monorepo
This commit is contained in:
parent
2a16a8ce31
commit
0df92e80c6
35 changed files with 10804 additions and 25 deletions
466
apps/chat_widget/app/app.tsx
Normal file
466
apps/chat_widget/app/app.tsx
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
"use client";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { z } from "zod";
|
||||
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Textarea } from "@nextui-org/react";
|
||||
import MarkdownContent from "./markdown-content";
|
||||
|
||||
type Message = {
|
||||
role: "user" | "assistant" | "system" | "tool";
|
||||
content: string;
|
||||
tool_call_id?: string;
|
||||
tool_name?: string;
|
||||
}
|
||||
|
||||
function ChatWindowHeader({
|
||||
chatId,
|
||||
closeChat,
|
||||
closed,
|
||||
setMinimized,
|
||||
}: {
|
||||
chatId: string | null;
|
||||
closeChat: () => Promise<void>;
|
||||
closed: boolean;
|
||||
setMinimized: (minimized: boolean) => void;
|
||||
}) {
|
||||
return <div className="shrink-0 flex justify-between items-center gap-2 bg-gray-400 px-2 py-1 rounded-t-lg dark:bg-gray-800">
|
||||
<div className="text-gray-800 dark:text-white">Chat</div>
|
||||
<div className="flex gap-1 items-center">
|
||||
{(chatId && !closed) && <Dropdown>
|
||||
<DropdownTrigger>
|
||||
<button>
|
||||
<svg className="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeWidth="2" d="M6 12h.01m6 0h.01m5.99 0h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu onAction={(key) => {
|
||||
if (key === "close") {
|
||||
closeChat();
|
||||
}
|
||||
}}>
|
||||
<DropdownItem key="close">
|
||||
Close chat
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>}
|
||||
<button onClick={() => setMinimized(true)}>
|
||||
<svg className="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m19 9-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function LoadingAssistantResponse() {
|
||||
return <div className="flex gap-2 items-end">
|
||||
<div className="shrink-0 w-10 h-10 bg-gray-400 rounded-full dark:bg-gray-800"></div>
|
||||
<div className="bg-white rounded-md dark:bg-gray-800 text-gray-800 dark:text-white mr-[20%] rounded-bl-none p-2">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-400 dark:bg-gray-600 animate-bounce"></div>
|
||||
<div className="w-2 h-2 rounded-full bg-gray-400 dark:bg-gray-600 animate-bounce [animation-delay:0.2s]"></div>
|
||||
<div className="w-2 h-2 rounded-full bg-gray-400 dark:bg-gray-600 animate-bounce [animation-delay:0.4s]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
function AssistantMessage({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="flex flex-col gap-1 items-start">
|
||||
<div className="text-gray-800 dark:text-white text-xs pl-2">Assistant</div>
|
||||
<div className="bg-gray-200 rounded-md dark:bg-gray-800 text-gray-800 dark:text-white mr-[20%] rounded-bl-none p-2">
|
||||
{typeof children === 'string' ? <MarkdownContent content={children} /> : children}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function UserMessage({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="flex flex-col gap-1 items-end">
|
||||
<div className="bg-gray-200 rounded-md dark:bg-gray-800 text-gray-800 dark:text-white ml-[20%] rounded-br-none p-2">
|
||||
{typeof children === 'string' ? <MarkdownContent content={children} /> : children}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
function ChatWindowMessages({
|
||||
messages,
|
||||
loadingAssistantResponse,
|
||||
}: {
|
||||
messages: Message[];
|
||||
loadingAssistantResponse: boolean;
|
||||
}) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
return <div className="flex flex-col grow p-2 gap-4 overflow-auto">
|
||||
<AssistantMessage>
|
||||
Hello! I'm Rowboat, your personal assistant. How can I help you today?
|
||||
</AssistantMessage>
|
||||
{messages.map((message, index) => {
|
||||
switch (message.role) {
|
||||
case "user":
|
||||
return <UserMessage key={index}>{message.content}</UserMessage>;
|
||||
case "assistant":
|
||||
return <AssistantMessage key={index}>{message.content}</AssistantMessage>;
|
||||
case "system":
|
||||
return null; // Hide system messages from the UI
|
||||
case "tool":
|
||||
return <AssistantMessage key={index}>
|
||||
Tool response ({message.tool_name}): {message.content}
|
||||
</AssistantMessage>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
{loadingAssistantResponse && <LoadingAssistantResponse />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
}
|
||||
|
||||
function ChatWindowInput({
|
||||
handleUserMessage,
|
||||
}: {
|
||||
handleUserMessage: (message: string) => Promise<void>;
|
||||
}) {
|
||||
const [prompt, setPrompt] = useState<string>("");
|
||||
|
||||
function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const input = prompt.trim();
|
||||
setPrompt('');
|
||||
|
||||
handleUserMessage(input);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="bg-white rounded-md dark:bg-gray-900 shrink-0 p-2">
|
||||
<Textarea
|
||||
placeholder="Ask me anything..."
|
||||
minRows={1}
|
||||
maxRows={3}
|
||||
variant="flat"
|
||||
className="w-full"
|
||||
onKeyDown={handleInputKeyDown}
|
||||
value={prompt}
|
||||
onValueChange={setPrompt}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
function ChatWindowBody({
|
||||
chatId,
|
||||
createChat,
|
||||
getAssistantResponse,
|
||||
closed,
|
||||
resetState,
|
||||
messages,
|
||||
setMessages,
|
||||
}: {
|
||||
chatId: string | null;
|
||||
createChat: () => Promise<string>;
|
||||
getAssistantResponse: (chatId: string, message: string) => Promise<Message>;
|
||||
closed: boolean;
|
||||
resetState: () => Promise<void>;
|
||||
messages: Message[];
|
||||
setMessages: (messages: Message[]) => void;
|
||||
}) {
|
||||
const [loadingAssistantResponse, setLoadingAssistantResponse] = useState<boolean>(false);
|
||||
|
||||
async function handleUserMessage(message: string) {
|
||||
const userMessage: Message = { role: "user", content: message };
|
||||
setMessages([...messages, userMessage]);
|
||||
setLoadingAssistantResponse(true);
|
||||
|
||||
let availableChatId = chatId;
|
||||
if (!availableChatId) {
|
||||
availableChatId = await createChat();
|
||||
}
|
||||
|
||||
const response = await getAssistantResponse(availableChatId, message);
|
||||
setMessages([...messages, userMessage, response]);
|
||||
setLoadingAssistantResponse(false);
|
||||
}
|
||||
|
||||
return <div className="flex flex-col grow bg-white rounded-b-lg dark:bg-gray-900 overflow-auto">
|
||||
<ChatWindowMessages messages={messages} loadingAssistantResponse={loadingAssistantResponse} />
|
||||
{!closed && <ChatWindowInput
|
||||
handleUserMessage={handleUserMessage}
|
||||
/>}
|
||||
{closed && <div className="flex flex-col items-center py-4 gap-2">
|
||||
<div className="text-gray-800 dark:text-white">This chat is closed</div>
|
||||
<Button
|
||||
onPress={resetState}
|
||||
>
|
||||
Start new chat
|
||||
</Button>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function ChatWindow({
|
||||
chatId,
|
||||
closed,
|
||||
closeChat,
|
||||
createChat,
|
||||
getAssistantResponse,
|
||||
resetState,
|
||||
messages,
|
||||
setMessages,
|
||||
setMinimized,
|
||||
}: {
|
||||
chatId: string | null;
|
||||
closed: boolean;
|
||||
closeChat: () => Promise<void>;
|
||||
createChat: () => Promise<string>;
|
||||
getAssistantResponse: (chatId: string, message: string) => Promise<Message>;
|
||||
resetState: () => Promise<void>;
|
||||
messages: Message[];
|
||||
setMessages: (messages: Message[]) => void;
|
||||
setMinimized: (minimized: boolean) => void;
|
||||
}) {
|
||||
return <div className="h-full flex flex-col rounded-lg overflow-hidden">
|
||||
<ChatWindowHeader
|
||||
chatId={chatId}
|
||||
closeChat={closeChat}
|
||||
closed={closed}
|
||||
setMinimized={setMinimized}
|
||||
/>
|
||||
<ChatWindowBody
|
||||
chatId={chatId}
|
||||
createChat={createChat}
|
||||
getAssistantResponse={getAssistantResponse}
|
||||
closed={closed}
|
||||
resetState={resetState}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function App({
|
||||
apiUrl,
|
||||
}: {
|
||||
apiUrl: string;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const sessionId = searchParams.get("session_id");
|
||||
const [minimized, setMinimized] = useState(searchParams.get("minimized") === 'true');
|
||||
const [chatId, setChatId] = useState<string | null>(null);
|
||||
const [closed, setClosed] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
|
||||
const fetchLastChat = useCallback(async (): Promise<{
|
||||
chat: z.infer<typeof apiV1.ApiGetChatsResponse.shape.chats.element>;
|
||||
messages: Message[];
|
||||
} | null> => {
|
||||
const response = await fetch(`${apiUrl}/chats`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${sessionId}`,
|
||||
},
|
||||
});
|
||||
if (response.status === 403) {
|
||||
window.parent.postMessage({
|
||||
type: 'sessionExpired'
|
||||
}, '*');
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch last chat");
|
||||
}
|
||||
const { chats }: z.infer<typeof apiV1.ApiGetChatsResponse> = await response.json();
|
||||
if (chats.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const chat = chats[0];
|
||||
|
||||
// fetch all chat messages
|
||||
let allMessages: Message[] = [];
|
||||
let nextCursor: string | undefined = undefined;
|
||||
|
||||
do {
|
||||
const url = new URL(`${apiUrl}/chats/${chat.id}/messages`);
|
||||
if (nextCursor) {
|
||||
url.searchParams.set('next', nextCursor);
|
||||
}
|
||||
|
||||
const messagesResponse = await fetch(url, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${sessionId}`,
|
||||
},
|
||||
});
|
||||
if (!messagesResponse.ok) {
|
||||
throw new Error("Failed to fetch chat messages");
|
||||
}
|
||||
const { messages, next }: z.infer<typeof apiV1.ApiGetChatMessagesResponse> = await messagesResponse.json();
|
||||
|
||||
const formattedMessages = messages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.role === "assistant" ? (m.content || '') : m.content,
|
||||
...(m.role === "tool" ? {
|
||||
tool_call_id: m.tool_call_id,
|
||||
tool_name: m.tool_name,
|
||||
} : {})
|
||||
}));
|
||||
|
||||
allMessages = [...allMessages, ...formattedMessages];
|
||||
nextCursor = next;
|
||||
} while (nextCursor);
|
||||
|
||||
return {
|
||||
chat,
|
||||
messages: allMessages,
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
async function resetState() {
|
||||
setChatId(null);
|
||||
setClosed(false);
|
||||
setMessages([]);
|
||||
}
|
||||
|
||||
async function closeChat() {
|
||||
const response = await fetch(`${apiUrl}/chats/${chatId}/close`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${sessionId}`,
|
||||
},
|
||||
});
|
||||
if (response.status === 403) {
|
||||
window.parent.postMessage({
|
||||
type: 'sessionExpired'
|
||||
}, '*');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to close chat");
|
||||
}
|
||||
setClosed(true);
|
||||
}
|
||||
|
||||
async function createChat(): Promise<string> {
|
||||
const response = await fetch(`${apiUrl}/chats`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${sessionId}`,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (response.status === 403) {
|
||||
window.parent.postMessage({
|
||||
type: 'sessionExpired'
|
||||
}, '*');
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
|
||||
const { id }: z.infer<typeof apiV1.ApiCreateChatResponse> = await response.json();
|
||||
setChatId(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function getAssistantResponse(chatId: string, message: string): Promise<Message> {
|
||||
const response = await fetch(`${apiUrl}/chats/${chatId}/turn`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${sessionId}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: message,
|
||||
}),
|
||||
});
|
||||
if (response.status === 403) {
|
||||
window.parent.postMessage({
|
||||
type: 'sessionExpired'
|
||||
}, '*');
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to get assistant response");
|
||||
}
|
||||
const { content }: z.infer<typeof apiV1.ApiChatTurnResponse> = await response.json();
|
||||
return {
|
||||
role: "assistant",
|
||||
content: content || '',
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.parent.postMessage({
|
||||
type: 'chatStateChange',
|
||||
isMinimized: minimized
|
||||
}, '*');
|
||||
}, [minimized]);
|
||||
|
||||
useEffect(() => {
|
||||
let abort = false;
|
||||
async function process(){
|
||||
const lastChat = await fetchLastChat();
|
||||
if (abort) {
|
||||
return;
|
||||
}
|
||||
if (lastChat) {
|
||||
setChatId(lastChat.chat.id);
|
||||
setClosed(lastChat.chat.closed || false);
|
||||
setMessages(lastChat.messages);
|
||||
}
|
||||
}
|
||||
process()
|
||||
.finally(() => {
|
||||
if (!abort) {
|
||||
window.parent.postMessage({
|
||||
type: 'chatLoaded',
|
||||
}, '*');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
abort = true;
|
||||
}
|
||||
}, [sessionId, fetchLastChat]);
|
||||
|
||||
if (!sessionId) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <>
|
||||
{minimized && <div className="fixed bottom-0 right-0">
|
||||
<button
|
||||
onClick={() => setMinimized(false)}
|
||||
className="w-12 h-12 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 rounded-full flex items-center justify-center shadow-lg transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17h6l3 3v-3h2V9h-2M4 4h11v8H9l-3 3v-3H4V4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>}
|
||||
{!minimized && <div className="fixed h-full">
|
||||
<ChatWindow
|
||||
key={sessionId}
|
||||
chatId={chatId}
|
||||
closed={closed}
|
||||
closeChat={closeChat}
|
||||
createChat={createChat}
|
||||
getAssistantResponse={getAssistantResponse}
|
||||
resetState={resetState}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
setMinimized={setMinimized}
|
||||
/>
|
||||
</div>}
|
||||
</>
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue