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 populate_by_name = True
class ApiRequest(BaseModel): class ApiRequest(BaseModel):
projectId: str
messages: List[UserMessage | AssistantMessage] messages: List[UserMessage | AssistantMessage]
workflow_schema: str workflow_schema: str
current_workflow_config: str current_workflow_config: str

View file

@ -1,35 +1,18 @@
'use server'; '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 { AgenticAPIChatRequest } from "../lib/types/agents_api_types";
import { WebpageCrawlResponse } from "../lib/types/tool_types"; import { WebpageCrawlResponse } from "../lib/types/tool_types";
import { webpagesCollection } from "../lib/mongodb"; import { webpagesCollection } from "../lib/mongodb";
import { z } from 'zod'; import { z } from 'zod';
import FirecrawlApp, { ScrapeResponse } from '@mendable/firecrawl-js'; import FirecrawlApp, { ScrapeResponse } from '@mendable/firecrawl-js';
import { apiV1 } from "rowboat-shared"; import { getAgenticResponseStreamId } from "../lib/utils";
import { Claims, getSession } from "@auth0/nextjs-auth0";
import { getAgenticApiResponse, getAgenticResponseStreamId } from "../lib/utils";
import { check_query_limit } from "../lib/rate_limiting"; import { check_query_limit } from "../lib/rate_limiting";
import { QueryLimitError } from "../lib/client_utils"; import { QueryLimitError } from "../lib/client_utils";
import { projectAuthCheck } from "./project_actions"; 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 || '' }); 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>> { export async function scrapeWebpage(url: string): Promise<z.infer<typeof WebpageCrawlResponse>> {
const page = await webpagesCollection.findOne({ const page = await webpagesCollection.findOne({
"_id": url, "_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<{ export async function getAssistantResponseStreamId(request: z.infer<typeof AgenticAPIChatRequest>): Promise<z.infer<typeof AgenticAPIInitStreamResponse> | { billingError: string }> {
messages: z.infer<typeof apiV1.ChatMessage>[],
state: unknown,
rawRequest: unknown,
rawResponse: unknown,
}> {
await projectAuthCheck(request.projectId); await projectAuthCheck(request.projectId);
if (!await check_query_limit(request.projectId)) { if (!await check_query_limit(request.projectId)) {
throw new QueryLimitError(); throw new QueryLimitError();
} }
const response = await getAgenticApiResponse(request); // Check billing authorization
return { const agentModels = request.agents.reduce((acc, agent) => {
messages: convertFromAgenticAPIChatMessages(response.messages), acc.push(agent.model);
state: response.state, return acc;
rawRequest: request, }, [] as string[]);
rawResponse: response.rawAPIResponse, const { success, error } = await authorizeUserAction({
}; type: 'agent_response',
} data: {
agentModels,
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)) { if (!success) {
throw new QueryLimitError(); return { billingError: error || 'Billing error' };
} }
const response = await getAgenticResponseStreamId(request); 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 { redisClient } from "../lib/redis";
import { fetchProjectMcpTools } from "../lib/project_tools"; import { fetchProjectMcpTools } from "../lib/project_tools";
import { mergeProjectTools } from "../lib/types/project_types"; import { mergeProjectTools } from "../lib/types/project_types";
import { authorizeUserAction, logUsage } from "./billing_actions";
import { USE_BILLING } from "../lib/feature_flags";
export async function getCopilotResponse( export async function getCopilotResponse(
projectId: string, projectId: string,
@ -28,12 +30,21 @@ export async function getCopilotResponse(
message: z.infer<typeof CopilotAssistantMessage>; message: z.infer<typeof CopilotAssistantMessage>;
rawRequest: unknown; rawRequest: unknown;
rawResponse: unknown; rawResponse: unknown;
}> { } | { billingError: string }> {
await projectAuthCheck(projectId); await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) { if (!await check_query_limit(projectId)) {
throw new QueryLimitError(); 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 // Get MCP tools from project and merge with workflow tools
const mcpTools = await fetchProjectMcpTools(projectId); const mcpTools = await fetchProjectMcpTools(projectId);
@ -45,6 +56,7 @@ export async function getCopilotResponse(
// prepare request // prepare request
const request: z.infer<typeof CopilotAPIRequest> = { const request: z.infer<typeof CopilotAPIRequest> = {
projectId: projectId,
messages: messages.map(convertToCopilotApiMessage), messages: messages.map(convertToCopilotApiMessage),
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)), workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
current_workflow_config: JSON.stringify(copilotWorkflow), current_workflow_config: JSON.stringify(copilotWorkflow),
@ -132,12 +144,25 @@ export async function getCopilotResponseStream(
dataSources?: z.infer<typeof DataSource>[] dataSources?: z.infer<typeof DataSource>[]
): Promise<{ ): Promise<{
streamId: string; streamId: string;
}> { } | { billingError: string }> {
await projectAuthCheck(projectId); await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) { if (!await check_query_limit(projectId)) {
throw new QueryLimitError(); 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 // Get MCP tools from project and merge with workflow tools
const mcpTools = await fetchProjectMcpTools(projectId); const mcpTools = await fetchProjectMcpTools(projectId);
@ -149,6 +174,7 @@ export async function getCopilotResponseStream(
// prepare request // prepare request
const request: z.infer<typeof CopilotAPIRequest> = { const request: z.infer<typeof CopilotAPIRequest> = {
projectId: projectId,
messages: messages.map(convertToCopilotApiMessage), messages: messages.map(convertToCopilotApiMessage),
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)), workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
current_workflow_config: JSON.stringify(copilotWorkflow), current_workflow_config: JSON.stringify(copilotWorkflow),
@ -177,12 +203,21 @@ export async function getCopilotAgentInstructions(
messages: z.infer<typeof CopilotMessage>[], messages: z.infer<typeof CopilotMessage>[],
current_workflow_config: z.infer<typeof Workflow>, current_workflow_config: z.infer<typeof Workflow>,
agentName: string, agentName: string,
): Promise<string> { ): Promise<string | { billingError: string }> {
await projectAuthCheck(projectId); await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) { if (!await check_query_limit(projectId)) {
throw new QueryLimitError(); 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 // Get MCP tools from project and merge with workflow tools
const mcpTools = await fetchProjectMcpTools(projectId); const mcpTools = await fetchProjectMcpTools(projectId);
@ -194,6 +229,7 @@ export async function getCopilotAgentInstructions(
// prepare request // prepare request
const request: z.infer<typeof CopilotAPIRequest> = { const request: z.infer<typeof CopilotAPIRequest> = {
projectId: projectId,
messages: messages.map(convertToCopilotApiMessage), messages: messages.map(convertToCopilotApiMessage),
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)), workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
current_workflow_config: JSON.stringify(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}`); 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 response
return agent_instructions; return agent_instructions;
} }

View file

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

View file

@ -7,6 +7,8 @@ import { projectsCollection } from '../lib/mongodb';
import { fetchMcpTools, toggleMcpTool } from './mcp_actions'; import { fetchMcpTools, toggleMcpTool } from './mcp_actions';
import { fetchMcpToolsForServer } from './mcp_actions'; import { fetchMcpToolsForServer } from './mcp_actions';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { authorizeUserAction } from './billing_actions';
import { redisClient } from '../lib/redis';
type McpServerType = z.infer<typeof MCPServer>; type McpServerType = z.infer<typeof MCPServer>;
type McpToolType = z.infer<typeof McpTool>; type McpToolType = z.infer<typeof McpTool>;
@ -542,13 +544,34 @@ export async function enableServer(
serverName: string, serverName: string,
projectId: string, projectId: string,
enabled: boolean enabled: boolean
): Promise<CreateServerInstanceResponse | {}> { ): Promise<CreateServerInstanceResponse | {} | { billingError: string }> {
try { try {
await projectAuthCheck(projectId); await projectAuthCheck(projectId);
console.log('[Klavis API] Toggle server request:', { serverName, projectId, enabled }); console.log('[Klavis API] Toggle server request:', { serverName, projectId, enabled });
if (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}...`); console.log(`[Klavis API] Creating server instance for ${serverName}...`);
const result = await createMcpServerInstance(serverName, projectId, "Rowboat"); const result = await createMcpServerInstance(serverName, projectId, "Rowboat");
console.log('[Klavis API] Server instance created:', { 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); console.error(`[Klavis API] Tool enrichment failed for ${serverName}:`, enrichError);
} }
// remove key from redis
await redisClient.del(`klavis_enabling_server:${projectId}`);
return result; return result;
} else { } else {
// Get active instances to find the one to delete // Get active instances to find the one to delete

View file

@ -6,12 +6,13 @@ import { z } from 'zod';
import crypto from 'crypto'; import crypto from 'crypto';
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { templates } from "../lib/project_templates"; import { templates } from "../lib/project_templates";
import { authCheck } from "./actions"; import { authCheck } from "./auth_actions";
import { WithStringId } from "../lib/types/types"; import { User, WithStringId } from "../lib/types/types";
import { ApiKey } from "../lib/types/project_types"; import { ApiKey } from "../lib/types/project_types";
import { Project } from "../lib/types/project_types"; import { Project } from "../lib/types/project_types";
import { USE_AUTH } from "../lib/feature_flags"; import { USE_AUTH } from "../lib/feature_flags";
import { deleteMcpServerInstance, listActiveServerInstances } from "./klavis_actions"; import { deleteMcpServerInstance, listActiveServerInstances } from "./klavis_actions";
import { authorizeUserAction } from "./billing_actions";
export async function projectAuthCheck(projectId: string) { export async function projectAuthCheck(projectId: string) {
if (!USE_AUTH) { if (!USE_AUTH) {
@ -20,23 +21,27 @@ export async function projectAuthCheck(projectId: string) {
const user = await authCheck(); const user = await authCheck();
const membership = await projectMembersCollection.findOne({ const membership = await projectMembersCollection.findOne({
projectId, projectId,
userId: user.sub, userId: user._id,
}); });
if (!membership) { if (!membership) {
throw new Error('User not a member of project'); throw new Error('User not a member of project');
} }
} }
async function createBaseProject(name: string, user: any) { async function createBaseProject(name: string, user: WithStringId<z.infer<typeof User>>): Promise<{ id: string } | { billingError: string }> {
// Check project limits // fetch project count for this user
const projectsLimit = Number(process.env.MAX_PROJECTS_PER_USER) || 0; const projectCount = await projectsCollection.countDocuments({
if (projectsLimit > 0) { createdByUserId: user._id,
const count = await projectsCollection.countDocuments({ });
createdByUserId: user.sub, // billing limit check
}); const authResponse = await authorizeUserAction({
if (count >= projectsLimit) { type: 'create_project',
throw new Error('You have reached your project limit. Please upgrade your plan.'); data: {
} existingProjectCount: projectCount,
},
});
if (!authResponse.success) {
return { billingError: authResponse.error || 'Billing error' };
} }
const projectId = crypto.randomUUID(); const projectId = crypto.randomUUID();
@ -49,7 +54,7 @@ async function createBaseProject(name: string, user: any) {
name, name,
createdAt: (new Date()).toISOString(), createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(), lastUpdatedAt: (new Date()).toISOString(),
createdByUserId: user.sub, createdByUserId: user._id,
chatClientId, chatClientId,
secret, secret,
nextWorkflowNumber: 1, nextWorkflowNumber: 1,
@ -58,7 +63,7 @@ async function createBaseProject(name: string, user: any) {
// Add user to project // Add user to project
await projectMembersCollection.insertOne({ await projectMembersCollection.insertOne({
userId: user.sub, userId: user._id,
projectId: projectId, projectId: projectId,
createdAt: (new Date()).toISOString(), createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(), lastUpdatedAt: (new Date()).toISOString(),
@ -67,15 +72,20 @@ async function createBaseProject(name: string, user: any) {
// Add first api key // Add first api key
await createApiKey(projectId); 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 user = await authCheck();
const name = formData.get('name') as string; const name = formData.get('name') as string;
const templateKey = formData.get('template') 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 // Add first workflow version with specified template
const { agents, prompts, tools, startAgent } = templates[templateKey]; const { agents, prompts, tools, startAgent } = templates[templateKey];
@ -90,7 +100,7 @@ export async function createProject(formData: FormData) {
name: `Version 1`, name: `Version 1`,
}); });
redirect(`/projects/${projectId}/workflow`); return { id: projectId };
} }
export async function getProjectConfig(projectId: string): Promise<WithStringId<z.infer<typeof Project>>> { 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>[]> { export async function listProjects(): Promise<z.infer<typeof Project>[]> {
const user = await authCheck(); const user = await authCheck();
const memberships = await projectMembersCollection.find({ const memberships = await projectMembersCollection.find({
userId: user.sub, userId: user._id,
}).toArray(); }).toArray();
const projectIds = memberships.map((m) => m.projectId); const projectIds = memberships.map((m) => m.projectId);
const projects = await projectsCollection.find({ const projects = await projectsCollection.find({
@ -271,11 +281,16 @@ export async function deleteProject(projectId: string) {
redirect('/projects'); redirect('/projects');
} }
export async function createProjectFromPrompt(formData: FormData) { export async function createProjectFromPrompt(formData: FormData): Promise<{ id: string } | { billingError: string }> {
const user = await authCheck(); const user = await authCheck();
const name = formData.get('name') as string; 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 // Add first workflow version with default template
const { agents, prompts, tools, startAgent } = templates['default']; 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 { redisClient } from "@/app/lib/redis";
import { CopilotAPIRequest } from "@/app/lib/types/copilot_types";
export async function GET(request: Request, { params }: { params: { streamId: string } }) { export async function GET(request: Request, { params }: { params: { streamId: string } }) {
// get the payload from redis // 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 }); 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. // Fetch the upstream SSE stream.
const upstreamResponse = await fetch(`${process.env.COPILOT_API_URL}/chat_stream`, { const upstreamResponse = await fetch(`${process.env.COPILOT_API_URL}/chat_stream`, {
method: 'POST', method: 'POST',
@ -36,6 +48,18 @@ export async function GET(request: Request, { params }: { params: { streamId: st
controller.enqueue(value); controller.enqueue(value);
} }
controller.close(); 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) { } catch (error) {
controller.error(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 { 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 } }) { export async function GET(request: Request, { params }: { params: { streamId: string } }) {
// get the payload from redis // 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 }); 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. // Fetch the upstream SSE stream.
const upstreamResponse = await fetch(`${process.env.AGENTS_API_URL}/chat_stream`, { const upstreamResponse = await fetch(`${process.env.AGENTS_API_URL}/chat_stream`, {
method: 'POST', method: 'POST',
@ -24,19 +37,63 @@ export async function GET(request: Request, { params }: { params: { streamId: st
} }
const reader = upstreamResponse.body.getReader(); const reader = upstreamResponse.body.getReader();
const encoder = new TextEncoder();
const stream = new ReadableStream({ const stream = new ReadableStream({
async start(controller) { 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 { try {
// Read from the upstream stream continuously.
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; 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(); controller.close();
if (USE_BILLING && billingCustomerId) {
await logUsage(billingCustomerId, {
type: "agent_messages",
amount: messageCount,
})
}
} catch (error) { } catch (error) {
console.error('Error processing stream:', error);
controller.error(error); controller.error(error);
} }
}, },

View file

@ -10,6 +10,8 @@ import { check_query_limit } from "../../../../lib/rate_limiting";
import { PrefixLogger } from "../../../../lib/utils"; import { PrefixLogger } from "../../../../lib/utils";
import { TestProfile } from "@/app/lib/types/testing_types"; import { TestProfile } from "@/app/lib/types/testing_types";
import { fetchProjectMcpTools } from "@/app/lib/project_tools"; 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 // get next turn / agent response
export async function POST( export async function POST(
@ -29,6 +31,12 @@ export async function POST(
} }
return await authCheck(projectId, req, async () => { 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 // parse and validate the request body
let body; let body;
try { try {
@ -74,6 +82,23 @@ export async function POST(
return Response.json({ error: "Workflow not found" }, { status: 404 }); 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 // if test profile is provided in the request, use it
let testProfile: z.infer<typeof TestProfile> | null = null; let testProfile: z.infer<typeof TestProfile> | null = null;
if (result.data.testProfileId) { if (result.data.testProfileId) {
@ -112,6 +137,15 @@ export async function POST(
const newMessages = convertFromAgenticApiToApiMessages(agenticMessages); const newMessages = convertFromAgenticApiToApiMessages(agenticMessages);
const newState = state; 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> = { const responseBody: z.infer<typeof ApiResponse> = {
messages: newMessages, messages: newMessages,
state: newState, state: newState,

View file

@ -12,6 +12,8 @@ import { getAgenticApiResponse } from "../../../../../../lib/utils";
import { check_query_limit } from "../../../../../../lib/rate_limiting"; import { check_query_limit } from "../../../../../../lib/rate_limiting";
import { PrefixLogger } from "../../../../../../lib/utils"; import { PrefixLogger } from "../../../../../../lib/utils";
import { fetchProjectMcpTools } from "@/app/lib/project_tools"; 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 // get next turn / agent response
export async function POST( export async function POST(
@ -24,6 +26,12 @@ export async function POST(
logger.log(`Processing turn request for chat ${chatId}`); 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 // check query limit
if (!await check_query_limit(session.projectId)) { if (!await check_query_limit(session.projectId)) {
logger.log(`Query limit exceeded for project ${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"); 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 // get assistant response
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow, projectTools); const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow, projectTools);
const unsavedMessages: z.infer<typeof apiV1.ChatMessage>[] = [userMessage]; const unsavedMessages: z.infer<typeof apiV1.ChatMessage>[] = [userMessage];
@ -132,6 +157,15 @@ export async function POST(
await chatMessagesCollection.insertMany(unsavedMessages); await chatMessagesCollection.insertMany(unsavedMessages);
await chatsCollection.updateOne({ _id: new ObjectId(chatId) }, { $set: { agenticState: state } }); 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`); logger.log(`Turn processing completed successfully`);
const lastMessage = unsavedMessages[unsavedMessages.length - 1] as WithId<z.infer<typeof apiV1.ChatMessage>>; const lastMessage = unsavedMessages[unsavedMessages.length - 1] as WithId<z.infer<typeof apiV1.ChatMessage>>;
return Response.json({ 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 { useUser } from '@auth0/nextjs-auth0/client';
import { Avatar, Dropdown, DropdownItem, DropdownSection, DropdownTrigger, DropdownMenu } from "@heroui/react"; import { Avatar, Dropdown, DropdownItem, DropdownSection, DropdownTrigger, DropdownMenu } from "@heroui/react";
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link';
export function UserButton() { export function UserButton({ useBilling }: { useBilling?: boolean }) {
const router = useRouter(); const router = useRouter();
const { user } = useUser(); const { user } = useUser();
if (!user) { if (!user) {
@ -25,9 +26,19 @@ export function UserButton() {
if (key === 'logout') { if (key === 'logout') {
router.push('/api/auth/logout'); router.push('/api/auth/logout');
} }
if (key === 'billing') {
router.push('/billing');
}
}} }}
> >
<DropdownSection title={name}> <DropdownSection title={name}>
{useBilling ? (
<DropdownItem key="billing">
Billing
</DropdownItem>
) : (
<></>
)}
<DropdownItem key="logout"> <DropdownItem key="logout">
Logout Logout
</DropdownItem> </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_AUTH = process.env.USE_AUTH === 'true';
export const USE_RAG_S3_UPLOADS = process.env.USE_RAG_S3_UPLOADS === '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_GEMINI_FILE_PARSING = process.env.USE_GEMINI_FILE_PARSING === 'true';
export const USE_BILLING = process.env.USE_BILLING === 'true';
// Hardcoded flags // Hardcoded flags
export const USE_MULTIPLE_PROJECTS = true; export const USE_MULTIPLE_PROJECTS = true;

View file

@ -1,5 +1,5 @@
import { MongoClient } from "mongodb"; import { MongoClient } from "mongodb";
import { Webpage } from "./types/types"; import { User, Webpage } from "./types/types";
import { Workflow } from "./types/workflow_types"; import { Workflow } from "./types/workflow_types";
import { ApiKey } from "./types/project_types"; import { ApiKey } from "./types/project_types";
import { ProjectMember } 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 chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages"); 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 twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("twilio_configs");
export const usersCollection = db.collection<z.infer<typeof User>>("users");
// Create indexes // Create indexes
twilioConfigsCollection.createIndexes([ 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({ export const CopilotAPIRequest = z.object({
projectId: z.string(),
messages: z.array(CopilotApiMessage), messages: z.array(CopilotApiMessage),
workflow_schema: z.string(), workflow_schema: z.string(),
current_workflow_config: z.string(), current_workflow_config: z.string(),

View file

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

View file

@ -76,6 +76,15 @@ export const McpServerResponse = z.object({
error: z.string().nullable(), 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({ export const PlaygroundChat = z.object({
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
projectId: z.string(), 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 { Metadata } from "next";
import App from "./app"; import App from "./app";
import { USE_CHAT_WIDGET } from "@/app/lib/feature_flags"; import { USE_CHAT_WIDGET } from "@/app/lib/feature_flags";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Project config", title: "Project config",
}; };
export default function Page({ export default async function Page({
params, params,
}: { }: {
params: { params: {
projectId: string; projectId: string;
}; };
}) { }) {
await requireActiveBillingSubscription();
return <App return <App
projectId={params.projectId} projectId={params.projectId}
useChatWidget={USE_CHAT_WIDGET} useChatWidget={USE_CHAT_WIDGET}

View file

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

View file

@ -16,9 +16,12 @@ interface UseCopilotResult {
streamingResponse: string; streamingResponse: string;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
clearError: () => void;
billingError: string | null;
clearBillingError: () => void;
start: ( start: (
messages: z.infer<typeof CopilotMessage>[], messages: z.infer<typeof CopilotMessage>[],
onDone: (finalResponse: string) => void onDone: (finalResponse: string) => void,
) => void; ) => void;
cancel: () => void; cancel: () => void;
} }
@ -27,13 +30,21 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
const [streamingResponse, setStreamingResponse] = useState(''); const [streamingResponse, setStreamingResponse] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [billingError, setBillingError] = useState<string | null>(null);
const cancelRef = useRef<() => void>(() => { }); const cancelRef = useRef<() => void>(() => { });
const responseRef = useRef(''); const responseRef = useRef('');
function clearError() {
setError(null);
}
function clearBillingError() {
setBillingError(null);
}
const start = useCallback(async ( const start = useCallback(async (
messages: z.infer<typeof CopilotMessage>[], messages: z.infer<typeof CopilotMessage>[],
onDone: (finalResponse: string) => void onDone: (finalResponse: string) => void,
) => { ) => {
if (!messages.length || messages.at(-1)?.role !== 'user') return; if (!messages.length || messages.at(-1)?.role !== 'user') return;
@ -44,6 +55,15 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
try { try {
const res = await getCopilotResponseStream(projectId, messages, workflow, context || null, dataSources); 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}`); const eventSource = new EventSource(`/api/copilot-stream-response/${res.streamId}`);
eventSource.onmessage = (event) => { eventSource.onmessage = (event) => {
@ -84,6 +104,9 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
streamingResponse, streamingResponse,
loading, loading,
error, error,
clearError,
billingError,
clearBillingError,
start, start,
cancel, 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 { WorkflowPrompt, WorkflowAgent, Workflow, WorkflowTool } from "../../../lib/types/workflow_types";
import { DataSource } from "../../../lib/types/datasource_types"; import { DataSource } from "../../../lib/types/datasource_types";
import { z } from "zod"; 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 { useState, useEffect, useRef } from "react";
import { usePreviewModal } from "../workflow/preview-modal"; 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 { PreviewModalProvider } from "../workflow/preview-modal";
import { CopilotMessage } from "@/app/lib/types/copilot_types"; import { CopilotMessage } from "@/app/lib/types/copilot_types";
import { getCopilotAgentInstructions } from "@/app/actions/copilot_actions"; 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 { Input } from "@/components/ui/input";
import { Info } from "lucide-react"; import { Info } from "lucide-react";
import { useCopilot } from "../copilot/use-copilot"; 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 // Common section header styles
const sectionHeaderStyles = "text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"; const sectionHeaderStyles = "text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400";
@ -47,6 +49,7 @@ export function AgentConfig({
handleClose, handleClose,
useRag, useRag,
triggerCopilotChat, triggerCopilotChat,
eligibleModels,
}: { }: {
projectId: string, projectId: string,
workflow: z.infer<typeof Workflow>, workflow: z.infer<typeof Workflow>,
@ -61,6 +64,7 @@ export function AgentConfig({
handleClose: () => void, handleClose: () => void,
useRag: boolean, useRag: boolean,
triggerCopilotChat: (message: string) => void, triggerCopilotChat: (message: string) => void,
eligibleModels: z.infer<typeof ModelsResponse.shape.agentModels> | "*",
}) { }) {
const [isAdvancedConfigOpen, setIsAdvancedConfigOpen] = useState(false); const [isAdvancedConfigOpen, setIsAdvancedConfigOpen] = useState(false);
const [showGenerateModal, setShowGenerateModal] = useState(false); const [showGenerateModal, setShowGenerateModal] = useState(false);
@ -72,7 +76,8 @@ export function AgentConfig({
const [activeTab, setActiveTab] = useState<TabType>('instructions'); const [activeTab, setActiveTab] = useState<TabType>('instructions');
const [showRagCta, setShowRagCta] = useState(false); const [showRagCta, setShowRagCta] = useState(false);
const [previousRagSources, setPreviousRagSources] = useState<string[]>([]); const [previousRagSources, setPreviousRagSources] = useState<string[]>([]);
const [billingError, setBillingError] = useState<string | null>(null);
const { const {
start: startCopilotChat, start: startCopilotChat,
} = useCopilot({ } = useCopilot({
@ -490,8 +495,8 @@ export function AgentConfig({
<label className={sectionHeaderStyles}> <label className={sectionHeaderStyles}>
Model Model
</label> </label>
<div className="relative ml-2 group"> {eligibleModels === "*" && <div className="relative ml-2 group">
<Info <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" 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"
/> />
<div className="absolute bottom-full left-0 mb-2 p-3 w-80 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-xs invisible group-hover:visible z-50"> <div className="absolute bottom-full left-0 mb-2 p-3 w-80 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-xs invisible group-hover:visible z-50">
@ -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. 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 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> </div>
<div className="w-full"> <div className="w-full">
<Input {eligibleModels === "*" && <Input
value={agent.model} value={agent.model}
onChange={(e) => handleUpdate({ onChange={(e) => handleUpdate({
...agent, ...agent,
model: e.target.value as z.infer<typeof WorkflowAgent>['model'] model: e.target.value as z.infer<typeof WorkflowAgent>['model']
})} })}
className="w-full max-w-64" 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>
</div> </div>
@ -764,6 +819,12 @@ export function AgentConfig({
}} }}
/> />
</PreviewModalProvider> </PreviewModalProvider>
<BillingUpgradeModal
isOpen={!!billingError}
onClose={() => setBillingError(null)}
errorMessage={billingError || ''}
/>
</div> </div>
</Panel> </Panel>
); );
@ -789,6 +850,7 @@ function GenerateInstructionsModal({
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [billingError, setBillingError] = useState<string | null>(null);
const { showPreview } = usePreviewModal(); const { showPreview } = usePreviewModal();
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -797,6 +859,7 @@ function GenerateInstructionsModal({
setPrompt(""); setPrompt("");
setIsLoading(false); setIsLoading(false);
setError(null); setError(null);
setBillingError(null);
textareaRef.current?.focus(); textareaRef.current?.focus();
} }
}, [isOpen]); }, [isOpen]);
@ -804,6 +867,7 @@ function GenerateInstructionsModal({
const handleGenerate = async () => { const handleGenerate = async () => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
setBillingError(null);
try { try {
const msgs: z.infer<typeof CopilotMessage>[] = [ const msgs: z.infer<typeof CopilotMessage>[] = [
{ {
@ -812,6 +876,12 @@ function GenerateInstructionsModal({
}, },
]; ];
const newInstructions = await getCopilotAgentInstructions(projectId, msgs, workflow, agent.name); 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(); onClose();
@ -840,59 +910,66 @@ function GenerateInstructionsModal({
}; };
return ( return (
<Modal isOpen={isOpen} onClose={onClose} size="lg"> <>
<ModalContent> <Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalHeader>Generate Instructions</ModalHeader> <ModalContent>
<ModalBody> <ModalHeader>Generate Instructions</ModalHeader>
<div className="flex flex-col gap-4"> <ModalBody>
{error && ( <div className="flex flex-col gap-4">
<div className="p-2 bg-red-50 border border-red-200 rounded-lg flex gap-2 justify-between items-center text-sm"> {error && (
<p className="text-red-600">{error}</p> <div className="p-2 bg-red-50 border border-red-200 rounded-lg flex gap-2 justify-between items-center text-sm">
<CustomButton <p className="text-red-600">{error}</p>
variant="primary" <CustomButton
size="sm" variant="primary"
onClick={() => { size="sm"
setError(null); onClick={() => {
handleGenerate(); setError(null);
}} handleGenerate();
> }}
Retry >
</CustomButton> Retry
</div> </CustomButton>
)} </div>
<Textarea )}
ref={textareaRef} <Textarea
value={prompt} ref={textareaRef}
onChange={(e) => setPrompt(e.target.value)} value={prompt}
onKeyDown={handleKeyDown} 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} disabled={isLoading}
placeholder="e.g., This agent should help users analyze their data and provide insights..." >
className={textareaStyles} Cancel
autoResize </CustomButton>
/> <CustomButton
</div> variant="primary"
</ModalBody> size="sm"
<ModalFooter> onClick={handleGenerate}
<CustomButton disabled={!prompt.trim() || isLoading}
variant="secondary" isLoading={isLoading}
size="sm" >
onClick={onClose} Generate
disabled={isLoading} </CustomButton>
> </ModalFooter>
Cancel </ModalContent>
</CustomButton> </Modal>
<CustomButton <BillingUpgradeModal
variant="primary" isOpen={!!billingError}
size="sm" onClose={() => setBillingError(null)}
onClick={handleGenerate} errorMessage={billingError || ''}
disabled={!prompt.trim() || isLoading} />
isLoading={isLoading} </>
>
Generate
</CustomButton>
</ModalFooter>
</ModalContent>
</Modal>
); );
} }

View file

@ -1,9 +1,11 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
export default function Page({ export default async function Page({
params params
}: { }: {
params: { projectId: string } params: { projectId: string }
}) { }) {
await requireActiveBillingSubscription();
redirect(`/projects/${params.projectId}/workflow`); 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 { WithStringId } from "@/app/lib/types/types";
import { ProfileContextBox } from "./profile-context-box"; import { ProfileContextBox } from "./profile-context-box";
import { USE_TESTING_FEATURE } from "@/app/lib/feature_flags"; import { USE_TESTING_FEATURE } from "@/app/lib/feature_flags";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
export function Chat({ export function Chat({
chat, chat,
@ -51,6 +52,7 @@ export function Chat({
last_agent_name: workflow.startAgent, last_agent_name: workflow.startAgent,
}); });
const [fetchResponseError, setFetchResponseError] = useState<string | null>(null); const [fetchResponseError, setFetchResponseError] = useState<string | null>(null);
const [billingError, setBillingError] = useState<string | null>(null);
const [lastAgenticRequest, setLastAgenticRequest] = useState<unknown | null>(null); const [lastAgenticRequest, setLastAgenticRequest] = useState<unknown | null>(null);
const [lastAgenticResponse, setLastAgenticResponse] = useState<unknown | null>(null); const [lastAgenticResponse, setLastAgenticResponse] = useState<unknown | null>(null);
const [optimisticMessages, setOptimisticMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>(chat.messages); const [optimisticMessages, setOptimisticMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>(chat.messages);
@ -122,7 +124,7 @@ export function Chat({
async function process() { async function process() {
setLoadingAssistantResponse(true); setLoadingAssistantResponse(true);
setFetchResponseError(null); setFetchResponseError(null);
// Reset request/response state before making new request // Reset request/response state before making new request
setLastAgenticRequest(null); setLastAgenticRequest(null);
setLastAgenticResponse(null); setLastAgenticResponse(null);
@ -150,7 +152,7 @@ export function Chat({
toolWebhookUrl: toolWebhookUrl, toolWebhookUrl: toolWebhookUrl,
testProfile: testProfile ?? undefined, testProfile: testProfile ?? undefined,
}; };
// Store the full request object // Store the full request object
setLastAgenticRequest(request); setLastAgenticRequest(request);
@ -160,6 +162,13 @@ export function Chat({
if (ignore) { if (ignore) {
return; 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; streamId = response.streamId;
} catch (err) { } catch (err) {
if (!ignore) { if (!ignore) {
@ -199,13 +208,13 @@ export function Chat({
const parsed = JSON.parse(event.data); const parsed = JSON.parse(event.data);
setAgenticState(parsed.state); setAgenticState(parsed.state);
// Combine state and collected messages in the response // Combine state and collected messages in the response
setLastAgenticResponse({ setLastAgenticResponse({
...parsed, ...parsed,
messages: msgs messages: msgs
}); });
setMessages([...messages, ...msgs]); setMessages([...messages, ...msgs]);
setLoadingAssistantResponse(false); setLoadingAssistantResponse(false);
}); });
@ -246,6 +255,7 @@ export function Chat({
return; return;
} }
console.log(`executing response process: fetchresponseerr: ${fetchResponseError}`);
process(); process();
return () => { return () => {
@ -277,7 +287,7 @@ export function Chat({
/> />
)} )}
</div> </div>
<div className="flex-1 overflow-auto pr-1 <div className="flex-1 overflow-auto pr-1
[&::-webkit-scrollbar]{width:4px} [&::-webkit-scrollbar]{width:4px}
[&::-webkit-scrollbar-track]{background:transparent} [&::-webkit-scrollbar-track]{background:transparent}
@ -307,13 +317,16 @@ export function Chat({
<Button <Button
size="sm" size="sm"
color="danger" color="danger"
onPress={() => setFetchResponseError(null)} onPress={() => {
setFetchResponseError(null);
setBillingError(null);
}}
> >
Retry Retry
</Button> </Button>
</div> </div>
)} )}
<ComposeBoxPlayground <ComposeBoxPlayground
handleUserMessage={handleUserMessage} handleUserMessage={handleUserMessage}
messages={messages.filter(msg => msg.content !== undefined) as any} messages={messages.filter(msg => msg.content !== undefined) as any}
@ -322,5 +335,12 @@ export function Chat({
onFocus={() => setIsLastInteracted(true)} onFocus={() => setIsLastInteracted(true)}
/> />
</div> </div>
<BillingUpgradeModal
isOpen={!!billingError}
onClose={() => setBillingError(null)}
errorMessage={billingError || ''}
/>
</div>; </div>;
} }

View file

@ -1,4 +1,5 @@
import { SourcePage } from "./source-page"; import { SourcePage } from "./source-page";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
export default async function Page({ export default async function Page({
params, params,
@ -8,5 +9,6 @@ export default async function Page({
sourceId: string sourceId: string
} }
}) { }) {
await requireActiveBillingSubscription();
return <SourcePage projectId={params.projectId} sourceId={params.sourceId} />; 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 Link from "next/link";
import { BackIcon } from "../../../../lib/components/icons"; import { BackIcon } from "../../../../lib/components/icons";
import { Textarea } from "@/components/ui/textarea"; 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({ export function SourcePage({
sourceId, sourceId,
@ -29,11 +31,15 @@ export function SourcePage({
const [source, setSource] = useState<WithStringId<z.infer<typeof DataSource>> | null>(null); const [source, setSource] = useState<WithStringId<z.infer<typeof DataSource>> | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [showSaveSuccess, setShowSaveSuccess] = useState(false); const [showSaveSuccess, setShowSaveSuccess] = useState(false);
const [billingError, setBillingError] = useState<string | null>(null);
async function handleReload() { async function handleReload() {
setIsLoading(true); setIsLoading(true);
const updatedSource = await getDataSource(projectId, sourceId); const updatedSource = await getDataSource(projectId, sourceId);
setSource(updatedSource); setSource(updatedSource);
if ("billingError" in updatedSource && updatedSource.billingError) {
setBillingError(updatedSource.billingError);
}
setIsLoading(false); setIsLoading(false);
} }
@ -45,6 +51,9 @@ export function SourcePage({
const source = await getDataSource(projectId, sourceId); const source = await getDataSource(projectId, sourceId);
if (!ignore) { if (!ignore) {
setSource(source); setSource(source);
if ("billingError" in source && source.billingError) {
setBillingError(source.billingError);
}
setIsLoading(false); setIsLoading(false);
} }
} }
@ -74,6 +83,9 @@ export function SourcePage({
const updatedSource = await getDataSource(projectId, sourceId); const updatedSource = await getDataSource(projectId, sourceId);
if (!ignore) { if (!ignore) {
setSource(updatedSource); setSource(updatedSource);
if ("billingError" in updatedSource && updatedSource.billingError) {
setBillingError(updatedSource.billingError);
}
timeout = setTimeout(refresh, 15 * 1000); timeout = setTimeout(refresh, 15 * 1000);
} }
} }
@ -101,7 +113,7 @@ export function SourcePage({
<div className="h-full overflow-auto px-4 py-4"> <div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[768px] mx-auto space-y-6"> <div className="max-w-[768px] mx-auto space-y-6">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Link <Link
href={`/projects/${projectId}/sources`} href={`/projects/${projectId}/sources`}
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100" className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
> >
@ -117,14 +129,14 @@ export function SourcePage({
<SectionRow> <SectionRow>
<SectionLabel>Toggle</SectionLabel> <SectionLabel>Toggle</SectionLabel>
<SectionContent> <SectionContent>
<ToggleSource <ToggleSource
projectId={projectId} projectId={projectId}
sourceId={sourceId} sourceId={sourceId}
active={source.active} active={source.active}
/> />
</SectionContent> </SectionContent>
</SectionRow> </SectionRow>
<SectionRow> <SectionRow>
<SectionLabel>Name</SectionLabel> <SectionLabel>Name</SectionLabel>
<SectionContent> <SectionContent>
@ -206,33 +218,46 @@ export function SourcePage({
<SectionLabel>Status</SectionLabel> <SectionLabel>Status</SectionLabel>
<SectionContent> <SectionContent>
<SourceStatus status={source.status} projectId={projectId} /> <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> </SectionContent>
</SectionRow> </SectionRow>
)} )}
</div> </div>
</Section> </Section>
{/* Source-specific sections */} {/* Source-specific sections */}
{source.data.type === 'urls' && {source.data.type === 'urls' &&
<ScrapeSource <ScrapeSource
projectId={projectId} projectId={projectId}
dataSource={source} dataSource={source}
handleReload={handleReload} handleReload={handleReload}
/> />
} }
{(source.data.type === 'files_local' || source.data.type === 'files_s3') && {(source.data.type === 'files_local' || source.data.type === 'files_s3') &&
<FilesSource <FilesSource
projectId={projectId} projectId={projectId}
dataSource={source} dataSource={source}
handleReload={handleReload} handleReload={handleReload}
type={source.data.type} type={source.data.type}
/> />
} }
{source.data.type === 'text' && {source.data.type === 'text' &&
<TextSource <TextSource
projectId={projectId} projectId={projectId}
dataSource={source} dataSource={source}
handleReload={handleReload} handleReload={handleReload}
/> />
} }
@ -251,7 +276,12 @@ export function SourcePage({
</div> </div>
</Section> </Section>
</div> </div>
</div> </div >
</Panel> <BillingUpgradeModal
isOpen={!!billingError}
onClose={() => setBillingError(null)}
errorMessage={billingError || ''}
/>
</Panel >
); );
} }

View file

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

View file

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

View file

@ -1,12 +1,13 @@
'use client';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { ScenariosApp } from "./scenarios_app"; import { ScenariosApp } from "./scenarios_app";
import { SimulationsApp } from "./simulations_app"; import { SimulationsApp } from "./simulations_app";
import { ProfilesApp } from "./profiles_app"; import { ProfilesApp } from "./profiles_app";
import { RunsApp } from "./runs_app"; import { RunsApp } from "./runs_app";
import { TestingMenu } from "./testing_menu"; 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; const { projectId, slug = [] } = params;
let app: "scenarios" | "simulations" | "profiles" | "runs" = "runs"; let app: "scenarios" | "simulations" | "profiles" | "runs" = "runs";

View file

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

View file

@ -1,8 +1,11 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import { ToolsConfig } from './components/ToolsConfig'; import { ToolsConfig } from './components/ToolsConfig';
import { PageHeader } from '@/components/ui/page-header'; 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 ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PageHeader <PageHeader

View file

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

View file

@ -3,6 +3,8 @@ import { App } from "./app";
import { USE_RAG } from "@/app/lib/feature_flags"; import { USE_RAG } from "@/app/lib/feature_flags";
import { projectsCollection } from "@/app/lib/mongodb"; import { projectsCollection } from "@/app/lib/mongodb";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
const DEFAULT_MODEL = process.env.PROVIDER_DEFAULT_MODEL || "gpt-4.1"; const DEFAULT_MODEL = process.env.PROVIDER_DEFAULT_MODEL || "gpt-4.1";
export const metadata: Metadata = { export const metadata: Metadata = {
@ -14,6 +16,7 @@ export default async function Page({
}: { }: {
params: { projectId: string }; params: { projectId: string };
}) { }) {
await requireActiveBillingSubscription();
console.log('->>> workflow page being rendered'); console.log('->>> workflow page being rendered');
const project = await projectsCollection.findOne({ const project = await projectsCollection.findOne({
_id: params.projectId, _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 { CopyIcon, ImportIcon, Layers2Icon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle } from "lucide-react";
import { EntityList } from "./entity_list"; import { EntityList } from "./entity_list";
import { ProductTour } from "@/components/common/product-tour"; import { ProductTour } from "@/components/common/product-tour";
import { ModelsResponse } from "@/app/lib/types/billing_types";
enablePatches(); enablePatches();
@ -563,6 +564,7 @@ export function WorkflowEditor({
toolWebhookUrl, toolWebhookUrl,
defaultModel, defaultModel,
projectTools, projectTools,
eligibleModels,
}: { }: {
dataSources: WithStringId<z.infer<typeof DataSource>>[]; dataSources: WithStringId<z.infer<typeof DataSource>>[];
workflow: WithStringId<z.infer<typeof Workflow>>; workflow: WithStringId<z.infer<typeof Workflow>>;
@ -574,6 +576,7 @@ export function WorkflowEditor({
toolWebhookUrl: string; toolWebhookUrl: string;
defaultModel: string; defaultModel: string;
projectTools: z.infer<typeof WorkflowTool>[]; projectTools: z.infer<typeof WorkflowTool>[];
eligibleModels: z.infer<typeof ModelsResponse> | "*";
}) { }) {
const [state, dispatch] = useReducer<Reducer<State, Action>>(reducer, { const [state, dispatch] = useReducer<Reducer<State, Action>>(reducer, {
@ -1026,6 +1029,7 @@ export function WorkflowEditor({
handleClose={handleUnselectAgent} handleClose={handleUnselectAgent}
useRag={useRag} useRag={useRag}
triggerCopilotChat={triggerCopilotChat} triggerCopilotChat={triggerCopilotChat}
eligibleModels={eligibleModels === "*" ? "*" : eligibleModels.agentModels}
/>} />}
{state.present.selection?.type === "tool" && (() => { {state.present.selection?.type === "tool" && (() => {
const selectedTool = state.present.workflow.tools.find( 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'; import AppLayout from './layout/components/app-layout';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -9,7 +9,7 @@ export default function Layout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<AppLayout useRag={USE_RAG} useAuth={USE_AUTH}> <AppLayout useRag={USE_RAG} useAuth={USE_AUTH} useBilling={USE_BILLING}>
{children} {children}
</AppLayout> </AppLayout>
); );

View file

@ -7,16 +7,16 @@ interface AppLayoutProps {
children: ReactNode; children: ReactNode;
useRag?: boolean; useRag?: boolean;
useAuth?: 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 [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const pathname = usePathname(); const pathname = usePathname();
const projectId = pathname.split('/')[2];
// For invalid projectId, return just the children let projectId: string|null = null;
if (!projectId && !pathname.startsWith('/projects')) { if (pathname.startsWith('/projects')) {
return children; projectId = pathname.split('/')[2];
} }
// Layout with sidebar for all routes // 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 */} {/* 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"> <div className="overflow-hidden rounded-xl bg-white/70 dark:bg-zinc-800/70 shadow-sm backdrop-blur-sm">
<Sidebar <Sidebar
projectId={projectId} projectId={projectId ?? undefined}
useRag={useRag} useRag={useRag}
useAuth={useAuth} useAuth={useAuth}
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)} onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
useBilling={useBilling}
/> />
</div> </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"; import { useHelpModal } from "@/app/providers/help-modal-provider";
interface SidebarProps { interface SidebarProps {
projectId: string; projectId?: string;
useRag: boolean; useRag: boolean;
useAuth: boolean; useAuth: boolean;
collapsed?: boolean; collapsed?: boolean;
onToggleCollapse?: () => void; onToggleCollapse?: () => void;
useBilling?: boolean;
} }
const EXPANDED_ICON_SIZE = 20; const EXPANDED_ICON_SIZE = 20;
const COLLAPSED_ICON_SIZE = 20; // DO NOT CHANGE THIS 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 pathname = usePathname();
const [projectName, setProjectName] = useState<string>("Select Project"); const [projectName, setProjectName] = useState<string>("Select Project");
const isProjectsRoute = pathname === '/projects' || pathname === '/projects/select'; const isProjectsRoute = pathname === '/projects' || pathname === '/projects/select';
@ -123,8 +124,8 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
</Tooltip> </Tooltip>
</div> </div>
{/* Navigation Items */} {/* Project-specific navigation Items */}
<nav className="p-3 space-y-4"> {projectId && <nav className="p-3 space-y-4">
{navItems.map((item) => { {navItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const fullPath = `/projects/${projectId}/${item.href}`; const fullPath = `/projects/${projectId}/${item.href}`;
@ -184,7 +185,7 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
</Tooltip> </Tooltip>
); );
})} })}
</nav> </nav>}
</> </>
)} )}
</div> </div>
@ -252,7 +253,7 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
hover:bg-zinc-100 dark:hover:bg-zinc-800/50 hover:bg-zinc-100 dark:hover:bg-zinc-800/50
`} `}
> >
<UserButton /> <UserButton useBilling={useBilling} />
{!collapsed && <span>Account</span>} {!collapsed && <span>Account</span>}
</div> </div>
</Tooltip> </Tooltip>

View file

@ -1,5 +1,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { requireActiveBillingSubscription } from '../lib/billing';
export default function Page() { export default async function Page() {
await requireActiveBillingSubscription();
redirect('/projects/select'); 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 { USE_MULTIPLE_PROJECTS } from "@/app/lib/feature_flags";
import { HorizontalDivider } from "@/components/ui/horizontal-divider"; import { HorizontalDivider } from "@/components/ui/horizontal-divider";
import { Tooltip } from "@heroui/react"; import { Tooltip } from "@heroui/react";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
// Add glow animation styles // Add glow animation styles
const glowStyles = ` const glowStyles = `
@ -137,6 +138,7 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
const [customPrompt, setCustomPrompt] = useState(""); const [customPrompt, setCustomPrompt] = useState("");
const [name, setName] = useState(defaultName); const [name, setName] = useState(defaultName);
const [promptError, setPromptError] = useState<string | null>(null); const [promptError, setPromptError] = useState<string | null>(null);
const [billingError, setBillingError] = useState<string | null>(null);
const router = useRouter(); const router = useRouter();
// Add this effect to update name when defaultName changes // Add this effect to update name when defaultName changes
@ -194,7 +196,7 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
setIsExamplesDropdownOpen(false); setIsExamplesDropdownOpen(false);
}; };
async function handleSubmit(formData: FormData) { async function handleSubmit() {
try { try {
if (selectedTab !== TabType.Blank && !customPrompt.trim()) { if (selectedTab !== TabType.Blank && !customPrompt.trim()) {
setPromptError("Prompt cannot be empty"); setPromptError("Prompt cannot be empty");
@ -213,237 +215,226 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
newFormData.append('name', name); newFormData.append('name', name);
newFormData.append('prompt', customPrompt); newFormData.append('prompt', customPrompt);
response = await createProjectFromPrompt(newFormData); response = await createProjectFromPrompt(newFormData);
}
if (response?.id && customPrompt) {
if ('id' in response) {
if (selectedTab !== TabType.Blank && customPrompt) {
localStorage.setItem(`project_prompt_${response.id}`, 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) { } catch (error) {
console.error('Error creating project:', 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 ( return (
<div className={clsx( <>
"overflow-auto", <div className={clsx(
!USE_MULTIPLE_PROJECTS && "max-w-none px-12 py-12", "overflow-auto",
USE_MULTIPLE_PROJECTS && !isProjectPaneOpen && "col-span-full" !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"
)}> )}>
{USE_MULTIPLE_PROJECTS && ( <section className={clsx(
<> "card h-full",
<div className="px-4 pt-4 pb-6 flex justify-between items-center"> !USE_MULTIPLE_PROJECTS && "px-24",
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100"> USE_MULTIPLE_PROJECTS && "px-8"
Create new assistant )}>
</h1> {USE_MULTIPLE_PROJECTS && (
{!isProjectPaneOpen && ( <>
<Button <div className="px-4 pt-4 pb-6 flex justify-between items-center">
onClick={onOpenProjectPane} <h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
variant="primary" Create new assistant
size="md" </h1>
startContent={<FolderOpenIcon className="w-4 h-4" />} {!isProjectPaneOpen && (
> <Button
View Existing Projects onClick={onOpenProjectPane}
</Button> variant="primary"
)} size="md"
</div> startContent={<FolderOpenIcon className="w-4 h-4" />}
<HorizontalDivider /> >
</> View Existing Projects
)} </Button>
<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>
)} )}
</div> </div>
</div> <HorizontalDivider />
</div> </>
)}
<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>
{/* Custom Prompt Section - Only show when needed */} {/* Tab Navigation */}
{(selectedTab === TabType.Describe || selectedTab === TabType.Example) && ( <div className="flex gap-6 relative">
<div className="space-y-4"> <Button
<div className="flex flex-col gap-4"> variant={selectedTab === TabType.Describe ? 'primary' : 'tertiary'}
<label className={largeSectionHeaderStyles}> size="md"
{selectedTab === TabType.Describe ? '✏️ What do you want to build?' : '✏️ Customize the description'} onClick={() => handleTabChange(TabType.Describe)}
</label> className={selectedTab === TabType.Describe ? selectedTabStyles : unselectedTabStyles}
<div className="flex items-center gap-2"> >
<p className="text-xs text-gray-600 dark:text-gray-400"> Describe your assistant
In the next step, our AI copilot will create agents for you, complete with mock-tools. </Button>
</p> <Button
<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]"> variant={selectedTab === TabType.Blank ? 'primary' : 'tertiary'}
<InformationCircleIcon className="w-4 h-4 text-indigo-500 hover:text-indigo-600 dark:text-indigo-400 dark:hover:text-indigo-300 cursor-help" /> size="md"
</Tooltip> onClick={handleBlankTemplateClick}
</div> type="button"
<div className="space-y-2"> className={selectedTab === TabType.Blank ? selectedTabStyles : unselectedTabStyles}
<Textarea >
value={customPrompt} Start from a blank template
onChange={(e) => { </Button>
setCustomPrompt(e.target.value); <div className="relative" ref={dropdownRef}>
setPromptError(null); <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( className={clsx(
textareaStyles, textareaStyles,
"min-h-[60px]",
"text-base", "text-base",
"text-gray-900 dark:text-gray-100", "text-gray-900 dark:text-gray-100"
promptError && "border-red-500 focus:ring-red-500/20",
!customPrompt && emptyTextareaStyles
)} )}
style={{ minHeight: "120px" }} placeholder={defaultName}
autoFocus
autoResize
required={isNotBlankTemplate(selectedTab)}
/> />
{promptError && (
<p className="text-sm text-red-500">
{promptError}
</p>
)}
</div> </div>
</div> </div>
</div> )}
)}
{selectedTab === TabType.Blank && ( {/* Submit Button */}
<div className="space-y-4"> <div className="pt-1 w-full -mt-4">
<div className="flex flex-col gap-4"> <Submit />
<p className="text-gray-600 dark:text-gray-400 text-sm">
👇 Click &ldquo;Create assistant&rdquo; below to get started
</p>
</div>
</div> </div>
)} </form>
</section>
{/* Name Section */} </div>
{USE_MULTIPLE_PROJECTS && ( <BillingUpgradeModal
<div className="space-y-4"> isOpen={!!billingError}
<div className="flex flex-col gap-4"> onClose={() => setBillingError(null)}
<label className={largeSectionHeaderStyles}> errorMessage={billingError || ''}
🏷 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>
); );
} }

View file

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

View file

@ -1,9 +1,9 @@
import '../lib/loadenv'; import '../lib/loadenv';
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters"; import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { z } from 'zod'; 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 { EmbeddingRecord, DataSourceDoc, DataSource } from "../lib/types/datasource_types";
import { WithId } from 'mongodb'; import { ObjectId, WithId } from 'mongodb';
import { embedMany, generateText } from 'ai'; import { embedMany, generateText } from 'ai';
import { embeddingModel } from '../lib/embedding'; import { embeddingModel } from '../lib/embedding';
import { qdrantClient } from '../lib/qdrant'; import { qdrantClient } from '../lib/qdrant';
@ -15,7 +15,8 @@ import fs from 'fs/promises';
import crypto from 'crypto'; import crypto from 'crypto';
import path from 'path'; import path from 'path';
import { createOpenAI } from '@ai-sdk/openai'; 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_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; 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 const logger = _logger
.child(doc._id.toString()) .child(doc._id.toString())
.child(doc.name); .child(doc.name);
@ -133,7 +134,7 @@ async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typ
// generate embeddings // generate embeddings
logger.log("Generating embeddings"); logger.log("Generating embeddings");
const { embeddings } = await embedMany({ const { embeddings, usage } = await embedMany({
model: embeddingModel, model: embeddingModel,
values: splits.map((split) => split.pageContent) values: splits.map((split) => split.pageContent)
}); });
@ -168,6 +169,8 @@ async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typ
lastUpdatedAt: new Date().toISOString(), 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> { 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`); 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 each doc
for (const doc of pendingDocs) { 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" } }; const ldoc = doc as WithId<z.infer<typeof DataSourceDoc>> & { data: { type: "file_local" | "file_s3" } };
try { 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) { } catch (e: any) {
errors = true; errors = true;
logger.log("Error processing doc:", e); logger.log("Error processing doc:", e);
@ -365,8 +399,29 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
} }
} }
} catch (e) { } 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); 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; continue;
} }
@ -379,6 +434,7 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
$set: { $set: {
status: errors ? "error" : "ready", status: errors ? "error" : "ready",
...(errors ? { error: "There were some errors processing this job" } : {}), ...(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 { qdrantClient } from '../lib/qdrant';
import { PrefixLogger } from "../lib/utils"; import { PrefixLogger } from "../lib/utils";
import crypto from 'crypto'; import crypto from 'crypto';
import { USE_BILLING } from '../lib/feature_flags';
import { authorize, BillingError, getCustomerIdForProject, logUsage } from '../lib/billing';
const splitter = new RecursiveCharacterTextSplitter({ const splitter = new RecursiveCharacterTextSplitter({
separators: ['\n\n', '\n', '. ', '.', ''], separators: ['\n\n', '\n', '. ', '.', ''],
@ -20,7 +22,7 @@ const second = 1000;
const minute = 60 * second; const minute = 60 * second;
const hour = 60 * minute; 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 const logger = _logger
.child(doc._id.toString()) .child(doc._id.toString())
.child(doc.name); .child(doc.name);
@ -35,7 +37,7 @@ async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typ
// generate embeddings // generate embeddings
logger.log("Generating embeddings"); logger.log("Generating embeddings");
const { embeddings } = await embedMany({ const { embeddings, usage } = await embedMany({
model: embeddingModel, model: embeddingModel,
values: splits.map((split) => split.pageContent) values: splits.map((split) => split.pageContent)
}); });
@ -70,6 +72,8 @@ async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typ
lastUpdatedAt: new Date().toISOString(), 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> { 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`); 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 each doc
for (const doc of pendingDocs) { 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 { 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) { } catch (e: any) {
errors = true; errors = true;
logger.log("Error processing doc:", e); logger.log("Error processing doc:", e);
@ -265,8 +300,29 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
} }
} }
} catch (e) { } 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); 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; continue;
} }

View file

@ -10,6 +10,8 @@ import { embeddingModel } from '../lib/embedding';
import { qdrantClient } from '../lib/qdrant'; import { qdrantClient } from '../lib/qdrant';
import { PrefixLogger } from "../lib/utils"; import { PrefixLogger } from "../lib/utils";
import crypto from 'crypto'; 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 }); 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 const logger = _logger
.child(doc._id.toString()) .child(doc._id.toString())
.child(doc.name); .child(doc.name);
@ -66,7 +68,7 @@ async function runScrapePipeline(_logger: PrefixLogger, job: WithId<z.infer<type
// generate embeddings // generate embeddings
logger.log("Generating embeddings"); logger.log("Generating embeddings");
const { embeddings } = await embedMany({ const { embeddings, usage } = await embedMany({
model: embeddingModel, model: embeddingModel,
values: splits.map((split) => split.pageContent) values: splits.map((split) => split.pageContent)
}); });
@ -101,6 +103,8 @@ async function runScrapePipeline(_logger: PrefixLogger, job: WithId<z.infer<type
lastUpdatedAt: new Date().toISOString(), 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> { 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`); 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 each doc
for (const doc of pendingDocs) { 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 { 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) { } catch (e: any) {
errors = true; errors = true;
logger.log("Error processing doc:", e); logger.log("Error processing doc:", e);
@ -297,8 +332,29 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
} }
} }
} catch (e) { } 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); 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; 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

@ -22,10 +22,10 @@ export async function middleware(request: NextRequest, event: NextFetchEvent) {
// Handle simple requests // Handle simple requests
const response = NextResponse.next(); const response = NextResponse.next();
// Set CORS headers for all origins // Set CORS headers for all origins
response.headers.set('Access-Control-Allow-Origin', '*'); response.headers.set('Access-Control-Allow-Origin', '*');
Object.entries(corsOptions).forEach(([key, value]) => { Object.entries(corsOptions).forEach(([key, value]) => {
response.headers.set(key, value); response.headers.set(key, value);
}) })
@ -33,7 +33,9 @@ export async function middleware(request: NextRequest, event: NextFetchEvent) {
return response; 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 // Skip auth check if USE_AUTH is not enabled
if (process.env.USE_AUTH !== 'true') { if (process.env.USE_AUTH !== 'true') {
return NextResponse.next(); return NextResponse.next();
@ -45,5 +47,11 @@ export async function middleware(request: NextRequest, event: NextFetchEvent) {
} }
export const config = { 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", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"eventsource-parser": "^3.0.2",
"framer-motion": "^11.5.4", "framer-motion": "^11.5.4",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"immer": "^10.1.1", "immer": "^10.1.1",
@ -15831,10 +15832,10 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/eventsource/node_modules/eventsource-parser": { "node_modules/eventsource-parser": {
"version": "3.0.0", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.2.tgz",
"integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==", "integrity": "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA==",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
} }

View file

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

View file

@ -51,6 +51,9 @@ services:
- KLAVIS_API_KEY=${KLAVIS_API_KEY} - KLAVIS_API_KEY=${KLAVIS_API_KEY}
- KLAVIS_GITHUB_CLIENT_ID=${KLAVIS_GITHUB_CLIENT_ID} - KLAVIS_GITHUB_CLIENT_ID=${KLAVIS_GITHUB_CLIENT_ID}
- KLAVIS_GOOGLE_CLIENT_ID=${KLAVIS_GOOGLE_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 restart: unless-stopped
volumes: volumes:
- uploads:/app/uploads - uploads:/app/uploads
@ -161,6 +164,9 @@ services:
- QDRANT_API_KEY=${QDRANT_API_KEY} - QDRANT_API_KEY=${QDRANT_API_KEY}
- RAG_UPLOADS_DIR=/app/uploads - RAG_UPLOADS_DIR=/app/uploads
- USE_GEMINI_FILE_PARSING=${USE_GEMINI_FILE_PARSING} - 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 restart: unless-stopped
volumes: volumes:
- uploads:/app/uploads - uploads:/app/uploads
@ -181,6 +187,9 @@ services:
- FIRECRAWL_API_KEY=${FIRECRAWL_API_KEY} - FIRECRAWL_API_KEY=${FIRECRAWL_API_KEY}
- QDRANT_URL=http://qdrant:6333 - QDRANT_URL=http://qdrant:6333
- QDRANT_API_KEY=${QDRANT_API_KEY} - 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 restart: unless-stopped
rag_text_worker: rag_text_worker:
@ -198,6 +207,9 @@ services:
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
- QDRANT_URL=http://qdrant:6333 - QDRANT_URL=http://qdrant:6333
- QDRANT_API_KEY=${QDRANT_API_KEY} - 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 restart: unless-stopped
# chat_widget: # chat_widget: