Merge pull request #38 from rowboatlabs/dev

Merge dev
This commit is contained in:
Ramnique Singh 2025-03-10 13:15:06 +05:30 committed by GitHub
commit 4793313daf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
169 changed files with 31623 additions and 7937 deletions

View file

@ -1,9 +1,33 @@
# Basic configuration
# ------------------------------------------------------------
MONGODB_CONNECTION_STRING=mongodb://127.0.0.1:27017/rowboat
OPENAI_API_KEY=<OPENAI_API_KEY>
MONGODB_CONNECTION_STRING=<MONGODB_CONNECTION_STRING>
AUTH0_SECRET=<AUTH0_SECRET>
AUTH0_BASE_URL=http://localhost:3000
AUTH0_ISSUER_BASE_URL=<AUTH0_ISSUER_BASE_URL>
AUTH0_CLIENT_ID=<AUTH0_CLIENT_ID>
AUTH0_CLIENT_SECRET=<AUTH0_CLIENT_SECRET>
COPILOT_API_KEY=test
AGENTS_API_KEY=test
# Uncomment to enable RAG:
# ------------------------------------------------------------
# USE_RAG=true
# QDRANT_URL=<QDRANT_URL>
# QDRANT_API_KEY=<QDRANT_API_KEY>
# Uncomment to enable RAG: File uploads
# ------------------------------------------------------------
# USE_RAG_UPLOADS=true
# AWS_ACCESS_KEY_ID=<AWS_ACCESS_KEY_ID>
# AWS_SECRET_ACCESS_KEY=<AWS_SECRET_ACCESS_KEY>
# RAG_UPLOADS_S3_BUCKET=<RAG_UPLOADS_S3_BUCKET>
# RAG_UPLOADS_S3_REGION=<RAG_UPLOADS_S3_REGION>
# Uncomment to enable RAG: Scraping URLs
# ------------------------------------------------------------
# USE_RAG_SCRAPING=true
# FIRECRAWL_API_KEY=<FIRECRAWL_API_KEY>
# Uncomment to enable chat widget
# ------------------------------------------------------------
# USE_CHAT_WIDGET=true
# CHAT_WIDGET_SESSION_JWT_SECRET=<CHAT_WIDGET_SESSION_JWT_SECRET>

208
README.md
View file

@ -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=<your-qdrant-url> # e.g., http://localhost:6333 for local
QDRANT_API_KEY=<your-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=<your-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:::<your-bucket-name>/*",
"arn:aws:s3:::<your-bucket-name>"
]
}
]
}
```
3. **Update Environment Variables**
```ini
USE_RAG_UPLOADS=true
AWS_ACCESS_KEY_ID=<your-aws-access-key>
AWS_SECRET_ACCESS_KEY=<your-aws-secret-key>
RAG_UPLOADS_S3_BUCKET=<your-s3-bucket-name>
RAG_UPLOADS_S3_REGION=<your-s3-region>
GOOGLE_API_KEY=<your-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/<PROJECT_ID>/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=<your-generated-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/<PROJECT_ID>/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=<your-generated-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/<PROJECT_ID>/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**

View file

@ -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

View file

@ -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 = {

View file

@ -0,0 +1,8 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
.env*

View file

@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

40
apps/chat_widget/.gitignore vendored Normal file
View file

@ -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

View file

@ -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"]

View file

@ -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.

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>
);
}

View file

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
};
export default nextConfig;

9671
apps/chat_widget/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,32 @@
{
"name": "chat-widget",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@nextui-org/react": "^2.4.8",
"framer-motion": "^11.11.11",
"next": "^14.2.16",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"rowboat-shared": "github:rowboatlabs/shared",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "15.0.2",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

View file

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View file

@ -0,0 +1,16 @@
import { nextui } from "@nextui-org/react";
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [nextui()],
};
export default config;

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View file

@ -5,6 +5,7 @@ from copilot import UserMessage, AssistantMessage, get_response
from lib import AgentContext, PromptContext, ToolContext, ChatContext
import os
from functools import wraps
from copilot import copilot_instructions, copilot_instructions_edit_agent
class ApiRequest(BaseModel):
messages: List[UserMessage | AssistantMessage]
@ -22,7 +23,7 @@ def validate_request(request_data: ApiRequest) -> None:
"""Validate the chat request data."""
if not request_data.messages:
raise ValueError('Messages list cannot be empty')
if not isinstance(request_data.messages[-1], UserMessage):
raise ValueError('Last message must be a user message')
@ -49,45 +50,70 @@ def health():
@require_api_key
def chat():
try:
# Log incoming request
print(f"Incoming request: {request.json}")
# Parse and validate request
request_data = ApiRequest(**request.json)
validate_request(request_data)
# Process chat request
response = get_response(
messages=request_data.messages,
workflow_schema=request_data.workflow_schema,
current_workflow_config=request_data.current_workflow_config,
context=request_data.context
context=request_data.context,
copilot_instructions=copilot_instructions
)
# Create API response
api_response = ApiResponse(response=response).model_dump()
# Log response before sending
print(f"Outgoing response: {api_response}")
return jsonify(api_response)
except ValidationError as ve:
print(ve)
return jsonify({
'error': 'Invalid request format',
'details': str(ve)
}), 400
except ValueError as ve:
print(ve)
return jsonify({
'error': 'Invalid request data',
'details': str(ve)
}), 400
except Exception as e:
print(e)
return jsonify({
'error': 'Internal server error',
'details': str(e)
}), 500
@app.route('/edit_agent_instructions', methods=['POST'])
@require_api_key
def edit_agent_instructions():
try:
request_data = ApiRequest(**request.json)
validate_request(request_data)
response = get_response(
messages=request_data.messages,
workflow_schema=request_data.workflow_schema,
current_workflow_config=request_data.current_workflow_config,
context=request_data.context,
copilot_instructions=copilot_instructions_edit_agent
)
api_response = ApiResponse(response=response).model_dump()
return jsonify(api_response)
except ValidationError as ve:
# Log the unexpected error here
print(ve)
return jsonify({
'error': 'Invalid request format',
'details': str(ve)
}), 400
except ValueError as ve:
# Log the unexpected error here
print(ve)
return jsonify({
'error': 'Invalid request data',
'details': str(ve)
}), 400
except Exception as e:
# Log the unexpected error here
print(e)
return jsonify({
'error': 'Internal server error',

View file

@ -99,11 +99,11 @@ When adding examples to an agent use the below format for each example you creat
```
Action involving calling other agents
1. If the action is calling another agent, denote it by 'Call <agent_name>
1. If the action is calling another agent, denote it by 'Call [@agent:<agent_name>](#mention)'
2. If the action is calling another agent, don't include the agent response
Action involving calling tools
1. If the action involves calling one or more tools, denote it by 'Call <tool_name_1>, Call <tool_name_2> ... '
1. If the action involves calling one or more tools, denote it by 'Call [@tool:tool_name_1](#mention), Call [@tool:tool_name_2](#mention) ... '
2. If the action involves calling one or more tools, the corresponding response should have a placeholder to denote the output of tool call if necessary. e.g. 'Your order will be delivered on <delivery_date>'
Style of Response
@ -152,7 +152,7 @@ You are responsible for providing delivery information to the user.
## ⚙️ Steps to Follow:
1. Fetch the delivery details using the function: get_shipping_details.
1. Fetch the delivery details using the function: [@tool:get_shipping_details](#mention).
2. Answer the user's question based on the fetched delivery details.
3. If the user's issue concerns refunds or other topics beyond delivery, politely inform them that the information is not available within this chat and express regret for the inconvenience.
@ -173,7 +173,7 @@ You are responsible for providing delivery information to the user.
## 📋 Guidelines:
Dos:
- Use get_shipping_details to fetch accurate delivery information.
- Use [@tool:get_shipping_details](#mention) to fetch accurate delivery information.
- Provide complete and clear answers based on the delivery details.
- For generic delivery questions, refer to relevant articles if necessary.
- Stick to factual information when answering.
@ -270,7 +270,7 @@ If the workflow has an 'Example Agent' as the main agent, it means the user is y
## Section 12: Examples
### Example 1:
### Example 1:
User: create a system to handle 2fa related customer support queries. The queries can be: 1. setting up 2fa : ask the users preferred methods 2. changing 2fa : chaing the 2fa method 3. troubleshooting : not getting 2fa codes etc.
@ -329,11 +329,9 @@ Copilot output:
"description": "Agent to guide users in setting up 2FA.",
"instructions": "## 🧑‍💼 Role:\nHelp users set up their 2FA preferences.\n\n---\n## ⚙️ Steps to Follow:\n1. Ask the user about their preferred 2FA method (e.g., SMS, Email).\n2. Confirm the setup method with the user.\n3. Guide them through the setup steps.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Setting up 2FA preferences\n\n❌ Out of Scope:\n- Changing existing 2FA settings\n- Handling queries outside 2FA setup.\n- General knowledge queries.\n\n---\n## 📋 Guidelines:\n✔️ Dos:\n- Clearly explain setup options and steps.\n\n🚫 Don'ts:\n- Assume preferences without user confirmation.\n- Extend the conversation beyond 2FA setup.",
"examples": "- **User** : I'd like to set up 2FA for my account.\n - **Agent response**: Sure, can you tell me your preferred method for 2FA? Options include SMS, Email, or an Authenticator App.\n\n- **User** : I want to use SMS for 2FA.\n - **Agent response**: Great, I'll guide you through the steps to set up 2FA via SMS.\n\n- **User** : How about using an Authenticator App?\n - **Agent response**: Sure, let's set up 2FA with an Authenticator App. I'll walk you through the necessary steps.\n\n- **User** : Can you help me set up 2FA through Email?\n - **Agent response**: No problem, I'll explain how to set up 2FA via Email now.\n\n- **User** : I changed my mind, can we start over?\n - **Agent response**: Of course, let's begin again. Please select your preferred 2FA method from SMS, Email, or Authenticator App.",
"prompts": [],
"tools": [],
"model": "gpt-4o",
"toggleAble": true,
"controlType": "relinquish_to_parent"
"controlType": "Retain control"
}
}
},
@ -352,15 +350,11 @@ Copilot output:
"name": "2FA Change",
"type": "conversation",
"description": "Agent to assist users in changing their 2FA method.",
"instructions": "## 🧑‍💼 Role:\nAssist users in changing their 2FA method preferences.\n\n---\n## ⚙️ Steps to Follow:\n1. Fetch the current 2FA method using the 'get_current_2fa_method' tool.\n2. Confirm with the user if they want to change the method.\n3. Guide them through the process of changing the method.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Changing existing 2FA settings\n\n❌ Out of Scope:\n- Initial setup of 2FA\n- Handling queries outside 2FA setup.\n- General knowledge queries.\n\n---\n## 📋 Guidelines:\n✔️ Dos:\n- Ensure the user is aware of the current method before change.\n\n🚫 Don'ts:\n- Change methods without explicit user confirmation.\n- Extend the conversation beyond 2FA change.",
"examples": "- **User** : I want to change my 2FA method from SMS to Email.\n - **Agent response**: I can help with that. Let me fetch your current 2FA setting first.\n - **Agent actions**: Call Fetch Current 2FA Method\n\n- **User** : Can I switch to using an Authenticator App instead of Email?\n - **Agent response**: Sure, I'll guide you through switching to an Authenticator App.\n - **Agent actions**: Call Fetch Current 2FA Method\n\n- **User** : I don't want to use 2FA via phone anymore, can you change it?\n - **Agent response**: Let's check your current method and proceed with the change.\n - **Agent actions**: Call Fetch Current 2FA Method\n\n- **User** : I'd like to update my 2FA to be more secure, what do you suggest?\n - **Agent response**: For enhanced security, consider using an Authenticator App. Let's fetch your current method and update it.\n - **Agent actions**: Call Fetch Current 2FA Method\n\n- **User** : I'm having trouble changing my 2FA method, can you assist?\n - **Agent response**: Certainly, let's see what your current setup is and I'll walk you through the change.",
"prompts": [],
"tools": [
"get_current_2fa_method"
],
"instructions": "## 🧑‍💼 Role:\nAssist users in changing their 2FA method preferences.\n\n---\n## ⚙️ Steps to Follow:\n1. Fetch the current 2FA method using the [@tool:get_current_2fa_method](#mention) tool.\n2. Confirm with the user if they want to change the method.\n3. Guide them through the process of changing the method.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Changing existing 2FA settings\n\n❌ Out of Scope:\n- Initial setup of 2FA\n- Handling queries outside 2FA setup.\n- General knowledge queries.\n\n---\n## 📋 Guidelines:\n✔️ Dos:\n- Ensure the user is aware of the current method before change.\n\n🚫 Don'ts:\n- Change methods without explicit user confirmation.\n- Extend the conversation beyond 2FA change.",
"examples": "- **User** : I want to change my 2FA method from SMS to Email.\n - **Agent response**: I can help with that. Let me fetch your current 2FA setting first.\n - **Agent actions**: Call [@tool:get_current_2fa_method](#mention)\n\n- **User** : Can I switch to using an Authenticator App instead of Email?\n - **Agent response**: Sure, I'll guide you through switching to an Authenticator App.\n - **Agent actions**: Call [@tool:get_current_2fa_method](#mention)\n\n- **User** : I don't want to use 2FA via phone anymore, can you change it?\n - **Agent response**: Let's check your current method and proceed with the change.\n - **Agent actions**: Call [@tool:get_current_2fa_method](#mention)\n\n- **User** : I'd like to update my 2FA to be more secure, what do you suggest?\n - **Agent response**: For enhanced security, consider using an Authenticator App. Let's fetch your current method and update it.\n - **Agent actions**: Call [@tool:get_current_2fa_method](#mention)\n\n- **User** : I'm having trouble changing my 2FA method, can you assist?\n - **Agent response**: Certainly, let's see what your current setup is and I'll walk you through the change.",
"model": "gpt-4o",
"toggleAble": true,
"controlType": "relinquish_to_parent"
"controlType": "Retain control"
}
}
},
@ -381,11 +375,9 @@ Copilot output:
"description": "Agent to troubleshoot issues related to not receiving 2FA codes.",
"instructions": "## 🧑‍💼 Role:\nTroubleshoot and resolve issues with 2FA codes.\n\n---\n## ⚙️ Steps to Follow:\n1. Confirm the contact details for 2FA are correct.\n2. Ask about the issue specifics (e.g., not receiving codes at all, delayed codes).\n3. Provide troubleshooting steps or escalate if unresolved.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Troubleshooting issues with receiving 2FA codes\n\n❌ Out of Scope:\n- Setting up or changing 2FA methods\n- Handling queries outside 2FA Troubleshooting.\n- General knowledge queries.\n\n---\n## 📋 Guidelines:\n✔️ Dos:\n- Verify contact information for accuracy.\n\n🚫 Don'ts:\n- Extend the conversation beyond 2FA change.",
"examples": "- **User** : I haven't received my 2FA code.\n - **Agent response**: Let's ensure your contact details are correct. Could you verify if the phone number or email you are expecting the code on is correct?\n\n- **User** : My 2FA codes are always delayed.\n - **Agent response**: I apologize for the inconvenience. Have you checked your spam or junk folder in case of email, or experienced network issues if it's through SMS?\n\n- **User** : The codes aren't coming to my phone.\n - **Agent response**: Let's confirm the number on file for you. Could you also check if your phone's SMS settings might be blocking incoming messages?\n\n- **User** : I'm facing issues with receiving 2FA codes via email.\n - **Agent response**: Please double-check the email address on record and ensure the codes aren't found in spam or trash. I can assist further if needed.\n\n- **User** : My codes are coming to the wrong email address.\n - **Agent response**: I recommend updating your 2FA contact information. Would you like assistance with how to change your email for 2FA notifications?",
"prompts": [],
"tools": [],
"model": "gpt-4o",
"toggleAble": true,
"controlType": "relinquish_to_parent"
"controlType": "Retain control"
}
}
},
@ -403,13 +395,8 @@ Copilot output:
"config_changes": {
"name": "2FA Hub",
"description": "Hub agent to manage 2FA-related queries.",
"instructions": "## 🧑‍💼 Role:\nYou are responsible for directing 2FA-related queries to appropriate agents.\n\n---\n## ⚙️ Steps to Follow:\n1. Greet the user and ask which 2FA-related query they need help with (e.g., 'Are you setting up, changing, or troubleshooting your 2FA?').\n2. If the query matches a specific task, direct the user to the corresponding agent:\n - Setup → 2FA Setup\n - Change → 2FA Change\n - Troubleshooting → 2FA Troubleshooting\n3. If the query doesn't match any specific task, respond with 'I'm sorry, I didn't understand. Could you clarify your request?' or escalate to human support.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Initialization of 2FA setup\n- Changing 2FA methods\n- Troubleshooting 2FA issues\n\n❌ Out of Scope:\n- Issues unrelated to 2FA\n- General knowledge queries\n\n---\n## 📋 Guidelines:\n✔️ Dos:\n- Direct queries to specific 2FA agents promptly.\n- Call escalation agent for unrecognized queries.\n\n🚫 Don'ts:\n- Engage in detailed support.\n- Extend the conversation beyond 2FA.\n- Provide user-facing text such as 'I will connect you now...' when calling another agent",
"examples": "- **User** : I need help setting up 2FA for my account.\n - **Agent actions**: Call 2FA Setup\n\n- **User** : How do I change my 2FA method?\n - **Agent actions**: Call 2FA Change\n\n- **User** : I'm not getting my 2FA codes.\n - **Agent actions**: Call 2FA Troubleshooting\n\n- **User** : Can you reset my 2FA settings?\n - **Agent actions**: Call Escalation\n\n- **User** : How are you today?\n - **Agent response**: I'm doing great. What would like help with today?",
"connectedAgents": [
"2FA Setup",
"2FA Change",
"2FA Troubleshooting"
]
"instructions": "## 🧑‍💼 Role:\nYou are responsible for directing 2FA-related queries to appropriate agents.\n\n---\n## ⚙️ Steps to Follow:\n1. Greet the user and ask which 2FA-related query they need help with (e.g., 'Are you setting up, changing, or troubleshooting your 2FA?').\n2. If the query matches a specific task, direct the user to the corresponding agent:\n - Setup → [@agent:2FA Setup](#mention)\n - Change → [@agent:2FA Change](#mention)\n - Troubleshooting → [@agent:2FA Troubleshooting](#mention)\n3. If the query doesn't match any specific task, respond with 'I'm sorry, I didn't understand. Could you clarify your request?' or escalate to human support.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Initialization of 2FA setup\n- Changing 2FA methods\n- Troubleshooting 2FA issues\n\n❌ Out of Scope:\n- Issues unrelated to 2FA\n- General knowledge queries\n\n---\n## 📋 Guidelines:\n✔️ Dos:\n- Direct queries to specific 2FA agents promptly.\n- Call [@agent:Escalation](#mention) agent for unrecognized queries.\n\n🚫 Don'ts:\n- Engage in detailed support.\n- Extend the conversation beyond 2FA.\n- Provide user-facing text such as 'I will connect you now...' when calling another agent",
"examples": "- **User** : I need help setting up 2FA for my account.\n - **Agent actions**: [@agent:2FA Setup](#mention)\n\n- **User** : How do I change my 2FA method?\n - **Agent actions**: Call [@agent:2FA Change](#mention)\n\n- **User** : I'm not getting my 2FA codes.\n - **Agent actions**: Call [@agent:2FA Troubleshooting](#mention)\n\n- **User** : Can you reset my 2FA settings?\n - **Agent actions**: [@agent:Escalation](#mention)\n\n- **User** : How are you today?\n - **Agent response**: I'm doing great. What would like help with today?"
}
}
},
@ -427,12 +414,73 @@ User: What can you help me with?
Copilot output:
```json
{
"response": [
{
"type": "text",
"content": "I can help you : \n\n1. Plan and create multi-agent support system\n2. Create new agents or imporve existing ones\n3. Adding / editing / removing tools\n4. Adding / editing / removing prompts and a lot more!\n\nWhat would you like to do?"
}
]
"response": "<new instructions with relevant changes>"
}
```
"""
copilot_instructions_edit_agent = """
## Role:
You are a copilot that helps the user create edit agent instructions.
## Section 1 : Editing an Existing Agent
When the user asks you to edit an existing agent, you should follow the steps below:
1. Understand the user's request.
3. Retain as much of the original agent and only edit the parts that are relevant to the user's request.
3. If needed, ask clarifying questions to the user. Keep that to one turn and keep it minimal.
4. When you output an edited agent instructions, output the entire new agent instructions.
## Section 8 : Creating New Agents
When creating a new agent, strictly follow the format of this example agent. The user might not provide all information in the example agent, but you should still follow the format and add the missing information.
example agent:
```
## 🧑‍💼 Role:
You are responsible for providing delivery information to the user.
---
## ⚙️ Steps to Follow:
1. Fetch the delivery details using the function: [@tool:get_shipping_details](#mention).
2. Answer the user's question based on the fetched delivery details.
3. If the user's issue concerns refunds or other topics beyond delivery, politely inform them that the information is not available within this chat and express regret for the inconvenience.
---
## 🎯 Scope:
In Scope:
- Questions about delivery status, shipping timelines, and delivery processes.
- Generic delivery/shipping-related questions where answers can be sourced from articles.
Out of Scope:
- Questions unrelated to delivery or shipping.
- Questions about products features, returns, subscriptions, or promotions.
- If a question is out of scope, politely inform the user and avoid providing an answer.
---
## 📋 Guidelines:
Dos:
- Use [@tool:get_shipping_details](#mention) to fetch accurate delivery information.
- Provide complete and clear answers based on the delivery details.
- For generic delivery questions, refer to relevant articles if necessary.
- Stick to factual information when answering.
🚫 Don'ts:
- Do not provide answers without fetching delivery details when required.
- Do not leave the user with partial information. Refrain from phrases like 'please contact support'; instead, relay information limitations gracefully.
```
output format:
```json
{
"agent_instructions": "<new agent instructions with relevant changes>"
}
```
"""
@ -441,7 +489,8 @@ def get_response(
messages: List[UserMessage | AssistantMessage],
workflow_schema: str,
current_workflow_config: str,
context: AgentContext | PromptContext | ToolContext | ChatContext | None = None
context: AgentContext | PromptContext | ToolContext | ChatContext | None = None,
copilot_instructions: str = copilot_instructions
) -> str:
# if context is provided, create a prompt for the context
if context:

View file

@ -81,4 +81,56 @@ chat = StatefulChat(
response = chat.run("Hello, how are you?")
print(response)
# I'm good, thanks! How can I help you today?
```
```
### Advanced Usage
#### Using a specific workflow
```python
response_messages, state = client.chat(
messages=messages,
workflow_id="<WORKFLOW_ID>"
)
# or
chat = StatefulChat(
client,
workflow_id="<WORKFLOW_ID>"
)
```
#### Using a test profile
You can specify a test profile ID to use a specific test configuration:
```python
response_messages, state = client.chat(
messages=messages,
test_profile_id="<TEST_PROFILE_ID>"
)
# or
chat = StatefulChat(
client,
test_profile_id="<TEST_PROFILE_ID>"
)
```
#### Skip tool call runs
This will surface the tool calls to the SDK instead of running them automatically on the Rowboat server.
```python
response_messages, state = client.chat(
messages=messages,
skip_tool_calls=True
)
# or
chat = StatefulChat(
client,
skip_tool_calls=True
)
```

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "rowboat"
version = "1.0.1"
version = "1.0.6"
authors = [
{ name = "Your Name", email = "your.email@example.com" },
]

View file

@ -25,10 +25,18 @@ class Client:
self,
messages: List[ApiMessage],
state: Optional[Dict[str, Any]] = None,
skip_tool_calls: bool = False,
max_turns: int = 3,
workflow_id: Optional[str] = None,
test_profile_id: Optional[str] = None
) -> ApiResponse:
request = ApiRequest(
messages=messages,
state=state
state=state,
skipToolCalls=skip_tool_calls,
maxTurns=max_turns,
workflowId=workflow_id,
testProfileId=test_profile_id
)
response = requests.post(self.base_url, headers=self.headers, data=request.model_dump_json())
@ -75,7 +83,10 @@ class Client:
messages: List[ApiMessage],
tools: Optional[Dict[str, Callable[..., str]]] = None,
state: Optional[Dict[str, Any]] = None,
max_turns: int = 3
max_turns: int = 3,
skip_tool_calls: bool = False,
workflow_id: Optional[str] = None,
test_profile_id: Optional[str] = None
) -> Tuple[List[ApiMessage], Optional[Dict[str, Any]]]:
"""Stateless chat method that handles a single conversation turn with multiple tool call rounds"""
@ -91,7 +102,11 @@ class Client:
# call api
response_data = self._call_api(
messages=current_messages,
state=current_state
state=current_state,
skip_tool_calls=skip_tool_calls,
max_turns=max_turns,
workflow_id=workflow_id,
test_profile_id=test_profile_id
)
current_messages.extend(response_data.messages)
@ -128,12 +143,19 @@ class StatefulChat:
client: Client,
tools: Optional[Dict[str, Callable[..., str]]] = None,
system_prompt: Optional[str] = None,
max_turns: int = 3,
skip_tool_calls: bool = False,
workflow_id: Optional[str] = None,
test_profile_id: Optional[str] = None
) -> None:
self.client = client
self.tools = tools
self.messages: List[ApiMessage] = []
self.state: Optional[Dict[str, Any]] = None
self.max_turns = max_turns
self.skip_tool_calls = skip_tool_calls
self.workflow_id = workflow_id
self.test_profile_id = test_profile_id
if system_prompt:
self.messages.append(SystemMessage(role='system', content=system_prompt))
@ -148,7 +170,11 @@ class StatefulChat:
new_messages, new_state = self.client.chat(
messages=self.messages,
tools=self.tools,
state=self.state
state=self.state,
max_turns=self.max_turns,
skip_tool_calls=self.skip_tool_calls,
workflow_id=self.workflow_id,
test_profile_id=self.test_profile_id
)
# Update internal state

View file

@ -48,6 +48,10 @@ ApiMessage = Union[
class ApiRequest(BaseModel):
messages: List[ApiMessage]
state: Any
skipToolCalls: Optional[bool] = None
maxTurns: Optional[int] = None
workflowId: Optional[str] = None
testProfileId: Optional[str] = None
class ApiResponse(BaseModel):
messages: List[ApiMessage]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,193 @@
'use server';
import { convertFromAgenticAPIChatMessages } from "../lib/types/agents_api_types";
import { AgenticAPIChatRequest } from "../lib/types/agents_api_types";
import { WorkflowAgent } from "../lib/types/workflow_types";
import { EmbeddingRecord } from "../lib/types/datasource_types";
import { WebpageCrawlResponse } from "../lib/types/tool_types";
import { GetInformationToolResult } from "../lib/types/tool_types";
import { EmbeddingDoc } from "../lib/types/datasource_types";
import { generateObject, generateText, embed } from "ai";
import { dataSourceDocsCollection, dataSourcesCollection, embeddingsCollection, webpagesCollection } from "../lib/mongodb";
import { z } from 'zod';
import { openai } from "@ai-sdk/openai";
import FirecrawlApp, { ScrapeResponse } from '@mendable/firecrawl-js';
import { embeddingModel } from "../lib/embedding";
import { apiV1 } from "rowboat-shared";
import { Claims, getSession } from "@auth0/nextjs-auth0";
import { callClientToolWebhook, getAgenticApiResponse, mockToolResponse, runRAGToolCall } from "../lib/utils";
import { check_query_limit } from "../lib/rate_limiting";
import { QueryLimitError } from "../lib/client_utils";
import { projectAuthCheck } from "./project_actions";
import { qdrantClient } from "../lib/qdrant";
import { ObjectId } from "mongodb";
import { TestProfile } from "../lib/types/testing_types";
const crawler = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY || '' });
export async function authCheck(): Promise<Claims> {
const { user } = await getSession() || {};
if (!user) {
throw new Error('User not authenticated');
}
return user;
}
export async function scrapeWebpage(url: string): Promise<z.infer<typeof WebpageCrawlResponse>> {
const page = await webpagesCollection.findOne({
"_id": url,
lastUpdatedAt: {
'$gte': new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), // 24 hours
},
});
if (page) {
// console.log("found webpage in db", url);
return {
title: page.title,
content: page.contentSimple,
};
}
// otherwise use firecrawl
const scrapeResult = await crawler.scrapeUrl(
url,
{
formats: ['markdown'],
onlyMainContent: true
}
) as ScrapeResponse;
// save the webpage using upsert
await webpagesCollection.updateOne(
{ _id: url },
{
$set: {
title: scrapeResult.metadata?.title || '',
contentSimple: scrapeResult.markdown || '',
lastUpdatedAt: (new Date()).toISOString(),
}
},
{ upsert: true }
);
// console.log("crawled webpage", url);
return {
title: scrapeResult.metadata?.title || '',
content: scrapeResult.markdown || '',
};
}
export async function getAssistantResponse(
projectId: string,
request: z.infer<typeof AgenticAPIChatRequest>,
): Promise<{
messages: z.infer<typeof apiV1.ChatMessage>[],
state: unknown,
rawRequest: unknown,
rawResponse: unknown,
}> {
await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) {
throw new QueryLimitError();
}
const response = await getAgenticApiResponse(request);
return {
messages: convertFromAgenticAPIChatMessages(response.messages),
state: response.state,
rawRequest: request,
rawResponse: response.rawAPIResponse,
};
}
export async function suggestToolResponse(toolId: string, projectId: string, messages: z.infer<typeof apiV1.ChatMessage>[], mockInstructions: string): Promise<string> {
await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) {
throw new QueryLimitError();
}
return await mockToolResponse(toolId, messages, mockInstructions);
}
export async function getInformationTool(
projectId: string,
query: string,
sourceIds: string[],
returnType: z.infer<typeof WorkflowAgent>['ragReturnType'],
k: number,
): Promise<z.infer<typeof GetInformationToolResult>> {
await projectAuthCheck(projectId);
return await runRAGToolCall(projectId, query, sourceIds, returnType, k);
}
export async function simulateUserResponse(
projectId: string,
messages: z.infer<typeof apiV1.ChatMessage>[],
scenario: string,
): Promise<string> {
await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) {
throw new QueryLimitError();
}
const scenarioPrompt = `
# Your Specific Task:
## Context:
Here is a scenario:
Scenario:
<START_SCENARIO>
{{scenario}}
<END_SCENARIO>
## Task definition:
Pretend to be a user reaching out to customer support. Chat with the
customer support assistant, assuming your issue is based on this scenario.
Ask follow-up questions and make it real-world like. Don't do dummy
conversations. Your conversation should be a maximum of 5 user turns.
As output, simply provide your (user) turn of conversation.
After you are done with the chat, keep replying with a single word EXIT
in all capitals.
`;
await projectAuthCheck(projectId);
// flip message assistant / user message
// roles from chat messages
// use only text response messages
const flippedMessages: { role: 'user' | 'assistant', content: string }[] = messages
.filter(m => m.role == 'assistant' || m.role == 'user')
.map(m => ({
role: m.role == 'assistant' ? 'user' : 'assistant',
content: m.content || '',
}));
// simulate user call
let prompt;
prompt = scenarioPrompt
.replace('{{scenario}}', scenario);
const { text } = await generateText({
model: openai("gpt-4o"),
system: prompt || '',
messages: flippedMessages,
});
return text.replace(/\. EXIT$/, '.');
}
export async function executeClientTool(
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number],
messages: z.infer<typeof apiV1.ChatMessage>[],
projectId: string,
): Promise<unknown> {
await projectAuthCheck(projectId);
const result = await callClientToolWebhook(toolCall, messages, projectId);
return result;
}

View file

@ -0,0 +1,222 @@
'use server';
import {
convertToCopilotWorkflow, convertToCopilotMessage, convertToCopilotApiMessage,
convertToCopilotApiChatContext, CopilotAPIResponse, CopilotAPIRequest,
CopilotChatContext, CopilotMessage, CopilotAssistantMessage, CopilotWorkflow
} from "../lib/types/copilot_types";
import {
Workflow, WorkflowTool, WorkflowPrompt, WorkflowAgent
} from "../lib/types/workflow_types";
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { assert } from "node:console";
import { check_query_limit } from "../lib/rate_limiting";
import { QueryLimitError } from "../lib/client_utils";
import { projectAuthCheck } from "./project_actions";
export async function getCopilotResponse(
projectId: string,
messages: z.infer<typeof CopilotMessage>[],
current_workflow_config: z.infer<typeof Workflow>,
context: z.infer<typeof CopilotChatContext> | null
): Promise<{
message: z.infer<typeof CopilotAssistantMessage>;
rawRequest: unknown;
rawResponse: unknown;
}> {
await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) {
throw new QueryLimitError();
}
// prepare request
const request: z.infer<typeof CopilotAPIRequest> = {
messages: messages.map(convertToCopilotApiMessage),
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
current_workflow_config: JSON.stringify(convertToCopilotWorkflow(current_workflow_config)),
context: context ? convertToCopilotApiChatContext(context) : null,
};
console.log(`copilot request`, JSON.stringify(request, null, 2));
// call copilot api
const response = await fetch(process.env.COPILOT_API_URL + '/chat', {
method: 'POST',
body: JSON.stringify(request),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.COPILOT_API_KEY || 'test'}`,
},
});
if (!response.ok) {
console.error('Failed to call copilot api', response);
throw new Error(`Failed to call copilot api: ${response.statusText}`);
}
// parse and return response
const json: z.infer<typeof CopilotAPIResponse> = await response.json();
console.log(`copilot response`, JSON.stringify(json, null, 2));
if ('error' in json) {
throw new Error(`Failed to call copilot api: ${json.error}`);
}
// remove leading ```json and trailing ```
const msg = convertToCopilotMessage({
role: 'assistant',
content: json.response.replace(/^```json\n/, '').replace(/\n```$/, ''),
});
// validate response schema
assert(msg.role === 'assistant');
if (msg.role === 'assistant') {
for (const part of msg.content.response) {
if (part.type === 'action') {
switch (part.content.config_type) {
case 'tool': {
const test = {
name: 'test',
description: 'test',
parameters: {
type: 'object',
properties: {},
required: [],
},
} as z.infer<typeof WorkflowTool>;
// iterate over each field in part.content.config_changes
// and test if the final object schema is valid
// if not, discard that field
for (const [key, value] of Object.entries(part.content.config_changes)) {
const result = WorkflowTool.safeParse({
...test,
[key]: value,
});
if (!result.success) {
console.log(`discarding field ${key} from ${part.content.config_type}: ${part.content.name}`, result.error.message);
delete part.content.config_changes[key];
}
}
break;
}
case 'agent': {
const test = {
name: 'test',
description: 'test',
type: 'conversation',
instructions: 'test',
prompts: [],
tools: [],
model: 'gpt-4o',
ragReturnType: 'chunks',
ragK: 10,
connectedAgents: [],
controlType: 'retain',
} as z.infer<typeof WorkflowAgent>;
// iterate over each field in part.content.config_changes
// and test if the final object schema is valid
// if not, discard that field
for (const [key, value] of Object.entries(part.content.config_changes)) {
const result = WorkflowAgent.safeParse({
...test,
[key]: value,
});
if (!result.success) {
console.log(`discarding field ${key} from ${part.content.config_type}: ${part.content.name}`, result.error.message);
delete part.content.config_changes[key];
}
}
break;
}
case 'prompt': {
const test = {
name: 'test',
type: 'base_prompt',
prompt: "test",
} as z.infer<typeof WorkflowPrompt>;
// iterate over each field in part.content.config_changes
// and test if the final object schema is valid
// if not, discard that field
for (const [key, value] of Object.entries(part.content.config_changes)) {
const result = WorkflowPrompt.safeParse({
...test,
[key]: value,
});
if (!result.success) {
console.log(`discarding field ${key} from ${part.content.config_type}: ${part.content.name}`, result.error.message);
delete part.content.config_changes[key];
}
}
break;
}
default: {
part.content.error = `Unknown config type: ${part.content.config_type}`;
break;
}
}
}
}
}
return {
message: msg as z.infer<typeof CopilotAssistantMessage>,
rawRequest: request,
rawResponse: json,
};
}
export async function getCopilotAgentInstructions(
projectId: string,
messages: z.infer<typeof CopilotMessage>[],
current_workflow_config: z.infer<typeof Workflow>,
agentName: string,
): Promise<string> {
await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) {
throw new QueryLimitError();
}
// prepare request
const request: z.infer<typeof CopilotAPIRequest> = {
messages: messages.map(convertToCopilotApiMessage),
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
current_workflow_config: JSON.stringify(convertToCopilotWorkflow(current_workflow_config)),
context: {
type: 'agent',
agentName: agentName,
}
};
console.log(`copilot request`, JSON.stringify(request, null, 2));
// call copilot api
const response = await fetch(process.env.COPILOT_API_URL + '/edit_agent_instructions', {
method: 'POST',
body: JSON.stringify(request),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.COPILOT_API_KEY || 'test'}`,
},
});
if (!response.ok) {
console.error('Failed to call copilot api', response);
throw new Error(`Failed to call copilot api: ${response.statusText}`);
}
// parse and return response
const json = await response.json();
console.log(`copilot response`, JSON.stringify(json, null, 2));
let copilotResponse: z.infer<typeof CopilotAPIResponse>;
let agent_instructions: string;
try {
copilotResponse = CopilotAPIResponse.parse(json);
const content = json.response.replace(/^```json\n/, '').replace(/\n```$/, '');
agent_instructions = JSON.parse(content).agent_instructions;
} catch (e) {
console.error('Failed to parse copilot response', e);
throw new Error(`Failed to parse copilot response: ${e}`);
}
if ('error' in copilotResponse) {
throw new Error(`Failed to call copilot api: ${copilotResponse.error}`);
}
// return response
return agent_instructions;
}

View file

@ -0,0 +1,344 @@
'use server';
import { redirect } from "next/navigation";
import { ObjectId, WithId } from "mongodb";
import { dataSourcesCollection, dataSourceDocsCollection } from "../lib/mongodb";
import { z } from 'zod';
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { projectAuthCheck } from "./project_actions";
import { WithStringId } from "../lib/types/types";
import { DataSourceDoc } from "../lib/types/datasource_types";
import { DataSource } from "../lib/types/datasource_types";
import { uploadsS3Client } from "../lib/uploads_s3_client";
export async function getDataSource(projectId: string, sourceId: string): Promise<WithStringId<z.infer<typeof DataSource>>> {
await projectAuthCheck(projectId);
const source = await dataSourcesCollection.findOne({
_id: new ObjectId(sourceId),
projectId,
});
if (!source) {
throw new Error('Invalid data source');
}
const { _id, ...rest } = source;
return {
...rest,
_id: _id.toString(),
};
}
export async function listDataSources(projectId: string): Promise<WithStringId<z.infer<typeof DataSource>>[]> {
await projectAuthCheck(projectId);
const sources = await dataSourcesCollection.find({
projectId: projectId,
status: { $ne: 'deleted' },
}).toArray();
return sources.map((s) => ({
...s,
_id: s._id.toString(),
}));
}
export async function createDataSource({
projectId,
name,
data,
status = 'pending',
}: {
projectId: string,
name: string,
data: z.infer<typeof DataSource>['data'],
status?: 'pending' | 'ready',
}): Promise<WithStringId<z.infer<typeof DataSource>>> {
await projectAuthCheck(projectId);
const source: z.infer<typeof DataSource> = {
projectId: projectId,
active: true,
name: name,
createdAt: (new Date()).toISOString(),
attempts: 0,
status: status,
version: 1,
data,
};
await dataSourcesCollection.insertOne(source);
const { _id, ...rest } = source as WithId<z.infer<typeof DataSource>>;
return {
...rest,
_id: _id.toString(),
};
}
export async function recrawlWebDataSource(projectId: string, sourceId: string) {
await projectAuthCheck(projectId);
const source = await getDataSource(projectId, sourceId);
if (source.data.type !== 'urls') {
throw new Error('Invalid data source type');
}
// mark all files as queued
await dataSourceDocsCollection.updateMany({
sourceId: sourceId,
}, {
$set: {
status: 'pending',
lastUpdatedAt: (new Date()).toISOString(),
attempts: 0,
}
});
// mark data source as pending
await dataSourcesCollection.updateOne({
_id: new ObjectId(sourceId),
}, {
$set: {
status: 'pending',
lastUpdatedAt: (new Date()).toISOString(),
attempts: 0,
},
$inc: {
version: 1,
},
});
}
export async function deleteDataSource(projectId: string, sourceId: string) {
await projectAuthCheck(projectId);
await getDataSource(projectId, sourceId);
// mark data source as deleted
await dataSourcesCollection.updateOne({
_id: new ObjectId(sourceId),
}, {
$set: {
status: 'deleted',
lastUpdatedAt: (new Date()).toISOString(),
attempts: 0,
},
$inc: {
version: 1,
},
});
redirect(`/projects/${projectId}/sources`);
}
export async function toggleDataSource(projectId: string, sourceId: string, active: boolean) {
await projectAuthCheck(projectId);
await getDataSource(projectId, sourceId);
await dataSourcesCollection.updateOne({
"_id": new ObjectId(sourceId),
"projectId": projectId,
}, {
$set: {
"active": active,
}
});
}
export async function addDocsToDataSource({
projectId,
sourceId,
docData,
}: {
projectId: string,
sourceId: string,
docData: {
_id?: string,
name: string,
data: z.infer<typeof DataSourceDoc>['data']
}[]
}): Promise<void> {
await projectAuthCheck(projectId);
await getDataSource(projectId, sourceId);
await dataSourceDocsCollection.insertMany(docData.map(doc => {
const record: z.infer<typeof DataSourceDoc> = {
sourceId,
name: doc.name,
status: 'pending',
createdAt: new Date().toISOString(),
data: doc.data,
version: 1,
};
if (!doc._id) {
return record;
}
const recordWithId = record as WithId<z.infer<typeof DataSourceDoc>>;
recordWithId._id = new ObjectId(doc._id);
return recordWithId;
}));
await dataSourcesCollection.updateOne(
{ _id: new ObjectId(sourceId) },
{
$set: {
status: 'pending',
attempts: 0,
lastUpdatedAt: new Date().toISOString(),
},
$inc: {
version: 1,
},
}
);
}
export async function listDocsInDataSource({
projectId,
sourceId,
page = 1,
limit = 10,
}: {
projectId: string,
sourceId: string,
page?: number,
limit?: number,
}): Promise<{
files: WithStringId<z.infer<typeof DataSourceDoc>>[],
total: number
}> {
await projectAuthCheck(projectId);
await getDataSource(projectId, sourceId);
// Get total count
const total = await dataSourceDocsCollection.countDocuments({
sourceId,
status: { $ne: 'deleted' },
});
// Fetch docs with pagination
const docs = await dataSourceDocsCollection.find({
sourceId,
status: { $ne: 'deleted' },
})
.skip((page - 1) * limit)
.limit(limit)
.toArray();
return {
files: docs.map(f => ({ ...f, _id: f._id.toString() })),
total
};
}
export async function deleteDocsFromDataSource({
projectId,
sourceId,
docIds,
}: {
projectId: string,
sourceId: string,
docIds: string[],
}): Promise<void> {
await projectAuthCheck(projectId);
await getDataSource(projectId, sourceId);
// mark for deletion
await dataSourceDocsCollection.updateMany(
{
sourceId,
_id: {
$in: docIds.map(id => new ObjectId(id))
}
},
{
$set: {
status: "deleted",
lastUpdatedAt: new Date().toISOString(),
},
$inc: {
version: 1,
},
}
);
// mark data source as pending
await dataSourcesCollection.updateOne({
_id: new ObjectId(sourceId),
}, {
$set: {
status: 'pending',
attempts: 0,
lastUpdatedAt: new Date().toISOString(),
},
$inc: {
version: 1,
},
});
}
export async function getDownloadUrlForFile(
projectId: string,
sourceId: string,
fileId: string
): Promise<string> {
await projectAuthCheck(projectId);
await getDataSource(projectId, sourceId);
// fetch s3 key for file
const file = await dataSourceDocsCollection.findOne({
sourceId,
_id: new ObjectId(fileId),
'data.type': 'file',
});
if (!file) {
throw new Error('File not found');
}
if (file.data.type !== 'file') {
throw new Error('File not found');
}
const command = new GetObjectCommand({
Bucket: process.env.RAG_UPLOADS_S3_BUCKET,
Key: file.data.s3Key,
});
return await getSignedUrl(uploadsS3Client, command, { expiresIn: 60 }); // URL valid for 1 minute
}
export async function getUploadUrlsForFilesDataSource(
projectId: string,
sourceId: string,
files: { name: string; type: string; size: number }[]
): Promise<{
fileId: string,
presignedUrl: string,
s3Key: string,
}[]> {
await projectAuthCheck(projectId);
const source = await getDataSource(projectId, sourceId);
if (source.data.type !== 'files') {
throw new Error('Invalid files data source');
}
const urls: {
fileId: string,
presignedUrl: string,
s3Key: string,
}[] = [];
for (const file of files) {
const fileId = new ObjectId().toString();
const projectIdPrefix = projectId.slice(0, 2); // 2 characters from the start of the projectId
const s3Key = `datasources/files/${projectIdPrefix}/${projectId}/${sourceId}/${fileId}/${file.name}`;
// Generate presigned URL
const command = new PutObjectCommand({
Bucket: process.env.RAG_UPLOADS_S3_BUCKET,
Key: s3Key,
ContentType: file.type,
});
const presignedUrl = await getSignedUrl(uploadsS3Client, command, { expiresIn: 10 * 60 }); // valid for 10 minutes
urls.push({
fileId,
presignedUrl,
s3Key,
});
}
return urls;
}

View file

@ -0,0 +1,215 @@
'use server';
import { redirect } from "next/navigation";
import { ObjectId } from "mongodb";
import { dataSourcesCollection, embeddingsCollection, projectsCollection, agentWorkflowsCollection, testScenariosCollection, projectMembersCollection, apiKeysCollection, dataSourceDocsCollection, testProfilesCollection } from "../lib/mongodb";
import { z } from 'zod';
import crypto from 'crypto';
import { revalidatePath } from "next/cache";
import { templates } from "../lib/project_templates";
import { authCheck } from "./actions";
import { WithStringId } from "../lib/types/types";
import { ApiKey } from "../lib/types/project_types";
import { Project } from "../lib/types/project_types";
export async function projectAuthCheck(projectId: string) {
const user = await authCheck();
const membership = await projectMembersCollection.findOne({
projectId,
userId: user.sub,
});
if (!membership) {
throw new Error('User not a member of project');
}
}
export async function createProject(formData: FormData) {
const user = await authCheck();
// ensure that projects created by this user is less than
// configured limit
const projectsLimit = Number(process.env.MAX_PROJECTS_PER_USER) || 0;
if (projectsLimit > 0) {
const count = await projectsCollection.countDocuments({
createdByUserId: user.sub,
});
if (count >= projectsLimit) {
throw new Error('You have reached your project limit. Please upgrade your plan.');
}
}
const name = formData.get('name') as string;
const templateKey = formData.get('template') as string;
const projectId = crypto.randomUUID();
const chatClientId = crypto.randomBytes(16).toString('base64url');
const secret = crypto.randomBytes(32).toString('hex');
// create project
await projectsCollection.insertOne({
_id: projectId,
name: name,
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
createdByUserId: user.sub,
chatClientId,
secret,
nextWorkflowNumber: 1,
testRunCounter: 0,
webhookUrl: 'http://tools_webhook:3005/tool_call',
});
// add first workflow version
const { agents, prompts, tools, startAgent } = templates[templateKey];
await agentWorkflowsCollection.insertOne({
projectId,
agents,
prompts,
tools,
startAgent,
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
name: `Version 1`,
});
// add user to project
await projectMembersCollection.insertOne({
userId: user.sub,
projectId: projectId,
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
});
// add first api key
await createApiKey(projectId);
redirect(`/projects/${projectId}/workflow`);
}
export async function getProjectConfig(projectId: string): Promise<WithStringId<z.infer<typeof Project>>> {
await projectAuthCheck(projectId);
const project = await projectsCollection.findOne({
_id: projectId,
});
if (!project) {
throw new Error('Project config not found');
}
return project;
}
export async function listProjects(): Promise<z.infer<typeof Project>[]> {
const user = await authCheck();
const memberships = await projectMembersCollection.find({
userId: user.sub,
}).toArray();
const projectIds = memberships.map((m) => m.projectId);
const projects = await projectsCollection.find({
_id: { $in: projectIds },
}).toArray();
return projects;
}
export async function rotateSecret(projectId: string): Promise<string> {
await projectAuthCheck(projectId);
const secret = crypto.randomBytes(32).toString('hex');
await projectsCollection.updateOne(
{ _id: projectId },
{ $set: { secret } }
);
return secret;
}
export async function updateWebhookUrl(projectId: string, url: string) {
await projectAuthCheck(projectId);
await projectsCollection.updateOne(
{ _id: projectId },
{ $set: { webhookUrl: url } }
);
}
export async function createApiKey(projectId: string): Promise<WithStringId<z.infer<typeof ApiKey>>> {
await projectAuthCheck(projectId);
// count existing keys
const count = await apiKeysCollection.countDocuments({ projectId });
if (count >= 3) {
throw new Error('Maximum number of API keys reached');
}
// create key
const key = crypto.randomBytes(32).toString('hex');
const doc: z.infer<typeof ApiKey> = {
projectId,
key,
createdAt: new Date().toISOString(),
};
await apiKeysCollection.insertOne(doc);
const { _id, ...rest } = doc as WithStringId<z.infer<typeof ApiKey>>;
return { ...rest, _id: _id.toString() };
}
export async function deleteApiKey(projectId: string, id: string) {
await projectAuthCheck(projectId);
await apiKeysCollection.deleteOne({ projectId, _id: new ObjectId(id) });
}
export async function listApiKeys(projectId: string): Promise<WithStringId<z.infer<typeof ApiKey>>[]> {
await projectAuthCheck(projectId);
const keys = await apiKeysCollection.find({ projectId }).toArray();
return keys.map(k => ({ ...k, _id: k._id.toString() }));
}
export async function updateProjectName(projectId: string, name: string) {
await projectAuthCheck(projectId);
await projectsCollection.updateOne({ _id: projectId }, { $set: { name } });
revalidatePath(`/projects/${projectId}`, 'layout');
}
export async function deleteProject(projectId: string) {
await projectAuthCheck(projectId);
// delete api keys
await apiKeysCollection.deleteMany({
projectId,
});
// delete embeddings
const sources = await dataSourcesCollection.find({
projectId,
}, {
projection: {
_id: true,
}
}).toArray();
const ids = sources.map(s => s._id);
// delete data sources
await embeddingsCollection.deleteMany({
sourceId: { $in: ids.map(i => i.toString()) },
});
await dataSourcesCollection.deleteMany({
_id: {
$in: ids,
}
});
// delete project members
await projectMembersCollection.deleteMany({
projectId,
});
// delete workflows
await agentWorkflowsCollection.deleteMany({
projectId,
});
// delete scenarios
await testScenariosCollection.deleteMany({
projectId,
});
// delete project
await projectsCollection.deleteOne({
_id: projectId,
});
redirect('/projects');
}

View file

@ -0,0 +1,547 @@
'use server';
import { ObjectId } from "mongodb";
import { testScenariosCollection, testSimulationsCollection, testProfilesCollection, testRunsCollection, testResultsCollection, projectsCollection } from "../lib/mongodb";
import { z } from 'zod';
import { projectAuthCheck } from "./project_actions";
import { type WithStringId } from "../lib/types/types";
import { TestScenario, TestSimulation, TestProfile, TestRun, TestResult } from "../lib/types/testing_types";
export async function listScenarios(
projectId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
scenarios: WithStringId<z.infer<typeof TestScenario>>[];
total: number;
}> {
await projectAuthCheck(projectId);
// Calculate skip value for pagination
const skip = (page - 1) * pageSize;
// Get total count for pagination
const total = await testScenariosCollection.countDocuments({ projectId });
// Get paginated scenarios
const scenarios = await testScenariosCollection
.find({ projectId })
.skip(skip)
.limit(pageSize)
.toArray();
return {
scenarios: scenarios.map(scenario => ({
...scenario,
_id: scenario._id.toString(),
})),
total,
};
}
export async function getScenario(projectId: string, scenarioId: string): Promise<WithStringId<z.infer<typeof TestScenario>> | null> {
await projectAuthCheck(projectId);
// fetch scenario
const scenario = await testScenariosCollection.findOne({
_id: new ObjectId(scenarioId),
projectId,
});
if (!scenario) {
return null;
}
const { _id, ...rest } = scenario;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteScenario(projectId: string, scenarioId: string): Promise<void> {
await projectAuthCheck(projectId);
await testScenariosCollection.deleteOne({
_id: new ObjectId(scenarioId),
projectId,
});
}
export async function createScenario(
projectId: string,
data: {
name: string;
description: string;
}
): Promise<WithStringId<z.infer<typeof TestScenario>>> {
await projectAuthCheck(projectId);
const doc = {
...data,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
const result = await testScenariosCollection.insertOne(doc);
return {
...doc,
_id: result.insertedId.toString(),
};
}
export async function updateScenario(
projectId: string,
scenarioId: string,
updates: {
name?: string;
description?: string;
}
): Promise<void> {
await projectAuthCheck(projectId);
const updateData: any = {
...updates,
lastUpdatedAt: new Date().toISOString(),
};
await testScenariosCollection.updateOne(
{
_id: new ObjectId(scenarioId),
projectId,
},
{
$set: updateData,
}
);
}
export async function listSimulations(
projectId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
simulations: WithStringId<z.infer<typeof TestSimulation>>[];
total: number;
}> {
await projectAuthCheck(projectId);
const skip = (page - 1) * pageSize;
const total = await testSimulationsCollection.countDocuments({ projectId });
const simulations = await testSimulationsCollection
.find({ projectId })
.skip(skip)
.limit(pageSize)
.toArray();
return {
simulations: simulations.map(simulation => ({
...simulation,
_id: simulation._id.toString(),
})),
total,
};
}
export async function getSimulation(projectId: string, simulationId: string): Promise<WithStringId<z.infer<typeof TestSimulation>> | null> {
await projectAuthCheck(projectId);
const simulation = await testSimulationsCollection.findOne({
_id: new ObjectId(simulationId),
projectId,
});
if (!simulation) {
return null;
}
const { _id, ...rest } = simulation;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteSimulation(projectId: string, simulationId: string): Promise<void> {
await projectAuthCheck(projectId);
await testSimulationsCollection.deleteOne({
_id: new ObjectId(simulationId),
projectId,
});
}
export async function createSimulation(
projectId: string,
data: {
name: string;
scenarioId: string;
profileId: string | null;
passCriteria: string;
}
): Promise<WithStringId<z.infer<typeof TestSimulation>>> {
await projectAuthCheck(projectId);
const doc: z.infer<typeof TestSimulation> = {
...data,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
const result = await testSimulationsCollection.insertOne(doc);
return {
...doc,
_id: result.insertedId.toString(),
};
}
export async function updateSimulation(
projectId: string,
simulationId: string,
updates: {
name?: string;
scenarioId?: string;
profileId?: string | null;
passCriteria?: string;
}
): Promise<void> {
await projectAuthCheck(projectId);
const updateData: any = {
...updates,
lastUpdatedAt: new Date().toISOString(),
};
await testSimulationsCollection.updateOne(
{
_id: new ObjectId(simulationId),
projectId,
},
{
$set: updateData,
}
);
}
export async function listProfiles(
projectId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
profiles: WithStringId<z.infer<typeof TestProfile>>[];
total: number;
}> {
await projectAuthCheck(projectId);
const skip = (page - 1) * pageSize;
const total = await testProfilesCollection.countDocuments({ projectId });
const profiles = await testProfilesCollection
.find({ projectId })
.skip(skip)
.limit(pageSize)
.toArray();
return {
profiles: profiles.map(profile => ({
...profile,
_id: profile._id.toString(),
})),
total,
};
}
export async function getProfile(projectId: string, profileId: string): Promise<WithStringId<z.infer<typeof TestProfile>> | null> {
await projectAuthCheck(projectId);
const profile = await testProfilesCollection.findOne({
_id: new ObjectId(profileId),
projectId,
});
if (!profile) {
return null;
}
const { _id, ...rest } = profile;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteProfile(projectId: string, profileId: string): Promise<void> {
await projectAuthCheck(projectId);
await testProfilesCollection.deleteOne({
_id: new ObjectId(profileId),
projectId,
default: false,
});
}
export async function createProfile(
projectId: string,
data: {
name: string;
context: string;
mockTools: boolean;
mockPrompt?: string;
}
): Promise<WithStringId<z.infer<typeof TestProfile>>> {
await projectAuthCheck(projectId);
const doc = {
...data,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
const result = await testProfilesCollection.insertOne(doc);
return {
...doc,
_id: result.insertedId.toString(),
};
}
export async function updateProfile(
projectId: string,
profileId: string,
updates: {
name?: string;
context?: string;
mockTools?: boolean;
mockPrompt?: string;
}
): Promise<void> {
await projectAuthCheck(projectId);
const updateData: any = {
...updates,
lastUpdatedAt: new Date().toISOString(),
};
await testProfilesCollection.updateOne(
{
_id: new ObjectId(profileId),
projectId,
},
{
$set: updateData,
}
);
}
export async function listRuns(
projectId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
runs: WithStringId<z.infer<typeof TestRun>>[];
total: number;
}> {
await projectAuthCheck(projectId);
const skip = (page - 1) * pageSize;
const total = await testRunsCollection.countDocuments({ projectId });
const runs = await testRunsCollection
.find({ projectId })
.sort({ startedAt: -1 }) // Sort by most recent first
.skip(skip)
.limit(pageSize)
.toArray();
return {
runs: runs.map(run => ({
...run,
_id: run._id.toString(),
})),
total,
};
}
export async function getRun(projectId: string, runId: string): Promise<WithStringId<z.infer<typeof TestRun>> | null> {
await projectAuthCheck(projectId);
const run = await testRunsCollection.findOne({
_id: new ObjectId(runId),
projectId,
});
if (!run) {
return null;
}
const { _id, ...rest } = run;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteRun(projectId: string, runId: string): Promise<void> {
await projectAuthCheck(projectId);
await testRunsCollection.deleteOne({
_id: new ObjectId(runId),
projectId,
});
}
export async function createRun(
projectId: string,
data: {
simulationIds: string[];
workflowId: string;
}
): Promise<WithStringId<z.infer<typeof TestRun>>> {
await projectAuthCheck(projectId);
// Increment the testRunCounter and get the new value
const result = await projectsCollection.findOneAndUpdate(
{ _id: projectId },
{ $inc: { testRunCounter: 1 } },
{ returnDocument: 'after' }
);
if (!result) {
throw new Error("Project not found");
}
const runNumber = result.testRunCounter || 1;
const doc = {
...data,
projectId,
name: `Run #${runNumber}`,
status: 'pending' as const,
startedAt: new Date().toISOString(),
aggregateResults: {
total: 0,
passCount: 0,
failCount: 0,
},
};
const insertResult = await testRunsCollection.insertOne(doc);
return {
...doc,
_id: insertResult.insertedId.toString(),
};
}
export async function updateRun(
projectId: string,
runId: string,
updates: {
status?: 'pending' | 'running' | 'completed' | 'cancelled' | 'failed' | 'error';
completedAt?: string;
aggregateResults?: {
total: number;
passCount: number;
failCount: number;
};
}
): Promise<void> {
await projectAuthCheck(projectId);
const updateData: any = {
...updates,
};
await testRunsCollection.updateOne(
{
_id: new ObjectId(runId),
projectId,
},
{
$set: updateData,
}
);
}
export async function listResults(
projectId: string,
runId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
results: WithStringId<z.infer<typeof TestResult>>[];
total: number;
}> {
await projectAuthCheck(projectId);
const skip = (page - 1) * pageSize;
const total = await testResultsCollection.countDocuments({ projectId, runId });
const results = await testResultsCollection
.find({ projectId, runId })
.skip(skip)
.limit(pageSize)
.toArray();
return {
results: results.map(result => ({
...result,
_id: result._id.toString(),
})),
total,
};
}
export async function getResult(projectId: string, resultId: string): Promise<WithStringId<z.infer<typeof TestResult>> | null> {
await projectAuthCheck(projectId);
const result = await testResultsCollection.findOne({
_id: new ObjectId(resultId),
projectId,
});
if (!result) {
return null;
}
const { _id, ...rest } = result;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteResult(projectId: string, resultId: string): Promise<void> {
await projectAuthCheck(projectId);
await testResultsCollection.deleteOne({
_id: new ObjectId(resultId),
projectId,
});
}
export async function createResult(
projectId: string,
data: {
runId: string;
simulationId: string;
result: 'pass' | 'fail';
details: string;
}
): Promise<WithStringId<z.infer<typeof TestResult>>> {
await projectAuthCheck(projectId);
const doc = {
...data,
projectId,
};
const result = await testResultsCollection.insertOne(doc);
return {
...doc,
_id: result.insertedId.toString(),
};
}
export async function updateResult(
projectId: string,
resultId: string,
updates: {
result?: 'pass' | 'fail';
details?: string;
}
): Promise<void> {
await projectAuthCheck(projectId);
await testResultsCollection.updateOne(
{
_id: new ObjectId(resultId),
projectId,
},
{
$set: updates,
}
);
}

View file

@ -0,0 +1,241 @@
'use server';
import { ObjectId, WithId } from "mongodb";
import { projectsCollection, agentWorkflowsCollection } from "../lib/mongodb";
import { z } from 'zod';
import { templates } from "../lib/project_templates";
import { projectAuthCheck } from "./project_actions";
import { WithStringId } from "../lib/types/types";
import { Workflow } from "../lib/types/workflow_types";
export async function createWorkflow(projectId: string): Promise<WithStringId<z.infer<typeof Workflow>>> {
await projectAuthCheck(projectId);
// get the next workflow number
const doc = await projectsCollection.findOneAndUpdate({
_id: projectId,
}, {
$inc: {
nextWorkflowNumber: 1,
},
}, {
returnDocument: 'after'
});
if (!doc) {
throw new Error('Project not found');
}
const nextWorkflowNumber = doc.nextWorkflowNumber;
// create the workflow
const { agents, prompts, tools, startAgent } = templates['default'];
const workflow = {
agents,
prompts,
tools,
startAgent,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
name: `Version ${nextWorkflowNumber}`,
};
const { insertedId } = await agentWorkflowsCollection.insertOne(workflow);
const { _id, ...rest } = workflow as WithId<z.infer<typeof Workflow>>;
return {
...rest,
_id: insertedId.toString(),
};
}
export async function cloneWorkflow(projectId: string, workflowId: string): Promise<WithStringId<z.infer<typeof Workflow>>> {
await projectAuthCheck(projectId);
const workflow = await agentWorkflowsCollection.findOne({
_id: new ObjectId(workflowId),
projectId,
});
if (!workflow) {
throw new Error('Workflow not found');
}
// create a new workflow with the same content
const newWorkflow = {
...workflow,
_id: new ObjectId(),
name: `Copy of ${workflow.name || 'Unnamed workflow'}`,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
const { insertedId } = await agentWorkflowsCollection.insertOne(newWorkflow);
const { _id, ...rest } = newWorkflow as WithId<z.infer<typeof Workflow>>;
return {
...rest,
_id: insertedId.toString(),
};
}
export async function renameWorkflow(projectId: string, workflowId: string, name: string) {
await projectAuthCheck(projectId);
await agentWorkflowsCollection.updateOne({
_id: new ObjectId(workflowId),
projectId,
}, {
$set: {
name,
lastUpdatedAt: new Date().toISOString(),
},
});
}
export async function saveWorkflow(projectId: string, workflowId: string, workflow: z.infer<typeof Workflow>) {
await projectAuthCheck(projectId);
// check if workflow exists
const existingWorkflow = await agentWorkflowsCollection.findOne({
_id: new ObjectId(workflowId),
projectId,
});
if (!existingWorkflow) {
throw new Error('Workflow not found');
}
// ensure that this is not the published workflow for this project
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
if (publishedWorkflowId && publishedWorkflowId === workflowId) {
throw new Error('Cannot save published workflow');
}
// update the workflow, except name and description
const { _id, name, ...rest } = workflow as WithId<z.infer<typeof Workflow>>;
await agentWorkflowsCollection.updateOne({
_id: new ObjectId(workflowId),
}, {
$set: {
...rest,
lastUpdatedAt: new Date().toISOString(),
},
});
}
export async function publishWorkflow(projectId: string, workflowId: string) {
await projectAuthCheck(projectId);
// check if workflow exists
const existingWorkflow = await agentWorkflowsCollection.findOne({
_id: new ObjectId(workflowId),
projectId,
});
if (!existingWorkflow) {
throw new Error('Workflow not found');
}
// publish the workflow
await projectsCollection.updateOne({
"_id": projectId,
}, {
$set: {
publishedWorkflowId: workflowId,
}
});
}
export async function fetchPublishedWorkflowId(projectId: string): Promise<string | null> {
await projectAuthCheck(projectId);
const project = await projectsCollection.findOne({
_id: projectId,
});
return project?.publishedWorkflowId || null;
}
export async function fetchWorkflow(projectId: string, workflowId: string): Promise<WithStringId<z.infer<typeof Workflow>>> {
await projectAuthCheck(projectId);
// fetch workflow
const workflow = await agentWorkflowsCollection.findOne({
_id: new ObjectId(workflowId),
projectId,
});
if (!workflow) {
throw new Error('Workflow not found');
}
const { _id, ...rest } = workflow;
return {
...rest,
_id: _id.toString(),
};
}
export async function listWorkflows(
projectId: string,
page: number = 1,
limit: number = 10
): Promise<{
workflows: (WithStringId<z.infer<typeof Workflow>>)[];
total: number;
publishedWorkflowId: string | null;
}> {
await projectAuthCheck(projectId);
// fetch total count
const total = await agentWorkflowsCollection.countDocuments({ projectId });
// fetch published workflow
let publishedWorkflowId: string | null = null;
let publishedWorkflow: WithId<z.infer<typeof Workflow>> | null = null;
if (page === 1) {
publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
if (publishedWorkflowId) {
publishedWorkflow = await agentWorkflowsCollection.findOne({
_id: new ObjectId(publishedWorkflowId),
projectId,
}, {
projection: {
_id: 1,
name: 1,
description: 1,
createdAt: 1,
lastUpdatedAt: 1,
},
});
}
}
// fetch workflows with pagination
let workflows: WithId<z.infer<typeof Workflow>>[] = await agentWorkflowsCollection.find(
{
projectId,
...(publishedWorkflowId ? {
_id: {
$ne: new ObjectId(publishedWorkflowId)
}
} : {}),
},
{
sort: { lastUpdatedAt: -1 },
projection: {
_id: 1,
name: 1,
description: 1,
createdAt: 1,
lastUpdatedAt: 1,
},
skip: (page - 1) * limit,
limit: limit,
}
).toArray();
workflows = [
...(publishedWorkflow ? [publishedWorkflow] : []),
...workflows,
];
// return workflows
return {
workflows: workflows.map((w) => {
const { _id, ...rest } = w;
return {
...rest,
_id: _id.toString(),
};
}),
total,
publishedWorkflowId,
};
}

View file

@ -1,11 +1,15 @@
import { NextRequest } from "next/server";
import { agentWorkflowsCollection, db, projectsCollection } from "@/app/lib/mongodb";
import { agentWorkflowsCollection, db, projectsCollection, testProfilesCollection } from "../../../../lib/mongodb";
import { z } from "zod";
import { ObjectId } from "mongodb";
import { authCheck } from "@/app/api/v1/utils";
import { convertFromApiToAgenticApiMessages, convertFromAgenticApiToApiMessages, AgenticAPIChatRequest, ApiRequest, ApiResponse, convertWorkflowToAgenticAPI } from "@/app/lib/types";
import { getAgenticApiResponse } from "@/app/lib/utils";
import { check_query_limit } from "@/app/lib/rate_limiting";
import { authCheck } from "../../utils";
import { ApiRequest, ApiResponse } from "../../../../lib/types/types";
import { AgenticAPIChatRequest, AgenticAPIChatMessage, convertFromAgenticApiToApiMessages, convertFromApiToAgenticApiMessages, convertWorkflowToAgenticAPI } from "../../../../lib/types/agents_api_types";
import { getAgenticApiResponse, callClientToolWebhook, runRAGToolCall, mockToolResponse } from "../../../../lib/utils";
import { check_query_limit } from "../../../../lib/rate_limiting";
import { apiV1 } from "rowboat-shared";
import { PrefixLogger } from "../../../../lib/utils";
import { TestProfile } from "@/app/lib/types/testing_types";
// get next turn / agent response
export async function POST(
@ -13,9 +17,14 @@ export async function POST(
{ params }: { params: Promise<{ projectId: string }> }
): Promise<Response> {
const { projectId } = await params;
const requestId = crypto.randomUUID();
const logger = new PrefixLogger(`${requestId}`);
logger.log(`Got chat request for project ${projectId}`);
// check query limit
if (!await check_query_limit(projectId)) {
logger.log(`Query limit exceeded for project ${projectId}`);
return Response.json({ error: "Query limit exceeded" }, { status: 429 });
}
@ -25,10 +34,13 @@ export async function POST(
try {
body = await req.json();
} catch (e) {
logger.log(`Invalid JSON in request body: ${e}`);
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
}
logger.log(`Request json: ${JSON.stringify(body, null, 2)}`);
const result = ApiRequest.safeParse(body);
if (!result.success) {
logger.log(`Invalid request body: ${result.error.message}`);
return Response.json({ error: `Invalid request body: ${result.error.message}` }, { status: 400 });
}
const reqMessages = result.data.messages;
@ -39,38 +51,168 @@ export async function POST(
_id: projectId,
});
if (!project) {
logger.log(`Project ${projectId} not found`);
return Response.json({ error: "Project not found" }, { status: 404 });
}
if (!project.publishedWorkflowId) {
return Response.json({ error: "Project has no published workflow" }, { status: 404 });
// if workflow id is provided in the request, use it, else use the published workflow id
let workflowId = result.data.workflowId ?? project.publishedWorkflowId;
if (!workflowId) {
logger.log(`No workflow id provided in request or project has no published workflow`);
return Response.json({ error: "No workflow id provided in request or project has no published workflow" }, { status: 404 });
}
// fetch workflow
const workflow = await agentWorkflowsCollection.findOne({
projectId: projectId,
_id: new ObjectId(project.publishedWorkflowId),
_id: new ObjectId(workflowId),
});
if (!workflow) {
logger.log(`Workflow ${workflowId} not found for project ${projectId}`);
return Response.json({ error: "Workflow not found" }, { status: 404 });
}
// if test profile is provided in the request, use it
let testProfile: z.infer<typeof TestProfile> | null = null;
if (result.data.testProfileId) {
testProfile = await testProfilesCollection.findOne({
projectId: projectId,
_id: new ObjectId(result.data.testProfileId),
});
if (!testProfile) {
logger.log(`Test profile ${result.data.testProfileId} not found for project ${projectId}`);
return Response.json({ error: "Test profile not found" }, { status: 404 });
}
}
// get assistant response
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow);
const request: z.infer<typeof AgenticAPIChatRequest> = {
messages: convertFromApiToAgenticApiMessages(reqMessages),
state: reqState ?? { last_agent_name: startAgent },
agents,
tools,
prompts,
startAgent,
// if profile has a context available, overwrite the system message in the request (if there is one)
let currentMessages = reqMessages;
if (testProfile?.context) {
// if there is a system message, overwrite it
const systemMessageIndex = reqMessages.findIndex(m => m.role === "system");
if (systemMessageIndex !== -1) {
currentMessages[systemMessageIndex].content = testProfile.context;
} else {
// if there is no system message, add one
currentMessages.unshift({ role: "system", content: testProfile.context });
}
}
const MAX_TURNS = result.data.maxTurns ?? 3;
let currentState: unknown = reqState ?? { last_agent_name: workflow.agents[0].name };
let turns = 0;
let hasToolCalls = false;
do {
hasToolCalls = false;
// get assistant response
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow);
const request: z.infer<typeof AgenticAPIChatRequest> = {
messages: convertFromApiToAgenticApiMessages(currentMessages),
state: currentState,
agents,
tools,
prompts,
startAgent,
};
console.log(`turn ${turns}: sending agentic request from /chat api`, JSON.stringify(request, null, 2));
logger.log(`Processing turn ${turns} for conversation`);
const { messages: agenticMessages, state } = await getAgenticApiResponse(request);
const newMessages = convertFromAgenticApiToApiMessages(agenticMessages);
currentState = state;
// if tool calls are to be skipped, return immediately
if (result.data.skipToolCalls) {
logger.log('Skipping tool calls as requested');
const responseBody: z.infer<typeof ApiResponse> = {
messages: newMessages,
state: currentState,
};
return Response.json(responseBody);
}
// get last message to check for tool calls
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage?.role === "assistant" &&
'tool_calls' in lastMessage &&
lastMessage.tool_calls?.length > 0) {
hasToolCalls = true;
const toolCallResultMessages: z.infer<typeof apiV1.ToolMessage>[] = [];
// Process tool calls
for (const toolCall of lastMessage.tool_calls) {
let result: unknown;
if (toolCall.function.name === "getArticleInfo") {
logger.log(`Running RAG tool call for agent ${lastMessage.agenticSender}`);
// find the source ids attached to this agent in the workflow
const agent = workflow.agents.find(a => a.name === lastMessage.agenticSender);
if (!agent) {
return Response.json({ error: "Agent not found" }, { status: 404 });
}
const sourceIds = agent.ragDataSources;
if (!sourceIds) {
return Response.json({ error: "Agent has no data sources" }, { status: 404 });
}
try {
result = await runRAGToolCall(projectId, toolCall.function.arguments, sourceIds, agent.ragReturnType, agent.ragK);
logger.log(`RAG tool call completed for agent ${lastMessage.agenticSender}`);
} catch (e) {
logger.log(`Error running RAG tool call: ${e}`);
return Response.json({ error: "Error running RAG tool call" }, { status: 500 });
}
} else {
logger.log(`Running client tool webhook for tool ${toolCall.function.name}`);
try {
// if tool is supposed to be mocked, mock it
const workflowTool = workflow.tools.find(t => t.name === toolCall.function.name);
if (testProfile?.mockTools || workflowTool?.mockTool) {
logger.log(`Mocking tool call ${toolCall.function.name}`);
result = await mockToolResponse(toolCall.id, currentMessages, testProfile?.mockPrompt || workflowTool?.mockInstructions || '');
} else {
// else run the tool call by calling the client tool webhook
logger.log(`Running client tool webhook for tool ${toolCall.function.name}`);
result = await callClientToolWebhook(
toolCall,
currentMessages,
projectId,
);
}
} catch (e) {
logger.log(`Error in tool call ${toolCall.function.name}: ${e}`);
return Response.json({ error: `Error in tool call ${toolCall.function.name}` }, { status: 500 });
}
logger.log(`Tool call ${toolCall.function.name} completed`);
}
toolCallResultMessages.push({
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify(result),
tool_name: toolCall.function.name,
});
}
// Add new messages to the conversation
currentMessages = [...currentMessages, ...newMessages, ...toolCallResultMessages];
} else {
// No tool calls, just add the new messages
currentMessages = [...currentMessages, ...newMessages];
}
turns++;
if (turns >= MAX_TURNS && hasToolCalls) {
logger.log(`Max turns (${MAX_TURNS}) reached for conversation`);
return Response.json({ error: "Max turns reached" }, { status: 429 });
}
} while (hasToolCalls);
const responseBody: z.infer<typeof ApiResponse> = {
messages: currentMessages.slice(reqMessages.length),
state: currentState,
};
console.log("turn: sending agentic request from /chat api", JSON.stringify(request, null, 2));
const { messages, state } = await getAgenticApiResponse(request);
const response: z.infer<typeof ApiResponse> = {
messages: convertFromAgenticApiToApiMessages(messages),
state,
};
return Response.json(response);
return Response.json(responseBody);
});
}

View file

@ -1,5 +1,5 @@
import { NextRequest } from "next/server";
import { apiKeysCollection, projectsCollection } from "@/app/lib/mongodb";
import { apiKeysCollection, projectsCollection } from "../../lib/mongodb";
export async function authCheck(projectId: string, req: NextRequest, handler: () => Promise<Response>): Promise<Response> {
const authHeader = req.headers.get('Authorization');

View file

@ -1,12 +1,8 @@
import { NextRequest } from "next/server";
import { apiV1 } from "rowboat-shared";
import { db } from "@/app/lib/mongodb";
import { z } from "zod";
import { chatsCollection } from "../../../../../../lib/mongodb";
import { ObjectId } from "mongodb";
import { authCheck } from "../../../utils";
const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
export async function POST(
request: NextRequest,
{ params }: { params: { chatId: string } }

View file

@ -1,13 +1,10 @@
import { NextRequest } from "next/server";
import { apiV1 } from "rowboat-shared";
import { db } from "@/app/lib/mongodb";
import { chatsCollection, chatMessagesCollection } from "../../../../../../lib/mongodb";
import { z } from "zod";
import { Filter, ObjectId } from "mongodb";
import { authCheck } from "../../../utils";
const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chatMessages");
// list messages
export async function GET(
req: NextRequest,

View file

@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { apiV1 } from "rowboat-shared";
import { db } from "@/app/lib/mongodb";
import { db } from "../../../../../lib/mongodb";
import { z } from "zod";
import { ObjectId } from "mongodb";
import { authCheck } from "../../utils";

View file

@ -1,15 +1,19 @@
import { NextRequest } from "next/server";
import { apiV1 } from "rowboat-shared";
import { agentWorkflowsCollection, db, projectsCollection } from "@/app/lib/mongodb";
import { agentWorkflowsCollection, projectsCollection, chatsCollection, chatMessagesCollection } from "../../../../../../lib/mongodb";
import { z } from "zod";
import { ObjectId, WithId } from "mongodb";
import { authCheck } from "../../../utils";
import { AgenticAPIChatRequest, convertFromAgenticAPIChatMessages, convertToAgenticAPIChatMessages, convertWorkflowToAgenticAPI } from "@/app/lib/types";
import { callClientToolWebhook, getAgenticApiResponse } from "@/app/lib/utils";
import { check_query_limit } from "@/app/lib/rate_limiting";
import { convertFromAgenticAPIChatMessages } from "../../../../../../lib/types/agents_api_types";
import { convertToAgenticAPIChatMessages } from "../../../../../../lib/types/agents_api_types";
import { convertWorkflowToAgenticAPI } from "../../../../../../lib/types/agents_api_types";
import { AgenticAPIChatRequest } from "../../../../../../lib/types/agents_api_types";
import { callClientToolWebhook, getAgenticApiResponse, runRAGToolCall, mockToolResponse } from "../../../../../../lib/utils";
import { check_query_limit } from "../../../../../../lib/rate_limiting";
import { PrefixLogger } from "../../../../../../lib/utils";
const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chatMessages");
// Add max turns constant at the top with other constants
const MAX_TURNS = 3;
// get next turn / agent response
export async function POST(
@ -18,9 +22,13 @@ export async function POST(
): Promise<Response> {
return await authCheck(req, async (session) => {
const { chatId } = await params;
const logger = new PrefixLogger(`widget-chat:${chatId}`);
logger.log(`Processing turn request for chat ${chatId}`);
// check query limit
if (!await check_query_limit(session.projectId)) {
logger.log(`Query limit exceeded for project ${session.projectId}`);
return Response.json({ error: "Query limit exceeded" }, { status: 429 });
}
@ -29,10 +37,12 @@ export async function POST(
try {
body = await req.json();
} catch (e) {
logger.log(`Invalid JSON in request body: ${e}`);
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
}
const result = apiV1.ApiChatTurnRequest.safeParse(body);
if (!result.success) {
logger.log(`Invalid request body: ${result.error.message}`);
return Response.json({ error: `Invalid request body: ${result.error.message}` }, { status: 400 });
}
const userMessage: z.infer<typeof apiV1.ChatMessage> = {
@ -87,7 +97,15 @@ export async function POST(
const unsavedMessages: z.infer<typeof apiV1.ChatMessage>[] = [userMessage];
let resolvingToolCalls = true;
let state: unknown = chat.agenticState ?? {last_agent_name: startAgent};
let turns = 0; // Add turns counter
while (resolvingToolCalls) {
if (turns >= MAX_TURNS) {
logger.log(`Max turns (${MAX_TURNS}) reached for chat ${chatId}`);
throw new Error("Max turns reached");
}
turns++;
const request: z.infer<typeof AgenticAPIChatRequest> = {
messages: convertToAgenticAPIChatMessages([systemMessage, ...messages, ...unsavedMessages]),
state,
@ -96,7 +114,7 @@ export async function POST(
prompts,
startAgent,
};
console.log("turn: sending agentic request", JSON.stringify(request, null, 2));
logger.log(`Turn ${turns}: sending agentic request`);
const response = await getAgenticApiResponse(request);
state = response.state;
if (response.messages.length === 0) {
@ -113,18 +131,43 @@ export async function POST(
// if the last messages is tool call, execute them
const lastMessage = convertedMessages[convertedMessages.length - 1];
if (lastMessage.role === 'assistant' && 'tool_calls' in lastMessage) {
// execute tool calls
console.log("Executing tool calls", lastMessage.tool_calls);
logger.log(`Processing ${lastMessage.tool_calls.length} tool calls`);
const toolCallResults = await Promise.all(lastMessage.tool_calls.map(async toolCall => {
console.log('executing tool call', toolCall);
logger.log(`Executing tool call: ${toolCall.function.name}`);
try {
if (toolCall.function.name === "getArticleInfo") {
logger.log(`Processing RAG tool call for agent ${lastMessage.agenticSender}`);
const agent = workflow.agents.find(a => a.name === lastMessage.agenticSender);
if (!agent || !agent.ragDataSources) {
throw new Error("Agent not found or has no data sources");
}
return await runRAGToolCall(
session.projectId,
toolCall.function.arguments,
agent.ragDataSources,
agent.ragReturnType,
agent.ragK
);
}
const workflowTool = workflow.tools.find(t => t.name === toolCall.function.name);
if (workflowTool?.mockTool) {
logger.log(`Using mock response for tool: ${toolCall.function.name}`);
return await mockToolResponse(
toolCall.id,
[...messages, ...unsavedMessages],
workflowTool.mockInstructions || ''
);
}
logger.log(`Calling webhook for tool: ${toolCall.function.name}`);
return await callClientToolWebhook(
toolCall,
[...messages, ...unsavedMessages],
session.projectId,
);
} catch (error) {
console.error(`Error executing tool call ${toolCall.id}:`, error);
logger.log(`Error executing tool call ${toolCall.id}: ${error}`);
return { error: "Tool execution failed" };
}
}));
@ -148,11 +191,11 @@ export async function POST(
}
}
// save unsaved messages and update chat state
logger.log(`Saving ${unsavedMessages.length} new messages and updating chat state`);
await chatMessagesCollection.insertMany(unsavedMessages);
await chatsCollection.updateOne({ _id: new ObjectId(chatId) }, { $set: { agenticState: state } });
// send back the last message
logger.log(`Turn processing completed successfully`);
const lastMessage = unsavedMessages[unsavedMessages.length - 1] as WithId<z.infer<typeof apiV1.ChatMessage>>;
return Response.json({
...lastMessage,

View file

@ -1,5 +1,5 @@
import { NextRequest } from "next/server";
import { db } from "@/app/lib/mongodb";
import { db } from "../../../../lib/mongodb";
import { z } from "zod";
import { ObjectId } from "mongodb";
import { apiV1 } from "rowboat-shared";

View file

@ -4,7 +4,7 @@ import { SignJWT, jwtVerify } from "jose";
import { z } from "zod";
import { Session } from "../../utils";
import { apiV1 } from "rowboat-shared";
import { projectsCollection } from "@/app/lib/mongodb";
import { projectsCollection } from "../../../../../lib/mongodb";
export async function POST(req: NextRequest): Promise<Response> {
return await clientIdCheck(req, async (projectId) => {

View file

@ -1,7 +1,7 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { jwtVerify } from "jose";
import { projectsCollection } from "@/app/lib/mongodb";
import { projectsCollection } from "../../../lib/mongodb";
export const Session = z.object({
userId: z.string(),

View file

@ -4,7 +4,7 @@ import Image from 'next/image';
import logo from "@/public/rowboat-logo.png";
import { useUser } from "@auth0/nextjs-auth0/client";
import { useRouter } from "next/navigation";
import { Spinner } from "@nextui-org/react";
import { Spinner } from "@heroui/react";
import { LogInIcon } from "lucide-react";
export function App() {
@ -15,6 +15,11 @@ export function App() {
router.push("/projects");
}
// Add auto-redirect for non-authenticated users
if (!isLoading && !user && !error) {
router.push("/api/auth/login");
}
return (
<div className="min-h-screen w-full bg-[url('/landing-bg.jpg')] bg-cover bg-center flex flex-col items-center justify-between py-10">
{/* Main content box */}
@ -25,17 +30,8 @@ export function App() {
alt="RowBoat Logo"
height={40}
/>
{isLoading && <Spinner size="sm" />}
{(isLoading || (!user && !error)) && <Spinner size="sm" />}
{error && <div className="text-red-500">{error.message}</div>}
{!isLoading && !error && !user && (
<a
className="bg-white/80 hover:bg-white/90 transition-colors text-black px-6 py-3 rounded-md flex items-center gap-2"
href="/api/auth/login"
>
<LogInIcon className="w-4 h-4" />
Sign in or sign up
</a>
)}
{user && <div className="flex items-center gap-2">
<Spinner size="sm" />
<div className="text-sm text-gray-400">Welcome, {user.name}</div>

View file

@ -1,3 +1,4 @@
@import './styles/quill-mentions.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@ -75,4 +76,25 @@ html, body {
body {
@apply bg-background text-foreground;
}
.card-shadow {
@apply shadow-sm dark:shadow-none dark:border-border;
}
.hover-effect {
@apply hover:bg-accent/10 dark:hover:bg-accent/20 transition-colors;
}
.border-subtle {
@apply border-border dark:border-border/50;
}
}
* {
-webkit-transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out, opacity 0.2s ease-in-out !important;
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out, opacity 0.2s ease-in-out !important;
}
* {
@apply transition-colors duration-200;
}

View file

@ -1,4 +1,5 @@
import "./globals.css";
import { ThemeProvider } from "./providers/theme-provider";
import { UserProvider } from '@auth0/nextjs-auth0/client';
import { Inter } from "next/font/google";
import { Providers } from "./providers";
@ -20,11 +21,13 @@ export default function RootLayout({
}>) {
return <html lang="en" className="h-dvh">
<UserProvider>
<body className={`${inter.className} h-full text-base [scrollbar-width:thin] bg-gray-100`}>
<Providers className='h-full flex flex-col'>
{children}
</Providers>
</body>
<ThemeProvider>
<body className={`${inter.className} h-full text-base [scrollbar-width:thin] bg-background`}>
<Providers className='h-full flex flex-col'>
{children}
</Providers>
</body>
</ThemeProvider>
</UserProvider>
</html>;
}

View file

@ -0,0 +1,57 @@
interface AtMentionItem {
id: string;
value: string;
[key: string]: string; // Add index signature to allow any string key
}
interface CreateAtMentionsProps {
agents: any[];
prompts: any[];
tools: any[];
currentAgentName?: string;
}
export function createAtMentions({ agents, prompts, tools, currentAgentName }: CreateAtMentionsProps): AtMentionItem[] {
const atMentions: AtMentionItem[] = [];
// Add agents
for (const a of agents) {
if (a.disabled || a.name === currentAgentName) {
continue;
}
const id = `agent:${a.name}`;
atMentions.push({
id,
value: id,
denotationChar: "@", // Add required properties for Match type
link: id,
target: "_self"
});
}
// Add prompts
for (const prompt of prompts) {
const id = `prompt:${prompt.name}`;
atMentions.push({
id,
value: id,
denotationChar: "@",
link: id,
target: "_self"
});
}
// Add tools
for (const tool of tools) {
const id = `tool:${tool.name}`;
atMentions.push({
id,
value: id,
denotationChar: "@",
link: id,
target: "_self"
});
}
return atMentions;
}

View file

@ -1,16 +1,17 @@
import { FileIcon, FilesIcon, GlobeIcon } from "lucide-react";
export function DataSourceIcon({
type = undefined,
size = "sm",
}: {
type?: "crawl" | "urls" | undefined;
type?: "crawl" | "urls" | "files" | undefined;
size?: "sm" | "md";
}) {
const sizeClass = size === "sm" ? "w-4 h-4" : "w-6 h-6";
return <>
{type === undefined && <svg className={sizeClass} 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="M19 6c0 1.657-3.134 3-7 3S5 7.657 5 6m14 0c0-1.657-3.134-3-7-3S5 4.343 5 6m14 0v6M5 6v6m0 0c0 1.657 3.134 3 7 3s7-1.343 7-3M5 12v6c0 1.657 3.134 3 7 3s7-1.343 7-3v-6" />
</svg>}
{type == "crawl" && <svg className={`${sizeClass} lucide lucide-globe`} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /><path d="M2 12h20" /></svg>}
{type == "urls" && <svg className={`${sizeClass} lucide lucide-globe`} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /><path d="M2 12h20" /></svg>}
{type === undefined && <FileIcon className={sizeClass} />}
{type == "crawl" && <GlobeIcon className={sizeClass} />}
{type == "urls" && <GlobeIcon className={sizeClass} />}
{type == "files" && <FilesIcon className={sizeClass} />}
</>;
}

View file

@ -0,0 +1,37 @@
import { Select, SelectItem } from "@heroui/react";
import { ReactNode } from "react";
export interface DropdownOption {
key: string;
label: string;
}
interface DropdownProps {
options: DropdownOption[];
value: string;
onChange: (value: string) => void;
className?: string;
}
export function Dropdown({
options,
value,
onChange,
className = "w-60"
}: DropdownProps) {
return (
<Select
variant="bordered"
selectedKeys={[value]}
size="sm"
className={className}
onSelectionChange={(keys) => onChange(keys.currentKey as string)}
>
{options.map((option) => (
<SelectItem key={option.key}>
{option.label}
</SelectItem>
))}
</Select>
);
}

View file

@ -1,9 +1,13 @@
import { Button, Input, InputProps, Kbd, Textarea } from "@nextui-org/react";
import { Button, Input, InputProps, Kbd, Textarea } from "@heroui/react";
import { useEffect, useRef, useState } from "react";
import { useClickAway } from "@/hooks/use-click-away";
import MarkdownContent from "@/app/lib/components/markdown-content";
import { useClickAway } from "../../../hooks/use-click-away";
import MarkdownContent from "./markdown-content";
import clsx from "clsx";
import { Label } from "@/app/lib/components/label";
import { Label } from "./label";
import dynamic from "next/dynamic";
import { Match } from "./mentions_editor";
import { SparklesIcon } from "lucide-react";
const MentionsEditor = dynamic(() => import('./mentions_editor'), { ssr: false });
interface EditableFieldProps {
value: string;
@ -16,6 +20,16 @@ interface EditableFieldProps {
className?: string;
validate?: (value: string) => { valid: boolean; errorMessage?: string };
light?: boolean;
mentions?: boolean;
mentionsAtValues?: Match[];
showSaveButton?: boolean;
showDiscardButton?: boolean;
error?: string | null;
inline?: boolean;
showGenerateButton?: {
show: boolean;
setShow: (show: boolean) => void;
};
}
export function EditableField({
@ -26,9 +40,16 @@ export function EditableField({
markdown = false,
multiline = false,
locked = false,
className = "flex flex-col gap-1",
className = "flex flex-col gap-1 w-full",
validate,
light = false,
mentions = false,
mentionsAtValues = [],
showSaveButton = false,
showDiscardButton = false,
error,
inline = false,
showGenerateButton,
}: EditableFieldProps) {
const [isEditing, setIsEditing] = useState(false);
const [localValue, setLocalValue] = useState(value);
@ -59,7 +80,11 @@ export function EditableField({
variant: "bordered" as const,
labelPlacement: "outside" as const,
placeholder: markdown ? '' : placeholder,
radius: "sm" as const,
classNames: {
input: "rounded-md",
inputWrapper: "rounded-md border-medium"
},
radius: "md" as const,
isInvalid: !isValid,
errorMessage: validationResult?.errorMessage,
onKeyDown: (e: React.KeyboardEvent) => {
@ -86,73 +111,147 @@ export function EditableField({
},
};
if (isEditing) {
const hasChanges = localValue !== value;
return (
<div ref={ref} className={clsx("flex flex-col gap-1 w-full", className)}>
{label && (
<div className="flex justify-between items-center">
<Label label={label} />
<div className="flex gap-2 items-center">
{showGenerateButton && (
<Button
variant="light"
size="sm"
startContent={<SparklesIcon size={16} />}
onPress={() => showGenerateButton.setShow(true)}
>
Generate
</Button>
)}
{hasChanges && (
<>
{showDiscardButton && (
<Button
variant="light"
size="sm"
onPress={() => {
setLocalValue(value);
setIsEditing(false);
}}
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
Discard
</Button>
)}
{showSaveButton && (
<Button
color="primary"
size="sm"
onPress={() => {
if (isValid && localValue !== value) {
onChange(localValue);
}
setIsEditing(false);
}}
>
Save
</Button>
)}
</>
)}
</div>
</div>
)}
{mentions && (
<div className="w-full rounded-md border-2 border-default-300">
<MentionsEditor
atValues={mentionsAtValues}
value={value}
placeholder={placeholder}
onValueChange={setLocalValue}
/>
</div>
)}
{multiline && !mentions && <Textarea
{...commonProps}
minRows={3}
maxRows={20}
className="w-full"
classNames={{
...commonProps.classNames,
input: "rounded-md py-2",
inputWrapper: "rounded-md border-medium py-1"
}}
/>}
{!multiline && <Input
{...commonProps}
className="w-full"
classNames={{
...commonProps.classNames,
input: "rounded-md py-2",
inputWrapper: "rounded-md border-medium py-1"
}}
/>}
</div>
);
}
return (
<div ref={ref} className={clsx("flex flex-col gap-1", className)}>
{(label || isEditing && multiline) && <div className="flex items-center gap-2 justify-between">
{label && <Label label={label} />}
{isEditing && multiline && <div className="flex items-center gap-2">
<Button
size="sm"
variant="light"
onClick={() => {
setLocalValue(value);
setIsEditing(false);
}}
>
Cancel
</Button>
<Button
size="sm"
color="primary"
onClick={() => {
if (isValid && localValue !== value) {
onChange(localValue);
}
setIsEditing(false);
}}
>
Save
</Button>
</div>}
</div>}
{isEditing ? (
multiline ? (
<Textarea
{...commonProps}
minRows={3}
maxRows={20}
/>
) : (
<Input {...commonProps} />
)
) : (
<div
onClick={() => !locked && setIsEditing(true)}
className={clsx("text-sm px-2 py-1 rounded-md", {
"bg-blue-50": markdown && !locked,
"bg-gray-50": light,
"hover:bg-blue-50 cursor-pointer": light && !locked,
"hover:bg-gray-50 cursor-pointer": !light && !locked,
"cursor-default": locked,
})}
>
{value ? (<>
{markdown && <div className="max-h-[420px] overflow-y-auto">
<MarkdownContent content={value} />
</div>}
{!markdown && <div className={`${multiline ? 'whitespace-pre-wrap max-h-[420px] overflow-y-auto' : 'flex items-center'}`}>
{value}
</div>}
</>) : (
<>
{markdown && <div className="max-h-[420px] overflow-y-auto text-gray-400 italic">
<MarkdownContent content={placeholder} />
</div>}
{!markdown && <span className="text-gray-400 italic">{placeholder}</span>}
</>
<div ref={ref} className={clsx("cursor-text", className)}>
{label && (
<div className="flex justify-between items-center">
<Label label={label} />
{showGenerateButton && (
<Button
variant="light"
size="sm"
startContent={<SparklesIcon size={16} />}
onPress={() => showGenerateButton.setShow(true)}
>
Generate
</Button>
)}
</div>
)}
<div
className={clsx(
{
"border border-gray-300 dark:border-gray-600 rounded px-3 py-3": !inline,
"bg-transparent focus:outline-none focus:ring-0 border-0 rounded-none text-gray-900 dark:text-gray-100": inline,
}
)}
style={inline ? {
border: 'none',
borderRadius: '0',
padding: '0'
} : undefined}
onClick={() => !locked && setIsEditing(true)}
>
{value ? (
<>
{markdown && <div className="max-h-[420px] overflow-y-auto">
<MarkdownContent content={value} atValues={mentionsAtValues} />
</div>}
{!markdown && <div className={`${multiline ? 'whitespace-pre-wrap max-h-[420px] overflow-y-auto' : 'flex items-center'}`}>
<MarkdownContent content={value} atValues={mentionsAtValues} />
</div>}
</>
) : (
<>
{markdown && <div className="max-h-[420px] overflow-y-auto text-gray-400">
<MarkdownContent content={placeholder} atValues={mentionsAtValues} />
</div>}
{!markdown && <span className="text-gray-400">{placeholder}</span>}
</>
)}
{error && (
<div className="text-xs text-red-500 mt-1">
{error}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,22 @@
import { Divider } from "@heroui/react";
import { Label } from "./label";
export function FormSection({
label,
children,
showDivider = false,
}: {
label?: string;
children: React.ReactNode;
showDivider?: boolean;
}) {
return (
<>
<div className="flex flex-col gap-2">
{label && <Label label={label} />}
{children}
</div>
{showDivider && <Divider className="my-4" />}
</>
);
}

View file

@ -1,7 +1,7 @@
'use client';
import { useFormStatus } from "react-dom";
import { Button, ButtonProps } from "@nextui-org/react";
import { Button, ButtonProps } from "@heroui/react";
export function FormStatusButton({
props

View file

@ -1,7 +1,14 @@
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Match } from './mentions_editor';
export default function MarkdownContent({ content }: { content: string }) {
export default function MarkdownContent({
content,
atValues = []
}: {
content: string;
atValues?: Match[];
}) {
return <Markdown
className="overflow-auto break-words"
remarkPlugins={[remarkGfm]}
@ -49,8 +56,46 @@ export default function MarkdownContent({ content }: { content: string }) {
return <blockquote className='py-2 bg-gray-200 px-1'>{children}</blockquote>;
},
a(props) {
const { children, className, node, ...rest } = props
return <a className="inline-flex items-center gap-1" target="_blank" {...rest} >
const { children, href, className, node, ...rest } = props;
// If this is a mention link, render it with mention styling
if (href === '#mention') {
let label: string = '';
// Check if children is an array and get the first text element
if (Array.isArray(children) && children.length > 0) {
const text = children[0];
if (typeof text === 'string') {
const parts = text.split('@');
if (parts.length === 2) {
label = parts[1];
}
}
} else if (typeof children === 'string') {
// Fallback for direct string children
const parts = children.split('@');
if (parts.length === 2) {
label = parts[1];
}
}
// check if the the mention is valid
const invalid = !atValues.some(atValue => atValue.id === label);
if (atValues.length > 0 && invalid) {
return (
<span className="inline-block bg-[#e0f2fe] text-[red] px-1.5 py-0.5 rounded whitespace-nowrap">
@{label} (!)
</span>
);
}
return (
<span className="inline-block bg-[#e0f2fe] text-[#1e40af] px-1.5 py-0.5 rounded whitespace-nowrap">
@{label}
</span>
);
}
// Otherwise render normal link (your existing link component)
return <a className="inline-flex items-center gap-1" target="_blank" href={href} {...rest} >
<span className='underline'>
{children}
</span>

View file

@ -0,0 +1,36 @@
/* Target both edit mode and view mode mentions */
.mention,
.ql-editor p span[class*="bg-[#e"], /* Matches both #e8f2fe and #e0f2fe */
span[class*="bg-[#e"] { /* For view mode */
background-color: #e8f2fe !important;
color: #1e40af !important;
}
/* Dark mode overrides */
.dark .mention,
.dark .ql-editor p span[class*="bg-[#e"],
.dark span[class*="bg-[#e"] {
background-color: rgb(31 41 55) !important; /* bg-gray-800 */
color: rgb(243 244 246) !important; /* text-gray-100 */
}
/* Handle Next.js dark mode class if needed */
:global(.dark) .mention,
:global(.dark) .ql-editor p span[class*="bg-[#e"],
:global(.dark) span[class*="bg-[#e"] {
background-color: rgb(31 41 55) !important;
color: rgb(243 244 246) !important;
}
/* Override the inline styles */
.ql-editor p span[class*="bg-[#e0f2fe]"],
.ql-editor p span[class*="bg-[#e8f2fe]"] {
background-color: rgb(31 41 55) !important; /* bg-gray-800 */
color: rgb(243 244 246) !important; /* text-gray-100 */
}
/* Target our custom class */
.dark .mention-tag {
background-color: rgb(31 41 55) !important; /* bg-gray-800 */
color: rgb(243 244 246) !important; /* text-gray-100 */
}

View file

@ -0,0 +1,198 @@
"use client"
import { useEffect, useRef } from 'react';
import Quill, { Delta, Op } from 'quill';
import { Mention, MentionBlot, MentionBlotData } from "quill-mention";
import "quill/dist/quill.snow.css";
import "./mentions-editor.css";
import { CopyIcon } from 'lucide-react';
import { CopyButton } from './copy-button';
export type Match = {
id: string;
value: string;
invalid?: boolean;
[key: string]: string | boolean | undefined;
};
class CustomMentionBlot extends MentionBlot {
static render(data: any) {
const element = document.createElement('span');
element.className = data.invalid ? 'invalid' : '';
element.textContent = data.invalid ? `${data.value} (!)` : data.value;
return element;
}
}
Quill.register('blots/mention', CustomMentionBlot);
Quill.register('modules/mention', Mention);
function markdownToParts(markdown: string, atValues: Match[]): (string | Match)[] {
// Regex match for pattern [@type:name](#type:something) where type is tool/prompt/agent
const mentionRegex = /\[@(tool|prompt|agent):([^\]]+)\]\(#mention\)/g;
const parts: (string | Match)[] = [];
let lastIndex = 0;
let match;
// Find all matches and build the parts array
while ((match = mentionRegex.exec(markdown)) !== null) {
// Add text before the match if there is any
if (match.index > lastIndex) {
parts.push(markdown.slice(lastIndex, match.index));
}
// check if the match is valid
const matchValue = `${match[1]}:${match[2]}`;
const isInvalid = !atValues.some(atValue => atValue.id === matchValue);
// parse the match into a mention
parts.push({
id: `${match[1]}:${match[2]}`,
value: `${match[1]}:${match[2]}`,
invalid: isInvalid,
});
lastIndex = match.index + match[0].length;
}
// Add any remaining text after the last match
if (lastIndex < markdown.length) {
parts.push(markdown.slice(lastIndex));
}
return parts;
}
function insertPartsIntoQuill(quill: Quill, parts: (string | Match)[]) {
let index = 0;
for (const part of parts) {
if (typeof part === 'string') {
quill.insertText(index, part, Quill.sources.SILENT);
index += part.length;
} else {
quill.insertEmbed(index, 'mention', {
id: part.id,
value: part.value,
denotationChar: '@',
invalid: part.invalid,
}, Quill.sources.SILENT);
index += 1;
}
}
}
export default function MentionEditor({
atValues,
value,
placeholder,
onValueChange,
}: {
atValues: Match[];
value: string;
placeholder?: string;
onValueChange?: (value: string) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const quillRef = useRef<Quill | null>(null);
function getMarkdown(): string {
if (!quillRef.current) {
return "";
}
// generate markdown representation of content
const markdown = quillRef.current.getContents().map((op) => {
if (op.insert && typeof op.insert === 'object' && 'mention' in op.insert) {
const mentionOp = op.insert as { mention: Match };
return `[@${mentionOp.mention.id}](#mention)`;
}
return op.insert;
}).join('');
return markdown;
}
function copyHandler() {
if (!quillRef.current) {
return;
}
navigator.clipboard.writeText(getMarkdown());
}
useEffect(() => {
if (!containerRef.current) {
return;
}
function load() {
if (!containerRef.current) {
return;
}
const quill = new Quill(containerRef.current, {
theme: 'snow',
formats: ["mention"],
placeholder,
modules: {
toolbar: false,
mention: {
allowedChars: /^[A-Za-z0-9_]*$/,
mentionDenotationChars: ["@"],
showDenotationChar: true,
source: async function (searchTerm: string, renderList: (values: Match[], searchTerm: string) => void) {
if (searchTerm.length === 0) {
renderList(atValues, searchTerm);
} else {
const matches = [];
for (let i = 0; i < atValues.length; i++) {
if (
atValues[i].value.toLowerCase().indexOf(searchTerm.toLowerCase()) !== -1
) {
matches.push(atValues[i]);
}
}
renderList(matches, searchTerm);
}
},
renderItem: (item: Match) => {
const div = document.createElement('div');
div.className = "px-2 py-1 bg-white text-blue-800 hover:bg-blue-100 cursor-pointer";
div.textContent = item.id;
return div;
},
}
}
});
// clear the quill contents
quill.setContents([]);
// convert the markdown to parts
const parts = markdownToParts(value, atValues);
insertPartsIntoQuill(quill, parts);
quill.on(Quill.events.TEXT_CHANGE, (delta: Delta, oldDelta: Delta, source: string) => {
if (onValueChange) {
onValueChange(getMarkdown());
}
});
quillRef.current = quill;
}
load();
return () => {
if (quillRef.current) {
quillRef.current.off(Quill.events.TEXT_CHANGE);
}
}
}, [atValues, onValueChange, placeholder, value]);
return <div className="relative">
<button className="absolute top-2 right-2 z-10">
<CopyButton
onCopy={copyHandler}
label="Copy"
successLabel="Copied!"
/>
</button>
<div ref={containerRef} />
</div>;
}

View file

@ -0,0 +1,31 @@
import React from 'react';
import clsx from 'clsx';
interface MenuItemProps {
icon: React.ReactNode;
children: React.ReactNode;
selected: boolean;
onClick: () => void;
}
const MenuItem: React.FC<MenuItemProps> = ({ icon, children, selected, onClick }) => {
return (
<button
className={clsx(
"w-full flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors",
"hover:bg-gray-100 dark:hover:bg-gray-800",
{
"bg-gray-100 dark:bg-gray-800": selected,
"text-gray-600 dark:text-gray-400": !selected,
"text-gray-900 dark:text-gray-100": selected,
}
)}
onClick={onClick}
>
{icon}
{children}
</button>
);
};
export default MenuItem;

View file

@ -1,6 +1,6 @@
'use client';
import { Pagination as NextUiPagination } from "@nextui-org/react";
import { Pagination as NextUiPagination } from "@heroui/react";
import { usePathname, useRouter } from "next/navigation";
export function Pagination({

View file

@ -0,0 +1,100 @@
import { WithStringId } from "@/app/lib/types/types";
import { TestProfile } from "@/app/lib/types/testing_types";
import { useCallback, useEffect, useState } from "react";
import { listProfiles } from "@/app/actions/testing_actions";
import { Button, Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { z } from "zod";
interface ProfileSelectorProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (profile: WithStringId<z.infer<typeof TestProfile>>) => void;
}
export function ProfileSelector({ projectId, isOpen, onOpenChange, onSelect }: ProfileSelectorProps) {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [profiles, setProfiles] = useState<WithStringId<z.infer<typeof TestProfile>>[]>([]);
const [totalPages, setTotalPages] = useState(0);
const pageSize = 10;
const fetchProfiles = useCallback(async (page: number) => {
setLoading(true);
setError(null);
try {
const result = await listProfiles(projectId, page, pageSize);
setProfiles(result.profiles);
setTotalPages(Math.ceil(result.total / pageSize));
} catch (error) {
setError(`Unable to fetch profiles: ${error}`);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
if (isOpen) {
fetchProfiles(page);
}
}, [page, isOpen, fetchProfiles]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Select a Profile</ModalHeader>
<ModalBody>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => fetchProfiles(page)}>Retry</Button>
</div>}
{!loading && !error && <>
{profiles.length === 0 && <div className="text-gray-600 text-center">No profiles found</div>}
{profiles.length > 0 && <div className="flex flex-col w-full">
<div className="grid grid-cols-6 py-2 bg-gray-100 font-semibold text-sm">
<div className="col-span-2 px-4">Name</div>
<div className="col-span-3 px-4">Context</div>
<div className="col-span-1 px-4">Mock Tools</div>
</div>
{profiles.map((p) => (
<div
key={p._id}
className="grid grid-cols-6 py-2 border-b hover:bg-gray-50 text-sm cursor-pointer"
onClick={() => {
onSelect(p);
onClose();
}}
>
<div className="col-span-2 px-4 truncate">{p.name}</div>
<div className="col-span-3 px-4 truncate">{p.context}</div>
<div className="col-span-1 px-4">{p.mockTools ? "Yes" : "No"}</div>
</div>
))}
</div>}
{totalPages > 1 && <Pagination
total={totalPages}
page={page}
onChange={setPage}
className="self-center"
/>}
</>}
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}

View file

@ -0,0 +1,98 @@
import { WithStringId } from "@/app/lib/types/types";
import { TestScenario } from "@/app/lib/types/testing_types";
import { useCallback, useEffect, useState } from "react";
import { listScenarios } from "@/app/actions/testing_actions";
import { Button, Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { z } from "zod";
interface ScenarioSelectorProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (scenario: WithStringId<z.infer<typeof TestScenario>>) => void;
}
export function ScenarioSelector({ projectId, isOpen, onOpenChange, onSelect }: ScenarioSelectorProps) {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [scenarios, setScenarios] = useState<WithStringId<z.infer<typeof TestScenario>>[]>([]);
const [totalPages, setTotalPages] = useState(0);
const pageSize = 10;
const fetchScenarios = useCallback(async (page: number) => {
setLoading(true);
setError(null);
try {
const result = await listScenarios(projectId, page, pageSize);
setScenarios(result.scenarios);
setTotalPages(Math.ceil(result.total / pageSize));
} catch (error) {
setError(`Unable to fetch scenarios: ${error}`);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
if (isOpen) {
fetchScenarios(page);
}
}, [page, isOpen, fetchScenarios]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Select a Scenario</ModalHeader>
<ModalBody>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => fetchScenarios(page)}>Retry</Button>
</div>}
{!loading && !error && <>
{scenarios.length === 0 && <div className="text-gray-600 text-center">No scenarios found</div>}
{scenarios.length > 0 && <div className="flex flex-col w-full">
<div className="grid grid-cols-5 py-2 bg-gray-100 font-semibold text-sm">
<div className="col-span-2 px-4">Name</div>
<div className="col-span-3 px-4">Description</div>
</div>
{scenarios.map((s) => (
<div
key={s._id}
className="grid grid-cols-5 py-2 border-b hover:bg-gray-50 text-sm cursor-pointer"
onClick={() => {
onSelect(s);
onClose();
}}
>
<div className="col-span-2 px-4 truncate">{s.name}</div>
<div className="col-span-3 px-4 truncate">{s.description}</div>
</div>
))}
</div>}
{totalPages > 1 && <Pagination
total={totalPages}
page={page}
onChange={setPage}
className="self-center"
/>}
</>}
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}

View file

@ -0,0 +1,140 @@
import { WithStringId } from "@/app/lib/types/types";
import { TestSimulation } from "@/app/lib/types/testing_types";
import { useCallback, useEffect, useState } from "react";
import { listSimulations } from "@/app/actions/testing_actions";
import { Button, Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Chip } from "@heroui/react";
import { z } from "zod";
import { RelativeTime } from "@primer/react";
interface SimulationSelectorProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (simulations: WithStringId<z.infer<typeof TestSimulation>>[]) => void;
initialSelected?: WithStringId<z.infer<typeof TestSimulation>>[];
}
export function SimulationSelector({ projectId, isOpen, onOpenChange, onSelect, initialSelected = [] }: SimulationSelectorProps) {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [simulations, setSimulations] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>([]);
const [totalPages, setTotalPages] = useState(0);
const [selectedSimulations, setSelectedSimulations] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>(initialSelected);
const pageSize = 3;
const fetchSimulations = useCallback(async (page: number) => {
setLoading(true);
setError(null);
try {
const result = await listSimulations(projectId, page, pageSize);
setSimulations(result.simulations);
setTotalPages(Math.ceil(result.total / pageSize));
} catch (error) {
setError(`Unable to fetch simulations: ${error}`);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
if (isOpen) {
fetchSimulations(page);
}
}, [page, isOpen, fetchSimulations]);
const handleSelect = (simulation: WithStringId<z.infer<typeof TestSimulation>>) => {
const isSelected = selectedSimulations.some(s => s._id === simulation._id);
let newSelected;
if (isSelected) {
newSelected = selectedSimulations.filter(s => s._id !== simulation._id);
} else {
newSelected = [...selectedSimulations, simulation];
}
setSelectedSimulations(newSelected);
onSelect(newSelected);
};
const handleRemove = (simulationId: string) => {
const newSelected = selectedSimulations.filter(s => s._id !== simulationId);
setSelectedSimulations(newSelected);
onSelect(newSelected);
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Select Simulations</ModalHeader>
<ModalBody>
{selectedSimulations.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{selectedSimulations.map((sim) => (
<Chip
key={sim._id}
onClose={() => handleRemove(sim._id)}
variant="flat"
className="py-1"
>
{sim.name}
</Chip>
))}
</div>
)}
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => fetchSimulations(page)}>Retry</Button>
</div>}
{!loading && !error && <>
{simulations.length === 0 && <div className="text-gray-600 text-center">No simulations found</div>}
{simulations.length > 0 && <div className="flex flex-col w-full">
<div className="grid grid-cols-8 py-2 bg-gray-100 font-semibold text-sm">
<div className="col-span-3 px-4">Name</div>
<div className="col-span-3 px-4">Pass Criteria</div>
<div className="col-span-2 px-4">Last Updated</div>
</div>
{simulations.map((sim) => {
const isSelected = selectedSimulations.some(s => s._id === sim._id);
return (
<div
key={sim._id}
className={`grid grid-cols-8 py-2 border-b hover:bg-gray-50 text-sm cursor-pointer ${
isSelected ? 'bg-blue-50 hover:bg-blue-100' : ''
}`}
onClick={() => handleSelect(sim)}
>
<div className="col-span-3 px-4 truncate">{sim.name}</div>
<div className="col-span-3 px-4 truncate">{sim.passCriteria || '-'}</div>
<div className="col-span-2 px-4 truncate">
<RelativeTime date={new Date(sim.lastUpdatedAt)} />
</div>
</div>
);
})}
</div>}
{totalPages > 1 && <Pagination
total={totalPages}
page={page}
onChange={setPage}
className="self-center"
/>}
</>}
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Done
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}

View file

@ -0,0 +1,106 @@
import { WithStringId } from "@/app/lib/types/types";
import { Workflow } from "@/app/lib/types/workflow_types";
import { useCallback, useEffect, useState } from "react";
import { listWorkflows } from "@/app/actions/workflow_actions";
import { Button, Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { z } from "zod";
import { RelativeTime } from "@primer/react";
import { WorkflowIcon } from "../icons";
import { PublishedBadge } from "@/app/projects/[projectId]/workflow/published_badge";
interface WorkflowSelectorProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (workflow: WithStringId<z.infer<typeof Workflow>>) => void;
}
export function WorkflowSelector({ projectId, isOpen, onOpenChange, onSelect }: WorkflowSelectorProps) {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [workflows, setWorkflows] = useState<WithStringId<z.infer<typeof Workflow>>[]>([]);
const [totalPages, setTotalPages] = useState(0);
const [publishedWorkflowId, setPublishedWorkflowId] = useState<string | null>(null);
const pageSize = 10;
const fetchWorkflows = useCallback(async (page: number) => {
setLoading(true);
setError(null);
try {
const result = await listWorkflows(projectId, page, pageSize);
setWorkflows(result.workflows);
setTotalPages(Math.ceil(result.total / pageSize));
setPublishedWorkflowId(result.publishedWorkflowId);
} catch (error) {
setError(`Unable to fetch workflows: ${error}`);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
if (isOpen) {
fetchWorkflows(page);
}
}, [page, isOpen, fetchWorkflows]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Select a Workflow</ModalHeader>
<ModalBody>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => fetchWorkflows(page)}>Retry</Button>
</div>}
{!loading && !error && <>
{workflows.length === 0 && <div className="text-gray-600 text-center">No workflows found</div>}
{workflows.length > 0 && <div className="flex flex-col gap-2">
{workflows.map((workflow) => (
<div
key={workflow._id}
className="flex items-center justify-between p-3 border rounded-md hover:bg-gray-50 cursor-pointer"
onClick={() => {
onSelect(workflow);
onClose();
}}
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<WorkflowIcon />
<span className="font-medium">{workflow.name || 'Unnamed workflow'}</span>
{publishedWorkflowId === workflow._id && <PublishedBadge />}
</div>
<div className="text-xs text-gray-500">
Updated <RelativeTime date={new Date(workflow.lastUpdatedAt)} />
</div>
</div>
</div>
))}
</div>}
{totalPages > 1 && <Pagination
total={totalPages}
page={page}
onChange={setPage}
className="self-center"
/>}
</>}
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}

View file

@ -0,0 +1,50 @@
import clsx from "clsx";
import { ActionButton } from "./structured-panel";
export function SectionHeader({ title, onAdd }: { title: string; onAdd: () => void }) {
return (
<div className="flex items-center justify-between px-2 py-1 mt-4 first:mt-0 border-b border-gray-200 dark:border-gray-600">
<div className="text-xs font-semibold text-gray-400 dark:text-gray-300 uppercase">{title}</div>
<ActionButton
icon={<svg className="w-4 h-4" 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="M5 12h14m-7 7V5" />
</svg>}
onClick={onAdd}
>
Add
</ActionButton>
</div>
);
}
export function ListItem({
name,
isSelected,
onClick,
disabled,
rightElement,
selectedRef
}: {
name: string;
isSelected: boolean;
onClick: () => void;
disabled?: boolean;
rightElement?: React.ReactNode;
selectedRef?: React.RefObject<HTMLButtonElement>;
}) {
return (
<button
ref={selectedRef as any}
onClick={onClick}
className={clsx("flex items-center justify-between rounded-md px-2 py-1", {
"bg-gray-100 dark:bg-gray-700": isSelected,
"hover:bg-gray-50 dark:hover:bg-gray-800": !isSelected,
})}
>
<div className={clsx("truncate text-sm dark:text-gray-200", {
"text-gray-400 dark:text-gray-500": disabled,
})}>{name}</div>
{rightElement}
</button>
);
}

View file

@ -0,0 +1,82 @@
import clsx from "clsx";
import { InfoIcon } from "lucide-react";
import { Tooltip } from "@heroui/react";
export function ActionButton({
icon = null,
children,
onClick = undefined,
disabled = false,
primary = false,
}: {
icon?: React.ReactNode;
children: React.ReactNode;
onClick?: () => void | undefined;
disabled?: boolean;
primary?: boolean;
}) {
const onClickProp = onClick ? { onClick } : {};
return <button
disabled={disabled}
className={clsx("rounded-md text-xs flex items-center gap-1 disabled:text-gray-300 dark:disabled:text-gray-600 hover:text-gray-600 dark:hover:text-gray-300", {
"text-blue-600 dark:text-blue-400": primary,
"text-gray-400 dark:text-gray-500": !primary,
})}
{...onClickProp}
>
{icon}
{children}
</button>;
}
export function StructuredPanel({
title,
actions = null,
children,
fancy = false,
tooltip = null,
}: {
title: React.ReactNode;
actions?: React.ReactNode[] | null;
children: React.ReactNode;
fancy?: boolean;
tooltip?: string | null;
}) {
return <div className={clsx("h-full flex flex-col overflow-auto rounded-md p-1", {
"bg-gray-100 dark:bg-gray-800": !fancy,
"bg-blue-100 dark:bg-blue-900": fancy,
})}>
<div className="shrink-0 flex justify-between items-center gap-2 px-2 py-1 rounded-t-sm">
<div className="flex items-center gap-1">
<div className={clsx("text-xs font-semibold uppercase", {
"text-gray-400 dark:text-gray-500": !fancy,
"text-blue-500 dark:text-blue-400": fancy,
})}>
{title}
</div>
{tooltip && (
<Tooltip
content={tooltip}
placement="right"
className="cursor-help"
>
<InfoIcon size={12} className={clsx({
"text-gray-400 dark:text-gray-500": !fancy,
"text-blue-500 dark:text-blue-400": fancy,
})} />
</Tooltip>
)}
</div>
{!actions && <div className="w-4 h-4" />}
{actions && <div className={clsx("rounded-md hover:text-gray-800 dark:hover:text-gray-200 px-2 text-sm flex items-center gap-2", {
"text-blue-600 dark:text-blue-400": fancy,
"text-gray-400 dark:text-gray-500": !fancy,
})}>
{actions}
</div>}
</div>
<div className="grow bg-white dark:bg-gray-900 rounded-md overflow-auto flex flex-col justify-start p-2">
{children}
</div>
</div>;
}

View file

@ -0,0 +1,21 @@
'use client'
import { Moon, Sun } from "lucide-react"
import { useTheme } from "@/app/providers/theme-provider"
import { Button } from "@heroui/react"
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
return (
<Button
variant="light"
isIconOnly
onPress={toggleTheme}
aria-label="Toggle theme"
className="text-foreground"
>
{theme === 'light' ? <Moon size={20} /> : <Sun size={20} />}
</Button>
)
}

View file

@ -1,6 +1,6 @@
'use client';
import { useUser } from '@auth0/nextjs-auth0/client';
import { Avatar, Dropdown, DropdownItem, DropdownSection, DropdownTrigger, DropdownMenu } from '@nextui-org/react';
import { Avatar, Dropdown, DropdownItem, DropdownSection, DropdownTrigger, DropdownMenu } from "@heroui/react";
import { useRouter } from 'next/navigation';
export function UserButton() {

View file

@ -0,0 +1,4 @@
export const USE_RAG = process.env.USE_RAG === 'true';
export const USE_RAG_UPLOADS = process.env.USE_RAG_UPLOADS === 'true';
export const USE_RAG_SCRAPING = process.env.USE_RAG_SCRAPING === 'true';
export const USE_CHAT_WIDGET = process.env.USE_CHAT_WIDGET === 'true';

View file

@ -1,15 +1,31 @@
import { MongoClient } from "mongodb";
import { PlaygroundChat, DataSource, EmbeddingDoc, Project, Webpage, ChatClientId, Workflow, Scenario, ProjectMember, ApiKey } from "./types";
import { Webpage } from "./types/types";
import { Workflow } from "./types/workflow_types";
import { ApiKey } from "./types/project_types";
import { ProjectMember } from "./types/project_types";
import { Project } from "./types/project_types";
import { EmbeddingDoc } from "./types/datasource_types";
import { DataSourceDoc } from "./types/datasource_types";
import { DataSource } from "./types/datasource_types";
import { TestScenario, TestResult, TestRun, TestProfile, TestSimulation } from "./types/testing_types";
import { z } from 'zod';
import { apiV1 } from "rowboat-shared";
const client = new MongoClient(process.env["MONGODB_CONNECTION_STRING"] || "mongodb://localhost:27017");
export const db = client.db("rowboat");
export const dataSourcesCollection = db.collection<z.infer<typeof DataSource>>("sources");
export const dataSourceDocsCollection = db.collection<z.infer<typeof DataSourceDoc>>("source_docs");
export const embeddingsCollection = db.collection<z.infer<typeof EmbeddingDoc>>("embeddings");
export const projectsCollection = db.collection<z.infer<typeof Project>>("projects");
export const projectMembersCollection = db.collection<z.infer<typeof ProjectMember>>("project_members");
export const webpagesCollection = db.collection<z.infer<typeof Webpage>>('webpages');
export const agentWorkflowsCollection = db.collection<z.infer<typeof Workflow>>("agent_workflows");
export const scenariosCollection = db.collection<z.infer<typeof Scenario>>("scenarios");
export const apiKeysCollection = db.collection<z.infer<typeof ApiKey>>("api_keys");
export const apiKeysCollection = db.collection<z.infer<typeof ApiKey>>("api_keys");
export const testScenariosCollection = db.collection<z.infer<typeof TestScenario>>("test_scenarios");
export const testProfilesCollection = db.collection<z.infer<typeof TestProfile>>("test_profiles");
export const testSimulationsCollection = db.collection<z.infer<typeof TestSimulation>>("test_simulations");
export const testRunsCollection = db.collection<z.infer<typeof TestRun>>("test_runs");
export const testResultsCollection = db.collection<z.infer<typeof TestResult>>("test_results");
export const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages");

View file

@ -1,4 +1,4 @@
import { WorkflowTemplate } from './types';
import { WorkflowTemplate } from "./types/workflow_types";
import { z } from 'zod';
export const templates: { [key: string]: z.infer<typeof WorkflowTemplate> } = {
@ -37,13 +37,10 @@ You are an helpful customer support assistant
Don'ts:
- don't ask user any other detail than email`,
prompts: [],
tools: [],
model: "gpt-4o-mini",
toggleAble: true,
ragReturnType: "chunks",
ragK: 3,
connectedAgents: [],
controlType: "retain",
},
{
@ -51,14 +48,12 @@ You are an helpful customer support assistant
type: "post_process",
description: "",
instructions: "Ensure that the agent response is terse and to the point.",
prompts: [],
tools: [],
model: "gpt-4o-mini",
toggleAble: true,
locked: true,
global: true,
ragReturnType: "chunks",
ragK: 3,
connectedAgents: [],
controlType: "retain",
},
{
@ -66,14 +61,11 @@ You are an helpful customer support assistant
type: "escalation",
description: "",
instructions: "Get the user's contact information and let them know that their request has been escalated.",
prompts: [],
tools: [],
model: "gpt-4o-mini",
locked: true,
toggleAble: false,
ragReturnType: "chunks",
ragK: 3,
connectedAgents: [],
controlType: "retain",
},
],
@ -92,107 +84,93 @@ You are an helpful customer support assistant
"name": "Example Single Agent",
"description": "With tool calls and escalation.",
"startAgent": "Account Balance Checker",
"agents": [
{
"agents": [
{
"name": "Post process",
"type": "post_process",
"description": "Minimal post processing",
"instructions": "- Avoid adding any additional phrases such as 'Let me know if you need anything else!' or similar.",
"prompts": [],
"tools": [],
"model": "gpt-4o",
"toggleAble": true,
"locked": true,
"global": true,
"ragReturnType": "chunks",
"ragK": 3,
"connectedAgents": [],
"controlType": "relinquish_to_parent"
},
{
},
{
"name": "Escalation",
"type": "escalation",
"description": "Escalation agent",
"instructions": "## 🧑‍💼 Role:\nHandle scenarios where the system needs to escalate a request to a human representative.\n\n---\n## ⚙️ Steps to Follow:\n1. Inform the user that their details are being escalated to a human agent.\n2. Call the 'close_chat' tool to close the chat session.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Escalating issues to human agents\n- Closing chat sessions\n\n❌ Out of Scope:\n- Handling queries that do not require escalation\n- Providing solutions without escalation\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Clearly inform the user about the escalation.\n- Ensure the chat is closed after escalation.\n\n🚫 Don'ts:\n- Attempt to resolve issues without escalation.\n- Leave the chat open after informing the user about escalation.",
"prompts": [],
"tools": [
"close_chat"
],
"instructions": "## 🧑‍💼 Role:\nHandle scenarios where the system needs to escalate a request to a human representative.\n\n---\n## ⚙️ Steps to Follow:\n1. Inform the user that their details are being escalated to a human agent.\n2. Call [@tool:close_chat](#mention) to close the chat session.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Escalating issues to human agents\n- Closing chat sessions\n\n❌ Out of Scope:\n- Handling queries that do not require escalation\n- Providing solutions without escalation\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Clearly inform the user about the escalation.\n- Ensure the chat is closed after escalation.\n\n🚫 Don'ts:\n- Attempt to resolve issues without escalation.\n- Leave the chat open after informing the user about escalation.\n",
"model": "gpt-4o",
"locked": true,
"toggleAble": false,
"ragReturnType": "chunks",
"ragK": 3,
"connectedAgents": [],
"controlType": "retain",
"examples": "- **User** : I need help with something urgent.\n - **Agent response**: Your request is being escalated to a human agent.\n - **Agent actions**: Call close_chat\n\n- **User** : Can you escalate this issue?\n - **Agent response**: Your details are being escalated to a human agent.\n - **Agent actions**: Call close_chat"
},
{
"examples": "- **User** : I need help with something urgent.\n - **Agent response**: Your request is being escalated to a human agent.\n - **Agent actions**: Call [@tool:close_chat](#mention)\n\n- **User** : Can you escalate this issue?\n - **Agent response**: Your details are being escalated to a human agent.\n - **Agent actions**: Call [@tool:close_chat](#mention)"
},
{
"name": "Account Balance Checker",
"type": "conversation",
"description": "Agent to check the user's account balance.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nAssist users in checking their account balance.\n\n---\n## ⚙️ Steps to Follow:\n1. Greet them with 'Hello, welcome to RowBoat Bank.'\n2. If the user hasn't provided their request yet, ask 'How may I help you today?'\n3. If the request is related to checking account balance, proceed with the following steps:\n - Ask the user to confirm the last 4 digits of their debit card.\n - Use the 'get_account_balance' tool to fetch the account balance.\n - Inform the user of their account balance based on the output of 'get_account_balance'\n4. If the user requests to talk to a human, call the 'Escalation' agent.\n5. If the request is not related to checking account balance, inform the user: 'Sorry, I can only help you with account balance.'\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Fetching and providing account balance\n- Escalating to human agents upon request\n\n❌ Out of Scope:\n- Handling queries unrelated to account balance\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Always call get_account_balance to fetch the user's account balance\n- Be clear and concise in communication.\n- Call the Escalation agent if the user requests to speak with a human.\n\n🚫 Don'ts:\n- Extend the conversation beyond account balance checking.",
"prompts": [],
"tools": [
"get_account_balance"
],
"instructions": "## 🧑‍💼 Role:\nAssist users in checking their account balance.\n\n---\n## ⚙️ Steps to Follow:\n1. Greet them with 'Hello, welcome to RowBoat Bank.'\n2. If the user hasn't provided their request yet, ask 'How may I help you today?'\n3. If the request is related to checking account balance, proceed with the following steps:\n - Ask the user to confirm the last 4 digits of their debit card.\n - Use [@tool:get_account_balance](#mention) to fetch the account balance.\n - Inform the user of their account balance based on the output of [@tool:get_account_balance](#mention) \n4. If the user requests to talk to a human, call [@agent:Escalation](#mention) .\n5. If the request is not related to checking account balance, inform the user: 'Sorry, I can only help you with account balance.'\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Fetching and providing account balance\n- Escalating to human agents upon request\n\n❌ Out of Scope:\n- Handling queries unrelated to account balance\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Always call get_account_balance to fetch the user's account balance\n- Be clear and concise in communication.\n- Call the Escalation agent if the user requests to speak with a human.\n\n🚫 Don'ts:\n- Extend the conversation beyond account balance checking.\n",
"model": "gpt-4o",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"connectedAgents": [
"Escalation"
],
"controlType": "retain",
"examples": "- **User** : Can you tell me my account balance?\n - **Agent response**: Sure, can you confirm the last 4 digits of your debit card?\n - **Agent actions**: Call get_account_balance\n\n- **User** : The last 4 digits are 1234.\n - **Agent actions**: Call get_account_balance\n - **Agent response**: Your account balance is $500.\n\n- **User** : What's my balance?\n - **Agent actions**: Call get_account_balance\n - **Agent response**: Your account balance is $500.\n\n- **User** : I need help with my credit card.\n - **Agent response**: Sorry, I can only help you with account balance.\n\n- **User** : I need to know my balance, please.\n - **Agent response**: Of course, please provide the last 4 digits of your debit card.\n - **Agent actions**: Call get_account_balance\n\n- **User** : How much money do I have in my account?\n - **Agent response**: Please confirm the last 4 digits of your debit card to proceed.\n - **Agent actions**: Call get_account_balance\n\n- **User** : My card ends in 5678.\n - **Agent actions**: Call get_account_balance\n - **Agent response**: Your account balance is $500.\n\n- **User** : Can you check my balance?\n - **Agent response**: Certainly, could you provide the last 4 digits of your debit card?\n - **Agent actions**: Call get_account_balance\n\n- **User** : I want to know my account balance.\n - **Agent response**: Sure, please confirm the last 4 digits of your debit card.\n - **Agent actions**: Call get_account_balance\n\n- **User** : The last 4 digits are 9876.\n - **Agent actions**: Call get_account_balance\n - **Agent response**: Your account balance is $500.\n\n- **User** : Hi\n - **Agent response**: Hello, welcome to RowBoat Bank. How may I help you today?\n\n- **User** : Hi, I want to check my balance\n - **Agent response**: Hello, welcome to RowBoat Bank. Sure, can you confirm the last 4 digits of your debit card?\n - **Agent actions**: Call get_account_balance\n\n- **User** : Who are you?\n - **Agent response**: I'm a friendly RowBoat Bank assistant, how can I help you today?\n\n- **User** : Who built you?\n - **Agent response**: I was created by the RowBoat Bank team to assist you with your banking needs. How may I help you today?\n\n- **User** : How are you?\n - **Agent response**: I'm here to help you with your banking queries. How can I assist you today?\n\n- **User** : I want to talk to a human.\n - **Agent actions**: Call Escalation\n - **Agent response**: Your request is being escalated to a human agent."
}
],
"prompts": [
{
"examples": "- **User** : Can you tell me my account balance?\n - **Agent response**: Sure, can you confirm the last 4 digits of your debit card?\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n\n- **User** : The last 4 digits are 1234.\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n - **Agent response**: Your account balance is $500.\n\n- **User** : What's my balance?\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n - **Agent response**: Your account balance is $500.\n\n- **User** : I need help with my credit card.\n - **Agent response**: Sorry, I can only help you with account balance.\n\n- **User** : I need to know my balance, please.\n - **Agent response**: Of course, please provide the last 4 digits of your debit card.\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n\n- **User** : How much money do I have in my account?\n - **Agent response**: Please confirm the last 4 digits of your debit card to proceed.\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n\n- **User** : My card ends in 5678.\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n - **Agent response**: Your account balance is $500.\n\n- **User** : Can you check my balance?\n - **Agent response**: Certainly, could you provide the last 4 digits of your debit card?\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n\n- **User** : I want to know my account balance.\n - **Agent response**: Sure, please confirm the last 4 digits of your debit card.\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n\n- **User** : The last 4 digits are 9876.\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n - **Agent response**: Your account balance is $500.\n\n- **User** : Hi\n - **Agent response**: Hello, welcome to RowBoat Bank. How may I help you today?\n\n- **User** : Hi, I want to check my balance\n - **Agent response**: Hello, welcome to RowBoat Bank. Sure, can you confirm the last 4 digits of your debit card?\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n\n- **User** : Who are you?\n - **Agent response**: I'm a friendly RowBoat Bank assistant, how can I help you today?\n\n- **User** : Who built you?\n - **Agent response**: I was created by the RowBoat Bank team to assist you with your banking needs. How may I help you today?\n\n- **User** : How are you?\n - **Agent response**: I'm here to help you with your banking queries. How can I assist you today?\n\n- **User** : I want to talk to a human.\n - **Agent actions**: Call [@agent:Escalation](#mention)\n - **Agent response**: Your request is being escalated to a human agent."
}
],
"prompts": [
{
"name": "Style prompt",
"type": "style_prompt",
"prompt": "You should be empathetic and helpful."
}
],
"tools": [
{
}
],
"tools": [
{
"name": "get_account_balance",
"description": "Return account balance typically around $15000 for the user.",
"parameters": {
"type": "object",
"properties": {
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "The unique identifier for the user whose account balance is being queried."
"type": "string",
"description": "The unique identifier for the user whose account balance is being queried."
}
},
"required": [
"user_id"
]
},
"mockInPlayground": true,
"autoSubmitMockedResponse": true
},
{
},
"required": [
"user_id"
]
},
"mockTool": true,
"autoSubmitMockedResponse": true
},
{
"name": "close_chat",
"description": "return 'The chat is now closed'",
"parameters": {
"type": "object",
"properties": {
"param1": {
"type": "string",
"description": ""
}
},
"required": [
"param1"
]
"type": "object",
"properties": {
"param1": {
"type": "string",
"description": ""
}
},
"required": [
"param1"
]
},
"mockInPlayground": true,
"autoSubmitMockedResponse": true
}
]
"mockTool": true,
"autoSubmitMockedResponse": true
}
],
},
// Scooter Subscription
@ -205,96 +183,60 @@ You are an helpful customer support assistant
"name": "Main agent",
"type": "conversation",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou are a customer support agent for ScootUp scooters. Your main responsibility is to orchestrate conversations and delegate them to specialized worker agents for efficient query handling.\n\n---\n## ⚙️ Steps to Follow:\n1. Engage in basic small talk to build rapport. Stick to the specified examples for such interactions.\n2. When a specific query arises, pass control to the relevant worker agent immediately.\n3. For follow-up questions on the same topic, direct them back to the same worker agent who handled the initial query.\n4. If the query is out-of-scope, call the Escalation agent.\n\n---\n## 🎯 Scope:\n\n✅ In Scope:\n- Initial query handling and passing control to specific agents\n\n❌ Out of Scope:\n- Detailed product or service resolutions\n- Technical troubleshooting or detailed assistance beyond initial query reading\n- General knowledge related questions\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Ensure smooth conversational flow while transferring queries to respective agents.\n- Engage only in light rapport-building or disambiguating discussions.\n\n🚫 Don'ts:\n- Avoid engaging in detailed support discussions or troubleshooting at length.\n- Do not address queries beyond initial understanding beyond relaying them to appropriate agents.\n- Do not answer out-of-scope questions; instead, direct them to the Escalation agent.\n- Do not talk about other agents or about transferring to them.",
"prompts": [
"self_support_prompt",
"Style prompt"
],
"tools": [],
"instructions": "## 🧑‍💼 Role:\nYou are a customer support agent for ScootUp scooters. Your main responsibility is to orchestrate conversations and delegate them to specialized worker agents for efficient query handling.\n\n---\n## ⚙️ Steps to Follow:\n1. Engage in basic small talk to build rapport. Stick to the specified examples for such interactions.\n2. When a specific query arises, pass control to the relevant worker agent immediately, such as [@agent:Product info agent](#mention) or [@agent:Delivery info agent](#mention) .\n3. For follow-up questions on the same topic, direct them back to the same worker agent who handled the initial query.\n4. If the query is out-of-scope, call [@agent:Escalation agent](#mention)\n\n---\n## 🎯 Scope:\n\n✅ In Scope:\n- Initial query handling and passing control to specific agents\n\n❌ Out of Scope:\n- Detailed product or service resolutions\n- Technical troubleshooting or detailed assistance beyond initial query reading\n- General knowledge related questions\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Ensure smooth conversational flow while transferring queries to respective agents.\n- Engage only in light rapport-building or disambiguating discussions.\n\n🚫 Don'ts:\n- Avoid engaging in detailed support discussions or troubleshooting at length.\n- Do not address queries beyond initial understanding beyond relaying them to appropriate agents.\n- Do not answer out-of-scope questions; instead, direct them to the [@agent:Escalation agent](#mention) .\n- Do not talk about other agents or about transferring to them.\n\nFollow [@prompt:Style prompt](#mention) . Also keep in mind [@prompt:self_support_prompt](#mention) .\n",
"model": "gpt-4o",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"connectedAgents": [
"Product info agent",
"Delivery info agent"
],
"ragDataSources": [],
"description": "The Main agent orchestrates interactions between various specialized worker agents to ensure efficient handling of user queries and support needs.",
"controlType": "retain",
"examples": "- **User**: \"hi\"\n - **Agent response**: \"Hello! How can I help you with your ScootUp scooter today? I can assist with product information or check on your delivery. Let me know how I can help!\"\n\n- **User**: \"How are you?\"\n - **Agent response**: \"I'm doing well, thank you. How can I help you with ScootUp today?\"\n\n- **User**: \"What can you do?\"\n - **Agent response**: \"I can help with customer support-related issues for ScootUp scooters. Let me know if you have any questions.\"\n\n- **User**: \"What's your role here?\"\n - **Agent response**: \"I'm here to assist you with any ScootUp scooter inquiries. Feel free to ask me anything!\"\n\n- **User**: \"Do you have a favorite scooter?\"\n - **Agent response**: \"I think all ScootUp scooters are great! What interests you about them?\"\n\n- **User**: \"What's your favorite season for scooter rides?\"\n - **Agent response**: \"Each season has its charm for a ride! How about you?\"\n\n- **User**: \"Tell me a fun fact about scooters.\"\n - **Agent response**: \"Did you know that ScootUp scooters can now be found in almost every major city as a part of shared transportation services?\"\n\n- **User**: \"Do you enjoy scooter rides too?\"\n - **Agent response**: \"I think riding scooters can be a lot of fun and a great way to explore the city!\"\n\n- **User**: \"What's a good way to spend a weekend?\"\n - **Agent response**: \"Exploring new places on a scooter could be very exciting! What do you think?\"\n\n- **User**: \"Do you follow any sports?\"\n - **Agent response**: \"I'm focused on supporting ScootUp-related inquiries, but I'd love to hear if you have a favorite sport!\"\n\n- **User**: \"Tell me something interesting.\"\n - **Agent response**: \"ScootUp scooters are known for their dual motors, which provide extra power for smooth rides.\"\n\n- **User**: \"Tell me about the product features.\"\n - **Agent actions**: \"Call Product info agent\"\n\n- **User**: \"Where is my scooter?\"\n - **Agent actions**: \"Call Delivery info agent\"\n\n- **User**: \"I need help with my return.\"\n - **Agent actions**: \"Call Returns agent\"\n\n- **User**: \"How does the ScootUp subscription work?\"\n - **Agent actions**: \"Call Subscriptions agent\"\n\n- **User**: \"Are there any current discounts or promotions?\"\n - **Agent actions**: \"Call Promotions agent\"\n\n- **User**: \"Are there any offers on accessories?\"\n - **Agent actions**: \"Call Promotions agent\"\n\n- **User**: \"Can I get details about shipping and delivery times?\"\n - **Agent actions**: \"Call Delivery info agent\"\n\n- **User**: \"What is the battery life of the scooter?\"\n - **Agent actions**: \"Call Product info agent\"\n\n- **User**: \"I have a broken scooter part, can I get a replacement?\"\n - **Agent actions**: \"Call Product info agent\"\n\n- **User**: \"What are ScootUp scooters?\"\n - **Agent actions**: \"Call Product info agent\""
"examples": "- **User**: \"hi\"\n - **Agent response**: \"Hello! How can I help you with your ScootUp scooter today? I can assist with product information or check on your delivery. Let me know how I can help!\"\n\n- **User**: \"How are you?\"\n - **Agent response**: \"I'm doing well, thank you. How can I help you with ScootUp today?\"\n\n- **User**: \"What can you do?\"\n - **Agent response**: \"I can help with customer support-related issues for ScootUp scooters. Let me know if you have any questions.\"\n\n- **User**: \"What's your role here?\"\n - **Agent response**: \"I'm here to assist you with any ScootUp scooter inquiries. Feel free to ask me anything!\"\n\n- **User**: \"Do you have a favorite scooter?\"\n - **Agent response**: \"I think all ScootUp scooters are great! What interests you about them?\"\n\n- **User**: \"What's your favorite season for scooter rides?\"\n - **Agent response**: \"Each season has its charm for a ride! How about you?\"\n\n- **User**: \"Tell me a fun fact about scooters.\"\n - **Agent response**: \"Did you know that ScootUp scooters can now be found in almost every major city as a part of shared transportation services?\"\n\n- **User**: \"Do you enjoy scooter rides too?\"\n - **Agent response**: \"I think riding scooters can be a lot of fun and a great way to explore the city!\"\n\n- **User**: \"What's a good way to spend a weekend?\"\n - **Agent response**: \"Exploring new places on a scooter could be very exciting! What do you think?\"\n\n- **User**: \"Do you follow any sports?\"\n - **Agent response**: \"I'm focused on supporting ScootUp-related inquiries, but I'd love to hear if you have a favorite sport!\"\n\n- **User**: \"Tell me something interesting.\"\n - **Agent response**: \"ScootUp scooters are known for their dual motors, which provide extra power for smooth rides.\"\n\n- **User**: \"Tell me about the product features.\"\n - **Agent actions**: \"Call [@agent:Product info agent](#mention)\"\n\n- **User**: \"Where is my scooter?\"\n - **Agent actions**: \"Call [@agent:Delivery info agent](#mention)\"\n\n- **User**: \"Can I get details about shipping and delivery times?\"\n - **Agent actions**: \"Call [@agent:Delivery info agent](#mention)\"\n\n- **User**: \"What is the battery life of the scooter?\"\n - **Agent actions**: \"Call [@agent:Product info agent](#mention)\"\n\n- **User**: \"I have a broken scooter part, can I get a replacement?\"\n - **Agent actions**: \"Call [@agent:Product info agent](#mention)\"\n\n- **User**: \"What are ScootUp scooters?\"\n - **Agent actions**: \"Call [@agent:Product info agent](#mention)\""
},
{
"name": "Post process",
"type": "post_process",
"disabled": false,
"instructions": "- Extract the response_to_user field from the provided structured JSON and ensure that this is the only content you use for the final output.\n- Ensure that the agent response covers all the details the user asked for.\n- Use bullet points only when providing lengthy or detailed information that benefits from such formatting.\n- Generally, aim to keep responses concise and focused on key details. You can summarize the info to around 5 sentences.\n- Focus specifically on the response_to_user field in its input.",
"prompts": [
"self_support_prompt"
],
"tools": [],
"model": "gpt-4o",
"locked": true,
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"connectedAgents": [],
"ragDataSources": [],
"description": "",
"controlType": "retain",
"controlType": "retain"
},
{
"name": "Product info agent",
"type": "conversation",
"disabled": false,
"instructions": "🧑‍💼 Role:\nYou are a product information agent for ScootUp scooters. Your job is to search for the right article and answer questions strictly based on the article about ScootUp products. Feel free to ask the user clarification questions if needed.\n\n---\n\n📜 Instructions:\n- Call retrieve_snippet to get the relevant information and answer questions strictly based on that\n\n✅ In Scope:\n- Answer questions strictly about ScootUp product information.\n\n❌ Out of Scope:\n- Questions about delivery, returns, and subscriptions.\n- Any topic unrelated to ScootUp products.\n- If a question is out of scope, call give_up_control and do not attempt to answer it.\n\n---\n## 📋 Guidelines:\n\n✔ Dos:\n- Stick to the facts provided in the articles.\n- Provide complete and direct answers to the user's questions.\n\n---\n\n🚫 Don'ts:\n- Do not partially answer questions or direct users to a URL for more information.\n- Do not provide information outside of the given context.\n",
"prompts": [
"structured_output",
"rag_article_prompt",
"self_support_prompt",
"Style prompt"
],
"tools": [
"retrieve_snippet"
],
"instructions": "🧑‍💼 Role:\nYou are a product information agent for ScootUp scooters. Your job is to search for the right article and answer questions strictly based on the article about ScootUp products. Feel free to ask the user clarification questions if needed.\n\n---\n\n📜 Instructions:\n- Call [@tool:retrieve_snippet](#mention) to get the relevant information and answer questions strictly based on that\n\n✅ In Scope:\n- Answer questions strictly about ScootUp product information.\n\n❌ Out of Scope:\n- Questions about delivery, returns, and subscriptions.\n- Any topic unrelated to ScootUp products.\n- If a question is out of scope, call give_up_control and do not attempt to answer it.\n\n---\n## 📋 Guidelines:\n\n✔ Dos:\n- Stick to the facts provided in the articles.\n- Provide complete and direct answers to the user's questions.\n\n---\n\n🚫 Don'ts:\n- Do not partially answer questions or direct users to a URL for more information.\n- Do not provide information outside of the given context.\n\n",
"model": "gpt-4o",
"locked": false,
"toggleAble": true,
"ragReturnType": "content",
"ragK": 3,
"connectedAgents": [],
"ragDataSources": [],
"description": "You assist with product-related questions by retrieving relevant articles and information.",
"controlType": "relinquish_to_parent",
"examples": "- **User**: \"What is the maximum speed of the ScootUp E500?\"\n - **Agent actions**: Call retrieve_snippet\n - **Agent response**: \"The maximum speed of the E500 is <snippet_based_info>.\"\n\n- **User**: \"How long does it take to charge a ScootUp scooter fully?\"\n - **Agent actions**: Call retrieve_snippet\n - **Agent response**: \"A full charge requires <snippet_based_info> hours.\"\n\n- **User**: \"Can you tell me about the weight-carrying capacity of ScootUp scooters?\"\n - **Agent actions**: Call retrieve_snippet\n - **Agent response**: \"It supports up to <snippet_based_info>.\"\n\n- **User**: \"What are the differences between the E250 and E500 models?\"\n - **Agent actions**: Call retrieve_snippet\n - **Agent response**: \"Here are the differences: <snippet_based_info>.\"\n\n- **User**: \"How far can I travel on a single charge with the E500?\"\n - **Agent actions**: Call retrieve_snippet\n - **Agent response**: \"You can typically travel up to <snippet_based_info> miles.\"\n\n- **User**: \"Is the scooter waterproof?\"\n - **Agent actions**: Call retrieve_snippet\n - **Agent response**: \"Its waterproof capabilities are: <snippet_based_info>.\"\n\n- **User**: \"Does the scooter have any safety features?\"\n - **Agent actions**: Call retrieve_snippet\n - **Agent response**: \"These safety features are: <snippet_based_info>.\"\n\n- **User**: \"What materials are used to make ScootUp scooters?\"\n - **Agent actions**: Call retrieve_snippet\n - **Agent response**: \"The materials used are: <snippet_based_info>.\"\n\n- **User**: \"Can the scooter be used off-road?\"\n - **Agent actions**: Call retrieve_snippet\n - **Agent response**: \"Regarding off-road use, <snippet_based_info>.\"\n\n- **User**: \"Are spare parts available for purchase?\"\n - **Agent actions**: Call retrieve_snippet\n - **Agent response**: \"Spare parts availability is <snippet_based_info>.\"\n\n- **User**: \"What is the status of my order delivery?\"\n - **Agent actions**: Call give_up_control\n\n- **User**: \"How do I process a return?\"\n - **Agent actions**: Call give_up_control\n\n- **User**: \"Can you tell me more about the subscription plans?\"\n - **Agent actions**: Call give_up_control\n\n- **User**: \"Are there any promotions or discounts?\"\n - **Agent actions**: Call give_up_control\n\n- **User**: \"Who won the last election?\"\n - **Agent actions**: Call give_up_control"
"examples": "- **User**: \"What is the maximum speed of the ScootUp E500?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"The maximum speed of the E500 is <snippet_based_info>.\"\n\n- **User**: \"How long does it take to charge a ScootUp scooter fully?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"A full charge requires <snippet_based_info> hours.\"\n\n- **User**: \"Can you tell me about the weight-carrying capacity of ScootUp scooters?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"It supports up to <snippet_based_info>.\"\n\n- **User**: \"What are the differences between the E250 and E500 models?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"Here are the differences: <snippet_based_info>.\"\n\n- **User**: \"How far can I travel on a single charge with the E500?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"You can typically travel up to <snippet_based_info> miles.\"\n\n- **User**: \"Is the scooter waterproof?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"Its waterproof capabilities are: <snippet_based_info>.\"\n\n- **User**: \"Does the scooter have any safety features?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"These safety features are: <snippet_based_info>.\"\n\n- **User**: \"What materials are used to make ScootUp scooters?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"The materials used are: <snippet_based_info>.\"\n\n- **User**: \"Can the scooter be used off-road?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"Regarding off-road use, <snippet_based_info>.\"\n\n- **User**: \"Are spare parts available for purchase?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"Spare parts availability is <snippet_based_info>.\"\n\n- **User**: \"What is the status of my order delivery?\"\n - **Agent actions**: Call give_up_control\n\n- **User**: \"How do I process a return?\"\n - **Agent actions**: Call give_up_control\n\n- **User**: \"Can you tell me more about the subscription plans?\"\n - **Agent actions**: Call give_up_control\n\n- **User**: \"Are there any promotions or discounts?\"\n - **Agent actions**: Call give_up_control\n\n- **User**: \"Who won the last election?\"\n - **Agent actions**: Call give_up_control"
},
{
"name": "Delivery info agent",
"type": "conversation",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\n\nYou are responsible for providing delivery information to the user.\n\n---\n\n## ⚙️ Steps to Follow:\n\n1. Check if the orderId is available:\n - If not available, politely ask the user for their orderId.\n - Once the user provides the orderId, call the 'validations' tool to check if it's valid.\n - If 'validated', proceed to Step 2.\n - If 'not validated', ask the user to re-check or provide a corrected orderId. Provide a reason on why it is invalid only if the validations tool returns that information.\n2. Fetch the delivery details using the function: get_shipping_details once the valid orderId is available.\n3. Answer the user's question based on the fetched delivery details.\n4. If the user asks a general delivery question, call retrieve_snippet and provide an answer only based on it.\n5. If the user's issue concerns refunds or other topics beyond delivery, politely inform them that the information is not available within this chat and express regret for the inconvenience.\n\n---\n## 🎯 Scope\n\n✅ In Scope:\n- Questions about delivery status, shipping timelines, and delivery processes.\n- Generic delivery/shipping-related questions where answers can be sourced from articles.\n\n❌ Out of Scope:\n- Questions unrelated to delivery or shipping.\n- Questions about product features, returns, subscriptions, or promotions.\n- If a question is out of scope, politely inform the user and avoid providing an answer.\n\n---\n\n## 📋 Guidelines\n\n✔ Dos:\n- Use validations to verify orderId.\n- Use get_shipping_details to fetch accurate delivery information.\n- Promptly ask for orderId if not available.\n- Provide complete and clear answers based on the delivery details.\n- For generic delivery questions, strictly refer to retrieved snippets. Stick to factual information when answering.\n\n🚫 Don'ts:\n- Do not mention or describe how you are fetching the information behind the scenes.\n- Do not provide answers without fetching delivery details when required.\n- Do not leave the user with partial information.\n- Refrain from phrases like 'please contact support'; instead, relay information limitations gracefully.",
"prompts": [
"structured_output",
"rag_article_prompt",
"self_support_prompt",
"Style prompt"
],
"tools": [
"get_delivery_details",
"retrieve_snippet",
"validate_entity"
],
"instructions": "## 🧑‍💼 Role:\n\nYou are responsible for providing delivery information to the user.\n\n---\n\n## ⚙️ Steps to Follow:\n\n1. Check if the orderId is available:\n - If not available, politely ask the user for their orderId.\n - Once the user provides the orderId, call the [@tool:validate_entity](#mention) tool to check if it's valid.\n - If 'validated', proceed to Step 2.\n - If 'not validated', ask the user to re-check or provide a corrected orderId. Provide a reason on why it is invalid only if the validations tool returns that information.\n2. Fetch the delivery details using the function: [@tool:get_delivery_details](#mention) once the valid orderId is available.\n3. Answer the user's question based on the fetched delivery details.\n4. If the user asks a general delivery question, call [@tool:retrieve_snippet](#mention) and provide an answer only based on it.\n5. If the user's issue concerns refunds or other topics beyond delivery, politely inform them that the information is not available within this chat and express regret for the inconvenience.\n\n---\n## 🎯 Scope\n\n✅ In Scope:\n- Questions about delivery status, shipping timelines, and delivery processes.\n- Generic delivery/shipping-related questions where answers can be sourced from articles.\n\n❌ Out of Scope:\n- Questions unrelated to delivery or shipping.\n- Questions about product features, returns, subscriptions, or promotions.\n- If a question is out of scope, politely inform the user and avoid providing an answer.\n\n---\n\n## 📋 Guidelines\n\n✔ Dos:\n- Use validations to verify orderId.\n- Use get_shipping_details to fetch accurate delivery information.\n- Promptly ask for orderId if not available.\n- Provide complete and clear answers based on the delivery details.\n- For generic delivery questions, strictly refer to retrieved snippets. Stick to factual information when answering.\n\n🚫 Don'ts:\n- Do not mention or describe how you are fetching the information behind the scenes.\n- Do not provide answers without fetching delivery details when required.\n- Do not leave the user with partial information.\n- Refrain from phrases like 'please contact support'; instead, relay information limitations gracefully.\n",
"model": "gpt-4o",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"connectedAgents": [],
"ragDataSources": [],
"description": "You are responsible for providing accurate delivery status and shipping details for orders.",
"controlType": "retain",
"examples": "- **User**: \"What is the status of my delivery?\"\n - **Agent actions**: Call get_delivery_details\n - **Agent response**: \"Could you please provide your order ID so I can check your delivery status?\"\n\n- **User**: \"Can you explain the delivery process?\"\n - **Agent actions**: Call retrieve_snippet\n - **Agent response**: \"Here's some information on the delivery process: <snippet_based_info>.\"\n\n- **User**: \"I have a question about product features such as range, durability etc.\"\n - **Agent actions**: give_up_control\n\n- **User**: \"I want to know when my scooter shipped.\"\n - **Agent actions**: Call get_delivery_details\n - **Agent response**: \"May I have your order ID, please?\"\n\n- **User**: \"Which shipping carrier do you use?\"\n - **Agent actions**: Call retrieve_snippet\n - **Agent response**: \"We typically use <snippet_based_info> as our shipping carrier.\"\n\n- **User**: \"Where can I find my orderId?\"\n - **Agent actions**: Call retrieve_snippet\n - **Agent response**: \"<snippet_based_info>\"\n\n- **User**: \"My orderId is 123456.\"\n - **Agent actions**: Call validations\n - **Agent actions**: Call get_delivery_details\n - **Agent response**: \"Your scooter is expected to arrive by <delivery_date>.\"\n\n- **User**: \"My orderId is abcxyz.\"\n - **Agent actions**: Call validations\n - **Agent response**: \"It seems your order ID is invalid <reason if provided by validations>. Could you please double-check and provide a correct orderId?\""
"examples": "- **User**: \"What is the status of my delivery?\"\n - **Agent actions**: Call [@tool:get_delivery_details](#mention)\n - **Agent response**: \"Could you please provide your order ID so I can check your delivery status?\"\n\n- **User**: \"Can you explain the delivery process?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"Here's some information on the delivery process: <snippet_based_info>.\"\n\n- **User**: \"I have a question about product features such as range, durability etc.\"\n - **Agent actions**: give_up_control\n\n- **User**: \"I want to know when my scooter shipped.\"\n - **Agent actions**: Call [@tool:get_delivery_details](#mention)\n - **Agent response**: \"May I have your order ID, please?\"\n\n- **User**: \"Which shipping carrier do you use?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"We typically use <snippet_based_info> as our shipping carrier.\"\n\n- **User**: \"Where can I find my orderId?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"<snippet_based_info>\"\n\n- **User**: \"My orderId is 123456.\"\n - **Agent actions**: Call [@tool:validate_entity](#mention)\n - **Agent actions**: Call [@tool:get_delivery_details](#mention)\n - **Agent response**: \"Your scooter is expected to arrive by <delivery_date>.\"\n\n- **User**: \"My orderId is abcxyz.\"\n - **Agent actions**: Call [@tool:validate_entity](#mention)\n - **Agent response**: \"It seems your order ID is invalid <reason if provided by validations>. Could you please double-check and provide a correct orderId?\""
},
{
"name": "Escalation agent",
@ -302,22 +244,16 @@ You are an helpful customer support assistant
"description": "Handles situations where user queries cannot be addressed by existing agents and require escalation.",
"disabled": false,
"instructions": "\n## 🧑‍💼 Role:\nYou handle situations where escalation is necessary because the current agents cannot fulfill the user's request.\n\n---\n\n## ⚙️ Steps to Follow:\n1. Tell the user you will setup a callback with the team. \n\n---\n\n## 🎯 Scope:\n✅ In Scope:\n- Escalating unresolvable queries, notifying users of escalation, and logging escalation activities.\n\n❌ Out of Scope:\n- Providing responses to general or specialized topics already handled by other agents.\n\n---\n\n## 📋 Guidelines:\n✔ Dos: \n- Respond empathetically to the user, inform them about the escalation, and ensure necessary actions are taken.\n\n🚫 Don'ts: \n- Do not attempt to resolve issues not within your scope.\n",
"prompts": [
"self_support_prompt",
"Style prompt"
],
"tools": [],
"model": "gpt-4o",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"connectedAgents": [],
"controlType": "retain",
"examples": "- **User**: \"I've tried everything, but no one can resolve my issue. I demand further assistance!\"\n - **Agent actions**: N/A\n - **Agent response**: \"I'm sorry about your experience. I'll set up a callback with our support team so we can thoroughly resolve your issue. We appreciate your patience, and we'll be in touch soon.\"\n\n- **User**: \"Could you escalate this? I've been waiting for days without a resolution.\"\n - **Agent actions**: N/A\n - **Agent response**: \"I'm sorry about your experience. I'll set up a callback with our support team so we can thoroughly resolve your issue. We appreciate your patience, and we'll be in touch soon.\"\n\n- **User**: \"I want a manager to handle my case personally. This is unacceptable.\"\n - **Agent actions**: N/A\n - **Agent response**: \"I'm sorry about your experience. I'll set up a callback with our support team so we can thoroughly resolve your issue. We appreciate your patience, and we'll be in touch soon.\"\n\n- **User**: \"None of the agents so far have fixed my problem. How do I escalate this?\"\n - **Agent actions**: N/A\n - **Agent response**: \"I'm sorry about your experience. I'll set up a callback with our support team so we can thoroughly resolve your issue. We appreciate your patience, and we'll be in touch soon.\"\n\n- **User**: \"I'm tired of repeating myself. I need upper management involved now.\"\n - **Agent actions**: N/A\n - **Agent response**: \"I'm sorry about your experience. I'll set up a callback with our support team so we can thoroughly resolve your issue. We appreciate your patience, and we'll be in touch soon.\""
}
],
"prompts": [
],
"prompts": [
{
"name": "Style prompt",
"type": "style_prompt",
@ -338,38 +274,38 @@ You are an helpful customer support assistant
"type": "base_prompt",
"prompt": "Self Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'."
}
],
"tools": [
],
"tools": [
{
"name": "get_delivery_details",
"description": "Return a estimated delivery date for the unagi scooter.",
"parameters": {
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": "the user's ID"
}
"orderId": {
"type": "string",
"description": "the user's ID"
}
},
"required": [
"orderId"
"orderId"
]
},
"mockInPlayground": true,
"mockTool": true,
"autoSubmitMockedResponse": true
},
{
"name": "retrieve_snippet",
"description": "This is a mock RAG service. Always return 2 paragraphs about a fictional scooter rental product, based on the query. Be verbose.",
"mockInPlayground": true,
"mockTool": true,
"autoSubmitMockedResponse": true,
"parameters": {
"type": "object",
"properties": {
"param1": {
"type": "string",
"description": ""
}
"param1": {
"type": "string",
"description": ""
}
},
"required": [
"param1"
@ -382,16 +318,16 @@ You are an helpful customer support assistant
"parameters": {
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": ""
}
"orderId": {
"type": "string",
"description": ""
}
},
"required": [
"orderId"
]
},
"mockInPlayground": true,
"mockTool": true,
"autoSubmitMockedResponse": true
}
],

View file

@ -0,0 +1,7 @@
import {QdrantClient} from '@qdrant/js-client-rest';
// TO connect to Qdrant running locally
export const qdrantClient = new QdrantClient({
url: process.env.QDRANT_URL,
...(process.env.QDRANT_API_KEY ? { apiKey: process.env.QDRANT_API_KEY } : {}),
});

View file

@ -1,773 +0,0 @@
import { CoreMessage, ToolCallPart } from "ai";
import { z } from "zod";
import { apiV1 } from "rowboat-shared";
export const SimulationArticleData = z.object({
articleUrl: z.string(),
articleTitle: z.string().default('').optional(),
articleContent: z.string().default('').optional(),
});
export const Scenario = z.object({
projectId: z.string(),
name: z.string().min(1, "Name cannot be empty"),
description: z.string().min(1, "Description cannot be empty"),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});
export const SimulationScenarioData = z.object({
scenario: z.string(),
});
export const SimulationChatMessagesData = z.object({
chatMessages: z.string(),
});
export const SimulationData = z.union([SimulationArticleData, SimulationScenarioData, SimulationChatMessagesData]);
export const PlaygroundChat = z.object({
createdAt: z.string().datetime(),
projectId: z.string(),
title: z.string().optional(),
messages: z.array(apiV1.ChatMessage),
simulated: z.boolean().default(false).optional(),
simulationData: SimulationData.optional(),
simulationComplete: z.boolean().default(false).optional(),
agenticState: z.unknown().optional(),
systemMessage: z.string().optional(),
});
export const Webpage = z.object({
_id: z.string(),
title: z.string(),
contentSimple: z.string(),
lastUpdatedAt: z.string().datetime(),
});
export const ChatClientId = z.object({
_id: z.string(),
projectId: z.string(),
});
export const DataSource = z.object({
name: z.string(),
projectId: z.string(),
active: z.boolean().default(true),
status: z.union([z.literal('new'), z.literal('processing'), z.literal('completed'), z.literal('error')]),
detailedStatus: z.string().optional(),
error: z.string().optional(),
attempts: z.number().default(0).optional(),
createdAt: z.string().datetime(),
lastAttemptAt: z.string().datetime().optional(),
data: z.discriminatedUnion('type', [
z.object({
type: z.literal('crawl'),
startUrl: z.string(),
limit: z.number(),
firecrawlId: z.string().optional(),
oxylabsId: z.string().optional(),
crawledUrls: z.string().optional(),
}),
z.object({
type: z.literal('urls'),
urls: z.array(z.string()),
scrapedUrls: z.string().optional(),
missingUrls: z.string().optional(),
}),
]),
});
export const EmbeddingDoc = z.object({
content: z.string(),
sourceId: z.string(),
embeddings: z.array(z.number()),
metadata: z.object({
sourceURL: z.string(),
title: z.string(),
score: z.number().optional(),
}),
});
export const Project = z.object({
_id: z.string().uuid(),
name: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
createdByUserId: z.string(),
secret: z.string(),
chatClientId: z.string(),
webhookUrl: z.string().optional(),
publishedWorkflowId: z.string().optional(),
nextWorkflowNumber: z.number().optional(),
});
export const ProjectMember = z.object({
userId: z.string(),
projectId: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});
export const ApiKey = z.object({
projectId: z.string(),
key: z.string(),
createdAt: z.string().datetime(),
lastUsedAt: z.string().datetime().optional(),
});
export const GetInformationToolResultItem = z.object({
title: z.string(),
content: z.string(),
url: z.string(),
score: z.number().optional(),
});
export const GetInformationToolResult = z.object({
results: z.array(GetInformationToolResultItem)
});
export const WebpageCrawlResponse = z.object({
title: z.string(),
content: z.string(),
});
export const AgenticAPIChatMessage = z.object({
role: z.union([z.literal('user'), z.literal('assistant'), z.literal('tool'), z.literal('system')]),
content: z.string().nullable(),
tool_calls: z.array(z.object({
id: z.string(),
function: z.object({
name: z.string(),
arguments: z.string(),
}),
type: z.literal('function'),
})).nullable(),
tool_call_id: z.string().nullable(),
tool_name: z.string().nullable(),
sender: z.string().nullable(),
response_type: z.union([
z.literal('internal'),
z.literal('external'),
]).optional(),
});
export const WorkflowAgent = z.object({
name: z.string(),
type: z.union([
z.literal('conversation'),
z.literal('post_process'),
z.literal('escalation'),
]),
description: z.string(),
disabled: z.boolean().default(false).optional(),
instructions: z.string(),
examples: z.string().optional(),
prompts: z.array(z.string()),
tools: z.array(z.string()),
model: z.union([
z.literal('gpt-4o'),
z.literal('gpt-4o-mini'),
]),
locked: z.boolean().default(false).describe('Whether this agent is locked and cannot be deleted').optional(),
toggleAble: z.boolean().default(true).describe('Whether this agent can be enabled or disabled').optional(),
global: z.boolean().default(false).describe('Whether this agent is a global agent, in which case it cannot be connected to other agents').optional(),
ragDataSources: z.array(z.string()).optional(),
ragReturnType: z.union([z.literal('chunks'), z.literal('content')]).default('chunks'),
ragK: z.number().default(3),
connectedAgents: z.array(z.string()),
controlType: z.union([z.literal('retain'), z.literal('relinquish_to_parent'), z.literal('relinquish_to_start')]).default('retain').describe('Whether this agent retains control after a turn, relinquishes to the parent agent, or relinquishes to the start agent'),
});
export const WorkflowPrompt = z.object({
name: z.string(),
type: z.union([
z.literal('base_prompt'),
z.literal('style_prompt'),
]),
prompt: z.string(),
});
export const WorkflowTool = z.object({
name: z.string(),
description: z.string(),
mockInPlayground: z.boolean().default(false).optional(),
autoSubmitMockedResponse: z.boolean().default(false).optional(),
parameters: z.object({
type: z.literal('object'),
properties: z.record(z.object({
type: z.string(),
description: z.string(),
})),
required: z.array(z.string()).optional(),
}),
});
export const AgenticAPIAgent = WorkflowAgent
.omit({
disabled: true,
examples: true,
prompts: true,
locked: true,
toggleAble: true,
global: true,
ragDataSources: true,
ragReturnType: true,
ragK: true,
})
.extend({
hasRagSources: z.boolean().default(false).optional(),
});
export const AgenticAPIPrompt = WorkflowPrompt;
export const AgenticAPITool = WorkflowTool.omit({
mockInPlayground: true,
autoSubmitMockedResponse: true,
});
export const Workflow = z.object({
name: z.string().optional(),
agents: z.array(WorkflowAgent),
prompts: z.array(WorkflowPrompt),
tools: z.array(WorkflowTool),
startAgent: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
projectId: z.string(),
});
export const WorkflowTemplate = Workflow
.omit({
projectId: true,
lastUpdatedAt: true,
createdAt: true,
})
.extend({
name: z.string(),
description: z.string(),
});
export type WithStringId<T> = T & { _id: string };
export const CopilotWorkflow = Workflow.omit({
lastUpdatedAt: true,
projectId: true,
});
export const AgenticAPIChatRequest = z.object({
messages: z.array(AgenticAPIChatMessage),
state: z.unknown(),
agents: z.array(AgenticAPIAgent),
tools: z.array(AgenticAPITool),
prompts: z.array(WorkflowPrompt),
startAgent: z.string(),
});
export const AgenticAPIChatResponse = z.object({
messages: z.array(AgenticAPIChatMessage),
state: z.unknown(),
});
export const CopilotUserMessage = z.object({
role: z.literal('user'),
content: z.string(),
});
export const CopilotAssistantMessageTextPart = z.object({
type: z.literal("text"),
content: z.string(),
});
export const CopilotAssistantMessageActionPart = z.object({
type: z.literal("action"),
content: z.object({
config_type: z.union([z.literal('tool'), z.literal('agent'), z.literal('prompt')]),
action: z.union([z.literal('create_new'), z.literal('edit')]),
name: z.string(),
change_description: z.string(),
config_changes: z.record(z.string(), z.unknown()),
error: z.string().optional(),
})
});
export const CopilotAssistantMessage = z.object({
role: z.literal('assistant'),
content: z.object({
thoughts: z.string().optional(),
response: z.array(z.union([CopilotAssistantMessageTextPart, CopilotAssistantMessageActionPart])),
}),
});
export const CopilotMessage = z.union([CopilotUserMessage, CopilotAssistantMessage]);
export const CopilotApiMessage = z.object({
role: z.union([z.literal('assistant'), z.literal('user')]),
content: z.string(),
});
export const CopilotChatContext = z.union([
z.object({
type: z.literal('chat'),
messages: z.array(apiV1.ChatMessage),
}),
z.object({
type: z.literal('agent'),
name: z.string(),
}),
z.object({
type: z.literal('tool'),
name: z.string(),
}),
z.object({
type: z.literal('prompt'),
name: z.string(),
}),
]);
export const CopilotApiChatContext = z.union([
z.object({
type: z.literal('chat'),
messages: z.array(AgenticAPIChatMessage),
}),
z.object({
type: z.literal('agent'),
agentName: z.string(),
}),
z.object({
type: z.literal('tool'),
toolName: z.string(),
}),
z.object({
type: z.literal('prompt'),
promptName: z.string(),
}),
]);
export const CopilotAPIRequest = z.object({
messages: z.array(CopilotApiMessage),
workflow_schema: z.string(),
current_workflow_config: z.string(),
context: CopilotApiChatContext.nullable(),
});
export const CopilotAPIResponse = z.union([
z.object({
response: z.string(),
}),
z.object({
error: z.string(),
}),
]);
export const ClientToolCallRequestBody = z.object({
toolCall: apiV1.AssistantMessageWithToolCalls.shape.tool_calls.element,
});
export const ClientToolCallJwt = z.object({
requestId: z.string().uuid(),
projectId: z.string(),
bodyHash: z.string(),
iat: z.number(),
exp: z.number(),
});
export const ClientToolCallRequest = z.object({
requestId: z.string().uuid(),
content: z.string(), // json stringified ClientToolCallRequestBody
});
export const ClientToolCallResponse = z.unknown();
export function convertToCopilotApiChatContext(context: z.infer<typeof CopilotChatContext>): z.infer<typeof CopilotApiChatContext> {
switch (context.type) {
case 'chat':
return {
type: 'chat',
messages: convertToAgenticAPIChatMessages(context.messages),
};
case 'agent':
return {
type: 'agent',
agentName: context.name,
};
case 'tool':
return {
type: 'tool',
toolName: context.name,
};
case 'prompt':
return {
type: 'prompt',
promptName: context.name,
};
}
}
export function convertToCopilotApiMessage(message: z.infer<typeof CopilotMessage>): z.infer<typeof CopilotApiMessage> {
return {
role: message.role,
content: JSON.stringify(message.content),
};
}
export function convertToCopilotMessage(message: z.infer<typeof CopilotApiMessage>): z.infer<typeof CopilotMessage> {
switch (message.role) {
case 'assistant':
return CopilotAssistantMessage.parse({
role: 'assistant',
content: JSON.parse(message.content),
});
case 'user':
return {
role: 'user',
content: message.content,
};
default:
throw new Error(`Unknown role: ${message.role}`);
}
}
export function convertWorkflowToAgenticAPI(workflow: z.infer<typeof Workflow>): {
agents: z.infer<typeof AgenticAPIAgent>[],
tools: z.infer<typeof AgenticAPITool>[],
prompts: z.infer<typeof AgenticAPIPrompt>[],
startAgent: string,
} {
return {
agents: workflow.agents
.filter(agent => !agent.disabled)
.map(agent => ({
name: agent.name,
type: agent.type,
description: agent.description,
instructions: agent.instructions +
'\n\n' + agent.prompts.map(prompt =>
workflow.prompts.find(p => p.name === prompt)?.prompt
).join('\n\n') +
(agent.examples ? '\n\n# Examples\n' + agent.examples : ''),
tools: agent.tools,
model: agent.model,
hasRagSources: agent.ragDataSources ? agent.ragDataSources.length > 0 : false,
connectedAgents: agent.connectedAgents,
controlType: agent.controlType,
})),
tools: workflow.tools.map(tool => {
const { mockInPlayground, autoSubmitMockedResponse, ...rest } = tool;
return {
...rest,
};
}),
prompts: workflow.prompts,
startAgent: workflow.startAgent,
};
}
export function convertToCoreMessages(messages: z.infer<typeof apiV1.ChatMessage>[]): CoreMessage[] {
// convert to core messages
const coreMessages: CoreMessage[] = [];
for (const m of messages) {
switch (m.role) {
case 'system':
coreMessages.push({
role: 'system',
content: m.content,
});
break;
case 'user':
coreMessages.push({
role: 'user',
content: m.content,
});
break;
case 'assistant':
if ('tool_calls' in m) {
const toolCallParts: ToolCallPart[] = m.tool_calls.map((toolCall) => ({
type: 'tool-call',
toolCallId: toolCall.id,
toolName: toolCall.function.name,
args: JSON.parse(toolCall.function.arguments),
}));
if (m.content) {
coreMessages.push({
role: 'assistant',
content: [
{
type: 'text',
text: m.content,
},
...toolCallParts,
]
});
} else {
coreMessages.push({
role: 'assistant',
content: toolCallParts,
});
}
} else {
coreMessages.push({
role: 'assistant',
content: m.content,
});
}
break;
case 'tool':
coreMessages.push({
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: m.tool_call_id,
toolName: m.tool_name,
result: JSON.parse(m.content),
}
]
});
break;
}
}
return coreMessages;
}
export function convertToAgenticAPIChatMessages(messages: z.infer<typeof apiV1.ChatMessage>[]): z.infer<typeof AgenticAPIChatMessage>[] {
const converted: z.infer<typeof AgenticAPIChatMessage>[] = [];
for (const m of messages) {
const baseMessage: z.infer<typeof AgenticAPIChatMessage> = {
content: null,
role: m.role,
sender: null,
tool_calls: null,
tool_call_id: null,
tool_name: null,
};
switch (m.role) {
case 'system':
converted.push({
...baseMessage,
content: m.content,
});
break;
case 'user':
converted.push({
...baseMessage,
content: m.content,
});
break;
case 'assistant':
if ('tool_calls' in m) {
converted.push({
...baseMessage,
tool_calls: m.tool_calls,
sender: m.agenticSender ?? null,
response_type: m.agenticResponseType,
});
} else {
converted.push({
...baseMessage,
content: m.content,
sender: m.agenticSender ?? null,
response_type: m.agenticResponseType,
});
}
break;
case 'tool':
converted.push({
...baseMessage,
content: m.content,
tool_call_id: m.tool_call_id,
tool_name: m.tool_name,
});
break;
default:
continue;
}
}
return converted;
}
export function convertFromAgenticAPIChatMessages(messages: z.infer<typeof AgenticAPIChatMessage>[]): z.infer<typeof apiV1.ChatMessage>[] {
const converted: z.infer<typeof apiV1.ChatMessage>[] = [];
for (const m of messages) {
const baseMessage = {
version: 'v1' as const,
chatId: '',
createdAt: new Date().toISOString(),
};
switch (m.role) {
case 'user':
converted.push({
...baseMessage,
role: 'user',
content: m.content ?? '',
});
break;
case 'assistant':
if (m.tool_calls) {
// TODO: handle tool calls
converted.push({
...baseMessage,
role: 'assistant',
tool_calls: m.tool_calls,
agenticSender: m.sender ?? undefined,
agenticResponseType: m.response_type ?? 'internal',
});
} else {
converted.push({
...baseMessage,
role: 'assistant',
content: m.content ?? '',
agenticSender: m.sender ?? undefined,
agenticResponseType: m.response_type ?? 'internal',
});
}
break;
case 'tool':
converted.push({
...baseMessage,
role: 'tool',
content: m.content ?? '',
tool_call_id: m.tool_call_id ?? '',
tool_name: m.tool_name ?? '',
});
break;
}
}
return converted;
}
export function convertToCopilotWorkflow(workflow: z.infer<typeof Workflow>): z.infer<typeof CopilotWorkflow> {
const { lastUpdatedAt, projectId, ...rest } = workflow;
return {
...rest,
};
}
export const ApiMessage = z.union([
apiV1.SystemMessage,
apiV1.UserMessage,
apiV1.AssistantMessage,
apiV1.AssistantMessageWithToolCalls,
apiV1.ToolMessage,
]);
export const ApiRequest = z.object({
messages: z.array(ApiMessage),
state: z.unknown(),
});
export const ApiResponse = z.object({
messages: z.array(ApiMessage),
state: z.unknown(),
});
export function convertFromApiToAgenticApiMessages(messages: z.infer<typeof ApiMessage>[]): z.infer<typeof AgenticAPIChatMessage>[] {
return messages.map(m => {
switch (m.role) {
case 'system':
return {
role: 'system',
content: m.content,
tool_calls: null,
tool_call_id: null,
tool_name: null,
sender: null,
};
case 'user':
return {
role: 'user',
content: m.content,
tool_calls: null,
tool_call_id: null,
tool_name: null,
sender: null,
};
case 'assistant':
if ('tool_calls' in m) {
return {
role: 'assistant',
content: m.content ?? null,
tool_calls: m.tool_calls,
tool_call_id: null,
tool_name: null,
sender: m.agenticSender ?? null,
response_type: m.agenticResponseType ?? 'external',
};
} else {
return {
role: 'assistant',
content: m.content ?? null,
sender: m.agenticSender ?? null,
response_type: m.agenticResponseType ?? 'external',
tool_call_id: null,
tool_calls: null,
tool_name: null,
};
}
case 'tool':
return {
role: 'tool',
content: m.content ?? null,
tool_calls: null,
tool_call_id: m.tool_call_id ?? null,
tool_name: m.tool_name ?? null,
sender: null,
};
default:
return {
role: "user",
content: "foo",
tool_calls: null,
tool_call_id: null,
tool_name: null,
sender: null,
}
}
});
}
export function convertFromAgenticApiToApiMessages(messages: z.infer<typeof AgenticAPIChatMessage>[]): z.infer<typeof ApiMessage>[] {
const converted: z.infer<typeof ApiMessage>[] = [];
for (const m of messages) {
switch (m.role) {
case 'user':
converted.push({
role: 'user',
content: m.content ?? '',
});
break;
case 'assistant':
if (m.tool_calls) {
converted.push({
role: 'assistant',
tool_calls: m.tool_calls,
agenticSender: m.sender ?? undefined,
agenticResponseType: m.response_type ?? 'internal',
});
} else {
converted.push({
role: 'assistant',
content: m.content ?? '',
agenticSender: m.sender ?? undefined,
agenticResponseType: m.response_type ?? 'internal',
});
}
break;
case 'tool':
converted.push({
role: 'tool',
content: m.content ?? '',
tool_call_id: m.tool_call_id ?? '',
tool_name: m.tool_name ?? '',
});
break;
}
}
return converted;
}

View file

@ -0,0 +1,326 @@
import { z } from "zod";
import { ConnectedEntity, sanitizeTextWithMentions, Workflow, WorkflowAgent, WorkflowPrompt, WorkflowTool } from "./workflow_types";
import { apiV1 } from "rowboat-shared";
import { ApiMessage } from "./types";
export const AgenticAPIChatMessage = z.object({
role: z.union([z.literal('user'), z.literal('assistant'), z.literal('tool'), z.literal('system')]),
content: z.string().nullable(),
tool_calls: z.array(z.object({
id: z.string(),
function: z.object({
name: z.string(),
arguments: z.string(),
}),
type: z.literal('function'),
})).nullable(),
tool_call_id: z.string().nullable(),
tool_name: z.string().nullable(),
sender: z.string().nullable(),
response_type: z.union([
z.literal('internal'),
z.literal('external'),
]).optional(),
});
export const AgenticAPIAgent = WorkflowAgent
.omit({
disabled: true,
examples: true,
locked: true,
toggleAble: true,
global: true,
ragDataSources: true,
ragReturnType: true,
ragK: true,
})
.extend({
hasRagSources: z.boolean().default(false).optional(),
tools: z.array(z.string()),
prompts: z.array(z.string()),
connectedAgents: z.array(z.string()),
});
export const AgenticAPIPrompt = WorkflowPrompt;
export const AgenticAPITool = WorkflowTool.omit({
mockTool: true,
autoSubmitMockedResponse: true,
});
export const AgenticAPIChatRequest = z.object({
messages: z.array(AgenticAPIChatMessage),
state: z.unknown(),
agents: z.array(AgenticAPIAgent),
tools: z.array(AgenticAPITool),
prompts: z.array(WorkflowPrompt),
startAgent: z.string(),
});
export const AgenticAPIChatResponse = z.object({
messages: z.array(AgenticAPIChatMessage),
state: z.unknown(),
});
export function convertWorkflowToAgenticAPI(workflow: z.infer<typeof Workflow>): {
agents: z.infer<typeof AgenticAPIAgent>[];
tools: z.infer<typeof AgenticAPITool>[];
prompts: z.infer<typeof AgenticAPIPrompt>[];
startAgent: string;
} {
return {
agents: workflow.agents
.filter(agent => !agent.disabled)
.map(agent => {
const compiledInstructions = agent.instructions +
(agent.examples ? '\n\n# Examples\n' + agent.examples : '');
const { sanitized, entities } = sanitizeTextWithMentions(compiledInstructions, workflow);
const agenticAgent: z.infer<typeof AgenticAPIAgent> = {
name: agent.name,
type: agent.type,
description: agent.description,
instructions: sanitized,
model: agent.model,
hasRagSources: agent.ragDataSources ? agent.ragDataSources.length > 0 : false,
controlType: agent.controlType,
tools: entities.filter(e => e.type == 'tool').map(e => e.name),
prompts: entities.filter(e => e.type == 'prompt').map(e => e.name),
connectedAgents: entities.filter(e => e.type === 'agent').map(e => e.name),
};
return agenticAgent;
}),
tools: workflow.tools.map(tool => {
const { mockTool, autoSubmitMockedResponse, ...rest } = tool;
return {
...rest,
};
}),
prompts: workflow.prompts
.map(p => {
const { sanitized } = sanitizeTextWithMentions(p.prompt, workflow);
return {
...p,
prompt: sanitized,
};
}),
startAgent: workflow.startAgent,
};
}
export function convertToAgenticAPIChatMessages(messages: z.infer<typeof apiV1.ChatMessage>[]): z.infer<typeof AgenticAPIChatMessage>[] {
const converted: z.infer<typeof AgenticAPIChatMessage>[] = [];
for (const m of messages) {
const baseMessage: z.infer<typeof AgenticAPIChatMessage> = {
content: null,
role: m.role,
sender: null,
tool_calls: null,
tool_call_id: null,
tool_name: null,
};
switch (m.role) {
case 'system':
converted.push({
...baseMessage,
content: m.content,
});
break;
case 'user':
converted.push({
...baseMessage,
content: m.content,
});
break;
case 'assistant':
if ('tool_calls' in m) {
converted.push({
...baseMessage,
tool_calls: m.tool_calls,
sender: m.agenticSender ?? null,
response_type: m.agenticResponseType,
});
} else {
converted.push({
...baseMessage,
content: m.content,
sender: m.agenticSender ?? null,
response_type: m.agenticResponseType,
});
}
break;
case 'tool':
converted.push({
...baseMessage,
content: m.content,
tool_call_id: m.tool_call_id,
tool_name: m.tool_name,
});
break;
default:
continue;
}
}
return converted;
}
export function convertFromAgenticAPIChatMessages(messages: z.infer<typeof AgenticAPIChatMessage>[]): z.infer<typeof apiV1.ChatMessage>[] {
const converted: z.infer<typeof apiV1.ChatMessage>[] = [];
for (const m of messages) {
const baseMessage = {
version: 'v1' as const,
chatId: '',
createdAt: new Date().toISOString(),
};
switch (m.role) {
case 'user':
converted.push({
...baseMessage,
role: 'user',
content: m.content ?? '',
});
break;
case 'assistant':
if (m.tool_calls) {
// TODO: handle tool calls
converted.push({
...baseMessage,
role: 'assistant',
tool_calls: m.tool_calls,
agenticSender: m.sender ?? undefined,
agenticResponseType: m.response_type ?? 'internal',
});
} else {
converted.push({
...baseMessage,
role: 'assistant',
content: m.content ?? '',
agenticSender: m.sender ?? undefined,
agenticResponseType: m.response_type ?? 'internal',
});
}
break;
case 'tool':
converted.push({
...baseMessage,
role: 'tool',
content: m.content ?? '',
tool_call_id: m.tool_call_id ?? '',
tool_name: m.tool_name ?? '',
});
break;
}
}
return converted;
}
export function convertFromApiToAgenticApiMessages(messages: z.infer<typeof ApiMessage>[]): z.infer<typeof AgenticAPIChatMessage>[] {
return messages.map(m => {
switch (m.role) {
case 'system':
return {
role: 'system',
content: m.content,
tool_calls: null,
tool_call_id: null,
tool_name: null,
sender: null,
};
case 'user':
return {
role: 'user',
content: m.content,
tool_calls: null,
tool_call_id: null,
tool_name: null,
sender: null,
};
case 'assistant':
if ('tool_calls' in m) {
return {
role: 'assistant',
content: m.content ?? null,
tool_calls: m.tool_calls,
tool_call_id: null,
tool_name: null,
sender: m.agenticSender ?? null,
response_type: m.agenticResponseType ?? 'external',
};
} else {
return {
role: 'assistant',
content: m.content ?? null,
sender: m.agenticSender ?? null,
response_type: m.agenticResponseType ?? 'external',
tool_call_id: null,
tool_calls: null,
tool_name: null,
};
}
case 'tool':
return {
role: 'tool',
content: m.content ?? null,
tool_calls: null,
tool_call_id: m.tool_call_id ?? null,
tool_name: m.tool_name ?? null,
sender: null,
};
default:
return {
role: "user",
content: "foo",
tool_calls: null,
tool_call_id: null,
tool_name: null,
sender: null,
};
}
});
}
export function convertFromAgenticApiToApiMessages(messages: z.infer<typeof AgenticAPIChatMessage>[]): z.infer<typeof ApiMessage>[] {
const converted: z.infer<typeof ApiMessage>[] = [];
for (const m of messages) {
switch (m.role) {
case 'user':
converted.push({
role: 'user',
content: m.content ?? '',
});
break;
case 'assistant':
if (m.tool_calls) {
converted.push({
role: 'assistant',
tool_calls: m.tool_calls,
agenticSender: m.sender ?? undefined,
agenticResponseType: m.response_type ?? 'internal',
});
} else {
converted.push({
role: 'assistant',
content: m.content ?? '',
agenticSender: m.sender ?? undefined,
agenticResponseType: m.response_type ?? 'internal',
});
}
break;
case 'tool':
converted.push({
role: 'tool',
content: m.content ?? '',
tool_call_id: m.tool_call_id ?? '',
tool_name: m.tool_name ?? '',
});
break;
}
}
return converted;
}

View file

@ -0,0 +1,144 @@
import { z } from "zod";
import { Workflow } from "./workflow_types";
import { apiV1 } from "rowboat-shared"
import { AgenticAPIChatMessage } from "./agents_api_types";
import { convertToAgenticAPIChatMessages } from "./agents_api_types";
export const CopilotWorkflow = Workflow.omit({
lastUpdatedAt: true,
projectId: true,
});export const CopilotUserMessage = z.object({
role: z.literal('user'),
content: z.string(),
});
export const CopilotAssistantMessageTextPart = z.object({
type: z.literal("text"),
content: z.string(),
});
export const CopilotAssistantMessageActionPart = z.object({
type: z.literal("action"),
content: z.object({
config_type: z.union([z.literal('tool'), z.literal('agent'), z.literal('prompt')]),
action: z.union([z.literal('create_new'), z.literal('edit')]),
name: z.string(),
change_description: z.string(),
config_changes: z.record(z.string(), z.unknown()),
error: z.string().optional(),
})
});
export const CopilotAssistantMessage = z.object({
role: z.literal('assistant'),
content: z.object({
thoughts: z.string().optional(),
response: z.array(z.union([CopilotAssistantMessageTextPart, CopilotAssistantMessageActionPart])),
}),
});
export const CopilotMessage = z.union([CopilotUserMessage, CopilotAssistantMessage]);
export const CopilotApiMessage = z.object({
role: z.union([z.literal('assistant'), z.literal('user')]),
content: z.string(),
});
export const CopilotChatContext = z.union([
z.object({
type: z.literal('chat'),
messages: z.array(apiV1.ChatMessage),
}),
z.object({
type: z.literal('agent'),
name: z.string(),
}),
z.object({
type: z.literal('tool'),
name: z.string(),
}),
z.object({
type: z.literal('prompt'),
name: z.string(),
}),
]);
export const CopilotApiChatContext = z.union([
z.object({
type: z.literal('chat'),
messages: z.array(AgenticAPIChatMessage),
}),
z.object({
type: z.literal('agent'),
agentName: z.string(),
}),
z.object({
type: z.literal('tool'),
toolName: z.string(),
}),
z.object({
type: z.literal('prompt'),
promptName: z.string(),
}),
]);
export const CopilotAPIRequest = z.object({
messages: z.array(CopilotApiMessage),
workflow_schema: z.string(),
current_workflow_config: z.string(),
context: CopilotApiChatContext.nullable(),
});
export const CopilotAPIResponse = z.union([
z.object({
response: z.string(),
}),
z.object({
error: z.string(),
}),
]);
export function convertToCopilotApiChatContext(context: z.infer<typeof CopilotChatContext>): z.infer<typeof CopilotApiChatContext> {
switch (context.type) {
case 'chat':
return {
type: 'chat',
messages: convertToAgenticAPIChatMessages(context.messages),
};
case 'agent':
return {
type: 'agent',
agentName: context.name,
};
case 'tool':
return {
type: 'tool',
toolName: context.name,
};
case 'prompt':
return {
type: 'prompt',
promptName: context.name,
};
}
}
export function convertToCopilotApiMessage(message: z.infer<typeof CopilotMessage>): z.infer<typeof CopilotApiMessage> {
return {
role: message.role,
content: JSON.stringify(message.content),
};
}
export function convertToCopilotMessage(message: z.infer<typeof CopilotApiMessage>): z.infer<typeof CopilotMessage> {
switch (message.role) {
case 'assistant':
return CopilotAssistantMessage.parse({
role: 'assistant',
content: JSON.parse(message.content),
});
case 'user':
return {
role: 'user',
content: message.content,
};
default:
throw new Error(`Unknown role: ${message.role}`);
}
}
export function convertToCopilotWorkflow(workflow: z.infer<typeof Workflow>): z.infer<typeof CopilotWorkflow> {
const { lastUpdatedAt, projectId, ...rest } = workflow;
return {
...rest,
};
}

View file

@ -0,0 +1,78 @@
import { z } from "zod";
export const DataSource = z.object({
name: z.string(),
projectId: z.string(),
active: z.boolean().default(true),
status: z.union([
z.literal('pending'),
z.literal('ready'),
z.literal('error'),
z.literal('deleted'),
]),
version: z.number(),
error: z.string().optional(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime().optional(),
attempts: z.number(),
lastAttemptAt: z.string().datetime().optional(),
pendingRefresh: z.boolean().default(false).optional(),
data: z.discriminatedUnion('type', [
z.object({
type: z.literal('urls'),
}),
z.object({
type: z.literal('files'),
}),
]),
});export const DataSourceDoc = z.object({
sourceId: z.string(),
name: z.string(),
version: z.number(),
status: z.union([
z.literal('pending'),
z.literal('ready'),
z.literal('error'),
z.literal('deleted'),
]),
content: z.string().optional(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime().optional(),
error: z.string().optional(),
data: z.discriminatedUnion('type', [
z.object({
type: z.literal('url'),
url: z.string(),
}),
z.object({
type: z.literal('file'),
name: z.string(),
size: z.number(),
mimeType: z.string(),
s3Key: z.string(),
}),
]),
});
export const EmbeddingDoc = z.object({
content: z.string(),
sourceId: z.string(),
embeddings: z.array(z.number()),
metadata: z.object({
sourceURL: z.string(),
title: z.string(),
score: z.number().optional(),
}),
});
export const EmbeddingRecord = z.object({
id: z.string().uuid(),
vector: z.array(z.number()),
payload: z.object({
projectId: z.string(),
sourceId: z.string(),
docId: z.string(),
content: z.string(),
title: z.string(),
name: z.string(),
}),
});

View file

@ -0,0 +1,26 @@
import { z } from "zod";
export const Project = z.object({
_id: z.string().uuid(),
name: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
createdByUserId: z.string(),
secret: z.string(),
chatClientId: z.string(),
webhookUrl: z.string().optional(),
publishedWorkflowId: z.string().optional(),
nextWorkflowNumber: z.number().optional(),
testRunCounter: z.number().default(0),
});export const ProjectMember = z.object({
userId: z.string(),
projectId: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});
export const ApiKey = z.object({
projectId: z.string(),
key: z.string(),
createdAt: z.string().datetime(),
lastUsedAt: z.string().datetime().optional(),
});

View file

@ -0,0 +1,52 @@
import { z } from "zod";
export const TestScenario = z.object({
projectId: z.string(),
name: z.string().min(1, "Name cannot be empty"),
description: z.string().min(1, "Description cannot be empty"),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});
export const TestProfile = z.object({
projectId: z.string(),
name: z.string().min(1, "Name cannot be empty"),
context: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
mockTools: z.boolean(),
mockPrompt: z.string().optional(),
});
export const TestSimulation = z.object({
projectId: z.string(),
name: z.string().min(1, "Name cannot be empty"),
scenarioId: z.string(),
profileId: z.string().nullable(),
passCriteria: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});
export const TestRun = z.object({
projectId: z.string(),
name: z.string(),
simulationIds: z.array(z.string()),
workflowId: z.string(),
status: z.enum(['pending', 'running', 'completed', 'cancelled', 'failed', 'error']),
startedAt: z.string(),
completedAt: z.string().optional(),
aggregateResults: z.object({
total: z.number(),
passCount: z.number(),
failCount: z.number(),
}).optional(),
});
export const TestResult = z.object({
projectId: z.string(),
runId: z.string(),
simulationId: z.string(),
result: z.union([z.literal('pass'), z.literal('fail')]),
details: z.string()
});

View file

@ -0,0 +1,32 @@
import { z } from "zod";
import { apiV1 } from "rowboat-shared"
export const GetInformationToolResultItem = z.object({
title: z.string(),
name: z.string(),
content: z.string(),
docId: z.string(),
sourceId: z.string(),
});export const GetInformationToolResult = z.object({
results: z.array(GetInformationToolResultItem)
});
export const WebpageCrawlResponse = z.object({
title: z.string(),
content: z.string(),
});
export const ClientToolCallRequestBody = z.object({
toolCall: apiV1.AssistantMessageWithToolCalls.shape.tool_calls.element,
});
export const ClientToolCallJwt = z.object({
requestId: z.string().uuid(),
projectId: z.string(),
bodyHash: z.string(),
iat: z.number(),
exp: z.number(),
});
export const ClientToolCallRequest = z.object({
requestId: z.string().uuid(),
content: z.string(), // json stringified ClientToolCallRequestBody
});
export const ClientToolCallResponse = z.unknown();

View file

@ -0,0 +1,118 @@
import { CoreMessage, ToolCallPart } from "ai";
import { z } from "zod";
import { apiV1 } from "rowboat-shared";
export const PlaygroundChat = z.object({
createdAt: z.string().datetime(),
projectId: z.string(),
title: z.string().optional(),
messages: z.array(apiV1.ChatMessage),
simulated: z.boolean().default(false).optional(),
simulationScenario: z.string().optional(),
simulationComplete: z.boolean().default(false).optional(),
agenticState: z.unknown().optional(),
systemMessage: z.string().optional(),
});
export const Webpage = z.object({
_id: z.string(),
title: z.string(),
contentSimple: z.string(),
lastUpdatedAt: z.string().datetime(),
});
export const ChatClientId = z.object({
_id: z.string(),
projectId: z.string(),
});
export type WithStringId<T> = T & { _id: string };
export function convertToCoreMessages(messages: z.infer<typeof apiV1.ChatMessage>[]): CoreMessage[] {
// convert to core messages
const coreMessages: CoreMessage[] = [];
for (const m of messages) {
switch (m.role) {
case 'system':
coreMessages.push({
role: 'system',
content: m.content,
});
break;
case 'user':
coreMessages.push({
role: 'user',
content: m.content,
});
break;
case 'assistant':
if ('tool_calls' in m) {
const toolCallParts: ToolCallPart[] = m.tool_calls.map((toolCall) => ({
type: 'tool-call',
toolCallId: toolCall.id,
toolName: toolCall.function.name,
args: JSON.parse(toolCall.function.arguments),
}));
if (m.content) {
coreMessages.push({
role: 'assistant',
content: [
{
type: 'text',
text: m.content,
},
...toolCallParts,
]
});
} else {
coreMessages.push({
role: 'assistant',
content: toolCallParts,
});
}
} else {
coreMessages.push({
role: 'assistant',
content: m.content,
});
}
break;
case 'tool':
coreMessages.push({
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: m.tool_call_id,
toolName: m.tool_name,
result: JSON.parse(m.content),
}
]
});
break;
}
}
return coreMessages;
}
export const ApiMessage = z.union([
apiV1.SystemMessage,
apiV1.UserMessage,
apiV1.AssistantMessage,
apiV1.AssistantMessageWithToolCalls,
apiV1.ToolMessage,
]);
export const ApiRequest = z.object({
messages: z.array(ApiMessage),
state: z.unknown(),
skipToolCalls: z.boolean().nullable().optional(),
maxTurns: z.number().nullable().optional(),
workflowId: z.string().nullable().optional(),
testProfileId: z.string().nullable().optional(),
});
export const ApiResponse = z.object({
messages: z.array(ApiMessage),
state: z.unknown(),
});

View file

@ -0,0 +1,128 @@
import { z } from "zod";
export const WorkflowAgent = z.object({
name: z.string(),
type: z.union([
z.literal('conversation'),
z.literal('post_process'),
z.literal('escalation'),
]),
description: z.string(),
disabled: z.boolean().default(false).optional(),
instructions: z.string(),
examples: z.string().optional(),
model: z.union([
z.literal('gpt-4o'),
z.literal('gpt-4o-mini'),
]),
locked: z.boolean().default(false).describe('Whether this agent is locked and cannot be deleted').optional(),
toggleAble: z.boolean().default(true).describe('Whether this agent can be enabled or disabled').optional(),
global: z.boolean().default(false).describe('Whether this agent is a global agent, in which case it cannot be connected to other agents').optional(),
ragDataSources: z.array(z.string()).optional(),
ragReturnType: z.union([z.literal('chunks'), z.literal('content')]).default('chunks'),
ragK: z.number().default(3),
controlType: z.union([z.literal('retain'), z.literal('relinquish_to_parent'), z.literal('relinquish_to_start')]).default('retain').describe('Whether this agent retains control after a turn, relinquishes to the parent agent, or relinquishes to the start agent'),
});
export const WorkflowPrompt = z.object({
name: z.string(),
type: z.union([
z.literal('base_prompt'),
z.literal('style_prompt'),
]),
prompt: z.string(),
});
export const WorkflowTool = z.object({
name: z.string(),
description: z.string(),
mockTool: z.boolean().default(false).optional(),
autoSubmitMockedResponse: z.boolean().default(false).optional(),
mockInstructions: z.string().optional(),
parameters: z.object({
type: z.literal('object'),
properties: z.record(z.object({
type: z.string(),
description: z.string(),
})),
required: z.array(z.string()).optional(),
}),
});
export const Workflow = z.object({
name: z.string().optional(),
agents: z.array(WorkflowAgent),
prompts: z.array(WorkflowPrompt),
tools: z.array(WorkflowTool),
startAgent: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
projectId: z.string(),
});
export const WorkflowTemplate = Workflow
.omit({
projectId: true,
lastUpdatedAt: true,
createdAt: true,
})
.extend({
name: z.string(),
description: z.string(),
});
export const ConnectedEntity = z.object({
type: z.union([z.literal('tool'), z.literal('prompt'), z.literal('agent')]),
name: z.string(),
});
export function sanitizeTextWithMentions(
text: string,
workflow: {
agents: z.infer<typeof WorkflowAgent>[],
tools: z.infer<typeof WorkflowTool>[],
prompts: z.infer<typeof WorkflowPrompt>[],
},
): {
sanitized: string;
entities: z.infer<typeof ConnectedEntity>[];
} {
// Regex to match [@type:name](#type:something) pattern where type is tool/prompt/agent
const mentionRegex = /\[@(tool|prompt|agent):([^\]]+)\]\(#mention\)/g;
const seen = new Set<string>();
// collect entities
const entities = Array
.from(text.matchAll(mentionRegex))
.filter(match => {
if (seen.has(match[0])) {
return false;
}
seen.add(match[0]);
return true;
})
.map(match => {
return {
type: match[1] as 'tool' | 'prompt' | 'agent',
name: match[2],
};
})
.filter(entity => {
seen.add(entity.name);
if (entity.type === 'agent') {
return workflow.agents.some(a => a.name === entity.name);
} else if (entity.type === 'tool') {
return workflow.tools.some(t => t.name === entity.name);
} else if (entity.type === 'prompt') {
return workflow.prompts.some(p => p.name === entity.name);
}
return false;
})
// sanitize text
for (const entity of entities) {
const id = `${entity.type}:${entity.name}`;
const textToReplace = `[@${id}](#mention)`;
text = text.replace(textToReplace, `[@${id}]`);
}
return {
sanitized: text,
entities,
};
}

View file

@ -0,0 +1,9 @@
import { S3Client } from "@aws-sdk/client-s3";
export const uploadsS3Client = new S3Client({
region: process.env.UPLOADS_AWS_REGION || 'us-east-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
},
});

View file

@ -1,13 +1,28 @@
import { AgenticAPIChatMessage, AgenticAPIChatRequest, AgenticAPIChatResponse, ClientToolCallJwt, ClientToolCallRequest, ClientToolCallRequestBody, convertFromAgenticAPIChatMessages, Workflow } from "@/app/lib/types";
import { convertFromAgenticAPIChatMessages } from "./types/agents_api_types";
import { ClientToolCallRequest } from "./types/tool_types";
import { ClientToolCallJwt, GetInformationToolResult } from "./types/tool_types";
import { ClientToolCallRequestBody } from "./types/tool_types";
import { AgenticAPIChatResponse } from "./types/agents_api_types";
import { AgenticAPIChatRequest } from "./types/agents_api_types";
import { Workflow, WorkflowAgent } from "./types/workflow_types";
import { AgenticAPIChatMessage } from "./types/agents_api_types";
import { z } from "zod";
import { projectsCollection } from "./mongodb";
import { dataSourceDocsCollection, dataSourcesCollection, projectsCollection } from "./mongodb";
import { apiV1 } from "rowboat-shared";
import { SignJWT } from "jose";
import crypto from "crypto";
import { ObjectId } from "mongodb";
import { embeddingModel } from "./embedding";
import { embed, generateObject } from "ai";
import { qdrantClient } from "./qdrant";
import { EmbeddingRecord } from "./types/datasource_types";
import { ApiMessage } from "./types/types";
import { openai } from "@ai-sdk/openai";
import { TestProfile } from "./types/testing_types";
export async function callClientToolWebhook(
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number],
messages: z.infer<typeof apiV1.ChatMessage>[],
messages: z.infer<typeof ApiMessage>[],
projectId: string,
): Promise<unknown> {
const project = await projectsCollection.findOne({
@ -98,4 +113,157 @@ export async function getAgenticApiResponse(
state: result.state,
rawAPIResponse: result,
};
}
export async function runRAGToolCall(
projectId: string,
query: string,
sourceIds: string[],
returnType: z.infer<typeof WorkflowAgent>['ragReturnType'],
k: number,
): Promise<z.infer<typeof GetInformationToolResult>> {
// create embedding for question
const embedResult = await embed({
model: embeddingModel,
value: query,
});
// fetch all data sources for this project
const sources = await dataSourcesCollection.find({
projectId: projectId,
active: true,
}).toArray();
const validSourceIds = sources
.filter(s => sourceIds.includes(s._id.toString())) // id should be in sourceIds
.filter(s => s.active) // should be active
.map(s => s._id.toString());
// if no sources found, return empty response
if (validSourceIds.length === 0) {
return {
results: [],
};
}
// perform qdrant vector search
const qdrantResults = await qdrantClient.query("embeddings", {
query: embedResult.embedding,
filter: {
must: [
{ key: "projectId", match: { value: projectId } },
{ key: "sourceId", match: { any: validSourceIds } },
],
},
limit: k,
with_payload: true,
});
// if return type is chunks, return the chunks
let results = qdrantResults.points.map((point) => {
const { title, name, content, docId, sourceId } = point.payload as z.infer<typeof EmbeddingRecord>['payload'];
return {
title,
name,
content,
docId,
sourceId,
};
});
if (returnType === 'chunks') {
return {
results,
};
}
// otherwise, fetch the doc contents from mongodb
const docs = await dataSourceDocsCollection.find({
_id: { $in: results.map(r => new ObjectId(r.docId)) },
}).toArray();
// map the results to the docs
results = results.map(r => {
const doc = docs.find(d => d._id.toString() === r.docId);
return {
...r,
content: doc?.content || '',
};
});
return {
results,
};
}
// create a PrefixLogger class that wraps console.log with a prefix
// and allows chaining with a parent logger
export class PrefixLogger {
private prefix: string;
private parent: PrefixLogger | null;
constructor(prefix: string, parent: PrefixLogger | null = null) {
this.prefix = prefix;
this.parent = parent;
}
log(...args: any[]) {
const timestamp = new Date().toISOString();
const prefix = '[' + this.prefix + ']';
if (this.parent) {
this.parent.log(prefix, ...args);
} else {
console.log(timestamp, prefix, ...args);
}
}
child(childPrefix: string): PrefixLogger {
return new PrefixLogger(childPrefix, this);
}
}
export async function mockToolResponse(toolId: string, messages: z.infer<typeof ApiMessage>[], mockInstructions: string): Promise<string> {
const prompt = `Given below is a chat between a user and a customer support assistant.
The assistant has requested a tool call with ID {{toolID}}.
Your job is to come up with the data that the tool call should return.
In order to help you mock the responses, the user has provided some contextual information,
and also some instructions on how to mock the tool call.
>>>CHAT_HISTORY
{{messages}}
<<<END_OF_CHAT_HISTORY
>>>MOCK_INSTRUCTIONS
{{mockInstructions}}
<<<END_OF_MOCK_INSTRUCTIONS
The current date is {{date}}.
`
.replace('{{toolID}}', toolId)
.replace(`{{date}}`, new Date().toISOString())
.replace('{{mockInstructions}}', mockInstructions)
.replace('{{messages}}', JSON.stringify(messages.map((m) => {
let tool_calls;
if ('tool_calls' in m && m.role == 'assistant') {
tool_calls = m.tool_calls;
}
let { role, content } = m;
return {
role,
content,
tool_calls,
}
})));
// console.log(prompt);
const { object } = await generateObject({
model: openai("gpt-4o"),
prompt: prompt,
schema: z.object({
result: z.any(),
}),
});
return JSON.stringify(object);
}

View file

@ -1,4 +1,4 @@
import { Spinner } from "@nextui-org/react";
import { Spinner } from "@heroui/react";
export default function Loading() {
// Stack uses React Suspense, which will render this page while user data is being fetched.

View file

@ -1,16 +1,17 @@
'use client';
import { Metadata } from "next";
import { Spinner, Textarea, Button, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider } from "@nextui-org/react";
import { Spinner, Textarea, Button, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider } from "@heroui/react";
import { ReactNode, useEffect, useState, useCallback } from "react";
import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "@/app/actions";
import { CopyButton } from "@/app/lib/components/copy-button";
import { EditableField } from "@/app/lib/components/editable-field";
import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../actions/project_actions";
import { CopyButton } from "../../../lib/components/copy-button";
import { EditableField } from "../../../lib/components/editable-field";
import { EyeIcon, EyeOffIcon, CopyIcon, MoreVerticalIcon, PlusIcon, EllipsisVerticalIcon } from "lucide-react";
import { WithStringId, ApiKey } from "@/app/lib/types";
import { WithStringId } from "../../../lib/types/types";
import { ApiKey } from "../../../lib/types/project_types";
import { z } from "zod";
import { RelativeTime } from "@primer/react";
import { Label } from "@/app/lib/components/label";
import { Label } from "../../../lib/components/label";
export const metadata: Metadata = {
title: "Project config",
@ -23,8 +24,8 @@ export function Section({
title: string;
children: React.ReactNode;
}) {
return <div className="w-full flex flex-col gap-4 border border-gray-300 p-4 rounded-md">
<h2 className="font-semibold pb-2 border-b border-gray-200">{title}</h2>
return <div className="w-full flex flex-col gap-4 border border-border p-4 rounded-md">
<h2 className="font-semibold pb-2 border-b border-border">{title}</h2>
{children}
</div>;
}
@ -222,7 +223,7 @@ export function ApiKeysSection({
API keys are used to authenticate requests to the Rowboat API.
</p>
<Button
onClick={handleCreateKey}
onPress={handleCreateKey}
size="sm"
startContent={<PlusIcon className="w-4 h-4" />}
variant="flat"
@ -234,8 +235,8 @@ export function ApiKeysSection({
<Divider />
{loading && <Spinner size="sm" />}
{!loading && <div className="border rounded-lg text-sm">
<div className="flex items-center border-b p-4">
{!loading && <div className="border border-border rounded-lg text-sm">
<div className="flex items-center border-b border-border p-4">
<div className="flex-[3] font-normal">API Key</div>
<div className="flex-1 font-normal">Created</div>
<div className="flex-1 font-normal">Last Used</div>
@ -252,7 +253,7 @@ export function ApiKeysSection({
</div>}
<div className="flex flex-col">
{keys.map((key) => (
<div key={key._id} className="flex items-start border-b last:border-b-0 p-4">
<div key={key._id} className="flex items-start border-b border-border last:border-b-0 p-4">
<div className="flex-[3] p-2">
<ApiKeyDisplay apiKey={key.key} />
</div>
@ -271,8 +272,9 @@ export function ApiKeysSection({
</DropdownTrigger>
<DropdownMenu>
<DropdownItem
key='delete'
className="text-destructive"
onClick={() => handleDeleteKey(key._id)}
onPress={() => handleDeleteKey(key._id)}
>
Delete
</DropdownItem>
@ -357,7 +359,7 @@ export function SecretSection({
size="sm"
variant="flat"
color="warning"
onClick={handleRotateSecret}
onPress={handleRotateSecret}
isDisabled={loading}
>
Rotate
@ -425,8 +427,10 @@ export function WebhookUrlSection({
export function ChatWidgetSection({
projectId,
chatWidgetHost,
}: {
projectId: string;
chatWidgetHost: string;
}) {
const [loading, setLoading] = useState(false);
const [chatClientId, setChatClientId] = useState<string | null>(null);
@ -446,7 +450,7 @@ export function ChatWidgetSection({
};
(function(d) {
var s = d.createElement('script');
s.src = 'https://chat.rowboatlabs.com/bootstrap.js';
s.src = '${chatWidgetHost}/api/bootstrap.js';
s.async = true;
d.getElementsByTagName('head')[0].appendChild(s);
})(document);
@ -565,11 +569,15 @@ export function DeleteProjectSection({
export default function App({
projectId,
useChatWidget,
chatWidgetHost,
}: {
projectId: string;
useChatWidget: boolean;
chatWidgetHost: string;
}) {
return <div className="flex flex-col h-full">
<div className="shrink-0 flex justify-between items-center pb-4 border-b border-b-gray-100">
<div className="shrink-0 flex justify-between items-center pb-4 border-b border-border">
<div className="flex flex-col">
<h1 className="text-lg">Project config</h1>
</div>
@ -580,7 +588,7 @@ export default function App({
<SecretSection projectId={projectId} />
<ApiKeysSection projectId={projectId} />
<WebhookUrlSection projectId={projectId} />
{/* <ChatWidgetSection projectId={projectId} /> */}
{useChatWidget && <ChatWidgetSection projectId={projectId} chatWidgetHost={chatWidgetHost} />}
<DeleteProjectSection projectId={projectId} />
</div>
</div>

View file

@ -1,5 +1,7 @@
import { Metadata } from "next";
import App from "./app";
import { USE_CHAT_WIDGET } from "@/app/lib/feature_flags";
export const metadata: Metadata = {
title: "Project config",
};
@ -11,5 +13,9 @@ export default function Page({
projectId: string;
};
}) {
return <App projectId={params.projectId} />;
return <App
projectId={params.projectId}
useChatWidget={USE_CHAT_WIDGET}
chatWidgetHost={process.env.CHAT_WIDGET_HOST || ''}
/>;
}

View file

@ -1,4 +1,5 @@
import { Nav } from "./nav";
import { USE_RAG } from "@/app/lib/feature_flags";
export default async function Layout({
params,
@ -7,12 +8,12 @@ export default async function Layout({
params: { projectId: string }
children: React.ReactNode
}) {
const useDataSources = process.env.USE_DATA_SOURCES === 'true';
const useRag = USE_RAG;
return <div className="flex h-full">
<Nav projectId={params.projectId} useDataSources={useDataSources} />
<div className="grow p-2 overflow-auto bg-white rounded-tl-lg">
<Nav projectId={params.projectId} useRag={useRag} />
<div className="grow p-2 overflow-auto bg-background dark:bg-background rounded-tl-lg">
{children}
</div>
</div >;
</div>;
}

View file

@ -1,82 +1,89 @@
'use client';
import { usePathname } from "next/navigation";
import { Tooltip } from "@nextui-org/react";
import { Tooltip } from "@heroui/react";
import Link from "next/link";
import clsx from "clsx";
import { DatabaseIcon, SettingsIcon, WorkflowIcon } from "lucide-react";
import { DatabaseIcon, SettingsIcon, WorkflowIcon, PlayIcon } from "lucide-react";
import MenuItem from "../../lib/components/menu-item";
function NavLink({ href, label, icon, collapsed, selected = false }: { href: string, label: string, icon: React.ReactNode, collapsed: boolean, selected?: boolean }) {
return <Link
href={href}
className={clsx("flex px-2 py-2 gap-2 items-center rounded-lg text-sm hover:text-black", {
"text-black": selected,
"justify-center": collapsed,
})}
>
{collapsed && <Tooltip content={label} showArrow placement="right">
<div className="shrink-0">
{icon}
</div>
</Tooltip>}
{!collapsed && <div className="shrink-0">
{icon}
</div>}
{!collapsed && <div className="truncate">
{label}
</div>}
</Link>;
function NavLink({ href, label, icon, collapsed, selected = false }: {
href: string,
label: string,
icon: React.ReactNode,
collapsed: boolean,
selected?: boolean
}) {
if (collapsed) {
return (
<Tooltip content={label} showArrow placement="right">
<Link href={href} className="block">
<MenuItem
icon={icon}
selected={selected}
onClick={() => {}}
>
<span className="sr-only">{label}</span>
</MenuItem>
</Link>
</Tooltip>
);
}
return (
<Link href={href}>
<MenuItem
icon={icon}
selected={selected}
onClick={() => {}}
>
{label}
</MenuItem>
</Link>
);
}
export default function Menu({
projectId,
collapsed,
useDataSources,
useRag,
}: {
projectId: string;
collapsed: boolean;
useDataSources: boolean;
useRag: boolean;
}) {
const pathname = usePathname();
return <div className="flex flex-col text-gray-500">
{/* <NavLink
href={`/projects/${projectId}/playground`}
label="Playground"
collapsed={collapsed}
icon=<svg className="w-[24px] h-[24px]" 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="M9 17h6l3 3v-3h2V9h-2M4 4h11v8H9l-3 3v-3H4V4Z" />
</svg>
selected={pathname.startsWith(`/projects/${projectId}/playground`)}
/> */}
<NavLink
href={`/projects/${projectId}/workflow`}
label="Workflow"
collapsed={collapsed}
icon={<WorkflowIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/workflow`)}
/>
{useDataSources && <NavLink
href={`/projects/${projectId}/sources`}
label="Data sources"
collapsed={collapsed}
icon={<DatabaseIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/sources`)}
/>}
<NavLink
href={`/projects/${projectId}/config`}
label="Config"
collapsed={collapsed}
icon={<SettingsIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/config`)}
/>
{/*<NavLink
href={`/projects/${projectId}/integrate`}
label="Integrate"
collapsed={collapsed}
icon=<svg className="w-[24px] h-[24px]" 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="m8 8-4 4 4 4m8 0 4-4-4-4m-2-3-4 14" />
</svg>
selected={pathname.startsWith(`/projects/${projectId}/integrate`)}
/>*/}
</div>;
}
return (
<div className="flex flex-col gap-1">
<NavLink
href={`/projects/${projectId}/workflow`}
label="Build"
collapsed={collapsed}
icon={<WorkflowIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/workflow`)}
/>
<NavLink
href={`/projects/${projectId}/test`}
label="Test"
collapsed={collapsed}
icon={<PlayIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/test`)}
/>
{useRag && (
<NavLink
href={`/projects/${projectId}/sources`}
label="Connect"
collapsed={collapsed}
icon={<DatabaseIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/sources`)}
/>
)}
<NavLink
href={`/projects/${projectId}/config`}
label="Integrate"
collapsed={collapsed}
icon={<SettingsIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/config`)}
/>
</div>
);
}

View file

@ -1,18 +1,18 @@
'use client';
import { Tooltip } from "@nextui-org/react";
import { Tooltip } from "@heroui/react";
import Link from "next/link";
import { useEffect, useState } from "react";
import clsx from "clsx";
import Menu from "./menu";
import { getProjectConfig } from "@/app/actions";
import { ChevronsLeftIcon, ChevronsRightIcon, FolderOpenIcon, PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
import { getProjectConfig } from "../../actions/project_actions";
import { FolderOpenIcon, PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
export function Nav({
projectId,
useDataSources,
useRag,
}: {
projectId: string;
useDataSources: boolean;
useRag: boolean;
}) {
const [collapsed, setCollapsed] = useState(false);
const [projectName, setProjectName] = useState<string | null>(null);
@ -29,7 +29,7 @@ export function Nav({
setCollapsed(!collapsed);
}
return <div className={clsx("shrink-0 flex flex-col gap-2 border-r-1 border-gray-100 relative p-2", {
return <div className={clsx("shrink-0 flex flex-col gap-2 border-r border-border relative p-2", {
"w-40": !collapsed,
"w-10": collapsed
})}>
@ -40,11 +40,8 @@ export function Nav({
</button>
</Tooltip>
{!collapsed && <div className="flex flex-col gap-1">
<Tooltip content="Change project" showArrow placement="bottom-end">
<Link className="relative group flex flex-col px-2 py-2 border border-gray-200 rounded-md hover:border-gray-500" href="/projects">
<div className="absolute top-[-7px] left-1 px-1 bg-gray-100 text-xs text-gray-400 group-hover:text-gray-600">
Project
</div>
<Tooltip content="Change project" showArrow placement="bottom-end" delay={0} closeDelay={0}>
<Link className="relative group flex flex-col px-2 py-2 border border-gray-200 rounded-md hover:border-gray-500 transition-colors duration-100" href="/projects">
<div className="flex flex-row items-center gap-2">
<FolderOpenIcon size={16} />
<div className="truncate text-sm">
@ -59,6 +56,6 @@ export function Nav({
<FolderOpenIcon size={16} className="ml-1" />
</Link>
</Tooltip>}
<Menu projectId={projectId} collapsed={collapsed} useDataSources={useDataSources} />
<Menu projectId={projectId} collapsed={collapsed} useRag={useRag} />
</div>;
}

View file

@ -1,15 +1,18 @@
'use client';
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner } from "@nextui-org/react";
import { useEffect, useState, useMemo } from "react";
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner } from "@heroui/react";
import { useEffect, useState, useMemo, useCallback } from "react";
import { z } from "zod";
import { PlaygroundChat, SimulationData, Workflow } from "@/app/lib/types";
import { SimulateScenarioOption, SimulateURLOption } from "./simulation-options";
import { PlaygroundChat } from "../../../lib/types/types";
import { Workflow } from "../../../lib/types/workflow_types";
import { Chat } from "./chat";
import { useSearchParams } from "next/navigation";
import { useSearchParams, useRouter } from "next/navigation";
import { ActionButton, Pane } from "../workflow/pane";
import { apiV1 } from "rowboat-shared";
import { EllipsisVerticalIcon, MessageSquarePlusIcon, PlayIcon } from "lucide-react";
import { getScenario } from "../../../actions/testing_actions";
import clsx from "clsx";
import { TestProfile, TestScenario } from "@/app/lib/types/testing_types";
import { WithStringId } from "@/app/lib/types/types";
function SimulateLabel() {
return <span>Simulate<sup className="pl-1">beta</sup></span>;
}
@ -27,12 +30,8 @@ export function App({
workflow: z.infer<typeof Workflow>;
messageSubscriber?: (messages: z.infer<typeof apiV1.ChatMessage>[]) => void;
}) {
const searchParams = useSearchParams();
const initialChatId = useMemo(() => searchParams.get('chatId'), [searchParams]);
const [existingChatId, setExistingChatId] = useState<string | null>(initialChatId);
const [loadingChat, setLoadingChat] = useState<boolean>(false);
const [viewSimulationMenu, setViewSimulationMenu] = useState<boolean>(false);
const [counter, setCounter] = useState<number>(0);
const [testProfile, setTestProfile] = useState<z.infer<typeof TestProfile> | null>(null);
const [chat, setChat] = useState<z.infer<typeof PlaygroundChat>>({
projectId,
createdAt: new Date().toISOString(),
@ -41,12 +40,45 @@ export function App({
systemMessage: defaultSystemMessage,
});
function handleSimulateButtonClick() {
setViewSimulationMenu(true);
function handleTestProfileChange(profile: WithStringId<z.infer<typeof TestProfile>> | null) {
setTestProfile(profile);
setCounter(counter + 1);
}
// const beginSimulation = useCallback((scenario: string) => {
// setExistingChatId(null);
// setLoadingChat(true);
// setCounter(counter + 1);
// setChat({
// projectId,
// createdAt: new Date().toISOString(),
// messages: [],
// simulated: true,
// simulationScenario: scenario,
// systemMessage: '',
// });
// }, [counter, projectId]);
// useEffect(() => {
// const scenarioId = localStorage.getItem('pendingScenarioId');
// if (scenarioId && projectId) {
// console.log('Scenario Effect triggered:', { scenarioId, projectId });
// getScenario(projectId, scenarioId).then((scenario) => {
// console.log('Scenario data received:', scenario);
// beginSimulation(scenario.description);
// localStorage.removeItem('pendingScenarioId');
// }).catch(error => {
// console.error('Error fetching scenario:', error);
// localStorage.removeItem('pendingScenarioId');
// });
// }
// }, [projectId, beginSimulation]);
if (hidden) {
return <></>;
}
function handleNewChatButtonClick() {
setExistingChatId(null);
setViewSimulationMenu(false);
setCounter(counter + 1);
setChat({
projectId,
@ -56,52 +88,32 @@ export function App({
systemMessage: defaultSystemMessage,
});
}
function beginSimulation(data: z.infer<typeof SimulationData>) {
setExistingChatId(null);
setViewSimulationMenu(false);
setCounter(counter + 1);
setChat({
projectId,
createdAt: new Date().toISOString(),
messages: [],
simulated: true,
simulationData: data,
});
}
if (hidden) {
return <></>;
}
return <Pane title={viewSimulationMenu ? <SimulateLabel /> : "Chat"} actions={[
<ActionButton
key="new-chat"
icon={<MessageSquarePlusIcon size={16} />}
onClick={handleNewChatButtonClick}
return (
<Pane
title="PLAYGROUND"
tooltip="Test your agents and see their responses in this interactive chat interface"
actions={[
<ActionButton
key="new-chat"
icon={<MessageSquarePlusIcon size={16} />}
onClick={handleNewChatButtonClick}
>
New chat
</ActionButton>,
]}
>
New chat
</ActionButton>,
!viewSimulationMenu && <ActionButton
key="simulate"
icon={<PlayIcon size={16} />}
onClick={handleSimulateButtonClick}
>
Simulate
</ActionButton>,
]}>
<div className="h-full overflow-auto">
{!viewSimulationMenu && loadingChat && <div className="flex justify-center items-center h-full">
<Spinner />
</div>}
{!viewSimulationMenu && !loadingChat && <Chat
key={existingChatId || 'chat-' + counter}
chat={chat}
initialChatId={existingChatId || null}
projectId={projectId}
workflow={workflow}
messageSubscriber={messageSubscriber}
/>}
{viewSimulationMenu && <SimulateScenarioOption beginSimulation={beginSimulation} projectId={projectId} />}
</div>
</Pane>;
<div className="h-full overflow-auto">
<Chat
key={`chat-${counter}`}
chat={chat}
projectId={projectId}
workflow={workflow}
testProfile={testProfile}
messageSubscriber={messageSubscriber}
onTestProfileChange={handleTestProfileChange}
/>
</div>
</Pane>
);
}

View file

@ -1,28 +1,37 @@
'use client';
import { getAssistantResponse, simulateUserResponse } from "@/app/actions";
import { getAssistantResponse, simulateUserResponse } from "../../../actions/actions";
import { useEffect, useState } from "react";
import { Messages } from "./messages";
import z from "zod";
import { AgenticAPIChatRequest, convertToAgenticAPIChatMessages, convertWorkflowToAgenticAPI, PlaygroundChat, Workflow } from "@/app/lib/types";
import { PlaygroundChat } from "../../../lib/types/types";
import { convertToAgenticAPIChatMessages } from "../../../lib/types/agents_api_types";
import { convertWorkflowToAgenticAPI } from "../../../lib/types/agents_api_types";
import { AgenticAPIChatRequest } from "../../../lib/types/agents_api_types";
import { Workflow } from "../../../lib/types/workflow_types";
import { ComposeBox } from "./compose-box";
import { Button, Spinner } from "@nextui-org/react";
import { Button, Spinner, Tooltip } from "@heroui/react";
import { apiV1 } from "rowboat-shared";
import { CopyAsJsonButton } from "./copy-as-json-button";
import { TestProfile } from "@/app/lib/types/testing_types";
import { ProfileSelector } from "@/app/lib/components/selectors/profile-selector";
import { WithStringId } from "@/app/lib/types/types";
import { XCircleIcon, XIcon } from "lucide-react";
export function Chat({
chat,
initialChatId = null,
projectId,
workflow,
messageSubscriber,
testProfile=null,
onTestProfileChange,
}: {
chat: z.infer<typeof PlaygroundChat>;
initialChatId?: string | null;
projectId: string;
workflow: z.infer<typeof Workflow>;
messageSubscriber?: (messages: z.infer<typeof apiV1.ChatMessage>[]) => void;
testProfile?: z.infer<typeof TestProfile> | null;
onTestProfileChange: (profile: WithStringId<z.infer<typeof TestProfile>> | null) => void;
}) {
const [chatId, setChatId] = useState<string | null>(initialChatId);
const [messages, setMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>(chat.messages);
const [loadingAssistantResponse, setLoadingAssistantResponse] = useState<boolean>(false);
const [loadingUserResponse, setLoadingUserResponse] = useState<boolean>(false);
@ -30,11 +39,11 @@ export function Chat({
const [agenticState, setAgenticState] = useState<unknown>(chat.agenticState || {
last_agent_name: workflow.startAgent,
});
const [showCopySuccess, setShowCopySuccess] = useState(false);
const [fetchResponseError, setFetchResponseError] = useState<string | null>(null);
const [lastAgenticRequest, setLastAgenticRequest] = useState<unknown | null>(null);
const [lastAgenticResponse, setLastAgenticResponse] = useState<unknown | null>(null);
const [systemMessage, setSystemMessage] = useState<string | undefined>(chat.systemMessage);
const [systemMessage, setSystemMessage] = useState<string | undefined>(testProfile?.context);
const [isProfileSelectorOpen, setIsProfileSelectorOpen] = useState(false);
// collect published tool call results
const toolCallResults: Record<string, z.infer<typeof apiV1.ToolMessage>> = {};
@ -49,7 +58,7 @@ export function Chat({
role: 'user',
content: prompt,
version: 'v1',
chatId: chatId ?? '',
chatId: '',
createdAt: new Date().toISOString(),
}];
setMessages(updatedMessages);
@ -60,7 +69,7 @@ export function Chat({
setMessages([...messages, ...results.map((result) => ({
...result,
version: 'v1' as const,
chatId: chatId ?? '',
chatId: '',
createdAt: new Date().toISOString(),
}))]);
}
@ -93,7 +102,7 @@ export function Chat({
role: 'system',
content: systemMessage || '',
version: 'v1' as const,
chatId: chatId ?? '',
chatId: '',
createdAt: new Date().toISOString(),
}, ...messages]),
state: agenticState,
@ -118,7 +127,7 @@ export function Chat({
setMessages([...messages, ...response.messages.map((message) => ({
...message,
version: 'v1' as const,
chatId: chatId ?? '',
chatId: '',
createdAt: new Date().toISOString(),
}))]);
setAgenticState(response.state);
@ -153,14 +162,14 @@ export function Chat({
return () => {
ignore = true;
};
}, [chatId, chat.simulated, messages, projectId, agenticState, workflow, fetchResponseError, systemMessage, simulationComplete]);
}, [chat.simulated, messages, projectId, agenticState, workflow, fetchResponseError, systemMessage, simulationComplete]);
// simulate user turn
useEffect(() => {
let ignore = false;
async function process() {
if (chat.simulationData === undefined) {
if (chat.simulationScenario === undefined) {
return;
}
@ -168,7 +177,7 @@ export function Chat({
setLoadingUserResponse(true);
try {
const response = await simulateUserResponse(projectId, messages, chat.simulationData)
const response = await simulateUserResponse(projectId, messages, chat.simulationScenario)
if (ignore) {
return;
}
@ -183,7 +192,7 @@ export function Chat({
role: 'user',
content: response,
version: 'v1' as const,
chatId: chatId ?? '',
chatId: '',
createdAt: new Date().toISOString(),
}]);
setFetchResponseError(null);
@ -222,7 +231,7 @@ export function Chat({
return () => {
ignore = true;
};
}, [chatId, chat.simulated, messages, projectId, simulationComplete, chat.simulationData]);
}, [chat.simulated, messages, projectId, simulationComplete, chat.simulationScenario]);
// save chat on every assistant message
// useEffect(() => {
@ -271,6 +280,27 @@ export function Chat({
return <div className="relative h-full flex flex-col gap-8 pt-8 overflow-auto">
<CopyAsJsonButton onCopy={handleCopyChat} />
<div className="absolute top-0 left-0 flex items-center gap-1">
<Tooltip content={"Change profile"} placement="right">
<button
className="border border-gray-200 dark:border-gray-800 p-2 rounded-lg text-xs hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={() => setIsProfileSelectorOpen(true)}
>
{`${testProfile?.name || 'Select test profile'}`}
</button>
</Tooltip>
{testProfile && <Tooltip content={"Remove profile"} placement="right">
<button className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300" onClick={() => onTestProfileChange(null)}>
<XIcon className="w-4 h-4" />
</button>
</Tooltip>}
</div>
<ProfileSelector
projectId={projectId}
isOpen={isProfileSelectorOpen}
onOpenChange={setIsProfileSelectorOpen}
onSelect={onTestProfileChange}
/>
<Messages
projectId={projectId}
messages={messages}
@ -280,6 +310,7 @@ export function Chat({
loadingAssistantResponse={loadingAssistantResponse}
loadingUserResponse={loadingUserResponse}
workflow={workflow}
testProfile={testProfile}
onSystemMessageChange={handleSystemMessageChange}
/>
<div className="shrink-0">
@ -289,7 +320,7 @@ export function Chat({
<Button
size="sm"
color="danger"
onClick={() => {
onPress={() => {
setFetchResponseError(null);
}}
>
@ -309,7 +340,7 @@ export function Chat({
<Button
size="sm"
color="danger"
onClick={() => {
onPress={() => {
setSimulationComplete(true);
}}
>

View file

@ -1,6 +1,6 @@
'use client';
import { Button, Spinner, Textarea } from "@nextui-org/react";
import { Button, Spinner, Textarea } from "@heroui/react";
import { CornerDownLeftIcon } from "lucide-react";
import { useRef, useState, useEffect } from "react";
import { apiV1 } from "rowboat-shared";
@ -59,8 +59,8 @@ export function ComposeBox({
size="sm"
isIconOnly
disabled={disabled}
onClick={handleInput}
className="bg-gray-100"
onPress={handleInput}
className="bg-default-100"
>
<CornerDownLeftIcon size={16} />
</Button>}

Some files were not shown because too many files have changed in this diff Show more