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