diff --git a/README.md b/README.md index d0b95ab6..1999e815 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,51 @@ Before running RowBoat, ensure you have: 4. **Access the App** - Visit [http://localhost:3000](http://localhost:3000). +5. **Use the API** + + You can use the API at [http://localhost:3000/api/v1/](http://localhost:3000/api/v1/) + - Project ID is available in the URL of the project page + - Project Secret is available in the project config page + + ```bash + curl --location 'http://localhost:3000/api/v1//chat' \ + --header 'Content-Type: application/json' \ + --header 'Authorization: Bearer ' \ + --data '{ + "messages": [ + { + "role": "user", + "content": "tell me the weather in london in metric units" + } + ] + }' + ``` + which gives: + ```json + { + "messages": [ + { + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"location\":\"London\",\"units\":\"metric\"}", + "name": "weather_lookup_tool" + }, + "id": "call_r6XKuVxmGRogofkyFZIacdL0", + "type": "function" + } + ], + "agenticSender": "Example Agent", + "agenticResponseType": "internal" + } + ], + "state": { + // .. state data + } + } + ``` + ## Troubleshooting 1. **MongoDB Connection Issues** diff --git a/apps/rowboat/app/actions.ts b/apps/rowboat/app/actions.ts index 2208681b..8a22b09a 100644 --- a/apps/rowboat/app/actions.ts +++ b/apps/rowboat/app/actions.ts @@ -467,7 +467,11 @@ export async function getAssistantResponse( await projectAuthCheck(projectId); const response = await getAgenticApiResponse(request); - return response; + return { + messages: convertFromAgenticAPIChatMessages(response.messages), + state: response.state, + rawAPIResponse: response.rawAPIResponse, + }; } export async function getCopilotResponse( diff --git a/apps/rowboat/app/api/v1/[projectId]/chat/route.ts b/apps/rowboat/app/api/v1/[projectId]/chat/route.ts new file mode 100644 index 00000000..0c4f391c --- /dev/null +++ b/apps/rowboat/app/api/v1/[projectId]/chat/route.ts @@ -0,0 +1,70 @@ +import { NextRequest } from "next/server"; +import { agentWorkflowsCollection, db, projectsCollection } from "@/app/lib/mongodb"; +import { z } from "zod"; +import { ObjectId } from "mongodb"; +import { authCheck } from "@/app/api/v1/utils"; +import { convertFromApiToAgenticApiMessages, convertFromAgenticApiToApiMessages, AgenticAPIChatRequest, ApiRequest, ApiResponse, convertWorkflowToAgenticAPI } from "@/app/lib/types"; +import { getAgenticApiResponse } from "@/app/lib/utils"; + +// get next turn / agent response +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +): Promise { + const { projectId } = await params; + + return await authCheck(projectId, req, async () => { + // 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 = ApiRequest.safeParse(body); + if (!result.success) { + return Response.json({ error: `Invalid request body: ${result.error.message}` }, { status: 400 }); + } + const reqMessages = result.data.messages; + const reqState = result.data.state; + + // fetch published workflow id + const project = await projectsCollection.findOne({ + _id: projectId, + }); + if (!project) { + return Response.json({ error: "Project not found" }, { status: 404 }); + } + if (!project.publishedWorkflowId) { + return Response.json({ error: "Project has no published workflow" }, { status: 404 }); + } + // fetch workflow + const workflow = await agentWorkflowsCollection.findOne({ + projectId: projectId, + _id: new ObjectId(project.publishedWorkflowId), + }); + if (!workflow) { + return Response.json({ error: "Workflow not found" }, { status: 404 }); + } + + // get assistant response + const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow); + const request: z.infer = { + messages: convertFromApiToAgenticApiMessages(reqMessages), + state: reqState ?? { last_agent_name: startAgent }, + agents, + tools, + prompts, + startAgent, + }; + console.log("turn: sending agentic request from /chat api", JSON.stringify(request, null, 2)); + const { messages, state } = await getAgenticApiResponse(request); + + const response: z.infer = { + messages: convertFromAgenticApiToApiMessages(messages), + state, + }; + + return Response.json(response); + }); +} diff --git a/apps/rowboat/app/api/v1/utils.ts b/apps/rowboat/app/api/v1/utils.ts new file mode 100644 index 00000000..18b317ed --- /dev/null +++ b/apps/rowboat/app/api/v1/utils.ts @@ -0,0 +1,24 @@ +import { NextRequest } from "next/server"; +import { projectsCollection } from "@/app/lib/mongodb"; + +export async function authCheck(projectId: string, req: NextRequest, handler: () => Promise): Promise { + const authHeader = req.headers.get('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return Response.json({ error: "Authorization header must be a Bearer token" }, { status: 400 }); + } + const token = authHeader.split(' ')[1]; + if (!token) { + return Response.json({ error: "Missing API key in request" }, { status: 400 }); + } + + // check the key in project settings + const project = await projectsCollection.findOne({ + _id: projectId, + secret: token, + }); + if (!project) { + return Response.json({ error: "Invalid API key" }, { status: 403 }); + } + + return await handler(); +} diff --git a/apps/rowboat/app/api/widget/v1/chats/[chatId]/turn/route.ts b/apps/rowboat/app/api/widget/v1/chats/[chatId]/turn/route.ts index 0d437504..d9bb823e 100644 --- a/apps/rowboat/app/api/widget/v1/chats/[chatId]/turn/route.ts +++ b/apps/rowboat/app/api/widget/v1/chats/[chatId]/turn/route.ts @@ -4,7 +4,7 @@ import { agentWorkflowsCollection, db, projectsCollection } from "@/app/lib/mong import { z } from "zod"; import { ObjectId, WithId } from "mongodb"; import { authCheck } from "../../../utils"; -import { AgenticAPIChatRequest, convertToAgenticAPIChatMessages, convertWorkflowToAgenticAPI } from "@/app/lib/types"; +import { AgenticAPIChatRequest, convertFromAgenticAPIChatMessages, convertToAgenticAPIChatMessages, convertWorkflowToAgenticAPI } from "@/app/lib/types"; import { callClientToolWebhook, getAgenticApiResponse } from "@/app/lib/utils"; const chatsCollection = db.collection>("chats"); @@ -96,7 +96,8 @@ export async function POST( if (response.messages.length === 0) { throw new Error("No messages returned from assistant"); } - unsavedMessages.push(...response.messages.map(m => ({ + const convertedMessages = convertFromAgenticAPIChatMessages(response.messages); + unsavedMessages.push(...convertedMessages.map(m => ({ ...m, version: 'v1' as const, chatId, @@ -104,7 +105,7 @@ export async function POST( }))); // if the last messages is tool call, execute them - const lastMessage = response.messages[response.messages.length - 1]; + const lastMessage = convertedMessages[convertedMessages.length - 1]; if (lastMessage.role === 'assistant' && 'tool_calls' in lastMessage) { // execute tool calls console.log("Executing tool calls", lastMessage.tool_calls); diff --git a/apps/rowboat/app/lib/types.ts b/apps/rowboat/app/lib/types.ts index b92c7978..eeb4361b 100644 --- a/apps/rowboat/app/lib/types.ts +++ b/apps/rowboat/app/lib/types.ts @@ -623,3 +623,128 @@ export function convertToCopilotWorkflow(workflow: z.infer): z. ...rest, }; } + +export const ApiMessage = z.union([ + apiV1.SystemMessage, + apiV1.UserMessage, + apiV1.AssistantMessage, + apiV1.AssistantMessageWithToolCalls, + apiV1.ToolMessage, +]); + +export const ApiRequest = z.object({ + messages: z.array(ApiMessage), + state: z.unknown(), +}); + +export const ApiResponse = z.object({ + messages: z.array(ApiMessage), + state: z.unknown(), +}); + +export function convertFromApiToAgenticApiMessages(messages: z.infer[]): z.infer[] { + return messages.map(m => { + switch (m.role) { + case 'system': + return { + role: 'system', + content: m.content, + tool_calls: null, + tool_call_id: null, + tool_name: null, + sender: null, + }; + case 'user': + return { + role: 'user', + content: m.content, + tool_calls: null, + tool_call_id: null, + tool_name: null, + sender: null, + }; + + case 'assistant': + if ('tool_calls' in m) { + return { + role: 'assistant', + content: m.content ?? null, + tool_calls: m.tool_calls, + tool_call_id: null, + tool_name: null, + sender: m.agenticSender ?? null, + response_type: m.agenticResponseType ?? 'external', + }; + } else { + return { + role: 'assistant', + content: m.content ?? null, + sender: m.agenticSender ?? null, + response_type: m.agenticResponseType ?? 'external', + tool_call_id: null, + tool_calls: null, + tool_name: null, + }; + } + case 'tool': + return { + role: 'tool', + content: m.content ?? null, + tool_calls: null, + tool_call_id: m.tool_call_id ?? null, + tool_name: m.tool_name ?? null, + sender: null, + }; + default: + return { + role: "user", + content: "foo", + tool_calls: null, + tool_call_id: null, + tool_name: null, + sender: null, + } + } + }); +} + +export function convertFromAgenticApiToApiMessages(messages: z.infer[]): z.infer[] { + const converted: z.infer[] = []; + + for (const m of messages) { + switch (m.role) { + case 'user': + converted.push({ + role: 'user', + content: m.content ?? '', + }); + break; + case 'assistant': + if (m.tool_calls) { + converted.push({ + role: 'assistant', + tool_calls: m.tool_calls, + agenticSender: m.sender ?? undefined, + agenticResponseType: m.response_type ?? 'internal', + }); + } else { + converted.push({ + role: 'assistant', + content: m.content ?? '', + agenticSender: m.sender ?? undefined, + agenticResponseType: m.response_type ?? 'internal', + }); + } + break; + case 'tool': + converted.push({ + role: 'tool', + content: m.content ?? '', + tool_call_id: m.tool_call_id ?? '', + tool_name: m.tool_name ?? '', + }); + break; + } + } + return converted; +} \ No newline at end of file diff --git a/apps/rowboat/app/lib/utils.ts b/apps/rowboat/app/lib/utils.ts index 8cf23a93..62930f82 100644 --- a/apps/rowboat/app/lib/utils.ts +++ b/apps/rowboat/app/lib/utils.ts @@ -1,4 +1,4 @@ -import { AgenticAPIChatRequest, AgenticAPIChatResponse, ClientToolCallJwt, ClientToolCallRequest, ClientToolCallRequestBody, convertFromAgenticAPIChatMessages, Workflow } from "@/app/lib/types"; +import { AgenticAPIChatMessage, AgenticAPIChatRequest, AgenticAPIChatResponse, ClientToolCallJwt, ClientToolCallRequest, ClientToolCallRequestBody, convertFromAgenticAPIChatMessages, Workflow } from "@/app/lib/types"; import { z } from "zod"; import { projectsCollection } from "./mongodb"; import { apiV1 } from "rowboat-shared"; @@ -172,7 +172,7 @@ export async function callClientToolWebhook( export async function getAgenticApiResponse( request: z.infer, ): Promise<{ - messages: z.infer[], + messages: z.infer[], state: unknown, rawAPIResponse: unknown, }> { @@ -191,7 +191,7 @@ export async function getAgenticApiResponse( const responseJson = await response.json(); const result: z.infer = responseJson; return { - messages: convertFromAgenticAPIChatMessages(result.messages), + messages: result.messages, state: result.state, rawAPIResponse: result, };