add stripe billing

This commit is contained in:
Ramnique Singh 2025-05-18 01:37:54 +05:30
parent d5302ea2d1
commit 2fda9a7e79
58 changed files with 2348 additions and 485 deletions

View file

@ -22,6 +22,7 @@ class DataSource(BaseModel):
populate_by_name = True
class ApiRequest(BaseModel):
projectId: str
messages: List[UserMessage | AssistantMessage]
workflow_schema: str
current_workflow_config: str

View file

@ -1,35 +1,18 @@
'use server';
import { AgenticAPIInitStreamResponse, convertFromAgenticAPIChatMessages } from "../lib/types/agents_api_types";
import { AgenticAPIInitStreamResponse } from "../lib/types/agents_api_types";
import { AgenticAPIChatRequest } from "../lib/types/agents_api_types";
import { WebpageCrawlResponse } from "../lib/types/tool_types";
import { webpagesCollection } from "../lib/mongodb";
import { z } from 'zod';
import FirecrawlApp, { ScrapeResponse } from '@mendable/firecrawl-js';
import { apiV1 } from "rowboat-shared";
import { Claims, getSession } from "@auth0/nextjs-auth0";
import { getAgenticApiResponse, getAgenticResponseStreamId } from "../lib/utils";
import { getAgenticResponseStreamId } from "../lib/utils";
import { check_query_limit } from "../lib/rate_limiting";
import { QueryLimitError } from "../lib/client_utils";
import { projectAuthCheck } from "./project_actions";
import { USE_AUTH } from "../lib/feature_flags";
import { authorizeUserAction } from "./billing_actions";
const crawler = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY || '' });
export async function authCheck(): Promise<Claims> {
if (!USE_AUTH) {
return {
email: 'guestuser@rowboatlabs.com',
email_verified: true,
sub: 'guest_user',
};
}
const { user } = await getSession() || {};
if (!user) {
throw new Error('User not authenticated');
}
return user;
}
export async function scrapeWebpage(url: string): Promise<z.infer<typeof WebpageCrawlResponse>> {
const page = await webpagesCollection.findOne({
"_id": url,
@ -74,30 +57,25 @@ export async function scrapeWebpage(url: string): Promise<z.infer<typeof Webpage
};
}
export async function getAssistantResponse(request: z.infer<typeof AgenticAPIChatRequest>): Promise<{
messages: z.infer<typeof apiV1.ChatMessage>[],
state: unknown,
rawRequest: unknown,
rawResponse: unknown,
}> {
export async function getAssistantResponseStreamId(request: z.infer<typeof AgenticAPIChatRequest>): Promise<z.infer<typeof AgenticAPIInitStreamResponse> | { billingError: string }> {
await projectAuthCheck(request.projectId);
if (!await check_query_limit(request.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 getAssistantResponseStreamId(request: z.infer<typeof AgenticAPIChatRequest>): Promise<z.infer<typeof AgenticAPIInitStreamResponse>> {
await projectAuthCheck(request.projectId);
if (!await check_query_limit(request.projectId)) {
throw new QueryLimitError();
// Check billing authorization
const agentModels = request.agents.reduce((acc, agent) => {
acc.push(agent.model);
return acc;
}, [] as string[]);
const { success, error } = await authorizeUserAction({
type: 'agent_response',
data: {
agentModels,
},
});
if (!success) {
return { billingError: error || 'Billing error' };
}
const response = await getAgenticResponseStreamId(request);

View file

@ -0,0 +1,53 @@
"use server";
import { getSession } from "@auth0/nextjs-auth0";
import { USE_AUTH } from "../lib/feature_flags";
import { WithStringId, User } from "../lib/types/types";
import { getUserFromSessionId, GUEST_DB_USER } from "../lib/auth";
import { z } from "zod";
import { ObjectId } from "mongodb";
import { usersCollection } from "../lib/mongodb";
export async function authCheck(): Promise<WithStringId<z.infer<typeof User>>> {
if (!USE_AUTH) {
return GUEST_DB_USER;
}
const { user } = await getSession() || {};
if (!user) {
throw new Error('User not authenticated');
}
const dbUser = await getUserFromSessionId(user.sub);
if (!dbUser) {
throw new Error('User record not found');
}
return dbUser;
}
const EmailOnly = z.object({
email: z.string().email(),
});
export async function updateUserEmail(email: string) {
if (!USE_AUTH) {
return;
}
const user = await authCheck();
if (!email.trim()) {
throw new Error('Email is required');
}
if (!EmailOnly.safeParse({ email }).success) {
throw new Error('Invalid email');
}
// update customer email in db
await usersCollection.updateOne({
_id: new ObjectId(user._id),
}, {
$set: {
email,
updatedAt: new Date().toISOString(),
}
});
}

View file

@ -0,0 +1,95 @@
"use server";
import {
authorize,
logUsage as libLogUsage,
getBillingCustomer,
createCustomerPortalSession,
getPrices as libGetPrices,
updateSubscriptionPlan as libUpdateSubscriptionPlan,
getEligibleModels as libGetEligibleModels
} from "../lib/billing";
import { authCheck } from "./auth_actions";
import { USE_BILLING } from "../lib/feature_flags";
import {
AuthorizeRequest,
AuthorizeResponse,
LogUsageRequest,
Customer,
PricesResponse,
SubscriptionPlan,
UpdateSubscriptionPlanRequest,
ModelsResponse
} from "../lib/types/billing_types";
import { z } from "zod";
import { WithStringId } from "../lib/types/types";
export async function getCustomer(): Promise<WithStringId<z.infer<typeof Customer>>> {
const user = await authCheck();
if (!user.billingCustomerId) {
throw new Error("Customer not found");
}
const customer = await getBillingCustomer(user.billingCustomerId);
if (!customer) {
throw new Error("Customer not found");
}
return customer;
}
export async function authorizeUserAction(request: z.infer<typeof AuthorizeRequest>): Promise<z.infer<typeof AuthorizeResponse>> {
if (!USE_BILLING) {
return { success: true };
}
const customer = await getCustomer();
const response = await authorize(customer._id, request);
return response;
}
export async function logUsage(request: z.infer<typeof LogUsageRequest>) {
if (!USE_BILLING) {
return;
}
const customer = await getCustomer();
await libLogUsage(customer._id, request);
return;
}
export async function getCustomerPortalUrl(returnUrl: string): Promise<string> {
if (!USE_BILLING) {
throw new Error("Billing is not enabled")
}
const customer = await getCustomer();
return await createCustomerPortalSession(customer._id, returnUrl);
}
export async function getPrices(): Promise<z.infer<typeof PricesResponse>> {
if (!USE_BILLING) {
throw new Error("Billing is not enabled");
}
const response = await libGetPrices();
return response;
}
export async function updateSubscriptionPlan(plan: z.infer<typeof SubscriptionPlan>, returnUrl: string): Promise<string> {
if (!USE_BILLING) {
throw new Error("Billing is not enabled");
}
const customer = await getCustomer();
const request: z.infer<typeof UpdateSubscriptionPlanRequest> = { plan, returnUrl };
const url = await libUpdateSubscriptionPlan(customer._id, request);
return url;
}
export async function getEligibleModels(): Promise<z.infer<typeof ModelsResponse> | "*"> {
if (!USE_BILLING) {
return "*";
}
const customer = await getCustomer();
const response = await libGetEligibleModels(customer._id);
return response;
}

View file

@ -17,6 +17,8 @@ import { projectAuthCheck } from "./project_actions";
import { redisClient } from "../lib/redis";
import { fetchProjectMcpTools } from "../lib/project_tools";
import { mergeProjectTools } from "../lib/types/project_types";
import { authorizeUserAction, logUsage } from "./billing_actions";
import { USE_BILLING } from "../lib/feature_flags";
export async function getCopilotResponse(
projectId: string,
@ -28,12 +30,21 @@ export async function getCopilotResponse(
message: z.infer<typeof CopilotAssistantMessage>;
rawRequest: unknown;
rawResponse: unknown;
}> {
} | { billingError: string }> {
await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) {
throw new QueryLimitError();
}
// Check billing authorization
const authResponse = await authorizeUserAction({
type: 'copilot_request',
data: {},
});
if (!authResponse.success) {
return { billingError: authResponse.error || 'Billing error' };
}
// Get MCP tools from project and merge with workflow tools
const mcpTools = await fetchProjectMcpTools(projectId);
@ -45,6 +56,7 @@ export async function getCopilotResponse(
// prepare request
const request: z.infer<typeof CopilotAPIRequest> = {
projectId: projectId,
messages: messages.map(convertToCopilotApiMessage),
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
current_workflow_config: JSON.stringify(copilotWorkflow),
@ -132,12 +144,25 @@ export async function getCopilotResponseStream(
dataSources?: z.infer<typeof DataSource>[]
): Promise<{
streamId: string;
}> {
} | { billingError: string }> {
await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) {
throw new QueryLimitError();
}
// Check billing authorization
const authResponse = await authorizeUserAction({
type: 'copilot_request',
data: {},
});
if (!authResponse.success) {
return { billingError: authResponse.error || 'Billing error' };
}
if (!await check_query_limit(projectId)) {
throw new QueryLimitError();
}
// Get MCP tools from project and merge with workflow tools
const mcpTools = await fetchProjectMcpTools(projectId);
@ -149,6 +174,7 @@ export async function getCopilotResponseStream(
// prepare request
const request: z.infer<typeof CopilotAPIRequest> = {
projectId: projectId,
messages: messages.map(convertToCopilotApiMessage),
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
current_workflow_config: JSON.stringify(copilotWorkflow),
@ -177,12 +203,21 @@ export async function getCopilotAgentInstructions(
messages: z.infer<typeof CopilotMessage>[],
current_workflow_config: z.infer<typeof Workflow>,
agentName: string,
): Promise<string> {
): Promise<string | { billingError: string }> {
await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) {
throw new QueryLimitError();
}
// Check billing authorization
const authResponse = await authorizeUserAction({
type: 'copilot_request',
data: {},
});
if (!authResponse.success) {
return { billingError: authResponse.error || 'Billing error' };
}
// Get MCP tools from project and merge with workflow tools
const mcpTools = await fetchProjectMcpTools(projectId);
@ -194,6 +229,7 @@ export async function getCopilotAgentInstructions(
// prepare request
const request: z.infer<typeof CopilotAPIRequest> = {
projectId: projectId,
messages: messages.map(convertToCopilotApiMessage),
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
current_workflow_config: JSON.stringify(copilotWorkflow),
@ -237,6 +273,14 @@ export async function getCopilotAgentInstructions(
throw new Error(`Failed to call copilot api: ${copilotResponse.error}`);
}
// log the billing usage
if (USE_BILLING) {
await logUsage({
type: 'copilot_requests',
amount: 1,
});
}
// return response
return agent_instructions;
}

View file

@ -105,6 +105,7 @@ export async function recrawlWebDataSource(projectId: string, sourceId: string)
}, {
$set: {
status: 'pending',
billingError: undefined,
lastUpdatedAt: (new Date()).toISOString(),
attempts: 0,
},
@ -124,6 +125,7 @@ export async function deleteDataSource(projectId: string, sourceId: string) {
}, {
$set: {
status: 'deleted',
billingError: undefined,
lastUpdatedAt: (new Date()).toISOString(),
attempts: 0,
},
@ -189,6 +191,7 @@ export async function addDocsToDataSource({
{
$set: {
status: 'pending',
billingError: undefined,
attempts: 0,
lastUpdatedAt: new Date().toISOString(),
},
@ -275,6 +278,7 @@ export async function deleteDocsFromDataSource({
}, {
$set: {
status: 'pending',
billingError: undefined,
attempts: 0,
lastUpdatedAt: new Date().toISOString(),
},

View file

@ -7,6 +7,8 @@ import { projectsCollection } from '../lib/mongodb';
import { fetchMcpTools, toggleMcpTool } from './mcp_actions';
import { fetchMcpToolsForServer } from './mcp_actions';
import { headers } from 'next/headers';
import { authorizeUserAction } from './billing_actions';
import { redisClient } from '../lib/redis';
type McpServerType = z.infer<typeof MCPServer>;
type McpToolType = z.infer<typeof McpTool>;
@ -542,13 +544,34 @@ export async function enableServer(
serverName: string,
projectId: string,
enabled: boolean
): Promise<CreateServerInstanceResponse | {}> {
): Promise<CreateServerInstanceResponse | {} | { billingError: string }> {
try {
await projectAuthCheck(projectId);
console.log('[Klavis API] Toggle server request:', { serverName, projectId, enabled });
if (enabled) {
// get count of enabled hosted mcp servers for this project
const existingInstances = await listActiveServerInstances(projectId);
// billing limit check
const authResponse = await authorizeUserAction({
type: 'enable_hosted_tool_server',
data: {
existingServerCount: existingInstances.length,
},
});
if (!authResponse.success) {
return { billingError: authResponse.error || 'Billing error' };
}
// set key in redis to indicate that a server is being enabled on this project
// the key set should only succeed if the key does not already exist
const setResult = await redisClient.set(`klavis_enabling_server:${projectId}`, 'true', { EX: 60 * 60, NX: true });
console.log('[redis] Set result here:', setResult);
if (setResult !== 'OK') {
throw new Error("A server is already being enabled on this project");
}
console.log(`[Klavis API] Creating server instance for ${serverName}...`);
const result = await createMcpServerInstance(serverName, projectId, "Rowboat");
console.log('[Klavis API] Server instance created:', {
@ -640,6 +663,9 @@ export async function enableServer(
console.error(`[Klavis API] Tool enrichment failed for ${serverName}:`, enrichError);
}
// remove key from redis
await redisClient.del(`klavis_enabling_server:${projectId}`);
return result;
} else {
// Get active instances to find the one to delete

View file

@ -6,12 +6,13 @@ import { z } from 'zod';
import crypto from 'crypto';
import { revalidatePath } from "next/cache";
import { templates } from "../lib/project_templates";
import { authCheck } from "./actions";
import { WithStringId } from "../lib/types/types";
import { authCheck } from "./auth_actions";
import { User, WithStringId } from "../lib/types/types";
import { ApiKey } from "../lib/types/project_types";
import { Project } from "../lib/types/project_types";
import { USE_AUTH } from "../lib/feature_flags";
import { deleteMcpServerInstance, listActiveServerInstances } from "./klavis_actions";
import { authorizeUserAction } from "./billing_actions";
export async function projectAuthCheck(projectId: string) {
if (!USE_AUTH) {
@ -20,23 +21,27 @@ export async function projectAuthCheck(projectId: string) {
const user = await authCheck();
const membership = await projectMembersCollection.findOne({
projectId,
userId: user.sub,
userId: user._id,
});
if (!membership) {
throw new Error('User not a member of project');
}
}
async function createBaseProject(name: string, user: any) {
// Check project limits
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.');
}
async function createBaseProject(name: string, user: WithStringId<z.infer<typeof User>>): Promise<{ id: string } | { billingError: string }> {
// fetch project count for this user
const projectCount = await projectsCollection.countDocuments({
createdByUserId: user._id,
});
// billing limit check
const authResponse = await authorizeUserAction({
type: 'create_project',
data: {
existingProjectCount: projectCount,
},
});
if (!authResponse.success) {
return { billingError: authResponse.error || 'Billing error' };
}
const projectId = crypto.randomUUID();
@ -49,7 +54,7 @@ async function createBaseProject(name: string, user: any) {
name,
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
createdByUserId: user.sub,
createdByUserId: user._id,
chatClientId,
secret,
nextWorkflowNumber: 1,
@ -58,7 +63,7 @@ async function createBaseProject(name: string, user: any) {
// Add user to project
await projectMembersCollection.insertOne({
userId: user.sub,
userId: user._id,
projectId: projectId,
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
@ -67,15 +72,20 @@ async function createBaseProject(name: string, user: any) {
// Add first api key
await createApiKey(projectId);
return projectId;
return { id: projectId };
}
export async function createProject(formData: FormData) {
export async function createProject(formData: FormData): Promise<{ id: string } | { billingError: string }> {
const user = await authCheck();
const name = formData.get('name') as string;
const templateKey = formData.get('template') as string;
const projectId = await createBaseProject(name, user);
const response = await createBaseProject(name, user);
if ('billingError' in response) {
return response;
}
const projectId = response.id;
// Add first workflow version with specified template
const { agents, prompts, tools, startAgent } = templates[templateKey];
@ -90,7 +100,7 @@ export async function createProject(formData: FormData) {
name: `Version 1`,
});
redirect(`/projects/${projectId}/workflow`);
return { id: projectId };
}
export async function getProjectConfig(projectId: string): Promise<WithStringId<z.infer<typeof Project>>> {
@ -107,7 +117,7 @@ export async function getProjectConfig(projectId: string): Promise<WithStringId<
export async function listProjects(): Promise<z.infer<typeof Project>[]> {
const user = await authCheck();
const memberships = await projectMembersCollection.find({
userId: user.sub,
userId: user._id,
}).toArray();
const projectIds = memberships.map((m) => m.projectId);
const projects = await projectsCollection.find({
@ -271,11 +281,16 @@ export async function deleteProject(projectId: string) {
redirect('/projects');
}
export async function createProjectFromPrompt(formData: FormData) {
export async function createProjectFromPrompt(formData: FormData): Promise<{ id: string } | { billingError: string }> {
const user = await authCheck();
const name = formData.get('name') as string;
const projectId = await createBaseProject(name, user);
const response = await createBaseProject(name, user);
if ('billingError' in response) {
return response;
}
const projectId = response.id;
// Add first workflow version with default template
const { agents, prompts, tools, startAgent } = templates['default'];

View file

@ -1,4 +1,7 @@
import { getCustomerIdForProject, logUsage } from "@/app/lib/billing";
import { USE_BILLING } from "@/app/lib/feature_flags";
import { redisClient } from "@/app/lib/redis";
import { CopilotAPIRequest } from "@/app/lib/types/copilot_types";
export async function GET(request: Request, { params }: { params: { streamId: string } }) {
// get the payload from redis
@ -7,6 +10,15 @@ export async function GET(request: Request, { params }: { params: { streamId: st
return new Response("Stream not found", { status: 404 });
}
// parse the payload
const parsedPayload = CopilotAPIRequest.parse(JSON.parse(payload));
// fetch billing customer id
let billingCustomerId: string | null = null;
if (USE_BILLING) {
billingCustomerId = await getCustomerIdForProject(parsedPayload.projectId);
}
// Fetch the upstream SSE stream.
const upstreamResponse = await fetch(`${process.env.COPILOT_API_URL}/chat_stream`, {
method: 'POST',
@ -36,6 +48,18 @@ export async function GET(request: Request, { params }: { params: { streamId: st
controller.enqueue(value);
}
controller.close();
// increment copilot request count in billing
if (USE_BILLING && billingCustomerId) {
try {
await logUsage(billingCustomerId, {
type: "copilot_requests",
amount: 1,
});
} catch (error) {
console.error("Error logging usage", error);
}
}
} catch (error) {
controller.error(error);
}

View file

@ -1,4 +1,8 @@
import { getCustomerIdForProject, logUsage } from "@/app/lib/billing";
import { USE_BILLING } from "@/app/lib/feature_flags";
import { redisClient } from "@/app/lib/redis";
import { AgenticAPIChatMessage, AgenticAPIChatRequest, convertFromAgenticAPIChatMessages } from "@/app/lib/types/agents_api_types";
import { createParser, type EventSourceMessage } from 'eventsource-parser';
export async function GET(request: Request, { params }: { params: { streamId: string } }) {
// get the payload from redis
@ -7,6 +11,15 @@ export async function GET(request: Request, { params }: { params: { streamId: st
return new Response("Stream not found", { status: 404 });
}
// parse the payload
const parsedPayload = AgenticAPIChatRequest.parse(JSON.parse(payload));
// fetch billing customer id
let billingCustomerId: string | null = null;
if (USE_BILLING) {
billingCustomerId = await getCustomerIdForProject(parsedPayload.projectId);
}
// Fetch the upstream SSE stream.
const upstreamResponse = await fetch(`${process.env.AGENTS_API_URL}/chat_stream`, {
method: 'POST',
@ -24,19 +37,63 @@ export async function GET(request: Request, { params }: { params: { streamId: st
}
const reader = upstreamResponse.body.getReader();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
let messageCount = 0;
function emitEvent(event: EventSourceMessage) {
// Re-emit the event in SSE format
let eventString = '';
if (event.id) eventString += `id: ${event.id}\n`;
if (event.event) eventString += `event: ${event.event}\n`;
if (event.data) eventString += `data: ${event.data}\n`;
eventString += '\n';
controller.enqueue(encoder.encode(eventString));
}
const parser = createParser({
onEvent(event: EventSourceMessage) {
if (event.event !== 'message') {
emitEvent(event);
return;
}
// Parse message
const data = JSON.parse(event.data);
const msg = AgenticAPIChatMessage.parse(data);
const parsedMsg = convertFromAgenticAPIChatMessages([msg])[0];
// increment the message count if this is an assistant message
if (parsedMsg.role === 'assistant') {
messageCount++;
}
// emit the event
emitEvent(event);
}
});
try {
// Read from the upstream stream continuously.
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Immediately enqueue each received chunk.
controller.enqueue(value);
// Feed the chunk to the parser
parser.feed(new TextDecoder().decode(value));
}
controller.close();
if (USE_BILLING && billingCustomerId) {
await logUsage(billingCustomerId, {
type: "agent_messages",
amount: messageCount,
})
}
} catch (error) {
console.error('Error processing stream:', error);
controller.error(error);
}
},

View file

@ -10,6 +10,8 @@ import { check_query_limit } from "../../../../lib/rate_limiting";
import { PrefixLogger } from "../../../../lib/utils";
import { TestProfile } from "@/app/lib/types/testing_types";
import { fetchProjectMcpTools } from "@/app/lib/project_tools";
import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing";
import { USE_BILLING } from "@/app/lib/feature_flags";
// get next turn / agent response
export async function POST(
@ -29,6 +31,12 @@ export async function POST(
}
return await authCheck(projectId, req, async () => {
// fetch billing customer id
let billingCustomerId: string | null = null;
if (USE_BILLING) {
billingCustomerId = await getCustomerIdForProject(projectId);
}
// parse and validate the request body
let body;
try {
@ -74,6 +82,23 @@ export async function POST(
return Response.json({ error: "Workflow not found" }, { status: 404 });
}
// check billing authorization
if (USE_BILLING && billingCustomerId) {
const agentModels = workflow.agents.reduce((acc, agent) => {
acc.push(agent.model);
return acc;
}, [] as string[]);
const response = await authorize(billingCustomerId, {
type: 'agent_response',
data: {
agentModels,
},
});
if (!response.success) {
return Response.json({ error: response.error || 'Billing error' }, { status: 402 });
}
}
// if test profile is provided in the request, use it
let testProfile: z.infer<typeof TestProfile> | null = null;
if (result.data.testProfileId) {
@ -112,6 +137,15 @@ export async function POST(
const newMessages = convertFromAgenticApiToApiMessages(agenticMessages);
const newState = state;
// log billing usage
if (USE_BILLING && billingCustomerId) {
const agentMessageCount = newMessages.filter(m => m.role === 'assistant').length;
await logUsage(billingCustomerId, {
type: 'agent_messages',
amount: agentMessageCount,
});
}
const responseBody: z.infer<typeof ApiResponse> = {
messages: newMessages,
state: newState,

View file

@ -12,6 +12,8 @@ import { getAgenticApiResponse } from "../../../../../../lib/utils";
import { check_query_limit } from "../../../../../../lib/rate_limiting";
import { PrefixLogger } from "../../../../../../lib/utils";
import { fetchProjectMcpTools } from "@/app/lib/project_tools";
import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing";
import { USE_BILLING } from "@/app/lib/feature_flags";
// get next turn / agent response
export async function POST(
@ -24,6 +26,12 @@ export async function POST(
logger.log(`Processing turn request for chat ${chatId}`);
// fetch billing customer id
let billingCustomerId: string | null = null;
if (USE_BILLING) {
billingCustomerId = await getCustomerIdForProject(session.projectId);
}
// check query limit
if (!await check_query_limit(session.projectId)) {
logger.log(`Query limit exceeded for project ${session.projectId}`);
@ -93,6 +101,23 @@ export async function POST(
throw new Error("Workflow not found");
}
// check billing authorization
if (USE_BILLING && billingCustomerId) {
const agentModels = workflow.agents.reduce((acc, agent) => {
acc.push(agent.model);
return acc;
}, [] as string[]);
const response = await authorize(billingCustomerId, {
type: 'agent_response',
data: {
agentModels,
},
});
if (!response.success) {
return Response.json({ error: response.error || 'Billing error' }, { status: 402 });
}
}
// get assistant response
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow, projectTools);
const unsavedMessages: z.infer<typeof apiV1.ChatMessage>[] = [userMessage];
@ -132,6 +157,15 @@ export async function POST(
await chatMessagesCollection.insertMany(unsavedMessages);
await chatsCollection.updateOne({ _id: new ObjectId(chatId) }, { $set: { agenticState: state } });
// log billing usage
if (USE_BILLING && billingCustomerId) {
const agentMessageCount = convertedMessages.filter(m => m.role === 'assistant').length;
await logUsage(billingCustomerId, {
type: 'agent_messages',
amount: agentMessageCount,
});
}
logger.log(`Turn processing completed successfully`);
const lastMessage = unsavedMessages[unsavedMessages.length - 1] as WithId<z.infer<typeof apiV1.ChatMessage>>;
return Response.json({

View file

@ -0,0 +1,148 @@
'use client';
import { Progress, Badge } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { Label } from "@/app/lib/components/label";
import { Customer, UsageResponse, UsageType } from "@/app/lib/types/billing_types";
import { z } from "zod";
import { tokens } from "@/app/styles/design-tokens";
import { SectionHeading } from "@/components/ui/section-heading";
import { HorizontalDivider } from "@/components/ui/horizontal-divider";
import { WithStringId } from "@/app/lib/types/types";
import clsx from 'clsx';
import { getCustomerPortalUrl } from "../actions/billing_actions";
const planDetails = {
free: {
name: "Free Plan",
color: "default"
},
starter: {
name: "Starter Plan",
color: "primary"
},
pro: {
name: "Pro Plan",
color: "secondary"
}
};
interface BillingPageProps {
customer: WithStringId<z.infer<typeof Customer>>;
usage: z.infer<typeof UsageResponse>;
}
export function BillingPage({ customer, usage }: BillingPageProps) {
const plan = customer.subscriptionPlan || "free";
const isActive = customer.subscriptionActive || false;
const planInfo = planDetails[plan];
async function handleManageSubscription() {
const returnUrl = new URL('/billing/callback', window.location.origin);
returnUrl.searchParams.set('redirect', window.location.href);
const url = await getCustomerPortalUrl(returnUrl.toString());
window.location.href = url;
}
return (
<div className="max-w-4xl mx-auto px-8 py-8 space-y-8">
<div className="px-4">
<h1 className={clsx(
tokens.typography.sizes.xl,
tokens.typography.weights.semibold,
tokens.colors.light.text.primary,
tokens.colors.dark.text.primary
)}>
Billing
</h1>
</div>
{/* Subscription Status Panel */}
<section className="card">
<div className="px-4 pt-4 pb-6">
<SectionHeading>
Current Plan
</SectionHeading>
</div>
<HorizontalDivider />
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className={clsx(
tokens.typography.sizes.lg,
tokens.typography.weights.semibold,
tokens.colors.light.text.primary,
tokens.colors.dark.text.primary
)}>
{planInfo.name}
</h3>
<Badge
color={isActive ? "success" : "warning"}
variant="flat"
className="text-xs"
>
{isActive ? "Active" : "Inactive"}
</Badge>
</div>
</div>
<form action={handleManageSubscription}>
<Button
variant="primary"
size="md"
type="submit"
>
Manage Subscription
</Button>
</form>
</div>
</div>
</section>
{/* Usage Metrics Panel */}
<section className="card">
<div className="px-4 pt-4 pb-6">
<SectionHeading>
Usage Metrics
</SectionHeading>
</div>
<HorizontalDivider />
<div className="p-6 space-y-6">
{Object.entries(usage.usage).map(([type, { usage: used, total }]) => {
const usageType = type as z.infer<typeof UsageType>;
const percentage = Math.min((used / total) * 100, 100);
const isOverLimit = used > total;
return (
<div key={type} className="space-y-2">
<div className="flex justify-between items-center">
<div className="space-y-1">
<Label label={type.replace(/_/g, ' ')} />
<p className={clsx(
tokens.typography.sizes.sm,
tokens.colors.light.text.secondary,
tokens.colors.dark.text.secondary
)}>
{used.toLocaleString()} / {total.toLocaleString()}
</p>
</div>
{isOverLimit && (
<Badge color="danger" variant="flat">
Over Limit
</Badge>
)}
</div>
<Progress
value={percentage}
color={isOverLimit ? "danger" : "primary"}
className="h-2"
aria-label={`${type} usage`}
/>
</div>
);
})}
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,18 @@
import { syncWithStripe } from "@/app/lib/billing";
import { requireBillingCustomer } from '@/app/lib/billing';
import { redirect } from "next/navigation";
export const dynamic = 'force-dynamic';
export default async function Page({
searchParams,
}: {
searchParams: {
redirect: string;
}
}) {
const customer = await requireBillingCustomer();
await syncWithStripe(customer._id);
const redirectUrl = searchParams.redirect as string;
redirect(redirectUrl || '/projects');
}

View file

@ -0,0 +1,13 @@
import AppLayout from '../projects/layout/components/app-layout';
export default function Layout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<AppLayout useRag={false} useAuth={true} useBilling={true}>
{children}
</AppLayout>
);
}

View file

@ -0,0 +1,17 @@
import { requireBillingCustomer } from '../lib/billing';
import { BillingPage } from './app';
import { getUsage } from '../lib/billing';
import { redirect } from 'next/navigation';
import { USE_BILLING } from '../lib/feature_flags';
export const dynamic = 'force-dynamic';
export default async function Page() {
if (!USE_BILLING) {
redirect('/projects');
}
const customer = await requireBillingCustomer();
const usage = await getUsage(customer._id);
return <BillingPage customer={customer} usage={usage} />;
}

View file

@ -0,0 +1,116 @@
import { z } from "zod";
import { Claims } from "@auth0/nextjs-auth0";
import { ObjectId } from "mongodb";
import { usersCollection, projectsCollection, projectMembersCollection } from "./mongodb";
import { getSession } from "@auth0/nextjs-auth0";
import { User, WithStringId } from "./types/types";
import { USE_AUTH } from "./feature_flags";
import { redirect } from "next/navigation";
export const GUEST_SESSION: Claims = {
email: "guest@rowboatlabs.com",
email_verified: true,
sub: "guest_user",
}
export const GUEST_DB_USER: WithStringId<z.infer<typeof User>> = {
_id: "guest_user",
auth0Id: "guest_user",
name: "Guest",
email: "guest@rowboatlabs.com",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
/**
* This function should be used as an initial check in server page components to ensure
* the user is authenticated. It will:
* 1. Check for a valid user session
* 2. Redirect to login if no session exists
* 3. Return the authenticated user
*
* Usage in server components:
* ```ts
* const user = await requireAuth();
* ```
*/
export async function requireAuth(): Promise<WithStringId<z.infer<typeof User>>> {
if (!USE_AUTH) {
return GUEST_DB_USER;
}
const { user } = await getSession() || {};
if (!user) {
redirect('/api/auth/login');
}
// fetch db user
let dbUser = await getUserFromSessionId(user.sub);
// if db user does not exist, create one
if (!dbUser) {
// create user record
const doc = {
_id: new ObjectId(),
auth0Id: user.sub,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
email: user.email,
};
console.log(`creating new user id ${doc._id.toString()} for session id ${user.sub}`);
await usersCollection.insertOne(doc);
// since auth feature was rolled out later,
// set all project authors to new user id instead
// of user.sub
await updateProjectRefs(user.sub, doc._id.toString());
dbUser = {
...doc,
_id: doc._id.toString(),
};
}
const { _id, ...rest } = dbUser;
return {
...rest,
_id: _id.toString(),
};
}
async function updateProjectRefs(sessionUserId: string, dbUserId: string) {
await projectsCollection.updateMany({
createdByUserId: sessionUserId
}, {
$set: {
createdByUserId: dbUserId,
lastUpdatedAt: new Date().toISOString(),
}
});
await projectMembersCollection.updateMany({
userId: sessionUserId
}, {
$set: {
userId: dbUserId,
}
});
}
export async function getUserFromSessionId(sessionUserId: string): Promise<WithStringId<z.infer<typeof User>> | null> {
if (!USE_AUTH) {
return GUEST_DB_USER;
}
let dbUser = await usersCollection.findOne({
auth0Id: sessionUserId
});
if (!dbUser) {
return null;
}
const { _id, ...rest } = dbUser;
return {
...rest,
_id: _id.toString(),
};
}

View file

@ -0,0 +1,304 @@
import { WithStringId } from './types/types';
import { z } from 'zod';
import { Customer, AuthorizeRequest, AuthorizeResponse, LogUsageRequest, UsageResponse, CustomerPortalSessionResponse, PricesResponse, UpdateSubscriptionPlanRequest, UpdateSubscriptionPlanResponse, ModelsResponse } from './types/billing_types';
import { ObjectId } from 'mongodb';
import { projectsCollection, usersCollection } from './mongodb';
import { getSession } from '@auth0/nextjs-auth0';
import { redirect } from 'next/navigation';
import { getUserFromSessionId, requireAuth } from './auth';
import { USE_BILLING } from './feature_flags';
const BILLING_API_URL = process.env.BILLING_API_URL || 'http://billing';
const BILLING_API_KEY = process.env.BILLING_API_KEY || 'test';
const GUEST_BILLING_CUSTOMER = {
_id: "guest-user",
userId: "guest-user",
name: "Guest",
email: "guest@rowboatlabs.com",
stripeCustomerId: "guest",
subscriptionPlan: "free" as const,
subscriptionActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
export class BillingError extends Error {
constructor(message: string) {
super(message);
this.name = 'BillingError';
}
}
export async function getCustomerIdForProject(projectId: string): Promise<string> {
const project = await projectsCollection.findOne({ _id: projectId });
if (!project) {
throw new Error("Project not found");
}
const user = await usersCollection.findOne({ _id: new ObjectId(project.createdByUserId) });
if (!user) {
throw new Error("User not found");
}
if (!user.billingCustomerId) {
throw new Error("User has no billing customer id");
}
return user.billingCustomerId;
}
export async function getBillingCustomer(id: string): Promise<WithStringId<z.infer<typeof Customer>> | null> {
const response = await fetch(`${BILLING_API_URL}/api/customers/${id}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${BILLING_API_KEY}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch billing customer: ${response.status} ${response.statusText} ${await response.text()}`);
}
const json = await response.json();
const parseResult = Customer.safeParse(json);
if (!parseResult.success) {
throw new Error(`Failed to parse billing customer: ${JSON.stringify(parseResult.error)}`);
}
return parseResult.data;
}
async function createBillingCustomer(userId: string, email: string): Promise<WithStringId<z.infer<typeof Customer>>> {
const response = await fetch(`${BILLING_API_URL}/api/customers`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${BILLING_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ userId, email })
});
if (!response.ok) {
throw new Error(`Failed to create billing customer: ${response.status} ${response.statusText} ${await response.text()}`);
}
const json = await response.json();
const parseResult = Customer.safeParse(json);
if (!parseResult.success) {
throw new Error(`Failed to parse billing customer: ${JSON.stringify(parseResult.error)}`);
}
return parseResult.data as z.infer<typeof Customer>;
}
export async function syncWithStripe(customerId: string): Promise<void> {
const response = await fetch(`${BILLING_API_URL}/api/customers/${customerId}/sync-with-stripe`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${BILLING_API_KEY}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Failed to sync with stripe: ${response.status} ${response.statusText} ${await response.text()}`);
}
}
export async function authorize(customerId: string, request: z.infer<typeof AuthorizeRequest>): Promise<z.infer<typeof AuthorizeResponse>> {
const response = await fetch(`${BILLING_API_URL}/api/customers/${customerId}/authorize`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${BILLING_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
});
if (!response.ok) {
throw new Error(`Failed to authorize billing: ${response.status} ${response.statusText} ${await response.text()}`);
}
const json = await response.json();
const parseResult = AuthorizeResponse.safeParse(json);
if (!parseResult.success) {
throw new Error(`Failed to parse authorize billing response: ${JSON.stringify(parseResult.error)}`);
}
return parseResult.data as z.infer<typeof AuthorizeResponse>;
}
export async function logUsage(customerId: string, request: z.infer<typeof LogUsageRequest>) {
const response = await fetch(`${BILLING_API_URL}/api/customers/${customerId}/log-usage`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${BILLING_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
});
if (!response.ok) {
throw new Error(`Failed to log usage: ${response.status} ${response.statusText} ${await response.text()}`);
}
}
export async function getUsage(customerId: string): Promise<z.infer<typeof UsageResponse>> {
const response = await fetch(`${BILLING_API_URL}/api/customers/${customerId}/usage`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${BILLING_API_KEY}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Failed to get usage: ${response.status} ${response.statusText} ${await response.text()}`);
}
const json = await response.json();
const parseResult = UsageResponse.safeParse(json);
if (!parseResult.success) {
throw new Error(`Failed to parse usage response: ${JSON.stringify(parseResult.error)}`);
}
return parseResult.data as z.infer<typeof UsageResponse>;
}
export async function createCustomerPortalSession(customerId: string, returnUrl: string): Promise<string> {
const response = await fetch(`${BILLING_API_URL}/api/customers/${customerId}/customer-portal-session`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${BILLING_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ returnUrl })
});
if (!response.ok) {
throw new Error(`Failed to get customer portal url: ${response.status} ${response.statusText} ${await response.text()}`);
}
const json = await response.json();
const parseResult = CustomerPortalSessionResponse.safeParse(json);
if (!parseResult.success) {
throw new Error(`Failed to parse customer portal session response: ${JSON.stringify(parseResult.error)}`);
}
return parseResult.data.url;
}
export async function getPrices(): Promise<z.infer<typeof PricesResponse>> {
const response = await fetch(`${BILLING_API_URL}/api/prices`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${BILLING_API_KEY}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Failed to get prices: ${response.status} ${response.statusText} ${await response.text()}`);
}
const json = await response.json();
const parseResult = PricesResponse.safeParse(json);
if (!parseResult.success) {
throw new Error(`Failed to parse prices response: ${JSON.stringify(parseResult.error)}`);
}
return parseResult.data as z.infer<typeof PricesResponse>;
}
export async function updateSubscriptionPlan(customerId: string, request: z.infer<typeof UpdateSubscriptionPlanRequest>): Promise<string> {
const response = await fetch(`${BILLING_API_URL}/api/customers/${customerId}/update-subscription-plan`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${BILLING_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
});
if (!response.ok) {
throw new Error(`Failed to update subscription plan: ${response.status} ${response.statusText} ${await response.text()}`);
}
const json = await response.json();
const parseResult = UpdateSubscriptionPlanResponse.safeParse(json);
if (!parseResult.success) {
throw new Error(`Failed to parse update subscription plan response: ${JSON.stringify(parseResult.error)}`);
}
return parseResult.data.url;
}
export async function getEligibleModels(customerId: string): Promise<z.infer<typeof ModelsResponse>> {
const response = await fetch(`${BILLING_API_URL}/api/customers/${customerId}/models`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${BILLING_API_KEY}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Failed to get eligible models: ${response.status} ${response.statusText} ${await response.text()}`);
}
const json = await response.json();
const parseResult = ModelsResponse.safeParse(json);
if (!parseResult.success) {
throw new Error(`Failed to parse eligible models response: ${JSON.stringify(parseResult.error)}`);
}
return parseResult.data as z.infer<typeof ModelsResponse>;
}
/**
* This function should be used as an initial check in server page components to ensure
* the user has a valid billing customer record. It will:
* 1. Return a guest customer if billing is disabled
* 2. Verify user authentication
* 3. Create/update the user record if needed
* 4. Redirect to onboarding if no billing customer exists
*
* Usage in server components:
* ```ts
* const billingCustomer = await requireBillingCustomer();
* ```
*/
export async function requireBillingCustomer(): Promise<WithStringId<z.infer<typeof Customer>>> {
const user = await requireAuth();
if (!USE_BILLING) {
return {
...GUEST_BILLING_CUSTOMER,
userId: user._id,
};
}
// if user does not have an email, redirect to onboarding
if (!user.email) {
redirect('/onboarding');
}
// fetch or create customer
let customer: WithStringId<z.infer<typeof Customer>> | null;
if (user.billingCustomerId) {
customer = await getBillingCustomer(user.billingCustomerId);
} else {
customer = await createBillingCustomer(user._id, user.email);
console.log("created billing customer", JSON.stringify({ userId: user._id, customer }));
// update customer id in db
await usersCollection.updateOne({
_id: new ObjectId(user._id),
}, {
$set: {
billingCustomerId: customer._id,
updatedAt: new Date().toISOString(),
}
});
}
if (!customer) {
throw new Error("Failed to fetch or create billing customer");
}
return customer;
}
/**
* This function should be used in server page components to ensure the user has an active
* billing subscription. It will:
* 1. Return a guest customer if billing is disabled
* 2. Verify the user has a valid billing customer record
* 3. Redirect to checkout if the subscription is not active
*
* Usage in server components:
* ```ts
* const billingCustomer = await requireActiveBillingSubscription();
* ```
*/
export async function requireActiveBillingSubscription(): Promise<WithStringId<z.infer<typeof Customer>>> {
const billingCustomer = await requireBillingCustomer();
if (USE_BILLING && !billingCustomer?.subscriptionActive) {
redirect('/billing/checkout');
}
return billingCustomer;
}

View file

@ -2,8 +2,9 @@
import { useUser } from '@auth0/nextjs-auth0/client';
import { Avatar, Dropdown, DropdownItem, DropdownSection, DropdownTrigger, DropdownMenu } from "@heroui/react";
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export function UserButton() {
export function UserButton({ useBilling }: { useBilling?: boolean }) {
const router = useRouter();
const { user } = useUser();
if (!user) {
@ -25,9 +26,19 @@ export function UserButton() {
if (key === 'logout') {
router.push('/api/auth/logout');
}
if (key === 'billing') {
router.push('/billing');
}
}}
>
<DropdownSection title={name}>
{useBilling ? (
<DropdownItem key="billing">
Billing
</DropdownItem>
) : (
<></>
)}
<DropdownItem key="logout">
Logout
</DropdownItem>

View file

@ -5,6 +5,7 @@ export const USE_CHAT_WIDGET = process.env.USE_CHAT_WIDGET === 'true';
export const USE_AUTH = process.env.USE_AUTH === 'true';
export const USE_RAG_S3_UPLOADS = process.env.USE_RAG_S3_UPLOADS === 'true';
export const USE_GEMINI_FILE_PARSING = process.env.USE_GEMINI_FILE_PARSING === 'true';
export const USE_BILLING = process.env.USE_BILLING === 'true';
// Hardcoded flags
export const USE_MULTIPLE_PROJECTS = true;

View file

@ -1,5 +1,5 @@
import { MongoClient } from "mongodb";
import { Webpage } from "./types/types";
import { User, Webpage } from "./types/types";
import { Workflow } from "./types/workflow_types";
import { ApiKey } from "./types/project_types";
import { ProjectMember } from "./types/project_types";
@ -31,6 +31,7 @@ export const testResultsCollection = db.collection<z.infer<typeof TestResult>>("
export const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages");
export const twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("twilio_configs");
export const usersCollection = db.collection<z.infer<typeof User>>("users");
// Create indexes
twilioConfigsCollection.createIndexes([

View file

@ -0,0 +1,100 @@
import { z } from "zod";
export const SubscriptionPlan = z.enum(["free", "starter", "pro"]);
export const UsageType = z.enum([
"copilot_requests",
"agent_messages",
"rag_tokens",
]);
export const Customer = z.object({
_id: z.string(),
userId: z.string(),
email: z.string(),
stripeCustomerId: z.string(),
subscriptionPlan: SubscriptionPlan.optional(),
subscriptionActive: z.boolean().optional(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
subscriptionPlanUpdatedAt: z.string().datetime().optional(),
usage: z.record(UsageType, z.number()).optional(),
usageUpdatedAt: z.string().datetime().optional(),
});
export const LogUsageRequest = z.object({
type: UsageType,
amount: z.number().int().positive(),
});
export const AuthorizeRequest = z.discriminatedUnion("type", [
z.object({
"type": z.literal("create_project"),
"data": z.object({
"existingProjectCount": z.number(),
}),
}),
z.object({
"type": z.literal("enable_hosted_tool_server"),
"data": z.object({
"existingServerCount": z.number(),
}),
}),
z.object({
"type": z.literal("process_rag"),
"data": z.object({}),
}),
z.object({
"type": z.literal("copilot_request"),
"data": z.object({}),
}),
z.object({
"type": z.literal("agent_response"),
"data": z.object({
agentModels: z.array(z.string()),
}),
}),
]);
export const AuthorizeResponse = z.object({
success: z.boolean(),
error: z.string().optional(),
});
export const UsageResponse = z.object({
usage: z.record(UsageType, z.object({
usage: z.number(),
total: z.number(),
})),
});
export const CustomerPortalSessionRequest = z.object({
returnUrl: z.string(),
});
export const CustomerPortalSessionResponse = z.object({
url: z.string(),
});
export const PricesResponse = z.object({
prices: z.record(SubscriptionPlan, z.object({
monthly: z.number(),
})),
});
export const UpdateSubscriptionPlanRequest = z.object({
plan: SubscriptionPlan,
returnUrl: z.string(),
});
export const UpdateSubscriptionPlanResponse = z.object({
url: z.string(),
});
export const ModelsResponse = z.object({
agentModels: z.array(z.object({
name: z.string(),
eligible: z.boolean(),
plan: SubscriptionPlan,
})),
});

View file

@ -104,6 +104,7 @@ export const CopilotApiChatContext = z.union([
}),
]);
export const CopilotAPIRequest = z.object({
projectId: z.string(),
messages: z.array(CopilotApiMessage),
workflow_schema: z.string(),
current_workflow_config: z.string(),

View file

@ -13,6 +13,7 @@ export const DataSource = z.object({
]).optional(),
version: z.number(),
error: z.string().optional(),
billingError: z.string().optional(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime().optional(),
attempts: z.number(),

View file

@ -76,6 +76,15 @@ export const McpServerResponse = z.object({
error: z.string().nullable(),
});
export const User = z.object({
auth0Id: z.string(),
billingCustomerId: z.string().optional(),
name: z.string().optional(),
email: z.string().optional(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export const PlaygroundChat = z.object({
createdAt: z.string().datetime(),
projectId: z.string(),

View file

@ -0,0 +1,90 @@
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { useRouter } from "next/navigation";
import { updateUserEmail } from "../actions/auth_actions";
import { tokens } from "@/app/styles/design-tokens";
import { SectionHeading } from "@/components/ui/section-heading";
import { HorizontalDivider } from "@/components/ui/horizontal-divider";
import clsx from 'clsx';
export default function App() {
const router = useRouter();
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
if (!email.trim()) {
setError("Please enter your email.");
return;
}
setSubmitted(true);
try {
await updateUserEmail(email);
router.push('/projects');
} catch (error) {
setError("Failed to update email.");
}
}
return (
<div className="max-w-4xl mx-auto px-8 py-8 space-y-8">
<div className="px-4">
<h1 className={clsx(
tokens.typography.sizes.xl,
tokens.typography.weights.semibold,
tokens.colors.light.text.primary,
tokens.colors.dark.text.primary
)}>
Complete your profile
</h1>
</div>
<section className="card">
<div className="px-4 pt-4 pb-6">
<SectionHeading>
Complete your profile
</SectionHeading>
</div>
<HorizontalDivider />
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="space-y-4">
<Input
label="Email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@example.com"
required
/>
{error && (
<div className={clsx(
tokens.typography.sizes.sm,
"text-red-500"
)}>
{error}
</div>
)}
</div>
<div className="flex justify-end">
<FormStatusButton
props={{
type: "submit",
children: submitted ? "Submitted!" : "Continue",
variant: "primary",
size: "md",
isLoading: false,
disabled: submitted,
}}
/>
</div>
</form>
</section>
</div>
);
}

View file

@ -0,0 +1,13 @@
import AppLayout from '../projects/layout/components/app-layout';
export default function Layout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<AppLayout useRag={false} useAuth={true} useBilling={true}>
{children}
</AppLayout>
);
}

View file

@ -0,0 +1,14 @@
import { redirect } from "next/navigation";
import App from "./app";
import { requireAuth } from "../lib/auth";
import { USE_AUTH } from "../lib/feature_flags";
export const dynamic = 'force-dynamic';
export default async function Page() {
if (!USE_AUTH) {
redirect('/projects');
}
await requireAuth();
return <App />;
}

View file

@ -1,18 +1,20 @@
import { Metadata } from "next";
import App from "./app";
import { USE_CHAT_WIDGET } from "@/app/lib/feature_flags";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
export const metadata: Metadata = {
title: "Project config",
};
export default function Page({
export default async function Page({
params,
}: {
params: {
projectId: string;
};
}) {
await requireActiveBillingSubscription();
return <App
projectId={params.projectId}
useChatWidget={USE_CHAT_WIDGET}

View file

@ -13,6 +13,7 @@ import { ComposeBoxCopilot } from "@/components/common/compose-box-copilot";
import { Messages } from "./components/messages";
import { CopyIcon, CheckIcon, PlusIcon, XIcon, InfoIcon } from "lucide-react";
import { useCopilot } from "./use-copilot";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
const CopilotContext = createContext<{
workflow: z.infer<typeof Workflow> | null;
@ -61,6 +62,9 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
streamingResponse,
loading: loadingResponse,
error: responseError,
clearError: clearResponseError,
billingError,
clearBillingError,
start,
cancel
} = useCopilot({
@ -108,6 +112,10 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
useEffect(() => {
if (!messages.length || messages.at(-1)?.role !== 'user') return;
if (responseError) {
return;
}
const currentStart = startRef.current;
const currentCancel = cancelRef.current;
@ -122,7 +130,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
});
return () => currentCancel();
}, [messages]); // Only depend on messages
}, [messages, responseError]);
const handleCopyChat = useCallback(() => {
if (onCopyJson) {
@ -157,7 +165,15 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
size="sm"
color="danger"
onClick={() => {
setMessages(prev => [...prev.slice(0, -1)]); // remove last assistant if needed
// remove the last assistant message, if any
setMessages(prev => {
const lastMessage = prev[prev.length - 1];
if (lastMessage?.role === 'assistant') {
return prev.slice(0, -1);
}
return prev;
});
clearResponseError();
}}
>
Retry
@ -191,6 +207,11 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
/>
</div>
</div>
<BillingUpgradeModal
isOpen={!!billingError}
onClose={clearBillingError}
errorMessage={billingError || ''}
/>
</CopilotContext.Provider>
);
});
@ -215,6 +236,7 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
const [copilotKey, setCopilotKey] = useState(0);
const [showCopySuccess, setShowCopySuccess] = useState(false);
const [messages, setMessages] = useState<z.infer<typeof CopilotMessage>[]>([]);
const [billingError, setBillingError] = useState<string | null>(null);
const appRef = useRef<{ handleCopyChat: () => void; handleUserMessage: (message: string) => void }>(null);
function handleNewChat() {
@ -242,64 +264,67 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
}), []);
return (
<Panel variant="copilot"
tourTarget="copilot"
showWelcome={messages.length === 0}
title={
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
COPILOT
<>
<Panel
variant="copilot"
tourTarget="copilot"
showWelcome={messages.length === 0}
title={
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
COPILOT
</div>
<Tooltip content="Ask copilot to help you build and modify your workflow">
<InfoIcon className="w-4 h-4 text-gray-400 cursor-help" />
</Tooltip>
</div>
<Tooltip content="Ask copilot to help you build and modify your workflow">
<InfoIcon className="w-4 h-4 text-gray-400 cursor-help" />
</Tooltip>
<Button
variant="primary"
size="sm"
onClick={handleNewChat}
className="bg-blue-50 text-blue-700 hover:bg-blue-100"
showHoverContent={true}
hoverContent="New chat"
>
<PlusIcon className="w-4 h-4" />
</Button>
</div>
<Button
variant="primary"
size="sm"
onClick={handleNewChat}
className="bg-blue-50 text-blue-700 hover:bg-blue-100"
showHoverContent={true}
hoverContent="New chat"
>
<PlusIcon className="w-4 h-4" />
</Button>
}
rightActions={
<div className="flex items-center gap-3">
<Button
variant="secondary"
size="sm"
onClick={() => appRef.current?.handleCopyChat()}
showHoverContent={true}
hoverContent={showCopySuccess ? "Copied" : "Copy JSON"}
>
{showCopySuccess ? (
<CheckIcon className="w-4 h-4" />
) : (
<CopyIcon className="w-4 h-4" />
)}
</Button>
</div>
}
>
<div className="h-full overflow-auto px-3 pt-4">
<App
key={copilotKey}
ref={appRef}
projectId={projectId}
workflow={workflow}
dispatch={dispatch}
chatContext={chatContext}
onCopyJson={handleCopyJson}
onMessagesChange={setMessages}
isInitialState={isInitialState}
dataSources={dataSources}
/>
</div>
}
rightActions={
<div className="flex items-center gap-3">
<Button
variant="secondary"
size="sm"
onClick={() => appRef.current?.handleCopyChat()}
showHoverContent={true}
hoverContent={showCopySuccess ? "Copied" : "Copy JSON"}
>
{showCopySuccess ? (
<CheckIcon className="w-4 h-4" />
) : (
<CopyIcon className="w-4 h-4" />
)}
</Button>
</div>
}
>
<div className="h-full overflow-auto px-3 pt-4">
<App
key={copilotKey}
ref={appRef}
projectId={projectId}
workflow={workflow}
dispatch={dispatch}
chatContext={chatContext}
onCopyJson={handleCopyJson}
onMessagesChange={setMessages}
isInitialState={isInitialState}
dataSources={dataSources}
/>
</div>
</Panel>
</Panel>
</>
);
});

View file

@ -16,9 +16,12 @@ interface UseCopilotResult {
streamingResponse: string;
loading: boolean;
error: string | null;
clearError: () => void;
billingError: string | null;
clearBillingError: () => void;
start: (
messages: z.infer<typeof CopilotMessage>[],
onDone: (finalResponse: string) => void
onDone: (finalResponse: string) => void,
) => void;
cancel: () => void;
}
@ -27,13 +30,21 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
const [streamingResponse, setStreamingResponse] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [billingError, setBillingError] = useState<string | null>(null);
const cancelRef = useRef<() => void>(() => { });
const responseRef = useRef('');
function clearError() {
setError(null);
}
function clearBillingError() {
setBillingError(null);
}
const start = useCallback(async (
messages: z.infer<typeof CopilotMessage>[],
onDone: (finalResponse: string) => void
onDone: (finalResponse: string) => void,
) => {
if (!messages.length || messages.at(-1)?.role !== 'user') return;
@ -44,6 +55,15 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
try {
const res = await getCopilotResponseStream(projectId, messages, workflow, context || null, dataSources);
// Check for billing error
if ('billingError' in res) {
setLoading(false);
setError(res.billingError);
setBillingError(res.billingError);
return;
}
const eventSource = new EventSource(`/api/copilot-stream-response/${res.streamId}`);
eventSource.onmessage = (event) => {
@ -84,6 +104,9 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
streamingResponse,
loading,
error,
clearError,
billingError,
clearBillingError,
start,
cancel,
};

View file

@ -4,10 +4,10 @@ import { AgenticAPITool } from "../../../lib/types/agents_api_types";
import { WorkflowPrompt, WorkflowAgent, Workflow, WorkflowTool } from "../../../lib/types/workflow_types";
import { DataSource } from "../../../lib/types/datasource_types";
import { z } from "zod";
import { PlusIcon, Sparkles, X as XIcon, ChevronDown, ChevronRight, Trash2, Maximize2, Minimize2 } from "lucide-react";
import { PlusIcon, Sparkles, X as XIcon, ChevronDown, ChevronRight, Trash2, Maximize2, Minimize2, StarIcon } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { usePreviewModal } from "../workflow/preview-modal";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Select, SelectItem } from "@heroui/react";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Select, SelectItem, Chip, SelectSection } from "@heroui/react";
import { PreviewModalProvider } from "../workflow/preview-modal";
import { CopilotMessage } from "@/app/lib/types/copilot_types";
import { getCopilotAgentInstructions } from "@/app/actions/copilot_actions";
@ -23,6 +23,8 @@ import { USE_TRANSFER_CONTROL_OPTIONS } from "@/app/lib/feature_flags";
import { Input } from "@/components/ui/input";
import { Info } from "lucide-react";
import { useCopilot } from "../copilot/use-copilot";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
import { ModelsResponse } from "@/app/lib/types/billing_types";
// Common section header styles
const sectionHeaderStyles = "text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400";
@ -47,6 +49,7 @@ export function AgentConfig({
handleClose,
useRag,
triggerCopilotChat,
eligibleModels,
}: {
projectId: string,
workflow: z.infer<typeof Workflow>,
@ -61,6 +64,7 @@ export function AgentConfig({
handleClose: () => void,
useRag: boolean,
triggerCopilotChat: (message: string) => void,
eligibleModels: z.infer<typeof ModelsResponse.shape.agentModels> | "*",
}) {
const [isAdvancedConfigOpen, setIsAdvancedConfigOpen] = useState(false);
const [showGenerateModal, setShowGenerateModal] = useState(false);
@ -72,6 +76,7 @@ export function AgentConfig({
const [activeTab, setActiveTab] = useState<TabType>('instructions');
const [showRagCta, setShowRagCta] = useState(false);
const [previousRagSources, setPreviousRagSources] = useState<string[]>([]);
const [billingError, setBillingError] = useState<string | null>(null);
const {
start: startCopilotChat,
@ -490,7 +495,7 @@ export function AgentConfig({
<label className={sectionHeaderStyles}>
Model
</label>
<div className="relative ml-2 group">
{eligibleModels === "*" && <div className="relative ml-2 group">
<Info
className="w-4 h-4 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer transition-colors"
/>
@ -505,17 +510,67 @@ export function AgentConfig({
By default, the model is set to gpt-4.1, assuming your OpenAI API key is set in PROVIDER_API_KEY and PROVIDER_BASE_URL is not set.
<div className="absolute h-2 w-2 bg-white dark:bg-gray-800 transform rotate-45 -bottom-1 left-4 border-r border-b border-gray-200 dark:border-gray-700"></div>
</div>
</div>
</div>}
</div>
<div className="w-full">
<Input
{eligibleModels === "*" && <Input
value={agent.model}
onChange={(e) => handleUpdate({
...agent,
model: e.target.value as z.infer<typeof WorkflowAgent>['model']
})}
className="w-full max-w-64"
/>
/>}
{eligibleModels !== "*" && <Select
variant="bordered"
placeholder="Select model"
className="w-full max-w-64"
selectedKeys={[agent.model]}
onSelectionChange={(keys) => {
const key = keys.currentKey as string;
const model = eligibleModels.find((m) => m.name === key);
if (!model) {
return;
}
if (!model.eligible) {
setBillingError(`Please upgrade to the ${model.plan.toUpperCase()} plan to use this model.`);
return;
}
handleUpdate({
...agent,
model: key as z.infer<typeof WorkflowAgent>['model']
});
}}
>
<SelectSection title="Available">
{eligibleModels.filter((model) => model.eligible).map((model) => (
<SelectItem
key={model.name}
>
{model.name}
</SelectItem>
))}
</SelectSection>
<SelectSection title="Requires plan upgrade">
{eligibleModels.filter((model) => !model.eligible).map((model) => (
<SelectItem
key={model.name}
endContent={<Chip
color="warning"
size="sm"
variant="bordered"
>
{model.plan.toUpperCase()}
</Chip>
}
startContent={<StarIcon className="w-4 h-4 text-warning" />}
>
{model.name}
</SelectItem>
))}
</SelectSection>
</Select>
}
</div>
</div>
@ -764,6 +819,12 @@ export function AgentConfig({
}}
/>
</PreviewModalProvider>
<BillingUpgradeModal
isOpen={!!billingError}
onClose={() => setBillingError(null)}
errorMessage={billingError || ''}
/>
</div>
</Panel>
);
@ -789,6 +850,7 @@ function GenerateInstructionsModal({
const [prompt, setPrompt] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [billingError, setBillingError] = useState<string | null>(null);
const { showPreview } = usePreviewModal();
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -797,6 +859,7 @@ function GenerateInstructionsModal({
setPrompt("");
setIsLoading(false);
setError(null);
setBillingError(null);
textareaRef.current?.focus();
}
}, [isOpen]);
@ -804,6 +867,7 @@ function GenerateInstructionsModal({
const handleGenerate = async () => {
setIsLoading(true);
setError(null);
setBillingError(null);
try {
const msgs: z.infer<typeof CopilotMessage>[] = [
{
@ -812,6 +876,12 @@ function GenerateInstructionsModal({
},
];
const newInstructions = await getCopilotAgentInstructions(projectId, msgs, workflow, agent.name);
if (typeof newInstructions === 'object' && 'billingError' in newInstructions) {
setBillingError(newInstructions.billingError);
setError(newInstructions.billingError);
setIsLoading(false);
return;
}
onClose();
@ -840,59 +910,66 @@ function GenerateInstructionsModal({
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalContent>
<ModalHeader>Generate Instructions</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4">
{error && (
<div className="p-2 bg-red-50 border border-red-200 rounded-lg flex gap-2 justify-between items-center text-sm">
<p className="text-red-600">{error}</p>
<CustomButton
variant="primary"
size="sm"
onClick={() => {
setError(null);
handleGenerate();
}}
>
Retry
</CustomButton>
</div>
)}
<Textarea
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
<>
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalContent>
<ModalHeader>Generate Instructions</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4">
{error && (
<div className="p-2 bg-red-50 border border-red-200 rounded-lg flex gap-2 justify-between items-center text-sm">
<p className="text-red-600">{error}</p>
<CustomButton
variant="primary"
size="sm"
onClick={() => {
setError(null);
handleGenerate();
}}
>
Retry
</CustomButton>
</div>
)}
<Textarea
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isLoading}
placeholder="e.g., This agent should help users analyze their data and provide insights..."
className={textareaStyles}
autoResize
/>
</div>
</ModalBody>
<ModalFooter>
<CustomButton
variant="secondary"
size="sm"
onClick={onClose}
disabled={isLoading}
placeholder="e.g., This agent should help users analyze their data and provide insights..."
className={textareaStyles}
autoResize
/>
</div>
</ModalBody>
<ModalFooter>
<CustomButton
variant="secondary"
size="sm"
onClick={onClose}
disabled={isLoading}
>
Cancel
</CustomButton>
<CustomButton
variant="primary"
size="sm"
onClick={handleGenerate}
disabled={!prompt.trim() || isLoading}
isLoading={isLoading}
>
Generate
</CustomButton>
</ModalFooter>
</ModalContent>
</Modal>
>
Cancel
</CustomButton>
<CustomButton
variant="primary"
size="sm"
onClick={handleGenerate}
disabled={!prompt.trim() || isLoading}
isLoading={isLoading}
>
Generate
</CustomButton>
</ModalFooter>
</ModalContent>
</Modal>
<BillingUpgradeModal
isOpen={!!billingError}
onClose={() => setBillingError(null)}
errorMessage={billingError || ''}
/>
</>
);
}

View file

@ -1,9 +1,11 @@
import { redirect } from "next/navigation";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
export default function Page({
export default async function Page({
params
}: {
params: { projectId: string }
}) {
await requireActiveBillingSubscription();
redirect(`/projects/${params.projectId}/workflow`);
}

View file

@ -15,6 +15,7 @@ import { TestProfile } from "@/app/lib/types/testing_types";
import { WithStringId } from "@/app/lib/types/types";
import { ProfileContextBox } from "./profile-context-box";
import { USE_TESTING_FEATURE } from "@/app/lib/feature_flags";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
export function Chat({
chat,
@ -51,6 +52,7 @@ export function Chat({
last_agent_name: workflow.startAgent,
});
const [fetchResponseError, setFetchResponseError] = useState<string | null>(null);
const [billingError, setBillingError] = useState<string | null>(null);
const [lastAgenticRequest, setLastAgenticRequest] = useState<unknown | null>(null);
const [lastAgenticResponse, setLastAgenticResponse] = useState<unknown | null>(null);
const [optimisticMessages, setOptimisticMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>(chat.messages);
@ -160,6 +162,13 @@ export function Chat({
if (ignore) {
return;
}
if ('billingError' in response) {
setBillingError(response.billingError);
setFetchResponseError(response.billingError);
setLoadingAssistantResponse(false);
console.log('returning from getAssistantResponseStreamId due to billing error');
return;
}
streamId = response.streamId;
} catch (err) {
if (!ignore) {
@ -246,6 +255,7 @@ export function Chat({
return;
}
console.log(`executing response process: fetchresponseerr: ${fetchResponseError}`);
process();
return () => {
@ -307,7 +317,10 @@ export function Chat({
<Button
size="sm"
color="danger"
onPress={() => setFetchResponseError(null)}
onPress={() => {
setFetchResponseError(null);
setBillingError(null);
}}
>
Retry
</Button>
@ -322,5 +335,12 @@ export function Chat({
onFocus={() => setIsLastInteracted(true)}
/>
</div>
<BillingUpgradeModal
isOpen={!!billingError}
onClose={() => setBillingError(null)}
errorMessage={billingError || ''}
/>
</div>;
}

View file

@ -1,4 +1,5 @@
import { SourcePage } from "./source-page";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
export default async function Page({
params,
@ -8,5 +9,6 @@ export default async function Page({
sourceId: string
}
}) {
await requireActiveBillingSubscription();
return <SourcePage projectId={params.projectId} sourceId={params.sourceId} />;
}

View file

@ -17,7 +17,9 @@ import { Section, SectionRow, SectionLabel, SectionContent } from "../components
import Link from "next/link";
import { BackIcon } from "../../../../lib/components/icons";
import { Textarea } from "@/components/ui/textarea";
import { CheckIcon } from "lucide-react";
import { CheckIcon, TriangleAlertIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
export function SourcePage({
sourceId,
@ -29,11 +31,15 @@ export function SourcePage({
const [source, setSource] = useState<WithStringId<z.infer<typeof DataSource>> | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showSaveSuccess, setShowSaveSuccess] = useState(false);
const [billingError, setBillingError] = useState<string | null>(null);
async function handleReload() {
setIsLoading(true);
const updatedSource = await getDataSource(projectId, sourceId);
setSource(updatedSource);
if ("billingError" in updatedSource && updatedSource.billingError) {
setBillingError(updatedSource.billingError);
}
setIsLoading(false);
}
@ -45,6 +51,9 @@ export function SourcePage({
const source = await getDataSource(projectId, sourceId);
if (!ignore) {
setSource(source);
if ("billingError" in source && source.billingError) {
setBillingError(source.billingError);
}
setIsLoading(false);
}
}
@ -74,6 +83,9 @@ export function SourcePage({
const updatedSource = await getDataSource(projectId, sourceId);
if (!ignore) {
setSource(updatedSource);
if ("billingError" in updatedSource && updatedSource.billingError) {
setBillingError(updatedSource.billingError);
}
timeout = setTimeout(refresh, 15 * 1000);
}
}
@ -206,9 +218,22 @@ export function SourcePage({
<SectionLabel>Status</SectionLabel>
<SectionContent>
<SourceStatus status={source.status} projectId={projectId} />
{("billingError" in source) && source.billingError && <div className="flex flex-col gap-1 items-start mt-4">
<div className="text-sm">{source.billingError}</div>
<Button
onClick={() => source.billingError ? setBillingError(source.billingError) : null}
variant="tertiary"
className="bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 hover:text-yellow-700 dark:hover:text-yellow-300 text-sm p-2"
>
Upgrade
</Button>
</div>}
</SectionContent>
</SectionRow>
)}
</div>
</Section>
@ -251,7 +276,12 @@ export function SourcePage({
</div>
</Section>
</div>
</div>
</Panel>
</div >
<BillingUpgradeModal
isOpen={!!billingError}
onClose={() => setBillingError(null)}
errorMessage={billingError || ''}
/>
</Panel >
);
}

View file

@ -2,6 +2,7 @@ import { Metadata } from "next";
import { Form } from "./form";
import { redirect } from "next/navigation";
import { USE_RAG, USE_RAG_UPLOADS, USE_RAG_S3_UPLOADS, USE_RAG_SCRAPING } from "../../../../lib/feature_flags";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
export const metadata: Metadata = {
title: "Add data source"
@ -12,6 +13,7 @@ export default async function Page({
}: {
params: { projectId: string }
}) {
await requireActiveBillingSubscription();
if (!USE_RAG) {
redirect(`/projects/${params.projectId}`);
}

View file

@ -1,5 +1,6 @@
import { Metadata } from "next";
import { SourcesList } from "./components/sources-list";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
export const metadata: Metadata = {
title: "Data sources",
@ -10,6 +11,7 @@ export default async function Page({
}: {
params: { projectId: string }
}) {
await requireActiveBillingSubscription();
return <SourcesList
projectId={params.projectId}
/>;

View file

@ -1,12 +1,13 @@
'use client';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { ScenariosApp } from "./scenarios_app";
import { SimulationsApp } from "./simulations_app";
import { ProfilesApp } from "./profiles_app";
import { RunsApp } from "./runs_app";
import { TestingMenu } from "./testing_menu";
export default function TestPage({ params }: { params: { projectId: string; slug?: string[] } }) {
import { requireActiveBillingSubscription } from '@/app/lib/billing';
export default async function TestPage({ params }: { params: { projectId: string; slug?: string[] } }) {
await requireActiveBillingSubscription();
const { projectId, slug = [] } = params;
let app: "scenarios" | "simulations" | "profiles" | "runs" = "runs";

View file

@ -20,7 +20,7 @@ import {
ServerCard,
ToolManagementPanel,
} from './MCPServersCommon';
import type { Key } from 'react';
import { BillingUpgradeModal } from '@/components/common/billing-upgrade-modal';
type McpServerType = z.infer<typeof MCPServer>;
type McpToolType = z.infer<typeof MCPServer>['tools'][number];
@ -139,6 +139,7 @@ export function HostedServers({ onSwitchTab }: HostedServersProps) {
const [savingTools, setSavingTools] = useState(false);
const [serverToolCounts, setServerToolCounts] = useState<Map<string, number>>(new Map());
const [syncingServers, setSyncingServers] = useState<Set<string>>(new Set());
const [billingError, setBillingError] = useState<string | null>(null);
const fetchServers = useCallback(async () => {
try {
@ -239,6 +240,7 @@ export function HostedServers({ onSwitchTab }: HostedServersProps) {
return next;
});
setToggleError(null);
setBillingError(null);
setServerOperations(prev => {
const next = new Map(prev);
@ -249,6 +251,24 @@ export function HostedServers({ onSwitchTab }: HostedServersProps) {
try {
const result = await enableServer(server.name, projectId || "", newState);
// Check for billing error
if ('billingError' in result) {
setBillingError(result.billingError);
// Revert UI state
setServers(prevServers => {
return prevServers.map(s => {
if (s.name === serverKey) {
return {
...s,
isActive: isCurrentlyEnabled
};
}
return s;
});
});
return;
}
setEnabledServers(prev => {
const next = new Set(prev);
if (!newState) {
@ -687,6 +707,12 @@ export function HostedServers({ onSwitchTab }: HostedServersProps) {
isSaving={savingTools}
isSyncing={selectedServer ? syncingServers.has(selectedServer.name) : false}
/>
<BillingUpgradeModal
isOpen={!!billingError}
onClose={() => setBillingError(null)}
errorMessage={billingError || ''}
/>
</div>
);
}

View file

@ -1,8 +1,11 @@
import { Suspense } from 'react';
import { ToolsConfig } from './components/ToolsConfig';
import { PageHeader } from '@/components/ui/page-header';
import { requireActiveBillingSubscription } from '@/app/lib/billing';
export default async function ToolsPage() {
await requireActiveBillingSubscription();
export default function ToolsPage() {
return (
<div className="flex flex-col h-full">
<PageHeader

View file

@ -12,6 +12,8 @@ import { listDataSources } from "../../../actions/datasource_actions";
import { listMcpServers, listProjectMcpTools } from "@/app/actions/mcp_actions";
import { getProjectConfig } from "@/app/actions/project_actions";
import { WorkflowTool } from "@/app/lib/types/workflow_types";
import { getEligibleModels } from "@/app/actions/billing_actions";
import { ModelsResponse } from "@/app/lib/types/billing_types";
export function App({
projectId,
@ -31,15 +33,28 @@ export function App({
const [autoSelectIfOnlyOneWorkflow, setAutoSelectIfOnlyOneWorkflow] = useState(true);
const [mcpServerUrls, setMcpServerUrls] = useState<Array<z.infer<typeof MCPServer>>>([]);
const [toolWebhookUrl, setToolWebhookUrl] = useState<string>('');
const [eligibleModels, setEligibleModels] = useState<z.infer<typeof ModelsResponse> | "*">("*");
const handleSelect = useCallback(async (workflowId: string) => {
setLoading(true);
const workflow = await fetchWorkflow(projectId, workflowId);
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
const dataSources = await listDataSources(projectId);
const mcpServers = await listMcpServers(projectId);
const projectConfig = await getProjectConfig(projectId);
const projectTools = await listProjectMcpTools(projectId);
const [
workflow,
publishedWorkflowId,
dataSources,
mcpServers,
projectConfig,
projectTools,
eligibleModels,
] = await Promise.all([
fetchWorkflow(projectId, workflowId),
fetchPublishedWorkflowId(projectId),
listDataSources(projectId),
listMcpServers(projectId),
getProjectConfig(projectId),
listProjectMcpTools(projectId),
getEligibleModels(),
]);
// Store the selected workflow ID in local storage
localStorage.setItem(`lastWorkflowId_${projectId}`, workflowId);
setWorkflow(workflow);
@ -48,6 +63,7 @@ export function App({
setMcpServerUrls(mcpServers);
setToolWebhookUrl(projectConfig.webhookUrl ?? '');
setProjectTools(projectTools);
setEligibleModels(eligibleModels);
setLoading(false);
}, [projectId]);
@ -126,6 +142,7 @@ export function App({
mcpServerUrls={mcpServerUrls}
toolWebhookUrl={toolWebhookUrl}
defaultModel={defaultModel}
eligibleModels={eligibleModels}
/>}
</>
}

View file

@ -3,6 +3,8 @@ import { App } from "./app";
import { USE_RAG } from "@/app/lib/feature_flags";
import { projectsCollection } from "@/app/lib/mongodb";
import { notFound } from "next/navigation";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
const DEFAULT_MODEL = process.env.PROVIDER_DEFAULT_MODEL || "gpt-4.1";
export const metadata: Metadata = {
@ -14,6 +16,7 @@ export default async function Page({
}: {
params: { projectId: string };
}) {
await requireActiveBillingSubscription();
console.log('->>> workflow page being rendered');
const project = await projectsCollection.findOne({
_id: params.projectId,

View file

@ -27,6 +27,7 @@ import { BackIcon, HamburgerIcon, WorkflowIcon } from "../../../lib/components/i
import { CopyIcon, ImportIcon, Layers2Icon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle } from "lucide-react";
import { EntityList } from "./entity_list";
import { ProductTour } from "@/components/common/product-tour";
import { ModelsResponse } from "@/app/lib/types/billing_types";
enablePatches();
@ -563,6 +564,7 @@ export function WorkflowEditor({
toolWebhookUrl,
defaultModel,
projectTools,
eligibleModels,
}: {
dataSources: WithStringId<z.infer<typeof DataSource>>[];
workflow: WithStringId<z.infer<typeof Workflow>>;
@ -574,6 +576,7 @@ export function WorkflowEditor({
toolWebhookUrl: string;
defaultModel: string;
projectTools: z.infer<typeof WorkflowTool>[];
eligibleModels: z.infer<typeof ModelsResponse> | "*";
}) {
const [state, dispatch] = useReducer<Reducer<State, Action>>(reducer, {
@ -1026,6 +1029,7 @@ export function WorkflowEditor({
handleClose={handleUnselectAgent}
useRag={useRag}
triggerCopilotChat={triggerCopilotChat}
eligibleModels={eligibleModels === "*" ? "*" : eligibleModels.agentModels}
/>}
{state.present.selection?.type === "tool" && (() => {
const selectedTool = state.present.workflow.tools.find(

View file

@ -1,4 +1,4 @@
import { USE_AUTH, USE_RAG } from "../lib/feature_flags";
import { USE_AUTH, USE_BILLING, USE_RAG } from "../lib/feature_flags";
import AppLayout from './layout/components/app-layout';
export const dynamic = 'force-dynamic';
@ -9,7 +9,7 @@ export default function Layout({
children: React.ReactNode;
}>) {
return (
<AppLayout useRag={USE_RAG} useAuth={USE_AUTH}>
<AppLayout useRag={USE_RAG} useAuth={USE_AUTH} useBilling={USE_BILLING}>
{children}
</AppLayout>
);

View file

@ -7,16 +7,16 @@ interface AppLayoutProps {
children: ReactNode;
useRag?: boolean;
useAuth?: boolean;
useBilling?: boolean;
}
export default function AppLayout({ children, useRag = false, useAuth = false }: AppLayoutProps) {
export default function AppLayout({ children, useRag = false, useAuth = false, useBilling = false }: AppLayoutProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const pathname = usePathname();
const projectId = pathname.split('/')[2];
// For invalid projectId, return just the children
if (!projectId && !pathname.startsWith('/projects')) {
return children;
let projectId: string|null = null;
if (pathname.startsWith('/projects')) {
projectId = pathname.split('/')[2];
}
// Layout with sidebar for all routes
@ -25,11 +25,12 @@ export default function AppLayout({ children, useRag = false, useAuth = false }:
{/* Sidebar with improved shadow and blur */}
<div className="overflow-hidden rounded-xl bg-white/70 dark:bg-zinc-800/70 shadow-sm backdrop-blur-sm">
<Sidebar
projectId={projectId}
projectId={projectId ?? undefined}
useRag={useRag}
useAuth={useAuth}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
useBilling={useBilling}
/>
</div>

View file

@ -23,17 +23,18 @@ import { USE_TESTING_FEATURE, USE_PRODUCT_TOUR } from '@/app/lib/feature_flags';
import { useHelpModal } from "@/app/providers/help-modal-provider";
interface SidebarProps {
projectId: string;
projectId?: string;
useRag: boolean;
useAuth: boolean;
collapsed?: boolean;
onToggleCollapse?: () => void;
useBilling?: boolean;
}
const EXPANDED_ICON_SIZE = 20;
const COLLAPSED_ICON_SIZE = 20; // DO NOT CHANGE THIS
export default function Sidebar({ projectId, useRag, useAuth, collapsed = false, onToggleCollapse }: SidebarProps) {
export default function Sidebar({ projectId, useRag, useAuth, collapsed = false, onToggleCollapse, useBilling }: SidebarProps) {
const pathname = usePathname();
const [projectName, setProjectName] = useState<string>("Select Project");
const isProjectsRoute = pathname === '/projects' || pathname === '/projects/select';
@ -123,8 +124,8 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
</Tooltip>
</div>
{/* Navigation Items */}
<nav className="p-3 space-y-4">
{/* Project-specific navigation Items */}
{projectId && <nav className="p-3 space-y-4">
{navItems.map((item) => {
const Icon = item.icon;
const fullPath = `/projects/${projectId}/${item.href}`;
@ -184,7 +185,7 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
</Tooltip>
);
})}
</nav>
</nav>}
</>
)}
</div>
@ -252,7 +253,7 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
hover:bg-zinc-100 dark:hover:bg-zinc-800/50
`}
>
<UserButton />
<UserButton useBilling={useBilling} />
{!collapsed && <span>Account</span>}
</div>
</Tooltip>

View file

@ -1,5 +1,7 @@
import { redirect } from 'next/navigation';
import { requireActiveBillingSubscription } from '../lib/billing';
export default function Page() {
export default async function Page() {
await requireActiveBillingSubscription();
redirect('/projects/select');
}

View file

@ -13,6 +13,7 @@ import { FolderOpenIcon, InformationCircleIcon } from "@heroicons/react/24/outli
import { USE_MULTIPLE_PROJECTS } from "@/app/lib/feature_flags";
import { HorizontalDivider } from "@/components/ui/horizontal-divider";
import { Tooltip } from "@heroui/react";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
// Add glow animation styles
const glowStyles = `
@ -137,6 +138,7 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
const [customPrompt, setCustomPrompt] = useState("");
const [name, setName] = useState(defaultName);
const [promptError, setPromptError] = useState<string | null>(null);
const [billingError, setBillingError] = useState<string | null>(null);
const router = useRouter();
// Add this effect to update name when defaultName changes
@ -194,7 +196,7 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
setIsExamplesDropdownOpen(false);
};
async function handleSubmit(formData: FormData) {
async function handleSubmit() {
try {
if (selectedTab !== TabType.Blank && !customPrompt.trim()) {
setPromptError("Prompt cannot be empty");
@ -213,237 +215,226 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
newFormData.append('name', name);
newFormData.append('prompt', customPrompt);
response = await createProjectFromPrompt(newFormData);
}
if (response?.id && customPrompt) {
if ('id' in response) {
if (selectedTab !== TabType.Blank && customPrompt) {
localStorage.setItem(`project_prompt_${response.id}`, customPrompt);
}
router.push(`/projects/${response.id}/workflow`);
} else {
setBillingError(response.billingError);
}
if (!response?.id) {
throw new Error('Project creation failed');
}
router.push(`/projects/${response.id}/workflow`);
} catch (error) {
console.error('Error creating project:', error);
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' &&
selectedTab !== TabType.Blank &&
(e.target as HTMLElement).tagName !== 'TEXTAREA') {
e.preventDefault();
const formData = new FormData();
formData.append('name', name);
handleSubmit(formData);
}
};
return (
<div className={clsx(
"overflow-auto",
!USE_MULTIPLE_PROJECTS && "max-w-none px-12 py-12",
USE_MULTIPLE_PROJECTS && !isProjectPaneOpen && "col-span-full"
)}>
<section className={clsx(
"card h-full",
!USE_MULTIPLE_PROJECTS && "px-24",
USE_MULTIPLE_PROJECTS && "px-8"
<>
<div className={clsx(
"overflow-auto",
!USE_MULTIPLE_PROJECTS && "max-w-none px-12 py-12",
USE_MULTIPLE_PROJECTS && !isProjectPaneOpen && "col-span-full"
)}>
{USE_MULTIPLE_PROJECTS && (
<>
<div className="px-4 pt-4 pb-6 flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
Create new assistant
</h1>
{!isProjectPaneOpen && (
<Button
onClick={onOpenProjectPane}
variant="primary"
size="md"
startContent={<FolderOpenIcon className="w-4 h-4" />}
>
View Existing Projects
</Button>
)}
</div>
<HorizontalDivider />
</>
)}
<form
id="create-project-form"
action={handleSubmit}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
handleSubmit(formData);
}}
onKeyDown={handleKeyDown}
className="pt-6 pb-16 space-y-12"
>
{/* Tab Section */}
<div>
<div className="mb-5">
<SectionHeading>
Get started
</SectionHeading>
</div>
{/* Tab Navigation */}
<div className="flex gap-6 relative">
<Button
variant={selectedTab === TabType.Describe ? 'primary' : 'tertiary'}
size="md"
onClick={() => handleTabChange(TabType.Describe)}
className={selectedTab === TabType.Describe ? selectedTabStyles : unselectedTabStyles}
>
Describe your assistant
</Button>
<Button
variant={selectedTab === TabType.Blank ? 'primary' : 'tertiary'}
size="md"
onClick={handleBlankTemplateClick}
type="button"
className={selectedTab === TabType.Blank ? selectedTabStyles : unselectedTabStyles}
>
Start from a blank template
</Button>
<div className="relative" ref={dropdownRef}>
<Button
variant={selectedTab === TabType.Example ? 'primary' : 'tertiary'}
size="md"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsExamplesDropdownOpen(!isExamplesDropdownOpen);
}}
type="button"
className={selectedTab === TabType.Example ? selectedTabStyles : unselectedTabStyles}
endContent={
<svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
}
>
Use an example
</Button>
{isExamplesDropdownOpen && (
<div className="absolute z-10 mt-2 min-w-[200px] max-w-[240px] rounded-lg shadow-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<div className="py-1">
{Object.entries(starting_copilot_prompts)
.filter(([name]) => name !== 'Blank Template')
.map(([name]) => (
<Button
key={name}
variant="tertiary"
size="sm"
className="w-full justify-start text-left text-sm py-1.5"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleExampleSelect(name);
}}
type="button"
>
{name}
</Button>
))
}
</div>
</div>
<section className={clsx(
"card h-full",
!USE_MULTIPLE_PROJECTS && "px-24",
USE_MULTIPLE_PROJECTS && "px-8"
)}>
{USE_MULTIPLE_PROJECTS && (
<>
<div className="px-4 pt-4 pb-6 flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
Create new assistant
</h1>
{!isProjectPaneOpen && (
<Button
onClick={onOpenProjectPane}
variant="primary"
size="md"
startContent={<FolderOpenIcon className="w-4 h-4" />}
>
View Existing Projects
</Button>
)}
</div>
</div>
</div>
<HorizontalDivider />
</>
)}
{/* Custom Prompt Section - Only show when needed */}
{(selectedTab === TabType.Describe || selectedTab === TabType.Example) && (
<div className="space-y-4">
<div className="flex flex-col gap-4">
<label className={largeSectionHeaderStyles}>
{selectedTab === TabType.Describe ? '✏️ What do you want to build?' : '✏️ Customize the description'}
</label>
<div className="flex items-center gap-2">
<p className="text-xs text-gray-600 dark:text-gray-400">
In the next step, our AI copilot will create agents for you, complete with mock-tools.
</p>
<Tooltip content={<div>If you already know the specific agents and tools you need, mention them below.<br /><br />Specify &apos;internal agents&apos; for task agents that will not interact with the user and &apos;user-facing agents&apos; for conversational agents that will interact with users.</div>} className="max-w-[560px]">
<InformationCircleIcon className="w-4 h-4 text-indigo-500 hover:text-indigo-600 dark:text-indigo-400 dark:hover:text-indigo-300 cursor-help" />
</Tooltip>
</div>
<div className="space-y-2">
<Textarea
value={customPrompt}
onChange={(e) => {
setCustomPrompt(e.target.value);
setPromptError(null);
<form
id="create-project-form"
action={handleSubmit}
className="pt-6 pb-16 space-y-12"
>
{/* Tab Section */}
<div>
<div className="mb-5">
<SectionHeading>
Get started
</SectionHeading>
</div>
{/* Tab Navigation */}
<div className="flex gap-6 relative">
<Button
variant={selectedTab === TabType.Describe ? 'primary' : 'tertiary'}
size="md"
onClick={() => handleTabChange(TabType.Describe)}
className={selectedTab === TabType.Describe ? selectedTabStyles : unselectedTabStyles}
>
Describe your assistant
</Button>
<Button
variant={selectedTab === TabType.Blank ? 'primary' : 'tertiary'}
size="md"
onClick={handleBlankTemplateClick}
type="button"
className={selectedTab === TabType.Blank ? selectedTabStyles : unselectedTabStyles}
>
Start from a blank template
</Button>
<div className="relative" ref={dropdownRef}>
<Button
variant={selectedTab === TabType.Example ? 'primary' : 'tertiary'}
size="md"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsExamplesDropdownOpen(!isExamplesDropdownOpen);
}}
placeholder="Example: Create a customer support assistant that can handle product inquiries and returns"
type="button"
className={selectedTab === TabType.Example ? selectedTabStyles : unselectedTabStyles}
endContent={
<svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
}
>
Use an example
</Button>
{isExamplesDropdownOpen && (
<div className="absolute z-10 mt-2 min-w-[200px] max-w-[240px] rounded-lg shadow-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<div className="py-1">
{Object.entries(starting_copilot_prompts)
.filter(([name]) => name !== 'Blank Template')
.map(([name]) => (
<Button
key={name}
variant="tertiary"
size="sm"
className="w-full justify-start text-left text-sm py-1.5"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleExampleSelect(name);
}}
type="button"
>
{name}
</Button>
))
}
</div>
</div>
)}
</div>
</div>
</div>
{/* Custom Prompt Section - Only show when needed */}
{(selectedTab === TabType.Describe || selectedTab === TabType.Example) && (
<div className="space-y-4">
<div className="flex flex-col gap-4">
<label className={largeSectionHeaderStyles}>
{selectedTab === TabType.Describe ? '✏️ What do you want to build?' : '✏️ Customize the description'}
</label>
<div className="flex items-center gap-2">
<p className="text-xs text-gray-600 dark:text-gray-400">
In the next step, our AI copilot will create agents for you, complete with mock-tools.
</p>
<Tooltip content={<div>If you already know the specific agents and tools you need, mention them below.<br /><br />Specify &apos;internal agents&apos; for task agents that will not interact with the user and &apos;user-facing agents&apos; for conversational agents that will interact with users.</div>} className="max-w-[560px]">
<InformationCircleIcon className="w-4 h-4 text-indigo-500 hover:text-indigo-600 dark:text-indigo-400 dark:hover:text-indigo-300 cursor-help" />
</Tooltip>
</div>
<div className="space-y-2">
<Textarea
value={customPrompt}
onChange={(e) => {
setCustomPrompt(e.target.value);
setPromptError(null);
}}
placeholder="Example: Create a customer support assistant that can handle product inquiries and returns"
className={clsx(
textareaStyles,
"text-base",
"text-gray-900 dark:text-gray-100",
promptError && "border-red-500 focus:ring-red-500/20",
!customPrompt && emptyTextareaStyles
)}
style={{ minHeight: "120px" }}
autoFocus
autoResize
required={isNotBlankTemplate(selectedTab)}
/>
{promptError && (
<p className="text-sm text-red-500">
{promptError}
</p>
)}
</div>
</div>
</div>
)}
{selectedTab === TabType.Blank && (
<div className="space-y-4">
<div className="flex flex-col gap-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">
👇 Click &ldquo;Create assistant&rdquo; below to get started
</p>
</div>
</div>
)}
{/* Name Section */}
{USE_MULTIPLE_PROJECTS && (
<div className="space-y-4">
<div className="flex flex-col gap-4">
<label className={largeSectionHeaderStyles}>
🏷 Name the project
</label>
<Textarea
required
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
className={clsx(
textareaStyles,
"min-h-[60px]",
"text-base",
"text-gray-900 dark:text-gray-100",
promptError && "border-red-500 focus:ring-red-500/20",
!customPrompt && emptyTextareaStyles
"text-gray-900 dark:text-gray-100"
)}
style={{ minHeight: "120px" }}
autoFocus
autoResize
required={isNotBlankTemplate(selectedTab)}
placeholder={defaultName}
/>
{promptError && (
<p className="text-sm text-red-500">
{promptError}
</p>
)}
</div>
</div>
</div>
)}
)}
{selectedTab === TabType.Blank && (
<div className="space-y-4">
<div className="flex flex-col gap-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">
👇 Click &ldquo;Create assistant&rdquo; below to get started
</p>
</div>
{/* Submit Button */}
<div className="pt-1 w-full -mt-4">
<Submit />
</div>
)}
{/* Name Section */}
{USE_MULTIPLE_PROJECTS && (
<div className="space-y-4">
<div className="flex flex-col gap-4">
<label className={largeSectionHeaderStyles}>
🏷 Name the project
</label>
<Textarea
required
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
className={clsx(
textareaStyles,
"min-h-[60px]",
"text-base",
"text-gray-900 dark:text-gray-100"
)}
placeholder={defaultName}
/>
</div>
</div>
)}
{/* Submit Button */}
<div className="pt-1 w-full -mt-4">
<Submit />
</div>
</form>
</section>
</div>
</form>
</section>
</div>
<BillingUpgradeModal
isOpen={!!billingError}
onClose={() => setBillingError(null)}
errorMessage={billingError || ''}
/>
</>
);
}

View file

@ -1,5 +1,7 @@
import App from "./app";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
export default function Page() {
export default async function Page() {
await requireActiveBillingSubscription();
return <App />
}

View file

@ -1,9 +1,9 @@
import '../lib/loadenv';
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { z } from 'zod';
import { dataSourceDocsCollection, dataSourcesCollection } from '../lib/mongodb';
import { dataSourceDocsCollection, dataSourcesCollection, projectsCollection, usersCollection } from '../lib/mongodb';
import { EmbeddingRecord, DataSourceDoc, DataSource } from "../lib/types/datasource_types";
import { WithId } from 'mongodb';
import { ObjectId, WithId } from 'mongodb';
import { embedMany, generateText } from 'ai';
import { embeddingModel } from '../lib/embedding';
import { qdrantClient } from '../lib/qdrant';
@ -15,7 +15,8 @@ import fs from 'fs/promises';
import crypto from 'crypto';
import path from 'path';
import { createOpenAI } from '@ai-sdk/openai';
import { USE_GEMINI_FILE_PARSING } from '../lib/feature_flags';
import { USE_BILLING, USE_GEMINI_FILE_PARSING } from '../lib/feature_flags';
import { authorize, BillingError, getCustomerIdForProject, logUsage } from '../lib/billing';
const FILE_PARSING_PROVIDER_API_KEY = process.env.FILE_PARSING_PROVIDER_API_KEY || process.env.OPENAI_API_KEY || '';
const FILE_PARSING_PROVIDER_BASE_URL = process.env.FILE_PARSING_PROVIDER_BASE_URL || undefined;
@ -73,7 +74,7 @@ async function retryable<T>(fn: () => Promise<T>, maxAttempts: number = 3): Prom
}
}
async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>> & { data: { type: "file_local" | "file_s3" } }): Promise<void> {
async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>> & { data: { type: "file_local" | "file_s3" } }): Promise<number> {
const logger = _logger
.child(doc._id.toString())
.child(doc.name);
@ -133,7 +134,7 @@ async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typ
// generate embeddings
logger.log("Generating embeddings");
const { embeddings } = await embedMany({
const { embeddings, usage } = await embedMany({
model: embeddingModel,
values: splits.map((split) => split.pageContent)
});
@ -168,6 +169,8 @@ async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typ
lastUpdatedAt: new Date().toISOString(),
}
});
return usage.tokens;
}
async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>>): Promise<void> {
@ -319,11 +322,42 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
logger.log(`Found ${pendingDocs.length} docs to process`);
// fetch project, user and billing data
let billingCustomerId: string | null = null;
if (USE_BILLING) {
try {
billingCustomerId = await getCustomerIdForProject(job.projectId);
} catch (e) {
logger.log("Unable to fetch billing customer id:", e);
throw new Error("Unable to fetch billing customer id");
}
}
// for each doc
for (const doc of pendingDocs) {
// authorize with billing
if (USE_BILLING && billingCustomerId) {
const authResponse = await authorize(billingCustomerId, {
type: "process_rag",
data: {},
});
if ('error' in authResponse) {
throw new BillingError(authResponse.error || "Unknown billing error")
}
}
const ldoc = doc as WithId<z.infer<typeof DataSourceDoc>> & { data: { type: "file_local" | "file_s3" } };
try {
await runProcessPipeline(logger, job, ldoc);
const usedTokens = await runProcessPipeline(logger, job, ldoc);
// log usage in billing
if (USE_BILLING && billingCustomerId) {
await logUsage(billingCustomerId, {
type: "rag_tokens",
amount: usedTokens,
});
}
} catch (e: any) {
errors = true;
logger.log("Error processing doc:", e);
@ -365,8 +399,29 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
}
}
} catch (e) {
if (e instanceof BillingError) {
logger.log("Billing error:", e.message);
await dataSourcesCollection.updateOne({
_id: job._id,
version: job.version,
}, {
$set: {
status: "error",
billingError: e.message,
lastUpdatedAt: new Date().toISOString(),
}
});
}
logger.log("Error processing job; will retry:", e);
await dataSourcesCollection.updateOne({ _id: job._id, version: job.version }, { $set: { status: "error" } });
await dataSourcesCollection.updateOne({
_id: job._id,
version: job.version,
}, {
$set: {
status: "error",
lastUpdatedAt: new Date().toISOString(),
}
});
continue;
}
@ -379,6 +434,7 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
$set: {
status: errors ? "error" : "ready",
...(errors ? { error: "There were some errors processing this job" } : {}),
lastUpdatedAt: new Date().toISOString(),
}
});
}

View file

@ -9,6 +9,8 @@ import { embeddingModel } from '../lib/embedding';
import { qdrantClient } from '../lib/qdrant';
import { PrefixLogger } from "../lib/utils";
import crypto from 'crypto';
import { USE_BILLING } from '../lib/feature_flags';
import { authorize, BillingError, getCustomerIdForProject, logUsage } from '../lib/billing';
const splitter = new RecursiveCharacterTextSplitter({
separators: ['\n\n', '\n', '. ', '.', ''],
@ -20,7 +22,7 @@ const second = 1000;
const minute = 60 * second;
const hour = 60 * minute;
async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>>): Promise<void> {
async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>>): Promise<number> {
const logger = _logger
.child(doc._id.toString())
.child(doc.name);
@ -35,7 +37,7 @@ async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typ
// generate embeddings
logger.log("Generating embeddings");
const { embeddings } = await embedMany({
const { embeddings, usage } = await embedMany({
model: embeddingModel,
values: splits.map((split) => split.pageContent)
});
@ -70,6 +72,8 @@ async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typ
lastUpdatedAt: new Date().toISOString(),
}
});
return usage.tokens;
}
async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>>): Promise<void> {
@ -220,10 +224,41 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
logger.log(`Found ${pendingDocs.length} docs to process`);
// fetch project, user and billing data
let billingCustomerId: string | null = null;
if (USE_BILLING) {
try {
billingCustomerId = await getCustomerIdForProject(job.projectId);
} catch (e) {
logger.log("Unable to fetch billing customer id:", e);
throw new Error("Unable to fetch billing customer id");
}
}
// for each doc
for (const doc of pendingDocs) {
// authorize with billing
if (USE_BILLING && billingCustomerId) {
const authResponse = await authorize(billingCustomerId, {
type: "process_rag",
data: {}
});
if ('error' in authResponse) {
throw new BillingError(authResponse.error || "Unknown billing error")
}
}
try {
await runProcessPipeline(logger, job, doc);
const usedTokens = await runProcessPipeline(logger, job, doc);
// log usage in billing
if (USE_BILLING && billingCustomerId) {
await logUsage(billingCustomerId, {
type: "rag_tokens",
amount: usedTokens,
});
}
} catch (e: any) {
errors = true;
logger.log("Error processing doc:", e);
@ -265,8 +300,29 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
}
}
} catch (e) {
if (e instanceof BillingError) {
logger.log("Billing error:", e.message);
await dataSourcesCollection.updateOne({
_id: job._id,
version: job.version,
}, {
$set: {
status: "error",
billingError: e.message,
lastUpdatedAt: new Date().toISOString(),
}
});
}
logger.log("Error processing job; will retry:", e);
await dataSourcesCollection.updateOne({ _id: job._id, version: job.version }, { $set: { status: "error" } });
await dataSourcesCollection.updateOne({
_id: job._id,
version: job.version,
}, {
$set: {
status: "error",
lastUpdatedAt: new Date().toISOString(),
}
});
continue;
}

View file

@ -10,6 +10,8 @@ import { embeddingModel } from '../lib/embedding';
import { qdrantClient } from '../lib/qdrant';
import { PrefixLogger } from "../lib/utils";
import crypto from 'crypto';
import { USE_BILLING } from '../lib/feature_flags';
import { authorize, BillingError, getCustomerIdForProject, logUsage } from '../lib/billing';
const firecrawl = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY });
@ -38,7 +40,7 @@ async function retryable<T>(fn: () => Promise<T>, maxAttempts: number = 3): Prom
}
}
async function runScrapePipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>>): Promise<void> {
async function runScrapePipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>>): Promise<number> {
const logger = _logger
.child(doc._id.toString())
.child(doc.name);
@ -66,7 +68,7 @@ async function runScrapePipeline(_logger: PrefixLogger, job: WithId<z.infer<type
// generate embeddings
logger.log("Generating embeddings");
const { embeddings } = await embedMany({
const { embeddings, usage } = await embedMany({
model: embeddingModel,
values: splits.map((split) => split.pageContent)
});
@ -101,6 +103,8 @@ async function runScrapePipeline(_logger: PrefixLogger, job: WithId<z.infer<type
lastUpdatedAt: new Date().toISOString(),
}
});
return usage.tokens;
}
async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>>): Promise<void> {
@ -252,10 +256,41 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
logger.log(`Found ${pendingDocs.length} docs to process`);
// fetch project, user and billing data
let billingCustomerId: string | null = null;
if (USE_BILLING) {
try {
billingCustomerId = await getCustomerIdForProject(job.projectId);
} catch (e) {
logger.log("Unable to fetch billing customer id:", e);
throw new Error("Unable to fetch billing customer id");
}
}
// for each doc
for (const doc of pendingDocs) {
// authorize with billing
if (USE_BILLING && billingCustomerId) {
const authResponse = await authorize(billingCustomerId, {
type: "process_rag",
data: {}
});
if ('error' in authResponse) {
throw new BillingError(authResponse.error || "Unknown billing error")
}
}
try {
await runScrapePipeline(logger, job, doc);
const usedTokens = await runScrapePipeline(logger, job, doc);
// log usage in billing
if (USE_BILLING && billingCustomerId) {
await logUsage(billingCustomerId, {
type: "rag_tokens",
amount: usedTokens,
});
}
} catch (e: any) {
errors = true;
logger.log("Error processing doc:", e);
@ -297,8 +332,29 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
}
}
} catch (e) {
if (e instanceof BillingError) {
logger.log("Billing error:", e.message);
await dataSourcesCollection.updateOne({
_id: job._id,
version: job.version,
}, {
$set: {
status: "error",
billingError: e.message,
lastUpdatedAt: new Date().toISOString(),
}
});
}
logger.log("Error processing job; will retry:", e);
await dataSourcesCollection.updateOne({ _id: job._id, version: job.version }, { $set: { status: "error" } });
await dataSourcesCollection.updateOne({
_id: job._id,
version: job.version,
}, {
$set: {
status: "error",
lastUpdatedAt: new Date().toISOString(),
}
});
continue;
}

View file

@ -0,0 +1,215 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Spinner } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { AlertCircle, CheckIcon } from "lucide-react";
import { getPrices, getCustomer, updateSubscriptionPlan } from "@/app/actions/billing_actions";
import { useEffect, useState } from "react";
import { PricesResponse, SubscriptionPlan } from "@/app/lib/types/billing_types";
import { z } from "zod";
import Link from "next/link";
interface BillingUpgradeModalProps {
isOpen: boolean;
onClose: () => void;
errorMessage: string;
}
export function BillingUpgradeModal({ isOpen, onClose, errorMessage }: BillingUpgradeModalProps) {
const [prices, setPrices] = useState<z.infer<typeof PricesResponse> | null>(null);
const [loading, setLoading] = useState(false);
const [currentPlan, setCurrentPlan] = useState<z.infer<typeof SubscriptionPlan> | null>(null);
const [subscribingPlan, setSubscribingPlan] = useState<z.infer<typeof SubscriptionPlan> | null>(null);
const [subscribeError, setSubscribeError] = useState<string | null>(null);
useEffect(() => {
let ignore = false;
async function loadData() {
try {
setLoading(true);
const [pricesResponse, customerResponse] = await Promise.all([
getPrices(),
getCustomer()
]);
if (ignore) return;
setPrices(pricesResponse);
setCurrentPlan(customerResponse.subscriptionPlan || 'free');
} catch (error) {
console.error('Failed to load data:', error);
} finally {
setLoading(false);
}
}
if (isOpen) {
loadData();
}
return () => {
ignore = true;
}
}, [isOpen]);
async function handleSubscribe(plan: z.infer<typeof SubscriptionPlan>) {
setSubscribingPlan(plan);
setSubscribeError(null);
try {
// construct return url:
// the return url is /billing/callback?redirect=<current url>
const returnUrl = new URL('/billing/callback', window.location.origin);
returnUrl.searchParams.set('redirect', window.location.href);
console.log('returnUrl', returnUrl.toString());
const url = await updateSubscriptionPlan(plan, returnUrl.toString());
window.location.href = url;
} catch (error) {
console.error('Failed to upgrade:', error);
setSubscribeError(error instanceof Error ? error.message : 'An unknown error occurred');
setSubscribingPlan(null);
}
}
const plans = [
{
name: "Starter",
plan: "starter" as const,
description: "Great for your personal projects",
features: [
"1000 playground chat requests",
"500 copilot requests"
]
},
{
name: "Pro",
plan: "pro" as const,
description: "Great for enterprise teams",
features: [
"10000 playground chat requests",
"2000 copilot requests"
],
recommended: true
}
];
const getVisiblePlans = () => {
if (!currentPlan) return [];
switch (currentPlan) {
case 'free':
return plans; // Show both starter and pro
case 'starter':
return plans.filter(p => p.plan === 'pro'); // Show only pro
case 'pro':
return []; // Show no plans
default:
return [];
}
};
const getModalTitle = () => {
if (currentPlan === 'pro') {
return "You've reached your plan limits";
}
return "Upgrade to do more with Rowboat";
};
const visiblePlans = getVisiblePlans();
return (
<Modal
isOpen={isOpen}
onOpenChange={onClose}
size="2xl"
classNames={{
base: "bg-white dark:bg-gray-900",
header: "border-b border-gray-200 dark:border-gray-800",
footer: "border-t border-gray-200 dark:border-gray-800",
}}
>
<ModalContent>
<ModalHeader className="flex gap-2 items-center">
<AlertCircle className="w-5 h-5 text-red-500" />
<span>{getModalTitle()}</span>
</ModalHeader>
<ModalBody>
<div className="space-y-6">
<div className="space-y-2">
<p className="text-gray-900 dark:text-gray-100">
{errorMessage}
</p>
</div>
{loading ? (
<div className="flex justify-center">
<Spinner size="lg" />
</div>
) : visiblePlans.length > 0 ? (
<div className={`grid grid-cols-1 ${visiblePlans.length > 1 ? 'md:grid-cols-2' : ''} gap-6`}>
{visiblePlans.map((plan) => (
<div
key={plan.plan}
className={`relative rounded-lg border p-6 ${
plan.recommended
? 'border-blue-500 bg-gray-50 dark:bg-gray-800'
: 'border-gray-200 dark:border-gray-700'
}`}
>
{plan.recommended && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="px-3 py-1 text-sm font-medium bg-blue-100 text-blue-800 rounded-full">
Recommended
</span>
</div>
)}
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">{plan.name}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">{plan.description}</p>
</div>
<div className="flex items-baseline">
<span className="text-3xl font-bold">
${((prices?.prices[plan.plan]?.monthly ?? 0) / 100)}
</span>
<span className="ml-1 text-gray-500 dark:text-gray-400">
/month
</span>
</div>
<ul className="space-y-2">
{plan.features.map((feature, index) => (
<li key={index} className="flex items-center gap-2">
<CheckIcon className="w-4 h-4 text-green-500" />
<span className="text-sm">{feature}</span>
</li>
))}
</ul>
<Button
className="w-full"
size="lg"
onClick={() => handleSubscribe(plan.plan)}
disabled={subscribingPlan !== null}
isLoading={subscribingPlan === plan.plan}
>
Subscribe
</Button>
{subscribeError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
{subscribeError}
</p>
)}
</div>
</div>
))}
</div>
) : null}
</div>
</ModalBody>
<ModalFooter>
<Link
href="/billing"
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
>
View usage
</Link>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View file

@ -33,7 +33,9 @@ export async function middleware(request: NextRequest, event: NextFetchEvent) {
return response;
}
if (request.nextUrl.pathname.startsWith('/projects')) {
if (request.nextUrl.pathname.startsWith('/projects') ||
request.nextUrl.pathname.startsWith('/billing') ||
request.nextUrl.pathname.startsWith('/onboarding')) {
// Skip auth check if USE_AUTH is not enabled
if (process.env.USE_AUTH !== 'true') {
return NextResponse.next();
@ -45,5 +47,11 @@ export async function middleware(request: NextRequest, event: NextFetchEvent) {
}
export const config = {
matcher: ['/projects/:path*', '/api/v1/:path*', '/api/widget/v1/:path*'],
matcher: [
'/projects/:path*',
'/billing/:path*',
// '/onboarding/:path*',
'/api/v1/:path*',
'/api/widget/v1/:path*',
],
};

View file

@ -33,6 +33,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^16.4.5",
"eventsource-parser": "^3.0.2",
"framer-motion": "^11.5.4",
"fuse.js": "^7.1.0",
"immer": "^10.1.1",
@ -15831,10 +15832,10 @@
"node": ">=18.0.0"
}
},
"node_modules/eventsource/node_modules/eventsource-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz",
"integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==",
"node_modules/eventsource-parser": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.2.tgz",
"integrity": "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA==",
"engines": {
"node": ">=18.0.0"
}

View file

@ -40,6 +40,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^16.4.5",
"eventsource-parser": "^3.0.2",
"framer-motion": "^11.5.4",
"fuse.js": "^7.1.0",
"immer": "^10.1.1",

View file

@ -51,6 +51,9 @@ services:
- KLAVIS_API_KEY=${KLAVIS_API_KEY}
- KLAVIS_GITHUB_CLIENT_ID=${KLAVIS_GITHUB_CLIENT_ID}
- KLAVIS_GOOGLE_CLIENT_ID=${KLAVIS_GOOGLE_CLIENT_ID}
- USE_BILLING=${USE_BILLING}
- BILLING_API_URL=${BILLING_API_URL}
- BILLING_API_KEY=${BILLING_API_KEY}
restart: unless-stopped
volumes:
- uploads:/app/uploads
@ -161,6 +164,9 @@ services:
- QDRANT_API_KEY=${QDRANT_API_KEY}
- RAG_UPLOADS_DIR=/app/uploads
- USE_GEMINI_FILE_PARSING=${USE_GEMINI_FILE_PARSING}
- USE_BILLING=${USE_BILLING}
- BILLING_API_URL=${BILLING_API_URL}
- BILLING_API_KEY=${BILLING_API_KEY}
restart: unless-stopped
volumes:
- uploads:/app/uploads
@ -181,6 +187,9 @@ services:
- FIRECRAWL_API_KEY=${FIRECRAWL_API_KEY}
- QDRANT_URL=http://qdrant:6333
- QDRANT_API_KEY=${QDRANT_API_KEY}
- USE_BILLING=${USE_BILLING}
- BILLING_API_URL=${BILLING_API_URL}
- BILLING_API_KEY=${BILLING_API_KEY}
restart: unless-stopped
rag_text_worker:
@ -198,6 +207,9 @@ services:
- REDIS_URL=redis://redis:6379
- QDRANT_URL=http://qdrant:6333
- QDRANT_API_KEY=${QDRANT_API_KEY}
- USE_BILLING=${USE_BILLING}
- BILLING_API_URL=${BILLING_API_URL}
- BILLING_API_KEY=${BILLING_API_KEY}
restart: unless-stopped
# chat_widget: