From 0df92e80c6df559e663886910d07a073aebc3b5e Mon Sep 17 00:00:00 2001 From: ramnique <30795890+ramnique@users.noreply.github.com> Date: Sun, 9 Mar 2025 15:13:19 +0530 Subject: [PATCH] add chat-widget to monorepo --- apps/chat_widget/.dockerignore | 8 + apps/chat_widget/.eslintrc.json | 3 + apps/chat_widget/.gitignore | 40 + apps/chat_widget/Dockerfile | 68 + apps/chat_widget/README.md | 36 + .../app/api/bootstrap.js/bootstrap.js | 183 + .../chat_widget/app/api/bootstrap.js/route.ts | 35 + apps/chat_widget/app/app.tsx | 466 + apps/chat_widget/app/favicon.ico | Bin 0 -> 25931 bytes apps/chat_widget/app/fonts/GeistMonoVF.woff | Bin 0 -> 67864 bytes apps/chat_widget/app/fonts/GeistVF.woff | Bin 0 -> 66268 bytes apps/chat_widget/app/globals.css | 7 + apps/chat_widget/app/layout.tsx | 35 + apps/chat_widget/app/markdown-content.tsx | 51 + apps/chat_widget/app/page.tsx | 10 + apps/chat_widget/app/providers.tsx | 16 + apps/chat_widget/next.config.mjs | 6 + apps/chat_widget/package-lock.json | 9671 +++++++++++++++++ apps/chat_widget/package.json | 32 + apps/chat_widget/postcss.config.mjs | 8 + apps/chat_widget/public/file.svg | 1 + apps/chat_widget/public/globe.svg | 1 + apps/chat_widget/public/next.svg | 1 + apps/chat_widget/public/vercel.svg | 1 + apps/chat_widget/public/window.svg | 1 + apps/chat_widget/tailwind.config.ts | 16 + apps/chat_widget/tsconfig.json | 27 + .../widget/v1/chats/[chatId]/close/route.ts | 6 +- .../v1/chats/[chatId]/messages/route.ts | 5 +- .../widget/v1/chats/[chatId]/turn/route.ts | 62 +- apps/rowboat/app/lib/mongodb.ts | 5 +- .../app/projects/[projectId]/config/app.tsx | 8 +- .../app/projects/[projectId]/config/page.tsx | 5 +- apps/rowboat/middleware.ts | 2 +- docker-compose.yml | 13 + 35 files changed, 10804 insertions(+), 25 deletions(-) create mode 100644 apps/chat_widget/.dockerignore create mode 100644 apps/chat_widget/.eslintrc.json create mode 100644 apps/chat_widget/.gitignore create mode 100644 apps/chat_widget/Dockerfile create mode 100644 apps/chat_widget/README.md create mode 100644 apps/chat_widget/app/api/bootstrap.js/bootstrap.js create mode 100644 apps/chat_widget/app/api/bootstrap.js/route.ts create mode 100644 apps/chat_widget/app/app.tsx create mode 100644 apps/chat_widget/app/favicon.ico create mode 100644 apps/chat_widget/app/fonts/GeistMonoVF.woff create mode 100644 apps/chat_widget/app/fonts/GeistVF.woff create mode 100644 apps/chat_widget/app/globals.css create mode 100644 apps/chat_widget/app/layout.tsx create mode 100644 apps/chat_widget/app/markdown-content.tsx create mode 100644 apps/chat_widget/app/page.tsx create mode 100644 apps/chat_widget/app/providers.tsx create mode 100644 apps/chat_widget/next.config.mjs create mode 100644 apps/chat_widget/package-lock.json create mode 100644 apps/chat_widget/package.json create mode 100644 apps/chat_widget/postcss.config.mjs create mode 100644 apps/chat_widget/public/file.svg create mode 100644 apps/chat_widget/public/globe.svg create mode 100644 apps/chat_widget/public/next.svg create mode 100644 apps/chat_widget/public/vercel.svg create mode 100644 apps/chat_widget/public/window.svg create mode 100644 apps/chat_widget/tailwind.config.ts create mode 100644 apps/chat_widget/tsconfig.json diff --git a/apps/chat_widget/.dockerignore b/apps/chat_widget/.dockerignore new file mode 100644 index 00000000..21b9cda1 --- /dev/null +++ b/apps/chat_widget/.dockerignore @@ -0,0 +1,8 @@ +Dockerfile +.dockerignore +node_modules +npm-debug.log +README.md +.next +.git +.env* \ No newline at end of file diff --git a/apps/chat_widget/.eslintrc.json b/apps/chat_widget/.eslintrc.json new file mode 100644 index 00000000..37224185 --- /dev/null +++ b/apps/chat_widget/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/apps/chat_widget/.gitignore b/apps/chat_widget/.gitignore new file mode 100644 index 00000000..26b002aa --- /dev/null +++ b/apps/chat_widget/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files (can opt-in for commiting if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/chat_widget/Dockerfile b/apps/chat_widget/Dockerfile new file mode 100644 index 00000000..4c488beb --- /dev/null +++ b/apps/chat_widget/Dockerfile @@ -0,0 +1,68 @@ +# syntax=docker.io/docker/dockerfile:1 + +FROM node:18-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +# ENV NEXT_TELEMETRY_DISABLED=1 + +RUN \ + if [ -f yarn.lock ]; then yarn run build; \ + elif [ -f package-lock.json ]; then npm run build; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output +ENV HOSTNAME="0.0.0.0" +ENV PORT=3000 +CMD echo "Starting server $CHAT_WIDGET_HOST, $ROWBOAT_HOST" && node server.js +#CMD ["node", "server.js"] \ No newline at end of file diff --git a/apps/chat_widget/README.md b/apps/chat_widget/README.md new file mode 100644 index 00000000..e215bc4c --- /dev/null +++ b/apps/chat_widget/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/chat_widget/app/api/bootstrap.js/bootstrap.js b/apps/chat_widget/app/api/bootstrap.js/bootstrap.js new file mode 100644 index 00000000..aa206af7 --- /dev/null +++ b/apps/chat_widget/app/api/bootstrap.js/bootstrap.js @@ -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()); +} \ No newline at end of file diff --git a/apps/chat_widget/app/api/bootstrap.js/route.ts b/apps/chat_widget/app/api/bootstrap.js/route.ts new file mode 100644 index 00000000..d2df0406 --- /dev/null +++ b/apps/chat_widget/app/api/bootstrap.js/route.ts @@ -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 }); + } +} diff --git a/apps/chat_widget/app/app.tsx b/apps/chat_widget/app/app.tsx new file mode 100644 index 00000000..650a9824 --- /dev/null +++ b/apps/chat_widget/app/app.tsx @@ -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; + closed: boolean; + setMinimized: (minimized: boolean) => void; +}) { + return
+
Chat
+
+ {(chatId && !closed) && + + + + { + if (key === "close") { + closeChat(); + } + }}> + + Close chat + + + } + +
+
+} + +function LoadingAssistantResponse() { + return
+
+
+
+
+
+
+
+
+
+} +function AssistantMessage({ + children, +}: { + children: React.ReactNode; +}) { + return
+
Assistant
+
+ {typeof children === 'string' ? : children} +
+
+} + +function UserMessage({ + children, +}: { + children: React.ReactNode; +}) { + return
+
+ {typeof children === 'string' ? : children} +
+
+} +function ChatWindowMessages({ + messages, + loadingAssistantResponse, +}: { + messages: Message[]; + loadingAssistantResponse: boolean; +}) { + const messagesEndRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + return
+ + Hello! I'm Rowboat, your personal assistant. How can I help you today? + + {messages.map((message, index) => { + switch (message.role) { + case "user": + return {message.content}; + case "assistant": + return {message.content}; + case "system": + return null; // Hide system messages from the UI + case "tool": + return + Tool response ({message.tool_name}): {message.content} + ; + default: + return null; + } + })} + {loadingAssistantResponse && } +
+
+} + +function ChatWindowInput({ + handleUserMessage, +}: { + handleUserMessage: (message: string) => Promise; +}) { + const [prompt, setPrompt] = useState(""); + + function handleInputKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + const input = prompt.trim(); + setPrompt(''); + + handleUserMessage(input); + } + } + + return
+