add chat-widget to monorepo

This commit is contained in:
ramnique 2025-03-09 15:13:19 +05:30
parent 2a16a8ce31
commit 0df92e80c6
35 changed files with 10804 additions and 25 deletions

View file

@ -0,0 +1,183 @@
// Split into separate configuration file/module
const CONFIG = {
CHAT_URL: '__CHAT_WIDGET_HOST__',
API_URL: '__ROWBOAT_HOST__/api/widget/v1',
STORAGE_KEYS: {
MINIMIZED: 'rowboat_chat_minimized',
SESSION: 'rowboat_session_id'
},
IFRAME_STYLES: {
MINIMIZED: {
width: '48px',
height: '48px',
borderRadius: '50%'
},
MAXIMIZED: {
width: '400px',
height: 'min(calc(100vh - 32px), 600px)',
borderRadius: '10px'
},
BASE: {
position: 'fixed',
bottom: '20px',
right: '20px',
border: 'none',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
zIndex: '999999',
transition: 'all 0.1s ease-in-out'
}
}
};
// New SessionManager class to handle session-related operations
class SessionManager {
static async createGuestSession() {
try {
const response = await fetch(`${CONFIG.API_URL}/session/guest`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-client-id': window.ROWBOAT_CONFIG.clientId
},
});
if (!response.ok) throw new Error('Failed to create session');
const data = await response.json();
CookieManager.setCookie(CONFIG.STORAGE_KEYS.SESSION, data.sessionId);
return true;
} catch (error) {
console.error('Failed to create chat session:', error);
return false;
}
}
}
// New CookieManager class for cookie operations
class CookieManager {
static getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
static setCookie(name, value) {
document.cookie = `${name}=${value}; path=/`;
}
static deleteCookie(name) {
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
}
}
// New IframeManager class to handle iframe-specific operations
class IframeManager {
static createIframe(url, isMinimized) {
const iframe = document.createElement('iframe');
iframe.hidden = true;
iframe.src = url.toString();
Object.assign(iframe.style, CONFIG.IFRAME_STYLES.BASE);
IframeManager.updateSize(iframe, isMinimized);
return iframe;
}
static updateSize(iframe, isMinimized) {
const styles = isMinimized ? CONFIG.IFRAME_STYLES.MINIMIZED : CONFIG.IFRAME_STYLES.MAXIMIZED;
Object.assign(iframe.style, styles);
}
static removeIframe(iframe) {
if (iframe && iframe.parentNode) {
iframe.parentNode.removeChild(iframe);
}
}
}
// Refactored main ChatWidget class
class ChatWidget {
constructor() {
this.iframe = null;
this.messageHandlers = {
chatLoaded: () => this.iframe.hidden = false,
chatStateChange: (data) => this.handleStateChange(data),
sessionExpired: () => this.handleSessionExpired()
};
this.init();
}
async init() {
const sessionId = CookieManager.getCookie(CONFIG.STORAGE_KEYS.SESSION);
if (!sessionId && !(await SessionManager.createGuestSession())) {
console.error('Chat widget initialization failed: Could not create session');
return;
}
this.createAndMountIframe();
this.setupEventListeners();
}
createAndMountIframe() {
const url = this.buildUrl();
const isMinimized = this.getStoredMinimizedState();
this.iframe = IframeManager.createIframe(url, isMinimized);
document.body.appendChild(this.iframe);
}
buildUrl() {
const sessionId = CookieManager.getCookie(CONFIG.STORAGE_KEYS.SESSION);
const isMinimized = this.getStoredMinimizedState();
const url = new URL(`${CONFIG.CHAT_URL}/`);
url.searchParams.append('session_id', sessionId);
url.searchParams.append('minimized', isMinimized);
return url;
}
setupEventListeners() {
window.addEventListener('message', (event) => this.handleMessage(event));
}
handleMessage(event) {
if (event.origin !== CONFIG.CHAT_URL) return;
if (this.messageHandlers[event.data.type]) {
this.messageHandlers[event.data.type](event.data);
}
}
async handleSessionExpired() {
console.log("Session expired");
IframeManager.removeIframe(this.iframe);
CookieManager.deleteCookie(CONFIG.STORAGE_KEYS.SESSION);
const sessionCreated = await SessionManager.createGuestSession();
if (!sessionCreated) {
console.error('Failed to recreate session after expiry');
return;
}
this.createAndMountIframe();
document.body.appendChild(this.iframe);
}
handleStateChange(data) {
localStorage.setItem(CONFIG.STORAGE_KEYS.MINIMIZED, data.isMinimized);
IframeManager.updateSize(this.iframe, data.isMinimized);
}
getStoredMinimizedState() {
return localStorage.getItem(CONFIG.STORAGE_KEYS.MINIMIZED) !== 'false';
}
}
// Initialize when DOM is ready
if (document.readyState === 'complete') {
new ChatWidget();
} else {
window.addEventListener('load', () => new ChatWidget());
}

View file

@ -0,0 +1,35 @@
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
export const dynamic = 'force-dynamic'
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Read the file once when the module loads
const jsFileContents = fs.readFile(
path.join(__dirname, 'bootstrap.js'),
'utf-8'
);
export async function GET() {
try {
// Reuse the cached content
const template = await jsFileContents;
// Replace placeholder values with actual URLs
const contents = template
.replace('__CHAT_WIDGET_HOST__', process.env.CHAT_WIDGET_HOST || '')
.replace('__ROWBOAT_HOST__', process.env.ROWBOAT_HOST || '');
return new Response(contents, {
headers: {
'Content-Type': 'application/javascript',
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
});
} catch (error) {
console.error('Error serving bootstrap.js:', error);
return new Response('Error loading script', { status: 500 });
}
}

View 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&apos;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>}
</>
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: Arial, Helvetica, sans-serif;
}

View file

@ -0,0 +1,35 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export const metadata: Metadata = {
title: "RowBoat Chat",
description: "RowBoat Chat",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="h-full bg-transparent">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased h-full`}
>
{children}
</body>
</html>
);
}

View file

@ -0,0 +1,51 @@
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
export default function MarkdownContent({ content }: { content: string }) {
return <Markdown
className="overflow-auto break-words"
remarkPlugins={[remarkGfm]}
components={{
strong({ children }) {
return <span className="font-semibold">{children}</span>
},
p({ children }) {
return <p className="py-1">{children}</p>
},
ul({ children }) {
return <ul className="py-1 pl-5 list-disc">{children}</ul>
},
ol({ children }) {
return <ul className="py-1 pl-5 list-decimal">{children}</ul>
},
h3({ children }) {
return <h3 className="font-semibold">{children}</h3>
},
table({ children }) {
return <table className="my-1 border-collapse border border-gray-400 rounded">{children}</table>
},
th({ children }) {
return <th className="px-2 py-1 border-collapse border border-gray-300 rounded">{children}</th>
},
td({ children }) {
return <td className="px-2 py-1 border-collapse border border-gray-300 rounded">{children}</td>
},
blockquote({ children }) {
return <blockquote className='bg-gray-200 px-1'>{children}</blockquote>;
},
a(props) {
const { children, ...rest } = props
return <a className="inline-flex items-center gap-1" target="_blank" {...rest} >
<span className='underline'>
{children}
</span>
<svg className="w-[16px] h-[16px]" 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="1" d="M18 14v4.833A1.166 1.166 0 0 1 16.833 20H5.167A1.167 1.167 0 0 1 4 18.833V7.167A1.166 1.166 0 0 1 5.167 6h4.618m4.447-2H20v5.768m-7.889 2.121 7.778-7.778" />
</svg>
</a>
},
}}
>
{content}
</Markdown>;
}

View file

@ -0,0 +1,10 @@
import { Suspense } from 'react';
import { App } from './app';
export const dynamic = 'force-dynamic';
export default function Page() {
return <Suspense>
<App apiUrl={`${process.env.ROWBOAT_HOST}/api/widget/v1`} />
</Suspense>
}

View file

@ -0,0 +1,16 @@
import * as React from "react";
// 1. import `NextUIProvider` component
import {NextUIProvider} from "@nextui-org/react";
export default function Providers({
children,
}: {
children: React.ReactNode;
}) {
return (
<NextUIProvider>
{children}
</NextUIProvider>
);
}