Merge pull request #215 from rowboatlabs/dev

dev changes
This commit is contained in:
Ramnique Singh 2025-08-23 10:19:27 +05:30 committed by GitHub
commit 6fc6abc2bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
107 changed files with 2359 additions and 649 deletions

View file

@ -1,13 +1,15 @@
"use server"; "use server";
import { auth0 } from "../lib/auth0"; import { auth0 } from "../lib/auth0";
import { USE_AUTH } from "../lib/feature_flags"; import { USE_AUTH } from "../lib/feature_flags";
import { WithStringId, User } from "../lib/types/types"; import { User } from "@/src/entities/models/user";
import { getUserFromSessionId, GUEST_DB_USER } from "../lib/auth"; import { getUserFromSessionId, GUEST_DB_USER } from "../lib/auth";
import { z } from "zod"; import { z } from "zod";
import { ObjectId } from "mongodb"; import { container } from "@/di/container";
import { usersCollection } from "../lib/mongodb"; import { IUsersRepository } from "@/src/application/repositories/users.repository.interface";
export async function authCheck(): Promise<WithStringId<z.infer<typeof User>>> { const usersRepository = container.resolve<IUsersRepository>("usersRepository");
export async function authCheck(): Promise<z.infer<typeof User>> {
if (!USE_AUTH) { if (!USE_AUTH) {
return GUEST_DB_USER; return GUEST_DB_USER;
} }
@ -42,12 +44,5 @@ export async function updateUserEmail(email: string) {
} }
// update customer email in db // update customer email in db
await usersCollection.updateOne({ await usersRepository.updateEmail(user.id, email);
_id: new ObjectId(user._id),
}, {
$set: {
email,
updatedAt: new Date().toISOString(),
}
});
} }

View file

@ -21,9 +21,8 @@ import {
ModelsResponse ModelsResponse
} from "../lib/types/billing_types"; } from "../lib/types/billing_types";
import { z } from "zod"; import { z } from "zod";
import { WithStringId } from "../lib/types/types";
export async function getCustomer(): Promise<WithStringId<z.infer<typeof Customer>>> { export async function getCustomer(): Promise<z.infer<typeof Customer>> {
const user = await authCheck(); const user = await authCheck();
if (!user.billingCustomerId) { if (!user.billingCustomerId) {
throw new Error("Customer not found"); throw new Error("Customer not found");
@ -41,7 +40,7 @@ export async function authorizeUserAction(request: z.infer<typeof AuthorizeReque
} }
const customer = await getCustomer(); const customer = await getCustomer();
const response = await authorize(customer._id, request); const response = await authorize(customer.id, request);
return response; return response;
} }
@ -51,7 +50,7 @@ export async function logUsage(request: z.infer<typeof LogUsageRequest>) {
} }
const customer = await getCustomer(); const customer = await getCustomer();
await libLogUsage(customer._id, request); await libLogUsage(customer.id, request);
return; return;
} }
@ -61,7 +60,7 @@ export async function getCustomerPortalUrl(returnUrl: string): Promise<string> {
} }
const customer = await getCustomer(); const customer = await getCustomer();
return await createCustomerPortalSession(customer._id, returnUrl); return await createCustomerPortalSession(customer.id, returnUrl);
} }
export async function getPrices(): Promise<z.infer<typeof PricesResponse>> { export async function getPrices(): Promise<z.infer<typeof PricesResponse>> {
@ -80,7 +79,7 @@ export async function updateSubscriptionPlan(plan: z.infer<typeof SubscriptionPl
const customer = await getCustomer(); const customer = await getCustomer();
const request: z.infer<typeof UpdateSubscriptionPlanRequest> = { plan, returnUrl }; const request: z.infer<typeof UpdateSubscriptionPlanRequest> = { plan, returnUrl };
const url = await libUpdateSubscriptionPlan(customer._id, request); const url = await libUpdateSubscriptionPlan(customer.id, request);
return url; return url;
} }
@ -90,6 +89,6 @@ export async function getEligibleModels(): Promise<z.infer<typeof ModelsResponse
} }
const customer = await getCustomer(); const customer = await getCustomer();
const response = await libGetEligibleModels(customer._id); const response = await libGetEligibleModels(customer.id);
return response; return response;
} }

View file

@ -49,7 +49,7 @@ export async function listToolkits(projectId: string, cursor: string | null = nu
const user = await authCheck(); const user = await authCheck();
return await listComposioToolkitsController.execute({ return await listComposioToolkitsController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
cursor, cursor,
}); });
@ -59,7 +59,7 @@ export async function getToolkit(projectId: string, toolkitSlug: string): Promis
const user = await authCheck(); const user = await authCheck();
return await getComposioToolkitController.execute({ return await getComposioToolkitController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
toolkitSlug, toolkitSlug,
}); });
@ -69,7 +69,7 @@ export async function listTools(projectId: string, toolkitSlug: string, searchQu
const user = await authCheck(); const user = await authCheck();
return await listComposioToolsController.execute({ return await listComposioToolsController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
toolkitSlug, toolkitSlug,
searchQuery, searchQuery,
@ -81,7 +81,7 @@ export async function createComposioManagedOauth2ConnectedAccount(projectId: str
const user = await authCheck(); const user = await authCheck();
return await createComposioManagedConnectedAccountController.execute({ return await createComposioManagedConnectedAccountController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
toolkitSlug, toolkitSlug,
callbackUrl, callbackUrl,
@ -92,7 +92,7 @@ export async function createCustomConnectedAccount(projectId: string, request: z
const user = await authCheck(); const user = await authCheck();
return await createCustomConnectedAccountController.execute({ return await createCustomConnectedAccountController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
toolkitSlug: request.toolkitSlug, toolkitSlug: request.toolkitSlug,
authConfig: request.authConfig, authConfig: request.authConfig,
@ -104,7 +104,7 @@ export async function syncConnectedAccount(projectId: string, toolkitSlug: strin
const user = await authCheck(); const user = await authCheck();
return await syncConnectedAccountController.execute({ return await syncConnectedAccountController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
toolkitSlug, toolkitSlug,
connectedAccountId, connectedAccountId,
@ -116,7 +116,7 @@ export async function deleteConnectedAccount(projectId: string, toolkitSlug: str
await deleteComposioConnectedAccountController.execute({ await deleteComposioConnectedAccountController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
toolkitSlug, toolkitSlug,
}); });
@ -144,7 +144,7 @@ export async function createComposioTriggerDeployment(request: {
// create trigger deployment // create trigger deployment
return await createComposioTriggerDeploymentController.execute({ return await createComposioTriggerDeploymentController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId: request.projectId, projectId: request.projectId,
data: { data: {
triggerTypeSlug: request.triggerTypeSlug, triggerTypeSlug: request.triggerTypeSlug,
@ -163,7 +163,7 @@ export async function listComposioTriggerDeployments(request: {
// list trigger deployments // list trigger deployments
return await listComposioTriggerDeploymentsController.execute({ return await listComposioTriggerDeploymentsController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId: request.projectId, projectId: request.projectId,
cursor: request.cursor, cursor: request.cursor,
}); });
@ -178,7 +178,7 @@ export async function deleteComposioTriggerDeployment(request: {
// delete trigger deployment // delete trigger deployment
return await deleteComposioTriggerDeploymentController.execute({ return await deleteComposioTriggerDeploymentController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId: request.projectId, projectId: request.projectId,
deploymentId: request.deploymentId, deploymentId: request.deploymentId,
}); });
@ -188,7 +188,7 @@ export async function fetchComposioTriggerDeployment(request: { deploymentId: st
const user = await authCheck(); const user = await authCheck();
return await fetchComposioTriggerDeploymentController.execute({ return await fetchComposioTriggerDeploymentController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
deploymentId: request.deploymentId, deploymentId: request.deploymentId,
}); });
} }

View file

@ -17,7 +17,7 @@ export async function listConversations(request: {
return await listConversationsController.execute({ return await listConversationsController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId: request.projectId, projectId: request.projectId,
cursor: request.cursor, cursor: request.cursor,
limit: request.limit, limit: request.limit,
@ -31,7 +31,7 @@ export async function fetchConversation(request: {
return await fetchConversationController.execute({ return await fetchConversationController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
conversationId: request.conversationId, conversationId: request.conversationId,
}); });
} }

View file

@ -28,7 +28,7 @@ export async function getCopilotResponseStream(
streamId: string; streamId: string;
} | { billingError: string }> { } | { billingError: string }> {
await projectAuthCheck(projectId); await projectAuthCheck(projectId);
await usageQuotaPolicy.assertAndConsume(projectId); await usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// Check billing authorization // Check billing authorization
const authResponse = await authorizeUserAction({ const authResponse = await authorizeUserAction({
@ -38,7 +38,7 @@ export async function getCopilotResponseStream(
return { billingError: authResponse.error || 'Billing error' }; return { billingError: authResponse.error || 'Billing error' };
} }
await usageQuotaPolicy.assertAndConsume(projectId); await usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// prepare request // prepare request
const request: z.infer<typeof CopilotAPIRequest> = { const request: z.infer<typeof CopilotAPIRequest> = {
@ -70,7 +70,7 @@ export async function getCopilotAgentInstructions(
agentName: string, agentName: string,
): Promise<string | { billingError: string }> { ): Promise<string | { billingError: string }> {
await projectAuthCheck(projectId); await projectAuthCheck(projectId);
await usageQuotaPolicy.assertAndConsume(projectId); await usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// Check billing authorization // Check billing authorization
const authResponse = await authorizeUserAction({ const authResponse = await authorizeUserAction({

View file

@ -32,7 +32,7 @@ export async function addServer(projectId: string, name: string, server: McpServ
validateUrl(server.serverUrl); validateUrl(server.serverUrl);
await addCustomMcpServerController.execute({ await addCustomMcpServerController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
name, name,
server, server,
@ -43,7 +43,7 @@ export async function removeServer(projectId: string, name: string): Promise<voi
const user = await authCheck(); const user = await authCheck();
await removeCustomMcpServerController.execute({ await removeCustomMcpServerController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
name, name,
}); });

View file

@ -35,7 +35,7 @@ export async function getDataSource(sourceId: string): Promise<z.infer<typeof Da
return await fetchDataSourceController.execute({ return await fetchDataSourceController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
sourceId, sourceId,
}); });
} }
@ -45,7 +45,7 @@ export async function listDataSources(projectId: string): Promise<z.infer<typeof
return await listDataSourcesController.execute({ return await listDataSourcesController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
}); });
} }
@ -66,7 +66,7 @@ export async function createDataSource({
const user = await authCheck(); const user = await authCheck();
return await createDataSourceController.execute({ return await createDataSourceController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
data: { data: {
projectId, projectId,
name, name,
@ -82,7 +82,7 @@ export async function recrawlWebDataSource(sourceId: string) {
return await recrawlWebDataSourceController.execute({ return await recrawlWebDataSourceController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
sourceId, sourceId,
}); });
} }
@ -92,7 +92,7 @@ export async function deleteDataSource(sourceId: string) {
return await deleteDataSourceController.execute({ return await deleteDataSourceController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
sourceId, sourceId,
}); });
} }
@ -102,7 +102,7 @@ export async function toggleDataSource(sourceId: string, active: boolean) {
return await toggleDataSourceController.execute({ return await toggleDataSourceController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
sourceId, sourceId,
active, active,
}); });
@ -122,7 +122,7 @@ export async function addDocsToDataSource({
return await addDocsToDataSourceController.execute({ return await addDocsToDataSourceController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
sourceId, sourceId,
docs: docData, docs: docData,
}); });
@ -144,7 +144,7 @@ export async function listDocsInDataSource({
const docs = await listDocsInDataSourceController.execute({ const docs = await listDocsInDataSourceController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
sourceId, sourceId,
}); });
@ -162,7 +162,7 @@ export async function deleteDocFromDataSource({
const user = await authCheck(); const user = await authCheck();
return await deleteDocFromDataSourceController.execute({ return await deleteDocFromDataSourceController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
docId, docId,
}); });
} }
@ -174,7 +174,7 @@ export async function getDownloadUrlForFile(
return await getDownloadUrlForFileController.execute({ return await getDownloadUrlForFileController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
fileId, fileId,
}); });
} }
@ -191,7 +191,7 @@ export async function getUploadUrlsForFilesDataSource(
return await getUploadUrlsForFilesController.execute({ return await getUploadUrlsForFilesController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
sourceId, sourceId,
files, files,
}); });
@ -208,7 +208,7 @@ export async function updateDataSource({
return await updateDataSourceController.execute({ return await updateDataSourceController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
sourceId, sourceId,
data: { data: {
description, description,

View file

@ -20,7 +20,7 @@ export async function listJobs(request: {
return await listJobsController.execute({ return await listJobsController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId: request.projectId, projectId: request.projectId,
filters: request.filters, filters: request.filters,
cursor: request.cursor, cursor: request.cursor,
@ -35,7 +35,7 @@ export async function fetchJob(request: {
return await fetchJobController.execute({ return await fetchJobController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
jobId: request.jobId, jobId: request.jobId,
}); });
} }

View file

@ -22,7 +22,7 @@ export async function createConversation({
const controller = container.resolve<ICreatePlaygroundConversationController>("createPlaygroundConversationController"); const controller = container.resolve<ICreatePlaygroundConversationController>("createPlaygroundConversationController");
return await controller.execute({ return await controller.execute({
userId: user._id, userId: user.id,
projectId, projectId,
workflow, workflow,
isLiveWorkflow, isLiveWorkflow,
@ -41,7 +41,7 @@ export async function createCachedTurn({
const { key } = await createCachedTurnController.execute({ const { key } = await createCachedTurnController.execute({
caller: "user", caller: "user",
userId: user._id, userId: user.id,
conversationId, conversationId,
input: { input: {
messages, messages,

View file

@ -57,7 +57,7 @@ export async function projectAuthCheck(projectId: string) {
const user = await authCheck(); const user = await authCheck();
await projectActionAuthorizationPolicy.authorize({ await projectActionAuthorizationPolicy.authorize({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
}); });
} }
@ -69,7 +69,7 @@ export async function createProject(formData: FormData): Promise<{ id: string }
try { try {
const project = await createProjectController.execute({ const project = await createProjectController.execute({
userId: user._id, userId: user.id,
data: { data: {
name: name || '', name: name || '',
mode: { mode: {
@ -94,7 +94,7 @@ export async function createProjectFromWorkflowJson(formData: FormData): Promise
try { try {
const project = await createProjectController.execute({ const project = await createProjectController.execute({
userId: user._id, userId: user.id,
data: { data: {
name: name || '', name: name || '',
mode: { mode: {
@ -116,7 +116,7 @@ export async function fetchProject(projectId: string): Promise<z.infer<typeof Pr
const user = await authCheck(); const user = await authCheck();
const project = await fetchProjectController.execute({ const project = await fetchProjectController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
}); });
@ -134,7 +134,7 @@ export async function listProjects(): Promise<z.infer<typeof Project>[]> {
let cursor = undefined; let cursor = undefined;
do { do {
const result = await listProjectsController.execute({ const result = await listProjectsController.execute({
userId: user._id, userId: user.id,
cursor, cursor,
}); });
projects.push(...result.items); projects.push(...result.items);
@ -148,7 +148,7 @@ export async function rotateSecret(projectId: string): Promise<string> {
const user = await authCheck(); const user = await authCheck();
return await rotateSecretController.execute({ return await rotateSecretController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
}); });
} }
@ -157,7 +157,7 @@ export async function updateWebhookUrl(projectId: string, url: string) {
const user = await authCheck(); const user = await authCheck();
await updateWebhookUrlController.execute({ await updateWebhookUrlController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
url, url,
}); });
@ -167,7 +167,7 @@ export async function createApiKey(projectId: string): Promise<z.infer<typeof Ap
const user = await authCheck(); const user = await authCheck();
return await createApiKeyController.execute({ return await createApiKeyController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
}); });
} }
@ -176,7 +176,7 @@ export async function deleteApiKey(projectId: string, id: string) {
const user = await authCheck(); const user = await authCheck();
return await deleteApiKeyController.execute({ return await deleteApiKeyController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
id, id,
}); });
@ -186,7 +186,7 @@ export async function listApiKeys(projectId: string): Promise<z.infer<typeof Api
const user = await authCheck(); const user = await authCheck();
return await listApiKeysController.execute({ return await listApiKeysController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
}); });
} }
@ -195,7 +195,7 @@ export async function updateProjectName(projectId: string, name: string) {
const user = await authCheck(); const user = await authCheck();
await updateProjectNameController.execute({ await updateProjectNameController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
name, name,
}); });
@ -205,7 +205,7 @@ export async function deleteProject(projectId: string) {
const user = await authCheck(); const user = await authCheck();
await deleteProjectController.execute({ await deleteProjectController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
}); });
@ -216,7 +216,7 @@ export async function saveWorkflow(projectId: string, workflow: z.infer<typeof W
const user = await authCheck(); const user = await authCheck();
await updateDraftWorkflowController.execute({ await updateDraftWorkflowController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
workflow, workflow,
}); });
@ -226,7 +226,7 @@ export async function publishWorkflow(projectId: string, workflow: z.infer<typeo
const user = await authCheck(); const user = await authCheck();
await updateLiveWorkflowController.execute({ await updateLiveWorkflowController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
workflow, workflow,
}); });
@ -236,7 +236,7 @@ export async function revertToLiveWorkflow(projectId: string) {
const user = await authCheck(); const user = await authCheck();
await revertToLiveWorkflowController.execute({ await revertToLiveWorkflowController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId, projectId,
}); });
} }

View file

@ -27,7 +27,7 @@ export async function createRecurringJobRule(request: {
return await createRecurringJobRuleController.execute({ return await createRecurringJobRuleController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId: request.projectId, projectId: request.projectId,
input: request.input, input: request.input,
cron: request.cron, cron: request.cron,
@ -43,7 +43,7 @@ export async function listRecurringJobRules(request: {
return await listRecurringJobRulesController.execute({ return await listRecurringJobRulesController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId: request.projectId, projectId: request.projectId,
cursor: request.cursor, cursor: request.cursor,
limit: request.limit, limit: request.limit,
@ -57,7 +57,7 @@ export async function fetchRecurringJobRule(request: {
return await fetchRecurringJobRuleController.execute({ return await fetchRecurringJobRuleController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
ruleId: request.ruleId, ruleId: request.ruleId,
}); });
} }
@ -70,7 +70,7 @@ export async function toggleRecurringJobRule(request: {
return await toggleRecurringJobRuleController.execute({ return await toggleRecurringJobRuleController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
ruleId: request.ruleId, ruleId: request.ruleId,
disabled: request.disabled, disabled: request.disabled,
}); });
@ -84,7 +84,7 @@ export async function deleteRecurringJobRule(request: {
return await deleteRecurringJobRuleController.execute({ return await deleteRecurringJobRuleController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId: request.projectId, projectId: request.projectId,
ruleId: request.ruleId, ruleId: request.ruleId,
}); });

View file

@ -25,7 +25,7 @@ export async function createScheduledJobRule(request: {
return await createScheduledJobRuleController.execute({ return await createScheduledJobRuleController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId: request.projectId, projectId: request.projectId,
input: request.input, input: request.input,
scheduledTime: request.scheduledTime, scheduledTime: request.scheduledTime,
@ -41,7 +41,7 @@ export async function listScheduledJobRules(request: {
return await listScheduledJobRulesController.execute({ return await listScheduledJobRulesController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId: request.projectId, projectId: request.projectId,
cursor: request.cursor, cursor: request.cursor,
limit: request.limit, limit: request.limit,
@ -55,7 +55,7 @@ export async function fetchScheduledJobRule(request: {
return await fetchScheduledJobRuleController.execute({ return await fetchScheduledJobRuleController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
ruleId: request.ruleId, ruleId: request.ruleId,
}); });
} }
@ -68,7 +68,7 @@ export async function deleteScheduledJobRule(request: {
return await deleteScheduledJobRuleController.execute({ return await deleteScheduledJobRuleController.execute({
caller: 'user', caller: 'user',
userId: user._id, userId: user.id,
projectId: request.projectId, projectId: request.projectId,
ruleId: request.ruleId, ruleId: request.ruleId,
}); });

View file

@ -53,7 +53,7 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
} }
} catch (error) { } catch (error) {
console.error('Error processing copilot stream:', error); console.error('Error processing copilot stream:', error);
controller.error(error); controller.error(new Error("Something went wrong. Please try again."));
} finally { } finally {
// log copilot usage // log copilot usage
if (USE_BILLING && billingCustomerId) { if (USE_BILLING && billingCustomerId) {

View file

@ -22,7 +22,7 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
// Iterate over the generator // Iterate over the generator
for await (const event of runCachedTurnController.execute({ for await (const event of runCachedTurnController.execute({
caller: "user", caller: "user",
userId: user._id, userId: user.id,
cachedTurnKey: params.streamId, cachedTurnKey: params.streamId,
})) { })) {
controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(event)}\n\n`)); controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(event)}\n\n`));
@ -31,7 +31,7 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
console.error('Error processing stream:', error); console.error('Error processing stream:', error);
const errMessage: z.infer<typeof TurnEvent> = { const errMessage: z.infer<typeof TurnEvent> = {
type: "error", type: "error",
error: `Error processing stream: ${error}`, error: "Something went wrong. Please try again.",
isBillingError: false, isBillingError: false,
}; };
controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(errMessage)}\n\n`)); controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(errMessage)}\n\n`));

View file

@ -55,7 +55,7 @@ export async function POST(
controller.close(); controller.close();
} catch (error) { } catch (error) {
logger.log(`Error processing stream: ${error}`); logger.log(`Error processing stream: ${error}`);
controller.error(error); controller.error(new Error("Something went wrong. Please try again."));
} }
}, },
}); });

View file

@ -8,7 +8,6 @@ import { z } from "zod";
import { tokens } from "@/app/styles/design-tokens"; import { tokens } from "@/app/styles/design-tokens";
import { SectionHeading } from "@/components/ui/section-heading"; import { SectionHeading } from "@/components/ui/section-heading";
import { HorizontalDivider } from "@/components/ui/horizontal-divider"; import { HorizontalDivider } from "@/components/ui/horizontal-divider";
import { WithStringId } from "@/app/lib/types/types";
import clsx from 'clsx'; import clsx from 'clsx';
import { getCustomerPortalUrl } from "../actions/billing.actions"; import { getCustomerPortalUrl } from "../actions/billing.actions";
import { useState } from "react"; import { useState } from "react";
@ -31,7 +30,7 @@ const planDetails = {
}; };
interface BillingPageProps { interface BillingPageProps {
customer: WithStringId<z.infer<typeof Customer>>; customer: z.infer<typeof Customer>;
usage: z.infer<typeof UsageResponse>; usage: z.infer<typeof UsageResponse>;
} }

View file

@ -13,7 +13,7 @@ export default async function Page(
) { ) {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const customer = await requireBillingCustomer(); const customer = await requireBillingCustomer();
await syncWithStripe(customer._id); await syncWithStripe(customer.id);
const redirectUrl = searchParams.redirect as string; const redirectUrl = searchParams.redirect as string;
redirect(redirectUrl || '/projects'); redirect(redirectUrl || '/projects');
} }

View file

@ -12,6 +12,6 @@ export default async function Page() {
} }
const customer = await requireBillingCustomer(); const customer = await requireBillingCustomer();
const usage = await getUsage(customer._id); const usage = await getUsage(customer.id);
return <BillingPage customer={customer} usage={usage} />; return <BillingPage customer={customer} usage={usage} />;
} }

View file

@ -1,10 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { ObjectId } from "mongodb";
import { usersCollection } from "./mongodb";
import { auth0 } from "./auth0"; import { auth0 } from "./auth0";
import { User, WithStringId } from "./types/types"; import { User } from "@/src/entities/models/user";
import { USE_AUTH } from "./feature_flags"; import { USE_AUTH } from "./feature_flags";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { container } from "@/di/container";
import { IUsersRepository } from "@/src/application/repositories/users.repository.interface";
export const GUEST_SESSION = { export const GUEST_SESSION = {
email: "guest@rowboatlabs.com", email: "guest@rowboatlabs.com",
@ -12,13 +12,12 @@ export const GUEST_SESSION = {
sub: "guest_user", sub: "guest_user",
} }
export const GUEST_DB_USER: WithStringId<z.infer<typeof User>> = { export const GUEST_DB_USER: z.infer<typeof User> = {
_id: "guest_user", id: "guest_user",
auth0Id: "guest_user", auth0Id: "guest_user",
name: "Guest", name: "Guest",
email: "guest@rowboatlabs.com", email: "guest@rowboatlabs.com",
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} }
/** /**
@ -33,7 +32,7 @@ export const GUEST_DB_USER: WithStringId<z.infer<typeof User>> = {
* const user = await requireAuth(); * const user = await requireAuth();
* ``` * ```
*/ */
export async function requireAuth(): Promise<WithStringId<z.infer<typeof User>>> { export async function requireAuth(): Promise<z.infer<typeof User>> {
if (!USE_AUTH) { if (!USE_AUTH) {
return GUEST_DB_USER; return GUEST_DB_USER;
} }
@ -44,48 +43,26 @@ export async function requireAuth(): Promise<WithStringId<z.infer<typeof User>>>
} }
// fetch db user // fetch db user
const usersRepository = container.resolve<IUsersRepository>("usersRepository");
let dbUser = await getUserFromSessionId(user.sub); let dbUser = await getUserFromSessionId(user.sub);
// if db user does not exist, create one // if db user does not exist, create one
if (!dbUser) { if (!dbUser) {
// create user record dbUser = await usersRepository.create({
const doc = {
_id: new ObjectId(),
auth0Id: user.sub, auth0Id: user.sub,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
email: user.email, email: user.email,
}; });
console.log(`creating new user id ${doc._id.toString()} for session id ${user.sub}`); console.log(`created new user id ${dbUser.id} for session id ${user.sub}`);
await usersCollection.insertOne(doc);
dbUser = {
...doc,
_id: doc._id.toString(),
};
} }
const { _id, ...rest } = dbUser; return dbUser;
return {
...rest,
_id: _id.toString(),
};
} }
export async function getUserFromSessionId(sessionUserId: string): Promise<WithStringId<z.infer<typeof User>> | null> { export async function getUserFromSessionId(sessionUserId: string): Promise<z.infer<typeof User> | null> {
if (!USE_AUTH) { if (!USE_AUTH) {
return GUEST_DB_USER; return GUEST_DB_USER;
} }
let dbUser = await usersCollection.findOne({ const usersRepository = container.resolve<IUsersRepository>("usersRepository");
auth0Id: sessionUserId return await usersRepository.fetchByAuth0Id(sessionUserId);
});
if (!dbUser) {
return null;
}
const { _id, ...rest } = dbUser;
return {
...rest,
_id: _id.toString(),
};
} }

View file

@ -1,13 +1,11 @@
import { WithStringId } from './types/types';
import { z } from 'zod'; import { z } from 'zod';
import { Customer, AuthorizeRequest, AuthorizeResponse, LogUsageRequest, UsageResponse, CustomerPortalSessionResponse, PricesResponse, UpdateSubscriptionPlanRequest, UpdateSubscriptionPlanResponse, ModelsResponse, UsageItem } from './types/billing_types'; import { Customer, AuthorizeRequest, AuthorizeResponse, LogUsageRequest, UsageResponse, CustomerPortalSessionResponse, PricesResponse, UpdateSubscriptionPlanRequest, UpdateSubscriptionPlanResponse, ModelsResponse, UsageItem } from './types/billing_types';
import { ObjectId } from 'mongodb';
import { usersCollection } from './mongodb';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getUserFromSessionId, requireAuth } from './auth'; import { getUserFromSessionId, requireAuth } from './auth';
import { USE_BILLING } from './feature_flags'; import { USE_BILLING } from './feature_flags';
import { container } from '@/di/container'; import { container } from '@/di/container';
import { IProjectsRepository } from '@/src/application/repositories/projects.repository.interface'; import { IProjectsRepository } from '@/src/application/repositories/projects.repository.interface';
import { IUsersRepository } from '@/src/application/repositories/users.repository.interface';
const BILLING_API_URL = process.env.BILLING_API_URL || 'http://billing'; const BILLING_API_URL = process.env.BILLING_API_URL || 'http://billing';
const BILLING_API_KEY = process.env.BILLING_API_KEY || 'test'; const BILLING_API_KEY = process.env.BILLING_API_KEY || 'test';
@ -15,7 +13,7 @@ const BILLING_API_KEY = process.env.BILLING_API_KEY || 'test';
let logCounter = 1; let logCounter = 1;
const GUEST_BILLING_CUSTOMER = { const GUEST_BILLING_CUSTOMER = {
_id: "guest-user", id: "guest-user",
userId: "guest-user", userId: "guest-user",
name: "Guest", name: "Guest",
email: "guest@rowboatlabs.com", email: "guest@rowboatlabs.com",
@ -24,9 +22,9 @@ const GUEST_BILLING_CUSTOMER = {
subscriptionPlan: "free" as const, subscriptionPlan: "free" as const,
subscriptionStatus: "active" as const, subscriptionStatus: "active" as const,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}; };
export class UsageTracker{ export class UsageTracker{
private items: z.infer<typeof UsageItem>[] = []; private items: z.infer<typeof UsageItem>[] = [];
@ -41,8 +39,10 @@ export class UsageTracker{
} }
} }
export async function getCustomerForUserId(userId: string): Promise<WithStringId<z.infer<typeof Customer>> | null> { export async function getCustomerForUserId(userId: string): Promise<z.infer<typeof Customer> | null> {
const user = await usersCollection.findOne({ _id: new ObjectId(userId) }); const usersRepository = container.resolve<IUsersRepository>("usersRepository");
const user = await usersRepository.fetch(userId);
if (!user) { if (!user) {
throw new Error("User not found"); throw new Error("User not found");
} }
@ -62,10 +62,10 @@ export async function getCustomerIdForProject(projectId: string): Promise<string
if (!customer) { if (!customer) {
throw new Error("User has no billing customer id"); throw new Error("User has no billing customer id");
} }
return customer._id; return customer.id;
} }
export async function getBillingCustomer(id: string): Promise<WithStringId<z.infer<typeof Customer>> | null> { export async function getBillingCustomer(id: string): Promise<z.infer<typeof Customer> | null> {
const response = await fetch(`${BILLING_API_URL}/api/customers/${id}`, { const response = await fetch(`${BILLING_API_URL}/api/customers/${id}`, {
method: 'GET', method: 'GET',
headers: { headers: {
@ -84,7 +84,7 @@ export async function getBillingCustomer(id: string): Promise<WithStringId<z.inf
return parseResult.data; return parseResult.data;
} }
async function createBillingCustomer(userId: string, email: string): Promise<WithStringId<z.infer<typeof Customer>>> { async function createBillingCustomer(userId: string, email: string): Promise<z.infer<typeof Customer>> {
const response = await fetch(`${BILLING_API_URL}/api/customers`, { const response = await fetch(`${BILLING_API_URL}/api/customers`, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -264,13 +264,14 @@ export async function getEligibleModels(customerId: string): Promise<z.infer<typ
* const billingCustomer = await requireBillingCustomer(); * const billingCustomer = await requireBillingCustomer();
* ``` * ```
*/ */
export async function requireBillingCustomer(): Promise<WithStringId<z.infer<typeof Customer>>> { export async function requireBillingCustomer(): Promise<z.infer<typeof Customer>> {
const user = await requireAuth(); const user = await requireAuth();
const usersRepository = container.resolve<IUsersRepository>("usersRepository");
if (!USE_BILLING) { if (!USE_BILLING) {
return { return {
...GUEST_BILLING_CUSTOMER, ...GUEST_BILLING_CUSTOMER,
userId: user._id, userId: user.id,
}; };
} }
@ -280,22 +281,15 @@ export async function requireBillingCustomer(): Promise<WithStringId<z.infer<typ
} }
// fetch or create customer // fetch or create customer
let customer: WithStringId<z.infer<typeof Customer>> | null; let customer: z.infer<typeof Customer> | null;
if (user.billingCustomerId) { if (user.billingCustomerId) {
customer = await getBillingCustomer(user.billingCustomerId); customer = await getBillingCustomer(user.billingCustomerId);
} else { } else {
customer = await createBillingCustomer(user._id, user.email); customer = await createBillingCustomer(user.id, user.email);
console.log("created billing customer", JSON.stringify({ userId: user._id, customer })); console.log("created billing customer", JSON.stringify({ userId: user.id, customer }));
// update customer id in db // update customer id in db
await usersCollection.updateOne({ await usersRepository.updateBillingCustomerId(user.id, customer.id);
_id: new ObjectId(user._id),
}, {
$set: {
billingCustomerId: customer._id,
updatedAt: new Date().toISOString(),
}
});
} }
if (!customer) { if (!customer) {
throw new Error("Failed to fetch or create billing customer"); throw new Error("Failed to fetch or create billing customer");
@ -316,7 +310,7 @@ export async function requireBillingCustomer(): Promise<WithStringId<z.infer<typ
* const billingCustomer = await requireActiveBillingSubscription(); * const billingCustomer = await requireActiveBillingSubscription();
* ``` * ```
*/ */
export async function requireActiveBillingSubscription(): Promise<WithStringId<z.infer<typeof Customer>>> { export async function requireActiveBillingSubscription(): Promise<z.infer<typeof Customer>> {
const billingCustomer = await requireBillingCustomer(); const billingCustomer = await requireBillingCustomer();
if (USE_BILLING && billingCustomer.subscriptionStatus !== "active" && billingCustomer.subscriptionStatus !== "past_due") { if (USE_BILLING && billingCustomer.subscriptionStatus !== "active" && billingCustomer.subscriptionStatus !== "past_due") {
@ -324,4 +318,3 @@ export async function requireActiveBillingSubscription(): Promise<WithStringId<z
} }
return billingCustomer; return billingCustomer;
} }

View file

@ -1,9 +1,11 @@
import { WorkflowTool, WorkflowAgent, WorkflowPrompt, WorkflowPipeline } from "./types/workflow_types"; import { WorkflowTool, WorkflowAgent, WorkflowPrompt, WorkflowPipeline } from "./types/workflow_types";
import { z } from "zod"; import { z } from "zod";
const ZFallbackSchema = z.object({}).passthrough();
export function validateConfigChanges(configType: string, configChanges: Record<string, unknown>, name: string) { export function validateConfigChanges(configType: string, configChanges: Record<string, unknown>, name: string) {
let testObject: any; let testObject: any;
let schema: z.ZodType<any>; let schema: z.ZodType<any> = ZFallbackSchema;
switch (configType) { switch (configType) {
case 'tool': { case 'tool': {
@ -56,6 +58,10 @@ export function validateConfigChanges(configType: string, configChanges: Record<
schema = WorkflowPipeline; schema = WorkflowPipeline;
break; break;
} }
case 'start_agent': {
testObject = {};
break;
}
default: default:
return { error: `Unknown config type: ${configType}` }; return { error: `Unknown config type: ${configType}` };
} }

View file

@ -55,11 +55,15 @@ export function createAtMentions({ agents, prompts, tools, pipelines = [], curre
// Add prompts (always allowed) // Add prompts (always allowed)
for (const prompt of prompts) { for (const prompt of prompts) {
const id = `prompt:${prompt.name}`; // Use 'variable' for base_prompt types, 'prompt' for others
const isVariable = prompt.type === 'base_prompt';
const type = isVariable ? 'variable' : 'prompt';
const label = isVariable ? 'Variable' : 'Prompt';
const id = `${type}:${prompt.name}`;
atMentions.push({ atMentions.push({
id, id,
value: id, value: id,
label: `Prompt: ${prompt.name}`, label: `${label}: ${prompt.name}`,
denotationChar: "@", denotationChar: "@",
link: id, link: id,
target: "_self" target: "_self"

View file

@ -15,6 +15,6 @@ export const USE_VOICE_FEATURE = false;
export const USE_TRANSFER_CONTROL_OPTIONS = false; export const USE_TRANSFER_CONTROL_OPTIONS = false;
export const USE_PRODUCT_TOUR = false; export const USE_PRODUCT_TOUR = false;
export const SHOW_COPILOT_MARQUEE = false; export const SHOW_COPILOT_MARQUEE = false;
export const SHOW_PROMPTS_SECTION = false; export const SHOW_PROMPTS_SECTION = true;
export const SHOW_DARK_MODE_TOGGLE = false; export const SHOW_DARK_MODE_TOGGLE = false;
export const SHOW_VISUALIZATION = false export const SHOW_VISUALIZATION = false

View file

@ -1,5 +1,4 @@
import { MongoClient } from "mongodb"; import { MongoClient } from "mongodb";
import { User } from "./types/types";
import { TwilioConfig, TwilioInboundCall } from "./types/voice_types"; import { TwilioConfig, TwilioInboundCall } from "./types/voice_types";
import { z } from 'zod'; import { z } from 'zod';
import { apiV1 } from "rowboat-shared"; import { apiV1 } from "rowboat-shared";
@ -10,7 +9,6 @@ export const db = client.db("rowboat");
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");
export const twilioInboundCallsCollection = db.collection<z.infer<typeof TwilioInboundCall>>("twilio_inbound_calls"); export const twilioInboundCallsCollection = db.collection<z.infer<typeof TwilioInboundCall>>("twilio_inbound_calls");
// Create indexes // Create indexes

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,17 @@
/**
* 🚨 ATTENTION: DO NOT MODIFY THIS FILE! 🚨
*
* This file contains billing types that are manually copied
* from the billing service repository. Any manual changes will be
* overwritten during the next sync.
*
* If you need to modify billing types:
* 1. Make changes in the billing service repo
* 2. Copy the updated file from there
* 3. Never edit this file directly
*
* This file is a manual copy - keep it in sync with the source!
*/
import { z } from "zod"; import { z } from "zod";
export const SubscriptionPlan = z.enum(["free", "starter", "pro"]); export const SubscriptionPlan = z.enum(["free", "starter", "pro"]);
@ -57,7 +71,7 @@ export const LogUsageRequest = z.object({
export const CustomerUsageData = z.record(z.string(), z.number()); export const CustomerUsageData = z.record(z.string(), z.number());
export const Customer = z.object({ export const Customer = z.object({
_id: z.string(), id: z.string(),
userId: z.string(), userId: z.string(),
email: z.string(), email: z.string(),
stripeCustomerId: z.string(), stripeCustomerId: z.string(),
@ -65,7 +79,7 @@ export const Customer = z.object({
subscriptionPlan: SubscriptionPlan.optional(), subscriptionPlan: SubscriptionPlan.optional(),
subscriptionStatus: z.enum([ 'active', 'past_due' ]).optional(), subscriptionStatus: z.enum([ 'active', 'past_due' ]).optional(),
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
updatedAt: z.string().datetime(), updatedAt: z.string().datetime().optional(),
subscriptionPlanUpdatedAt: z.string().datetime().optional(), subscriptionPlanUpdatedAt: z.string().datetime().optional(),
usage: CustomerUsageData.optional(), usage: CustomerUsageData.optional(),
usageUpdatedAt: z.string().datetime().optional(), usageUpdatedAt: z.string().datetime().optional(),

View file

@ -124,15 +124,6 @@ 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 Webpage = z.object({ export const Webpage = z.object({
_id: z.string(), _id: z.string(),
title: z.string(), title: z.string(),

View file

@ -129,8 +129,8 @@ export function sanitizeTextWithMentions(
sanitized: string; sanitized: string;
entities: z.infer<typeof ConnectedEntity>[]; entities: z.infer<typeof ConnectedEntity>[];
} { } {
// Regex to match [@type:name](#type:something) pattern where type is tool/prompt/agent/pipeline // Regex to match [@type:name](#type:something) pattern where type is tool/prompt/agent/pipeline/variable
const mentionRegex = /\[@(tool|prompt|agent|pipeline):([^\]]+)\]\(#mention\)/g; const mentionRegex = /\[@(tool|prompt|agent|pipeline|variable):([^\]]+)\]\(#mention\)/g;
const seen = new Set<string>(); const seen = new Set<string>();
// collect entities // collect entities
@ -144,8 +144,10 @@ export function sanitizeTextWithMentions(
return true; return true;
}) })
.map(match => { .map(match => {
// Treat @variable: as @prompt: internally
const type = match[1] === 'variable' ? 'prompt' : match[1];
return { return {
type: match[1] as 'tool' | 'prompt' | 'agent' | 'pipeline', type: type as 'tool' | 'prompt' | 'agent' | 'pipeline',
name: match[2], name: match[2],
}; };
}) })
@ -176,6 +178,12 @@ export function sanitizeTextWithMentions(
const id = `${entity.type}:${entity.name}`; const id = `${entity.type}:${entity.name}`;
const textToReplace = `[@${id}](#mention)`; const textToReplace = `[@${id}](#mention)`;
text = text.replace(textToReplace, `[@${id}]`); text = text.replace(textToReplace, `[@${id}]`);
// Also handle @variable: mentions for prompts
if (entity.type === 'prompt') {
const variableTextToReplace = `[@variable:${entity.name}](#mention)`;
text = text.replace(variableTextToReplace, `[@variable:${entity.name}]`);
}
} }
return { return {

View file

@ -14,6 +14,9 @@ import { Messages } from "./components/messages";
import { CopyIcon, CheckIcon, PlusIcon, XIcon, InfoIcon, Sparkles } from "lucide-react"; import { CopyIcon, CheckIcon, PlusIcon, XIcon, InfoIcon, Sparkles } from "lucide-react";
import { useCopilot } from "./use-copilot"; import { useCopilot } from "./use-copilot";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal"; import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
import { SHOW_COPILOT_MARQUEE } from "@/app/lib/feature_flags";
import Image from "next/image";
import mascot from "@/public/mascot.png";
const CopilotContext = createContext<{ const CopilotContext = createContext<{
workflow: z.infer<typeof Workflow> | null; workflow: z.infer<typeof Workflow> | null;
@ -205,6 +208,56 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
<CopilotContext.Provider value={{ workflow: workflowRef.current, dispatch }}> <CopilotContext.Provider value={{ workflow: workflowRef.current, dispatch }}>
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 pointer-events-none">
{/* Replace Sparkles icon with mascot image */}
<Image src={mascot} alt="Rowboat Mascot" width={160} height={160} className="object-contain mb-3 animate-float" />
{/* Welcome/Intro Section */}
<div className="text-center max-w-md px-6 mb-3">
<h3 className="text-xl font-semibold text-zinc-700 dark:text-zinc-300 mb-2 text-center">
👋 Hi there!
</h3>
<p className="text-base text-zinc-600 dark:text-zinc-400 mb-4 text-center">
I&apos;m your copilot for building agents and adding tools to them.
</p>
<p className="text-base text-zinc-600 dark:text-zinc-400 mb-3 text-center">
Here&apos;s what you can do in Rowboat:
</p>
<div className="space-y-2 max-w-2xl mx-auto text-left">
<div className="flex items-start gap-3">
<span className="text-lg"></span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Build AI agents instantly with natural language.</span>
</div>
<div className="flex items-start gap-3">
<span className="text-lg">🔌</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Connect tools with one-click integrations.</span>
</div>
<div className="flex items-start gap-3">
<span className="text-lg">📂</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Power with knowledge by adding documents for RAG.</span>
</div>
<div className="flex items-start gap-3">
<span className="text-lg">🔄</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Automate workflows by setting up triggers and actions.</span>
</div>
<div className="flex items-start gap-3">
<span className="text-lg">🚀</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Deploy anywhere via API or SDK.</span>
</div>
</div>
</div>
{SHOW_COPILOT_MARQUEE && (
<div className="relative mt-2 max-w-full px-8">
<div className="font-mono text-sm whitespace-nowrap text-blue-400/60 dark:text-blue-500/40 font-small inline-flex">
<div className="overflow-hidden w-0 animate-typing">What can I help you build?</div>
<div className="border-r-2 border-blue-400 dark:border-blue-500 animate-cursor">&nbsp;</div>
</div>
</div>
)}
</div>
)}
<Messages <Messages
messages={messages} messages={messages}
streamingResponse={streamingResponse} streamingResponse={streamingResponse}
@ -320,7 +373,6 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
<Panel <Panel
variant="copilot" variant="copilot"
tourTarget="copilot" tourTarget="copilot"
showWelcome={messages.length === 0}
icon={<Sparkles className="w-5 h-5 text-indigo-600 dark:text-indigo-400" />} icon={<Sparkles className="w-5 h-5 text-indigo-600 dark:text-indigo-400" />}
title="Skipper" title="Skipper"
subtitle="Build your assistant" subtitle="Build your assistant"

View file

@ -51,9 +51,12 @@ export function Action({
const appliedFields = Object.keys(action.config_changes).filter(key => const appliedFields = Object.keys(action.config_changes).filter(key =>
appliedChanges[getAppliedChangeKey(msgIndex, actionIndex, key)] appliedChanges[getAppliedChangeKey(msgIndex, actionIndex, key)]
); );
const allApplied = externallyApplied || Object.keys(action.config_changes).every(key => let allApplied = externallyApplied || Object.keys(action.config_changes).every(key =>
appliedFields.includes(key) appliedFields.includes(key)
); );
if (!externallyApplied && (action.action === "delete" || action.config_type === 'start_agent')) {
allApplied = false;
}
// Handle applying a single field change // Handle applying a single field change
const handleFieldChange = (field: string) => { const handleFieldChange = (field: string) => {
@ -160,7 +163,8 @@ export function Action({
'transition-shadow duration-150', 'transition-shadow duration-150',
{ {
'border-l-2 border-l-blue-500': !stale && !allApplied && action.action == 'create_new', 'border-l-2 border-l-blue-500': !stale && !allApplied && action.action == 'create_new',
'border-l-2 border-l-orange-500': !stale && !allApplied && action.action == 'edit', 'border-l-2 border-l-yellow-500': !stale && !allApplied && action.action == 'edit',
'border-l-2 border-l-red-500': !stale && !allApplied && action.action == 'delete',
'border-l-2 border-l-gray-400': stale || allApplied || action.error, 'border-l-2 border-l-gray-400': stale || allApplied || action.error,
} }
)}> )}>
@ -171,14 +175,15 @@ export function Action({
'inline-flex items-center justify-center rounded-full h-5 w-5 text-xs', 'inline-flex items-center justify-center rounded-full h-5 w-5 text-xs',
{ {
'bg-blue-100 text-blue-600': action.action == 'create_new', 'bg-blue-100 text-blue-600': action.action == 'create_new',
'bg-orange-100 text-orange-600': action.action == 'edit', 'bg-yellow-100 text-yellow-600': action.action == 'edit',
'bg-red-100 text-red-600': action.action == 'delete',
'bg-gray-200 text-gray-600': stale || allApplied || action.error, 'bg-gray-200 text-gray-600': stale || allApplied || action.error,
} }
)}> )}>
{action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : '💬'} {action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : action.config_type === 'start_agent' ? '🏁' : action.config_type === 'prompt' ? '💬' : '💬'}
</span> </span>
<span className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1"> <span className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1">
{action.action === 'create_new' ? 'Add' : 'Edit'} {action.config_type}: {action.name} {action.action === 'create_new' ? 'Add' : action.action === 'edit' ? 'Edit' : 'Delete'} {action.config_type}: {action.name}
</span> </span>
{/* Action buttons - compact, icon only, show text on hover */} {/* Action buttons - compact, icon only, show text on hover */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -195,13 +200,13 @@ export function Action({
<CheckIcon size={13} className={allApplied ? 'text-zinc-400' : 'text-green-600 group-hover:text-green-700'} /> <CheckIcon size={13} className={allApplied ? 'text-zinc-400' : 'text-green-600 group-hover:text-green-700'} />
<span>{allApplied ? 'Applied' : 'Apply'}</span> <span>{allApplied ? 'Applied' : 'Apply'}</span>
</button> </button>
<button {action.action !== 'delete' && <button
className="flex items-center gap-1 rounded-full px-2 h-7 text-xs font-medium bg-transparent text-indigo-600 hover:text-indigo-700 transition-colors" className="flex items-center gap-1 rounded-full px-2 h-7 text-xs font-medium bg-transparent text-indigo-600 hover:text-indigo-700 transition-colors"
onClick={handleViewDiff} onClick={handleViewDiff}
> >
<EyeIcon size={13} className="text-indigo-600 group-hover:text-indigo-700" /> <EyeIcon size={13} className="text-indigo-600 group-hover:text-indigo-700" />
<span>View Diff</span> <span>View Diff</span>
</button> </button>}
</div> </div>
</div> </div>
{/* Description of what happened */} {/* Description of what happened */}
@ -341,8 +346,8 @@ export function StreamingAction({
loading, loading,
}: { }: {
action: { action: {
action?: 'create_new' | 'edit'; action?: 'create_new' | 'edit' | 'delete';
config_type?: 'tool' | 'agent' | 'prompt' | 'pipeline'; config_type?: 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent';
name?: string; name?: string;
}; };
loading: boolean; loading: boolean;
@ -354,7 +359,8 @@ export function StreamingAction({
'transition-shadow duration-150', 'transition-shadow duration-150',
{ {
'border-l-2 border-l-blue-500': action.action == 'create_new', 'border-l-2 border-l-blue-500': action.action == 'create_new',
'border-l-2 border-l-orange-500': action.action == 'edit', 'border-l-2 border-l-yellow-500': action.action == 'edit',
'border-l-2 border-l-red-500': action.action == 'delete',
'border-l-2 border-l-gray-400': !action.action, 'border-l-2 border-l-gray-400': !action.action,
} }
)}> )}>
@ -364,14 +370,15 @@ export function StreamingAction({
'inline-flex items-center justify-center rounded-full h-5 w-5 text-xs', 'inline-flex items-center justify-center rounded-full h-5 w-5 text-xs',
{ {
'bg-blue-100 text-blue-600': action.action == 'create_new', 'bg-blue-100 text-blue-600': action.action == 'create_new',
'bg-orange-100 text-orange-600': action.action == 'edit', 'bg-yellow-100 text-yellow-600': action.action == 'edit',
'bg-red-100 text-red-600': action.action == 'delete',
'bg-gray-200 text-gray-600': !action.action, 'bg-gray-200 text-gray-600': !action.action,
} }
)}> )}>
{action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : '💬'} {action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : action.config_type === 'start_agent' ? '🏁' : '💬'}
</span> </span>
<span className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1"> <span className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1">
{action.action === 'create_new' ? 'Add' : 'Edit'} {action.config_type}: {action.name} {action.action === 'create_new' ? 'Add' : action.action === 'edit' ? 'Edit' : 'Delete'} {action.config_type}: {action.name}
</span> </span>
</div> </div>
{/* Loading state body */} {/* Loading state body */}

View file

@ -70,8 +70,8 @@ function enrich(response: string): z.infer<typeof CopilotResponsePart> {
return { return {
type: 'action', type: 'action',
action: { action: {
action: metadata.action as 'create_new' | 'edit', action: metadata.action as 'create_new' | 'edit' | 'delete',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline', config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent',
name: metadata.name, name: metadata.name,
change_description: jsonData.change_description || '', change_description: jsonData.change_description || '',
config_changes: {}, config_changes: {},
@ -83,8 +83,8 @@ function enrich(response: string): z.infer<typeof CopilotResponsePart> {
return { return {
type: 'action', type: 'action',
action: { action: {
action: metadata.action as 'create_new' | 'edit', action: metadata.action as 'create_new' | 'edit' | 'delete',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline', config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent',
name: metadata.name, name: metadata.name,
change_description: jsonData.change_description || '', change_description: jsonData.change_description || '',
config_changes: result.changes config_changes: result.changes
@ -99,8 +99,8 @@ function enrich(response: string): z.infer<typeof CopilotResponsePart> {
return { return {
type: 'streaming_action', type: 'streaming_action',
action: { action: {
action: (metadata.action as 'create_new' | 'edit') || undefined, action: (metadata.action as 'create_new' | 'edit' | 'delete') || undefined,
config_type: (metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline') || undefined, config_type: (metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent') || undefined,
name: metadata.name name: metadata.name
} }
}; };
@ -289,6 +289,39 @@ function AssistantMessage({
pipeline: action.config_changes pipeline: action.config_changes
}); });
break; break;
case 'start_agent':
dispatch({
type: 'set_main_agent',
name: action.name,
})
break;
}
} else if (action.action === 'delete') {
switch (action.config_type) {
case 'agent':
dispatch({
type: 'delete_agent',
name: action.name
});
break;
case 'tool':
dispatch({
type: 'delete_tool',
name: action.name
});
break;
case 'prompt':
dispatch({
type: 'delete_prompt',
name: action.name
});
break;
case 'pipeline':
dispatch({
type: 'delete_pipeline',
name: action.name
});
break;
} }
} }
}, [dispatch, workflow.agents, workflow.tools]); }, [dispatch, workflow.agents, workflow.tools]);
@ -542,7 +575,7 @@ export function Messages({
}; };
return ( return (
<div className="h-full"> <div className={displayMessages.length === 0 ? "" : "h-full"}>
<div className="flex flex-col mb-4"> <div className="flex flex-col mb-4">
{displayMessages.map((message, index) => ( {displayMessages.map((message, index) => (
<div key={index} className="mb-4"> <div key={index} className="mb-4">

View file

@ -2,10 +2,10 @@
import { WorkflowPrompt, WorkflowAgent, Workflow, WorkflowTool } from "../../../lib/types/workflow_types"; import { WorkflowPrompt, WorkflowAgent, Workflow, WorkflowTool } from "../../../lib/types/workflow_types";
import { DataSource } from "@/src/entities/models/data-source"; import { DataSource } from "@/src/entities/models/data-source";
import { z } from "zod"; import { z } from "zod";
import { PlusIcon, X as XIcon, ChevronDown, ChevronRight, Trash2, Maximize2, Minimize2, StarIcon, DatabaseIcon, UserIcon, Settings, Info } from "lucide-react"; import { PlusIcon, X as XIcon, ChevronDown, ChevronRight, Trash2, Maximize2, Minimize2, StarIcon, DatabaseIcon, UserIcon, Settings, Info, Edit3 } 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, Chip, SelectSection } from "@heroui/react"; import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Select, SelectItem, Chip, SelectSection, Input } from "@heroui/react";
import { PreviewModalProvider } from "../workflow/preview-modal"; import { PreviewModalProvider } from "../workflow/preview-modal";
import { CopilotMessage } from "@/src/application/lib/copilot/types"; import { CopilotMessage } from "@/src/application/lib/copilot/types";
import { getCopilotAgentInstructions } from "@/app/actions/copilot.actions"; import { getCopilotAgentInstructions } from "@/app/actions/copilot.actions";
@ -17,7 +17,6 @@ import { Button as CustomButton } from "@/components/ui/button";
import clsx from "clsx"; import clsx from "clsx";
import { InputField } from "@/app/lib/components/input-field"; import { InputField } from "@/app/lib/components/input-field";
import { USE_TRANSFER_CONTROL_OPTIONS } from "@/app/lib/feature_flags"; import { USE_TRANSFER_CONTROL_OPTIONS } from "@/app/lib/feature_flags";
import { Input } from "@/components/ui/input";
import { Info as InfoIcon } from "lucide-react"; import { Info as InfoIcon } from "lucide-react";
import { useCopilot } from "../copilot/use-copilot"; import { useCopilot } from "../copilot/use-copilot";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal"; import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
@ -78,6 +77,8 @@ export function AgentConfig({
const [previousRagSources, setPreviousRagSources] = useState<string[]>([]); const [previousRagSources, setPreviousRagSources] = useState<string[]>([]);
const [billingError, setBillingError] = useState<string | null>(null); const [billingError, setBillingError] = useState<string | null>(null);
const [showSavedBanner, setShowSavedBanner] = useState(false); const [showSavedBanner, setShowSavedBanner] = useState(false);
const [isEditingName, setIsEditingName] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(null);
// Check if this agent is a pipeline agent // Check if this agent is a pipeline agent
const isPipelineAgent = agent.type === 'pipeline'; const isPipelineAgent = agent.type === 'pipeline';
@ -101,6 +102,14 @@ export function AgentConfig({
setLocalName(agent.name); setLocalName(agent.name);
}, [agent.name]); }, [agent.name]);
// Focus name input when entering edit mode
useEffect(() => {
if (isEditingName && nameInputRef.current) {
nameInputRef.current.focus();
nameInputRef.current.select();
}
}, [isEditingName]);
// Track changes in RAG datasources // Track changes in RAG datasources
useEffect(() => { useEffect(() => {
const currentSources = agent.ragDataSources || []; const currentSources = agent.ragDataSources || [];
@ -149,6 +158,13 @@ export function AgentConfig({
} }
}, [agent.controlType, agent.outputVisibility, agent, handleUpdate]); }, [agent.controlType, agent.outputVisibility, agent, handleUpdate]);
// Add effect to ensure internal agents have maxCallsPerParentAgent set to 1 by default
useEffect(() => {
if (agent.outputVisibility === "internal" && !isPipelineAgent && agent.maxCallsPerParentAgent === undefined) {
handleUpdate({ ...agent, maxCallsPerParentAgent: 1 });
}
}, [agent.outputVisibility, agent.maxCallsPerParentAgent, agent, handleUpdate, isPipelineAgent]);
// Add effect to handle escape key // Add effect to handle escape key
useEffect(() => { useEffect(() => {
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
@ -188,15 +204,36 @@ export function AgentConfig({
return true; return true;
}; };
const handleNameChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newName = e.target.value; const newName = e.target.value;
setLocalName(newName); setLocalName(newName);
setNameError(null);
};
if (validateName(newName)) { const handleNameCommit = () => {
if (validateName(localName)) {
handleUpdate({ handleUpdate({
...agent, ...agent,
name: newName name: localName
}); });
showSavedMessage();
setIsEditingName(false);
}
};
const handleNameCancel = () => {
setLocalName(agent.name);
setNameError(null);
setIsEditingName(false);
};
const handleNameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleNameCommit();
} else if (e.key === 'Escape') {
e.preventDefault();
handleNameCancel();
} }
}; };
@ -209,20 +246,42 @@ export function AgentConfig({
currentAgent: agent currentAgent: agent
}); });
// Add local state for max calls input
const [maxCallsInput, setMaxCallsInput] = useState(String(agent.maxCallsPerParentAgent || 3));
const [maxCallsError, setMaxCallsError] = useState<string | null>(null);
// Sync local state with agent prop
useEffect(() => {
setMaxCallsInput(String(agent.maxCallsPerParentAgent || 3));
}, [agent.maxCallsPerParentAgent]);
return ( return (
<Panel <Panel
title={ title={
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<div className="text-base font-semibold text-gray-900 dark:text-gray-100"> <div className="flex items-center gap-2 flex-1 min-w-0">
{agent.name} {isEditingName ? (
<div className="flex flex-col min-w-0 flex-1">
<Input
ref={nameInputRef}
type="text"
value={localName}
onChange={handleNameChange}
onKeyDown={handleNameKeyDown}
onBlur={handleNameCommit}
isInvalid={!!nameError}
errorMessage={nameError}
variant="bordered"
size="sm"
classNames={{
base: "max-w-xs",
input: "text-base font-semibold px-2",
inputWrapper: "min-h-[28px] h-[28px] border-gray-200 dark:border-gray-700 px-0"
}}
/>
</div>
) : (
<button
onClick={() => setIsEditingName(true)}
className="flex items-center gap-2 text-base font-semibold text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 px-2 py-1 rounded-md transition-colors group"
>
<span className="truncate">{agent.name}</span>
<Edit3 className="w-4 h-4 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors" />
</button>
)}
</div> </div>
<CustomButton <CustomButton
variant="secondary" variant="secondary"
@ -260,7 +319,7 @@ export function AgentConfig({
: "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300" : "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
)} )}
> >
{tab.charAt(0).toUpperCase() + tab.slice(1)} {tab === 'instructions' ? 'Instructions' : 'Model & RAG'}
</button> </button>
))} ))}
</div> </div>
@ -480,38 +539,6 @@ export function AgentConfig({
{activeTab === 'configurations' && ( {activeTab === 'configurations' && (
<div className="flex flex-col gap-4 pb-4 pt-0"> <div className="flex flex-col gap-4 pb-4 pt-0">
{/* Identity Section Card */}
<SectionCard
icon={<UserIcon className="w-5 h-5 text-indigo-500" />}
title="Identity"
labelWidth="md:w-32"
className="mb-1"
>
<div className="flex flex-col gap-6">
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Name</label>
<div className="flex-1">
<InputField
type="text"
value={localName}
onChange={(value) => {
setLocalName(value);
if (validateName(value)) {
handleUpdate({
...agent,
name: value
});
}
showSavedMessage();
}}
error={nameError}
className="w-full"
/>
</div>
</div>
</div>
</SectionCard>
{/* Behavior Section Card */} {/* Behavior Section Card */}
<SectionCard <SectionCard
icon={<Settings className="w-5 h-5 text-indigo-500" />} icon={<Settings className="w-5 h-5 text-indigo-500" />}
@ -618,43 +645,7 @@ export function AgentConfig({
} }
</div> </div>
</div> </div>
{agent.outputVisibility === "internal" && !isPipelineAgent && (
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Max Calls From Parent</label>
<div className="flex-1">
<InputField
type="number"
value={maxCallsInput}
onChange={(value: string) => {
setMaxCallsInput(value);
setMaxCallsError(null);
const num = Number(value);
if (value && !isNaN(num) && num >= 1 && Number.isInteger(num)) {
if (num !== agent.maxCallsPerParentAgent) {
handleUpdate({
...agent,
maxCallsPerParentAgent: num
});
}
}
}}
validate={(value: string) => {
const num = Number(value);
if (!value || isNaN(num) || num < 1 || !Number.isInteger(num)) {
return { valid: false, errorMessage: "Must be an integer >= 1" };
}
return { valid: true };
}}
error={maxCallsError}
min={1}
className="w-full max-w-24"
/>
{maxCallsError && (
<p className="text-sm text-red-500 mt-1">{maxCallsError}</p>
)}
</div>
</div>
)}
{USE_TRANSFER_CONTROL_OPTIONS && !isPipelineAgent && ( {USE_TRANSFER_CONTROL_OPTIONS && !isPipelineAgent && (
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0"> <div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">After Turn</label> <label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">After Turn</label>

View file

@ -14,6 +14,9 @@ const sectionHeaderStyles = "block text-xs font-medium uppercase tracking-wider
// Enhanced textarea styles with improved states // Enhanced textarea styles with improved states
const textareaStyles = "rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500"; const textareaStyles = "rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500";
// Value field styles without grey placeholder text
const valueTextareaStyles = "rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-black dark:placeholder:text-white";
export function PromptConfig({ export function PromptConfig({
prompt, prompt,
agents, agents,
@ -128,7 +131,7 @@ export function PromptConfig({
<div className="space-y-4"> <div className="space-y-4">
<label className={sectionHeaderStyles}> <label className={sectionHeaderStyles}>
Prompt Value
</label> </label>
<Textarea <Textarea
value={prompt.prompt} value={prompt.prompt}
@ -139,8 +142,8 @@ export function PromptConfig({
}); });
showSavedMessage(); showSavedMessage();
}} }}
placeholder="Edit prompt here..." placeholder="Enter variable value..."
className={`${textareaStyles} min-h-[200px]`} className={`${valueTextareaStyles} min-h-[200px]`}
autoResize autoResize
/> />
</div> </div>

View file

@ -160,6 +160,16 @@ export function Chat({
setIsLastInteracted(true); setIsLastInteracted(true);
} }
// clean up event source on component unmount
useEffect(() => {
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
}
}, []);
useEffect(() => { useEffect(() => {
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
if (!container) return; if (!container) return;

View file

@ -63,6 +63,9 @@ interface EntityListProps {
onAddAgent: (agent: Partial<z.infer<typeof WorkflowAgent>>) => void; onAddAgent: (agent: Partial<z.infer<typeof WorkflowAgent>>) => void;
onAddTool: (tool: Partial<z.infer<typeof WorkflowTool>>) => void; onAddTool: (tool: Partial<z.infer<typeof WorkflowTool>>) => void;
onAddPrompt: (prompt: Partial<z.infer<typeof WorkflowPrompt>>) => void; onAddPrompt: (prompt: Partial<z.infer<typeof WorkflowPrompt>>) => void;
onUpdatePrompt: (name: string, prompt: Partial<z.infer<typeof WorkflowPrompt>>) => void;
onAddPromptFromModal: (prompt: Partial<z.infer<typeof WorkflowPrompt>>) => void;
onUpdatePromptFromModal: (name: string, prompt: Partial<z.infer<typeof WorkflowPrompt>>) => void;
onAddPipeline: (pipeline: Partial<z.infer<typeof WorkflowPipeline>>) => void; onAddPipeline: (pipeline: Partial<z.infer<typeof WorkflowPipeline>>) => void;
onAddAgentToPipeline: (pipelineName: string) => void; onAddAgentToPipeline: (pipelineName: string) => void;
onToggleAgent: (name: string) => void; onToggleAgent: (name: string) => void;
@ -97,6 +100,7 @@ const EmptyState: React.FC<EmptyStateProps> = ({ entity, hasFilteredItems }) =>
const ListItemWithMenu = ({ const ListItemWithMenu = ({
name, name,
value,
isSelected, isSelected,
onClick, onClick,
disabled, disabled,
@ -110,6 +114,7 @@ const ListItemWithMenu = ({
isMocked, isMocked,
}: { }: {
name: string; name: string;
value?: string;
isSelected?: boolean; isSelected?: boolean;
onClick?: () => void; onClick?: () => void;
disabled?: boolean; disabled?: boolean;
@ -157,9 +162,34 @@ const ListItemWithMenu = ({
/> />
) : icon} ) : icon}
</div> </div>
<span className="text-xs">{name}</span> {value ? (
<div className="flex-1 min-w-0 grid grid-cols-2 gap-2">
<Tooltip
content={name}
size="sm"
delay={500}
isDisabled={name.length <= 20}
>
<span className="text-xs font-medium truncate">
{name}
</span>
</Tooltip>
<Tooltip
content={value}
size="sm"
delay={500}
isDisabled={value.length <= 30}
>
<span className="text-xs text-zinc-600 dark:text-zinc-400 truncate">
{value}
</span>
</Tooltip>
</div>
) : (
<span className="text-xs">{name}</span>
)}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 shrink-0">
{statusLabel} {statusLabel}
{isMocked && ( {isMocked && (
<Tooltip content="Mocked" size="sm" delay={500}> <Tooltip content="Mocked" size="sm" delay={500}>
@ -168,7 +198,9 @@ const ListItemWithMenu = ({
</div> </div>
</Tooltip> </Tooltip>
)} )}
{menuContent} <div className="opacity-100">
{menuContent}
</div>
</div> </div>
</div> </div>
); );
@ -464,6 +496,9 @@ export const EntityList = forwardRef<
onAddAgent, onAddAgent,
onAddTool, onAddTool,
onAddPrompt, onAddPrompt,
onUpdatePrompt,
onAddPromptFromModal,
onUpdatePromptFromModal,
onAddPipeline, onAddPipeline,
onAddAgentToPipeline, onAddAgentToPipeline,
onToggleAgent, onToggleAgent,
@ -490,6 +525,8 @@ export const EntityList = forwardRef<
const [showAgentTypeModal, setShowAgentTypeModal] = useState(false); const [showAgentTypeModal, setShowAgentTypeModal] = useState(false);
const [showToolsModal, setShowToolsModal] = useState(false); const [showToolsModal, setShowToolsModal] = useState(false);
const [showDataSourcesModal, setShowDataSourcesModal] = useState(false); const [showDataSourcesModal, setShowDataSourcesModal] = useState(false);
const [showAddVariableModal, setShowAddVariableModal] = useState(false);
const [editingVariable, setEditingVariable] = useState<{name: string; value: string} | null>(null);
// State to track which toolkit's tools panel to open // State to track which toolkit's tools panel to open
const [selectedToolkitSlug, setSelectedToolkitSlug] = useState<string | null>(null); const [selectedToolkitSlug, setSelectedToolkitSlug] = useState<string | null>(null);
@ -498,6 +535,11 @@ export const EntityList = forwardRef<
outputVisibility: agentType outputVisibility: agentType
}); });
}; };
const handleVariableClick = (prompt: z.infer<typeof WorkflowPrompt>) => {
setEditingVariable({ name: prompt.name, value: prompt.prompt });
setShowAddVariableModal(true);
};
const selectedRef = useRef<HTMLDivElement>(null); const selectedRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState<number>(0); const [containerHeight, setContainerHeight] = useState<number>(0);
@ -1146,7 +1188,7 @@ export const EntityList = forwardRef<
)} )}
</button> </button>
<PenLine className="w-4 h-4" /> <PenLine className="w-4 h-4" />
<span>Prompts</span> <span>Variables</span>
</div> </div>
<Button <Button
variant="secondary" variant="secondary"
@ -1154,11 +1196,11 @@ export const EntityList = forwardRef<
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setExpandedPanels(prev => ({ ...prev, prompts: true })); setExpandedPanels(prev => ({ ...prev, prompts: true }));
onAddPrompt({}); setShowAddVariableModal(true);
}} }}
className={`group ${buttonClasses}`} className={`group ${buttonClasses}`}
showHoverContent={true} showHoverContent={true}
hoverContent="Add Prompt" hoverContent="Add Variable"
> >
<PlusIcon className="w-4 h-4" /> <PlusIcon className="w-4 h-4" />
</Button> </Button>
@ -1174,8 +1216,9 @@ export const EntityList = forwardRef<
<ListItemWithMenu <ListItemWithMenu
key={`prompt-${index}`} key={`prompt-${index}`}
name={prompt.name} name={prompt.name}
value={prompt.prompt}
isSelected={selectedEntity?.type === "prompt" && selectedEntity.name === prompt.name} isSelected={selectedEntity?.type === "prompt" && selectedEntity.name === prompt.name}
onClick={() => onSelectPrompt(prompt.name)} onClick={() => handleVariableClick(prompt)}
selectedRef={selectedEntity?.type === "prompt" && selectedEntity.name === prompt.name ? selectedRef : undefined} selectedRef={selectedEntity?.type === "prompt" && selectedEntity.name === prompt.name ? selectedRef : undefined}
icon={<ScrollText className="w-4 h-4 text-blue-600/70 dark:text-blue-500/70" />} icon={<ScrollText className="w-4 h-4 text-blue-600/70 dark:text-blue-500/70" />}
menuContent={ menuContent={
@ -1188,7 +1231,7 @@ export const EntityList = forwardRef<
))} ))}
</div> </div>
) : ( ) : (
<EmptyState entity="prompts" hasFilteredItems={false} /> <EmptyState entity="variables" hasFilteredItems={false} />
)} )}
</div> </div>
</div> </div>
@ -1227,6 +1270,27 @@ export const EntityList = forwardRef<
useRagS3Uploads={useRagS3Uploads} useRagS3Uploads={useRagS3Uploads}
useRagScraping={useRagScraping} useRagScraping={useRagScraping}
/> />
<AddVariableModal
isOpen={showAddVariableModal}
onClose={() => {
setShowAddVariableModal(false);
setEditingVariable(null);
}}
onConfirm={(name, value) => {
if (editingVariable) {
// Update existing variable using modal-specific handler
onUpdatePromptFromModal(editingVariable.name, { name, prompt: value });
} else {
// Add new variable using modal-specific handler
onAddPromptFromModal({ name, prompt: value });
}
setShowAddVariableModal(false);
setEditingVariable(null);
}}
initialName={editingVariable?.name}
initialValue={editingVariable?.value}
isEditing={!!editingVariable}
/>
</div> </div>
); );
}); });
@ -1923,3 +1987,133 @@ function AgentTypeModal({ isOpen, onClose, onConfirm, onCreatePipeline }: AgentT
</Modal> </Modal>
); );
} }
interface AddVariableModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (name: string, value: string) => void;
initialName?: string;
initialValue?: string;
isEditing?: boolean;
}
function AddVariableModal({ isOpen, onClose, onConfirm, initialName, initialValue, isEditing = false }: AddVariableModalProps) {
const [name, setName] = useState('');
const [value, setValue] = useState('');
const [errors, setErrors] = useState<{ name?: string; value?: string }>({});
// Initialize form with values when modal opens
useEffect(() => {
if (isOpen) {
setName(initialName || '');
setValue(initialValue || '');
setErrors({});
}
}, [isOpen, initialName, initialValue]);
const resetForm = () => {
setName('');
setValue('');
setErrors({});
};
const handleClose = () => {
resetForm();
onClose();
};
const handleConfirm = () => {
const newErrors: { name?: string; value?: string } = {};
if (!name.trim()) {
newErrors.name = 'Variable name is required';
}
if (!value.trim()) {
newErrors.value = 'Variable value is required';
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onConfirm(name.trim(), value.trim());
resetForm();
};
return (
<Modal isOpen={isOpen} onClose={handleClose} size="md">
<ModalContent>
<ModalHeader>
<div className="flex items-center gap-2">
<PenLine className="w-5 h-5 text-indigo-600" />
<span>{isEditing ? 'Edit Variable' : 'Add Variable'}</span>
</div>
</ModalHeader>
<ModalBody className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Variable Name
</label>
<input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
if (errors.name) setErrors(prev => ({ ...prev, name: undefined }));
}}
placeholder="Enter variable name (e.g., greeting_message)"
className={clsx(
"w-full px-3 py-2 border rounded-md text-sm",
"focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500",
"dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100",
errors.name ? "border-red-500" : "border-gray-300 dark:border-gray-600"
)}
/>
{errors.name && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.name}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Variable Value
</label>
<textarea
value={value}
onChange={(e) => {
setValue(e.target.value);
if (errors.value) setErrors(prev => ({ ...prev, value: undefined }));
}}
placeholder="Enter the variable value..."
rows={4}
className={clsx(
"w-full px-3 py-2 border rounded-md text-sm resize-none",
"focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500",
"dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100",
errors.value ? "border-red-500" : "border-gray-300 dark:border-gray-600"
)}
/>
{errors.value && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.value}</p>
)}
</div>
</ModalBody>
<ModalFooter>
<Button
variant="secondary"
onClick={handleClose}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleConfirm}
>
{isEditing ? 'Update Variable' : 'Add Variable'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View file

@ -32,7 +32,7 @@ export default async function Page(
const project = await fetchProjectController.execute({ const project = await fetchProjectController.execute({
caller: "user", caller: "user",
userId: user._id, userId: user.id,
projectId: params.projectId, projectId: params.projectId,
}); });
if (!project) { if (!project) {
@ -41,13 +41,13 @@ export default async function Page(
const sources = await listDataSourcesController.execute({ const sources = await listDataSourcesController.execute({
caller: "user", caller: "user",
userId: user._id, userId: user.id,
projectId: params.projectId, projectId: params.projectId,
}); });
let eligibleModels: z.infer<typeof ModelsResponse> | "*" = '*'; let eligibleModels: z.infer<typeof ModelsResponse> | "*" = '*';
if (USE_BILLING) { if (USE_BILLING) {
eligibleModels = await getEligibleModels(customer._id); eligibleModels = await getEligibleModels(customer.id);
} }
console.log('/workflow page.tsx serve'); console.log('/workflow page.tsx serve');

View file

@ -85,6 +85,9 @@ export type Action = {
} | { } | {
type: "add_prompt"; type: "add_prompt";
prompt: Partial<z.infer<typeof WorkflowPrompt>>; prompt: Partial<z.infer<typeof WorkflowPrompt>>;
} | {
type: "add_prompt_no_select";
prompt: Partial<z.infer<typeof WorkflowPrompt>>;
} | { } | {
type: "add_pipeline"; type: "add_pipeline";
pipeline: Partial<z.infer<typeof WorkflowPipeline>>; pipeline: Partial<z.infer<typeof WorkflowPipeline>>;
@ -143,6 +146,10 @@ export type Action = {
type: "update_prompt"; type: "update_prompt";
name: string; name: string;
prompt: Partial<z.infer<typeof WorkflowPrompt>>; prompt: Partial<z.infer<typeof WorkflowPrompt>>;
} | {
type: "update_prompt_no_select";
name: string;
prompt: Partial<z.infer<typeof WorkflowPrompt>>;
} | { } | {
type: "toggle_agent"; type: "toggle_agent";
name: string; name: string;
@ -391,10 +398,10 @@ function reducer(state: State, action: Action): State {
if (isLive) { if (isLive) {
break; break;
} }
let newPromptName = "New prompt"; let newPromptName = "New Variable";
if (draft.workflow?.prompts.some((prompt) => prompt.name === newPromptName)) { if (draft.workflow?.prompts.some((prompt) => prompt.name === newPromptName)) {
newPromptName = `New prompt ${draft.workflow?.prompts.filter((prompt) => newPromptName = `New Variable ${draft.workflow?.prompts.filter((prompt) =>
prompt.name.startsWith("New prompt")).length + 1}`; prompt.name.startsWith("New Variable")).length + 1}`;
} }
draft.workflow?.prompts.push({ draft.workflow?.prompts.push({
name: newPromptName, name: newPromptName,
@ -410,6 +417,26 @@ function reducer(state: State, action: Action): State {
draft.chatKey++; draft.chatKey++;
break; break;
} }
case "add_prompt_no_select": {
if (isLive) {
break;
}
let newPromptName = "New Variable";
if (draft.workflow?.prompts.some((prompt) => prompt.name === newPromptName)) {
newPromptName = `New Variable ${draft.workflow?.prompts.filter((prompt) =>
prompt.name.startsWith("New Variable")).length + 1}`;
}
draft.workflow?.prompts.push({
name: newPromptName,
type: "base_prompt",
prompt: "",
...action.prompt
});
// Don't set selection - this is the key difference
draft.pendingChanges = true;
draft.chatKey++;
break;
}
case "add_pipeline": { case "add_pipeline": {
if (isLive) { if (isLive) {
break; break;
@ -751,6 +778,47 @@ function reducer(state: State, action: Action): State {
draft.pendingChanges = true; draft.pendingChanges = true;
draft.chatKey++; draft.chatKey++;
break; break;
case "update_prompt_no_select":
if (isLive) {
break;
}
// update prompt data
draft.workflow.prompts = draft.workflow.prompts.map((prompt) =>
prompt.name === action.name ? { ...prompt, ...action.prompt } : prompt
);
// if the prompt is renamed
if (action.prompt.name && action.prompt.name !== action.name) {
// update this prompts references in other agents / prompts
draft.workflow.agents = draft.workflow.agents.map(agent => ({
...agent,
instructions: agent.instructions.replace(
`[@prompt:${action.name}](#mention)`,
`[@prompt:${action.prompt.name}](#mention)`
)
}));
draft.workflow.prompts = draft.workflow.prompts.map(prompt => ({
...prompt,
prompt: prompt.prompt.replace(
`[@prompt:${action.name}](#mention)`,
`[@prompt:${action.prompt.name}](#mention)`
)
}));
// if this is the selected prompt, update the selection
if (draft.selection?.type === "prompt" && draft.selection.name === action.name) {
draft.selection = {
type: "prompt",
name: action.prompt.name
};
}
}
// Don't set selection - this is the key difference
draft.pendingChanges = true;
draft.chatKey++;
break;
case "toggle_agent": case "toggle_agent":
if (isLive) { if (isLive) {
break; break;
@ -1074,6 +1142,15 @@ export function WorkflowEditor({
dispatch({ type: "update_prompt", name, prompt }); dispatch({ type: "update_prompt", name, prompt });
} }
// Modal-specific handlers that don't auto-select
function handleAddPromptFromModal(prompt: Partial<z.infer<typeof WorkflowPrompt>>) {
dispatch({ type: "add_prompt_no_select", prompt });
}
function handleUpdatePromptFromModal(name: string, prompt: Partial<z.infer<typeof WorkflowPrompt>>) {
dispatch({ type: "update_prompt_no_select", name, prompt });
}
function handleDeletePrompt(name: string) { function handleDeletePrompt(name: string) {
if (window.confirm(`Are you sure you want to delete the prompt "${name}"?`)) { if (window.confirm(`Are you sure you want to delete the prompt "${name}"?`)) {
dispatch({ type: "delete_prompt", name }); dispatch({ type: "delete_prompt", name });
@ -1129,7 +1206,23 @@ export function WorkflowEditor({
// Remove handleCopyJSON and add handleDownloadJSON // Remove handleCopyJSON and add handleDownloadJSON
function handleDownloadJSON() { function handleDownloadJSON() {
const workflow = state.present.workflow; const workflow = state.present.workflow;
const json = JSON.stringify(workflow, null, 2);
// Create a copy of the workflow and replace variable values with dummy text
const workflowCopy = {
...workflow,
prompts: workflow.prompts.map(prompt => {
// If this is a variable (base_prompt type), replace its value with dummy text
if (prompt.type === 'base_prompt') {
return {
...prompt,
prompt: '<needs to be added>'
};
}
return prompt;
})
};
const json = JSON.stringify(workflowCopy, null, 2);
const blob = new Blob([json], { type: 'application/json' }); const blob = new Blob([json], { type: 'application/json' });
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@ -1310,6 +1403,9 @@ export function WorkflowEditor({
onAddAgent={handleAddAgent} onAddAgent={handleAddAgent}
onAddTool={handleAddTool} onAddTool={handleAddTool}
onAddPrompt={handleAddPrompt} onAddPrompt={handleAddPrompt}
onUpdatePrompt={handleUpdatePrompt}
onAddPromptFromModal={handleAddPromptFromModal}
onUpdatePromptFromModal={handleUpdatePromptFromModal}
onAddPipeline={handleAddPipeline} onAddPipeline={handleAddPipeline}
onAddAgentToPipeline={handleAddAgentToPipeline} onAddAgentToPipeline={handleAddAgentToPipeline}
onToggleAgent={handleToggleAgent} onToggleAgent={handleToggleAgent}

View file

@ -256,24 +256,27 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
{/* Theme and Auth Controls */} {/* Theme and Auth Controls */}
<div className="p-3 border-t border-zinc-100 dark:border-zinc-800 space-y-2"> <div className="p-3 border-t border-zinc-100 dark:border-zinc-800 space-y-2">
{USE_PRODUCT_TOUR && !isProjectsRoute && ( {/* Help button - always visible, but behavior depends on feature flag */}
<Tooltip content={collapsed ? "Help" : ""} showArrow placement="right"> <Tooltip content={collapsed ? "Help" : ""} showArrow placement="right">
<button <button
onClick={showHelpModal} onClick={USE_PRODUCT_TOUR ? showHelpModal : () => {
className={` // Basic help behavior when tour is disabled
w-full rounded-md flex items-center // You can customize this to show a different help modal or redirect
text-[15px] font-medium transition-all duration-200 window.open('https://discord.com/invite/rxB8pzHxaS', '_blank');
${collapsed ? 'justify-center py-4' : 'px-4 py-4 gap-3'} }}
hover:bg-zinc-100 dark:hover:bg-zinc-800/50 className={`
text-zinc-600 dark:text-zinc-400 w-full rounded-md flex items-center
`} text-[15px] font-medium transition-all duration-200
data-tour-target="tour-button" ${collapsed ? 'justify-center py-4' : 'px-4 py-4 gap-3'}
> hover:bg-zinc-100 dark:hover:bg-zinc-800/50
<HelpCircle size={COLLAPSED_ICON_SIZE} /> text-zinc-600 dark:text-zinc-400
{!collapsed && <span>Help</span>} `}
</button> data-tour-target="tour-button"
</Tooltip> >
)} <HelpCircle size={COLLAPSED_ICON_SIZE} />
{!collapsed && <span>Help</span>}
</button>
</Tooltip>
{SHOW_DARK_MODE_TOGGLE && ( {SHOW_DARK_MODE_TOGGLE && (
<Tooltip content={collapsed ? "Appearance" : ""} showArrow placement="right"> <Tooltip content={collapsed ? "Appearance" : ""} showArrow placement="right">

View file

@ -433,7 +433,7 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: z.infer<typeof Da
logger.log("Error processing doc:", e); logger.log("Error processing doc:", e);
await dataSourceDocsRepository.updateByVersion(doc.id, doc.version, { await dataSourceDocsRepository.updateByVersion(doc.id, doc.version, {
status: "error", status: "error",
error: e.message, error: "Error processing doc",
}); });
} finally { } finally {
// log usage in billing // log usage in billing
@ -466,7 +466,7 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: z.infer<typeof Da
logger.log("Error deleting doc:", e); logger.log("Error deleting doc:", e);
await dataSourceDocsRepository.updateByVersion(doc.id, doc.version, { await dataSourceDocsRepository.updateByVersion(doc.id, doc.version, {
status: "error", status: "error",
error: e.message, error: "Error deleting doc",
}); });
} }
} }

View file

@ -81,56 +81,6 @@ export function Panel({
onClick={onClick} onClick={onClick}
data-tour-target={tourTarget} data-tour-target={tourTarget}
> >
{variant === 'copilot' && showWelcome && (
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none -mt-8">
{/* Replace Sparkles icon with mascot image */}
<Image src={mascot} alt="Rowboat Mascot" width={192} height={192} className="object-contain mb-4 animate-float" />
{/* Welcome/Intro Section */}
<div className="text-center max-w-md px-6 mb-4">
<h3 className="text-xl font-semibold text-zinc-700 dark:text-zinc-300 mb-3 text-center">
👋 Welcome to Rowboat!
</h3>
<p className="text-base text-zinc-600 dark:text-zinc-400 mb-6 text-center">
I&apos;m your copilot for building agents and adding tools to them.
</p>
<p className="text-base text-zinc-600 dark:text-zinc-400 mb-4 text-center">
Here&apos;s what you can do in Rowboat:
</p>
<div className="space-y-3 max-w-2xl mx-auto text-left">
<div className="flex items-start gap-3">
<span className="text-lg"></span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Build AI agents instantly with natural language.</span>
</div>
<div className="flex items-start gap-3">
<span className="text-lg">🔌</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Connect tools with one-click integrations.</span>
</div>
<div className="flex items-start gap-3">
<span className="text-lg">📂</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Power with knowledge by adding documents for RAG.</span>
</div>
<div className="flex items-start gap-3">
<span className="text-lg">🔄</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Automate workflows by setting up triggers and actions.</span>
</div>
<div className="flex items-start gap-3">
<span className="text-lg">🚀</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Deploy anywhere via API or SDK.</span>
</div>
</div>
</div>
{SHOW_COPILOT_MARQUEE && (
<div className="relative mt-4 max-w-full px-8">
<div className="font-mono text-sm whitespace-nowrap text-blue-400/60 dark:text-blue-500/40 font-small inline-flex">
<div className="overflow-hidden w-0 animate-typing">What can I help you build?</div>
<div className="border-r-2 border-blue-400 dark:border-blue-500 animate-cursor">&nbsp;</div>
</div>
</div>
)}
</div>
)}
<div <div
className={clsx( className={clsx(
"shrink-0 border-b relative", "shrink-0 border-b relative",
@ -207,7 +157,9 @@ export function Panel({
<div className="px-4 py-3"> <div className="px-4 py-3">
{children} {children}
</div> </div>
) : children} ) : (
children
)}
</div> </div>
</div>; </div>;
} }

View file

@ -145,6 +145,9 @@ import { UpdateLiveWorkflowController } from "@/src/interface-adapters/controlle
import { RevertToLiveWorkflowUseCase } from "@/src/application/use-cases/projects/revert-to-live-workflow.use-case"; import { RevertToLiveWorkflowUseCase } from "@/src/application/use-cases/projects/revert-to-live-workflow.use-case";
import { RevertToLiveWorkflowController } from "@/src/interface-adapters/controllers/projects/revert-to-live-workflow.controller"; import { RevertToLiveWorkflowController } from "@/src/interface-adapters/controllers/projects/revert-to-live-workflow.controller";
// users
import { MongoDBUsersRepository } from "@/src/infrastructure/repositories/mongodb.users.repository";
export const container = createContainer({ export const container = createContainer({
injectionMode: InjectionMode.PROXY, injectionMode: InjectionMode.PROXY,
strict: true, strict: true,
@ -324,4 +327,8 @@ container.register({
runTurnController: asClass(RunTurnController).singleton(), runTurnController: asClass(RunTurnController).singleton(),
listConversationsController: asClass(ListConversationsController).singleton(), listConversationsController: asClass(ListConversationsController).singleton(),
fetchConversationController: asClass(FetchConversationController).singleton(), fetchConversationController: asClass(FetchConversationController).singleton(),
// users
// ---
usersRepository: asClass(MongoDBUsersRepository).singleton(),
}); });

View file

@ -412,7 +412,7 @@ export function createMockTool(
} catch (error) { } catch (error) {
logger.log(`Error executing mock tool ${config.name}:`, error); logger.log(`Error executing mock tool ${config.name}:`, error);
return JSON.stringify({ return JSON.stringify({
error: `Mock tool execution failed: ${error}`, error: "Tool execution failed!",
}); });
} }
} }
@ -447,7 +447,7 @@ export function createWebhookTool(
} catch (error) { } catch (error) {
logger.log(`Error executing webhook tool ${config.name}:`, error); logger.log(`Error executing webhook tool ${config.name}:`, error);
return JSON.stringify({ return JSON.stringify({
error: `Tool execution failed: ${error}`, error: "Tool execution failed!",
}); });
} }
} }
@ -482,7 +482,7 @@ export function createMcpTool(
} catch (error) { } catch (error) {
logger.log(`Error executing mcp tool ${name}:`, error); logger.log(`Error executing mcp tool ${name}:`, error);
return JSON.stringify({ return JSON.stringify({
error: `Tool execution failed: ${error}`, error: "Tool execution failed!",
}); });
} }
} }
@ -521,7 +521,7 @@ export function createComposioTool(
} catch (error) { } catch (error) {
logger.log(`Error executing composio tool ${name}:`, error); logger.log(`Error executing composio tool ${name}:`, error);
return JSON.stringify({ return JSON.stringify({
error: `Tool execution failed: ${error}`, error: "Tool execution failed!",
}); });
} }
} }

View file

@ -144,3 +144,23 @@ export const PIPELINE_TYPE_INSTRUCTIONS = (): string => `
- Reading the message history will show you the pipeline execution flow up to your step. - Reading the message history will show you the pipeline execution flow up to your step.
- These are high level instructions only. The user will provide more specific instructions which will be below. - These are high level instructions only. The user will provide more specific instructions which will be below.
`; `;
/**
* Instructions for providing variable context to agents
* Appends variable names and values to agent system prompts
*/
export const VARIABLES_CONTEXT_INSTRUCTIONS = (variablesList: Array<{name: string, value: string}>): string => {
if (!variablesList || variablesList.length === 0) {
return '';
}
const variablesText = variablesList
.map(variable => `${variable.name}: ${variable.value}`)
.join('\n');
return `
# Variables Context
Here is information that is already provided:
${variablesText}
`;
};

View file

@ -9,7 +9,7 @@ import crypto from "crypto";
// Internal dependencies // Internal dependencies
import { createTools, createRagTool } from "./agent-tools"; import { createTools, createRagTool } from "./agent-tools";
import { ConnectedEntity, sanitizeTextWithMentions, Workflow, WorkflowAgent, WorkflowPipeline, WorkflowPrompt, WorkflowTool } from "@/app/lib/types/workflow_types"; import { ConnectedEntity, sanitizeTextWithMentions, Workflow, WorkflowAgent, WorkflowPipeline, WorkflowPrompt, WorkflowTool } from "@/app/lib/types/workflow_types";
import { CHILD_TRANSFER_RELATED_INSTRUCTIONS, CONVERSATION_TYPE_INSTRUCTIONS, PIPELINE_TYPE_INSTRUCTIONS, RAG_INSTRUCTIONS, TASK_TYPE_INSTRUCTIONS } from "./agent_instructions"; import { CHILD_TRANSFER_RELATED_INSTRUCTIONS, CONVERSATION_TYPE_INSTRUCTIONS, PIPELINE_TYPE_INSTRUCTIONS, RAG_INSTRUCTIONS, TASK_TYPE_INSTRUCTIONS, VARIABLES_CONTEXT_INSTRUCTIONS } from "./agent_instructions";
import { PrefixLogger } from "@/app/lib/utils"; import { PrefixLogger } from "@/app/lib/utils";
import { Message, AssistantMessage, AssistantMessageWithToolCalls, ToolMessage } from "@/app/lib/types/types"; import { Message, AssistantMessage, AssistantMessageWithToolCalls, ToolMessage } from "@/app/lib/types/types";
import { UsageTracker } from "@/app/lib/billing"; import { UsageTracker } from "@/app/lib/billing";
@ -99,6 +99,14 @@ function createAgent(
): { agent: Agent, entities: z.infer<typeof ConnectedEntity>[] } { ): { agent: Agent, entities: z.infer<typeof ConnectedEntity>[] } {
const agentLogger = logger.child(`createAgent: ${config.name}`); const agentLogger = logger.child(`createAgent: ${config.name}`);
// Extract variables from workflow prompts (variables are stored as prompts with type 'base_prompt')
const variables = workflow.prompts
.filter(prompt => prompt.type === 'base_prompt')
.map(prompt => ({
name: prompt.name,
value: prompt.prompt
}));
// Combine instructions and examples // Combine instructions and examples
let instructions = `${RECOMMENDED_PROMPT_PREFIX} let instructions = `${RECOMMENDED_PROMPT_PREFIX}
@ -122,6 +130,8 @@ ${config.instructions}
${config.examples ? ('# Examples\n' + config.examples) : ''} ${config.examples ? ('# Examples\n' + config.examples) : ''}
${VARIABLES_CONTEXT_INSTRUCTIONS(variables)}
${'-'.repeat(100)} ${'-'.repeat(100)}
${CHILD_TRANSFER_RELATED_INSTRUCTIONS} ${CHILD_TRANSFER_RELATED_INSTRUCTIONS}
@ -737,29 +747,44 @@ function maybeInjectGiveUpControlInstructions(
async function* handleRawModelStreamEvent( async function* handleRawModelStreamEvent(
event: RunRawModelStreamEvent, event: RunRawModelStreamEvent,
agentConfig: Record<string, z.infer<typeof WorkflowAgent>>, agentConfig: Record<string, z.infer<typeof WorkflowAgent>>,
pipelineConfig: Record<string, z.infer<typeof WorkflowPipeline>>,
agentName: string, agentName: string,
turnMsgs: z.infer<typeof Message>[], turnMsgs: z.infer<typeof Message>[],
usageTracker: UsageTracker, usageTracker: UsageTracker,
eventLogger: PrefixLogger, eventLogger: PrefixLogger,
getAgentState?: (agentName: string) => AgentState getAgentState?: (agentName: string) => AgentState
): AsyncIterable<z.infer<typeof ZOutMessage>> { ): AsyncIterable<z.infer<typeof ZOutMessage>> {
// check response visibility - could be an agent or pipeline
const agentConfigObj = agentConfig[agentName];
const pipelineConfigObj = pipelineConfig[agentName];
const isInternal = agentConfigObj?.outputVisibility === 'internal' || agentConfigObj?.type === 'pipeline' || !!pipelineConfigObj;
if (event.data.type === 'response_done') { if (event.data.type === 'response_done') {
// Count tool calls (excluding transfer_to_* calls)
const toolCallCount = event.data.response.output.filter(
(output: any) => output.type === 'function_call' && !output.name.startsWith('transfer_to')
).length;
// If we have tool calls, increment pending counter
if (toolCallCount > 0 && getAgentState) {
const state = getAgentState(agentName);
state.pendingToolCalls += toolCallCount;
eventLogger.log(`🔧 Agent ${agentName} has ${toolCallCount} new tool calls (total: ${state.pendingToolCalls})`);
}
for (const output of event.data.response.output) { for (const output of event.data.response.output) {
if (output.type === 'message') {
for (const c of output.content) {
if (c.type === 'output_text' && c.text.trim()) {
const m: z.infer<typeof Message> = {
role: 'assistant',
content: c.text,
agentName: agentName,
responseType: isInternal ? 'internal' : 'external',
};
turnMsgs.push(m);
yield* emitEvent(eventLogger, m);
}
}
}
// handle tool call invocation // handle tool call invocation
// except for transfer_to_* tool calls // except for transfer_to_* tool calls
if (output.type === 'function_call' && !output.name.startsWith('transfer_to')) { if (output.type === 'function_call' && !output.name.startsWith('transfer_to')) {
if (getAgentState) {
const state = getAgentState(agentName);
state.pendingToolCalls++;
eventLogger.log(`🔧 Agent ${agentName} has ${state.pendingToolCalls} pending tool calls`);
}
const m: z.infer<typeof Message> = { const m: z.infer<typeof Message> = {
role: 'assistant', role: 'assistant',
content: null, content: null,
@ -1020,6 +1045,7 @@ async function* handleMessageOutput(
const pipelineConfigObj = pipelineConfig[agentName]; const pipelineConfigObj = pipelineConfig[agentName];
const isInternal = agentConfigObj?.outputVisibility === 'internal' || agentConfigObj?.type === 'pipeline' || !!pipelineConfigObj; const isInternal = agentConfigObj?.outputVisibility === 'internal' || agentConfigObj?.type === 'pipeline' || !!pipelineConfigObj;
/* ignore handling text messages here in favor of handling raw events
for (const content of event.item.rawItem.content) { for (const content of event.item.rawItem.content) {
if (content.type === 'output_text') { if (content.type === 'output_text') {
// todo: look into what is causing empty messages // todo: look into what is causing empty messages
@ -1044,6 +1070,7 @@ async function* handleMessageOutput(
yield* emitEvent(eventLogger, msg); yield* emitEvent(eventLogger, msg);
} }
} }
*/
// if this is an internal agent or pipeline agent, switch to previous agent // if this is an internal agent or pipeline agent, switch to previous agent
if (isInternal) { if (isInternal) {
@ -1362,10 +1389,20 @@ export async function* streamResponse(
// handle streaming events // handle streaming events
for await (const event of result) { for await (const event of result) {
const eventLogger = loopLogger.child(event.type); const eventLogger = loopLogger.child(event.type);
eventLogger.log(`*** GOT EVENT ***`, JSON.stringify(event));
switch (event.type) { switch (event.type) {
case 'raw_model_stream_event': case 'raw_model_stream_event':
yield* handleRawModelStreamEvent(event, agentConfig, agentName!, turnMsgs, usageTracker, eventLogger, getAgentState); yield* handleRawModelStreamEvent(
event,
agentConfig,
pipelineConfig,
agentName!,
turnMsgs,
usageTracker,
eventLogger,
getAgentState,
);
break; break;
case 'run_item_stream_event': case 'run_item_stream_event':

View file

@ -990,4 +990,92 @@ This workflow is now ready. Once you apply the changes, it will automatically ha
--- ---
### Example 7: Setting the start agent
**User Request**
Can you set the start agent to the Meeting Prep Hub?
**Copilot Response**
Yes, I can set the start agent to the Meeting Prep Hub.
\`\`\`copilot_change
// action: edit
// config_type: start_agent
// name: Meeting Prep Hub
{
"change_description": "Set the start agent to the Meeting Prep Hub.",
"config_changes": {},
}
\`\`\`
---
### Example 8: Delete an agent
**User Request:**
Can you delete the Slack Send Agent?
**Copilot Response:**
Yes, I can delete the Slack Send Agent.
\`\`\`copilot_change
// action: delete
// config_type: agent
// name: Slack Send Agent
{
"change_description": "Delete the Slack Send Agent.",
"config_changes": {},
}
}
\`\`\`
---
### Example 9: Delete a tool
**User Request:**
Can you delete the Search tool?
**Copilot Response:**
Yes, I can delete the Search tool.
\`\`\`copilot_change
// action: delete
// config_type: tool
// name: Search
{
"change_description": "Delete the Search tool.",
"config_changes": {},
}
\`\`\`
---
### Example 10: Delete a pipeline
**User Request:**
Can you delete the Meeting Prep Pipeline?
**Copilot Response:**
Yes, I can delete the Meeting Prep Pipeline.
\`\`\`copilot_change
// action: delete
// config_type: pipeline
// name: Meeting Prep Pipeline
{
"change_description": "Delete the Meeting Prep Pipeline.",
"config_changes": {},
}
\`\`\`
---
`; `;

View file

@ -21,8 +21,8 @@ export const CopilotAssistantMessageTextPart = z.object({
export const CopilotAssistantMessageActionPart = z.object({ export const CopilotAssistantMessageActionPart = z.object({
type: z.literal("action"), type: z.literal("action"),
content: z.object({ content: z.object({
config_type: z.union([z.literal('tool'), z.literal('agent'), z.literal('prompt'), z.literal('pipeline')]), config_type: z.enum(['tool', 'agent', 'prompt', 'pipeline', 'start_agent']),
action: z.union([z.literal('create_new'), z.literal('edit')]), action: z.enum(['create_new', 'edit', 'delete']),
name: z.string(), name: z.string(),
change_description: z.string(), change_description: z.string(),
config_changes: z.record(z.string(), z.unknown()), config_changes: z.record(z.string(), z.unknown()),

View file

@ -0,0 +1,12 @@
// returns the number of seconds until the next minute
export function secondsToNextMinute(): number {
const now = new Date();
const secondsUntilNextMinute = 60 - now.getSeconds();
return secondsUntilNextMinute;
}
export function minutesToNextHour(): number {
const now = new Date();
const minutesUntilNextHour = 60 - now.getMinutes();
return minutesUntilNextHour;
}

View file

@ -1,4 +1,21 @@
import { QuotaExceededError } from "@/src/entities/errors/common";
export interface IUsageQuotaPolicy { export interface IUsageQuotaPolicy {
// this method will throw a QuotaExceededError if the quota is exceeded /**
assertAndConsume(projectId: string): Promise<void>; * Asserts that the project has not exceeded its usage quota and consumes the action.
* Used for general project actions.
*
* @param projectId - The ID of the project to assert and consume.
* @throws QuotaExceededError if the quota is exceeded.
*/
assertAndConsumeProjectAction(projectId: string): Promise<void>;
/**
* Asserts that the project has not exceeded its usage quota for running jobs.
*
* @param projectId - The ID of the project to assert and consume.
* @throws QuotaExceededError if the quota is exceeded.
*/
assertAndConsumeRunJobAction(projectId: string): Promise<void>;
} }

View file

@ -0,0 +1,19 @@
import { z } from "zod";
import { User } from "@/src/entities/models/user";
export const CreateSchema = User.pick({
auth0Id: true,
email: true,
});
export interface IUsersRepository {
create(data: z.infer<typeof CreateSchema>): Promise<z.infer<typeof User>>;
fetch(id: string): Promise<z.infer<typeof User> | null>;
fetchByAuth0Id(auth0Id: string): Promise<z.infer<typeof User> | null>;
updateEmail(id: string, email: string): Promise<z.infer<typeof User>>;
updateBillingCustomerId(id: string, billingCustomerId: string): Promise<z.infer<typeof User>>;
}

View file

@ -59,7 +59,7 @@ export class CreateComposioTriggerDeploymentUseCase implements ICreateComposioTr
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// get trigger type info // get trigger type info
const triggerType = await getTriggersType(request.data.triggerTypeSlug); const triggerType = await getTriggersType(request.data.triggerTypeSlug);

View file

@ -54,7 +54,7 @@ export class DeleteComposioTriggerDeploymentUseCase implements IDeleteComposioTr
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// ensure deployment belongs to this project // ensure deployment belongs to this project
const deployment = await this.composioTriggerDeploymentsRepository.fetch(request.deploymentId); const deployment = await this.composioTriggerDeploymentsRepository.fetch(request.deploymentId);

View file

@ -53,7 +53,7 @@ export class FetchComposioTriggerDeploymentUseCase implements IFetchComposioTrig
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
return deployment; return deployment;
} }

View file

@ -51,7 +51,7 @@ export class ListComposioTriggerDeploymentsUseCase implements IListComposioTrigg
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// fetch deployments for project // fetch deployments for project
return await this.composioTriggerDeploymentsRepository.listByProjectId(projectId, request.cursor, limit); return await this.composioTriggerDeploymentsRepository.listByProjectId(projectId, request.cursor, limit);

View file

@ -61,7 +61,7 @@ export class CreateCachedTurnUseCase implements ICreateCachedTurnUseCase {
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// create cache entry // create cache entry
const key = nanoid(); const key = nanoid();

View file

@ -61,7 +61,7 @@ export class CreateConversationUseCase implements ICreateConversationUseCase {
} }
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// if workflow is not provided, fetch workflow // if workflow is not provided, fetch workflow
if (!workflow) { if (!workflow) {

View file

@ -68,7 +68,7 @@ export class FetchCachedTurnUseCase implements IFetchCachedTurnUseCase {
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// delete from cache // delete from cache
await this.cacheService.delete(`turn-${data.key}`); await this.cacheService.delete(`turn-${data.key}`);

View file

@ -54,7 +54,7 @@ export class FetchConversationUseCase implements IFetchConversationUseCase {
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// return the conversation // return the conversation
return conversation; return conversation;

View file

@ -51,7 +51,7 @@ export class ListConversationsUseCase implements IListConversationsUseCase {
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// fetch conversations for project // fetch conversations for project
return await this.conversationsRepository.list(projectId, request.cursor, limit); return await this.conversationsRepository.list(projectId, request.cursor, limit);

View file

@ -62,7 +62,7 @@ export class RunConversationTurnUseCase implements IRunConversationTurnUseCase {
} }
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// Check billing auth // Check billing auth
let billingCustomerId: string | null = null; let billingCustomerId: string | null = null;
@ -165,6 +165,7 @@ export class RunConversationTurnUseCase implements IRunConversationTurnUseCase {
} }
} finally { } finally {
// Log billing usage // Log billing usage
console.log('finally logging billing usage');
if (USE_BILLING && billingCustomerId) { if (USE_BILLING && billingCustomerId) {
await logUsage(billingCustomerId, { await logUsage(billingCustomerId, {
items: usageTracker.flush(), items: usageTracker.flush(),

View file

@ -55,7 +55,7 @@ export class AddDocsToDataSourceUseCase implements IAddDocsToDataSourceUseCase {
projectId: source.projectId, projectId: source.projectId,
}); });
await this.usageQuotaPolicy.assertAndConsume(source.projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(source.projectId);
await this.dataSourceDocsRepository.bulkCreate(source.projectId, sourceId, docs); await this.dataSourceDocsRepository.bulkCreate(source.projectId, sourceId, docs);

View file

@ -44,7 +44,7 @@ export class CreateDataSourceUseCase implements ICreateDataSourceUseCase {
projectId, projectId,
}); });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
let _status = "pending"; let _status = "pending";
// Only set status for non-file data sources // Only set status for non-file data sources

View file

@ -49,7 +49,7 @@ export class DeleteDataSourceUseCase implements IDeleteDataSourceUseCase {
projectId, projectId,
}); });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
await this.dataSourcesRepository.update(request.sourceId, { await this.dataSourcesRepository.update(request.sourceId, {
status: 'deleted', status: 'deleted',

View file

@ -54,7 +54,7 @@ export class DeleteDocFromDataSourceUseCase implements IDeleteDocFromDataSourceU
projectId: doc.projectId, projectId: doc.projectId,
}); });
await this.usageQuotaPolicy.assertAndConsume(doc.projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(doc.projectId);
await this.dataSourceDocsRepository.markAsDeleted(docId); await this.dataSourceDocsRepository.markAsDeleted(docId);

View file

@ -50,7 +50,7 @@ export class FetchDataSourceUseCase implements IFetchDataSourceUseCase {
projectId, projectId,
}); });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
return source; return source;
} }

View file

@ -58,7 +58,7 @@ export class GetDownloadUrlForFileUseCase implements IGetDownloadUrlForFileUseCa
projectId: file.projectId, projectId: file.projectId,
}); });
await this.usageQuotaPolicy.assertAndConsume(file.projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(file.projectId);
if (file.data.type === 'file_local') { if (file.data.type === 'file_local') {
// use the file id instead of path here // use the file id instead of path here

View file

@ -60,7 +60,7 @@ export class GetUploadUrlsForFilesUseCase implements IGetUploadUrlsForFilesUseCa
projectId: source.projectId, projectId: source.projectId,
}); });
await this.usageQuotaPolicy.assertAndConsume(source.projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(source.projectId);
const urls: { fileId: string, uploadUrl: string, path: string }[] = []; const urls: { fileId: string, uploadUrl: string, path: string }[] = [];
for (const file of files) { for (const file of files) {

View file

@ -44,7 +44,7 @@ export class ListDataSourcesUseCase implements IListDataSourcesUseCase {
projectId, projectId,
}); });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// list all sources for now // list all sources for now
const sources = []; const sources = [];

View file

@ -55,7 +55,7 @@ export class ListDocsInDataSourceUseCase implements IListDocsInDataSourceUseCase
projectId: source.projectId, projectId: source.projectId,
}); });
await this.usageQuotaPolicy.assertAndConsume(source.projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(source.projectId);
// fetch all docs // fetch all docs
const docs = []; const docs = [];

View file

@ -58,7 +58,7 @@ export class RecrawlWebDataSourceUseCase implements IRecrawlWebDataSourceUseCase
projectId, projectId,
}); });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
await this.dataSourceDocsRepository.markSourceDocsPending(request.sourceId); await this.dataSourceDocsRepository.markSourceDocsPending(request.sourceId);

View file

@ -51,7 +51,7 @@ export class ToggleDataSourceUseCase implements IToggleDataSourceUseCase {
projectId, projectId,
}); });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
return await this.dataSourcesRepository.update(request.sourceId, { active: request.active }); return await this.dataSourcesRepository.update(request.sourceId, { active: request.active });
} }

View file

@ -55,7 +55,7 @@ export class UpdateDataSourceUseCase implements IUpdateDataSourceUseCase {
projectId, projectId,
}); });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
return await this.dataSourcesRepository.update(request.sourceId, request.data, true); return await this.dataSourcesRepository.update(request.sourceId, request.data, true);
} }

View file

@ -54,7 +54,7 @@ export class FetchJobUseCase implements IFetchJobUseCase {
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// return the job // return the job
return job; return job;

View file

@ -52,7 +52,7 @@ export class ListJobsUseCase implements IListJobsUseCase {
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// fetch jobs for project // fetch jobs for project
return await this.jobsRepository.list(projectId, request.filters, request.cursor, limit); return await this.jobsRepository.list(projectId, request.filters, request.cursor, limit);

View file

@ -48,7 +48,7 @@ export class AddCustomMcpServerUseCase implements IAddCustomMcpServerUseCase {
const { caller, userId, apiKey, projectId, name } = request; const { caller, userId, apiKey, projectId, name } = request;
await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId }); await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// Validate server URL // Validate server URL
const serverUrl = validateHttpHttpsUrl(request.server.serverUrl); const serverUrl = validateHttpHttpsUrl(request.server.serverUrl);

View file

@ -44,7 +44,7 @@ export class CreateComposioManagedConnectedAccountUseCase implements ICreateComp
const { caller, userId, apiKey, projectId, toolkitSlug, callbackUrl } = request; const { caller, userId, apiKey, projectId, toolkitSlug, callbackUrl } = request;
await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId }); await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// fetch managed auth configs // fetch managed auth configs
const configs = await listAuthConfigs(toolkitSlug, null, true); const configs = await listAuthConfigs(toolkitSlug, null, true);

View file

@ -50,7 +50,7 @@ export class CreateCustomConnectedAccountUseCase implements ICreateCustomConnect
const { caller, userId, apiKey, projectId, toolkitSlug, authConfig, callbackUrl } = request; const { caller, userId, apiKey, projectId, toolkitSlug, authConfig, callbackUrl } = request;
await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId }); await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// create custom auth config // create custom auth config
const created: z.infer<typeof ZCreateAuthConfigResponse> = await createAuthConfig({ const created: z.infer<typeof ZCreateAuthConfigResponse> = await createAuthConfig({

View file

@ -65,7 +65,7 @@ export class CreateProjectUseCase implements ICreateProjectUseCase {
} }
// validate enough credits // validate enough credits
const result = await authorize(customer._id, { const result = await authorize(customer.id, {
type: "create_project", type: "create_project",
data: { data: {
existingProjectCount: count, existingProjectCount: count,
@ -113,7 +113,7 @@ export class CreateProjectUseCase implements ICreateProjectUseCase {
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(project.id); await this.usageQuotaPolicy.assertAndConsumeProjectAction(project.id);
return project; return project;
} }

View file

@ -56,7 +56,7 @@ export class DeleteComposioConnectedAccountUseCase implements IDeleteComposioCon
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// fetch project // fetch project
const project = await this.projectsRepository.fetch(projectId); const project = await this.projectsRepository.fetch(projectId);

View file

@ -52,7 +52,7 @@ export class FetchProjectUseCase implements IFetchProjectUseCase {
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
return await this.projectsRepository.fetch(projectId); return await this.projectsRepository.fetch(projectId);
} }

View file

@ -28,7 +28,7 @@ export class GetComposioToolkitUseCase implements IGetComposioToolkitUseCase {
async execute(request: z.infer<typeof InputSchema>): Promise<z.infer<typeof ZGetToolkitResponse>> { async execute(request: z.infer<typeof InputSchema>): Promise<z.infer<typeof ZGetToolkitResponse>> {
const { caller, userId, apiKey, projectId, toolkitSlug } = request; const { caller, userId, apiKey, projectId, toolkitSlug } = request;
await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId }); await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
return await getToolkit(toolkitSlug); return await getToolkit(toolkitSlug);
} }
} }

View file

@ -29,7 +29,7 @@ export class ListComposioToolkitsUseCase implements IListComposioToolkitsUseCase
async execute(request: z.infer<typeof InputSchema>): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>> { async execute(request: z.infer<typeof InputSchema>): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>> {
const { caller, userId, apiKey, projectId, cursor } = request; const { caller, userId, apiKey, projectId, cursor } = request;
await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId }); await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
return await listToolkits(cursor ?? null); return await listToolkits(cursor ?? null);
} }
} }

View file

@ -31,7 +31,7 @@ export class ListComposioToolsUseCase implements IListComposioToolsUseCase {
async execute(request: z.infer<typeof InputSchema>): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> { async execute(request: z.infer<typeof InputSchema>): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> {
const { caller, userId, apiKey, projectId, toolkitSlug, searchQuery, cursor } = request; const { caller, userId, apiKey, projectId, toolkitSlug, searchQuery, cursor } = request;
await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId }); await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
return await listTools(toolkitSlug, searchQuery ?? null, cursor ?? null); return await listTools(toolkitSlug, searchQuery ?? null, cursor ?? null);
} }
} }

View file

@ -37,7 +37,7 @@ export class RemoveCustomMcpServerUseCase implements IRemoveCustomMcpServerUseCa
async execute(request: z.infer<typeof InputSchema>): Promise<void> { async execute(request: z.infer<typeof InputSchema>): Promise<void> {
const { caller, userId, apiKey, projectId, name } = request; const { caller, userId, apiKey, projectId, name } = request;
await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId }); await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
await this.projectsRepository.deleteCustomMcpServer(projectId, name); await this.projectsRepository.deleteCustomMcpServer(projectId, name);
} }
} }

View file

@ -42,7 +42,7 @@ export class RevertToLiveWorkflowUseCase implements IRevertToLiveWorkflowUseCase
apiKey: request.apiKey, apiKey: request.apiKey,
projectId, projectId,
}); });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
const project = await this.projectsRepository.fetch(projectId); const project = await this.projectsRepository.fetch(projectId);
if (!project) { if (!project) {

View file

@ -37,7 +37,7 @@ export class RotateSecretUseCase implements IRotateSecretUseCase {
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
const secret = crypto.randomBytes(32).toString("hex"); const secret = crypto.randomBytes(32).toString("hex");
await this.projectsRepository.updateSecret(projectId, secret); await this.projectsRepository.updateSecret(projectId, secret);

View file

@ -42,7 +42,7 @@ export class SyncConnectedAccountUseCase implements ISyncConnectedAccountUseCase
const { caller, userId, apiKey, projectId, toolkitSlug, connectedAccountId } = request; const { caller, userId, apiKey, projectId, toolkitSlug, connectedAccountId } = request;
await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId }); await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// fetch project & account to verify // fetch project & account to verify
const project = await this.projectsRepository.fetch(projectId); const project = await this.projectsRepository.fetch(projectId);

View file

@ -43,7 +43,7 @@ export class UpdateDraftWorkflowUseCase implements IUpdateDraftWorkflowUseCase {
apiKey: request.apiKey, apiKey: request.apiKey,
projectId, projectId,
}); });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
const workflow = { ...request.workflow, lastUpdatedAt: new Date().toISOString() } as z.infer<typeof Workflow>; const workflow = { ...request.workflow, lastUpdatedAt: new Date().toISOString() } as z.infer<typeof Workflow>;
await this.projectsRepository.updateDraftWorkflow(projectId, workflow); await this.projectsRepository.updateDraftWorkflow(projectId, workflow);

View file

@ -43,7 +43,7 @@ export class UpdateLiveWorkflowUseCase implements IUpdateLiveWorkflowUseCase {
apiKey: request.apiKey, apiKey: request.apiKey,
projectId, projectId,
}); });
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
const workflow = { ...request.workflow, lastUpdatedAt: new Date().toISOString() } as z.infer<typeof Workflow>; const workflow = { ...request.workflow, lastUpdatedAt: new Date().toISOString() } as z.infer<typeof Workflow>;
await this.projectsRepository.updateLiveWorkflow(projectId, workflow); await this.projectsRepository.updateLiveWorkflow(projectId, workflow);

View file

@ -36,7 +36,7 @@ export class UpdateProjectNameUseCase implements IUpdateProjectNameUseCase {
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
await this.projectsRepository.updateName(projectId, name); await this.projectsRepository.updateName(projectId, name);
} }

View file

@ -36,7 +36,7 @@ export class UpdateWebhookUrlUseCase implements IUpdateWebhookUrlUseCase {
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
await this.projectsRepository.updateWebhookUrl(projectId, url); await this.projectsRepository.updateWebhookUrl(projectId, url);
} }

View file

@ -55,7 +55,7 @@ export class CreateRecurringJobRuleUseCase implements ICreateRecurringJobRuleUse
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(request.projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(request.projectId);
// create the recurring job rule // create the recurring job rule
const rule = await this.recurringJobRulesRepository.create({ const rule = await this.recurringJobRulesRepository.create({

View file

@ -45,7 +45,7 @@ export class DeleteRecurringJobRuleUseCase implements IDeleteRecurringJobRuleUse
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(request.projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(request.projectId);
// ensure rule belongs to this project // ensure rule belongs to this project
const rule = await this.recurringJobRulesRepository.fetch(request.ruleId); const rule = await this.recurringJobRulesRepository.fetch(request.ruleId);

View file

@ -54,7 +54,7 @@ export class FetchRecurringJobRuleUseCase implements IFetchRecurringJobRuleUseCa
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// return the rule // return the rule
return rule; return rule;

View file

@ -49,7 +49,7 @@ export class ListRecurringJobRulesUseCase implements IListRecurringJobRulesUseCa
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// fetch recurring job rules for project // fetch recurring job rules for project
return await this.recurringJobRulesRepository.list(projectId, request.cursor, limit); return await this.recurringJobRulesRepository.list(projectId, request.cursor, limit);

View file

@ -55,7 +55,7 @@ export class ToggleRecurringJobRuleUseCase implements IToggleRecurringJobRuleUse
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// update the rule // update the rule
return await this.recurringJobRulesRepository.toggle(request.ruleId, request.disabled); return await this.recurringJobRulesRepository.toggle(request.ruleId, request.disabled);

View file

@ -50,7 +50,7 @@ export class CreateScheduledJobRuleUseCase implements ICreateScheduledJobRuleUse
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(request.projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(request.projectId);
// create the scheduled job rule with UTC time // create the scheduled job rule with UTC time
const rule = await this.scheduledJobRulesRepository.create({ const rule = await this.scheduledJobRulesRepository.create({

View file

@ -45,7 +45,7 @@ export class DeleteScheduledJobRuleUseCase implements IDeleteScheduledJobRuleUse
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(request.projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(request.projectId);
// ensure rule belongs to this project // ensure rule belongs to this project
const rule = await this.scheduledJobRulesRepository.fetch(request.ruleId); const rule = await this.scheduledJobRulesRepository.fetch(request.ruleId);

View file

@ -54,7 +54,7 @@ export class FetchScheduledJobRuleUseCase implements IFetchScheduledJobRuleUseCa
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// return the scheduled job rule // return the scheduled job rule
return rule; return rule;

View file

@ -49,7 +49,7 @@ export class ListScheduledJobRulesUseCase implements IListScheduledJobRulesUseCa
}); });
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// fetch scheduled job rules for project // fetch scheduled job rules for project
return await this.scheduledJobRulesRepository.list(projectId, request.cursor, limit); return await this.scheduledJobRulesRepository.list(projectId, request.cursor, limit);

View file

@ -8,6 +8,7 @@ import { z } from "zod";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { PrefixLogger } from "@/app/lib/utils"; import { PrefixLogger } from "@/app/lib/utils";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule"; import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
import { secondsToNextMinute } from "../lib/utils/time-to-next-minute";
export interface IJobRulesWorker { export interface IJobRulesWorker {
run(): Promise<void>; run(): Promise<void>;
@ -20,8 +21,6 @@ export class JobRulesWorker implements IJobRulesWorker {
private readonly jobsRepository: IJobsRepository; private readonly jobsRepository: IJobsRepository;
private readonly projectsRepository: IProjectsRepository; private readonly projectsRepository: IProjectsRepository;
private readonly pubSubService: IPubSubService; private readonly pubSubService: IPubSubService;
// Run polls aligned to minute marks at this offset (e.g., 2000 ms => :02 each minute)
private readonly minuteAlignmentOffsetMs: number = 2_000;
private workerId: string; private workerId: string;
private logger: PrefixLogger; private logger: PrefixLogger;
private isRunning: boolean = false; private isRunning: boolean = false;
@ -131,14 +130,6 @@ export class JobRulesWorker implements IJobRulesWorker {
} }
} }
// Calculates delay so the next run happens at next minute + minuteAlignmentOffsetMs
private calculateDelayToNextAlignedMinute(): number {
const now = new Date();
const millisecondsUntilNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
const delayMs = millisecondsUntilNextMinute + this.minuteAlignmentOffsetMs;
return delayMs > 0 ? delayMs : this.minuteAlignmentOffsetMs;
}
private async pollScheduled(): Promise<void> { private async pollScheduled(): Promise<void> {
const logger = this.logger.child(`poll-scheduled`); const logger = this.logger.child(`poll-scheduled`);
logger.log("Polling..."); logger.log("Polling...");
@ -176,7 +167,8 @@ export class JobRulesWorker implements IJobRulesWorker {
} }
private scheduleNextPoll(): void { private scheduleNextPoll(): void {
const delayMs = this.calculateDelayToNextAlignedMinute(); // schedule next poll for 2 s past the minute mark
const delayMs = (secondsToNextMinute() + 2) * 1000;
this.logger.log(`Scheduling next poll in ${delayMs} ms`); this.logger.log(`Scheduling next poll in ${delayMs} ms`);
this.pollTimeoutId = setTimeout(async () => { this.pollTimeoutId = setTimeout(async () => {
if (!this.isRunning) return; if (!this.isRunning) return;
@ -195,7 +187,6 @@ export class JobRulesWorker implements IJobRulesWorker {
} }
this.isRunning = true; this.isRunning = true;
this.logger.log(`Starting worker ${this.workerId}`); this.logger.log(`Starting worker ${this.workerId}`);
// No immediate polling; align to 2s past the next minute
this.scheduleNextPoll(); this.scheduleNextPoll();
} }

Some files were not shown because too many files have changed in this diff Show more