From 7847c969775d6795021f0344ce9b3cde0c493a37 Mon Sep 17 00:00:00 2001 From: ramnique <30795890+ramnique@users.noreply.github.com> Date: Sat, 8 Feb 2025 00:41:01 +0530 Subject: [PATCH 01/75] refactor rag; add files support; use qdrant --- apps/rowboat/app/actions.ts | 1164 ------------ apps/rowboat/app/actions/actions.ts | 499 +++++ .../rowboat/app/actions/datasource_actions.ts | 342 ++++ apps/rowboat/app/actions/project_actions.ts | 209 +++ apps/rowboat/app/actions/scenario_actions.ts | 51 + apps/rowboat/app/actions/workflow_actions.ts | 240 +++ .../app/lib/components/datasource-icon.tsx | 13 +- apps/rowboat/app/lib/mongodb.ts | 3 +- apps/rowboat/app/lib/qdrant.ts | 7 + apps/rowboat/app/lib/types.ts | 72 +- apps/rowboat/app/lib/uploads_s3_client.ts | 9 + .../app/projects/[projectId]/config/app.tsx | 2 +- apps/rowboat/app/projects/[projectId]/nav.tsx | 2 +- .../projects/[projectId]/playground/chat.tsx | 2 +- .../[projectId]/playground/messages.tsx | 15 +- .../[projectId]/playground/scenario-list.tsx | 2 +- .../playground/simulation-options.tsx | 2 +- .../[projectId]/sources/[sourceId]/delete.tsx | 2 +- .../sources/[sourceId]/files-source.tsx | 290 +++ .../[projectId]/sources/[sourceId]/page.tsx | 5 - .../sources/[sourceId]/scrape-source.tsx | 273 +++ .../[projectId]/sources/[sourceId]/shared.tsx | 13 + .../sources/[sourceId]/source-page.tsx | 128 +- .../sources/[sourceId]/web-recrawl.tsx | 7 +- .../projects/[projectId]/sources/new/form.tsx | 104 +- .../sources/self-updating-source-status.tsx | 40 +- .../[projectId]/sources/source-status.tsx | 17 +- .../[projectId]/sources/sources-list.tsx | 10 +- .../[projectId]/sources/toggle-source.tsx | 4 +- .../app/projects/[projectId]/workflow/app.tsx | 9 +- .../projects/[projectId]/workflow/copilot.tsx | 2 +- .../[projectId]/workflow/workflow_editor.tsx | 2 +- .../workflow/workflow_selector.tsx | 2 +- apps/rowboat/app/projects/app.tsx | 2 +- apps/rowboat/app/projects/new/app.tsx | 2 +- apps/rowboat/app/scripts/crawlUrls.ts | 994 ---------- apps/rowboat/app/scripts/delete_qdrant.ts | 9 + apps/rowboat/app/scripts/rag_files_worker.ts | 339 ++++ apps/rowboat/app/scripts/rag_urls_worker.ts | 339 ++++ apps/rowboat/app/scripts/setup_qdrant.ts | 14 + apps/rowboat/app/scripts/shared.ts | 26 + apps/rowboat/package-lock.json | 1650 ++++++++++++++++- apps/rowboat/package.json | 11 +- 43 files changed, 4556 insertions(+), 2372 deletions(-) delete mode 100644 apps/rowboat/app/actions.ts create mode 100644 apps/rowboat/app/actions/actions.ts create mode 100644 apps/rowboat/app/actions/datasource_actions.ts create mode 100644 apps/rowboat/app/actions/project_actions.ts create mode 100644 apps/rowboat/app/actions/scenario_actions.ts create mode 100644 apps/rowboat/app/actions/workflow_actions.ts create mode 100644 apps/rowboat/app/lib/qdrant.ts create mode 100644 apps/rowboat/app/lib/uploads_s3_client.ts create mode 100644 apps/rowboat/app/projects/[projectId]/sources/[sourceId]/files-source.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/sources/[sourceId]/scrape-source.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/sources/[sourceId]/shared.tsx delete mode 100644 apps/rowboat/app/scripts/crawlUrls.ts create mode 100644 apps/rowboat/app/scripts/delete_qdrant.ts create mode 100644 apps/rowboat/app/scripts/rag_files_worker.ts create mode 100644 apps/rowboat/app/scripts/rag_urls_worker.ts create mode 100644 apps/rowboat/app/scripts/setup_qdrant.ts create mode 100644 apps/rowboat/app/scripts/shared.ts diff --git a/apps/rowboat/app/actions.ts b/apps/rowboat/app/actions.ts deleted file mode 100644 index af06afc0..00000000 --- a/apps/rowboat/app/actions.ts +++ /dev/null @@ -1,1164 +0,0 @@ -'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, WorkflowTool, WorkflowPrompt, ApiKey } from "./lib/types"; -import { ObjectId, WithId } from "mongodb"; -import { generateObject, generateText, embed } from "ai"; -import { dataSourcesCollection, embeddingsCollection, projectsCollection, webpagesCollection, agentWorkflowsCollection, scenariosCollection, projectMembersCollection, apiKeysCollection } 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 { Claims, getSession } from "@auth0/nextjs-auth0"; -import { revalidatePath } from "next/cache"; -import { callClientToolWebhook, getAgenticApiResponse } from "./lib/utils"; -import { templates } from "./lib/project_templates"; -import { assert, error } from "node:console"; -import { check_query_limit } from "./lib/rate_limiting"; -import { QueryLimitError } from "./lib/client_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 { 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>; - 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(); - - // 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, - }); - - // add first workflow version - const { agents, prompts, tools, startAgent } = templates[templateKey]; - await agentWorkflowsCollection.insertOne({ - _id: new ObjectId(), - 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(), - }); - - 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 valid urls (as in parse them) - const limitedUrls = urls.split('\n') - .map((url) => url.trim()) - .filter((url) => { - try { - new URL(url); - return true; - } catch (e) { - return false; - } - }) - .slice(0, 100); - 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, - 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 getCopilotResponse( - projectId: string, - messages: z.infer[], - current_workflow_config: z.infer, - context: z.infer | null, -): Promise<{ - message: z.infer, - rawRequest: unknown, - rawResponse: unknown, -}> { - await projectAuthCheck(projectId); - if (!await check_query_limit(projectId)) { - throw new QueryLimitError(); - } - - // 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', - '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 = 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; - // 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; - // 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; - // 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, - rawRequest: request, - rawResponse: json, - }; -} - -export async function suggestToolResponse(toolId: string, projectId: string, messages: z.infer[]): Promise { - await projectAuthCheck(projectId); - if (!await check_query_limit(projectId)) { - throw new QueryLimitError(); - } - - 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); - if (!await check_query_limit(projectId)) { - throw new QueryLimitError(); - } - - 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], - messages: z.infer[], - projectId: string, -): Promise { - await projectAuthCheck(projectId); - - const result = await callClientToolWebhook(toolCall, messages, projectId); - return result; -} - -export async function createApiKey(projectId: string): Promise>> { - 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 = { - projectId, - key, - createdAt: new Date().toISOString(), - }; - await apiKeysCollection.insertOne(doc); - const { _id, ...rest } = doc as WithStringId>; - 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>[]> { - 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 scenariosCollection.deleteMany({ - projectId, - }); - - // delete project - await projectsCollection.deleteOne({ - _id: projectId, - }); - - redirect('/projects'); -} diff --git a/apps/rowboat/app/actions/actions.ts b/apps/rowboat/app/actions/actions.ts new file mode 100644 index 00000000..76006021 --- /dev/null +++ b/apps/rowboat/app/actions/actions.ts @@ -0,0 +1,499 @@ +'use server'; +import { SimulationData, EmbeddingDoc, GetInformationToolResult, AgenticAPIChatRequest, convertFromAgenticAPIChatMessages, WebpageCrawlResponse, Workflow, WorkflowAgent, CopilotAPIRequest, CopilotAPIResponse, CopilotMessage, CopilotWorkflow, convertToCopilotWorkflow, convertToCopilotApiMessage, convertToCopilotMessage, CopilotAssistantMessage, CopilotChatContext, convertToCopilotApiChatContext, WorkflowTool, WorkflowPrompt, EmbeddingRecord } from "../lib/types"; +import { generateObject, generateText, embed } from "ai"; +import { dataSourceDocsCollection, dataSourcesCollection, embeddingsCollection, webpagesCollection } 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 { Claims, getSession } from "@auth0/nextjs-auth0"; +import { callClientToolWebhook, getAgenticApiResponse } from "../lib/utils"; +import { assert } from "node:console"; +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"; + +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 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 getAssistantResponse( + projectId: string, + request: z.infer, +): Promise<{ + messages: z.infer[], + 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 getCopilotResponse( + projectId: string, + messages: z.infer[], + current_workflow_config: z.infer, + context: z.infer | null, +): Promise<{ + message: z.infer, + rawRequest: unknown, + rawResponse: unknown, +}> { + await projectAuthCheck(projectId); + if (!await check_query_limit(projectId)) { + throw new QueryLimitError(); + } + + // 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', + '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 = 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; + // 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; + // 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; + // 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, + rawRequest: request, + rawResponse: json, + }; +} + +export async function suggestToolResponse(toolId: string, projectId: string, messages: z.infer[]): Promise { + await projectAuthCheck(projectId); + if (!await check_query_limit(projectId)) { + throw new QueryLimitError(); + } + + 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 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 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['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, + }; +} + +export async function simulateUserResponse( + projectId: string, + messages: z.infer[], + simulationData: z.infer +): Promise { + await projectAuthCheck(projectId); + if (!await check_query_limit(projectId)) { + throw new QueryLimitError(); + } + + 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 executeClientTool( + toolCall: z.infer['tool_calls'][number], + messages: z.infer[], + projectId: string, +): Promise { + await projectAuthCheck(projectId); + + const result = await callClientToolWebhook(toolCall, messages, projectId); + return result; +} \ No newline at end of file diff --git a/apps/rowboat/app/actions/datasource_actions.ts b/apps/rowboat/app/actions/datasource_actions.ts new file mode 100644 index 00000000..351c476c --- /dev/null +++ b/apps/rowboat/app/actions/datasource_actions.ts @@ -0,0 +1,342 @@ +'use server'; +import { redirect } from "next/navigation"; +import { ObjectId, WithId } from "mongodb"; +import { dataSourcesCollection, dataSourceDocsCollection } from "@/app/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 { DataSource, DataSourceDoc, WithStringId } from "../lib/types"; +import { uploadsS3Client } from "../lib/uploads_s3_client"; + +export async function getDataSource(projectId: string, sourceId: string): Promise>> { + 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>[]> { + 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['data'], + status?: 'pending' | 'ready', +}): Promise>> { + await projectAuthCheck(projectId); + + const source: z.infer = { + 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>; + 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['data'] + }[] +}): Promise { + await projectAuthCheck(projectId); + await getDataSource(projectId, sourceId); + + await dataSourceDocsCollection.insertMany(docData.map(doc => { + const record: z.infer = { + 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>; + 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>[], + 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 { + 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 { + 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.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.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; +} diff --git a/apps/rowboat/app/actions/project_actions.ts b/apps/rowboat/app/actions/project_actions.ts new file mode 100644 index 00000000..ed32bf28 --- /dev/null +++ b/apps/rowboat/app/actions/project_actions.ts @@ -0,0 +1,209 @@ +'use server'; +import { redirect } from "next/navigation"; +import { ObjectId } from "mongodb"; +import { dataSourcesCollection, embeddingsCollection, projectsCollection, agentWorkflowsCollection, scenariosCollection, projectMembersCollection, apiKeysCollection, dataSourceDocsCollection } from "@/app/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 { ApiKey, WithStringId, Project } from "../lib/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, + }); + + // add first workflow version + const { agents, prompts, tools, startAgent } = templates[templateKey]; + await agentWorkflowsCollection.insertOne({ + _id: new ObjectId(), + 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(), + }); + + 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 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 createApiKey(projectId: string): Promise>> { + 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 = { + projectId, + key, + createdAt: new Date().toISOString(), + }; + await apiKeysCollection.insertOne(doc); + const { _id, ...rest } = doc as WithStringId>; + 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>[]> { + 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 scenariosCollection.deleteMany({ + projectId, + }); + + // delete project + await projectsCollection.deleteOne({ + _id: projectId, + }); + + redirect('/projects'); +} diff --git a/apps/rowboat/app/actions/scenario_actions.ts b/apps/rowboat/app/actions/scenario_actions.ts new file mode 100644 index 00000000..321dea8b --- /dev/null +++ b/apps/rowboat/app/actions/scenario_actions.ts @@ -0,0 +1,51 @@ +'use server'; +import { ObjectId } from "mongodb"; +import { scenariosCollection } from "@/app/lib/mongodb"; +import { z } from 'zod'; +import { Scenario, WithStringId } from "../lib/types"; +import { projectAuthCheck } from "./project_actions"; + +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, + }); +} diff --git a/apps/rowboat/app/actions/workflow_actions.ts b/apps/rowboat/app/actions/workflow_actions.ts new file mode 100644 index 00000000..3a73eebd --- /dev/null +++ b/apps/rowboat/app/actions/workflow_actions.ts @@ -0,0 +1,240 @@ +'use server'; +import { ObjectId, WithId } from "mongodb"; +import { projectsCollection, agentWorkflowsCollection } from "@/app/lib/mongodb"; +import { z } from 'zod'; +import { templates } from "../lib/project_templates"; +import { projectAuthCheck } from "./project_actions"; +import { Workflow, WithStringId } from "../lib/types"; + +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 { 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>; + 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, + }; +} \ No newline at end of file diff --git a/apps/rowboat/app/lib/components/datasource-icon.tsx b/apps/rowboat/app/lib/components/datasource-icon.tsx index 44fa5f9a..7b445061 100644 --- a/apps/rowboat/app/lib/components/datasource-icon.tsx +++ b/apps/rowboat/app/lib/components/datasource-icon.tsx @@ -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 && } - {type == "crawl" && } - {type == "urls" && } + {type === undefined && } + {type == "crawl" && } + {type == "urls" && } + {type == "files" && } ; } diff --git a/apps/rowboat/app/lib/mongodb.ts b/apps/rowboat/app/lib/mongodb.ts index cea0a94f..1c2e11da 100644 --- a/apps/rowboat/app/lib/mongodb.ts +++ b/apps/rowboat/app/lib/mongodb.ts @@ -1,11 +1,12 @@ import { MongoClient } from "mongodb"; -import { PlaygroundChat, DataSource, EmbeddingDoc, Project, Webpage, ChatClientId, Workflow, Scenario, ProjectMember, ApiKey } from "./types"; +import { PlaygroundChat, DataSource, EmbeddingDoc, Project, Webpage, ChatClientId, Workflow, Scenario, ProjectMember, ApiKey, DataSourceDoc } from "./types"; import { z } from 'zod'; const client = new MongoClient(process.env["MONGODB_CONNECTION_STRING"] || "mongodb://localhost:27017"); export const db = client.db("rowboat"); export const dataSourcesCollection = db.collection>("sources"); +export const dataSourceDocsCollection = db.collection>("source_docs"); export const embeddingsCollection = db.collection>("embeddings"); export const projectsCollection = db.collection>("projects"); export const projectMembersCollection = db.collection>("project_members"); diff --git a/apps/rowboat/app/lib/qdrant.ts b/apps/rowboat/app/lib/qdrant.ts new file mode 100644 index 00000000..cd38f2fb --- /dev/null +++ b/apps/rowboat/app/lib/qdrant.ts @@ -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 } : {}), +}); \ No newline at end of file diff --git a/apps/rowboat/app/lib/types.ts b/apps/rowboat/app/lib/types.ts index 82960cec..82accba1 100644 --- a/apps/rowboat/app/lib/types.ts +++ b/apps/rowboat/app/lib/types.ts @@ -54,30 +54,58 @@ 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(), + status: z.union([ + z.literal('pending'), + z.literal('ready'), + z.literal('error'), + z.literal('deleted'), + ]), + version: z.number(), error: z.string().optional(), - attempts: z.number().default(0).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('crawl'), - startUrl: z.string(), - limit: z.number(), - firecrawlId: z.string().optional(), - oxylabsId: z.string().optional(), - crawledUrls: z.string().optional(), + type: z.literal('urls'), }), z.object({ - type: z.literal('urls'), - urls: z.array(z.string()), - scrapedUrls: z.string().optional(), - missingUrls: z.string().optional(), + 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(), @@ -118,9 +146,10 @@ export const ApiKey = z.object({ export const GetInformationToolResultItem = z.object({ title: z.string(), + name: z.string(), content: z.string(), - url: z.string(), - score: z.number().optional(), + docId: z.string(), + sourceId: z.string(), }); export const GetInformationToolResult = z.object({ @@ -152,6 +181,19 @@ export const AgenticAPIChatMessage = z.object({ ]).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(), + }), +}); + export const WorkflowAgent = z.object({ name: z.string(), type: z.union([ diff --git a/apps/rowboat/app/lib/uploads_s3_client.ts b/apps/rowboat/app/lib/uploads_s3_client.ts new file mode 100644 index 00000000..1e0de406 --- /dev/null +++ b/apps/rowboat/app/lib/uploads_s3_client.ts @@ -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 || '', + }, +}); \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/config/app.tsx b/apps/rowboat/app/projects/[projectId]/config/app.tsx index dc619449..649ba93e 100644 --- a/apps/rowboat/app/projects/[projectId]/config/app.tsx +++ b/apps/rowboat/app/projects/[projectId]/config/app.tsx @@ -3,7 +3,7 @@ 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 { ReactNode, useEffect, useState, useCallback } from "react"; -import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "@/app/actions"; +import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "@/app/actions/project_actions"; import { CopyButton } from "@/app/lib/components/copy-button"; import { EditableField } from "@/app/lib/components/editable-field"; import { EyeIcon, EyeOffIcon, CopyIcon, MoreVerticalIcon, PlusIcon, EllipsisVerticalIcon } from "lucide-react"; diff --git a/apps/rowboat/app/projects/[projectId]/nav.tsx b/apps/rowboat/app/projects/[projectId]/nav.tsx index e357c453..8d38a101 100644 --- a/apps/rowboat/app/projects/[projectId]/nav.tsx +++ b/apps/rowboat/app/projects/[projectId]/nav.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import clsx from "clsx"; import Menu from "./menu"; -import { getProjectConfig } from "@/app/actions"; +import { getProjectConfig } from "@/app/actions/project_actions"; import { ChevronsLeftIcon, ChevronsRightIcon, FolderOpenIcon, PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react"; export function Nav({ diff --git a/apps/rowboat/app/projects/[projectId]/playground/chat.tsx b/apps/rowboat/app/projects/[projectId]/playground/chat.tsx index f1ac6c30..4d123a36 100644 --- a/apps/rowboat/app/projects/[projectId]/playground/chat.tsx +++ b/apps/rowboat/app/projects/[projectId]/playground/chat.tsx @@ -1,5 +1,5 @@ 'use client'; -import { getAssistantResponse, simulateUserResponse } from "@/app/actions"; +import { getAssistantResponse, simulateUserResponse } from "@/app/actions/actions"; import { useEffect, useState } from "react"; import { Messages } from "./messages"; import z from "zod"; diff --git a/apps/rowboat/app/projects/[projectId]/playground/messages.tsx b/apps/rowboat/app/projects/[projectId]/playground/messages.tsx index 41f8f118..9ef16650 100644 --- a/apps/rowboat/app/projects/[projectId]/playground/messages.tsx +++ b/apps/rowboat/app/projects/[projectId]/playground/messages.tsx @@ -3,7 +3,7 @@ import { Button, Spinner, Textarea } from "@nextui-org/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import z from "zod"; import { GetInformationToolResult, WebpageCrawlResponse, Workflow, WorkflowTool } from "@/app/lib/types"; -import { executeClientTool, getInformationTool, scrapeWebpage, suggestToolResponse } from "@/app/actions"; +import { executeClientTool, getInformationTool, scrapeWebpage, suggestToolResponse } from "@/app/actions/actions"; import MarkdownContent from "@/app/lib/components/markdown-content"; import Link from "next/link"; import { apiV1 } from "rowboat-shared"; @@ -293,14 +293,15 @@ function GetInformationToolCall({ {typedResult && typedResult.results.length === 0 &&
No matches found.
} {typedResult && typedResult.results.length > 0 &&
    {typedResult.results.map((result, index) => { - return
  • - - {result.url} - + return
  • +
  • })} -
- } + } } diff --git a/apps/rowboat/app/projects/[projectId]/playground/scenario-list.tsx b/apps/rowboat/app/projects/[projectId]/playground/scenario-list.tsx index cce50e6d..634fb200 100644 --- a/apps/rowboat/app/projects/[projectId]/playground/scenario-list.tsx +++ b/apps/rowboat/app/projects/[projectId]/playground/scenario-list.tsx @@ -2,7 +2,7 @@ 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 { getScenarios, createScenario, updateScenario, deleteScenario } from "@/app/actions/scenario_actions"; import { Scenario, WithStringId } from "@/app/lib/types"; import { z } from "zod"; import { EditableField } from "@/app/lib/components/editable-field"; diff --git a/apps/rowboat/app/projects/[projectId]/playground/simulation-options.tsx b/apps/rowboat/app/projects/[projectId]/playground/simulation-options.tsx index 2b5da1d7..845ee8fb 100644 --- a/apps/rowboat/app/projects/[projectId]/playground/simulation-options.tsx +++ b/apps/rowboat/app/projects/[projectId]/playground/simulation-options.tsx @@ -3,7 +3,7 @@ import { Input, Textarea } from "@nextui-org/react"; import { FormStatusButton } from "@/app/lib/components/FormStatusButton"; import { SimulationData } from "@/app/lib/types"; import { z } from "zod"; -import { scrapeWebpage } from "@/app/actions"; +import { scrapeWebpage } from "@/app/actions/actions"; import { ScenarioList } from "./scenario-list"; export function SimulateURLOption({ diff --git a/apps/rowboat/app/projects/[projectId]/sources/[sourceId]/delete.tsx b/apps/rowboat/app/projects/[projectId]/sources/[sourceId]/delete.tsx index b3b1e4c3..71c3bb4c 100644 --- a/apps/rowboat/app/projects/[projectId]/sources/[sourceId]/delete.tsx +++ b/apps/rowboat/app/projects/[projectId]/sources/[sourceId]/delete.tsx @@ -1,6 +1,6 @@ 'use client'; -import { deleteDataSource } from "@/app/actions"; +import { deleteDataSource } from "@/app/actions/datasource_actions"; import { FormStatusButton } from "@/app/lib/components/FormStatusButton"; export function DeleteSource({ diff --git a/apps/rowboat/app/projects/[projectId]/sources/[sourceId]/files-source.tsx b/apps/rowboat/app/projects/[projectId]/sources/[sourceId]/files-source.tsx new file mode 100644 index 00000000..a51bd5e3 --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/sources/[sourceId]/files-source.tsx @@ -0,0 +1,290 @@ +"use client"; +import { PageSection } from "@/app/lib/components/PageSection"; +import { DataSource, DataSourceDoc, WithStringId } from "@/app/lib/types"; +import { z } from "zod"; +import { useCallback, useEffect, useState } from "react"; +import { useDropzone } from "react-dropzone"; +import { deleteDocsFromDataSource, getUploadUrlsForFilesDataSource, addDocsToDataSource, getDownloadUrlForFile, listDocsInDataSource } from "@/app/actions/datasource_actions"; +import { RelativeTime } from "@primer/react"; +import { Pagination, Spinner } from "@nextui-org/react"; +import { DownloadIcon } from "lucide-react"; + +function FileListItem({ + projectId, + sourceId, + file, + onDelete, +}: { + projectId: string, + sourceId: string, + file: WithStringId>, + onDelete: (fileId: string) => Promise; +}) { + const [isDeleting, setIsDeleting] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + + const handleDeleteClick = async () => { + setIsDeleting(true); + try { + await onDelete(file._id); + } finally { + setIsDeleting(false); + } + }; + + const handleDownloadClick = async () => { + setIsDownloading(true); + try { + const url = await getDownloadUrlForFile(projectId, sourceId, file._id); + window.open(url, '_blank'); + } catch (error) { + console.error('Download failed:', error); + // TODO: Add error handling + } finally { + setIsDownloading(false); + } + }; + + if (file.data.type !== 'file') { + return null; + } + + return ( +
+
+
+

{file.name}

+
+ {isDownloading ? ( + + ) : ( + + )} +
+
+

+ uploaded - {formatFileSize(file.data.size)} +

+
+
+ +
+
+ ); +} + +function PaginatedFileList({ + projectId, + sourceId, + handleReload, + onDelete, +}: { + projectId: string, + sourceId: string, + handleReload: () => void; + onDelete: (fileId: string) => Promise; +}) { + const [files, setFiles] = useState>[]>([]); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + + const totalPages = Math.ceil(total / 10); + + useEffect(() => { + let ignore = false; + + async function fetchFiles() { + setLoading(true); + try { + const { files, total } = await listDocsInDataSource({ + projectId, + sourceId, + page, + limit: 10, + }); + if (!ignore) { + setFiles(files); + setTotal(total); + } + } catch (error) { + console.error('Error fetching files:', error); + } finally { + setLoading(false); + } + } + + fetchFiles(); + return () => { + ignore = true; + } + }, [projectId, sourceId, page]); + + return ( +
+

Uploaded Files

+ {loading &&
+ +

Loading list...

+
} + {!loading && files.length === 0 &&
+

No files uploaded yet

+
} + {!loading && files.length > 0 &&
+ {files.map(file => ( + + ))} + {totalPages > 1 && } +
} +
+ ) +} + +export function FilesSource({ + projectId, + dataSource, + handleReload, +}: { + projectId: string, + dataSource: WithStringId>, + handleReload: () => void; +}) { + const [uploading, setUploading] = useState(false); + const [fileListKey, setFileListKey] = useState(0); + + const onDrop = useCallback(async (acceptedFiles: File[]) => { + setUploading(true); + try { + const urls = await getUploadUrlsForFilesDataSource(projectId, dataSource._id, acceptedFiles.map(file => ({ + name: file.name, + type: file.type, + size: file.size, + }))); + + // Upload files in parallel + await Promise.all(acceptedFiles.map(async (file, index) => { + await fetch(urls[index].presignedUrl, { + method: 'PUT', + body: file, + headers: { + 'Content-Type': file.type, + }, + }); + })); + + // After successful uploads, update the database with file information + await addDocsToDataSource({ + projectId, + sourceId: dataSource._id, + docData: acceptedFiles.map((file, index) => ({ + _id: urls[index].fileId, + name: file.name, + data: { + type: 'file', + name: file.name, + size: file.size, + mimeType: file.type, + s3Key: urls[index].s3Key, + }, + })), + }); + + handleReload(); + setFileListKey(prev => prev + 1); + } catch (error) { + console.error('Upload failed:', error); + // TODO: Add error handling + } finally { + setUploading(false); + } + }, [projectId, dataSource._id, handleReload]); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + disabled: uploading, + accept: { + 'application/pdf': ['.pdf'], + 'text/plain': ['.txt'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + }, + }); + + const handleDelete = async (docId: string) => { + await deleteDocsFromDataSource({ + projectId, + sourceId: dataSource._id, + docIds: [docId], + }); + handleReload(); + setFileListKey(fileListKey + 1); + }; + + return ( + +
+ + {uploading ? ( +
+ +

Uploading files...

+
+ ) : isDragActive ? ( +

Drop the files here...

+ ) : ( +
+

Drag and drop files here, or click to select files

+

+ Supported file types: PDF, TXT, DOC, DOCX +

+
+ )} +
+ + +
+ ); +} + +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/sources/[sourceId]/page.tsx b/apps/rowboat/app/projects/[projectId]/sources/[sourceId]/page.tsx index becd7f3a..eea25998 100644 --- a/apps/rowboat/app/projects/[projectId]/sources/[sourceId]/page.tsx +++ b/apps/rowboat/app/projects/[projectId]/sources/[sourceId]/page.tsx @@ -1,9 +1,4 @@ -import { notFound } from "next/navigation"; -import { dataSourcesCollection } from "@/app/lib/mongodb"; -import { ObjectId } from "mongodb"; -import { Metadata } from "next"; import { SourcePage } from "./source-page"; -import { getDataSource } from "@/app/actions"; export default async function Page({ params, diff --git a/apps/rowboat/app/projects/[projectId]/sources/[sourceId]/scrape-source.tsx b/apps/rowboat/app/projects/[projectId]/sources/[sourceId]/scrape-source.tsx new file mode 100644 index 00000000..ccf7edd6 --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/sources/[sourceId]/scrape-source.tsx @@ -0,0 +1,273 @@ +"use client"; +import { PageSection } from "@/app/lib/components/PageSection"; +import { DataSource, DataSourceDoc, WithStringId } from "@/app/lib/types"; +import { z } from "zod"; +import { Recrawl } from "./web-recrawl"; +import { deleteDocsFromDataSource, listDocsInDataSource, recrawlWebDataSource, addDocsToDataSource } from "@/app/actions/datasource_actions"; +import { useState, useEffect } from "react"; +import { Spinner } from "@nextui-org/react"; +import { Pagination } from "@nextui-org/react"; +import { ExternalLinkIcon } from "lucide-react"; +import { Textarea } from "@nextui-org/react"; +import { FormStatusButton } from "@/app/lib/components/FormStatusButton"; +import { PlusIcon } from "lucide-react"; + +function UrlListItem({ + file, + onDelete, +}: { + file: WithStringId>, + onDelete: (fileId: string) => Promise; +}) { + const [isDeleting, setIsDeleting] = useState(false); + + const handleDeleteClick = async () => { + setIsDeleting(true); + try { + await onDelete(file._id); + } finally { + setIsDeleting(false); + } + }; + + if (file.data.type !== 'url') { + return null; + } + + return ( +
+
+
+

{file.name}

+
+ + + +
+
+
+
+ +
+
+ ); +} + +function UrlList({ + projectId, + sourceId, + onDelete, +}: { + projectId: string, + sourceId: string, + onDelete: (fileId: string) => Promise, +}) { + const [files, setFiles] = useState>[]>([]); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + + const totalPages = Math.ceil(total / 10); + + useEffect(() => { + let ignore = false; + + async function fetchFiles() { + setLoading(true); + try { + const { files, total } = await listDocsInDataSource({ projectId, sourceId, page, limit: 10 }); + if (!ignore) { + setFiles(files); + setTotal(total); + } + } catch (error) { + console.error('Error fetching files:', error); + } finally { + setLoading(false); + } + } + + fetchFiles(); + + return () => { + ignore = true; + }; + }, [projectId, sourceId, page]); + + return ( +
+

URLs

+ {loading &&
+ +

Loading list...

+
} + {!loading && files.length === 0 &&
+

No files uploaded yet

+
} + {!loading && files.length > 0 &&
+ {files.map(file => ( + + ))} + {totalPages > 1 && } +
} +
+ ) +} + +function AddUrls({ + projectId, + sourceId, + onAdd, +}: { + projectId: string, + sourceId: string, + onAdd: () => void, +}) { + const [isAdding, setIsAdding] = useState(false); + const [showForm, setShowForm] = useState(false); + + async function handleSubmit(formData: FormData) { + setIsAdding(true); + try { + const urls = formData.get('urls') as string; + const urlsArray = urls.split('\n') + .map(url => url.trim()) + .filter(url => url.length > 0); + const first100Urls = urlsArray.slice(0, 100); + + await addDocsToDataSource({ + projectId, + sourceId, + docData: first100Urls.map(url => ({ + name: url, + data: { + type: 'url', + url, + }, + })), + }); + onAdd(); + setShowForm(false); // Hide form after successful submission + } finally { + setIsAdding(false); + } + } + + return ( +
+ {!showForm ? ( + setShowForm(true), + children: "Add more URLs", + className: "self-start", + startContent: , + }} + /> + ) : ( +
+
+ +
} {!loading && profile && ( @@ -214,7 +214,7 @@ function ViewProfile({ size="sm" color="danger" variant="flat" - onClick={() => setIsDeleteModalOpen(true)} + onPress={() => setIsDeleteModalOpen(true)} > Delete @@ -323,7 +323,7 @@ function NewProfile({ {error &&
{error} - +
} Profiles
} {error &&
{error} - +
} {!loading && !error && <> {profiles.length === 0 &&
No profiles found
} diff --git a/apps/rowboat/app/projects/[projectId]/test/[[...slug]]/runs_app.tsx b/apps/rowboat/app/projects/[projectId]/test/[[...slug]]/runs_app.tsx index a5ce8f90..6207bd51 100644 --- a/apps/rowboat/app/projects/[projectId]/test/[[...slug]]/runs_app.tsx +++ b/apps/rowboat/app/projects/[projectId]/test/[[...slug]]/runs_app.tsx @@ -68,7 +68,7 @@ function NewRun({ + } {!loading && !error && <> {runs.length === 0 &&
No test runs found
} diff --git a/apps/rowboat/app/projects/[projectId]/test/[[...slug]]/scenarios_app.tsx b/apps/rowboat/app/projects/[projectId]/test/[[...slug]]/scenarios_app.tsx index 9054c494..43541df8 100644 --- a/apps/rowboat/app/projects/[projectId]/test/[[...slug]]/scenarios_app.tsx +++ b/apps/rowboat/app/projects/[projectId]/test/[[...slug]]/scenarios_app.tsx @@ -61,7 +61,7 @@ function EditScenario({ @@ -292,7 +292,7 @@ function NewScenario({ + } {!loading && !error && <> {scenarios.length === 0 &&
No scenarios found
} diff --git a/apps/rowboat/app/projects/[projectId]/test/[[...slug]]/simulations_app.tsx b/apps/rowboat/app/projects/[projectId]/test/[[...slug]]/simulations_app.tsx index 2fa1c47a..b02d4bae 100644 --- a/apps/rowboat/app/projects/[projectId]/test/[[...slug]]/simulations_app.tsx +++ b/apps/rowboat/app/projects/[projectId]/test/[[...slug]]/simulations_app.tsx @@ -86,7 +86,7 @@ function EditSimulation({ } {error &&
{error} - +
} {!loading && simulation && ( @@ -116,7 +116,7 @@ function EditSimulation({ )} } @@ -438,7 +438,7 @@ function NewSimulation({ )} } + } {!loading && !error && <> {simulationList.length === 0 &&
No simulation found
} diff --git a/apps/rowboat/app/projects/[projectId]/workflow/agent_config.tsx b/apps/rowboat/app/projects/[projectId]/workflow/agent_config.tsx index 85411c25..d250bfae 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/agent_config.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/agent_config.tsx @@ -210,7 +210,7 @@ export function AgentConfig({ size="sm" variant="light" className="opacity-0 group-hover:opacity-100 transition-opacity text-gray-500 hover:text-red-500" - onClick={() => { + onPress={() => { const newSources = agent.ragDataSources?.filter((s) => s !== source); handleUpdate({ ...agent, @@ -409,7 +409,7 @@ function GenerateInstructionsModal({ diff --git a/apps/rowboat/app/projects/[projectId]/workflow/workflow_selector.tsx b/apps/rowboat/app/projects/[projectId]/workflow/workflow_selector.tsx index b816fee8..a479a5fb 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/workflow_selector.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/workflow_selector.tsx @@ -105,7 +105,7 @@ export function WorkflowSelector({ From 3e6759136749977dd6bb4b3df269d4fc33c3a850 Mon Sep 17 00:00:00 2001 From: ramnique <30795890+ramnique@users.noreply.github.com> Date: Thu, 6 Mar 2025 14:01:24 +0530 Subject: [PATCH 70/75] auto-redirect to login page --- apps/rowboat/app/app.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/rowboat/app/app.tsx b/apps/rowboat/app/app.tsx index b404c875..7437d7a0 100644 --- a/apps/rowboat/app/app.tsx +++ b/apps/rowboat/app/app.tsx @@ -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 (
{/* Main content box */} @@ -25,17 +30,8 @@ export function App() { alt="RowBoat Logo" height={40} /> - {isLoading && } + {(isLoading || (!user && !error)) && } {error &&
{error.message}
} - {!isLoading && !error && !user && ( - - - Sign in or sign up - - )} {user &&
Welcome, {user.name}
From 2a16a8ce31097b9fd1200f238ab848c26bb71722 Mon Sep 17 00:00:00 2001 From: ramnique <30795890+ramnique@users.noreply.github.com> Date: Fri, 7 Mar 2025 11:51:33 +0530 Subject: [PATCH 71/75] api should only return new msgs --- apps/rowboat/app/api/v1/[projectId]/chat/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/rowboat/app/api/v1/[projectId]/chat/route.ts b/apps/rowboat/app/api/v1/[projectId]/chat/route.ts index 8047f40d..f4d9c4fc 100644 --- a/apps/rowboat/app/api/v1/[projectId]/chat/route.ts +++ b/apps/rowboat/app/api/v1/[projectId]/chat/route.ts @@ -210,7 +210,7 @@ export async function POST( } while (hasToolCalls); const responseBody: z.infer = { - messages: currentMessages, + messages: currentMessages.slice(reqMessages.length), state: currentState, }; return Response.json(responseBody); From 0df92e80c6df559e663886910d07a073aebc3b5e Mon Sep 17 00:00:00 2001 From: ramnique <30795890+ramnique@users.noreply.github.com> Date: Sun, 9 Mar 2025 15:13:19 +0530 Subject: [PATCH 72/75] add chat-widget to monorepo --- apps/chat_widget/.dockerignore | 8 + apps/chat_widget/.eslintrc.json | 3 + apps/chat_widget/.gitignore | 40 + apps/chat_widget/Dockerfile | 68 + apps/chat_widget/README.md | 36 + .../app/api/bootstrap.js/bootstrap.js | 183 + .../chat_widget/app/api/bootstrap.js/route.ts | 35 + apps/chat_widget/app/app.tsx | 466 + apps/chat_widget/app/favicon.ico | Bin 0 -> 25931 bytes apps/chat_widget/app/fonts/GeistMonoVF.woff | Bin 0 -> 67864 bytes apps/chat_widget/app/fonts/GeistVF.woff | Bin 0 -> 66268 bytes apps/chat_widget/app/globals.css | 7 + apps/chat_widget/app/layout.tsx | 35 + apps/chat_widget/app/markdown-content.tsx | 51 + apps/chat_widget/app/page.tsx | 10 + apps/chat_widget/app/providers.tsx | 16 + apps/chat_widget/next.config.mjs | 6 + apps/chat_widget/package-lock.json | 9671 +++++++++++++++++ apps/chat_widget/package.json | 32 + apps/chat_widget/postcss.config.mjs | 8 + apps/chat_widget/public/file.svg | 1 + apps/chat_widget/public/globe.svg | 1 + apps/chat_widget/public/next.svg | 1 + apps/chat_widget/public/vercel.svg | 1 + apps/chat_widget/public/window.svg | 1 + apps/chat_widget/tailwind.config.ts | 16 + apps/chat_widget/tsconfig.json | 27 + .../widget/v1/chats/[chatId]/close/route.ts | 6 +- .../v1/chats/[chatId]/messages/route.ts | 5 +- .../widget/v1/chats/[chatId]/turn/route.ts | 62 +- apps/rowboat/app/lib/mongodb.ts | 5 +- .../app/projects/[projectId]/config/app.tsx | 8 +- .../app/projects/[projectId]/config/page.tsx | 5 +- apps/rowboat/middleware.ts | 2 +- docker-compose.yml | 13 + 35 files changed, 10804 insertions(+), 25 deletions(-) create mode 100644 apps/chat_widget/.dockerignore create mode 100644 apps/chat_widget/.eslintrc.json create mode 100644 apps/chat_widget/.gitignore create mode 100644 apps/chat_widget/Dockerfile create mode 100644 apps/chat_widget/README.md create mode 100644 apps/chat_widget/app/api/bootstrap.js/bootstrap.js create mode 100644 apps/chat_widget/app/api/bootstrap.js/route.ts create mode 100644 apps/chat_widget/app/app.tsx create mode 100644 apps/chat_widget/app/favicon.ico create mode 100644 apps/chat_widget/app/fonts/GeistMonoVF.woff create mode 100644 apps/chat_widget/app/fonts/GeistVF.woff create mode 100644 apps/chat_widget/app/globals.css create mode 100644 apps/chat_widget/app/layout.tsx create mode 100644 apps/chat_widget/app/markdown-content.tsx create mode 100644 apps/chat_widget/app/page.tsx create mode 100644 apps/chat_widget/app/providers.tsx create mode 100644 apps/chat_widget/next.config.mjs create mode 100644 apps/chat_widget/package-lock.json create mode 100644 apps/chat_widget/package.json create mode 100644 apps/chat_widget/postcss.config.mjs create mode 100644 apps/chat_widget/public/file.svg create mode 100644 apps/chat_widget/public/globe.svg create mode 100644 apps/chat_widget/public/next.svg create mode 100644 apps/chat_widget/public/vercel.svg create mode 100644 apps/chat_widget/public/window.svg create mode 100644 apps/chat_widget/tailwind.config.ts create mode 100644 apps/chat_widget/tsconfig.json diff --git a/apps/chat_widget/.dockerignore b/apps/chat_widget/.dockerignore new file mode 100644 index 00000000..21b9cda1 --- /dev/null +++ b/apps/chat_widget/.dockerignore @@ -0,0 +1,8 @@ +Dockerfile +.dockerignore +node_modules +npm-debug.log +README.md +.next +.git +.env* \ No newline at end of file diff --git a/apps/chat_widget/.eslintrc.json b/apps/chat_widget/.eslintrc.json new file mode 100644 index 00000000..37224185 --- /dev/null +++ b/apps/chat_widget/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/apps/chat_widget/.gitignore b/apps/chat_widget/.gitignore new file mode 100644 index 00000000..26b002aa --- /dev/null +++ b/apps/chat_widget/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files (can opt-in for commiting if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/chat_widget/Dockerfile b/apps/chat_widget/Dockerfile new file mode 100644 index 00000000..4c488beb --- /dev/null +++ b/apps/chat_widget/Dockerfile @@ -0,0 +1,68 @@ +# syntax=docker.io/docker/dockerfile:1 + +FROM node:18-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +# ENV NEXT_TELEMETRY_DISABLED=1 + +RUN \ + if [ -f yarn.lock ]; then yarn run build; \ + elif [ -f package-lock.json ]; then npm run build; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output +ENV HOSTNAME="0.0.0.0" +ENV PORT=3000 +CMD echo "Starting server $CHAT_WIDGET_HOST, $ROWBOAT_HOST" && node server.js +#CMD ["node", "server.js"] \ No newline at end of file diff --git a/apps/chat_widget/README.md b/apps/chat_widget/README.md new file mode 100644 index 00000000..e215bc4c --- /dev/null +++ b/apps/chat_widget/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/chat_widget/app/api/bootstrap.js/bootstrap.js b/apps/chat_widget/app/api/bootstrap.js/bootstrap.js new file mode 100644 index 00000000..aa206af7 --- /dev/null +++ b/apps/chat_widget/app/api/bootstrap.js/bootstrap.js @@ -0,0 +1,183 @@ +// Split into separate configuration file/module +const CONFIG = { + CHAT_URL: '__CHAT_WIDGET_HOST__', + API_URL: '__ROWBOAT_HOST__/api/widget/v1', + STORAGE_KEYS: { + MINIMIZED: 'rowboat_chat_minimized', + SESSION: 'rowboat_session_id' + }, + IFRAME_STYLES: { + MINIMIZED: { + width: '48px', + height: '48px', + borderRadius: '50%' + }, + MAXIMIZED: { + width: '400px', + height: 'min(calc(100vh - 32px), 600px)', + borderRadius: '10px' + }, + BASE: { + position: 'fixed', + bottom: '20px', + right: '20px', + border: 'none', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + zIndex: '999999', + transition: 'all 0.1s ease-in-out' + } + } +}; + +// New SessionManager class to handle session-related operations +class SessionManager { + static async createGuestSession() { + try { + const response = await fetch(`${CONFIG.API_URL}/session/guest`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-client-id': window.ROWBOAT_CONFIG.clientId + }, + }); + + if (!response.ok) throw new Error('Failed to create session'); + + const data = await response.json(); + CookieManager.setCookie(CONFIG.STORAGE_KEYS.SESSION, data.sessionId); + return true; + } catch (error) { + console.error('Failed to create chat session:', error); + return false; + } + } +} + +// New CookieManager class for cookie operations +class CookieManager { + static getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); + return null; + } + + static setCookie(name, value) { + document.cookie = `${name}=${value}; path=/`; + } + + static deleteCookie(name) { + document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`; + } +} + +// New IframeManager class to handle iframe-specific operations +class IframeManager { + static createIframe(url, isMinimized) { + const iframe = document.createElement('iframe'); + iframe.hidden = true; + iframe.src = url.toString(); + + Object.assign(iframe.style, CONFIG.IFRAME_STYLES.BASE); + IframeManager.updateSize(iframe, isMinimized); + + return iframe; + } + + static updateSize(iframe, isMinimized) { + const styles = isMinimized ? CONFIG.IFRAME_STYLES.MINIMIZED : CONFIG.IFRAME_STYLES.MAXIMIZED; + Object.assign(iframe.style, styles); + } + + static removeIframe(iframe) { + if (iframe && iframe.parentNode) { + iframe.parentNode.removeChild(iframe); + } + } +} + +// Refactored main ChatWidget class +class ChatWidget { + constructor() { + this.iframe = null; + this.messageHandlers = { + chatLoaded: () => this.iframe.hidden = false, + chatStateChange: (data) => this.handleStateChange(data), + sessionExpired: () => this.handleSessionExpired() + }; + + this.init(); + } + + async init() { + const sessionId = CookieManager.getCookie(CONFIG.STORAGE_KEYS.SESSION); + if (!sessionId && !(await SessionManager.createGuestSession())) { + console.error('Chat widget initialization failed: Could not create session'); + return; + } + + this.createAndMountIframe(); + this.setupEventListeners(); + } + + createAndMountIframe() { + const url = this.buildUrl(); + const isMinimized = this.getStoredMinimizedState(); + this.iframe = IframeManager.createIframe(url, isMinimized); + document.body.appendChild(this.iframe); + } + + buildUrl() { + const sessionId = CookieManager.getCookie(CONFIG.STORAGE_KEYS.SESSION); + const isMinimized = this.getStoredMinimizedState(); + + const url = new URL(`${CONFIG.CHAT_URL}/`); + url.searchParams.append('session_id', sessionId); + url.searchParams.append('minimized', isMinimized); + + return url; + } + + setupEventListeners() { + window.addEventListener('message', (event) => this.handleMessage(event)); + } + + handleMessage(event) { + if (event.origin !== CONFIG.CHAT_URL) return; + + if (this.messageHandlers[event.data.type]) { + this.messageHandlers[event.data.type](event.data); + } + } + + async handleSessionExpired() { + console.log("Session expired"); + IframeManager.removeIframe(this.iframe); + CookieManager.deleteCookie(CONFIG.STORAGE_KEYS.SESSION); + + const sessionCreated = await SessionManager.createGuestSession(); + if (!sessionCreated) { + console.error('Failed to recreate session after expiry'); + return; + } + + this.createAndMountIframe(); + document.body.appendChild(this.iframe); + } + + handleStateChange(data) { + localStorage.setItem(CONFIG.STORAGE_KEYS.MINIMIZED, data.isMinimized); + IframeManager.updateSize(this.iframe, data.isMinimized); + } + + getStoredMinimizedState() { + return localStorage.getItem(CONFIG.STORAGE_KEYS.MINIMIZED) !== 'false'; + } +} + +// Initialize when DOM is ready +if (document.readyState === 'complete') { + new ChatWidget(); +} else { + window.addEventListener('load', () => new ChatWidget()); +} \ No newline at end of file diff --git a/apps/chat_widget/app/api/bootstrap.js/route.ts b/apps/chat_widget/app/api/bootstrap.js/route.ts new file mode 100644 index 00000000..d2df0406 --- /dev/null +++ b/apps/chat_widget/app/api/bootstrap.js/route.ts @@ -0,0 +1,35 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +export const dynamic = 'force-dynamic' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Read the file once when the module loads +const jsFileContents = fs.readFile( + path.join(__dirname, 'bootstrap.js'), + 'utf-8' +); + +export async function GET() { + try { + // Reuse the cached content + const template = await jsFileContents; + + // Replace placeholder values with actual URLs + const contents = template + .replace('__CHAT_WIDGET_HOST__', process.env.CHAT_WIDGET_HOST || '') + .replace('__ROWBOAT_HOST__', process.env.ROWBOAT_HOST || ''); + + return new Response(contents, { + headers: { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }, + }); + } catch (error) { + console.error('Error serving bootstrap.js:', error); + return new Response('Error loading script', { status: 500 }); + } +} diff --git a/apps/chat_widget/app/app.tsx b/apps/chat_widget/app/app.tsx new file mode 100644 index 00000000..650a9824 --- /dev/null +++ b/apps/chat_widget/app/app.tsx @@ -0,0 +1,466 @@ +"use client"; +import { useEffect, useRef, useState, useCallback } from "react"; +import { useSearchParams } from "next/navigation"; +import { apiV1 } from "rowboat-shared"; +import { z } from "zod"; +import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Textarea } from "@nextui-org/react"; +import MarkdownContent from "./markdown-content"; + +type Message = { + role: "user" | "assistant" | "system" | "tool"; + content: string; + tool_call_id?: string; + tool_name?: string; +} + +function ChatWindowHeader({ + chatId, + closeChat, + closed, + setMinimized, +}: { + chatId: string | null; + closeChat: () => Promise; + closed: boolean; + setMinimized: (minimized: boolean) => void; +}) { + return
+
Chat
+
+ {(chatId && !closed) && + + + + { + if (key === "close") { + closeChat(); + } + }}> + + Close chat + + + } + +
+
+} + +function LoadingAssistantResponse() { + return
+
+
+
+
+
+
+
+
+
+} +function AssistantMessage({ + children, +}: { + children: React.ReactNode; +}) { + return
+
Assistant
+
+ {typeof children === 'string' ? : children} +
+
+} + +function UserMessage({ + children, +}: { + children: React.ReactNode; +}) { + return
+
+ {typeof children === 'string' ? : children} +
+
+} +function ChatWindowMessages({ + messages, + loadingAssistantResponse, +}: { + messages: Message[]; + loadingAssistantResponse: boolean; +}) { + const messagesEndRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + return
+ + Hello! I'm Rowboat, your personal assistant. How can I help you today? + + {messages.map((message, index) => { + switch (message.role) { + case "user": + return {message.content}; + case "assistant": + return {message.content}; + case "system": + return null; // Hide system messages from the UI + case "tool": + return + Tool response ({message.tool_name}): {message.content} + ; + default: + return null; + } + })} + {loadingAssistantResponse && } +
+
+} + +function ChatWindowInput({ + handleUserMessage, +}: { + handleUserMessage: (message: string) => Promise; +}) { + const [prompt, setPrompt] = useState(""); + + function handleInputKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + const input = prompt.trim(); + setPrompt(''); + + handleUserMessage(input); + } + } + + return
+