mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
commit
4793313daf
169 changed files with 31623 additions and 7937 deletions
30
.env.example
30
.env.example
|
|
@ -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
208
README.md
|
|
@ -3,6 +3,26 @@
|
|||
|
||||
This guide will help you set up and run the RowBoat applications locally using Docker. Please see our [docs](https://docs.rowboatlabs.com/) for more details.
|
||||
|
||||
RowBoat offers several optional services that can be enabled using Docker Compose profiles. You can run multiple profiles simultaneously using:
|
||||
```bash
|
||||
docker compose --profile rag_urls_worker --profile chat_widget --profile tools_webhook up -d
|
||||
```
|
||||
See the relevant sections below for details on each service.
|
||||
|
||||
## Table of Contents
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Local Development Setup](#local-development-setup)
|
||||
- [Python SDK](#option-1-python-sdk)
|
||||
- [HTTP API](#option-2-http-api)
|
||||
- [Optional Features](#optional-features)
|
||||
- [Enable RAG](#enable-rag)
|
||||
- [URL Scraping](#url-scraping)
|
||||
- [File Uploads](#file-uploads)
|
||||
- [Enable Chat Widget](#enable-chat-widget)
|
||||
- [Enable Tools Webhook](#enable-tools-webhook)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Attribution](#attribution)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running RowBoat, ensure you have:
|
||||
|
|
@ -153,6 +173,194 @@ Before running RowBoat, ensure you have:
|
|||
|
||||
The documentation site is available at [http://localhost:8000](http://localhost:8000)
|
||||
|
||||
## Enable RAG
|
||||
|
||||
RowBoat supports RAG capabilities to enhance responses with your custom knowledge base. To enable RAG, you'll need:
|
||||
|
||||
1. **Qdrant Vector Database**
|
||||
- **Option 1**: Use [Qdrant Cloud](https://cloud.qdrant.io/)
|
||||
- Create an account and cluster
|
||||
- Note your cluster URL and API key
|
||||
- **Option 2**: Run Qdrant locally with Docker:
|
||||
```bash
|
||||
docker run -p 6333:6333 qdrant/qdrant
|
||||
```
|
||||
|
||||
2. **Update Environment Variables**
|
||||
```ini
|
||||
USE_RAG=true
|
||||
QDRANT_URL=<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**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
8
apps/chat_widget/.dockerignore
Normal file
8
apps/chat_widget/.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
.env*
|
||||
3
apps/chat_widget/.eslintrc.json
Normal file
3
apps/chat_widget/.eslintrc.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
40
apps/chat_widget/.gitignore
vendored
Normal file
40
apps/chat_widget/.gitignore
vendored
Normal 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
|
||||
68
apps/chat_widget/Dockerfile
Normal file
68
apps/chat_widget/Dockerfile
Normal 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"]
|
||||
36
apps/chat_widget/README.md
Normal file
36
apps/chat_widget/README.md
Normal 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.
|
||||
183
apps/chat_widget/app/api/bootstrap.js/bootstrap.js
vendored
Normal file
183
apps/chat_widget/app/api/bootstrap.js/bootstrap.js
vendored
Normal 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());
|
||||
}
|
||||
35
apps/chat_widget/app/api/bootstrap.js/route.ts
Normal file
35
apps/chat_widget/app/api/bootstrap.js/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
466
apps/chat_widget/app/app.tsx
Normal file
466
apps/chat_widget/app/app.tsx
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
"use client";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { z } from "zod";
|
||||
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Textarea } from "@nextui-org/react";
|
||||
import MarkdownContent from "./markdown-content";
|
||||
|
||||
type Message = {
|
||||
role: "user" | "assistant" | "system" | "tool";
|
||||
content: string;
|
||||
tool_call_id?: string;
|
||||
tool_name?: string;
|
||||
}
|
||||
|
||||
function ChatWindowHeader({
|
||||
chatId,
|
||||
closeChat,
|
||||
closed,
|
||||
setMinimized,
|
||||
}: {
|
||||
chatId: string | null;
|
||||
closeChat: () => Promise<void>;
|
||||
closed: boolean;
|
||||
setMinimized: (minimized: boolean) => void;
|
||||
}) {
|
||||
return <div className="shrink-0 flex justify-between items-center gap-2 bg-gray-400 px-2 py-1 rounded-t-lg dark:bg-gray-800">
|
||||
<div className="text-gray-800 dark:text-white">Chat</div>
|
||||
<div className="flex gap-1 items-center">
|
||||
{(chatId && !closed) && <Dropdown>
|
||||
<DropdownTrigger>
|
||||
<button>
|
||||
<svg className="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeWidth="2" d="M6 12h.01m6 0h.01m5.99 0h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu onAction={(key) => {
|
||||
if (key === "close") {
|
||||
closeChat();
|
||||
}
|
||||
}}>
|
||||
<DropdownItem key="close">
|
||||
Close chat
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>}
|
||||
<button onClick={() => setMinimized(true)}>
|
||||
<svg className="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m19 9-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function LoadingAssistantResponse() {
|
||||
return <div className="flex gap-2 items-end">
|
||||
<div className="shrink-0 w-10 h-10 bg-gray-400 rounded-full dark:bg-gray-800"></div>
|
||||
<div className="bg-white rounded-md dark:bg-gray-800 text-gray-800 dark:text-white mr-[20%] rounded-bl-none p-2">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-400 dark:bg-gray-600 animate-bounce"></div>
|
||||
<div className="w-2 h-2 rounded-full bg-gray-400 dark:bg-gray-600 animate-bounce [animation-delay:0.2s]"></div>
|
||||
<div className="w-2 h-2 rounded-full bg-gray-400 dark:bg-gray-600 animate-bounce [animation-delay:0.4s]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
function AssistantMessage({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="flex flex-col gap-1 items-start">
|
||||
<div className="text-gray-800 dark:text-white text-xs pl-2">Assistant</div>
|
||||
<div className="bg-gray-200 rounded-md dark:bg-gray-800 text-gray-800 dark:text-white mr-[20%] rounded-bl-none p-2">
|
||||
{typeof children === 'string' ? <MarkdownContent content={children} /> : children}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function UserMessage({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="flex flex-col gap-1 items-end">
|
||||
<div className="bg-gray-200 rounded-md dark:bg-gray-800 text-gray-800 dark:text-white ml-[20%] rounded-br-none p-2">
|
||||
{typeof children === 'string' ? <MarkdownContent content={children} /> : children}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
function ChatWindowMessages({
|
||||
messages,
|
||||
loadingAssistantResponse,
|
||||
}: {
|
||||
messages: Message[];
|
||||
loadingAssistantResponse: boolean;
|
||||
}) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
return <div className="flex flex-col grow p-2 gap-4 overflow-auto">
|
||||
<AssistantMessage>
|
||||
Hello! I'm Rowboat, your personal assistant. How can I help you today?
|
||||
</AssistantMessage>
|
||||
{messages.map((message, index) => {
|
||||
switch (message.role) {
|
||||
case "user":
|
||||
return <UserMessage key={index}>{message.content}</UserMessage>;
|
||||
case "assistant":
|
||||
return <AssistantMessage key={index}>{message.content}</AssistantMessage>;
|
||||
case "system":
|
||||
return null; // Hide system messages from the UI
|
||||
case "tool":
|
||||
return <AssistantMessage key={index}>
|
||||
Tool response ({message.tool_name}): {message.content}
|
||||
</AssistantMessage>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
{loadingAssistantResponse && <LoadingAssistantResponse />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
}
|
||||
|
||||
function ChatWindowInput({
|
||||
handleUserMessage,
|
||||
}: {
|
||||
handleUserMessage: (message: string) => Promise<void>;
|
||||
}) {
|
||||
const [prompt, setPrompt] = useState<string>("");
|
||||
|
||||
function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const input = prompt.trim();
|
||||
setPrompt('');
|
||||
|
||||
handleUserMessage(input);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="bg-white rounded-md dark:bg-gray-900 shrink-0 p-2">
|
||||
<Textarea
|
||||
placeholder="Ask me anything..."
|
||||
minRows={1}
|
||||
maxRows={3}
|
||||
variant="flat"
|
||||
className="w-full"
|
||||
onKeyDown={handleInputKeyDown}
|
||||
value={prompt}
|
||||
onValueChange={setPrompt}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
function ChatWindowBody({
|
||||
chatId,
|
||||
createChat,
|
||||
getAssistantResponse,
|
||||
closed,
|
||||
resetState,
|
||||
messages,
|
||||
setMessages,
|
||||
}: {
|
||||
chatId: string | null;
|
||||
createChat: () => Promise<string>;
|
||||
getAssistantResponse: (chatId: string, message: string) => Promise<Message>;
|
||||
closed: boolean;
|
||||
resetState: () => Promise<void>;
|
||||
messages: Message[];
|
||||
setMessages: (messages: Message[]) => void;
|
||||
}) {
|
||||
const [loadingAssistantResponse, setLoadingAssistantResponse] = useState<boolean>(false);
|
||||
|
||||
async function handleUserMessage(message: string) {
|
||||
const userMessage: Message = { role: "user", content: message };
|
||||
setMessages([...messages, userMessage]);
|
||||
setLoadingAssistantResponse(true);
|
||||
|
||||
let availableChatId = chatId;
|
||||
if (!availableChatId) {
|
||||
availableChatId = await createChat();
|
||||
}
|
||||
|
||||
const response = await getAssistantResponse(availableChatId, message);
|
||||
setMessages([...messages, userMessage, response]);
|
||||
setLoadingAssistantResponse(false);
|
||||
}
|
||||
|
||||
return <div className="flex flex-col grow bg-white rounded-b-lg dark:bg-gray-900 overflow-auto">
|
||||
<ChatWindowMessages messages={messages} loadingAssistantResponse={loadingAssistantResponse} />
|
||||
{!closed && <ChatWindowInput
|
||||
handleUserMessage={handleUserMessage}
|
||||
/>}
|
||||
{closed && <div className="flex flex-col items-center py-4 gap-2">
|
||||
<div className="text-gray-800 dark:text-white">This chat is closed</div>
|
||||
<Button
|
||||
onPress={resetState}
|
||||
>
|
||||
Start new chat
|
||||
</Button>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function ChatWindow({
|
||||
chatId,
|
||||
closed,
|
||||
closeChat,
|
||||
createChat,
|
||||
getAssistantResponse,
|
||||
resetState,
|
||||
messages,
|
||||
setMessages,
|
||||
setMinimized,
|
||||
}: {
|
||||
chatId: string | null;
|
||||
closed: boolean;
|
||||
closeChat: () => Promise<void>;
|
||||
createChat: () => Promise<string>;
|
||||
getAssistantResponse: (chatId: string, message: string) => Promise<Message>;
|
||||
resetState: () => Promise<void>;
|
||||
messages: Message[];
|
||||
setMessages: (messages: Message[]) => void;
|
||||
setMinimized: (minimized: boolean) => void;
|
||||
}) {
|
||||
return <div className="h-full flex flex-col rounded-lg overflow-hidden">
|
||||
<ChatWindowHeader
|
||||
chatId={chatId}
|
||||
closeChat={closeChat}
|
||||
closed={closed}
|
||||
setMinimized={setMinimized}
|
||||
/>
|
||||
<ChatWindowBody
|
||||
chatId={chatId}
|
||||
createChat={createChat}
|
||||
getAssistantResponse={getAssistantResponse}
|
||||
closed={closed}
|
||||
resetState={resetState}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function App({
|
||||
apiUrl,
|
||||
}: {
|
||||
apiUrl: string;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const sessionId = searchParams.get("session_id");
|
||||
const [minimized, setMinimized] = useState(searchParams.get("minimized") === 'true');
|
||||
const [chatId, setChatId] = useState<string | null>(null);
|
||||
const [closed, setClosed] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
|
||||
const fetchLastChat = useCallback(async (): Promise<{
|
||||
chat: z.infer<typeof apiV1.ApiGetChatsResponse.shape.chats.element>;
|
||||
messages: Message[];
|
||||
} | null> => {
|
||||
const response = await fetch(`${apiUrl}/chats`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${sessionId}`,
|
||||
},
|
||||
});
|
||||
if (response.status === 403) {
|
||||
window.parent.postMessage({
|
||||
type: 'sessionExpired'
|
||||
}, '*');
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch last chat");
|
||||
}
|
||||
const { chats }: z.infer<typeof apiV1.ApiGetChatsResponse> = await response.json();
|
||||
if (chats.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const chat = chats[0];
|
||||
|
||||
// fetch all chat messages
|
||||
let allMessages: Message[] = [];
|
||||
let nextCursor: string | undefined = undefined;
|
||||
|
||||
do {
|
||||
const url = new URL(`${apiUrl}/chats/${chat.id}/messages`);
|
||||
if (nextCursor) {
|
||||
url.searchParams.set('next', nextCursor);
|
||||
}
|
||||
|
||||
const messagesResponse = await fetch(url, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${sessionId}`,
|
||||
},
|
||||
});
|
||||
if (!messagesResponse.ok) {
|
||||
throw new Error("Failed to fetch chat messages");
|
||||
}
|
||||
const { messages, next }: z.infer<typeof apiV1.ApiGetChatMessagesResponse> = await messagesResponse.json();
|
||||
|
||||
const formattedMessages = messages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.role === "assistant" ? (m.content || '') : m.content,
|
||||
...(m.role === "tool" ? {
|
||||
tool_call_id: m.tool_call_id,
|
||||
tool_name: m.tool_name,
|
||||
} : {})
|
||||
}));
|
||||
|
||||
allMessages = [...allMessages, ...formattedMessages];
|
||||
nextCursor = next;
|
||||
} while (nextCursor);
|
||||
|
||||
return {
|
||||
chat,
|
||||
messages: allMessages,
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
async function resetState() {
|
||||
setChatId(null);
|
||||
setClosed(false);
|
||||
setMessages([]);
|
||||
}
|
||||
|
||||
async function closeChat() {
|
||||
const response = await fetch(`${apiUrl}/chats/${chatId}/close`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${sessionId}`,
|
||||
},
|
||||
});
|
||||
if (response.status === 403) {
|
||||
window.parent.postMessage({
|
||||
type: 'sessionExpired'
|
||||
}, '*');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to close chat");
|
||||
}
|
||||
setClosed(true);
|
||||
}
|
||||
|
||||
async function createChat(): Promise<string> {
|
||||
const response = await fetch(`${apiUrl}/chats`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${sessionId}`,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (response.status === 403) {
|
||||
window.parent.postMessage({
|
||||
type: 'sessionExpired'
|
||||
}, '*');
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
|
||||
const { id }: z.infer<typeof apiV1.ApiCreateChatResponse> = await response.json();
|
||||
setChatId(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function getAssistantResponse(chatId: string, message: string): Promise<Message> {
|
||||
const response = await fetch(`${apiUrl}/chats/${chatId}/turn`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${sessionId}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: message,
|
||||
}),
|
||||
});
|
||||
if (response.status === 403) {
|
||||
window.parent.postMessage({
|
||||
type: 'sessionExpired'
|
||||
}, '*');
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to get assistant response");
|
||||
}
|
||||
const { content }: z.infer<typeof apiV1.ApiChatTurnResponse> = await response.json();
|
||||
return {
|
||||
role: "assistant",
|
||||
content: content || '',
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.parent.postMessage({
|
||||
type: 'chatStateChange',
|
||||
isMinimized: minimized
|
||||
}, '*');
|
||||
}, [minimized]);
|
||||
|
||||
useEffect(() => {
|
||||
let abort = false;
|
||||
async function process(){
|
||||
const lastChat = await fetchLastChat();
|
||||
if (abort) {
|
||||
return;
|
||||
}
|
||||
if (lastChat) {
|
||||
setChatId(lastChat.chat.id);
|
||||
setClosed(lastChat.chat.closed || false);
|
||||
setMessages(lastChat.messages);
|
||||
}
|
||||
}
|
||||
process()
|
||||
.finally(() => {
|
||||
if (!abort) {
|
||||
window.parent.postMessage({
|
||||
type: 'chatLoaded',
|
||||
}, '*');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
abort = true;
|
||||
}
|
||||
}, [sessionId, fetchLastChat]);
|
||||
|
||||
if (!sessionId) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <>
|
||||
{minimized && <div className="fixed bottom-0 right-0">
|
||||
<button
|
||||
onClick={() => setMinimized(false)}
|
||||
className="w-12 h-12 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 rounded-full flex items-center justify-center shadow-lg transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17h6l3 3v-3h2V9h-2M4 4h11v8H9l-3 3v-3H4V4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>}
|
||||
{!minimized && <div className="fixed h-full">
|
||||
<ChatWindow
|
||||
key={sessionId}
|
||||
chatId={chatId}
|
||||
closed={closed}
|
||||
closeChat={closeChat}
|
||||
createChat={createChat}
|
||||
getAssistantResponse={getAssistantResponse}
|
||||
resetState={resetState}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
setMinimized={setMinimized}
|
||||
/>
|
||||
</div>}
|
||||
</>
|
||||
}
|
||||
BIN
apps/chat_widget/app/favicon.ico
Normal file
BIN
apps/chat_widget/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
apps/chat_widget/app/fonts/GeistMonoVF.woff
Normal file
BIN
apps/chat_widget/app/fonts/GeistMonoVF.woff
Normal file
Binary file not shown.
BIN
apps/chat_widget/app/fonts/GeistVF.woff
Normal file
BIN
apps/chat_widget/app/fonts/GeistVF.woff
Normal file
Binary file not shown.
7
apps/chat_widget/app/globals.css
Normal file
7
apps/chat_widget/app/globals.css
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
35
apps/chat_widget/app/layout.tsx
Normal file
35
apps/chat_widget/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/chat_widget/app/markdown-content.tsx
Normal file
51
apps/chat_widget/app/markdown-content.tsx
Normal 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>;
|
||||
}
|
||||
10
apps/chat_widget/app/page.tsx
Normal file
10
apps/chat_widget/app/page.tsx
Normal 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>
|
||||
}
|
||||
16
apps/chat_widget/app/providers.tsx
Normal file
16
apps/chat_widget/app/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
apps/chat_widget/next.config.mjs
Normal file
6
apps/chat_widget/next.config.mjs
Normal 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
9671
apps/chat_widget/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
apps/chat_widget/package.json
Normal file
32
apps/chat_widget/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
apps/chat_widget/postcss.config.mjs
Normal file
8
apps/chat_widget/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
apps/chat_widget/public/file.svg
Normal file
1
apps/chat_widget/public/file.svg
Normal 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 |
1
apps/chat_widget/public/globe.svg
Normal file
1
apps/chat_widget/public/globe.svg
Normal 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 |
1
apps/chat_widget/public/next.svg
Normal file
1
apps/chat_widget/public/next.svg
Normal 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 |
1
apps/chat_widget/public/vercel.svg
Normal file
1
apps/chat_widget/public/vercel.svg
Normal 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 |
1
apps/chat_widget/public/window.svg
Normal file
1
apps/chat_widget/public/window.svg
Normal 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 |
16
apps/chat_widget/tailwind.config.ts
Normal file
16
apps/chat_widget/tailwind.config.ts
Normal 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;
|
||||
27
apps/chat_widget/tsconfig.json
Normal file
27
apps/chat_widget/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
193
apps/rowboat/app/actions/actions.ts
Normal file
193
apps/rowboat/app/actions/actions.ts
Normal 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;
|
||||
}
|
||||
222
apps/rowboat/app/actions/copilot_actions.ts
Normal file
222
apps/rowboat/app/actions/copilot_actions.ts
Normal 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;
|
||||
}
|
||||
344
apps/rowboat/app/actions/datasource_actions.ts
Normal file
344
apps/rowboat/app/actions/datasource_actions.ts
Normal 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;
|
||||
}
|
||||
215
apps/rowboat/app/actions/project_actions.ts
Normal file
215
apps/rowboat/app/actions/project_actions.ts
Normal 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');
|
||||
}
|
||||
547
apps/rowboat/app/actions/testing_actions.ts
Normal file
547
apps/rowboat/app/actions/testing_actions.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
241
apps/rowboat/app/actions/workflow_actions.ts
Normal file
241
apps/rowboat/app/actions/workflow_actions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
57
apps/rowboat/app/lib/components/atmentions.ts
Normal file
57
apps/rowboat/app/lib/components/atmentions.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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} />}
|
||||
</>;
|
||||
}
|
||||
|
|
|
|||
37
apps/rowboat/app/lib/components/dropdown.tsx
Normal file
37
apps/rowboat/app/lib/components/dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
22
apps/rowboat/app/lib/components/form-section.tsx
Normal file
22
apps/rowboat/app/lib/components/form-section.tsx
Normal 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" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
36
apps/rowboat/app/lib/components/mentions-editor.css
Normal file
36
apps/rowboat/app/lib/components/mentions-editor.css
Normal 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 */
|
||||
}
|
||||
198
apps/rowboat/app/lib/components/mentions_editor.tsx
Normal file
198
apps/rowboat/app/lib/components/mentions_editor.tsx
Normal 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>;
|
||||
}
|
||||
31
apps/rowboat/app/lib/components/menu-item.tsx
Normal file
31
apps/rowboat/app/lib/components/menu-item.tsx
Normal 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;
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
100
apps/rowboat/app/lib/components/selectors/profile-selector.tsx
Normal file
100
apps/rowboat/app/lib/components/selectors/profile-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
106
apps/rowboat/app/lib/components/selectors/workflow-selector.tsx
Normal file
106
apps/rowboat/app/lib/components/selectors/workflow-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
apps/rowboat/app/lib/components/structured-list.tsx
Normal file
50
apps/rowboat/app/lib/components/structured-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
apps/rowboat/app/lib/components/structured-panel.tsx
Normal file
82
apps/rowboat/app/lib/components/structured-panel.tsx
Normal 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>;
|
||||
}
|
||||
21
apps/rowboat/app/lib/components/theme-toggle.tsx
Normal file
21
apps/rowboat/app/lib/components/theme-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
4
apps/rowboat/app/lib/feature_flags.ts
Normal file
4
apps/rowboat/app/lib/feature_flags.ts
Normal 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';
|
||||
|
|
@ -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");
|
||||
|
|
@ -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
|
||||
}
|
||||
],
|
||||
|
|
|
|||
7
apps/rowboat/app/lib/qdrant.ts
Normal file
7
apps/rowboat/app/lib/qdrant.ts
Normal 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 } : {}),
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
326
apps/rowboat/app/lib/types/agents_api_types.ts
Normal file
326
apps/rowboat/app/lib/types/agents_api_types.ts
Normal 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;
|
||||
}
|
||||
144
apps/rowboat/app/lib/types/copilot_types.ts
Normal file
144
apps/rowboat/app/lib/types/copilot_types.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
78
apps/rowboat/app/lib/types/datasource_types.ts
Normal file
78
apps/rowboat/app/lib/types/datasource_types.ts
Normal 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(),
|
||||
}),
|
||||
});
|
||||
|
||||
26
apps/rowboat/app/lib/types/project_types.ts
Normal file
26
apps/rowboat/app/lib/types/project_types.ts
Normal 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(),
|
||||
});
|
||||
|
||||
52
apps/rowboat/app/lib/types/testing_types.ts
Normal file
52
apps/rowboat/app/lib/types/testing_types.ts
Normal 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()
|
||||
});
|
||||
32
apps/rowboat/app/lib/types/tool_types.ts
Normal file
32
apps/rowboat/app/lib/types/tool_types.ts
Normal 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();
|
||||
|
||||
118
apps/rowboat/app/lib/types/types.ts
Normal file
118
apps/rowboat/app/lib/types/types.ts
Normal 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(),
|
||||
});
|
||||
128
apps/rowboat/app/lib/types/workflow_types.ts
Normal file
128
apps/rowboat/app/lib/types/workflow_types.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
9
apps/rowboat/app/lib/uploads_s3_client.ts
Normal file
9
apps/rowboat/app/lib/uploads_s3_client.ts
Normal 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 || '',
|
||||
},
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 || ''}
|
||||
/>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue