add rowboat app

This commit is contained in:
ramnique 2025-01-13 15:31:31 +05:30
parent b83b5f8a07
commit 10f76ef49f
117 changed files with 25370 additions and 0 deletions

View file

@ -0,0 +1,3 @@
import { handleAuth } from '@auth0/nextjs-auth0';
export const GET = handleAuth();

View file

@ -0,0 +1,40 @@
import { NextRequest } from "next/server";
import { apiV1 } from "rowboat-shared";
import { db } from "@/app/lib/mongodb";
import { z } from "zod";
import { ObjectId } from "mongodb";
import { authCheck } from "../../../utils";
const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
export async function POST(
request: NextRequest,
{ params }: { params: { chatId: string } }
): Promise<Response> {
return await authCheck(request, async (session) => {
const { chatId } = params;
const result = await chatsCollection.findOneAndUpdate(
{
_id: new ObjectId(chatId),
projectId: session.projectId,
userId: session.userId,
closed: { $exists: false },
},
{
$set: {
closed: true,
closedAt: new Date().toISOString(),
closeReason: "user-closed-chat",
},
},
{ returnDocument: 'after' }
);
if (!result) {
return Response.json({ error: "Chat not found" }, { status: 404 });
}
return Response.json(result);
});
}

View file

@ -0,0 +1,94 @@
import { NextRequest } from "next/server";
import { apiV1 } from "rowboat-shared";
import { db } from "@/app/lib/mongodb";
import { z } from "zod";
import { Filter, ObjectId } from "mongodb";
import { authCheck } from "../../../utils";
const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chatMessages");
// list messages
export async function GET(
req: NextRequest,
{ params }: { params: { chatId: string } }
): Promise<Response> {
return await authCheck(req, async (session) => {
const { chatId } = params;
// Check if chat exists
const chat = await chatsCollection.findOne({
_id: new ObjectId(chatId),
projectId: session.projectId,
userId: session.userId
});
if (!chat) {
return Response.json({ error: "Chat not found" }, { status: 404 });
}
// Parse query parameters
const searchParams = req.nextUrl.searchParams;
const limit = 10; // Hardcoded limit
const next = searchParams.get('next');
const previous = searchParams.get('previous');
// Construct the query
const query: Filter<z.infer<typeof apiV1.ChatMessage>> = {
chatId,
$or: [
{ role: 'user' },
{ role: 'assistant', agenticResponseType: { $eq: 'external' } }
],
};
// Add cursor condition to the query
if (previous) {
query._id = { $lt: new ObjectId(previous) };
} else if (next) {
query._id = { $gt: new ObjectId(next) };
}
// Fetch messages from the database
let messages = await chatMessagesCollection
.find(query)
.sort({ _id: previous ? -1 : 1 }) // Sort based on direction
.limit(limit + 1) // Fetch one extra to determine if there are more results
.toArray();
// Determine if there are more results
const hasMore = messages.length > limit;
if (hasMore) {
messages.pop();
}
// Reverse the array if we're paginating backwards
if (previous) {
messages.reverse();
}
let nextCursor: string | undefined;
let previousCursor: string | undefined;
if (messages.length > 0) {
if (hasMore || previous) {
nextCursor = messages[messages.length - 1]._id.toString();
}
if (next || (previous && hasMore)) {
previousCursor = messages[0]._id.toString();
}
}
// Prepare the response
const response: z.infer<typeof apiV1.ApiGetChatMessagesResponse> = {
messages: messages.map(message => ({
...message,
id: message._id.toString(),
_id: undefined
})),
next: nextCursor,
previous: previousCursor,
};
// Return response
return Response.json(response);
});
}

View file

@ -0,0 +1,43 @@
import { NextRequest } from "next/server";
import { apiV1 } from "rowboat-shared";
import { db } from "@/app/lib/mongodb";
import { z } from "zod";
import { ObjectId } from "mongodb";
import { authCheck } from "../../utils";
const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
// get chat
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ chatId: string }> }
): Promise<Response> {
return await authCheck(req, async (session) => {
const { chatId } = await params;
// fetch the chat from the database
let chatIdObj: ObjectId;
try {
chatIdObj = new ObjectId(chatId);
} catch (e) {
return Response.json({ error: "Invalid chat ID" }, { status: 400 });
}
const chat = await chatsCollection.findOne({
projectId: session.projectId,
userId: session.userId,
_id: chatIdObj
});
if (!chat) {
return Response.json({ error: "Chat not found" }, { status: 404 });
}
// return the chat
return Response.json({
...chat,
id: chat._id.toString(),
_id: undefined,
});
});
}

View file

@ -0,0 +1,152 @@
import { NextRequest } from "next/server";
import { apiV1 } from "rowboat-shared";
import { agentWorkflowsCollection, db, projectsCollection } from "@/app/lib/mongodb";
import { z } from "zod";
import { ObjectId, WithId } from "mongodb";
import { authCheck } from "../../../utils";
import { AgenticAPIChatRequest, convertToAgenticAPIChatMessages, convertToCoreMessages, convertWorkflowToAgenticAPI } from "@/app/lib/types";
import { executeClientTool, getAssistantResponse } from "@/app/actions";
const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chatMessages");
// get next turn / agent response
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ chatId: string }> }
): Promise<Response> {
return await authCheck(req, async (session) => {
const { chatId } = await params;
// parse and validate the request body
let body;
try {
body = await req.json();
} catch (e) {
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
}
const result = apiV1.ApiChatTurnRequest.safeParse(body);
if (!result.success) {
return Response.json({ error: `Invalid request body: ${result.error.message}` }, { status: 400 });
}
const userMessage: z.infer<typeof apiV1.ChatMessage> = {
version: 'v1',
createdAt: new Date().toISOString(),
chatId,
role: 'user',
content: result.data.message,
};
// ensure chat exists
const chat = await chatsCollection.findOne({
projectId: session.projectId,
userId: session.userId,
_id: new ObjectId(chatId)
});
if (!chat) {
return Response.json({ error: "Chat not found" }, { status: 404 });
}
// prepare system message which will contain user data
const systemMessage: z.infer<typeof apiV1.ChatMessage> = {
version: 'v1',
createdAt: new Date().toISOString(),
chatId,
role: 'system',
content: `The following user data is available to you: ${JSON.stringify(chat.userData)}`,
};
// fetch existing chat messages
const messages = await chatMessagesCollection.find({ chatId: chatId }).toArray();
// fetch project settings
const projectSettings = await projectsCollection.findOne({
"_id": session.projectId,
});
if (!projectSettings) {
throw new Error("Project settings not found");
}
// fetch workflow
const workflow = await agentWorkflowsCollection.findOne({
projectId: session.projectId,
_id: new ObjectId(projectSettings.publishedWorkflowId),
});
if (!workflow) {
throw new Error("Workflow not found");
}
// get assistant response
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow);
const unsavedMessages: z.infer<typeof apiV1.ChatMessage>[] = [userMessage];
let resolvingToolCalls = true;
let state: unknown = chat.agenticState ?? {last_agent_name: startAgent};
while (resolvingToolCalls) {
const request: z.infer<typeof AgenticAPIChatRequest> = {
messages: convertToAgenticAPIChatMessages([systemMessage, ...messages, ...unsavedMessages]),
state,
agents,
tools,
prompts,
startAgent,
};
console.log("turn: sending agentic request", JSON.stringify(request, null, 2));
const response = await getAssistantResponse(session.projectId, request);
state = response.state;
if (response.messages.length === 0) {
throw new Error("No messages returned from assistant");
}
unsavedMessages.push(...response.messages.map(m => ({
...m,
version: 'v1' as const,
chatId,
createdAt: new Date().toISOString(),
})));
// if the last messages is tool call, execute them
const lastMessage = response.messages[response.messages.length - 1];
if (lastMessage.role === 'assistant' && 'tool_calls' in lastMessage) {
// execute tool calls
console.log("Executing tool calls", lastMessage.tool_calls);
const toolCallResults = await Promise.all(lastMessage.tool_calls.map(async toolCall => {
console.log('executing tool call', toolCall);
try {
return await executeClientTool(toolCall, session.projectId);
} catch (error) {
console.error(`Error executing tool call ${toolCall.id}:`, error);
return { error: "Tool execution failed" };
}
}));
unsavedMessages.push(...toolCallResults.map((result, index) => ({
version: 'v1' as const,
chatId,
createdAt: new Date().toISOString(),
role: 'tool' as const,
tool_call_id: lastMessage.tool_calls[index].id,
tool_name: lastMessage.tool_calls[index].function.name,
content: JSON.stringify(result),
})));
} else {
// ensure that the last message is from an assistant
// and is of an external type
if (lastMessage.role !== 'assistant' || lastMessage.agenticResponseType !== 'external') {
throw new Error("Last message is not from an assistant and is not of an external type");
}
resolvingToolCalls = false;
break;
}
}
// save unsaved messages and update chat state
await chatMessagesCollection.insertMany(unsavedMessages);
await chatsCollection.updateOne({ _id: new ObjectId(chatId) }, { $set: { agenticState: state } });
// send back the last message
const lastMessage = unsavedMessages[unsavedMessages.length - 1] as WithId<z.infer<typeof apiV1.ChatMessage>>;
return Response.json({
...lastMessage,
id: lastMessage._id.toString(),
_id: undefined,
});
});
}

View file

@ -0,0 +1,116 @@
import { NextRequest } from "next/server";
import { db } from "@/app/lib/mongodb";
import { z } from "zod";
import { ObjectId } from "mongodb";
import { apiV1 } from "rowboat-shared";
import { authCheck } from "../utils";
const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
// create a chat
export async function POST(
req: NextRequest,
): Promise<Response> {
return await authCheck(req, async (session) => {
// parse and validate the request body
let body;
try {
body = await req.json();
} catch (e) {
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
}
const result = apiV1.ApiCreateChatRequest.safeParse(body);
if (!result.success) {
return new Response(JSON.stringify({ error: `Invalid request body: ${result.error.message}` }), { status: 400 });
}
// insert the chat into the database
const id = new ObjectId();
const chat: z.infer<typeof apiV1.Chat> = {
version: "v1",
projectId: session.projectId,
userId: session.userId,
createdAt: new Date().toISOString(),
userData: {
userId: session.userId,
userName: session.userName,
},
}
await chatsCollection.insertOne({
...chat,
_id: id,
});
// return response
const response: z.infer<typeof apiV1.ApiCreateChatResponse> = {
...chat,
id: id.toString(),
};
return Response.json(response);
});
}
// list chats
export async function GET(
req: NextRequest,
): Promise<Response> {
return await authCheck(req, async (session) => {
// Parse query parameters
const searchParams = req.nextUrl.searchParams;
const limit = 10; // Hardcoded limit
const next = searchParams.get('next');
const previous = searchParams.get('previous');
// Add userId to query to only show chats for current user
const query: { projectId: string; userId: string; _id?: { $lt?: ObjectId; $gt?: ObjectId } } = {
projectId: session.projectId,
userId: session.userId
};
// Add cursor condition to the query
if (next) {
query._id = { $lt: new ObjectId(next) };
} else if (previous) {
query._id = { $gt: new ObjectId(previous) };
}
// Fetch chats from the database
let chats = await chatsCollection
.find(query)
.sort({ _id: -1 }) // Sort in descending order
.limit(limit + 1) // Fetch one extra to determine if there are more results
.toArray();
// Determine if there are more results
const hasMore = chats.length > limit;
if (hasMore) {
chats.pop();
}
let nextCursor: string | undefined;
let previousCursor: string | undefined;
if (chats.length > 0) {
if (hasMore || previous) {
nextCursor = chats[chats.length - 1]._id.toString();
}
if (next || (previous && hasMore)) {
previousCursor = chats[0]._id.toString();
}
}
// Prepare the response
const response: z.infer<typeof apiV1.ApiGetChatsResponse> = {
chats: chats
.slice(0, limit)
.map(chat => ({
...chat,
id: chat._id.toString(),
_id: undefined
})),
next: nextCursor,
previous: previousCursor,
};
// Return response
return Response.json(response);
});
}

View file

@ -0,0 +1,30 @@
import { NextRequest } from "next/server";
import { clientIdCheck } from "../../utils";
import { SignJWT } from "jose";
import { z } from "zod";
import { Session } from "../../utils";
import { apiV1 } from "rowboat-shared";
export async function POST(req: NextRequest): Promise<Response> {
return await clientIdCheck(req, async (projectId) => {
// create a new guest user
const session: z.infer<typeof Session> = {
userId: `guest-${crypto.randomUUID()}`,
userName: 'Guest User',
projectId: projectId
};
// Create and sign JWT
const token = await new SignJWT(session)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('24h')
.sign(new TextEncoder().encode(process.env.CHAT_WIDGET_SESSION_JWT_SECRET));
const response: z.infer<typeof apiV1.ApiCreateGuestSessionResponse> = {
sessionId: token,
};
return Response.json(response);
});
}

View file

@ -0,0 +1,55 @@
import { NextRequest } from "next/server";
import { clientIdCheck } from "../../utils";
import { SignJWT, jwtVerify } from "jose";
import { z } from "zod";
import { Session } from "../../utils";
import { apiV1 } from "rowboat-shared";
import { projectsCollection } from "@/app/lib/mongodb";
export async function POST(req: NextRequest): Promise<Response> {
return await clientIdCheck(req, async (projectId) => {
// decode and validate JWT
const json = await req.json();
const parsedRequest = apiV1.ApiCreateUserSessionRequest.parse(json);
// fetch client signing key from db
const project = await projectsCollection.findOne({
_id: projectId
});
if (!project) {
return Response.json({ error: 'Project not found' }, { status: 404 });
}
const clientSigningKey = project.secret;
// verify client signing key
let verified;
try {
verified = await jwtVerify<{
userId: string;
userName?: string;
}>(parsedRequest.userDataJwt, new TextEncoder().encode(clientSigningKey));
} catch (e) {
return Response.json({ error: 'Invalid jwt' }, { status: 403 });
}
// create new user session
const session: z.infer<typeof Session> = {
userId: verified.payload.userId,
userName: verified.payload.userName ?? 'Unknown',
projectId: projectId
};
// Create and sign JWT
const token = await new SignJWT(session)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('24h')
.sign(new TextEncoder().encode(process.env.CHAT_WIDGET_SESSION_JWT_SECRET));
const response: z.infer<typeof apiV1.ApiCreateGuestSessionResponse> = {
sessionId: token,
};
return Response.json(response);
});
}

View file

@ -0,0 +1,62 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { jwtVerify } from "jose";
import { projectsCollection } from "@/app/lib/mongodb";
export const Session = z.object({
userId: z.string(),
userName: z.string(),
projectId: z.string(),
});
/*
This function wraps an API handler with client ID validation.
It checks for a client ID in the request headers and returns a 400
Bad Request response if missing. It then looks up the client ID in the
database to fetch the corresponding project ID. If no record is found,
it returns a 403 Forbidden response. Otherwise, it sets the project ID
in the request headers and calls the provided handler function.
*/
export async function clientIdCheck(req: NextRequest, handler: (projectId: string) => Promise<Response>): Promise<Response> {
const clientId = req.headers.get('x-client-id')?.trim();
if (!clientId) {
return Response.json({ error: "Missing client ID in request" }, { status: 400 });
}
const project = await projectsCollection.findOne({
chatClientId: clientId
});
if (!project) {
return Response.json({ error: "Invalid client ID" }, { status: 403 });
}
// set the project id in the request headers
req.headers.set('x-project-id', project._id);
return await handler(project._id);
}
/*
This function wraps an API handler with session validation.
It checks for a session in the request headers and returns a 400
Bad Request response if missing. It then verifies the session JWT.
If no record is found, it returns a 403 Forbidden response. Otherwise,
it sets the project ID and user ID in the request headers and calls the
provided handler function.
*/
export async function authCheck(req: NextRequest, handler: (session: z.infer<typeof Session>) => Promise<Response>): Promise<Response> {
const authHeader = req.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return Response.json({ error: "Authorization header must be a Bearer token" }, { status: 400 });
}
const token = authHeader.split(' ')[1];
if (!token) {
return Response.json({ error: "Missing session token in request" }, { status: 400 });
}
let session;
try {
session = await jwtVerify(token, new TextEncoder().encode(process.env.CHAT_WIDGET_SESSION_JWT_SECRET));
} catch (error) {
return Response.json({ error: "Invalid session token" }, { status: 403 });
}
return await handler(session.payload as z.infer<typeof Session>);
}