mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
add stripe billing
This commit is contained in:
parent
d5302ea2d1
commit
2fda9a7e79
58 changed files with 2348 additions and 485 deletions
|
|
@ -22,6 +22,7 @@ class DataSource(BaseModel):
|
|||
populate_by_name = True
|
||||
|
||||
class ApiRequest(BaseModel):
|
||||
projectId: str
|
||||
messages: List[UserMessage | AssistantMessage]
|
||||
workflow_schema: str
|
||||
current_workflow_config: str
|
||||
|
|
|
|||
|
|
@ -1,35 +1,18 @@
|
|||
'use server';
|
||||
import { AgenticAPIInitStreamResponse, convertFromAgenticAPIChatMessages } from "../lib/types/agents_api_types";
|
||||
import { AgenticAPIInitStreamResponse } from "../lib/types/agents_api_types";
|
||||
import { AgenticAPIChatRequest } from "../lib/types/agents_api_types";
|
||||
import { WebpageCrawlResponse } from "../lib/types/tool_types";
|
||||
import { webpagesCollection } from "../lib/mongodb";
|
||||
import { z } from 'zod';
|
||||
import FirecrawlApp, { ScrapeResponse } from '@mendable/firecrawl-js';
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { Claims, getSession } from "@auth0/nextjs-auth0";
|
||||
import { getAgenticApiResponse, getAgenticResponseStreamId } from "../lib/utils";
|
||||
import { getAgenticResponseStreamId } from "../lib/utils";
|
||||
import { check_query_limit } from "../lib/rate_limiting";
|
||||
import { QueryLimitError } from "../lib/client_utils";
|
||||
import { projectAuthCheck } from "./project_actions";
|
||||
import { USE_AUTH } from "../lib/feature_flags";
|
||||
import { authorizeUserAction } from "./billing_actions";
|
||||
|
||||
const crawler = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY || '' });
|
||||
|
||||
export async function authCheck(): Promise<Claims> {
|
||||
if (!USE_AUTH) {
|
||||
return {
|
||||
email: 'guestuser@rowboatlabs.com',
|
||||
email_verified: true,
|
||||
sub: 'guest_user',
|
||||
};
|
||||
}
|
||||
const { user } = await getSession() || {};
|
||||
if (!user) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function scrapeWebpage(url: string): Promise<z.infer<typeof WebpageCrawlResponse>> {
|
||||
const page = await webpagesCollection.findOne({
|
||||
"_id": url,
|
||||
|
|
@ -74,30 +57,25 @@ export async function scrapeWebpage(url: string): Promise<z.infer<typeof Webpage
|
|||
};
|
||||
}
|
||||
|
||||
export async function getAssistantResponse(request: z.infer<typeof AgenticAPIChatRequest>): Promise<{
|
||||
messages: z.infer<typeof apiV1.ChatMessage>[],
|
||||
state: unknown,
|
||||
rawRequest: unknown,
|
||||
rawResponse: unknown,
|
||||
}> {
|
||||
export async function getAssistantResponseStreamId(request: z.infer<typeof AgenticAPIChatRequest>): Promise<z.infer<typeof AgenticAPIInitStreamResponse> | { billingError: string }> {
|
||||
await projectAuthCheck(request.projectId);
|
||||
if (!await check_query_limit(request.projectId)) {
|
||||
throw new QueryLimitError();
|
||||
}
|
||||
|
||||
const response = await getAgenticApiResponse(request);
|
||||
return {
|
||||
messages: convertFromAgenticAPIChatMessages(response.messages),
|
||||
state: response.state,
|
||||
rawRequest: request,
|
||||
rawResponse: response.rawAPIResponse,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAssistantResponseStreamId(request: z.infer<typeof AgenticAPIChatRequest>): Promise<z.infer<typeof AgenticAPIInitStreamResponse>> {
|
||||
await projectAuthCheck(request.projectId);
|
||||
if (!await check_query_limit(request.projectId)) {
|
||||
throw new QueryLimitError();
|
||||
// Check billing authorization
|
||||
const agentModels = request.agents.reduce((acc, agent) => {
|
||||
acc.push(agent.model);
|
||||
return acc;
|
||||
}, [] as string[]);
|
||||
const { success, error } = await authorizeUserAction({
|
||||
type: 'agent_response',
|
||||
data: {
|
||||
agentModels,
|
||||
},
|
||||
});
|
||||
if (!success) {
|
||||
return { billingError: error || 'Billing error' };
|
||||
}
|
||||
|
||||
const response = await getAgenticResponseStreamId(request);
|
||||
|
|
|
|||
53
apps/rowboat/app/actions/auth_actions.ts
Normal file
53
apps/rowboat/app/actions/auth_actions.ts
Normal 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(),
|
||||
}
|
||||
});
|
||||
}
|
||||
95
apps/rowboat/app/actions/billing_actions.ts
Normal file
95
apps/rowboat/app/actions/billing_actions.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -17,6 +17,8 @@ import { projectAuthCheck } from "./project_actions";
|
|||
import { redisClient } from "../lib/redis";
|
||||
import { fetchProjectMcpTools } from "../lib/project_tools";
|
||||
import { mergeProjectTools } from "../lib/types/project_types";
|
||||
import { authorizeUserAction, logUsage } from "./billing_actions";
|
||||
import { USE_BILLING } from "../lib/feature_flags";
|
||||
|
||||
export async function getCopilotResponse(
|
||||
projectId: string,
|
||||
|
|
@ -28,12 +30,21 @@ export async function getCopilotResponse(
|
|||
message: z.infer<typeof CopilotAssistantMessage>;
|
||||
rawRequest: unknown;
|
||||
rawResponse: unknown;
|
||||
}> {
|
||||
} | { billingError: string }> {
|
||||
await projectAuthCheck(projectId);
|
||||
if (!await check_query_limit(projectId)) {
|
||||
throw new QueryLimitError();
|
||||
}
|
||||
|
||||
// Check billing authorization
|
||||
const authResponse = await authorizeUserAction({
|
||||
type: 'copilot_request',
|
||||
data: {},
|
||||
});
|
||||
if (!authResponse.success) {
|
||||
return { billingError: authResponse.error || 'Billing error' };
|
||||
}
|
||||
|
||||
// Get MCP tools from project and merge with workflow tools
|
||||
const mcpTools = await fetchProjectMcpTools(projectId);
|
||||
|
||||
|
|
@ -45,6 +56,7 @@ export async function getCopilotResponse(
|
|||
|
||||
// prepare request
|
||||
const request: z.infer<typeof CopilotAPIRequest> = {
|
||||
projectId: projectId,
|
||||
messages: messages.map(convertToCopilotApiMessage),
|
||||
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
|
||||
current_workflow_config: JSON.stringify(copilotWorkflow),
|
||||
|
|
@ -132,12 +144,25 @@ export async function getCopilotResponseStream(
|
|||
dataSources?: z.infer<typeof DataSource>[]
|
||||
): Promise<{
|
||||
streamId: string;
|
||||
}> {
|
||||
} | { billingError: string }> {
|
||||
await projectAuthCheck(projectId);
|
||||
if (!await check_query_limit(projectId)) {
|
||||
throw new QueryLimitError();
|
||||
}
|
||||
|
||||
// Check billing authorization
|
||||
const authResponse = await authorizeUserAction({
|
||||
type: 'copilot_request',
|
||||
data: {},
|
||||
});
|
||||
if (!authResponse.success) {
|
||||
return { billingError: authResponse.error || 'Billing error' };
|
||||
}
|
||||
|
||||
if (!await check_query_limit(projectId)) {
|
||||
throw new QueryLimitError();
|
||||
}
|
||||
|
||||
// Get MCP tools from project and merge with workflow tools
|
||||
const mcpTools = await fetchProjectMcpTools(projectId);
|
||||
|
||||
|
|
@ -149,6 +174,7 @@ export async function getCopilotResponseStream(
|
|||
|
||||
// prepare request
|
||||
const request: z.infer<typeof CopilotAPIRequest> = {
|
||||
projectId: projectId,
|
||||
messages: messages.map(convertToCopilotApiMessage),
|
||||
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
|
||||
current_workflow_config: JSON.stringify(copilotWorkflow),
|
||||
|
|
@ -177,12 +203,21 @@ export async function getCopilotAgentInstructions(
|
|||
messages: z.infer<typeof CopilotMessage>[],
|
||||
current_workflow_config: z.infer<typeof Workflow>,
|
||||
agentName: string,
|
||||
): Promise<string> {
|
||||
): Promise<string | { billingError: string }> {
|
||||
await projectAuthCheck(projectId);
|
||||
if (!await check_query_limit(projectId)) {
|
||||
throw new QueryLimitError();
|
||||
}
|
||||
|
||||
// Check billing authorization
|
||||
const authResponse = await authorizeUserAction({
|
||||
type: 'copilot_request',
|
||||
data: {},
|
||||
});
|
||||
if (!authResponse.success) {
|
||||
return { billingError: authResponse.error || 'Billing error' };
|
||||
}
|
||||
|
||||
// Get MCP tools from project and merge with workflow tools
|
||||
const mcpTools = await fetchProjectMcpTools(projectId);
|
||||
|
||||
|
|
@ -194,6 +229,7 @@ export async function getCopilotAgentInstructions(
|
|||
|
||||
// prepare request
|
||||
const request: z.infer<typeof CopilotAPIRequest> = {
|
||||
projectId: projectId,
|
||||
messages: messages.map(convertToCopilotApiMessage),
|
||||
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
|
||||
current_workflow_config: JSON.stringify(copilotWorkflow),
|
||||
|
|
@ -237,6 +273,14 @@ export async function getCopilotAgentInstructions(
|
|||
throw new Error(`Failed to call copilot api: ${copilotResponse.error}`);
|
||||
}
|
||||
|
||||
// log the billing usage
|
||||
if (USE_BILLING) {
|
||||
await logUsage({
|
||||
type: 'copilot_requests',
|
||||
amount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// return response
|
||||
return agent_instructions;
|
||||
}
|
||||
|
|
@ -105,6 +105,7 @@ export async function recrawlWebDataSource(projectId: string, sourceId: string)
|
|||
}, {
|
||||
$set: {
|
||||
status: 'pending',
|
||||
billingError: undefined,
|
||||
lastUpdatedAt: (new Date()).toISOString(),
|
||||
attempts: 0,
|
||||
},
|
||||
|
|
@ -124,6 +125,7 @@ export async function deleteDataSource(projectId: string, sourceId: string) {
|
|||
}, {
|
||||
$set: {
|
||||
status: 'deleted',
|
||||
billingError: undefined,
|
||||
lastUpdatedAt: (new Date()).toISOString(),
|
||||
attempts: 0,
|
||||
},
|
||||
|
|
@ -189,6 +191,7 @@ export async function addDocsToDataSource({
|
|||
{
|
||||
$set: {
|
||||
status: 'pending',
|
||||
billingError: undefined,
|
||||
attempts: 0,
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
},
|
||||
|
|
@ -275,6 +278,7 @@ export async function deleteDocsFromDataSource({
|
|||
}, {
|
||||
$set: {
|
||||
status: 'pending',
|
||||
billingError: undefined,
|
||||
attempts: 0,
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { projectsCollection } from '../lib/mongodb';
|
|||
import { fetchMcpTools, toggleMcpTool } from './mcp_actions';
|
||||
import { fetchMcpToolsForServer } from './mcp_actions';
|
||||
import { headers } from 'next/headers';
|
||||
import { authorizeUserAction } from './billing_actions';
|
||||
import { redisClient } from '../lib/redis';
|
||||
|
||||
type McpServerType = z.infer<typeof MCPServer>;
|
||||
type McpToolType = z.infer<typeof McpTool>;
|
||||
|
|
@ -542,13 +544,34 @@ export async function enableServer(
|
|||
serverName: string,
|
||||
projectId: string,
|
||||
enabled: boolean
|
||||
): Promise<CreateServerInstanceResponse | {}> {
|
||||
): Promise<CreateServerInstanceResponse | {} | { billingError: string }> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
console.log('[Klavis API] Toggle server request:', { serverName, projectId, enabled });
|
||||
|
||||
if (enabled) {
|
||||
// get count of enabled hosted mcp servers for this project
|
||||
const existingInstances = await listActiveServerInstances(projectId);
|
||||
// billing limit check
|
||||
const authResponse = await authorizeUserAction({
|
||||
type: 'enable_hosted_tool_server',
|
||||
data: {
|
||||
existingServerCount: existingInstances.length,
|
||||
},
|
||||
});
|
||||
if (!authResponse.success) {
|
||||
return { billingError: authResponse.error || 'Billing error' };
|
||||
}
|
||||
|
||||
// set key in redis to indicate that a server is being enabled on this project
|
||||
// the key set should only succeed if the key does not already exist
|
||||
const setResult = await redisClient.set(`klavis_enabling_server:${projectId}`, 'true', { EX: 60 * 60, NX: true });
|
||||
console.log('[redis] Set result here:', setResult);
|
||||
if (setResult !== 'OK') {
|
||||
throw new Error("A server is already being enabled on this project");
|
||||
}
|
||||
|
||||
console.log(`[Klavis API] Creating server instance for ${serverName}...`);
|
||||
const result = await createMcpServerInstance(serverName, projectId, "Rowboat");
|
||||
console.log('[Klavis API] Server instance created:', {
|
||||
|
|
@ -640,6 +663,9 @@ export async function enableServer(
|
|||
console.error(`[Klavis API] Tool enrichment failed for ${serverName}:`, enrichError);
|
||||
}
|
||||
|
||||
// remove key from redis
|
||||
await redisClient.del(`klavis_enabling_server:${projectId}`);
|
||||
|
||||
return result;
|
||||
} else {
|
||||
// Get active instances to find the one to delete
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ import { z } from 'zod';
|
|||
import crypto from 'crypto';
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { templates } from "../lib/project_templates";
|
||||
import { authCheck } from "./actions";
|
||||
import { WithStringId } from "../lib/types/types";
|
||||
import { authCheck } from "./auth_actions";
|
||||
import { User, WithStringId } from "../lib/types/types";
|
||||
import { ApiKey } from "../lib/types/project_types";
|
||||
import { Project } from "../lib/types/project_types";
|
||||
import { USE_AUTH } from "../lib/feature_flags";
|
||||
import { deleteMcpServerInstance, listActiveServerInstances } from "./klavis_actions";
|
||||
import { authorizeUserAction } from "./billing_actions";
|
||||
|
||||
export async function projectAuthCheck(projectId: string) {
|
||||
if (!USE_AUTH) {
|
||||
|
|
@ -20,23 +21,27 @@ export async function projectAuthCheck(projectId: string) {
|
|||
const user = await authCheck();
|
||||
const membership = await projectMembersCollection.findOne({
|
||||
projectId,
|
||||
userId: user.sub,
|
||||
userId: user._id,
|
||||
});
|
||||
if (!membership) {
|
||||
throw new Error('User not a member of project');
|
||||
}
|
||||
}
|
||||
|
||||
async function createBaseProject(name: string, user: any) {
|
||||
// Check project limits
|
||||
const projectsLimit = Number(process.env.MAX_PROJECTS_PER_USER) || 0;
|
||||
if (projectsLimit > 0) {
|
||||
const count = await projectsCollection.countDocuments({
|
||||
createdByUserId: user.sub,
|
||||
});
|
||||
if (count >= projectsLimit) {
|
||||
throw new Error('You have reached your project limit. Please upgrade your plan.');
|
||||
}
|
||||
async function createBaseProject(name: string, user: WithStringId<z.infer<typeof User>>): Promise<{ id: string } | { billingError: string }> {
|
||||
// fetch project count for this user
|
||||
const projectCount = await projectsCollection.countDocuments({
|
||||
createdByUserId: user._id,
|
||||
});
|
||||
// billing limit check
|
||||
const authResponse = await authorizeUserAction({
|
||||
type: 'create_project',
|
||||
data: {
|
||||
existingProjectCount: projectCount,
|
||||
},
|
||||
});
|
||||
if (!authResponse.success) {
|
||||
return { billingError: authResponse.error || 'Billing error' };
|
||||
}
|
||||
|
||||
const projectId = crypto.randomUUID();
|
||||
|
|
@ -49,7 +54,7 @@ async function createBaseProject(name: string, user: any) {
|
|||
name,
|
||||
createdAt: (new Date()).toISOString(),
|
||||
lastUpdatedAt: (new Date()).toISOString(),
|
||||
createdByUserId: user.sub,
|
||||
createdByUserId: user._id,
|
||||
chatClientId,
|
||||
secret,
|
||||
nextWorkflowNumber: 1,
|
||||
|
|
@ -58,7 +63,7 @@ async function createBaseProject(name: string, user: any) {
|
|||
|
||||
// Add user to project
|
||||
await projectMembersCollection.insertOne({
|
||||
userId: user.sub,
|
||||
userId: user._id,
|
||||
projectId: projectId,
|
||||
createdAt: (new Date()).toISOString(),
|
||||
lastUpdatedAt: (new Date()).toISOString(),
|
||||
|
|
@ -67,15 +72,20 @@ async function createBaseProject(name: string, user: any) {
|
|||
// Add first api key
|
||||
await createApiKey(projectId);
|
||||
|
||||
return projectId;
|
||||
return { id: projectId };
|
||||
}
|
||||
|
||||
export async function createProject(formData: FormData) {
|
||||
export async function createProject(formData: FormData): Promise<{ id: string } | { billingError: string }> {
|
||||
const user = await authCheck();
|
||||
const name = formData.get('name') as string;
|
||||
const templateKey = formData.get('template') as string;
|
||||
|
||||
const projectId = await createBaseProject(name, user);
|
||||
const response = await createBaseProject(name, user);
|
||||
if ('billingError' in response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const projectId = response.id;
|
||||
|
||||
// Add first workflow version with specified template
|
||||
const { agents, prompts, tools, startAgent } = templates[templateKey];
|
||||
|
|
@ -90,7 +100,7 @@ export async function createProject(formData: FormData) {
|
|||
name: `Version 1`,
|
||||
});
|
||||
|
||||
redirect(`/projects/${projectId}/workflow`);
|
||||
return { id: projectId };
|
||||
}
|
||||
|
||||
export async function getProjectConfig(projectId: string): Promise<WithStringId<z.infer<typeof Project>>> {
|
||||
|
|
@ -107,7 +117,7 @@ export async function getProjectConfig(projectId: string): Promise<WithStringId<
|
|||
export async function listProjects(): Promise<z.infer<typeof Project>[]> {
|
||||
const user = await authCheck();
|
||||
const memberships = await projectMembersCollection.find({
|
||||
userId: user.sub,
|
||||
userId: user._id,
|
||||
}).toArray();
|
||||
const projectIds = memberships.map((m) => m.projectId);
|
||||
const projects = await projectsCollection.find({
|
||||
|
|
@ -271,11 +281,16 @@ export async function deleteProject(projectId: string) {
|
|||
redirect('/projects');
|
||||
}
|
||||
|
||||
export async function createProjectFromPrompt(formData: FormData) {
|
||||
export async function createProjectFromPrompt(formData: FormData): Promise<{ id: string } | { billingError: string }> {
|
||||
const user = await authCheck();
|
||||
const name = formData.get('name') as string;
|
||||
|
||||
const projectId = await createBaseProject(name, user);
|
||||
const response = await createBaseProject(name, user);
|
||||
if ('billingError' in response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const projectId = response.id;
|
||||
|
||||
// Add first workflow version with default template
|
||||
const { agents, prompts, tools, startAgent } = templates['default'];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { getCustomerIdForProject, logUsage } from "@/app/lib/billing";
|
||||
import { USE_BILLING } from "@/app/lib/feature_flags";
|
||||
import { redisClient } from "@/app/lib/redis";
|
||||
import { CopilotAPIRequest } from "@/app/lib/types/copilot_types";
|
||||
|
||||
export async function GET(request: Request, { params }: { params: { streamId: string } }) {
|
||||
// get the payload from redis
|
||||
|
|
@ -7,6 +10,15 @@ export async function GET(request: Request, { params }: { params: { streamId: st
|
|||
return new Response("Stream not found", { status: 404 });
|
||||
}
|
||||
|
||||
// parse the payload
|
||||
const parsedPayload = CopilotAPIRequest.parse(JSON.parse(payload));
|
||||
|
||||
// fetch billing customer id
|
||||
let billingCustomerId: string | null = null;
|
||||
if (USE_BILLING) {
|
||||
billingCustomerId = await getCustomerIdForProject(parsedPayload.projectId);
|
||||
}
|
||||
|
||||
// Fetch the upstream SSE stream.
|
||||
const upstreamResponse = await fetch(`${process.env.COPILOT_API_URL}/chat_stream`, {
|
||||
method: 'POST',
|
||||
|
|
@ -36,6 +48,18 @@ export async function GET(request: Request, { params }: { params: { streamId: st
|
|||
controller.enqueue(value);
|
||||
}
|
||||
controller.close();
|
||||
|
||||
// increment copilot request count in billing
|
||||
if (USE_BILLING && billingCustomerId) {
|
||||
try {
|
||||
await logUsage(billingCustomerId, {
|
||||
type: "copilot_requests",
|
||||
amount: 1,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error logging usage", error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { getCustomerIdForProject, logUsage } from "@/app/lib/billing";
|
||||
import { USE_BILLING } from "@/app/lib/feature_flags";
|
||||
import { redisClient } from "@/app/lib/redis";
|
||||
import { AgenticAPIChatMessage, AgenticAPIChatRequest, convertFromAgenticAPIChatMessages } from "@/app/lib/types/agents_api_types";
|
||||
import { createParser, type EventSourceMessage } from 'eventsource-parser';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: { streamId: string } }) {
|
||||
// get the payload from redis
|
||||
|
|
@ -7,6 +11,15 @@ export async function GET(request: Request, { params }: { params: { streamId: st
|
|||
return new Response("Stream not found", { status: 404 });
|
||||
}
|
||||
|
||||
// parse the payload
|
||||
const parsedPayload = AgenticAPIChatRequest.parse(JSON.parse(payload));
|
||||
|
||||
// fetch billing customer id
|
||||
let billingCustomerId: string | null = null;
|
||||
if (USE_BILLING) {
|
||||
billingCustomerId = await getCustomerIdForProject(parsedPayload.projectId);
|
||||
}
|
||||
|
||||
// Fetch the upstream SSE stream.
|
||||
const upstreamResponse = await fetch(`${process.env.AGENTS_API_URL}/chat_stream`, {
|
||||
method: 'POST',
|
||||
|
|
@ -24,19 +37,63 @@ export async function GET(request: Request, { params }: { params: { streamId: st
|
|||
}
|
||||
|
||||
const reader = upstreamResponse.body.getReader();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
let messageCount = 0;
|
||||
|
||||
function emitEvent(event: EventSourceMessage) {
|
||||
// Re-emit the event in SSE format
|
||||
let eventString = '';
|
||||
if (event.id) eventString += `id: ${event.id}\n`;
|
||||
if (event.event) eventString += `event: ${event.event}\n`;
|
||||
if (event.data) eventString += `data: ${event.data}\n`;
|
||||
eventString += '\n';
|
||||
|
||||
controller.enqueue(encoder.encode(eventString));
|
||||
}
|
||||
|
||||
const parser = createParser({
|
||||
onEvent(event: EventSourceMessage) {
|
||||
if (event.event !== 'message') {
|
||||
emitEvent(event);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse message
|
||||
const data = JSON.parse(event.data);
|
||||
const msg = AgenticAPIChatMessage.parse(data);
|
||||
const parsedMsg = convertFromAgenticAPIChatMessages([msg])[0];
|
||||
|
||||
// increment the message count if this is an assistant message
|
||||
if (parsedMsg.role === 'assistant') {
|
||||
messageCount++;
|
||||
}
|
||||
|
||||
// emit the event
|
||||
emitEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// Read from the upstream stream continuously.
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
// Immediately enqueue each received chunk.
|
||||
controller.enqueue(value);
|
||||
|
||||
// Feed the chunk to the parser
|
||||
parser.feed(new TextDecoder().decode(value));
|
||||
}
|
||||
controller.close();
|
||||
|
||||
if (USE_BILLING && billingCustomerId) {
|
||||
await logUsage(billingCustomerId, {
|
||||
type: "agent_messages",
|
||||
amount: messageCount,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing stream:', error);
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import { check_query_limit } from "../../../../lib/rate_limiting";
|
|||
import { PrefixLogger } from "../../../../lib/utils";
|
||||
import { TestProfile } from "@/app/lib/types/testing_types";
|
||||
import { fetchProjectMcpTools } from "@/app/lib/project_tools";
|
||||
import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing";
|
||||
import { USE_BILLING } from "@/app/lib/feature_flags";
|
||||
|
||||
// get next turn / agent response
|
||||
export async function POST(
|
||||
|
|
@ -29,6 +31,12 @@ export async function POST(
|
|||
}
|
||||
|
||||
return await authCheck(projectId, req, async () => {
|
||||
// fetch billing customer id
|
||||
let billingCustomerId: string | null = null;
|
||||
if (USE_BILLING) {
|
||||
billingCustomerId = await getCustomerIdForProject(projectId);
|
||||
}
|
||||
|
||||
// parse and validate the request body
|
||||
let body;
|
||||
try {
|
||||
|
|
@ -74,6 +82,23 @@ export async function POST(
|
|||
return Response.json({ error: "Workflow not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// check billing authorization
|
||||
if (USE_BILLING && billingCustomerId) {
|
||||
const agentModels = workflow.agents.reduce((acc, agent) => {
|
||||
acc.push(agent.model);
|
||||
return acc;
|
||||
}, [] as string[]);
|
||||
const response = await authorize(billingCustomerId, {
|
||||
type: 'agent_response',
|
||||
data: {
|
||||
agentModels,
|
||||
},
|
||||
});
|
||||
if (!response.success) {
|
||||
return Response.json({ error: response.error || 'Billing error' }, { status: 402 });
|
||||
}
|
||||
}
|
||||
|
||||
// if test profile is provided in the request, use it
|
||||
let testProfile: z.infer<typeof TestProfile> | null = null;
|
||||
if (result.data.testProfileId) {
|
||||
|
|
@ -112,6 +137,15 @@ export async function POST(
|
|||
const newMessages = convertFromAgenticApiToApiMessages(agenticMessages);
|
||||
const newState = state;
|
||||
|
||||
// log billing usage
|
||||
if (USE_BILLING && billingCustomerId) {
|
||||
const agentMessageCount = newMessages.filter(m => m.role === 'assistant').length;
|
||||
await logUsage(billingCustomerId, {
|
||||
type: 'agent_messages',
|
||||
amount: agentMessageCount,
|
||||
});
|
||||
}
|
||||
|
||||
const responseBody: z.infer<typeof ApiResponse> = {
|
||||
messages: newMessages,
|
||||
state: newState,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { getAgenticApiResponse } from "../../../../../../lib/utils";
|
|||
import { check_query_limit } from "../../../../../../lib/rate_limiting";
|
||||
import { PrefixLogger } from "../../../../../../lib/utils";
|
||||
import { fetchProjectMcpTools } from "@/app/lib/project_tools";
|
||||
import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing";
|
||||
import { USE_BILLING } from "@/app/lib/feature_flags";
|
||||
|
||||
// get next turn / agent response
|
||||
export async function POST(
|
||||
|
|
@ -24,6 +26,12 @@ export async function POST(
|
|||
|
||||
logger.log(`Processing turn request for chat ${chatId}`);
|
||||
|
||||
// fetch billing customer id
|
||||
let billingCustomerId: string | null = null;
|
||||
if (USE_BILLING) {
|
||||
billingCustomerId = await getCustomerIdForProject(session.projectId);
|
||||
}
|
||||
|
||||
// check query limit
|
||||
if (!await check_query_limit(session.projectId)) {
|
||||
logger.log(`Query limit exceeded for project ${session.projectId}`);
|
||||
|
|
@ -93,6 +101,23 @@ export async function POST(
|
|||
throw new Error("Workflow not found");
|
||||
}
|
||||
|
||||
// check billing authorization
|
||||
if (USE_BILLING && billingCustomerId) {
|
||||
const agentModels = workflow.agents.reduce((acc, agent) => {
|
||||
acc.push(agent.model);
|
||||
return acc;
|
||||
}, [] as string[]);
|
||||
const response = await authorize(billingCustomerId, {
|
||||
type: 'agent_response',
|
||||
data: {
|
||||
agentModels,
|
||||
},
|
||||
});
|
||||
if (!response.success) {
|
||||
return Response.json({ error: response.error || 'Billing error' }, { status: 402 });
|
||||
}
|
||||
}
|
||||
|
||||
// get assistant response
|
||||
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow, projectTools);
|
||||
const unsavedMessages: z.infer<typeof apiV1.ChatMessage>[] = [userMessage];
|
||||
|
|
@ -132,6 +157,15 @@ export async function POST(
|
|||
await chatMessagesCollection.insertMany(unsavedMessages);
|
||||
await chatsCollection.updateOne({ _id: new ObjectId(chatId) }, { $set: { agenticState: state } });
|
||||
|
||||
// log billing usage
|
||||
if (USE_BILLING && billingCustomerId) {
|
||||
const agentMessageCount = convertedMessages.filter(m => m.role === 'assistant').length;
|
||||
await logUsage(billingCustomerId, {
|
||||
type: 'agent_messages',
|
||||
amount: agentMessageCount,
|
||||
});
|
||||
}
|
||||
|
||||
logger.log(`Turn processing completed successfully`);
|
||||
const lastMessage = unsavedMessages[unsavedMessages.length - 1] as WithId<z.infer<typeof apiV1.ChatMessage>>;
|
||||
return Response.json({
|
||||
|
|
|
|||
148
apps/rowboat/app/billing/app.tsx
Normal file
148
apps/rowboat/app/billing/app.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/rowboat/app/billing/callback/page.tsx
Normal file
18
apps/rowboat/app/billing/callback/page.tsx
Normal 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');
|
||||
}
|
||||
13
apps/rowboat/app/billing/layout.tsx
Normal file
13
apps/rowboat/app/billing/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/rowboat/app/billing/page.tsx
Normal file
17
apps/rowboat/app/billing/page.tsx
Normal 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} />;
|
||||
}
|
||||
116
apps/rowboat/app/lib/auth.ts
Normal file
116
apps/rowboat/app/lib/auth.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
304
apps/rowboat/app/lib/billing.ts
Normal file
304
apps/rowboat/app/lib/billing.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
|
@ -2,8 +2,9 @@
|
|||
import { useUser } from '@auth0/nextjs-auth0/client';
|
||||
import { Avatar, Dropdown, DropdownItem, DropdownSection, DropdownTrigger, DropdownMenu } from "@heroui/react";
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function UserButton() {
|
||||
export function UserButton({ useBilling }: { useBilling?: boolean }) {
|
||||
const router = useRouter();
|
||||
const { user } = useUser();
|
||||
if (!user) {
|
||||
|
|
@ -25,9 +26,19 @@ export function UserButton() {
|
|||
if (key === 'logout') {
|
||||
router.push('/api/auth/logout');
|
||||
}
|
||||
if (key === 'billing') {
|
||||
router.push('/billing');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownSection title={name}>
|
||||
{useBilling ? (
|
||||
<DropdownItem key="billing">
|
||||
Billing
|
||||
</DropdownItem>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<DropdownItem key="logout">
|
||||
Logout
|
||||
</DropdownItem>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const USE_CHAT_WIDGET = process.env.USE_CHAT_WIDGET === 'true';
|
|||
export const USE_AUTH = process.env.USE_AUTH === 'true';
|
||||
export const USE_RAG_S3_UPLOADS = process.env.USE_RAG_S3_UPLOADS === 'true';
|
||||
export const USE_GEMINI_FILE_PARSING = process.env.USE_GEMINI_FILE_PARSING === 'true';
|
||||
export const USE_BILLING = process.env.USE_BILLING === 'true';
|
||||
|
||||
// Hardcoded flags
|
||||
export const USE_MULTIPLE_PROJECTS = true;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { MongoClient } from "mongodb";
|
||||
import { Webpage } from "./types/types";
|
||||
import { User, Webpage } from "./types/types";
|
||||
import { Workflow } from "./types/workflow_types";
|
||||
import { ApiKey } from "./types/project_types";
|
||||
import { ProjectMember } from "./types/project_types";
|
||||
|
|
@ -31,6 +31,7 @@ export const testResultsCollection = db.collection<z.infer<typeof TestResult>>("
|
|||
export const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
|
||||
export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages");
|
||||
export const twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("twilio_configs");
|
||||
export const usersCollection = db.collection<z.infer<typeof User>>("users");
|
||||
|
||||
// Create indexes
|
||||
twilioConfigsCollection.createIndexes([
|
||||
|
|
|
|||
100
apps/rowboat/app/lib/types/billing_types.ts
Normal file
100
apps/rowboat/app/lib/types/billing_types.ts
Normal 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,
|
||||
})),
|
||||
});
|
||||
|
|
@ -104,6 +104,7 @@ export const CopilotApiChatContext = z.union([
|
|||
}),
|
||||
]);
|
||||
export const CopilotAPIRequest = z.object({
|
||||
projectId: z.string(),
|
||||
messages: z.array(CopilotApiMessage),
|
||||
workflow_schema: z.string(),
|
||||
current_workflow_config: z.string(),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export const DataSource = z.object({
|
|||
]).optional(),
|
||||
version: z.number(),
|
||||
error: z.string().optional(),
|
||||
billingError: z.string().optional(),
|
||||
createdAt: z.string().datetime(),
|
||||
lastUpdatedAt: z.string().datetime().optional(),
|
||||
attempts: z.number(),
|
||||
|
|
|
|||
|
|
@ -76,6 +76,15 @@ export const McpServerResponse = z.object({
|
|||
error: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const User = z.object({
|
||||
auth0Id: z.string(),
|
||||
billingCustomerId: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const PlaygroundChat = z.object({
|
||||
createdAt: z.string().datetime(),
|
||||
projectId: z.string(),
|
||||
|
|
|
|||
90
apps/rowboat/app/onboarding/app.tsx
Normal file
90
apps/rowboat/app/onboarding/app.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
apps/rowboat/app/onboarding/layout.tsx
Normal file
13
apps/rowboat/app/onboarding/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
apps/rowboat/app/onboarding/page.tsx
Normal file
14
apps/rowboat/app/onboarding/page.tsx
Normal 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 />;
|
||||
}
|
||||
|
|
@ -1,18 +1,20 @@
|
|||
import { Metadata } from "next";
|
||||
import App from "./app";
|
||||
import { USE_CHAT_WIDGET } from "@/app/lib/feature_flags";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Project config",
|
||||
};
|
||||
|
||||
export default function Page({
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: {
|
||||
projectId: string;
|
||||
};
|
||||
}) {
|
||||
await requireActiveBillingSubscription();
|
||||
return <App
|
||||
projectId={params.projectId}
|
||||
useChatWidget={USE_CHAT_WIDGET}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { ComposeBoxCopilot } from "@/components/common/compose-box-copilot";
|
|||
import { Messages } from "./components/messages";
|
||||
import { CopyIcon, CheckIcon, PlusIcon, XIcon, InfoIcon } from "lucide-react";
|
||||
import { useCopilot } from "./use-copilot";
|
||||
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
|
||||
|
||||
const CopilotContext = createContext<{
|
||||
workflow: z.infer<typeof Workflow> | null;
|
||||
|
|
@ -61,6 +62,9 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
|
|||
streamingResponse,
|
||||
loading: loadingResponse,
|
||||
error: responseError,
|
||||
clearError: clearResponseError,
|
||||
billingError,
|
||||
clearBillingError,
|
||||
start,
|
||||
cancel
|
||||
} = useCopilot({
|
||||
|
|
@ -108,6 +112,10 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
|
|||
useEffect(() => {
|
||||
if (!messages.length || messages.at(-1)?.role !== 'user') return;
|
||||
|
||||
if (responseError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStart = startRef.current;
|
||||
const currentCancel = cancelRef.current;
|
||||
|
||||
|
|
@ -122,7 +130,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
|
|||
});
|
||||
|
||||
return () => currentCancel();
|
||||
}, [messages]); // Only depend on messages
|
||||
}, [messages, responseError]);
|
||||
|
||||
const handleCopyChat = useCallback(() => {
|
||||
if (onCopyJson) {
|
||||
|
|
@ -157,7 +165,15 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
|
|||
size="sm"
|
||||
color="danger"
|
||||
onClick={() => {
|
||||
setMessages(prev => [...prev.slice(0, -1)]); // remove last assistant if needed
|
||||
// remove the last assistant message, if any
|
||||
setMessages(prev => {
|
||||
const lastMessage = prev[prev.length - 1];
|
||||
if (lastMessage?.role === 'assistant') {
|
||||
return prev.slice(0, -1);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
clearResponseError();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
|
|
@ -191,6 +207,11 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<BillingUpgradeModal
|
||||
isOpen={!!billingError}
|
||||
onClose={clearBillingError}
|
||||
errorMessage={billingError || ''}
|
||||
/>
|
||||
</CopilotContext.Provider>
|
||||
);
|
||||
});
|
||||
|
|
@ -215,6 +236,7 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
|
|||
const [copilotKey, setCopilotKey] = useState(0);
|
||||
const [showCopySuccess, setShowCopySuccess] = useState(false);
|
||||
const [messages, setMessages] = useState<z.infer<typeof CopilotMessage>[]>([]);
|
||||
const [billingError, setBillingError] = useState<string | null>(null);
|
||||
const appRef = useRef<{ handleCopyChat: () => void; handleUserMessage: (message: string) => void }>(null);
|
||||
|
||||
function handleNewChat() {
|
||||
|
|
@ -242,64 +264,67 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
|
|||
}), []);
|
||||
|
||||
return (
|
||||
<Panel variant="copilot"
|
||||
tourTarget="copilot"
|
||||
showWelcome={messages.length === 0}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
COPILOT
|
||||
<>
|
||||
<Panel
|
||||
variant="copilot"
|
||||
tourTarget="copilot"
|
||||
showWelcome={messages.length === 0}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
COPILOT
|
||||
</div>
|
||||
<Tooltip content="Ask copilot to help you build and modify your workflow">
|
||||
<InfoIcon className="w-4 h-4 text-gray-400 cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip content="Ask copilot to help you build and modify your workflow">
|
||||
<InfoIcon className="w-4 h-4 text-gray-400 cursor-help" />
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleNewChat}
|
||||
className="bg-blue-50 text-blue-700 hover:bg-blue-100"
|
||||
showHoverContent={true}
|
||||
hoverContent="New chat"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleNewChat}
|
||||
className="bg-blue-50 text-blue-700 hover:bg-blue-100"
|
||||
showHoverContent={true}
|
||||
hoverContent="New chat"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
}
|
||||
rightActions={
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => appRef.current?.handleCopyChat()}
|
||||
showHoverContent={true}
|
||||
hoverContent={showCopySuccess ? "Copied" : "Copy JSON"}
|
||||
>
|
||||
{showCopySuccess ? (
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full overflow-auto px-3 pt-4">
|
||||
<App
|
||||
key={copilotKey}
|
||||
ref={appRef}
|
||||
projectId={projectId}
|
||||
workflow={workflow}
|
||||
dispatch={dispatch}
|
||||
chatContext={chatContext}
|
||||
onCopyJson={handleCopyJson}
|
||||
onMessagesChange={setMessages}
|
||||
isInitialState={isInitialState}
|
||||
dataSources={dataSources}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
rightActions={
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => appRef.current?.handleCopyChat()}
|
||||
showHoverContent={true}
|
||||
hoverContent={showCopySuccess ? "Copied" : "Copy JSON"}
|
||||
>
|
||||
{showCopySuccess ? (
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full overflow-auto px-3 pt-4">
|
||||
<App
|
||||
key={copilotKey}
|
||||
ref={appRef}
|
||||
projectId={projectId}
|
||||
workflow={workflow}
|
||||
dispatch={dispatch}
|
||||
chatContext={chatContext}
|
||||
onCopyJson={handleCopyJson}
|
||||
onMessagesChange={setMessages}
|
||||
isInitialState={isInitialState}
|
||||
dataSources={dataSources}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,12 @@ interface UseCopilotResult {
|
|||
streamingResponse: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
clearError: () => void;
|
||||
billingError: string | null;
|
||||
clearBillingError: () => void;
|
||||
start: (
|
||||
messages: z.infer<typeof CopilotMessage>[],
|
||||
onDone: (finalResponse: string) => void
|
||||
onDone: (finalResponse: string) => void,
|
||||
) => void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
|
@ -27,13 +30,21 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
|
|||
const [streamingResponse, setStreamingResponse] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [billingError, setBillingError] = useState<string | null>(null);
|
||||
const cancelRef = useRef<() => void>(() => { });
|
||||
const responseRef = useRef('');
|
||||
|
||||
function clearError() {
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function clearBillingError() {
|
||||
setBillingError(null);
|
||||
}
|
||||
|
||||
const start = useCallback(async (
|
||||
messages: z.infer<typeof CopilotMessage>[],
|
||||
onDone: (finalResponse: string) => void
|
||||
onDone: (finalResponse: string) => void,
|
||||
) => {
|
||||
if (!messages.length || messages.at(-1)?.role !== 'user') return;
|
||||
|
||||
|
|
@ -44,6 +55,15 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
|
|||
|
||||
try {
|
||||
const res = await getCopilotResponseStream(projectId, messages, workflow, context || null, dataSources);
|
||||
|
||||
// Check for billing error
|
||||
if ('billingError' in res) {
|
||||
setLoading(false);
|
||||
setError(res.billingError);
|
||||
setBillingError(res.billingError);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventSource = new EventSource(`/api/copilot-stream-response/${res.streamId}`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
|
|
@ -84,6 +104,9 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
|
|||
streamingResponse,
|
||||
loading,
|
||||
error,
|
||||
clearError,
|
||||
billingError,
|
||||
clearBillingError,
|
||||
start,
|
||||
cancel,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { AgenticAPITool } from "../../../lib/types/agents_api_types";
|
|||
import { WorkflowPrompt, WorkflowAgent, Workflow, WorkflowTool } from "../../../lib/types/workflow_types";
|
||||
import { DataSource } from "../../../lib/types/datasource_types";
|
||||
import { z } from "zod";
|
||||
import { PlusIcon, Sparkles, X as XIcon, ChevronDown, ChevronRight, Trash2, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { PlusIcon, Sparkles, X as XIcon, ChevronDown, ChevronRight, Trash2, Maximize2, Minimize2, StarIcon } from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { usePreviewModal } from "../workflow/preview-modal";
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Select, SelectItem } from "@heroui/react";
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Select, SelectItem, Chip, SelectSection } from "@heroui/react";
|
||||
import { PreviewModalProvider } from "../workflow/preview-modal";
|
||||
import { CopilotMessage } from "@/app/lib/types/copilot_types";
|
||||
import { getCopilotAgentInstructions } from "@/app/actions/copilot_actions";
|
||||
|
|
@ -23,6 +23,8 @@ import { USE_TRANSFER_CONTROL_OPTIONS } from "@/app/lib/feature_flags";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Info } from "lucide-react";
|
||||
import { useCopilot } from "../copilot/use-copilot";
|
||||
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
|
||||
import { ModelsResponse } from "@/app/lib/types/billing_types";
|
||||
|
||||
// Common section header styles
|
||||
const sectionHeaderStyles = "text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400";
|
||||
|
|
@ -47,6 +49,7 @@ export function AgentConfig({
|
|||
handleClose,
|
||||
useRag,
|
||||
triggerCopilotChat,
|
||||
eligibleModels,
|
||||
}: {
|
||||
projectId: string,
|
||||
workflow: z.infer<typeof Workflow>,
|
||||
|
|
@ -61,6 +64,7 @@ export function AgentConfig({
|
|||
handleClose: () => void,
|
||||
useRag: boolean,
|
||||
triggerCopilotChat: (message: string) => void,
|
||||
eligibleModels: z.infer<typeof ModelsResponse.shape.agentModels> | "*",
|
||||
}) {
|
||||
const [isAdvancedConfigOpen, setIsAdvancedConfigOpen] = useState(false);
|
||||
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
||||
|
|
@ -72,6 +76,7 @@ export function AgentConfig({
|
|||
const [activeTab, setActiveTab] = useState<TabType>('instructions');
|
||||
const [showRagCta, setShowRagCta] = useState(false);
|
||||
const [previousRagSources, setPreviousRagSources] = useState<string[]>([]);
|
||||
const [billingError, setBillingError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
start: startCopilotChat,
|
||||
|
|
@ -490,7 +495,7 @@ export function AgentConfig({
|
|||
<label className={sectionHeaderStyles}>
|
||||
Model
|
||||
</label>
|
||||
<div className="relative ml-2 group">
|
||||
{eligibleModels === "*" && <div className="relative ml-2 group">
|
||||
<Info
|
||||
className="w-4 h-4 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer transition-colors"
|
||||
/>
|
||||
|
|
@ -505,17 +510,67 @@ export function AgentConfig({
|
|||
By default, the model is set to gpt-4.1, assuming your OpenAI API key is set in PROVIDER_API_KEY and PROVIDER_BASE_URL is not set.
|
||||
<div className="absolute h-2 w-2 bg-white dark:bg-gray-800 transform rotate-45 -bottom-1 left-4 border-r border-b border-gray-200 dark:border-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Input
|
||||
{eligibleModels === "*" && <Input
|
||||
value={agent.model}
|
||||
onChange={(e) => handleUpdate({
|
||||
...agent,
|
||||
model: e.target.value as z.infer<typeof WorkflowAgent>['model']
|
||||
})}
|
||||
className="w-full max-w-64"
|
||||
/>
|
||||
/>}
|
||||
{eligibleModels !== "*" && <Select
|
||||
variant="bordered"
|
||||
placeholder="Select model"
|
||||
className="w-full max-w-64"
|
||||
selectedKeys={[agent.model]}
|
||||
onSelectionChange={(keys) => {
|
||||
const key = keys.currentKey as string;
|
||||
const model = eligibleModels.find((m) => m.name === key);
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
if (!model.eligible) {
|
||||
setBillingError(`Please upgrade to the ${model.plan.toUpperCase()} plan to use this model.`);
|
||||
return;
|
||||
}
|
||||
handleUpdate({
|
||||
...agent,
|
||||
model: key as z.infer<typeof WorkflowAgent>['model']
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectSection title="Available">
|
||||
{eligibleModels.filter((model) => model.eligible).map((model) => (
|
||||
<SelectItem
|
||||
key={model.name}
|
||||
>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectSection>
|
||||
<SelectSection title="Requires plan upgrade">
|
||||
{eligibleModels.filter((model) => !model.eligible).map((model) => (
|
||||
<SelectItem
|
||||
key={model.name}
|
||||
endContent={<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
>
|
||||
{model.plan.toUpperCase()}
|
||||
</Chip>
|
||||
}
|
||||
startContent={<StarIcon className="w-4 h-4 text-warning" />}
|
||||
>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectSection>
|
||||
</Select>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -764,6 +819,12 @@ export function AgentConfig({
|
|||
}}
|
||||
/>
|
||||
</PreviewModalProvider>
|
||||
|
||||
<BillingUpgradeModal
|
||||
isOpen={!!billingError}
|
||||
onClose={() => setBillingError(null)}
|
||||
errorMessage={billingError || ''}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
|
|
@ -789,6 +850,7 @@ function GenerateInstructionsModal({
|
|||
const [prompt, setPrompt] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [billingError, setBillingError] = useState<string | null>(null);
|
||||
const { showPreview } = usePreviewModal();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
|
|
@ -797,6 +859,7 @@ function GenerateInstructionsModal({
|
|||
setPrompt("");
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
setBillingError(null);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
|
@ -804,6 +867,7 @@ function GenerateInstructionsModal({
|
|||
const handleGenerate = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setBillingError(null);
|
||||
try {
|
||||
const msgs: z.infer<typeof CopilotMessage>[] = [
|
||||
{
|
||||
|
|
@ -812,6 +876,12 @@ function GenerateInstructionsModal({
|
|||
},
|
||||
];
|
||||
const newInstructions = await getCopilotAgentInstructions(projectId, msgs, workflow, agent.name);
|
||||
if (typeof newInstructions === 'object' && 'billingError' in newInstructions) {
|
||||
setBillingError(newInstructions.billingError);
|
||||
setError(newInstructions.billingError);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
|
||||
|
|
@ -840,59 +910,66 @@ function GenerateInstructionsModal({
|
|||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||
<ModalContent>
|
||||
<ModalHeader>Generate Instructions</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && (
|
||||
<div className="p-2 bg-red-50 border border-red-200 rounded-lg flex gap-2 justify-between items-center text-sm">
|
||||
<p className="text-red-600">{error}</p>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
handleGenerate();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</CustomButton>
|
||||
</div>
|
||||
)}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
<>
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||
<ModalContent>
|
||||
<ModalHeader>Generate Instructions</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && (
|
||||
<div className="p-2 bg-red-50 border border-red-200 rounded-lg flex gap-2 justify-between items-center text-sm">
|
||||
<p className="text-red-600">{error}</p>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
handleGenerate();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</CustomButton>
|
||||
</div>
|
||||
)}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isLoading}
|
||||
placeholder="e.g., This agent should help users analyze their data and provide insights..."
|
||||
className={textareaStyles}
|
||||
autoResize
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<CustomButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
placeholder="e.g., This agent should help users analyze their data and provide insights..."
|
||||
className={textareaStyles}
|
||||
autoResize
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<CustomButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
disabled={!prompt.trim() || isLoading}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Generate
|
||||
</CustomButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
disabled={!prompt.trim() || isLoading}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Generate
|
||||
</CustomButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<BillingUpgradeModal
|
||||
isOpen={!!billingError}
|
||||
onClose={() => setBillingError(null)}
|
||||
errorMessage={billingError || ''}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
|
||||
export default function Page({
|
||||
export default async function Page({
|
||||
params
|
||||
}: {
|
||||
params: { projectId: string }
|
||||
}) {
|
||||
await requireActiveBillingSubscription();
|
||||
redirect(`/projects/${params.projectId}/workflow`);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { TestProfile } from "@/app/lib/types/testing_types";
|
|||
import { WithStringId } from "@/app/lib/types/types";
|
||||
import { ProfileContextBox } from "./profile-context-box";
|
||||
import { USE_TESTING_FEATURE } from "@/app/lib/feature_flags";
|
||||
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
|
||||
|
||||
export function Chat({
|
||||
chat,
|
||||
|
|
@ -51,6 +52,7 @@ export function Chat({
|
|||
last_agent_name: workflow.startAgent,
|
||||
});
|
||||
const [fetchResponseError, setFetchResponseError] = useState<string | null>(null);
|
||||
const [billingError, setBillingError] = useState<string | null>(null);
|
||||
const [lastAgenticRequest, setLastAgenticRequest] = useState<unknown | null>(null);
|
||||
const [lastAgenticResponse, setLastAgenticResponse] = useState<unknown | null>(null);
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>(chat.messages);
|
||||
|
|
@ -160,6 +162,13 @@ export function Chat({
|
|||
if (ignore) {
|
||||
return;
|
||||
}
|
||||
if ('billingError' in response) {
|
||||
setBillingError(response.billingError);
|
||||
setFetchResponseError(response.billingError);
|
||||
setLoadingAssistantResponse(false);
|
||||
console.log('returning from getAssistantResponseStreamId due to billing error');
|
||||
return;
|
||||
}
|
||||
streamId = response.streamId;
|
||||
} catch (err) {
|
||||
if (!ignore) {
|
||||
|
|
@ -246,6 +255,7 @@ export function Chat({
|
|||
return;
|
||||
}
|
||||
|
||||
console.log(`executing response process: fetchresponseerr: ${fetchResponseError}`);
|
||||
process();
|
||||
|
||||
return () => {
|
||||
|
|
@ -307,7 +317,10 @@ export function Chat({
|
|||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
onPress={() => setFetchResponseError(null)}
|
||||
onPress={() => {
|
||||
setFetchResponseError(null);
|
||||
setBillingError(null);
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
|
|
@ -322,5 +335,12 @@ export function Chat({
|
|||
onFocus={() => setIsLastInteracted(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<BillingUpgradeModal
|
||||
isOpen={!!billingError}
|
||||
onClose={() => setBillingError(null)}
|
||||
errorMessage={billingError || ''}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { SourcePage } from "./source-page";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
|
|
@ -8,5 +9,6 @@ export default async function Page({
|
|||
sourceId: string
|
||||
}
|
||||
}) {
|
||||
await requireActiveBillingSubscription();
|
||||
return <SourcePage projectId={params.projectId} sourceId={params.sourceId} />;
|
||||
}
|
||||
|
|
@ -17,7 +17,9 @@ import { Section, SectionRow, SectionLabel, SectionContent } from "../components
|
|||
import Link from "next/link";
|
||||
import { BackIcon } from "../../../../lib/components/icons";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { CheckIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
|
||||
|
||||
export function SourcePage({
|
||||
sourceId,
|
||||
|
|
@ -29,11 +31,15 @@ export function SourcePage({
|
|||
const [source, setSource] = useState<WithStringId<z.infer<typeof DataSource>> | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showSaveSuccess, setShowSaveSuccess] = useState(false);
|
||||
const [billingError, setBillingError] = useState<string | null>(null);
|
||||
|
||||
async function handleReload() {
|
||||
setIsLoading(true);
|
||||
const updatedSource = await getDataSource(projectId, sourceId);
|
||||
setSource(updatedSource);
|
||||
if ("billingError" in updatedSource && updatedSource.billingError) {
|
||||
setBillingError(updatedSource.billingError);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +51,9 @@ export function SourcePage({
|
|||
const source = await getDataSource(projectId, sourceId);
|
||||
if (!ignore) {
|
||||
setSource(source);
|
||||
if ("billingError" in source && source.billingError) {
|
||||
setBillingError(source.billingError);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
|
@ -74,6 +83,9 @@ export function SourcePage({
|
|||
const updatedSource = await getDataSource(projectId, sourceId);
|
||||
if (!ignore) {
|
||||
setSource(updatedSource);
|
||||
if ("billingError" in updatedSource && updatedSource.billingError) {
|
||||
setBillingError(updatedSource.billingError);
|
||||
}
|
||||
timeout = setTimeout(refresh, 15 * 1000);
|
||||
}
|
||||
}
|
||||
|
|
@ -206,9 +218,22 @@ export function SourcePage({
|
|||
<SectionLabel>Status</SectionLabel>
|
||||
<SectionContent>
|
||||
<SourceStatus status={source.status} projectId={projectId} />
|
||||
|
||||
{("billingError" in source) && source.billingError && <div className="flex flex-col gap-1 items-start mt-4">
|
||||
<div className="text-sm">{source.billingError}</div>
|
||||
<Button
|
||||
onClick={() => source.billingError ? setBillingError(source.billingError) : null}
|
||||
variant="tertiary"
|
||||
className="bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 hover:text-yellow-700 dark:hover:text-yellow-300 text-sm p-2"
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
</div>}
|
||||
</SectionContent>
|
||||
</SectionRow>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
|
|
@ -251,7 +276,12 @@ export function SourcePage({
|
|||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div >
|
||||
<BillingUpgradeModal
|
||||
isOpen={!!billingError}
|
||||
onClose={() => setBillingError(null)}
|
||||
errorMessage={billingError || ''}
|
||||
/>
|
||||
</Panel >
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { Metadata } from "next";
|
|||
import { Form } from "./form";
|
||||
import { redirect } from "next/navigation";
|
||||
import { USE_RAG, USE_RAG_UPLOADS, USE_RAG_S3_UPLOADS, USE_RAG_SCRAPING } from "../../../../lib/feature_flags";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Add data source"
|
||||
|
|
@ -12,6 +13,7 @@ export default async function Page({
|
|||
}: {
|
||||
params: { projectId: string }
|
||||
}) {
|
||||
await requireActiveBillingSubscription();
|
||||
if (!USE_RAG) {
|
||||
redirect(`/projects/${params.projectId}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Metadata } from "next";
|
||||
import { SourcesList } from "./components/sources-list";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Data sources",
|
||||
|
|
@ -10,6 +11,7 @@ export default async function Page({
|
|||
}: {
|
||||
params: { projectId: string }
|
||||
}) {
|
||||
await requireActiveBillingSubscription();
|
||||
return <SourcesList
|
||||
projectId={params.projectId}
|
||||
/>;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
|
||||
import { ScenariosApp } from "./scenarios_app";
|
||||
import { SimulationsApp } from "./simulations_app";
|
||||
import { ProfilesApp } from "./profiles_app";
|
||||
import { RunsApp } from "./runs_app";
|
||||
import { TestingMenu } from "./testing_menu";
|
||||
export default function TestPage({ params }: { params: { projectId: string; slug?: string[] } }) {
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
|
||||
export default async function TestPage({ params }: { params: { projectId: string; slug?: string[] } }) {
|
||||
await requireActiveBillingSubscription();
|
||||
const { projectId, slug = [] } = params;
|
||||
let app: "scenarios" | "simulations" | "profiles" | "runs" = "runs";
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
ServerCard,
|
||||
ToolManagementPanel,
|
||||
} from './MCPServersCommon';
|
||||
import type { Key } from 'react';
|
||||
import { BillingUpgradeModal } from '@/components/common/billing-upgrade-modal';
|
||||
|
||||
type McpServerType = z.infer<typeof MCPServer>;
|
||||
type McpToolType = z.infer<typeof MCPServer>['tools'][number];
|
||||
|
|
@ -139,6 +139,7 @@ export function HostedServers({ onSwitchTab }: HostedServersProps) {
|
|||
const [savingTools, setSavingTools] = useState(false);
|
||||
const [serverToolCounts, setServerToolCounts] = useState<Map<string, number>>(new Map());
|
||||
const [syncingServers, setSyncingServers] = useState<Set<string>>(new Set());
|
||||
const [billingError, setBillingError] = useState<string | null>(null);
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -239,6 +240,7 @@ export function HostedServers({ onSwitchTab }: HostedServersProps) {
|
|||
return next;
|
||||
});
|
||||
setToggleError(null);
|
||||
setBillingError(null);
|
||||
|
||||
setServerOperations(prev => {
|
||||
const next = new Map(prev);
|
||||
|
|
@ -249,6 +251,24 @@ export function HostedServers({ onSwitchTab }: HostedServersProps) {
|
|||
try {
|
||||
const result = await enableServer(server.name, projectId || "", newState);
|
||||
|
||||
// Check for billing error
|
||||
if ('billingError' in result) {
|
||||
setBillingError(result.billingError);
|
||||
// Revert UI state
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === serverKey) {
|
||||
return {
|
||||
...s,
|
||||
isActive: isCurrentlyEnabled
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setEnabledServers(prev => {
|
||||
const next = new Set(prev);
|
||||
if (!newState) {
|
||||
|
|
@ -687,6 +707,12 @@ export function HostedServers({ onSwitchTab }: HostedServersProps) {
|
|||
isSaving={savingTools}
|
||||
isSyncing={selectedServer ? syncingServers.has(selectedServer.name) : false}
|
||||
/>
|
||||
|
||||
<BillingUpgradeModal
|
||||
isOpen={!!billingError}
|
||||
onClose={() => setBillingError(null)}
|
||||
errorMessage={billingError || ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import { Suspense } from 'react';
|
||||
import { ToolsConfig } from './components/ToolsConfig';
|
||||
import { PageHeader } from '@/components/ui/page-header';
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
|
||||
export default async function ToolsPage() {
|
||||
await requireActiveBillingSubscription();
|
||||
|
||||
export default function ToolsPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { listDataSources } from "../../../actions/datasource_actions";
|
|||
import { listMcpServers, listProjectMcpTools } from "@/app/actions/mcp_actions";
|
||||
import { getProjectConfig } from "@/app/actions/project_actions";
|
||||
import { WorkflowTool } from "@/app/lib/types/workflow_types";
|
||||
import { getEligibleModels } from "@/app/actions/billing_actions";
|
||||
import { ModelsResponse } from "@/app/lib/types/billing_types";
|
||||
|
||||
export function App({
|
||||
projectId,
|
||||
|
|
@ -31,15 +33,28 @@ export function App({
|
|||
const [autoSelectIfOnlyOneWorkflow, setAutoSelectIfOnlyOneWorkflow] = useState(true);
|
||||
const [mcpServerUrls, setMcpServerUrls] = useState<Array<z.infer<typeof MCPServer>>>([]);
|
||||
const [toolWebhookUrl, setToolWebhookUrl] = useState<string>('');
|
||||
const [eligibleModels, setEligibleModels] = useState<z.infer<typeof ModelsResponse> | "*">("*");
|
||||
|
||||
const handleSelect = useCallback(async (workflowId: string) => {
|
||||
setLoading(true);
|
||||
const workflow = await fetchWorkflow(projectId, workflowId);
|
||||
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
|
||||
const dataSources = await listDataSources(projectId);
|
||||
const mcpServers = await listMcpServers(projectId);
|
||||
const projectConfig = await getProjectConfig(projectId);
|
||||
const projectTools = await listProjectMcpTools(projectId);
|
||||
const [
|
||||
workflow,
|
||||
publishedWorkflowId,
|
||||
dataSources,
|
||||
mcpServers,
|
||||
projectConfig,
|
||||
projectTools,
|
||||
eligibleModels,
|
||||
] = await Promise.all([
|
||||
fetchWorkflow(projectId, workflowId),
|
||||
fetchPublishedWorkflowId(projectId),
|
||||
listDataSources(projectId),
|
||||
listMcpServers(projectId),
|
||||
getProjectConfig(projectId),
|
||||
listProjectMcpTools(projectId),
|
||||
getEligibleModels(),
|
||||
]);
|
||||
|
||||
// Store the selected workflow ID in local storage
|
||||
localStorage.setItem(`lastWorkflowId_${projectId}`, workflowId);
|
||||
setWorkflow(workflow);
|
||||
|
|
@ -48,6 +63,7 @@ export function App({
|
|||
setMcpServerUrls(mcpServers);
|
||||
setToolWebhookUrl(projectConfig.webhookUrl ?? '');
|
||||
setProjectTools(projectTools);
|
||||
setEligibleModels(eligibleModels);
|
||||
setLoading(false);
|
||||
}, [projectId]);
|
||||
|
||||
|
|
@ -126,6 +142,7 @@ export function App({
|
|||
mcpServerUrls={mcpServerUrls}
|
||||
toolWebhookUrl={toolWebhookUrl}
|
||||
defaultModel={defaultModel}
|
||||
eligibleModels={eligibleModels}
|
||||
/>}
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { App } from "./app";
|
|||
import { USE_RAG } from "@/app/lib/feature_flags";
|
||||
import { projectsCollection } from "@/app/lib/mongodb";
|
||||
import { notFound } from "next/navigation";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
|
||||
const DEFAULT_MODEL = process.env.PROVIDER_DEFAULT_MODEL || "gpt-4.1";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -14,6 +16,7 @@ export default async function Page({
|
|||
}: {
|
||||
params: { projectId: string };
|
||||
}) {
|
||||
await requireActiveBillingSubscription();
|
||||
console.log('->>> workflow page being rendered');
|
||||
const project = await projectsCollection.findOne({
|
||||
_id: params.projectId,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { BackIcon, HamburgerIcon, WorkflowIcon } from "../../../lib/components/i
|
|||
import { CopyIcon, ImportIcon, Layers2Icon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle } from "lucide-react";
|
||||
import { EntityList } from "./entity_list";
|
||||
import { ProductTour } from "@/components/common/product-tour";
|
||||
import { ModelsResponse } from "@/app/lib/types/billing_types";
|
||||
|
||||
enablePatches();
|
||||
|
||||
|
|
@ -563,6 +564,7 @@ export function WorkflowEditor({
|
|||
toolWebhookUrl,
|
||||
defaultModel,
|
||||
projectTools,
|
||||
eligibleModels,
|
||||
}: {
|
||||
dataSources: WithStringId<z.infer<typeof DataSource>>[];
|
||||
workflow: WithStringId<z.infer<typeof Workflow>>;
|
||||
|
|
@ -574,6 +576,7 @@ export function WorkflowEditor({
|
|||
toolWebhookUrl: string;
|
||||
defaultModel: string;
|
||||
projectTools: z.infer<typeof WorkflowTool>[];
|
||||
eligibleModels: z.infer<typeof ModelsResponse> | "*";
|
||||
}) {
|
||||
|
||||
const [state, dispatch] = useReducer<Reducer<State, Action>>(reducer, {
|
||||
|
|
@ -1026,6 +1029,7 @@ export function WorkflowEditor({
|
|||
handleClose={handleUnselectAgent}
|
||||
useRag={useRag}
|
||||
triggerCopilotChat={triggerCopilotChat}
|
||||
eligibleModels={eligibleModels === "*" ? "*" : eligibleModels.agentModels}
|
||||
/>}
|
||||
{state.present.selection?.type === "tool" && (() => {
|
||||
const selectedTool = state.present.workflow.tools.find(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { USE_AUTH, USE_RAG } from "../lib/feature_flags";
|
||||
import { USE_AUTH, USE_BILLING, USE_RAG } from "../lib/feature_flags";
|
||||
import AppLayout from './layout/components/app-layout';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
|
@ -9,7 +9,7 @@ export default function Layout({
|
|||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<AppLayout useRag={USE_RAG} useAuth={USE_AUTH}>
|
||||
<AppLayout useRag={USE_RAG} useAuth={USE_AUTH} useBilling={USE_BILLING}>
|
||||
{children}
|
||||
</AppLayout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,16 +7,16 @@ interface AppLayoutProps {
|
|||
children: ReactNode;
|
||||
useRag?: boolean;
|
||||
useAuth?: boolean;
|
||||
useBilling?: boolean;
|
||||
}
|
||||
|
||||
export default function AppLayout({ children, useRag = false, useAuth = false }: AppLayoutProps) {
|
||||
export default function AppLayout({ children, useRag = false, useAuth = false, useBilling = false }: AppLayoutProps) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
||||
const pathname = usePathname();
|
||||
const projectId = pathname.split('/')[2];
|
||||
|
||||
// For invalid projectId, return just the children
|
||||
if (!projectId && !pathname.startsWith('/projects')) {
|
||||
return children;
|
||||
let projectId: string|null = null;
|
||||
if (pathname.startsWith('/projects')) {
|
||||
projectId = pathname.split('/')[2];
|
||||
}
|
||||
|
||||
// Layout with sidebar for all routes
|
||||
|
|
@ -25,11 +25,12 @@ export default function AppLayout({ children, useRag = false, useAuth = false }:
|
|||
{/* Sidebar with improved shadow and blur */}
|
||||
<div className="overflow-hidden rounded-xl bg-white/70 dark:bg-zinc-800/70 shadow-sm backdrop-blur-sm">
|
||||
<Sidebar
|
||||
projectId={projectId}
|
||||
projectId={projectId ?? undefined}
|
||||
useRag={useRag}
|
||||
useAuth={useAuth}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
useBilling={useBilling}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -23,17 +23,18 @@ import { USE_TESTING_FEATURE, USE_PRODUCT_TOUR } from '@/app/lib/feature_flags';
|
|||
import { useHelpModal } from "@/app/providers/help-modal-provider";
|
||||
|
||||
interface SidebarProps {
|
||||
projectId: string;
|
||||
projectId?: string;
|
||||
useRag: boolean;
|
||||
useAuth: boolean;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
useBilling?: boolean;
|
||||
}
|
||||
|
||||
const EXPANDED_ICON_SIZE = 20;
|
||||
const COLLAPSED_ICON_SIZE = 20; // DO NOT CHANGE THIS
|
||||
|
||||
export default function Sidebar({ projectId, useRag, useAuth, collapsed = false, onToggleCollapse }: SidebarProps) {
|
||||
export default function Sidebar({ projectId, useRag, useAuth, collapsed = false, onToggleCollapse, useBilling }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const [projectName, setProjectName] = useState<string>("Select Project");
|
||||
const isProjectsRoute = pathname === '/projects' || pathname === '/projects/select';
|
||||
|
|
@ -123,8 +124,8 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
|
|||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Navigation Items */}
|
||||
<nav className="p-3 space-y-4">
|
||||
{/* Project-specific navigation Items */}
|
||||
{projectId && <nav className="p-3 space-y-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const fullPath = `/projects/${projectId}/${item.href}`;
|
||||
|
|
@ -184,7 +185,7 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
|
|||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</nav>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -252,7 +253,7 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
|
|||
hover:bg-zinc-100 dark:hover:bg-zinc-800/50
|
||||
`}
|
||||
>
|
||||
<UserButton />
|
||||
<UserButton useBilling={useBilling} />
|
||||
{!collapsed && <span>Account</span>}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { redirect } from 'next/navigation';
|
||||
import { requireActiveBillingSubscription } from '../lib/billing';
|
||||
|
||||
export default function Page() {
|
||||
export default async function Page() {
|
||||
await requireActiveBillingSubscription();
|
||||
redirect('/projects/select');
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { FolderOpenIcon, InformationCircleIcon } from "@heroicons/react/24/outli
|
|||
import { USE_MULTIPLE_PROJECTS } from "@/app/lib/feature_flags";
|
||||
import { HorizontalDivider } from "@/components/ui/horizontal-divider";
|
||||
import { Tooltip } from "@heroui/react";
|
||||
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
|
||||
|
||||
// Add glow animation styles
|
||||
const glowStyles = `
|
||||
|
|
@ -137,6 +138,7 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
|
|||
const [customPrompt, setCustomPrompt] = useState("");
|
||||
const [name, setName] = useState(defaultName);
|
||||
const [promptError, setPromptError] = useState<string | null>(null);
|
||||
const [billingError, setBillingError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Add this effect to update name when defaultName changes
|
||||
|
|
@ -194,7 +196,7 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
|
|||
setIsExamplesDropdownOpen(false);
|
||||
};
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
if (selectedTab !== TabType.Blank && !customPrompt.trim()) {
|
||||
setPromptError("Prompt cannot be empty");
|
||||
|
|
@ -213,237 +215,226 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
|
|||
newFormData.append('name', name);
|
||||
newFormData.append('prompt', customPrompt);
|
||||
response = await createProjectFromPrompt(newFormData);
|
||||
}
|
||||
|
||||
if (response?.id && customPrompt) {
|
||||
if ('id' in response) {
|
||||
if (selectedTab !== TabType.Blank && customPrompt) {
|
||||
localStorage.setItem(`project_prompt_${response.id}`, customPrompt);
|
||||
}
|
||||
router.push(`/projects/${response.id}/workflow`);
|
||||
} else {
|
||||
setBillingError(response.billingError);
|
||||
}
|
||||
|
||||
if (!response?.id) {
|
||||
throw new Error('Project creation failed');
|
||||
}
|
||||
|
||||
router.push(`/projects/${response.id}/workflow`);
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' &&
|
||||
selectedTab !== TabType.Blank &&
|
||||
(e.target as HTMLElement).tagName !== 'TEXTAREA') {
|
||||
e.preventDefault();
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
handleSubmit(formData);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx(
|
||||
"overflow-auto",
|
||||
!USE_MULTIPLE_PROJECTS && "max-w-none px-12 py-12",
|
||||
USE_MULTIPLE_PROJECTS && !isProjectPaneOpen && "col-span-full"
|
||||
)}>
|
||||
<section className={clsx(
|
||||
"card h-full",
|
||||
!USE_MULTIPLE_PROJECTS && "px-24",
|
||||
USE_MULTIPLE_PROJECTS && "px-8"
|
||||
<>
|
||||
<div className={clsx(
|
||||
"overflow-auto",
|
||||
!USE_MULTIPLE_PROJECTS && "max-w-none px-12 py-12",
|
||||
USE_MULTIPLE_PROJECTS && !isProjectPaneOpen && "col-span-full"
|
||||
)}>
|
||||
{USE_MULTIPLE_PROJECTS && (
|
||||
<>
|
||||
<div className="px-4 pt-4 pb-6 flex justify-between items-center">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Create new assistant
|
||||
</h1>
|
||||
{!isProjectPaneOpen && (
|
||||
<Button
|
||||
onClick={onOpenProjectPane}
|
||||
variant="primary"
|
||||
size="md"
|
||||
startContent={<FolderOpenIcon className="w-4 h-4" />}
|
||||
>
|
||||
View Existing Projects
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<HorizontalDivider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<form
|
||||
id="create-project-form"
|
||||
action={handleSubmit}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
handleSubmit(formData);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="pt-6 pb-16 space-y-12"
|
||||
>
|
||||
{/* Tab Section */}
|
||||
<div>
|
||||
<div className="mb-5">
|
||||
<SectionHeading>
|
||||
✨ Get started
|
||||
</SectionHeading>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-6 relative">
|
||||
<Button
|
||||
variant={selectedTab === TabType.Describe ? 'primary' : 'tertiary'}
|
||||
size="md"
|
||||
onClick={() => handleTabChange(TabType.Describe)}
|
||||
className={selectedTab === TabType.Describe ? selectedTabStyles : unselectedTabStyles}
|
||||
>
|
||||
Describe your assistant
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedTab === TabType.Blank ? 'primary' : 'tertiary'}
|
||||
size="md"
|
||||
onClick={handleBlankTemplateClick}
|
||||
type="button"
|
||||
className={selectedTab === TabType.Blank ? selectedTabStyles : unselectedTabStyles}
|
||||
>
|
||||
Start from a blank template
|
||||
</Button>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Button
|
||||
variant={selectedTab === TabType.Example ? 'primary' : 'tertiary'}
|
||||
size="md"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsExamplesDropdownOpen(!isExamplesDropdownOpen);
|
||||
}}
|
||||
type="button"
|
||||
className={selectedTab === TabType.Example ? selectedTabStyles : unselectedTabStyles}
|
||||
endContent={
|
||||
<svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Use an example
|
||||
</Button>
|
||||
|
||||
{isExamplesDropdownOpen && (
|
||||
<div className="absolute z-10 mt-2 min-w-[200px] max-w-[240px] rounded-lg shadow-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
<div className="py-1">
|
||||
{Object.entries(starting_copilot_prompts)
|
||||
.filter(([name]) => name !== 'Blank Template')
|
||||
.map(([name]) => (
|
||||
<Button
|
||||
key={name}
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left text-sm py-1.5"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleExampleSelect(name);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<section className={clsx(
|
||||
"card h-full",
|
||||
!USE_MULTIPLE_PROJECTS && "px-24",
|
||||
USE_MULTIPLE_PROJECTS && "px-8"
|
||||
)}>
|
||||
{USE_MULTIPLE_PROJECTS && (
|
||||
<>
|
||||
<div className="px-4 pt-4 pb-6 flex justify-between items-center">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Create new assistant
|
||||
</h1>
|
||||
{!isProjectPaneOpen && (
|
||||
<Button
|
||||
onClick={onOpenProjectPane}
|
||||
variant="primary"
|
||||
size="md"
|
||||
startContent={<FolderOpenIcon className="w-4 h-4" />}
|
||||
>
|
||||
View Existing Projects
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalDivider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Custom Prompt Section - Only show when needed */}
|
||||
{(selectedTab === TabType.Describe || selectedTab === TabType.Example) && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className={largeSectionHeaderStyles}>
|
||||
{selectedTab === TabType.Describe ? '✏️ What do you want to build?' : '✏️ Customize the description'}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
In the next step, our AI copilot will create agents for you, complete with mock-tools.
|
||||
</p>
|
||||
<Tooltip content={<div>If you already know the specific agents and tools you need, mention them below.<br /><br />Specify 'internal agents' for task agents that will not interact with the user and 'user-facing agents' for conversational agents that will interact with users.</div>} className="max-w-[560px]">
|
||||
<InformationCircleIcon className="w-4 h-4 text-indigo-500 hover:text-indigo-600 dark:text-indigo-400 dark:hover:text-indigo-300 cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={customPrompt}
|
||||
onChange={(e) => {
|
||||
setCustomPrompt(e.target.value);
|
||||
setPromptError(null);
|
||||
<form
|
||||
id="create-project-form"
|
||||
action={handleSubmit}
|
||||
className="pt-6 pb-16 space-y-12"
|
||||
>
|
||||
{/* Tab Section */}
|
||||
<div>
|
||||
<div className="mb-5">
|
||||
<SectionHeading>
|
||||
✨ Get started
|
||||
</SectionHeading>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-6 relative">
|
||||
<Button
|
||||
variant={selectedTab === TabType.Describe ? 'primary' : 'tertiary'}
|
||||
size="md"
|
||||
onClick={() => handleTabChange(TabType.Describe)}
|
||||
className={selectedTab === TabType.Describe ? selectedTabStyles : unselectedTabStyles}
|
||||
>
|
||||
Describe your assistant
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedTab === TabType.Blank ? 'primary' : 'tertiary'}
|
||||
size="md"
|
||||
onClick={handleBlankTemplateClick}
|
||||
type="button"
|
||||
className={selectedTab === TabType.Blank ? selectedTabStyles : unselectedTabStyles}
|
||||
>
|
||||
Start from a blank template
|
||||
</Button>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Button
|
||||
variant={selectedTab === TabType.Example ? 'primary' : 'tertiary'}
|
||||
size="md"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsExamplesDropdownOpen(!isExamplesDropdownOpen);
|
||||
}}
|
||||
placeholder="Example: Create a customer support assistant that can handle product inquiries and returns"
|
||||
type="button"
|
||||
className={selectedTab === TabType.Example ? selectedTabStyles : unselectedTabStyles}
|
||||
endContent={
|
||||
<svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Use an example
|
||||
</Button>
|
||||
|
||||
{isExamplesDropdownOpen && (
|
||||
<div className="absolute z-10 mt-2 min-w-[200px] max-w-[240px] rounded-lg shadow-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
<div className="py-1">
|
||||
{Object.entries(starting_copilot_prompts)
|
||||
.filter(([name]) => name !== 'Blank Template')
|
||||
.map(([name]) => (
|
||||
<Button
|
||||
key={name}
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left text-sm py-1.5"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleExampleSelect(name);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Prompt Section - Only show when needed */}
|
||||
{(selectedTab === TabType.Describe || selectedTab === TabType.Example) && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className={largeSectionHeaderStyles}>
|
||||
{selectedTab === TabType.Describe ? '✏️ What do you want to build?' : '✏️ Customize the description'}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
In the next step, our AI copilot will create agents for you, complete with mock-tools.
|
||||
</p>
|
||||
<Tooltip content={<div>If you already know the specific agents and tools you need, mention them below.<br /><br />Specify 'internal agents' for task agents that will not interact with the user and 'user-facing agents' 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 “Create assistant” below to get started
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name Section */}
|
||||
{USE_MULTIPLE_PROJECTS && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className={largeSectionHeaderStyles}>
|
||||
🏷️ Name the project
|
||||
</label>
|
||||
<Textarea
|
||||
required
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={clsx(
|
||||
textareaStyles,
|
||||
"min-h-[60px]",
|
||||
"text-base",
|
||||
"text-gray-900 dark:text-gray-100",
|
||||
promptError && "border-red-500 focus:ring-red-500/20",
|
||||
!customPrompt && emptyTextareaStyles
|
||||
"text-gray-900 dark:text-gray-100"
|
||||
)}
|
||||
style={{ minHeight: "120px" }}
|
||||
autoFocus
|
||||
autoResize
|
||||
required={isNotBlankTemplate(selectedTab)}
|
||||
placeholder={defaultName}
|
||||
/>
|
||||
{promptError && (
|
||||
<p className="text-sm text-red-500">
|
||||
{promptError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{selectedTab === TabType.Blank && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
👇 Click “Create assistant” below to get started
|
||||
</p>
|
||||
</div>
|
||||
{/* Submit Button */}
|
||||
<div className="pt-1 w-full -mt-4">
|
||||
<Submit />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name Section */}
|
||||
{USE_MULTIPLE_PROJECTS && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className={largeSectionHeaderStyles}>
|
||||
🏷️ Name the project
|
||||
</label>
|
||||
<Textarea
|
||||
required
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={clsx(
|
||||
textareaStyles,
|
||||
"min-h-[60px]",
|
||||
"text-base",
|
||||
"text-gray-900 dark:text-gray-100"
|
||||
)}
|
||||
placeholder={defaultName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-1 w-full -mt-4">
|
||||
<Submit />
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<BillingUpgradeModal
|
||||
isOpen={!!billingError}
|
||||
onClose={() => setBillingError(null)}
|
||||
errorMessage={billingError || ''}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import App from "./app";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
|
||||
export default function Page() {
|
||||
export default async function Page() {
|
||||
await requireActiveBillingSubscription();
|
||||
return <App />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import '../lib/loadenv';
|
||||
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
|
||||
import { z } from 'zod';
|
||||
import { dataSourceDocsCollection, dataSourcesCollection } from '../lib/mongodb';
|
||||
import { dataSourceDocsCollection, dataSourcesCollection, projectsCollection, usersCollection } from '../lib/mongodb';
|
||||
import { EmbeddingRecord, DataSourceDoc, DataSource } from "../lib/types/datasource_types";
|
||||
import { WithId } from 'mongodb';
|
||||
import { ObjectId, WithId } from 'mongodb';
|
||||
import { embedMany, generateText } from 'ai';
|
||||
import { embeddingModel } from '../lib/embedding';
|
||||
import { qdrantClient } from '../lib/qdrant';
|
||||
|
|
@ -15,7 +15,8 @@ import fs from 'fs/promises';
|
|||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { USE_GEMINI_FILE_PARSING } from '../lib/feature_flags';
|
||||
import { USE_BILLING, USE_GEMINI_FILE_PARSING } from '../lib/feature_flags';
|
||||
import { authorize, BillingError, getCustomerIdForProject, logUsage } from '../lib/billing';
|
||||
|
||||
const FILE_PARSING_PROVIDER_API_KEY = process.env.FILE_PARSING_PROVIDER_API_KEY || process.env.OPENAI_API_KEY || '';
|
||||
const FILE_PARSING_PROVIDER_BASE_URL = process.env.FILE_PARSING_PROVIDER_BASE_URL || undefined;
|
||||
|
|
@ -73,7 +74,7 @@ async function retryable<T>(fn: () => Promise<T>, maxAttempts: number = 3): Prom
|
|||
}
|
||||
}
|
||||
|
||||
async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>> & { data: { type: "file_local" | "file_s3" } }): Promise<void> {
|
||||
async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>> & { data: { type: "file_local" | "file_s3" } }): Promise<number> {
|
||||
const logger = _logger
|
||||
.child(doc._id.toString())
|
||||
.child(doc.name);
|
||||
|
|
@ -133,7 +134,7 @@ async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typ
|
|||
|
||||
// generate embeddings
|
||||
logger.log("Generating embeddings");
|
||||
const { embeddings } = await embedMany({
|
||||
const { embeddings, usage } = await embedMany({
|
||||
model: embeddingModel,
|
||||
values: splits.map((split) => split.pageContent)
|
||||
});
|
||||
|
|
@ -168,6 +169,8 @@ async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typ
|
|||
lastUpdatedAt: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
|
||||
return usage.tokens;
|
||||
}
|
||||
|
||||
async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>>): Promise<void> {
|
||||
|
|
@ -319,11 +322,42 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
|
|||
|
||||
logger.log(`Found ${pendingDocs.length} docs to process`);
|
||||
|
||||
// fetch project, user and billing data
|
||||
let billingCustomerId: string | null = null;
|
||||
if (USE_BILLING) {
|
||||
try {
|
||||
billingCustomerId = await getCustomerIdForProject(job.projectId);
|
||||
} catch (e) {
|
||||
logger.log("Unable to fetch billing customer id:", e);
|
||||
throw new Error("Unable to fetch billing customer id");
|
||||
}
|
||||
}
|
||||
|
||||
// for each doc
|
||||
for (const doc of pendingDocs) {
|
||||
// authorize with billing
|
||||
if (USE_BILLING && billingCustomerId) {
|
||||
const authResponse = await authorize(billingCustomerId, {
|
||||
type: "process_rag",
|
||||
data: {},
|
||||
});
|
||||
|
||||
if ('error' in authResponse) {
|
||||
throw new BillingError(authResponse.error || "Unknown billing error")
|
||||
}
|
||||
}
|
||||
|
||||
const ldoc = doc as WithId<z.infer<typeof DataSourceDoc>> & { data: { type: "file_local" | "file_s3" } };
|
||||
try {
|
||||
await runProcessPipeline(logger, job, ldoc);
|
||||
const usedTokens = await runProcessPipeline(logger, job, ldoc);
|
||||
|
||||
// log usage in billing
|
||||
if (USE_BILLING && billingCustomerId) {
|
||||
await logUsage(billingCustomerId, {
|
||||
type: "rag_tokens",
|
||||
amount: usedTokens,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
errors = true;
|
||||
logger.log("Error processing doc:", e);
|
||||
|
|
@ -365,8 +399,29 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof BillingError) {
|
||||
logger.log("Billing error:", e.message);
|
||||
await dataSourcesCollection.updateOne({
|
||||
_id: job._id,
|
||||
version: job.version,
|
||||
}, {
|
||||
$set: {
|
||||
status: "error",
|
||||
billingError: e.message,
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
}
|
||||
logger.log("Error processing job; will retry:", e);
|
||||
await dataSourcesCollection.updateOne({ _id: job._id, version: job.version }, { $set: { status: "error" } });
|
||||
await dataSourcesCollection.updateOne({
|
||||
_id: job._id,
|
||||
version: job.version,
|
||||
}, {
|
||||
$set: {
|
||||
status: "error",
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -379,6 +434,7 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
|
|||
$set: {
|
||||
status: errors ? "error" : "ready",
|
||||
...(errors ? { error: "There were some errors processing this job" } : {}),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { embeddingModel } from '../lib/embedding';
|
|||
import { qdrantClient } from '../lib/qdrant';
|
||||
import { PrefixLogger } from "../lib/utils";
|
||||
import crypto from 'crypto';
|
||||
import { USE_BILLING } from '../lib/feature_flags';
|
||||
import { authorize, BillingError, getCustomerIdForProject, logUsage } from '../lib/billing';
|
||||
|
||||
const splitter = new RecursiveCharacterTextSplitter({
|
||||
separators: ['\n\n', '\n', '. ', '.', ''],
|
||||
|
|
@ -20,7 +22,7 @@ const second = 1000;
|
|||
const minute = 60 * second;
|
||||
const hour = 60 * minute;
|
||||
|
||||
async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>>): Promise<void> {
|
||||
async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>>): Promise<number> {
|
||||
const logger = _logger
|
||||
.child(doc._id.toString())
|
||||
.child(doc.name);
|
||||
|
|
@ -35,7 +37,7 @@ async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typ
|
|||
|
||||
// generate embeddings
|
||||
logger.log("Generating embeddings");
|
||||
const { embeddings } = await embedMany({
|
||||
const { embeddings, usage } = await embedMany({
|
||||
model: embeddingModel,
|
||||
values: splits.map((split) => split.pageContent)
|
||||
});
|
||||
|
|
@ -70,6 +72,8 @@ async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typ
|
|||
lastUpdatedAt: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
|
||||
return usage.tokens;
|
||||
}
|
||||
|
||||
async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>>): Promise<void> {
|
||||
|
|
@ -220,10 +224,41 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
|
|||
|
||||
logger.log(`Found ${pendingDocs.length} docs to process`);
|
||||
|
||||
// fetch project, user and billing data
|
||||
let billingCustomerId: string | null = null;
|
||||
if (USE_BILLING) {
|
||||
try {
|
||||
billingCustomerId = await getCustomerIdForProject(job.projectId);
|
||||
} catch (e) {
|
||||
logger.log("Unable to fetch billing customer id:", e);
|
||||
throw new Error("Unable to fetch billing customer id");
|
||||
}
|
||||
}
|
||||
|
||||
// for each doc
|
||||
for (const doc of pendingDocs) {
|
||||
// authorize with billing
|
||||
if (USE_BILLING && billingCustomerId) {
|
||||
const authResponse = await authorize(billingCustomerId, {
|
||||
type: "process_rag",
|
||||
data: {}
|
||||
});
|
||||
|
||||
if ('error' in authResponse) {
|
||||
throw new BillingError(authResponse.error || "Unknown billing error")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await runProcessPipeline(logger, job, doc);
|
||||
const usedTokens = await runProcessPipeline(logger, job, doc);
|
||||
|
||||
// log usage in billing
|
||||
if (USE_BILLING && billingCustomerId) {
|
||||
await logUsage(billingCustomerId, {
|
||||
type: "rag_tokens",
|
||||
amount: usedTokens,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
errors = true;
|
||||
logger.log("Error processing doc:", e);
|
||||
|
|
@ -265,8 +300,29 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof BillingError) {
|
||||
logger.log("Billing error:", e.message);
|
||||
await dataSourcesCollection.updateOne({
|
||||
_id: job._id,
|
||||
version: job.version,
|
||||
}, {
|
||||
$set: {
|
||||
status: "error",
|
||||
billingError: e.message,
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
}
|
||||
logger.log("Error processing job; will retry:", e);
|
||||
await dataSourcesCollection.updateOne({ _id: job._id, version: job.version }, { $set: { status: "error" } });
|
||||
await dataSourcesCollection.updateOne({
|
||||
_id: job._id,
|
||||
version: job.version,
|
||||
}, {
|
||||
$set: {
|
||||
status: "error",
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import { embeddingModel } from '../lib/embedding';
|
|||
import { qdrantClient } from '../lib/qdrant';
|
||||
import { PrefixLogger } from "../lib/utils";
|
||||
import crypto from 'crypto';
|
||||
import { USE_BILLING } from '../lib/feature_flags';
|
||||
import { authorize, BillingError, getCustomerIdForProject, logUsage } from '../lib/billing';
|
||||
|
||||
const firecrawl = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY });
|
||||
|
||||
|
|
@ -38,7 +40,7 @@ async function retryable<T>(fn: () => Promise<T>, maxAttempts: number = 3): Prom
|
|||
}
|
||||
}
|
||||
|
||||
async function runScrapePipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>>): Promise<void> {
|
||||
async function runScrapePipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>>): Promise<number> {
|
||||
const logger = _logger
|
||||
.child(doc._id.toString())
|
||||
.child(doc.name);
|
||||
|
|
@ -66,7 +68,7 @@ async function runScrapePipeline(_logger: PrefixLogger, job: WithId<z.infer<type
|
|||
|
||||
// generate embeddings
|
||||
logger.log("Generating embeddings");
|
||||
const { embeddings } = await embedMany({
|
||||
const { embeddings, usage } = await embedMany({
|
||||
model: embeddingModel,
|
||||
values: splits.map((split) => split.pageContent)
|
||||
});
|
||||
|
|
@ -101,6 +103,8 @@ async function runScrapePipeline(_logger: PrefixLogger, job: WithId<z.infer<type
|
|||
lastUpdatedAt: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
|
||||
return usage.tokens;
|
||||
}
|
||||
|
||||
async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>>): Promise<void> {
|
||||
|
|
@ -252,10 +256,41 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
|
|||
|
||||
logger.log(`Found ${pendingDocs.length} docs to process`);
|
||||
|
||||
// fetch project, user and billing data
|
||||
let billingCustomerId: string | null = null;
|
||||
if (USE_BILLING) {
|
||||
try {
|
||||
billingCustomerId = await getCustomerIdForProject(job.projectId);
|
||||
} catch (e) {
|
||||
logger.log("Unable to fetch billing customer id:", e);
|
||||
throw new Error("Unable to fetch billing customer id");
|
||||
}
|
||||
}
|
||||
|
||||
// for each doc
|
||||
for (const doc of pendingDocs) {
|
||||
// authorize with billing
|
||||
if (USE_BILLING && billingCustomerId) {
|
||||
const authResponse = await authorize(billingCustomerId, {
|
||||
type: "process_rag",
|
||||
data: {}
|
||||
});
|
||||
|
||||
if ('error' in authResponse) {
|
||||
throw new BillingError(authResponse.error || "Unknown billing error")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await runScrapePipeline(logger, job, doc);
|
||||
const usedTokens = await runScrapePipeline(logger, job, doc);
|
||||
|
||||
// log usage in billing
|
||||
if (USE_BILLING && billingCustomerId) {
|
||||
await logUsage(billingCustomerId, {
|
||||
type: "rag_tokens",
|
||||
amount: usedTokens,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
errors = true;
|
||||
logger.log("Error processing doc:", e);
|
||||
|
|
@ -297,8 +332,29 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<ty
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof BillingError) {
|
||||
logger.log("Billing error:", e.message);
|
||||
await dataSourcesCollection.updateOne({
|
||||
_id: job._id,
|
||||
version: job.version,
|
||||
}, {
|
||||
$set: {
|
||||
status: "error",
|
||||
billingError: e.message,
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
}
|
||||
logger.log("Error processing job; will retry:", e);
|
||||
await dataSourcesCollection.updateOne({ _id: job._id, version: job.version }, { $set: { status: "error" } });
|
||||
await dataSourcesCollection.updateOne({
|
||||
_id: job._id,
|
||||
version: job.version,
|
||||
}, {
|
||||
$set: {
|
||||
status: "error",
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
215
apps/rowboat/components/common/billing-upgrade-modal.tsx
Normal file
215
apps/rowboat/components/common/billing-upgrade-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -33,7 +33,9 @@ export async function middleware(request: NextRequest, event: NextFetchEvent) {
|
|||
return response;
|
||||
}
|
||||
|
||||
if (request.nextUrl.pathname.startsWith('/projects')) {
|
||||
if (request.nextUrl.pathname.startsWith('/projects') ||
|
||||
request.nextUrl.pathname.startsWith('/billing') ||
|
||||
request.nextUrl.pathname.startsWith('/onboarding')) {
|
||||
// Skip auth check if USE_AUTH is not enabled
|
||||
if (process.env.USE_AUTH !== 'true') {
|
||||
return NextResponse.next();
|
||||
|
|
@ -45,5 +47,11 @@ export async function middleware(request: NextRequest, event: NextFetchEvent) {
|
|||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/projects/:path*', '/api/v1/:path*', '/api/widget/v1/:path*'],
|
||||
matcher: [
|
||||
'/projects/:path*',
|
||||
'/billing/:path*',
|
||||
// '/onboarding/:path*',
|
||||
'/api/v1/:path*',
|
||||
'/api/widget/v1/:path*',
|
||||
],
|
||||
};
|
||||
|
|
|
|||
9
apps/rowboat/package-lock.json
generated
9
apps/rowboat/package-lock.json
generated
|
|
@ -33,6 +33,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"eventsource-parser": "^3.0.2",
|
||||
"framer-motion": "^11.5.4",
|
||||
"fuse.js": "^7.1.0",
|
||||
"immer": "^10.1.1",
|
||||
|
|
@ -15831,10 +15832,10 @@
|
|||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource/node_modules/eventsource-parser": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz",
|
||||
"integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==",
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.2.tgz",
|
||||
"integrity": "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA==",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"eventsource-parser": "^3.0.2",
|
||||
"framer-motion": "^11.5.4",
|
||||
"fuse.js": "^7.1.0",
|
||||
"immer": "^10.1.1",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ services:
|
|||
- KLAVIS_API_KEY=${KLAVIS_API_KEY}
|
||||
- KLAVIS_GITHUB_CLIENT_ID=${KLAVIS_GITHUB_CLIENT_ID}
|
||||
- KLAVIS_GOOGLE_CLIENT_ID=${KLAVIS_GOOGLE_CLIENT_ID}
|
||||
- USE_BILLING=${USE_BILLING}
|
||||
- BILLING_API_URL=${BILLING_API_URL}
|
||||
- BILLING_API_KEY=${BILLING_API_KEY}
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
|
|
@ -161,6 +164,9 @@ services:
|
|||
- QDRANT_API_KEY=${QDRANT_API_KEY}
|
||||
- RAG_UPLOADS_DIR=/app/uploads
|
||||
- USE_GEMINI_FILE_PARSING=${USE_GEMINI_FILE_PARSING}
|
||||
- USE_BILLING=${USE_BILLING}
|
||||
- BILLING_API_URL=${BILLING_API_URL}
|
||||
- BILLING_API_KEY=${BILLING_API_KEY}
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
|
|
@ -181,6 +187,9 @@ services:
|
|||
- FIRECRAWL_API_KEY=${FIRECRAWL_API_KEY}
|
||||
- QDRANT_URL=http://qdrant:6333
|
||||
- QDRANT_API_KEY=${QDRANT_API_KEY}
|
||||
- USE_BILLING=${USE_BILLING}
|
||||
- BILLING_API_URL=${BILLING_API_URL}
|
||||
- BILLING_API_KEY=${BILLING_API_KEY}
|
||||
restart: unless-stopped
|
||||
|
||||
rag_text_worker:
|
||||
|
|
@ -198,6 +207,9 @@ services:
|
|||
- REDIS_URL=redis://redis:6379
|
||||
- QDRANT_URL=http://qdrant:6333
|
||||
- QDRANT_API_KEY=${QDRANT_API_KEY}
|
||||
- USE_BILLING=${USE_BILLING}
|
||||
- BILLING_API_URL=${BILLING_API_URL}
|
||||
- BILLING_API_KEY=${BILLING_API_KEY}
|
||||
restart: unless-stopped
|
||||
|
||||
# chat_widget:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue