diff --git a/apps/.gitignore b/apps/.gitignore new file mode 100644 index 00000000..e43b0f98 --- /dev/null +++ b/apps/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/apps/rowboat/.eslintrc.json b/apps/rowboat/.eslintrc.json new file mode 100644 index 00000000..bffb357a --- /dev/null +++ b/apps/rowboat/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/apps/rowboat/.gitignore b/apps/rowboat/.gitignore new file mode 100644 index 00000000..b3499047 --- /dev/null +++ b/apps/rowboat/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# crawler script artifacts +chunked.jsonl +crawled.jsonl +embeddings.jsonl +rewritten.jsonl diff --git a/apps/rowboat/README.md b/apps/rowboat/README.md new file mode 100644 index 00000000..c4033664 --- /dev/null +++ b/apps/rowboat/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/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/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## 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/deployment) for more details. diff --git a/apps/rowboat/app/actions.ts b/apps/rowboat/app/actions.ts new file mode 100644 index 00000000..fa47527e --- /dev/null +++ b/apps/rowboat/app/actions.ts @@ -0,0 +1,976 @@ +'use server'; + +import { redirect } from "next/navigation"; +import { SimulationData, EmbeddingDoc, GetInformationToolResult, DataSource, PlaygroundChat, AgenticAPIChatRequest, AgenticAPIChatResponse, convertFromAgenticAPIChatMessages, WebpageCrawlResponse, Workflow, WorkflowAgent, CopilotAPIRequest, CopilotAPIResponse, CopilotMessage, CopilotWorkflow, convertToCopilotWorkflow, convertToCopilotApiMessage, convertToCopilotMessage, CopilotAssistantMessage, CopilotChatContext, convertToCopilotApiChatContext, Scenario, ClientToolCallRequestBody, ClientToolCallJwt, ClientToolCallRequest, WithStringId, Project } from "./lib/types"; +import { ObjectId, WithId } from "mongodb"; +import { generateObject, generateText, tool, embed } from "ai"; +import { dataSourcesCollection, embeddingsCollection, projectsCollection, webpagesCollection, agentWorkflowsCollection, scenariosCollection, projectMembersCollection } from "@/app/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 { zodToJsonSchema } from 'zod-to-json-schema'; +import crypto from 'crypto'; +import { SignJWT } from "jose"; +import { Claims, getSession } from "@auth0/nextjs-auth0"; +import { revalidatePath } from "next/cache"; +import { baseWorkflow } from "./lib/utils"; + +const crawler = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY }); + +export async function authCheck(): Promise { + const { user } = await getSession() || {}; + if (!user) { + throw new Error('User not authenticated'); + } + return user; +} + +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 createWorkflow(projectId: string): Promise>> { + 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 workflow = { + ...baseWorkflow, + projectId, + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + name: `Version ${nextWorkflowNumber}`, + }; + const { insertedId } = await agentWorkflowsCollection.insertOne(workflow); + const { _id, ...rest } = workflow as WithId>; + return { + ...rest, + _id: insertedId.toString(), + }; +} + +export async function cloneWorkflow(projectId: string, workflowId: string): Promise>> { + 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>; + 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) { + 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>; + 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 { + await projectAuthCheck(projectId); + const project = await projectsCollection.findOne({ + _id: projectId, + }); + return project?.publishedWorkflowId || null; +} + +export async function fetchWorkflow(projectId: string, workflowId: string): Promise>> { + 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>)[]; + 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> | 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>[] = 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, + }; +} + +export async function scrapeWebpage(url: string): Promise> { + 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 createProject(formData: FormData) { + const user = await authCheck(); + const name = formData.get('name') as string; + const projectId = crypto.randomUUID(); + const chatClientId = crypto.randomBytes(16).toString('base64url'); + const secret = crypto.randomBytes(32).toString('hex'); + await projectsCollection.insertOne({ + _id: projectId, + name: name, + createdAt: (new Date()).toISOString(), + lastUpdatedAt: (new Date()).toISOString(), + createdByUserId: user.sub, + chatClientId, + secret, + }); + // add user to project + await projectMembersCollection.insertOne({ + userId: user.sub, + projectId: projectId, + createdAt: (new Date()).toISOString(), + lastUpdatedAt: (new Date()).toISOString(), + }); + redirect(`/projects/${projectId}/workflow`); +} + +export async function getProjectConfig(projectId: string): Promise> { + 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[]> { + 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 listSources(projectId: string): Promise>[]> { + await projectAuthCheck(projectId); + const sources = await dataSourcesCollection.find({ + projectId: projectId, + }).toArray(); + return sources.map((s) => ({ + ...s, + _id: s._id.toString(), + })); +} + +export async function createCrawlDataSource(projectId: string, formData: FormData) { + await projectAuthCheck(projectId); + const url = formData.get('url') as string; + const name = formData.get('name') as string; + const limit = Number(formData.get('limit')); + + const result = await dataSourcesCollection.insertOne({ + projectId: projectId, + active: true, + name: name, + createdAt: (new Date()).toISOString(), + status: "new", + data: { + type: 'crawl', + startUrl: url, + limit: limit, + } + }); + + redirect(`/projects/${projectId}/sources/${result.insertedId}`); +} + +export async function createUrlsDataSource(projectId: string, formData: FormData) { + await projectAuthCheck(projectId); + const urls = formData.get('urls') as string; + // take first 100 urls + const limitedUrls = urls.split('\n').slice(0, 100).map((url) => url.trim()); + const name = formData.get('name') as string; + + const result = await dataSourcesCollection.insertOne({ + projectId: projectId, + active: true, + name: name, + createdAt: (new Date()).toISOString(), + status: "new", + data: { + type: 'urls', + urls: limitedUrls, + } + }); + + redirect(`/projects/${projectId}/sources/${result.insertedId}`); +} + +export async function recrawlWebDataSource(projectId: string, sourceId: string) { + await projectAuthCheck(projectId); + + const source = await dataSourcesCollection.findOne({ + "_id": new ObjectId(sourceId), + "projectId": projectId, + }); + if (!source) { + throw new Error('Data source not found'); + } + + await dataSourcesCollection.updateOne({ + "_id": new ObjectId(sourceId), + }, { + $set: { + "status": "new", + "attempts": 0, + }, + $unset: { + 'data.firecrawlId': '', + 'data.crawledUrls': '', + 'data.scrapedUrls': '', + } + }); + + revalidatePath(`/projects/${projectId}/sources/${sourceId}`); +} + +export async function deleteDataSource(projectId: string, sourceId: string) { + await projectAuthCheck(projectId); + + await dataSourcesCollection.deleteOne({ + _id: new ObjectId(sourceId), + }); + + await embeddingsCollection.deleteMany({ + sourceId: sourceId, + }); + + redirect(`/projects/${projectId}/sources`); +} + +export async function getAssistantResponse( + projectId: string, + request: z.infer, +): Promise<{ + messages: z.infer[], + state: unknown, + rawAPIResponse: unknown, +}> { + await projectAuthCheck(projectId); + + // call agentic api + const response = await fetch(process.env.AGENTIC_API_URL + '/chat', { + method: 'POST', + body: JSON.stringify(request), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (!response.ok) { + console.error('Failed to call agentic api', response); + throw new Error(`Failed to call agentic api: ${response.statusText}`); + } + const responseJson = await response.json(); + const result: z.infer = responseJson; + return { + messages: convertFromAgenticAPIChatMessages(result.messages), + state: result.state, + rawAPIResponse: result, + }; +} + +export async function getCopilotResponse( + projectId: string, + messages: z.infer[], + current_workflow_config: z.infer, + context: z.infer | null, +): Promise> { + await projectAuthCheck(projectId); + + // prepare request + const request: z.infer = { + 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', + }, + }); + 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 = 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```$/, ''), + }); + return msg as z.infer; +} + +export async function suggestToolResponse(toolId: string, projectId: string, messages: z.infer[]): Promise { + await projectAuthCheck(projectId); + + const prompt = ` +# Your Specific Task: +Here 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 an example of the data that the tool call should return. +The current date is {{date}}. + +CONVERSATION: +{{messages}} +` + .replace('{{toolID}}', toolId) + .replace(`{{date}}`, new Date().toISOString()) + .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); +} + +export async function getDataSource(projectId: string, sourceId: string): Promise> { + await projectAuthCheck(projectId); + + const source = await dataSourcesCollection.findOne({ + "_id": new ObjectId(sourceId), + "projectId": projectId, + }); + if (!source) { + throw new Error('Data source not found'); + } + // send source without _id + const { _id, ...sourceData } = source; + return sourceData +} + +export async function getUpdatedSourceStatus(projectId: string, sourceId: string) { + await projectAuthCheck(projectId); + + const source = await dataSourcesCollection.findOne({ + "_id": new ObjectId(sourceId), + "projectId": projectId, + }, { + projection: { + status: 1, + } + }); + if (!source) { + throw new Error('Data source not found'); + } + return source.status; +} + +export async function getInformationTool( + projectId: string, + query: string, + sourceIds: string[], + returnType: z.infer['ragReturnType'], + k: number, +): Promise> { + await projectAuthCheck(projectId); + + // 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 vector search on mongodb for similar documents + // from the sources fetched above + const agg = [ + { + '$vectorSearch': { + 'index': 'vector_index', + 'path': 'embeddings', + 'filter': { + 'sourceId': { + '$in': validSourceIds, + } + }, + 'queryVector': embedResult.embedding, + 'numCandidates': 5000, + 'limit': k, + } + }, { + '$project': { + '_id': 0, + 'content': 1, + 'metadata.sourceURL': 1, + 'metadata.title': 1, + 'score': { + '$meta': 'vectorSearchScore' + } + } + } + ]; + + // run pipeline + const embeddingMatches = await embeddingsCollection.aggregate>(agg).toArray(); + + // if return type is chunks, return the chunks + if (returnType === 'chunks') { + return { + results: embeddingMatches.map(m => ({ + title: m.metadata.title, + content: m.content, + url: m.metadata.sourceURL, + score: m.metadata.score, + })), + }; + } + + // else return the content of the webpages + const result: z.infer = { + results: [], + }; + + // coalesce results by url + const seenUrls = new Set(); + for (const match of embeddingMatches) { + if (seenUrls.has(match.metadata.sourceURL)) { + continue; + } + seenUrls.add(match.metadata.sourceURL); + result.results.push({ + title: match.metadata.title, + content: match.content, + url: match.metadata.sourceURL, + score: match.metadata.score, + }); + } + + // now fetch each webpage content and overwrite + for (const res of result.results) { + try { + const page = await webpagesCollection.findOne({ + "_id": res.url, + }); + if (!page) { + continue; + } + res.content = page.contentSimple; + } catch (e) { + // console.error('error fetching page:', e); + } + } + + return result; +} + +export async function toggleDataSource(projectId: string, sourceId: string, active: boolean) { + await projectAuthCheck(projectId); + + await dataSourcesCollection.updateOne({ + "_id": new ObjectId(sourceId), + "projectId": projectId, + }, { + $set: { + "active": active, + } + }); +} + +export async function getScenarios(projectId: string): Promise>[]> { + await projectAuthCheck(projectId); + + const scenarios = await scenariosCollection.find({ projectId }).toArray(); + return scenarios.map(s => ({ ...s, _id: s._id.toString() })); +} + +export async function createScenario(projectId: string, name: string, description: string): Promise { + await projectAuthCheck(projectId); + + const now = new Date().toISOString(); + const result = await scenariosCollection.insertOne({ + projectId, + name, + description, + lastUpdatedAt: now, + createdAt: now, + }); + return result.insertedId.toString(); +} + +export async function updateScenario(projectId: string, scenarioId: string, name: string, description: string) { + await projectAuthCheck(projectId); + + await scenariosCollection.updateOne({ + "_id": new ObjectId(scenarioId), + "projectId": projectId, + }, { + $set: { + name, + description, + lastUpdatedAt: new Date().toISOString(), + } + }); +} + +export async function deleteScenario(projectId: string, scenarioId: string) { + await projectAuthCheck(projectId); + + await scenariosCollection.deleteOne({ + "_id": new ObjectId(scenarioId), + "projectId": projectId, + }); +} + +export async function simulateUserResponse( + projectId: string, + messages: z.infer[], + simulationData: z.infer +): Promise { + await projectAuthCheck(projectId); + const articlePrompt = ` +# Your Specific Task: + +## Context: + +Here is a help article: + +Content: + +Title: {{title}} +{{content}} + + +## Task definition: + +Pretend to be a user reaching out to customer support. Chat with the +customer support assistant, assuming your issue or query is from this article. +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. +`; + + const scenarioPrompt = ` +# Your Specific Task: + +## Context: + +Here is a scenario: + +Scenario: + +{{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. +`; + + const previousChatPrompt = ` +# Your Specific Task: + +## Context: + +Here is a chat between a user and a customer support assistant: + +Chat: + +{{messages}} + + +## Task definition: + +Pretend to be a user reaching out to customer support. Chat with the +customer support assistant, assuming your issue based on this previous chat. +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; + if ('articleUrl' in simulationData) { + prompt = articlePrompt + .replace('{{title}}', simulationData.articleTitle || '') + .replace('{{content}}', simulationData.articleContent || ''); + } + if ('scenario' in simulationData) { + prompt = scenarioPrompt + .replace('{{scenario}}', simulationData.scenario); + } + if ('chatMessages' in simulationData) { + prompt = previousChatPrompt + .replace('{{messages}}', simulationData.chatMessages); + } + const { text } = await generateText({ + model: openai("gpt-4o"), + system: prompt || '', + messages: flippedMessages, + }); + + return text.replace(/\. EXIT$/, '.'); +} + +export async function rotateSecret(projectId: string): Promise { + 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 executeClientTool( + toolCall: z.infer['tool_calls'][number], + projectId: string, +): Promise { + await projectAuthCheck(projectId); + + const project = await projectsCollection.findOne({ + "_id": projectId, + }); + if (!project) { + throw new Error('Project not found'); + } + + if (!project.webhookUrl) { + throw new Error('Webhook URL not found'); + } + + // prepare request body + const content = JSON.stringify({ + toolCall, + } as z.infer); + const requestId = crypto.randomUUID(); + const bodyHash = crypto + .createHash('sha256') + .update(content, 'utf8') + .digest('hex'); + + // sign request + const jwt = await new SignJWT({ + requestId, + projectId, + bodyHash, + } as z.infer) + .setProtectedHeader({ + alg: 'HS256', + typ: 'JWT', + }) + .setIssuer('rowboat') + .setAudience(project.webhookUrl) + .setSubject(`tool-call-${toolCall.id}`) + .setJti(requestId) + .setIssuedAt() + .setExpirationTime("5 minutes") + .sign(new TextEncoder().encode(project.secret)); + + // make request + const request: z.infer = { + requestId, + content, + }; + const response = await fetch(project.webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-signature-jwt': jwt, + }, + body: JSON.stringify(request), + }); + if (!response.ok) { + throw new Error(`Failed to call webhook: ${response.status}: ${response.statusText}`); + } + const responseBody = await response.json(); + return responseBody; +} \ No newline at end of file diff --git a/apps/rowboat/app/android-chrome-192x192.png b/apps/rowboat/app/android-chrome-192x192.png new file mode 100644 index 00000000..4e31e080 Binary files /dev/null and b/apps/rowboat/app/android-chrome-192x192.png differ diff --git a/apps/rowboat/app/android-chrome-512x512.png b/apps/rowboat/app/android-chrome-512x512.png new file mode 100644 index 00000000..bb22e445 Binary files /dev/null and b/apps/rowboat/app/android-chrome-512x512.png differ diff --git a/apps/rowboat/app/api/auth/[auth0]/route.ts b/apps/rowboat/app/api/auth/[auth0]/route.ts new file mode 100644 index 00000000..aa8b000c --- /dev/null +++ b/apps/rowboat/app/api/auth/[auth0]/route.ts @@ -0,0 +1,3 @@ +import { handleAuth } from '@auth0/nextjs-auth0'; + +export const GET = handleAuth(); \ No newline at end of file diff --git a/apps/rowboat/app/api/v1/chats/[chatId]/close/route.ts b/apps/rowboat/app/api/v1/chats/[chatId]/close/route.ts new file mode 100644 index 00000000..25c7dfba --- /dev/null +++ b/apps/rowboat/app/api/v1/chats/[chatId]/close/route.ts @@ -0,0 +1,40 @@ +import { NextRequest } from "next/server"; +import { apiV1 } from "rowboat-shared"; +import { db } from "@/app/lib/mongodb"; +import { z } from "zod"; +import { ObjectId } from "mongodb"; +import { authCheck } from "../../../utils"; + +const chatsCollection = db.collection>("chats"); + +export async function POST( + request: NextRequest, + { params }: { params: { chatId: string } } +): Promise { + return await authCheck(request, async (session) => { + const { chatId } = params; + + const result = await chatsCollection.findOneAndUpdate( + { + _id: new ObjectId(chatId), + projectId: session.projectId, + userId: session.userId, + closed: { $exists: false }, + }, + { + $set: { + closed: true, + closedAt: new Date().toISOString(), + closeReason: "user-closed-chat", + }, + }, + { returnDocument: 'after' } + ); + + if (!result) { + return Response.json({ error: "Chat not found" }, { status: 404 }); + } + + return Response.json(result); + }); +} diff --git a/apps/rowboat/app/api/v1/chats/[chatId]/messages/route.ts b/apps/rowboat/app/api/v1/chats/[chatId]/messages/route.ts new file mode 100644 index 00000000..696de694 --- /dev/null +++ b/apps/rowboat/app/api/v1/chats/[chatId]/messages/route.ts @@ -0,0 +1,94 @@ +import { NextRequest } from "next/server"; +import { apiV1 } from "rowboat-shared"; +import { db } from "@/app/lib/mongodb"; +import { z } from "zod"; +import { Filter, ObjectId } from "mongodb"; +import { authCheck } from "../../../utils"; + +const chatsCollection = db.collection>("chats"); +const chatMessagesCollection = db.collection>("chatMessages"); + +// list messages +export async function GET( + req: NextRequest, + { params }: { params: { chatId: string } } +): Promise { + return await authCheck(req, async (session) => { + const { chatId } = params; + + // Check if chat exists + const chat = await chatsCollection.findOne({ + _id: new ObjectId(chatId), + projectId: session.projectId, + userId: session.userId + }); + if (!chat) { + return Response.json({ error: "Chat not found" }, { status: 404 }); + } + + // Parse query parameters + const searchParams = req.nextUrl.searchParams; + const limit = 10; // Hardcoded limit + const next = searchParams.get('next'); + const previous = searchParams.get('previous'); + + // Construct the query + const query: Filter> = { + chatId, + $or: [ + { role: 'user' }, + { role: 'assistant', agenticResponseType: { $eq: 'external' } } + ], + }; + + // Add cursor condition to the query + if (previous) { + query._id = { $lt: new ObjectId(previous) }; + } else if (next) { + query._id = { $gt: new ObjectId(next) }; + } + + // Fetch messages from the database + let messages = await chatMessagesCollection + .find(query) + .sort({ _id: previous ? -1 : 1 }) // Sort based on direction + .limit(limit + 1) // Fetch one extra to determine if there are more results + .toArray(); + + // Determine if there are more results + const hasMore = messages.length > limit; + if (hasMore) { + messages.pop(); + } + + // Reverse the array if we're paginating backwards + if (previous) { + messages.reverse(); + } + + let nextCursor: string | undefined; + let previousCursor: string | undefined; + if (messages.length > 0) { + if (hasMore || previous) { + nextCursor = messages[messages.length - 1]._id.toString(); + } + if (next || (previous && hasMore)) { + previousCursor = messages[0]._id.toString(); + } + } + + // Prepare the response + const response: z.infer = { + messages: messages.map(message => ({ + ...message, + id: message._id.toString(), + _id: undefined + })), + next: nextCursor, + previous: previousCursor, + }; + + // Return response + return Response.json(response); + }); +} diff --git a/apps/rowboat/app/api/v1/chats/[chatId]/route.ts b/apps/rowboat/app/api/v1/chats/[chatId]/route.ts new file mode 100644 index 00000000..ae11c95f --- /dev/null +++ b/apps/rowboat/app/api/v1/chats/[chatId]/route.ts @@ -0,0 +1,43 @@ +import { NextRequest } from "next/server"; +import { apiV1 } from "rowboat-shared"; +import { db } from "@/app/lib/mongodb"; +import { z } from "zod"; +import { ObjectId } from "mongodb"; +import { authCheck } from "../../utils"; + +const chatsCollection = db.collection>("chats"); + +// get chat +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ chatId: string }> } +): Promise { + return await authCheck(req, async (session) => { + const { chatId } = await params; + + // fetch the chat from the database + let chatIdObj: ObjectId; + try { + chatIdObj = new ObjectId(chatId); + } catch (e) { + return Response.json({ error: "Invalid chat ID" }, { status: 400 }); + } + + const chat = await chatsCollection.findOne({ + projectId: session.projectId, + userId: session.userId, + _id: chatIdObj + }); + + if (!chat) { + return Response.json({ error: "Chat not found" }, { status: 404 }); + } + + // return the chat + return Response.json({ + ...chat, + id: chat._id.toString(), + _id: undefined, + }); + }); +} diff --git a/apps/rowboat/app/api/v1/chats/[chatId]/turn/route.ts b/apps/rowboat/app/api/v1/chats/[chatId]/turn/route.ts new file mode 100644 index 00000000..80ea9b2d --- /dev/null +++ b/apps/rowboat/app/api/v1/chats/[chatId]/turn/route.ts @@ -0,0 +1,152 @@ +import { NextRequest } from "next/server"; +import { apiV1 } from "rowboat-shared"; +import { agentWorkflowsCollection, db, projectsCollection } from "@/app/lib/mongodb"; +import { z } from "zod"; +import { ObjectId, WithId } from "mongodb"; +import { authCheck } from "../../../utils"; +import { AgenticAPIChatRequest, convertToAgenticAPIChatMessages, convertToCoreMessages, convertWorkflowToAgenticAPI } from "@/app/lib/types"; +import { executeClientTool, getAssistantResponse } from "@/app/actions"; + +const chatsCollection = db.collection>("chats"); +const chatMessagesCollection = db.collection>("chatMessages"); + +// get next turn / agent response +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ chatId: string }> } +): Promise { + return await authCheck(req, async (session) => { + const { chatId } = await params; + + // parse and validate the request body + let body; + try { + body = await req.json(); + } catch (e) { + return Response.json({ error: "Invalid JSON in request body" }, { status: 400 }); + } + const result = apiV1.ApiChatTurnRequest.safeParse(body); + if (!result.success) { + return Response.json({ error: `Invalid request body: ${result.error.message}` }, { status: 400 }); + } + const userMessage: z.infer = { + version: 'v1', + createdAt: new Date().toISOString(), + chatId, + role: 'user', + content: result.data.message, + }; + + // ensure chat exists + const chat = await chatsCollection.findOne({ + projectId: session.projectId, + userId: session.userId, + _id: new ObjectId(chatId) + }); + if (!chat) { + return Response.json({ error: "Chat not found" }, { status: 404 }); + } + + // prepare system message which will contain user data + const systemMessage: z.infer = { + version: 'v1', + createdAt: new Date().toISOString(), + chatId, + role: 'system', + content: `The following user data is available to you: ${JSON.stringify(chat.userData)}`, + }; + + // fetch existing chat messages + const messages = await chatMessagesCollection.find({ chatId: chatId }).toArray(); + + // fetch project settings + const projectSettings = await projectsCollection.findOne({ + "_id": session.projectId, + }); + if (!projectSettings) { + throw new Error("Project settings not found"); + } + + // fetch workflow + const workflow = await agentWorkflowsCollection.findOne({ + projectId: session.projectId, + _id: new ObjectId(projectSettings.publishedWorkflowId), + }); + if (!workflow) { + throw new Error("Workflow not found"); + } + + // get assistant response + const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow); + const unsavedMessages: z.infer[] = [userMessage]; + let resolvingToolCalls = true; + let state: unknown = chat.agenticState ?? {last_agent_name: startAgent}; + while (resolvingToolCalls) { + const request: z.infer = { + messages: convertToAgenticAPIChatMessages([systemMessage, ...messages, ...unsavedMessages]), + state, + agents, + tools, + prompts, + startAgent, + }; + console.log("turn: sending agentic request", JSON.stringify(request, null, 2)); + const response = await getAssistantResponse(session.projectId, request); + state = response.state; + if (response.messages.length === 0) { + throw new Error("No messages returned from assistant"); + } + unsavedMessages.push(...response.messages.map(m => ({ + ...m, + version: 'v1' as const, + chatId, + createdAt: new Date().toISOString(), + }))); + + // if the last messages is tool call, execute them + const lastMessage = response.messages[response.messages.length - 1]; + if (lastMessage.role === 'assistant' && 'tool_calls' in lastMessage) { + // execute tool calls + console.log("Executing tool calls", lastMessage.tool_calls); + const toolCallResults = await Promise.all(lastMessage.tool_calls.map(async toolCall => { + console.log('executing tool call', toolCall); + try { + return await executeClientTool(toolCall, session.projectId); + } catch (error) { + console.error(`Error executing tool call ${toolCall.id}:`, error); + return { error: "Tool execution failed" }; + } + })); + unsavedMessages.push(...toolCallResults.map((result, index) => ({ + version: 'v1' as const, + chatId, + createdAt: new Date().toISOString(), + role: 'tool' as const, + tool_call_id: lastMessage.tool_calls[index].id, + tool_name: lastMessage.tool_calls[index].function.name, + content: JSON.stringify(result), + }))); + } else { + // ensure that the last message is from an assistant + // and is of an external type + if (lastMessage.role !== 'assistant' || lastMessage.agenticResponseType !== 'external') { + throw new Error("Last message is not from an assistant and is not of an external type"); + } + resolvingToolCalls = false; + break; + } + } + + // save unsaved messages and update chat state + await chatMessagesCollection.insertMany(unsavedMessages); + await chatsCollection.updateOne({ _id: new ObjectId(chatId) }, { $set: { agenticState: state } }); + + // send back the last message + const lastMessage = unsavedMessages[unsavedMessages.length - 1] as WithId>; + return Response.json({ + ...lastMessage, + id: lastMessage._id.toString(), + _id: undefined, + }); + }); +} diff --git a/apps/rowboat/app/api/v1/chats/route.ts b/apps/rowboat/app/api/v1/chats/route.ts new file mode 100644 index 00000000..8990974f --- /dev/null +++ b/apps/rowboat/app/api/v1/chats/route.ts @@ -0,0 +1,116 @@ +import { NextRequest } from "next/server"; +import { db } from "@/app/lib/mongodb"; +import { z } from "zod"; +import { ObjectId } from "mongodb"; +import { apiV1 } from "rowboat-shared"; +import { authCheck } from "../utils"; + +const chatsCollection = db.collection>("chats"); + +// create a chat +export async function POST( + req: NextRequest, +): Promise { + return await authCheck(req, async (session) => { + // parse and validate the request body + let body; + try { + body = await req.json(); + } catch (e) { + return Response.json({ error: "Invalid JSON in request body" }, { status: 400 }); + } + const result = apiV1.ApiCreateChatRequest.safeParse(body); + if (!result.success) { + return new Response(JSON.stringify({ error: `Invalid request body: ${result.error.message}` }), { status: 400 }); + } + + // insert the chat into the database + const id = new ObjectId(); + const chat: z.infer = { + version: "v1", + projectId: session.projectId, + userId: session.userId, + createdAt: new Date().toISOString(), + userData: { + userId: session.userId, + userName: session.userName, + }, + } + await chatsCollection.insertOne({ + ...chat, + _id: id, + }); + + // return response + const response: z.infer = { + ...chat, + id: id.toString(), + }; + return Response.json(response); + }); +} + +// list chats +export async function GET( + req: NextRequest, +): Promise { + return await authCheck(req, async (session) => { + // Parse query parameters + const searchParams = req.nextUrl.searchParams; + const limit = 10; // Hardcoded limit + const next = searchParams.get('next'); + const previous = searchParams.get('previous'); + + // Add userId to query to only show chats for current user + const query: { projectId: string; userId: string; _id?: { $lt?: ObjectId; $gt?: ObjectId } } = { + projectId: session.projectId, + userId: session.userId + }; + + // Add cursor condition to the query + if (next) { + query._id = { $lt: new ObjectId(next) }; + } else if (previous) { + query._id = { $gt: new ObjectId(previous) }; + } + + // Fetch chats from the database + let chats = await chatsCollection + .find(query) + .sort({ _id: -1 }) // Sort in descending order + .limit(limit + 1) // Fetch one extra to determine if there are more results + .toArray(); + + // Determine if there are more results + const hasMore = chats.length > limit; + if (hasMore) { + chats.pop(); + } + let nextCursor: string | undefined; + let previousCursor: string | undefined; + if (chats.length > 0) { + if (hasMore || previous) { + nextCursor = chats[chats.length - 1]._id.toString(); + } + if (next || (previous && hasMore)) { + previousCursor = chats[0]._id.toString(); + } + } + + // Prepare the response + const response: z.infer = { + chats: chats + .slice(0, limit) + .map(chat => ({ + ...chat, + id: chat._id.toString(), + _id: undefined + })), + next: nextCursor, + previous: previousCursor, + }; + + // Return response + return Response.json(response); + }); +} diff --git a/apps/rowboat/app/api/v1/session/guest/route.ts b/apps/rowboat/app/api/v1/session/guest/route.ts new file mode 100644 index 00000000..c660b8ee --- /dev/null +++ b/apps/rowboat/app/api/v1/session/guest/route.ts @@ -0,0 +1,30 @@ +import { NextRequest } from "next/server"; +import { clientIdCheck } from "../../utils"; +import { SignJWT } from "jose"; +import { z } from "zod"; +import { Session } from "../../utils"; +import { apiV1 } from "rowboat-shared"; + +export async function POST(req: NextRequest): Promise { + return await clientIdCheck(req, async (projectId) => { + // create a new guest user + const session: z.infer = { + userId: `guest-${crypto.randomUUID()}`, + userName: 'Guest User', + projectId: projectId + }; + + // Create and sign JWT + const token = await new SignJWT(session) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('24h') + .sign(new TextEncoder().encode(process.env.CHAT_WIDGET_SESSION_JWT_SECRET)); + + const response: z.infer = { + sessionId: token, + }; + + return Response.json(response); + }); +} diff --git a/apps/rowboat/app/api/v1/session/user/route.ts b/apps/rowboat/app/api/v1/session/user/route.ts new file mode 100644 index 00000000..e747bcf2 --- /dev/null +++ b/apps/rowboat/app/api/v1/session/user/route.ts @@ -0,0 +1,55 @@ +import { NextRequest } from "next/server"; +import { clientIdCheck } from "../../utils"; +import { SignJWT, jwtVerify } from "jose"; +import { z } from "zod"; +import { Session } from "../../utils"; +import { apiV1 } from "rowboat-shared"; +import { projectsCollection } from "@/app/lib/mongodb"; + +export async function POST(req: NextRequest): Promise { + return await clientIdCheck(req, async (projectId) => { + // decode and validate JWT + const json = await req.json(); + const parsedRequest = apiV1.ApiCreateUserSessionRequest.parse(json); + + // fetch client signing key from db + const project = await projectsCollection.findOne({ + _id: projectId + }); + if (!project) { + return Response.json({ error: 'Project not found' }, { status: 404 }); + } + const clientSigningKey = project.secret; + + // verify client signing key + let verified; + try { + verified = await jwtVerify<{ + userId: string; + userName?: string; + }>(parsedRequest.userDataJwt, new TextEncoder().encode(clientSigningKey)); + } catch (e) { + return Response.json({ error: 'Invalid jwt' }, { status: 403 }); + } + + // create new user session + const session: z.infer = { + userId: verified.payload.userId, + userName: verified.payload.userName ?? 'Unknown', + projectId: projectId + }; + + // Create and sign JWT + const token = await new SignJWT(session) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('24h') + .sign(new TextEncoder().encode(process.env.CHAT_WIDGET_SESSION_JWT_SECRET)); + + const response: z.infer = { + sessionId: token, + }; + + return Response.json(response); + }); +} diff --git a/apps/rowboat/app/api/v1/utils.ts b/apps/rowboat/app/api/v1/utils.ts new file mode 100644 index 00000000..a198135d --- /dev/null +++ b/apps/rowboat/app/api/v1/utils.ts @@ -0,0 +1,62 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { jwtVerify } from "jose"; +import { projectsCollection } from "@/app/lib/mongodb"; + +export const Session = z.object({ + userId: z.string(), + userName: z.string(), + projectId: z.string(), +}); + +/* + This function wraps an API handler with client ID validation. + It checks for a client ID in the request headers and returns a 400 + Bad Request response if missing. It then looks up the client ID in the + database to fetch the corresponding project ID. If no record is found, + it returns a 403 Forbidden response. Otherwise, it sets the project ID + in the request headers and calls the provided handler function. +*/ +export async function clientIdCheck(req: NextRequest, handler: (projectId: string) => Promise): Promise { + const clientId = req.headers.get('x-client-id')?.trim(); + if (!clientId) { + return Response.json({ error: "Missing client ID in request" }, { status: 400 }); + } + const project = await projectsCollection.findOne({ + chatClientId: clientId + }); + if (!project) { + return Response.json({ error: "Invalid client ID" }, { status: 403 }); + } + // set the project id in the request headers + req.headers.set('x-project-id', project._id); + return await handler(project._id); +} + +/* + This function wraps an API handler with session validation. + It checks for a session in the request headers and returns a 400 + Bad Request response if missing. It then verifies the session JWT. + If no record is found, it returns a 403 Forbidden response. Otherwise, + it sets the project ID and user ID in the request headers and calls the + provided handler function. +*/ +export async function authCheck(req: NextRequest, handler: (session: z.infer) => Promise): Promise { + const authHeader = req.headers.get('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return Response.json({ error: "Authorization header must be a Bearer token" }, { status: 400 }); + } + const token = authHeader.split(' ')[1]; + if (!token) { + return Response.json({ error: "Missing session token in request" }, { status: 400 }); + } + + let session; + try { + session = await jwtVerify(token, new TextEncoder().encode(process.env.CHAT_WIDGET_SESSION_JWT_SECRET)); + } catch (error) { + return Response.json({ error: "Invalid session token" }, { status: 403 }); + } + + return await handler(session.payload as z.infer); +} diff --git a/apps/rowboat/app/app.tsx b/apps/rowboat/app/app.tsx new file mode 100644 index 00000000..48b68f82 --- /dev/null +++ b/apps/rowboat/app/app.tsx @@ -0,0 +1,64 @@ +'use client'; +import { TypewriterEffect } from "./lib/components/typewriter"; +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"; + +export function App() { + const router = useRouter(); + const { user, error, isLoading } = useUser(); + + if (user) { + router.push("/projects"); + } + + return
+
+
+ RowBoat Logo +
+

+ AI agents for human-like customer assistance +

+

+ Set up a personalized agent for your app or website in minutes. +

+
+
+ +
+
+ RowBoat Logo + {isLoading && } + {error &&
{error.message}
} + {!isLoading && !error && !user && ( + + Sign in to get started + + )} + {user &&
+ +
Welcome, {user.name}
+
} +
+
© 2024 RowBoat Labs
+ Terms and Conditions + Privacy Policy +
+
+
; +} diff --git a/apps/rowboat/app/apple-touch-icon.png b/apps/rowboat/app/apple-touch-icon.png new file mode 100644 index 00000000..00c5fa3f Binary files /dev/null and b/apps/rowboat/app/apple-touch-icon.png differ diff --git a/apps/rowboat/app/browserconfig.xml b/apps/rowboat/app/browserconfig.xml new file mode 100644 index 00000000..b3930d0f --- /dev/null +++ b/apps/rowboat/app/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #da532c + + + diff --git a/apps/rowboat/app/favicon-16x16.png b/apps/rowboat/app/favicon-16x16.png new file mode 100644 index 00000000..9861db05 Binary files /dev/null and b/apps/rowboat/app/favicon-16x16.png differ diff --git a/apps/rowboat/app/favicon-32x32.png b/apps/rowboat/app/favicon-32x32.png new file mode 100644 index 00000000..9d0624f2 Binary files /dev/null and b/apps/rowboat/app/favicon-32x32.png differ diff --git a/apps/rowboat/app/favicon.ico b/apps/rowboat/app/favicon.ico new file mode 100644 index 00000000..afb1afd4 Binary files /dev/null and b/apps/rowboat/app/favicon.ico differ diff --git a/apps/rowboat/app/globals.css b/apps/rowboat/app/globals.css new file mode 100644 index 00000000..4940ed61 --- /dev/null +++ b/apps/rowboat/app/globals.css @@ -0,0 +1,78 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} + +html, body { + height: 100vh; +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/apps/rowboat/app/layout.tsx b/apps/rowboat/app/layout.tsx new file mode 100644 index 00000000..64b0fc79 --- /dev/null +++ b/apps/rowboat/app/layout.tsx @@ -0,0 +1,30 @@ +import "./globals.css"; +import { UserProvider } from '@auth0/nextjs-auth0/client'; +import { Inter } from "next/font/google"; +import { Providers } from "./providers"; +import { Metadata } from "next"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: { + default: "RowBoat labs", + template: "%s | RowBoat Labs", + } +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return + + + + {children} + + + + ; +} diff --git a/apps/rowboat/app/lib/components/FormStatusButton.tsx b/apps/rowboat/app/lib/components/FormStatusButton.tsx new file mode 100644 index 00000000..a18441dd --- /dev/null +++ b/apps/rowboat/app/lib/components/FormStatusButton.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { useFormStatus } from "react-dom"; +import { Button, ButtonProps } from "@nextui-org/react"; + +export function FormStatusButton({ + props +}: { + props: ButtonProps; +}) { + const { pending } = useFormStatus(); + + return + + } + + {isEditing ? ( + multiline ? ( + + + } + + ; +} + +function ExpandableContent({ + label, + content, + expanded = false +}: { + label: string, + content: string + expanded?: boolean +}) { + const [isExpanded, setIsExpanded] = useState(expanded); + + function toggleExpanded() { + setIsExpanded(!isExpanded); + } + + return
+
+ {!isExpanded && } + {isExpanded && } +
{label}
+
+ {isExpanded &&
+ {content} +
} +
; +} + +function SystemMessage({ + content, + onChange, + locked +}: { + content: string, + onChange: (content: string) => void, + locked: boolean +}) { + return ( +
+ +
+ ); +} + +export function Messages({ + projectId, + systemMessage, + messages, + toolCallResults, + handleToolCallResults, + loadingAssistantResponse, + loadingUserResponse, + workflow, + onSystemMessageChange, +}: { + projectId: string; + systemMessage: string | undefined; + messages: z.infer[]; + toolCallResults: Record>; + handleToolCallResults: (results: z.infer[]) => void; + loadingAssistantResponse: boolean; + loadingUserResponse: boolean; + workflow: z.infer; + onSystemMessageChange: (message: string) => void; +}) { + const messagesEndRef = useRef(null); + let lastUserMessageTimestamp = 0; + + const systemMessageLocked = messages.length > 0; + + // scroll to bottom on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) + }, [messages, loadingAssistantResponse, loadingUserResponse]); + + return
+
+ + {messages.map((message, index) => { + if (message.role === 'assistant') { + if ('tool_calls' in message) { + return ; + } else { + // the assistant message createdAt is an ISO string timestamp + const latency = new Date(message.createdAt).getTime() - lastUserMessageTimestamp; + if (message.agenticResponseType === 'internal') { + return ( + + ); + } else { + return ( + + ); + } + } + } + if (message.role === 'user' && typeof message.content === 'string') { + lastUserMessageTimestamp = new Date(message.createdAt).getTime(); + return ; + } + return <>; + })} + {loadingAssistantResponse && } + {loadingUserResponse && } +
+
+
; +} \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/playground/scenario-list.tsx b/apps/rowboat/app/projects/[projectId]/playground/scenario-list.tsx new file mode 100644 index 00000000..fe380f10 --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/playground/scenario-list.tsx @@ -0,0 +1,233 @@ +'use client'; + +import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Spinner, Textarea } from "@nextui-org/react"; +import { useState, useEffect } from "react"; +import { getScenarios, createScenario, updateScenario, deleteScenario } from "@/app/actions"; +import { Scenario, WithStringId } from "@/app/lib/types"; +import { z } from "zod"; +import { EditableField } from "@/app/lib/components/editable-field"; +import { HamburgerIcon } from "@/app/lib/components/icons"; +import { EllipsisVerticalIcon } from "lucide-react"; + +export function AddScenarioForm({ + onAdd, +}: { + onAdd: (name: string, description: string) => void; +}) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + const handleAdd = async () => { + try { + setSaving(true); + await onAdd(name, description); + setName(""); + setDescription(""); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Invalid input"); + } finally { + setSaving(false); + } + }; + + return
+
Add Scenario
+ setName(e.target.value)} + isInvalid={!!error} + required + /> +