diff --git a/.env.example b/.env.example index 8351ad5b..7f47d65a 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,33 @@ +# Basic configuration +# ------------------------------------------------------------ +MONGODB_CONNECTION_STRING=mongodb://127.0.0.1:27017/rowboat OPENAI_API_KEY= -MONGODB_CONNECTION_STRING= AUTH0_SECRET= AUTH0_BASE_URL=http://localhost:3000 AUTH0_ISSUER_BASE_URL= AUTH0_CLIENT_ID= AUTH0_CLIENT_SECRET= -COPILOT_API_KEY=test -AGENTS_API_KEY=test \ No newline at end of file + +# Uncomment to enable RAG: +# ------------------------------------------------------------ +# USE_RAG=true +# QDRANT_URL= +# QDRANT_API_KEY= + +# Uncomment to enable RAG: File uploads +# ------------------------------------------------------------ +# USE_RAG_UPLOADS=true +# AWS_ACCESS_KEY_ID= +# AWS_SECRET_ACCESS_KEY= +# RAG_UPLOADS_S3_BUCKET= +# RAG_UPLOADS_S3_REGION= + +# Uncomment to enable RAG: Scraping URLs +# ------------------------------------------------------------ +# USE_RAG_SCRAPING=true +# FIRECRAWL_API_KEY= + +# Uncomment to enable chat widget +# ------------------------------------------------------------ +# USE_CHAT_WIDGET=true +# CHAT_WIDGET_SESSION_JWT_SECRET= \ No newline at end of file diff --git a/README.md b/README.md index 30719426..90dd9ca3 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,26 @@ This guide will help you set up and run the RowBoat applications locally using Docker. Please see our [docs](https://docs.rowboatlabs.com/) for more details. +RowBoat offers several optional services that can be enabled using Docker Compose profiles. You can run multiple profiles simultaneously using: +```bash +docker compose --profile rag_urls_worker --profile chat_widget --profile tools_webhook up -d +``` +See the relevant sections below for details on each service. + +## Table of Contents +- [Prerequisites](#prerequisites) +- [Local Development Setup](#local-development-setup) + - [Python SDK](#option-1-python-sdk) + - [HTTP API](#option-2-http-api) +- [Optional Features](#optional-features) + - [Enable RAG](#enable-rag) + - [URL Scraping](#url-scraping) + - [File Uploads](#file-uploads) + - [Enable Chat Widget](#enable-chat-widget) + - [Enable Tools Webhook](#enable-tools-webhook) +- [Troubleshooting](#troubleshooting) +- [Attribution](#attribution) + ## Prerequisites Before running RowBoat, ensure you have: @@ -153,6 +173,194 @@ Before running RowBoat, ensure you have: The documentation site is available at [http://localhost:8000](http://localhost:8000) +## Enable RAG + +RowBoat supports RAG capabilities to enhance responses with your custom knowledge base. To enable RAG, you'll need: + +1. **Qdrant Vector Database** + - **Option 1**: Use [Qdrant Cloud](https://cloud.qdrant.io/) + - Create an account and cluster + - Note your cluster URL and API key + - **Option 2**: Run Qdrant locally with Docker: + ```bash + docker run -p 6333:6333 qdrant/qdrant + ``` + +2. **Update Environment Variables** + ```ini + USE_RAG=true + QDRANT_URL= # e.g., http://localhost:6333 for local + QDRANT_API_KEY= # Only needed for Qdrant Cloud + ``` + +### RAG Features + +RowBoat supports two types of knowledge base ingestion: + +#### URL Scraping + +Enable web page scraping to build your knowledge base: + +1. **Get Firecrawl API Key** + - Sign up at [Firecrawl](https://firecrawl.co) + - Generate an API key + +2. **Update Environment Variables** + ```ini + USE_RAG_SCRAPING=true + FIRECRAWL_API_KEY= + ``` + +3. **Start the URLs Worker** + ```bash + docker compose --profile rag_urls_worker up -d + ``` + +#### File Uploads + +Enable file upload support (PDF, DOCX, TXT) for your knowledge base: + +1. **Prerequisites** + - An AWS S3 bucket for file storage + - Google Cloud API key with Vision API enabled (for enhanced document parsing) + +2. **Configure AWS S3** + - Create an S3 bucket + - Add the following CORS configuration to your bucket: + ```json + [ + { + "AllowedHeaders": [ + "*" + ], + "AllowedMethods": [ + "PUT", + "POST", + "DELETE", + "GET" + ], + "AllowedOrigins": [ + "http://localhost:3000", + ], + "ExposeHeaders": [ + "ETag" + ] + } + ] + ``` + - Ensure your AWS credentials have the following IAM policy: + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "s3:PutObject", + "s3:GetObject", + "s3:DeleteObject", + "s3:ListBucket" + ], + "Resource": [ + "arn:aws:s3:::/*", + "arn:aws:s3:::" + ] + } + ] + } + ``` + +3. **Update Environment Variables** + ```ini + USE_RAG_UPLOADS=true + AWS_ACCESS_KEY_ID= + AWS_SECRET_ACCESS_KEY= + RAG_UPLOADS_S3_BUCKET= + RAG_UPLOADS_S3_REGION= + GOOGLE_API_KEY= + ``` + +4. **Start the Files Worker** + ```bash + docker compose --profile rag_files_worker up -d + ``` + +After enabling RAG and starting the required workers, you can manage your knowledge base through the RowBoat UI at `/projects//sources`. + +## Enable Chat Widget + +RowBoat provides an embeddable chat widget that you can add to any website. To enable and use the chat widget: + +1. **Generate JWT Secret** + Generate a secret for securing chat widget sessions: + ```bash + openssl rand -hex 32 + ``` + +2. **Update Environment Variables** + ```ini + USE_CHAT_WIDGET=true + CHAT_WIDGET_SESSION_JWT_SECRET= + ``` + +3. **Start the Chat Widget Service** + ```bash + docker compose --profile chat_widget up -d + ``` + +4. **Add Widget to Your Website** + You can find the chat-widget embed code under `/projects//config` + +After setup, the chat widget will appear on your website and connect to your RowBoat project. + +## Enable Tools Webhook + +RowBoat includes a built-in webhook service that allows you to implement custom tool functions. To use this feature: + +1. **Generate Signing Secret** + Generate a secret for securing webhook requests: + ```bash + openssl rand -hex 32 + ``` + +2. **Update Environment Variables** + ```ini + SIGNING_SECRET= + ``` + +3. **Implement Your Functions** + Add your custom functions to `apps/tools_webhook/function_map.py`: + ```python + def get_weather(location: str, units: str = "metric"): + """Return weather data for the given location.""" + # Your implementation here + return {"temperature": 20, "conditions": "sunny"} + + def check_inventory(product_id: str): + """Check inventory levels for a product.""" + # Your implementation here + return {"in_stock": 42, "warehouse": "NYC"} + + # Add your functions to the map + FUNCTIONS_MAP = { + "get_weather": get_weather, + "check_inventory": check_inventory + } + ``` + +4. **Start the Tools Webhook Service** + ```bash + docker compose --profile tools_webhook up -d + ``` + +5. **Register Tools in RowBoat** + - Navigate to your project config at `/projects//config` + - Ensure that the webhook URL is set to: `http://tools_webhook:3005/tool_call` + - Tools will automatically be forwarded to your webhook implementation + +The webhook service handles all the security and parameter validation, allowing you to focus on implementing your tool logic. + ## Troubleshooting 1. **MongoDB Connection Issues** diff --git a/apps/agents/src/graph/core.py b/apps/agents/src/graph/core.py index 5e2d8dca..074fe86b 100644 --- a/apps/agents/src/graph/core.py +++ b/apps/agents/src/graph/core.py @@ -13,6 +13,7 @@ from .helpers.transfer import create_transfer_function_to_agent, create_transfer from .helpers.state import add_recent_messages_to_history, construct_state_from_response, reset_current_turn, reset_current_turn_agent_history from .helpers.instructions import add_transfer_instructions_to_child_agents, add_transfer_instructions_to_parent_agents, add_rag_instructions_to_agent, add_error_escalation_instructions, get_universal_system_message, add_universal_system_message_to_agent from .helpers.control import get_latest_assistant_msg, get_latest_non_assistant_messages, get_last_agent_name +from src.swarm.types import Response from src.utils.common import common_logger logger = common_logger @@ -474,6 +475,21 @@ def run_turn(messages, start_agent_name, agent_configs, tool_configs, available_ turn_messages.extend(response.messages) logger.info("Response post-processed") + else: + logger.info("No post-processing agent found. Duplicating last response and setting to external.") + duplicate_msg = deepcopy(turn_messages[-1]) + duplicate_msg["response_type"] = "external" + duplicate_msg["sender"] = duplicate_msg["sender"] + ' >> External' + response = Response( + messages=[duplicate_msg], + tokens_used=tokens_used, + agent=last_agent, + error_msg='' + ) + response.messages = order_messages(response.messages) + turn_messages.extend(response.messages) + logger.info("Last response duplicated and set to external") + if guardrails_agent_config: logger.info("Guardrails agent not implemented (ignoring)") pass diff --git a/apps/agents/src/swarm/core.py b/apps/agents/src/swarm/core.py index 67ee79ec..663e5894 100644 --- a/apps/agents/src/swarm/core.py +++ b/apps/agents/src/swarm/core.py @@ -57,7 +57,7 @@ class Swarm: for tool in funcs_and_tools: params = tool["function"]["parameters"] params["properties"].pop(__CTX_VARS_NAME__, None) - if __CTX_VARS_NAME__ in params["required"]: + if __CTX_VARS_NAME__ in params.get("required", []): params["required"].remove(__CTX_VARS_NAME__) create_params = { 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
+