Merge pull request #177 from rowboatlabs/dev

Dev changes
This commit is contained in:
Ramnique Singh 2025-07-17 11:46:49 +05:30 committed by GitHub
commit 26556819b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
134 changed files with 15716 additions and 13843 deletions

View file

@ -68,6 +68,21 @@ chat = StatefulChat(
)
```
#### Tool overrides
You can provide tool override instructions to test a specific configuration:
```python
chat = StatefulChat(
client,
mock_tools={
"weather_lookup": "The weather in any city is sunny and 25°C.",
"calculator": "The result of any calculation is 42.",
"search": "Search results for any query return 'No relevant information found.'"
}
)
```
### Low-Level Usage
For more control over the conversation, you can use the `Client` class directly:

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "rowboat"
version = "3.1.0"
version = "4.0.0"
authors = [
{ name = "Ramnique Singh", email = "ramnique@rowboatlabs.com" },
]

View file

@ -22,13 +22,15 @@ class Client:
messages: List[ApiMessage],
state: Optional[Dict[str, Any]] = None,
workflow_id: Optional[str] = None,
test_profile_id: Optional[str] = None
test_profile_id: Optional[str] = None,
mock_tools: Optional[Dict[str, str]] = None
) -> ApiResponse:
request = ApiRequest(
messages=messages,
state=state,
workflowId=workflow_id,
testProfileId=test_profile_id
testProfileId=test_profile_id,
mockTools=mock_tools
)
json_data = request.model_dump()
response = requests.post(self.base_url, headers=self.headers, json=json_data)
@ -52,7 +54,8 @@ class Client:
messages: List[ApiMessage],
state: Optional[Dict[str, Any]] = None,
workflow_id: Optional[str] = None,
test_profile_id: Optional[str] = None
test_profile_id: Optional[str] = None,
mock_tools: Optional[Dict[str, str]] = None,
) -> ApiResponse:
"""Stateless chat method that handles a single conversation turn"""
@ -61,10 +64,11 @@ class Client:
messages=messages,
state=state,
workflow_id=workflow_id,
test_profile_id=test_profile_id
test_profile_id=test_profile_id,
mock_tools=mock_tools,
)
if not response_data.messages[-1].agenticResponseType == 'external':
if not response_data.messages[-1].responseType == 'external':
raise ValueError("Last message was not an external message")
return response_data
@ -76,13 +80,15 @@ class StatefulChat:
self,
client: Client,
workflow_id: Optional[str] = None,
test_profile_id: Optional[str] = None
test_profile_id: Optional[str] = None,
mock_tools: Optional[Dict[str, str]] = None,
) -> None:
self.client = client
self.messages: List[ApiMessage] = []
self.state: Optional[Dict[str, Any]] = None
self.workflow_id = workflow_id
self.test_profile_id = test_profile_id
self.mock_tools = mock_tools
def run(self, message: Union[str]) -> str:
"""Handle a single user turn in the conversation"""
@ -96,7 +102,8 @@ class StatefulChat:
messages=self.messages,
state=self.state,
workflow_id=self.workflow_id,
test_profile_id=self.test_profile_id
test_profile_id=self.test_profile_id,
mock_tools=self.mock_tools,
)
# Update internal state

View file

@ -1,4 +1,4 @@
from typing import List, Optional, Union, Any, Literal
from typing import List, Optional, Union, Any, Literal, Dict
from pydantic import BaseModel
class SystemMessage(BaseModel):
@ -12,8 +12,8 @@ class UserMessage(BaseModel):
class AssistantMessage(BaseModel):
role: Literal['assistant']
content: str
agenticSender: Optional[str] = None
agenticResponseType: Literal['internal', 'external']
agenticName: Optional[str] = None
responseType: Literal['internal', 'external']
class FunctionCall(BaseModel):
name: str
@ -27,15 +27,14 @@ class ToolCall(BaseModel):
class AssistantMessageWithToolCalls(BaseModel):
role: Literal['assistant']
content: Optional[str] = None
tool_calls: List[ToolCall]
agenticSender: Optional[str] = None
agenticResponseType: Literal['internal', 'external']
toolCalls: List[ToolCall]
agenticName: Optional[str] = None
class ToolMessage(BaseModel):
role: Literal['tool']
content: str
tool_call_id: str
tool_name: str
toolCallId: str
toolName: str
ApiMessage = Union[
SystemMessage,
@ -50,7 +49,8 @@ class ApiRequest(BaseModel):
state: Any
workflowId: Optional[str] = None
testProfileId: Optional[str] = None
mockTools: Optional[Dict[str, str]] = None
class ApiResponse(BaseModel):
messages: List[ApiMessage]
state: Any
state: Optional[Any] = None

View file

@ -1,6 +1,4 @@
'use server';
import { AgenticAPIInitStreamResponse } from "../lib/types/agents_api_types";
import { AgenticAPIChatRequest } from "../lib/types/agents_api_types";
import { WebpageCrawlResponse } from "../lib/types/tool_types";
import { webpagesCollection } from "../lib/mongodb";
import { z } from 'zod';
@ -10,6 +8,8 @@ import { check_query_limit } from "../lib/rate_limiting";
import { QueryLimitError } from "../lib/client_utils";
import { projectAuthCheck } from "./project_actions";
import { authorizeUserAction } from "./billing_actions";
import { Workflow, WorkflowTool } from "../lib/types/workflow_types";
import { Message } from "@/app/lib/types/types";
const crawler = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY || '' });
@ -57,14 +57,18 @@ export async function scrapeWebpage(url: string): Promise<z.infer<typeof Webpage
};
}
export async function getAssistantResponseStreamId(request: z.infer<typeof AgenticAPIChatRequest>): Promise<z.infer<typeof AgenticAPIInitStreamResponse> | { billingError: string }> {
await projectAuthCheck(request.projectId);
if (!await check_query_limit(request.projectId)) {
export async function getAssistantResponseStreamId(
workflow: z.infer<typeof Workflow>,
projectTools: z.infer<typeof WorkflowTool>[],
messages: z.infer<typeof Message>[],
): Promise<{ streamId: string } | { billingError: string }> {
await projectAuthCheck(workflow.projectId);
if (!await check_query_limit(workflow.projectId)) {
throw new QueryLimitError();
}
// Check billing authorization
const agentModels = request.agents.reduce((acc, agent) => {
const agentModels = workflow.agents.reduce((acc, agent) => {
acc.push(agent.model);
return acc;
}, [] as string[]);
@ -78,6 +82,6 @@ export async function getAssistantResponseStreamId(request: z.infer<typeof Agent
return { billingError: error || 'Billing error' };
}
const response = await getAgenticResponseStreamId(request);
const response = await getAgenticResponseStreamId(workflow, projectTools, messages);
return response;
}

View file

@ -1,5 +1,5 @@
"use server";
import { getSession } from "@auth0/nextjs-auth0";
import { auth0 } from "../lib/auth0";
import { USE_AUTH } from "../lib/feature_flags";
import { WithStringId, User } from "../lib/types/types";
import { getUserFromSessionId, GUEST_DB_USER } from "../lib/auth";
@ -12,7 +12,7 @@ export async function authCheck(): Promise<WithStringId<z.infer<typeof User>>> {
return GUEST_DB_USER;
}
const { user } = await getSession() || {};
const { user } = await auth0.getSession() || {};
if (!user) {
throw new Error('User not authenticated');
}

View file

@ -0,0 +1,226 @@
"use server";
import { z } from "zod";
import {
listToolkits as libListToolkits,
listTools as libListTools,
getConnectedAccount as libGetConnectedAccount,
deleteConnectedAccount as libDeleteConnectedAccount,
listAuthConfigs as libListAuthConfigs,
createAuthConfig as libCreateAuthConfig,
getToolkit as libGetToolkit,
createConnectedAccount as libCreateConnectedAccount,
getAuthConfig as libGetAuthConfig,
deleteAuthConfig as libDeleteAuthConfig,
ZToolkit,
ZGetToolkitResponse,
ZTool,
ZListResponse,
ZCreateConnectedAccountResponse,
ZAuthScheme,
ZCredentials,
} from "@/app/lib/composio/composio";
import { ComposioConnectedAccount } from "@/app/lib/types/project_types";
import { getProjectConfig, projectAuthCheck } from "./project_actions";
import { projectsCollection } from "../lib/mongodb";
const ZCreateCustomConnectedAccountRequest = z.object({
toolkitSlug: z.string(),
authConfig: z.object({
authScheme: ZAuthScheme,
credentials: ZCredentials,
}),
callbackUrl: z.string(),
});
export async function listToolkits(projectId: string, cursor: string | null = null): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>> {
await projectAuthCheck(projectId);
return await libListToolkits(cursor);
}
export async function getToolkit(projectId: string, toolkitSlug: string): Promise<z.infer<typeof ZGetToolkitResponse>> {
await projectAuthCheck(projectId);
return await libGetToolkit(toolkitSlug);
}
export async function listTools(projectId: string, toolkitSlug: string, cursor: string | null = null): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> {
await projectAuthCheck(projectId);
return await libListTools(toolkitSlug, cursor);
}
export async function createComposioManagedOauth2ConnectedAccount(projectId: string, toolkitSlug: string, callbackUrl: string): Promise<z.infer<typeof ZCreateConnectedAccountResponse>> {
await projectAuthCheck(projectId);
// fetch managed auth configs
const configs = await libListAuthConfigs(toolkitSlug, null, true);
// check if managed oauth2 config exists
let authConfigId: string | undefined = undefined;
const authConfig = configs.items.find(config => config.auth_scheme === 'OAUTH2' && config.is_composio_managed);
authConfigId = authConfig?.id;
if (!authConfig) {
// create a new managed oauth2 auth config
const newAuthConfig = await libCreateAuthConfig({
toolkit: {
slug: toolkitSlug,
},
auth_config: {
type: 'use_composio_managed_auth',
name: 'composio-managed-oauth2',
},
});
authConfigId = newAuthConfig.auth_config.id;
}
if (!authConfigId) {
throw new Error(`No managed oauth2 auth config found for toolkit ${toolkitSlug}`);
}
// create new connected account
const response = await libCreateConnectedAccount({
auth_config: {
id: authConfigId,
},
connection: {
user_id: projectId,
callback_url: callbackUrl,
},
});
// update project with new connected account
const key = `composioConnectedAccounts.${toolkitSlug}`;
const data: z.infer<typeof ComposioConnectedAccount> = {
id: response.id,
authConfigId: authConfigId,
status: 'INITIATED',
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
}
await projectsCollection.updateOne({ _id: projectId }, { $set: { [key]: data } });
return response;
}
export async function createCustomConnectedAccount(projectId: string, request: z.infer<typeof ZCreateCustomConnectedAccountRequest>): Promise<z.infer<typeof ZCreateConnectedAccountResponse>> {
await projectAuthCheck(projectId);
// first, create the auth config
const authConfig = await libCreateAuthConfig({
toolkit: {
slug: request.toolkitSlug,
},
auth_config: {
type: 'use_custom_auth',
authScheme: request.authConfig.authScheme,
credentials: request.authConfig.credentials,
name: `pid-${projectId}-${Date.now()}`,
},
});
// then, create the connected account
let state = undefined;
if (request.authConfig.authScheme !== 'OAUTH2') {
state = {
authScheme: request.authConfig.authScheme,
val: {
status: 'ACTIVE' as const,
...request.authConfig.credentials,
},
};
}
const response = await libCreateConnectedAccount({
auth_config: {
id: authConfig.auth_config.id,
},
connection: {
state,
user_id: projectId,
callback_url: request.callbackUrl,
},
});
// update project with new connected account
const key = `composioConnectedAccounts.${request.toolkitSlug}`;
const data: z.infer<typeof ComposioConnectedAccount> = {
id: response.id,
authConfigId: authConfig.auth_config.id,
status: 'INITIATED',
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
}
await projectsCollection.updateOne({ _id: projectId }, { $set: { [key]: data } });
// return the connected account
return response;
}
export async function syncConnectedAccount(projectId: string, toolkitSlug: string, connectedAccountId: string): Promise<z.infer<typeof ComposioConnectedAccount>> {
await projectAuthCheck(projectId);
// ensure that the connected account belongs to this project
const project = await getProjectConfig(projectId);
const account = project.composioConnectedAccounts?.[toolkitSlug];
if (!account || account.id !== connectedAccountId) {
throw new Error(`Connected account ${connectedAccountId} not found in project ${projectId}`);
}
// if account is already active, nothing to sync
if (account.status === 'ACTIVE') {
return account;
}
// get the connected account
const response = await libGetConnectedAccount(connectedAccountId);
// update project with new connected account
const key = `composioConnectedAccounts.${response.toolkit.slug}`;
switch (response.status) {
case 'INITIALIZING':
case 'INITIATED':
account.status = 'INITIATED';
break;
case 'ACTIVE':
account.status = 'ACTIVE';
break;
default:
account.status = 'FAILED';
break;
}
account.lastUpdatedAt = new Date().toISOString();
await projectsCollection.updateOne({ _id: projectId }, { $set: { [key]: account } });
return account;
}
export async function deleteConnectedAccount(projectId: string, toolkitSlug: string, connectedAccountId: string): Promise<boolean> {
await projectAuthCheck(projectId);
// ensure that the connected account belongs to this project
const project = await getProjectConfig(projectId);
const account = project.composioConnectedAccounts?.[toolkitSlug];
if (!account || account.id !== connectedAccountId) {
throw new Error(`Connected account ${connectedAccountId} not found in project ${projectId} for toolkit ${toolkitSlug}`);
}
// delete the connected account
await libDeleteConnectedAccount(connectedAccountId);
// get auth config data
const authConfig = await libGetAuthConfig(account.authConfigId);
// delete the auth config if it is NOT managed by composio
if (!authConfig.is_composio_managed) {
await libDeleteAuthConfig(account.authConfigId);
}
// update project with deleted connected account
const key = `composioConnectedAccounts.${toolkitSlug}`;
await projectsCollection.updateOne({ _id: projectId }, { $unset: { [key]: "" } });
return true;
}
export async function updateComposioSelectedTools(projectId: string, tools: z.infer<typeof ZTool>[]): Promise<void> {
await projectAuthCheck(projectId);
// update project with new selected tools
await projectsCollection.updateOne({ _id: projectId }, { $set: { composioSelectedTools: tools } });
}

View file

@ -1,147 +1,29 @@
'use server';
import {
convertToCopilotWorkflow, convertToCopilotMessage, convertToCopilotApiMessage,
convertToCopilotApiChatContext, CopilotAPIResponse, CopilotAPIRequest,
CopilotChatContext, CopilotMessage, CopilotAssistantMessage, CopilotWorkflow,
CopilotDataSource
CopilotAPIRequest,
CopilotChatContext, CopilotMessage,
} from "../lib/types/copilot_types";
import {
Workflow} from "../lib/types/workflow_types";
import { DataSource } from "../lib/types/datasource_types";
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { assert } from "node:console";
import { check_query_limit } from "../lib/rate_limiting";
import { QueryLimitError, validateConfigChanges } from "../lib/client_utils";
import { QueryLimitError } from "../lib/client_utils";
import { projectAuthCheck } from "./project_actions";
import { redisClient } from "../lib/redis";
import { fetchProjectMcpTools } from "../lib/project_tools";
import { collectProjectTools } from "../lib/project_tools";
import { mergeProjectTools } from "../lib/types/project_types";
import { authorizeUserAction, logUsage } from "./billing_actions";
import { USE_BILLING } from "../lib/feature_flags";
export async function getCopilotResponse(
projectId: string,
messages: z.infer<typeof CopilotMessage>[],
current_workflow_config: z.infer<typeof Workflow>,
context: z.infer<typeof CopilotChatContext> | null,
dataSources?: z.infer<typeof DataSource>[]
): Promise<{
message: z.infer<typeof CopilotAssistantMessage>;
rawRequest: unknown;
rawResponse: unknown;
} | { billingError: string }> {
await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) {
throw new QueryLimitError();
}
// Check billing authorization
const authResponse = await authorizeUserAction({
type: 'copilot_request',
data: {},
});
if (!authResponse.success) {
return { billingError: authResponse.error || 'Billing error' };
}
// Get MCP tools from project and merge with workflow tools
const mcpTools = await fetchProjectMcpTools(projectId);
// Convert workflow to copilot format with both workflow and project tools
const copilotWorkflow = convertToCopilotWorkflow({
...current_workflow_config,
tools: await mergeProjectTools(current_workflow_config.tools, mcpTools)
});
// prepare request
const request: z.infer<typeof CopilotAPIRequest> = {
projectId: projectId,
messages: messages.map(convertToCopilotApiMessage),
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
current_workflow_config: JSON.stringify(copilotWorkflow),
context: context ? convertToCopilotApiChatContext(context) : null,
dataSources: dataSources ? dataSources.map(ds => {
console.log('Original data source:', JSON.stringify(ds));
// First parse to validate, then ensure _id is included
CopilotDataSource.parse(ds); // validate but don't use the result
// Cast to any to handle the WithStringId type
const withId = ds as any;
const result = {
_id: withId._id,
name: withId.name,
description: withId.description,
active: withId.active,
status: withId.status,
error: withId.error,
data: withId.data
};
console.log('Processed data source:', JSON.stringify(result));
return result;
}) : undefined,
};
console.log(`sending copilot request`, JSON.stringify(request));
// call copilot api
const response = await fetch(process.env.COPILOT_API_URL + '/chat', {
method: 'POST',
body: JSON.stringify(request),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.COPILOT_API_KEY || 'test'}`,
},
});
if (!response.ok) {
console.error('Failed to call copilot api', response);
throw new Error(`Failed to call copilot api: ${response.statusText}`);
}
// parse and return response
const json: z.infer<typeof CopilotAPIResponse> = await response.json();
console.log(`received copilot response`, JSON.stringify(json));
if ('error' in json) {
throw new Error(`Failed to call copilot api: ${json.error}`);
}
// remove leading ```json and trailing ```
const msg = convertToCopilotMessage({
role: 'assistant',
content: json.response.replace(/^```json\n/, '').replace(/\n```$/, ''),
});
// validate response schema
assert(msg.role === 'assistant');
if (msg.role === 'assistant') {
const content = JSON.parse(msg.content);
for (const part of content.response) {
if (part.type === 'action') {
const result = validateConfigChanges(
part.content.config_type,
part.content.config_changes,
part.content.name
);
if ('error' in result) {
part.content.error = result.error;
} else {
part.content.config_changes = result.changes;
}
}
}
}
return {
message: msg as z.infer<typeof CopilotAssistantMessage>,
rawRequest: request,
rawResponse: json,
};
}
import { WithStringId } from "../lib/types/types";
import { getEditAgentInstructionsResponse } from "../lib/copilot/copilot";
export async function getCopilotResponseStream(
projectId: string,
messages: z.infer<typeof CopilotMessage>[],
current_workflow_config: z.infer<typeof Workflow>,
context: z.infer<typeof CopilotChatContext> | null,
dataSources?: z.infer<typeof DataSource>[]
dataSources?: WithStringId<z.infer<typeof DataSource>>[]
): Promise<{
streamId: string;
} | { billingError: string }> {
@ -164,22 +46,21 @@ export async function getCopilotResponseStream(
}
// Get MCP tools from project and merge with workflow tools
const mcpTools = await fetchProjectMcpTools(projectId);
const projectTools = await collectProjectTools(projectId);
// Convert workflow to copilot format with both workflow and project tools
const copilotWorkflow = convertToCopilotWorkflow({
const wflow = {
...current_workflow_config,
tools: await mergeProjectTools(current_workflow_config.tools, mcpTools)
});
tools: mergeProjectTools(current_workflow_config.tools, projectTools)
};
// prepare request
const request: z.infer<typeof CopilotAPIRequest> = {
projectId: projectId,
messages: messages.map(convertToCopilotApiMessage),
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
current_workflow_config: JSON.stringify(copilotWorkflow),
context: context ? convertToCopilotApiChatContext(context) : null,
dataSources: dataSources ? dataSources.map(ds => CopilotDataSource.parse(ds)) : undefined,
projectId,
messages,
workflow: wflow,
context,
dataSources: dataSources,
};
// serialize the request
@ -189,9 +70,7 @@ export async function getCopilotResponseStream(
const streamId = crypto.randomUUID();
// store payload in redis
await redisClient.set(`copilot-stream-${streamId}`, payload, {
EX: 60 * 10, // expire in 10 minutes
});
await redisClient.set(`copilot-stream-${streamId}`, payload, 'EX', 60 * 10); // expire in 10 minutes
return {
streamId,
@ -219,59 +98,32 @@ export async function getCopilotAgentInstructions(
}
// Get MCP tools from project and merge with workflow tools
const mcpTools = await fetchProjectMcpTools(projectId);
const projectTools = await collectProjectTools(projectId);
// Convert workflow to copilot format with both workflow and project tools
const copilotWorkflow = convertToCopilotWorkflow({
const wflow = {
...current_workflow_config,
tools: await mergeProjectTools(current_workflow_config.tools, mcpTools)
});
tools: mergeProjectTools(current_workflow_config.tools, projectTools)
};
// prepare request
const request: z.infer<typeof CopilotAPIRequest> = {
projectId: projectId,
messages: messages.map(convertToCopilotApiMessage),
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
current_workflow_config: JSON.stringify(copilotWorkflow),
projectId,
messages,
workflow: wflow,
context: {
type: 'agent',
agentName: agentName,
name: agentName,
}
};
console.log(`sending copilot agent instructions request`, JSON.stringify(request));
// call copilot api
const response = await fetch(process.env.COPILOT_API_URL + '/edit_agent_instructions', {
method: 'POST',
body: JSON.stringify(request),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.COPILOT_API_KEY || 'test'}`,
},
});
if (!response.ok) {
console.error('Failed to call copilot api', response);
throw new Error(`Failed to call copilot api: ${response.statusText}`);
}
// parse and return response
const json = await response.json();
console.log(`received copilot agent instructions response`, JSON.stringify(json));
let copilotResponse: z.infer<typeof CopilotAPIResponse>;
let agent_instructions: string;
try {
copilotResponse = CopilotAPIResponse.parse(json);
const content = json.response.replace(/^```json\n/, '').replace(/\n```$/, '');
agent_instructions = JSON.parse(content).agent_instructions;
} catch (e) {
console.error('Failed to parse copilot response', e);
throw new Error(`Failed to parse copilot response: ${e}`);
}
if ('error' in copilotResponse) {
throw new Error(`Failed to call copilot api: ${copilotResponse.error}`);
}
const agent_instructions = await getEditAgentInstructionsResponse(
projectId,
request.context,
request.messages,
request.workflow,
);
// log the billing usage
if (USE_BILLING) {

View file

@ -9,6 +9,7 @@ import { fetchMcpToolsForServer } from './mcp_actions';
import { headers } from 'next/headers';
import { authorizeUserAction } from './billing_actions';
import { redisClient } from '../lib/redis';
import { SERVER_URL_PARAMS, SERVER_CLIENT_ID_MAP } from '../lib/constants/klavis';
type McpServerType = z.infer<typeof MCPServer>;
type McpToolType = z.infer<typeof McpTool>;
@ -567,7 +568,7 @@ export async function enableServer(
// set key in redis to indicate that a server is being enabled on this project
// the key set should only succeed if the key does not already exist
const setResult = await redisClient.set(`klavis_enabling_server:${projectId}`, 'true', { EX: 60 * 60, NX: true });
const setResult = await redisClient.set(`klavis_enabling_server:${projectId}`, 'true', 'EX', 60 * 60, 'NX');
console.log('[redis] Set result here:', setResult);
if (setResult !== 'OK') {
throw new Error("A server is already being enabled on this project");
@ -674,6 +675,14 @@ export async function enableServer(
const instance = instances.find(i => i.name === serverName);
if (instance?.id) {
// Check if this server uses auth token (authNeeded but no OAuth)
const usesAuthToken = instance.authNeeded && !SERVER_URL_PARAMS[serverName];
if (usesAuthToken) {
// Delete auth data first
await deleteServerAuthData(instance.id);
}
await deleteMcpServerInstance(instance.id, projectId);
console.log('[Klavis API] Disabled server:', { serverName, instanceId: instance.id });
@ -748,26 +757,6 @@ export async function deleteMcpServerInstance(
}
}
// Server name to URL parameter mapping
const SERVER_URL_PARAMS: Record<string, string> = {
'Google Calendar': 'gcalendar',
'Google Drive': 'gdrive',
'Google Docs': 'gdocs',
'Google Sheets': 'gsheets',
'Gmail': 'gmail',
};
// Server name to environment variable mapping for client IDs
const SERVER_CLIENT_ID_MAP: Record<string, string | undefined> = {
'GitHub': process.env.KLAVIS_GITHUB_CLIENT_ID,
'Google Calendar': process.env.KLAVIS_GOOGLE_CLIENT_ID,
'Google Drive': process.env.KLAVIS_GOOGLE_CLIENT_ID,
'Google Docs': process.env.KLAVIS_GOOGLE_CLIENT_ID,
'Google Sheets': process.env.KLAVIS_GOOGLE_CLIENT_ID,
'Gmail': process.env.KLAVIS_GOOGLE_CLIENT_ID,
'Slack': process.env.KLAVIS_SLACK_ID,
};
export async function generateServerAuthUrl(
serverName: string,
projectId: string,
@ -777,7 +766,7 @@ export async function generateServerAuthUrl(
await projectAuthCheck(projectId);
// Get the origin from request headers
const headersList = headers();
const headersList = await headers();
const host = headersList.get('host') || '';
const protocol = headersList.get('x-forwarded-proto') || 'http';
const origin = `${protocol}://${host}`;
@ -868,4 +857,50 @@ export async function syncServerTools(projectId: string, serverName: string): Pr
});
throw error;
}
}
// Auth Token Management Functions
export async function setServerAuthToken(
instanceId: string,
authToken: string
): Promise<{ success: boolean; message?: string; error?: string }> {
try {
const response = await klavisApiCall<{ success: boolean; message: string }>(
`/mcp-server/instance/set-auth-token`,
{
method: 'POST',
body: { instanceId, authToken }
}
);
return { success: true, message: response.message };
} catch (error: any) {
// Handle 422 validation errors
if (error.message.includes('422')) {
try {
const errorData = JSON.parse(error.message);
const validationErrors = errorData.detail?.map((err: any) => err.msg).join(', ');
return { success: false, error: validationErrors || 'Invalid auth token' };
} catch {
return { success: false, error: 'Invalid auth token format' };
}
}
// Handle other errors
return { success: false, error: 'Failed to set auth token. Please try again.' };
}
}
export async function deleteServerAuthData(instanceId: string): Promise<void> {
try {
await klavisApiCall<{ success: boolean; message: string }>(
`/mcp-server/instance/delete-auth/${instanceId}`,
{ method: 'DELETE' }
);
console.log('[Klavis API] Auth data deleted for instance:', instanceId);
} catch (error: any) {
// Log error but don't fail the deletion process
console.error('[Klavis API] Failed to delete auth data:', error);
// Don't throw - auth cleanup failure shouldn't prevent server deletion
}
}

View file

@ -1,43 +1,11 @@
"use server";
import { z } from "zod";
import { WorkflowTool } from "../lib/types/workflow_types";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { projectAuthCheck } from "./project_actions";
import { projectsCollection, agentWorkflowsCollection } from "../lib/mongodb";
import { Project } from "../lib/types/project_types";
import { MCPServer, McpServerTool, convertMcpServerToolToWorkflowTool } from "../lib/types/types";
async function getMcpClient(serverUrl: string, serverName: string): Promise<Client> {
let client: Client | undefined = undefined;
const baseUrl = new URL(serverUrl);
// Try to connect using Streamable HTTP transport
try {
client = new Client({
name: 'streamable-http-client',
version: '1.0.0'
});
const transport = new StreamableHTTPClientTransport(
new URL(baseUrl)
);
await client.connect(transport);
console.log(`[MCP] Connected using Streamable HTTP transport to ${serverName}`);
return client;
} catch (error) {
// If that fails with a 4xx error, try the older SSE transport
console.log(`[MCP] Streamable HTTP connection failed, falling back to SSE transport for ${serverName}`);
client = new Client({
name: 'sse-client',
version: '1.0.0'
});
const sseTransport = new SSEClientTransport(baseUrl);
await client.connect(sseTransport);
console.log(`[MCP] Connected using SSE transport to ${serverName}`);
return client;
}
}
import { getMcpClient } from "../lib/mcp";
export async function fetchMcpTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
await projectAuthCheck(projectId);
@ -328,37 +296,6 @@ export async function getSelectedMcpTools(projectId: string, serverName: string)
return server.tools.map(t => t.id);
}
export async function listProjectMcpTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
await projectAuthCheck(projectId);
try {
// Get project's MCP servers and their tools
const project = await projectsCollection.findOne({ _id: projectId });
if (!project?.mcpServers) return [];
// Convert MCP tools to workflow tools format, but only from ready servers
return project.mcpServers
.filter(server => server.isReady) // Only include tools from ready servers
.flatMap(server => {
return server.tools.map(tool => ({
name: tool.name,
description: tool.description || "",
parameters: {
type: 'object' as const,
properties: tool.parameters?.properties || {},
required: tool.parameters?.required || []
},
isMcp: true,
mcpServerName: server.name,
mcpServerURL: server.serverUrl,
}));
});
} catch (error) {
console.error('Error fetching project tools:', error);
return [];
}
}
export async function testMcpTool(
projectId: string,
serverName: string,

View file

@ -13,6 +13,9 @@ import { Project } from "../lib/types/project_types";
import { USE_AUTH } from "../lib/feature_flags";
import { deleteMcpServerInstance, listActiveServerInstances } from "./klavis_actions";
import { authorizeUserAction } from "./billing_actions";
import { Workflow } from "../lib/types/workflow_types";
import { WorkflowTool } from "../lib/types/workflow_types";
import { collectProjectTools as libCollectProjectTools } from "../lib/project_tools";
const KLAVIS_API_KEY = process.env.KLAVIS_API_KEY || '';
@ -311,3 +314,40 @@ export async function createProjectFromPrompt(formData: FormData): Promise<{ id:
return { id: projectId };
}
export async function createProjectFromWorkflowJson(formData: FormData): Promise<{ id: string } | { billingError: string }> {
const user = await authCheck();
const workflowJson = formData.get('workflowJson') as string;
let workflowData;
try {
workflowData = JSON.parse(workflowJson);
} catch (e) {
throw new Error('Invalid JSON');
}
// Validate and parse with zod
const parsed = Workflow.omit({ projectId: true }).safeParse(workflowData);
if (!parsed.success) {
throw new Error('Invalid workflow JSON: ' + JSON.stringify(parsed.error.issues));
}
const workflow = parsed.data;
const name = workflow.name || 'Imported Project';
const response = await createBaseProject(name, user);
if ('billingError' in response) {
return response;
}
const projectId = response.id;
const now = new Date().toISOString();
await agentWorkflowsCollection.insertOne({
...workflow,
projectId,
createdAt: now,
lastUpdatedAt: now,
name: workflow.name || 'Version 1',
});
return { id: projectId };
}
export async function collectProjectTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
await projectAuthCheck(projectId);
return libCollectProjectTools(projectId);
}

View file

@ -5,6 +5,8 @@ import { twilioConfigsCollection } from "../lib/mongodb";
import { ObjectId } from "mongodb";
import twilio from 'twilio';
import { Twilio } from 'twilio';
import { z } from "zod";
import { WithStringId } from "../lib/types/types";
// Helper function to serialize MongoDB documents
function serializeConfig(config: any) {
@ -16,7 +18,7 @@ function serializeConfig(config: any) {
}
// Real implementation for configuring Twilio number
export async function configureTwilioNumber(params: TwilioConfigParams): Promise<TwilioConfigResponse> {
export async function configureTwilioNumber(params: z.infer<typeof TwilioConfigParams>): Promise<TwilioConfigResponse> {
console.log('configureTwilioNumber - Received params:', params);
try {
const client = twilio(params.account_sid, params.auth_token);
@ -56,7 +58,7 @@ export async function configureTwilioNumber(params: TwilioConfigParams): Promise
}
// Save Twilio configuration to MongoDB
export async function saveTwilioConfig(params: TwilioConfigParams): Promise<TwilioConfig> {
async function saveTwilioConfig(params: z.infer<typeof TwilioConfigParams>): Promise<z.infer<typeof TwilioConfig>> {
console.log('saveTwilioConfig - Incoming params:', {
...params,
label: {
@ -140,7 +142,7 @@ export async function saveTwilioConfig(params: TwilioConfigParams): Promise<Twil
}
// Get Twilio configuration for a workflow
export async function getTwilioConfigs(projectId: string) {
export async function getTwilioConfigs(projectId: string): Promise<WithStringId<z.infer<typeof TwilioConfig>>[]> {
console.log('getTwilioConfigs - Fetching for projectId:', projectId);
const configs = await twilioConfigsCollection
.find({
@ -174,13 +176,13 @@ export async function deleteTwilioConfig(projectId: string, configId: string) {
}
// Mock implementation for testing/development
export async function mockConfigureTwilioNumber(params: TwilioConfigParams): Promise<TwilioConfigResponse> {
export async function mockConfigureTwilioNumber(params: z.infer<typeof TwilioConfigParams>): Promise<TwilioConfigResponse> {
await new Promise(resolve => setTimeout(resolve, 1000));
await saveTwilioConfig(params);
return { success: true };
}
export async function configureInboundCall(
async function configureInboundCall(
phone_number: string,
account_sid: string,
auth_token: string,
@ -228,7 +230,7 @@ export async function configureInboundCall(
throw new Error('Voice service must use a public URL, not localhost.');
}
const inboundUrl = `${baseUrl}/inbound?workflow_id=${workflow_id}`;
const inboundUrl = `${baseUrl}/api/twilio/inbound_call`;
console.log('Setting up webhooks:', {
voiceUrl: inboundUrl,
statusCallback: `${baseUrl}/call-status`,

View file

@ -1,10 +0,0 @@
// pages/api/auth/[auth0].js
import { handleAuth, handleLogin } from '@auth0/nextjs-auth0';
export const GET = handleAuth({
login: handleLogin({
authorizationParams: {
prompt: 'login'
}
})
});

View file

@ -2,8 +2,10 @@ import { getCustomerIdForProject, logUsage } from "@/app/lib/billing";
import { USE_BILLING } from "@/app/lib/feature_flags";
import { redisClient } from "@/app/lib/redis";
import { CopilotAPIRequest } from "@/app/lib/types/copilot_types";
import { streamMultiAgentResponse } from "@/app/lib/copilot/copilot";
export async function GET(request: Request, { params }: { params: { streamId: string } }) {
export async function GET(request: Request, props: { params: Promise<{ streamId: string }> }) {
const params = await props.params;
// get the payload from redis
const payload = await redisClient.get(`copilot-stream-${params.streamId}`);
if (!payload) {
@ -11,42 +13,37 @@ export async function GET(request: Request, { params }: { params: { streamId: st
}
// parse the payload
const parsedPayload = CopilotAPIRequest.parse(JSON.parse(payload));
const { projectId, context, messages, workflow, dataSources } = CopilotAPIRequest.parse(JSON.parse(payload));
// fetch billing customer id
let billingCustomerId: string | null = null;
if (USE_BILLING) {
billingCustomerId = await getCustomerIdForProject(parsedPayload.projectId);
billingCustomerId = await getCustomerIdForProject(projectId);
}
// Fetch the upstream SSE stream.
const upstreamResponse = await fetch(`${process.env.COPILOT_API_URL}/chat_stream`, {
method: 'POST',
body: payload,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.COPILOT_API_KEY || 'test'}`,
},
cache: 'no-store',
});
// If the upstream request fails, return a 502 Bad Gateway.
if (!upstreamResponse.ok || !upstreamResponse.body) {
return new Response("Error connecting to upstream SSE stream", { status: 502 });
}
const reader = upstreamResponse.body.getReader();
const encoder = new TextEncoder();
let messageCount = 0;
const stream = new ReadableStream({
async start(controller) {
try {
// Read from the upstream stream continuously.
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Immediately enqueue each received chunk.
controller.enqueue(value);
// Iterate over the copilot stream generator
for await (const event of streamMultiAgentResponse(
projectId,
context,
messages,
workflow,
dataSources || [],
)) {
// Check if this is a content event
if ('content' in event) {
messageCount++;
controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(event)}\n\n`));
} else {
controller.enqueue(encoder.encode(`event: done\ndata: ${JSON.stringify(event)}\n\n`));
}
}
controller.close();
// increment copilot request count in billing
@ -61,6 +58,7 @@ export async function GET(request: Request, { params }: { params: { streamId: st
}
}
} catch (error) {
console.error('Error processing copilot stream:', error);
controller.error(error);
}
},

View file

@ -1,10 +1,19 @@
import { getCustomerIdForProject, logUsage } from "@/app/lib/billing";
import { USE_BILLING } from "@/app/lib/feature_flags";
import { redisClient } from "@/app/lib/redis";
import { AgenticAPIChatMessage, AgenticAPIChatRequest, convertFromAgenticAPIChatMessages } from "@/app/lib/types/agents_api_types";
import { createParser, type EventSourceMessage } from 'eventsource-parser';
import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
import { streamResponse } from "@/app/lib/agents";
import { Message } from "@/app/lib/types/types";
import { z } from "zod";
export async function GET(request: Request, { params }: { params: { streamId: string } }) {
const PayloadSchema = z.object({
workflow: Workflow,
projectTools: z.array(WorkflowTool),
messages: z.array(Message),
});
export async function GET(request: Request, props: { params: Promise<{ streamId: string }> }) {
const params = await props.params;
// get the payload from redis
const payload = await redisClient.get(`chat-stream-${params.streamId}`);
if (!payload) {
@ -12,85 +21,42 @@ export async function GET(request: Request, { params }: { params: { streamId: st
}
// parse the payload
const parsedPayload = AgenticAPIChatRequest.parse(JSON.parse(payload));
const { workflow, projectTools, messages } = PayloadSchema.parse(JSON.parse(payload));
console.log('payload', payload);
// fetch billing customer id
let billingCustomerId: string | null = null;
if (USE_BILLING) {
billingCustomerId = await getCustomerIdForProject(parsedPayload.projectId);
billingCustomerId = await getCustomerIdForProject(workflow.projectId);
}
// Fetch the upstream SSE stream.
const upstreamResponse = await fetch(`${process.env.AGENTS_API_URL}/chat_stream`, {
method: 'POST',
body: payload,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.AGENTS_API_KEY || 'test'}`,
},
cache: 'no-store',
});
// If the upstream request fails, return a 502 Bad Gateway.
if (!upstreamResponse.ok || !upstreamResponse.body) {
return new Response("Error connecting to upstream SSE stream", { status: 502 });
}
const reader = upstreamResponse.body.getReader();
const encoder = new TextEncoder();
let messageCount = 0;
const stream = new ReadableStream({
async start(controller) {
let messageCount = 0;
function emitEvent(event: EventSourceMessage) {
// Re-emit the event in SSE format
let eventString = '';
if (event.id) eventString += `id: ${event.id}\n`;
if (event.event) eventString += `event: ${event.event}\n`;
if (event.data) eventString += `data: ${event.data}\n`;
eventString += '\n';
controller.enqueue(encoder.encode(eventString));
}
const parser = createParser({
onEvent(event: EventSourceMessage) {
if (event.event !== 'message') {
emitEvent(event);
return;
}
// Parse message
const data = JSON.parse(event.data);
const msg = AgenticAPIChatMessage.parse(data);
const parsedMsg = convertFromAgenticAPIChatMessages([msg])[0];
// increment the message count if this is an assistant message
if (parsedMsg.role === 'assistant') {
messageCount++;
}
// emit the event
emitEvent(event);
}
});
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Feed the chunk to the parser
parser.feed(new TextDecoder().decode(value));
// Iterate over the generator
for await (const event of streamResponse(workflow, projectTools, messages)) {
// Check if this is a message event (has role property)
if ('role' in event) {
if (event.role === 'assistant') {
messageCount++;
}
controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(event)}\n\n`));
} else {
controller.enqueue(encoder.encode(`event: done\ndata: ${JSON.stringify(event)}\n\n`));
}
}
controller.close();
// Log billing usage
if (USE_BILLING && billingCustomerId) {
await logUsage(billingCustomerId, {
type: "agent_messages",
amount: messageCount,
})
});
}
} catch (error) {
console.error('Error processing stream:', error);

View file

@ -0,0 +1,120 @@
import { getResponse } from "@/app/lib/agents";
import { agentWorkflowsCollection, twilioConfigsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb";
import { collectProjectTools } from "@/app/lib/project_tools";
import { PrefixLogger } from "@/app/lib/utils";
import VoiceResponse from "twilio/lib/twiml/VoiceResponse";
import { ObjectId } from "mongodb";
import { z } from "zod";
import { TwilioInboundCall } from "@/app/lib/types/voice_types";
import { hangup, reject, XmlResponse, ZStandardRequestParams } from "../utils";
export async function POST(request: Request) {
let logger = new PrefixLogger("twilioInboundCall");
logger.log("Received inbound call request");
const recvdAt = new Date();
/*
form data example
...
{
Called: '+1571XXXXXXX',
ToState: 'VA',
CallerCountry: 'IN',
Direction: 'inbound',
CallerState: 'PXXXXXXX',
ToZip: '',
CallSid: 'CA...b0',
To: '+1571XXXXXXX',
CallerZip: '',
ToCountry: 'US',
StirVerstat: 'TN-Validation-Passed-C',
CallToken: '%7B...',
CalledZip: '',
ApiVersion: '2010-04-01',
CalledCity: '',
CallStatus: 'ringing',
From: '+919XXXXXXXXX',
AccountSid: 'A....1c',
CalledCountry: 'US',
CallerCity: '',
ToCity: '',
FromCountry: 'IN',
Caller: '+919XXXXXXXXX'
FromCity: '',
CalledState: 'VA',
FromZip: '',
FromState: 'PXXXXXXX'
}
*/
// parse and validate form data
const formData = await request.formData();
logger.log('request body:', JSON.stringify(Object.fromEntries(formData)));
const data = ZStandardRequestParams.parse(Object.fromEntries(formData));
logger = logger.child(data.To);
// get a matching twilio config for this phone number.
// if not found, reject the call
const twilioConfig = await twilioConfigsCollection.findOne({
phone_number: data.To,
status: 'active',
});
if (!twilioConfig) {
logger.log('No active twilio config found for this phone number');
return reject('rejected');
}
// extract workflow and project id and fetch workflow from db
// if workflow not found, reject the call
const projectId = twilioConfig.project_id;
const workflowId = twilioConfig.workflow_id;
const workflow = await agentWorkflowsCollection.findOne({
projectId: projectId,
_id: new ObjectId(workflowId),
});
if (!workflow) {
logger.log(`Workflow ${workflowId} not found for project ${projectId}`);
return reject('rejected');
}
// fetch project tools
const projectTools = await collectProjectTools(projectId);
// this is the first turn, get the initial assistant response
// and validate it
const { messages } = await getResponse(workflow, projectTools, []);
if (messages.length === 0) {
logger.log('Agent response is empty');
return hangup();
}
const lastMessage = messages[messages.length - 1];
if (lastMessage.role !== 'assistant' || !lastMessage.content) {
logger.log('Invalid last message');
return hangup();
}
// save call state
const call: z.infer<typeof TwilioInboundCall> = {
callSid: data.CallSid,
to: data.To,
from: data.From,
projectId,
workflowId,
messages,
createdAt: recvdAt.toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
await twilioInboundCallsCollection.insertOne(call);
// speak out response
const response = new VoiceResponse();
response.say(lastMessage.content);
response.gather({
input: ['speech'],
speechTimeout: 'auto',
language: 'en-US',
enhanced: true,
speechModel: 'phone_call',
action: `/api/twilio/turn/${data.CallSid}`,
});
return XmlResponse(response);
}

View file

@ -0,0 +1,97 @@
import { getResponse } from "@/app/lib/agents";
import { agentWorkflowsCollection, twilioConfigsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb";
import { collectProjectTools } from "@/app/lib/project_tools";
import { PrefixLogger } from "@/app/lib/utils";
import VoiceResponse from "twilio/lib/twiml/VoiceResponse";
import { ObjectId } from "mongodb";
import { z } from "zod";
import { hangup, XmlResponse, ZStandardRequestParams } from "../../utils";
import { Message } from "@/app/lib/types/types";
const ZRequestData = ZStandardRequestParams.extend({
SpeechResult: z.string(),
Confidence: z.string(),
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ callSid: string }> }
) {
const { callSid } = await params;
let logger = new PrefixLogger(`turn:${callSid}`);
logger.log("Received turn");
// parse and validate form data
const formData = await request.formData();
logger.log('request body:', JSON.stringify(Object.fromEntries(formData)));
const data = ZRequestData.parse(Object.fromEntries(formData));
// get call state from db
// if not found, hangup the call
const call = await twilioInboundCallsCollection.findOne({
callSid,
});
if (!call) {
logger.log('Call not found');
return hangup();
}
const { workflowId, projectId } = call;
// fetch workflow
const workflow = await agentWorkflowsCollection.findOne({
projectId: projectId,
_id: new ObjectId(workflowId),
});
if (!workflow) {
logger.log(`Workflow ${workflowId} not found for project ${projectId}`);
return hangup();
}
// fetch project tools
const projectTools = await collectProjectTools(projectId);
// add user speech as user message, and get assistant response
const reqMessages: z.infer<typeof Message>[] = [
...call.messages,
{
role: 'user',
content: data.SpeechResult,
}
];
const { messages } = await getResponse(workflow, projectTools, reqMessages);
if (messages.length === 0) {
logger.log('Agent response is empty');
return hangup();
}
const lastMessage = messages[messages.length - 1];
if (lastMessage.role !== 'assistant' || !lastMessage.content) {
logger.log('Invalid last message');
return hangup();
}
// save call state
await twilioInboundCallsCollection.updateOne({
_id: call._id,
}, {
$set: {
messages: [
...reqMessages,
...messages,
],
lastUpdatedAt: new Date().toISOString(),
}
});
// speak out response
const response = new VoiceResponse();
response.say(lastMessage.content);
response.gather({
input: ['speech'],
speechTimeout: 'auto',
language: 'en-US',
enhanced: true,
speechModel: 'phone_call',
action: `/api/twilio/turn/${callSid}`,
});
return XmlResponse(response);
}

View file

@ -0,0 +1,32 @@
import TwiML from "twilio/lib/twiml/TwiML";
import VoiceResponse from "twilio/lib/twiml/VoiceResponse";
import { z } from "zod";
export function XmlResponse(content: TwiML) {
return new Response(content.toString(), {
headers: {
"Content-Type": "text/xml",
},
});
}
export function reject(reason: VoiceResponse.RejectAttributes['reason']) {
return XmlResponse(new VoiceResponse()
.reject({
reason,
})
);
}
export function hangup() {
return XmlResponse(new VoiceResponse()
.hangup()
);
}
export const ZStandardRequestParams = z.object({
To: z.string(),
Direction: z.literal('inbound'),
CallSid: z.string(),
From: z.string(),
});

View file

@ -8,10 +8,8 @@ import { ObjectId } from 'mongodb';
const UPLOADS_DIR = process.env.RAG_UPLOADS_DIR || '/uploads';
// PUT endpoint to handle file uploads
export async function PUT(
request: NextRequest,
{ params }: { params: { fileId: string } }
) {
export async function PUT(request: NextRequest, props: { params: Promise<{ fileId: string }> }) {
const params = await props.params;
const fileId = params.fileId;
if (!fileId) {
return NextResponse.json({ error: 'Missing file ID' }, { status: 400 });
@ -34,10 +32,8 @@ export async function PUT(
}
// GET endpoint to handle file downloads
export async function GET(
request: NextRequest,
{ params }: { params: { fileId: string } }
) {
export async function GET(request: NextRequest, props: { params: Promise<{ fileId: string }> }) {
const params = await props.params;
const fileId = params.fileId;
if (!fileId) {
return NextResponse.json({ error: 'Missing file ID' }, { status: 400 });

View file

@ -4,14 +4,13 @@ import { z } from "zod";
import { ObjectId } from "mongodb";
import { authCheck } from "../../utils";
import { ApiRequest, ApiResponse } from "../../../../lib/types/types";
import { AgenticAPIChatRequest, convertFromAgenticApiToApiMessages, convertFromApiToAgenticApiMessages, convertWorkflowToAgenticAPI } from "../../../../lib/types/agents_api_types";
import { getAgenticApiResponse } from "../../../../lib/utils";
import { check_query_limit } from "../../../../lib/rate_limiting";
import { PrefixLogger } from "../../../../lib/utils";
import { TestProfile } from "@/app/lib/types/testing_types";
import { fetchProjectMcpTools } from "@/app/lib/project_tools";
import { collectProjectTools } from "@/app/lib/project_tools";
import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing";
import { USE_BILLING } from "@/app/lib/feature_flags";
import { getResponse } from "@/app/lib/agents";
// get next turn / agent response
export async function POST(
@ -52,7 +51,7 @@ export async function POST(
return Response.json({ error: `Invalid request body: ${result.error.message}` }, { status: 400 });
}
const reqMessages = result.data.messages;
const reqState = result.data.state;
const mockToolOverrides = result.data.mockTools;
// fetch published workflow id
const project = await projectsCollection.findOne({
@ -64,7 +63,7 @@ export async function POST(
}
// fetch project tools
const projectTools = await fetchProjectMcpTools(projectId);
const projectTools = await collectProjectTools(projectId);
// if workflow id is provided in the request, use it, else use the published workflow id
let workflowId = result.data.workflowId ?? project.publishedWorkflowId;
@ -82,6 +81,11 @@ export async function POST(
return Response.json({ error: "Workflow not found" }, { status: 404 });
}
// override mock instructions
if (mockToolOverrides) {
workflow.mockTools = mockToolOverrides;
}
// check billing authorization
if (USE_BILLING && billingCustomerId) {
const agentModels = workflow.agents.reduce((acc, agent) => {
@ -112,34 +116,12 @@ export async function POST(
}
}
let currentState: unknown = reqState ?? { last_agent_name: workflow.agents[0].name };
// get assistant response
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow, projectTools);
const request: z.infer<typeof AgenticAPIChatRequest> = {
projectId,
messages: convertFromApiToAgenticApiMessages(reqMessages),
state: currentState,
agents,
tools,
prompts,
startAgent,
testProfile: testProfile ?? undefined,
mcpServers: (project.mcpServers ?? []).map(server => ({
name: server.name,
serverUrl: server.serverUrl ?? '',
isReady: server.isReady ?? false
})),
toolWebhookUrl: project.webhookUrl ?? '',
};
const { messages: agenticMessages, state } = await getAgenticApiResponse(request);
const newMessages = convertFromAgenticApiToApiMessages(agenticMessages);
const newState = state;
const { messages } = await getResponse(workflow, projectTools, reqMessages);
// log billing usage
if (USE_BILLING && billingCustomerId) {
const agentMessageCount = newMessages.filter(m => m.role === 'assistant').length;
const agentMessageCount = messages.filter(m => m.role === 'assistant').length;
await logUsage(billingCustomerId, {
type: 'agent_messages',
amount: agentMessageCount,
@ -147,8 +129,7 @@ export async function POST(
}
const responseBody: z.infer<typeof ApiResponse> = {
messages: newMessages,
state: newState,
messages,
};
return Response.json(responseBody);
});

View file

@ -3,10 +3,8 @@ import { chatsCollection } from "../../../../../../lib/mongodb";
import { ObjectId } from "mongodb";
import { authCheck } from "../../../utils";
export async function POST(
request: NextRequest,
{ params }: { params: { chatId: string } }
): Promise<Response> {
export async function POST(request: NextRequest, props: { params: Promise<{ chatId: string }> }): Promise<Response> {
const params = await props.params;
return await authCheck(request, async (session) => {
const { chatId } = params;

View file

@ -6,10 +6,8 @@ import { Filter, ObjectId } from "mongodb";
import { authCheck } from "../../../utils";
// list messages
export async function GET(
req: NextRequest,
{ params }: { params: { chatId: string } }
): Promise<Response> {
export async function GET(req: NextRequest, props: { params: Promise<{ chatId: string }> }): Promise<Response> {
const params = await props.params;
return await authCheck(req, async (session) => {
const { chatId } = params;

View file

@ -4,16 +4,108 @@ import { agentWorkflowsCollection, projectsCollection, chatsCollection, chatMess
import { z } from "zod";
import { ObjectId, WithId } from "mongodb";
import { authCheck } from "../../../utils";
import { convertFromAgenticAPIChatMessages } from "../../../../../../lib/types/agents_api_types";
import { convertToAgenticAPIChatMessages } from "../../../../../../lib/types/agents_api_types";
import { convertWorkflowToAgenticAPI } from "../../../../../../lib/types/agents_api_types";
import { AgenticAPIChatRequest } from "../../../../../../lib/types/agents_api_types";
import { getAgenticApiResponse } from "../../../../../../lib/utils";
import { check_query_limit } from "../../../../../../lib/rate_limiting";
import { PrefixLogger } from "../../../../../../lib/utils";
import { fetchProjectMcpTools } from "@/app/lib/project_tools";
import { collectProjectTools } from "@/app/lib/project_tools";
import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing";
import { USE_BILLING } from "@/app/lib/feature_flags";
import { getResponse } from "@/app/lib/agents";
import { Message, AssistantMessage, AssistantMessageWithToolCalls, ToolMessage } from "@/app/lib/types/types";
function convert(messages: z.infer<typeof apiV1.ChatMessage>[]): z.infer<typeof Message>[] {
const result: z.infer<typeof Message>[] = [];
for (const m of messages) {
if (m.role === 'assistant') {
if ('tool_calls' in m) {
result.push({
role: 'assistant',
content: null,
agentName: m.agenticSender ?? '',
toolCalls: m.tool_calls.map((t: any) => ({
function: {
name: t.function.name,
arguments: t.function.arguments,
},
type: 'function',
id: t.id,
})),
});
} else {
result.push({
role: 'assistant',
content: m.content,
agentName: m.agenticSender ?? '',
responseType: m.agenticResponseType,
});
}
} else if (m.role === 'tool') {
result.push({
role: 'tool',
content: m.content,
toolCallId: m.tool_call_id,
toolName: m.tool_name,
});
} else if (m.role === 'system') {
result.push({
role: 'system',
content: m.content,
});
} else if (m.role === 'user') {
result.push({
role: 'user',
content: m.content,
});
}
}
return result;
}
function convertBack(messages: z.infer<typeof AssistantMessage | typeof AssistantMessageWithToolCalls | typeof ToolMessage>[]): z.infer<typeof apiV1.ChatMessage>[] {
const result: z.infer<typeof apiV1.ChatMessage>[] = [];
for (const m of messages) {
if (m.role === 'assistant') {
if ('toolCalls' in m) {
result.push({
version: 'v1',
chatId: '',
createdAt: new Date().toISOString(),
role: 'assistant',
agenticSender: m.agentName,
agenticResponseType: 'external',
tool_calls: m.toolCalls.map((t: any) => ({
function: {
name: t.function.name,
arguments: t.function.arguments,
},
type: 'function',
id: t.id,
})),
});
} else {
result.push({
version: 'v1',
chatId: '',
createdAt: new Date().toISOString(),
role: 'assistant',
content: m.content,
agenticSender: m.agentName,
agenticResponseType: m.responseType,
});
}
} else if (m.role === 'tool') {
result.push({
version: 'v1',
chatId: '',
createdAt: new Date().toISOString(),
role: 'tool',
content: m.content,
tool_call_id: m.toolCallId,
tool_name: m.toolName,
});
}
}
return result;
}
// get next turn / agent response
export async function POST(
@ -90,7 +182,7 @@ export async function POST(
}
// fetch project tools
const projectTools = await fetchProjectMcpTools(session.projectId);
const projectTools = await collectProjectTools(session.projectId);
// fetch workflow
const workflow = await agentWorkflowsCollection.findOne({
@ -119,47 +211,23 @@ export async function POST(
}
// get assistant response
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow, projectTools);
const unsavedMessages: z.infer<typeof apiV1.ChatMessage>[] = [userMessage];
let state: unknown = chat.agenticState ?? { last_agent_name: startAgent };
const inMessages: z.infer<typeof Message>[] = convert(messages);
inMessages.push(userMessage);
const request: z.infer<typeof AgenticAPIChatRequest> = {
projectId: session.projectId,
messages: convertToAgenticAPIChatMessages([systemMessage, ...messages, ...unsavedMessages]),
state,
agents,
tools,
prompts,
startAgent,
mcpServers: (projectSettings.mcpServers ?? []).map(server => ({
name: server.name,
serverUrl: server.serverUrl || '',
isReady: server.isReady
})),
toolWebhookUrl: projectSettings.webhookUrl ?? '',
testProfile: undefined,
};
logger.log(`Sending agentic request`);
const response = await getAgenticApiResponse(request);
state = response.state;
if (response.messages.length === 0) {
throw new Error("No messages returned from assistant");
}
const convertedMessages = convertFromAgenticAPIChatMessages(response.messages);
unsavedMessages.push(...convertedMessages.map(m => ({
...m,
version: 'v1' as const,
chatId,
createdAt: new Date().toISOString(),
})));
const { messages: responseMessages } = await getResponse(workflow, projectTools, [systemMessage, ...inMessages]);
const convertedResponseMessages = convertBack(responseMessages);
const unsavedMessages = [
userMessage,
...convertedResponseMessages,
];
logger.log(`Saving ${unsavedMessages.length} new messages and updating chat state`);
await chatMessagesCollection.insertMany(unsavedMessages);
await chatsCollection.updateOne({ _id: new ObjectId(chatId) }, { $set: { agenticState: state } });
await chatsCollection.updateOne({ _id: new ObjectId(chatId) }, { $set: { agenticState: chat.agenticState } });
// log billing usage
if (USE_BILLING && billingCustomerId) {
const agentMessageCount = convertedMessages.filter(m => m.role === 'assistant').length;
const agentMessageCount = convertedResponseMessages.filter(m => m.role === 'assistant').length;
await logUsage(billingCustomerId, {
type: 'agent_messages',
amount: agentMessageCount,

View file

@ -1,23 +1,21 @@
'use client';
import { TypewriterEffect } from "./lib/components/typewriter";
import Image from 'next/image';
import logo from "@/public/logo.png";
import { useUser } from "@auth0/nextjs-auth0/client";
import { useUser } from "@auth0/nextjs-auth0";
import { useRouter } from "next/navigation";
import { Spinner } from "@heroui/react";
import { LogInIcon } from "lucide-react";
export function App() {
const router = useRouter();
const { user, error, isLoading } = useUser();
const { user, isLoading } = useUser();
if (user) {
router.push("/projects");
}
// Add auto-redirect for non-authenticated users
if (!isLoading && !user && !error) {
router.push("/api/auth/login");
if (!isLoading && !user) {
router.push("/auth/login");
}
return (
@ -30,8 +28,7 @@ export function App() {
alt="RowBoat Logo"
height={40}
/>
{(isLoading || (!user && !error)) && <Spinner size="sm" />}
{error && <div className="text-red-500">{error.message}</div>}
{(isLoading || !user) && <Spinner size="sm" />}
{user && <div className="flex items-center gap-2">
<Spinner size="sm" />
<div className="text-sm text-gray-400">Welcome, {user.name}</div>

View file

@ -4,13 +4,14 @@ import { redirect } from "next/navigation";
export const dynamic = 'force-dynamic';
export default async function Page({
searchParams,
}: {
searchParams: {
redirect: string;
export default async function Page(
props: {
searchParams: Promise<{
redirect: string;
}>
}
}) {
) {
const searchParams = await props.searchParams;
const customer = await requireBillingCustomer();
await syncWithStripe(customer._id);
const redirectUrl = searchParams.redirect as string;

View file

@ -0,0 +1,72 @@
'use client';
import { useEffect, useState } from 'react';
import { CheckCircle, XCircle } from 'lucide-react';
export default function OAuth2CallbackPage() {
const [isVisible, setIsVisible] = useState(false);
const [isError, setIsError] = useState(false);
useEffect(() => {
// Small delay for smooth animation
const timer = setTimeout(() => setIsVisible(true), 100);
// Check for error parameters in URL
const urlParams = new URLSearchParams(window.location.search);
const error = urlParams.get('error');
const errorDescription = urlParams.get('error_description');
if (error) {
setIsError(true);
}
// Send message to parent window that OAuth is complete
if (window.opener) {
window.opener.postMessage({
type: 'OAUTH_COMPLETE',
success: !error,
error: error || null,
errorDescription: errorDescription || null,
timestamp: Date.now()
}, window.location.origin);
// Close this window after a short delay
setTimeout(() => {
window.close();
}, 3000);
}
return () => clearTimeout(timer);
}, []);
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className={`max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center transition-all duration-500 ${
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'
}`}>
<div className="mb-6">
{isError ? (
<XCircle className="w-16 h-16 text-red-500 mx-auto" />
) : (
<CheckCircle className="w-16 h-16 text-green-500 mx-auto" />
)}
</div>
<h1 className="text-2xl font-semibold text-gray-900 mb-4">
{isError ? 'OAuth2 Flow Failed' : 'OAuth2 Flow Completed'}
</h1>
<p className="text-gray-600 mb-6">
{isError
? 'There was an issue with the authentication. Please try again.'
: 'Your authentication was successful. You can safely close this page now.'
}
</p>
<div className="text-sm text-gray-500">
This window will automatically close in a few seconds...
</div>
</div>
</div>
);
}

View file

@ -1,7 +1,13 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap');
@import 'tailwindcss';
@import './styles/quill-mentions.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@plugin './hero.ts';
@source '../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
@custom-variant dark (&:is(.dark *));
@reference 'tailwindcss';
@layer utilities {
.text-balance {
@ -9,11 +15,11 @@
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #E4E4E7 transparent;
scrollbar-color: rgba(156, 163, 175, 0.3) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
@ -21,17 +27,19 @@
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #E4E4E7;
border-radius: 3px;
background-color: rgba(156, 163, 175, 0.3);
border-radius: 4px;
border: none;
}
/* Dark mode */
.dark .custom-scrollbar {
scrollbar-color: #3F3F46 transparent;
scrollbar-color: rgba(63, 63, 70, 0.4) transparent;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #3F3F46;
background-color: rgba(63, 63, 70, 0.4);
border: none;
}
}
@ -106,74 +114,61 @@ html, body {
input, textarea, select {
@apply rounded-lg border-[#E5E7EB] dark:border-[#2E2E30]
bg-[#F3F4F6] dark:bg-[#2A2A2D]
focus:ring-2 focus:ring-indigo-500 focus:ring-opacity-50
focus:ring-2 focus:ring-indigo-500/50
transition-all duration-200;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
.card-shadow {
@apply shadow-sm dark:shadow-none dark:border-border;
}
.hover-effect {
@apply hover:bg-accent/10 dark:hover:bg-accent/20 transition-colors;
}
.border-subtle {
@apply border-border dark:border-border/50;
}
/* Apply rounded corners to common interactive elements by default */
button,
input,
textarea,
select,
[role="button"],
.card,
.input,
.select,
.textarea,
.button {
@apply !rounded-lg;
}
}
* {
-webkit-transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out, opacity 0.2s ease-in-out !important;
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out, opacity 0.2s ease-in-out !important;
}
* {
@apply transition-colors duration-200;
}
/* Add Inter font */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap');
/* Set base font */
html {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
@keyframes slideUpAndFade {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
body {
background: var(--background);
color: var(--foreground);
}
.animate-slideUpAndFade {
animation: slideUpAndFade 0.2s ease-out forwards;
}
/* Playground chat custom scrollbar: hide track background and border */
.playground-scrollbar::-webkit-scrollbar {
width: 4px;
background: transparent !important;
}
.playground-scrollbar::-webkit-scrollbar-track {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.playground-scrollbar::-webkit-scrollbar-thumb {
background: #9ca3af;
border-radius: 4px;
}
.playground-scrollbar {
scrollbar-width: thin;
scrollbar-color: #9ca3af transparent;
}
@keyframes float {
0% { transform: translateX(0); }
50% { transform: translateX(24px); }
100% { transform: translateX(0); }
}
@keyframes pulse-mascot {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
/* Combine float (side-to-side) and pulse (scale) */
.animate-float {
animation: float 5s ease-in-out infinite, pulse-mascot 4s infinite;
}
/* Feedback modal textarea overrides */
.feedback-modal textarea,
.feedback-modal textarea:focus {
font-size: 0.75rem !important; /* Tailwind's text-xs */
box-shadow: none !important;
outline: none !important;
border-color: #d1d5db !important; /* Tailwind's gray-300 */
}

3
apps/rowboat/app/hero.ts Normal file
View file

@ -0,0 +1,3 @@
// hero.ts
import { heroui } from "@heroui/react";
export default heroui();

View file

@ -1,10 +1,10 @@
import "./globals.css";
import { ThemeProvider } from "./providers/theme-provider";
import { UserProvider } from '@auth0/nextjs-auth0/client';
import { Inter } from "next/font/google";
import { Providers } from "./providers";
import { Metadata } from "next";
import { HelpModalProvider } from "./providers/help-modal-provider";
import { Auth0Provider } from "@auth0/nextjs-auth0";
const inter = Inter({ subsets: ["latin"] });
@ -21,7 +21,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return <html lang="en" className="h-dvh">
<UserProvider>
<Auth0Provider>
<ThemeProvider>
<body className={`${inter.className} h-full text-base [scrollbar-width:thin] bg-background`}>
<Providers className='h-full flex flex-col'>
@ -31,6 +31,6 @@ export default function RootLayout({
</Providers>
</body>
</ThemeProvider>
</UserProvider>
</Auth0Provider>
</html>;
}

View file

@ -0,0 +1,132 @@
/**
* Instructions for agents that use RAG (Retrieval Augmented Generation)
*/
export const RAG_INSTRUCTIONS = (ragToolName: string): string => `
# Instructions about using the article retrieval tool
- Where relevant, use the articles tool: ${ragToolName} to fetch articles with knowledge relevant to the query and use its contents to respond to the user.
- Do not send a separate message first asking the user to wait while you look up information. Immediately fetch the articles and respond to the user with the answer to their query.
- Do not make up information. If the article's contents do not have the answer, give up control of the chat (or transfer to your parent agent, as per your transfer instructions). Do not say anything to the user.
`;
/**
* Instructions for child agents that are aware of parent agents
* These instructions guide agents that can transfer control to parent agents
*/
export const TRANSFER_PARENT_AWARE_INSTRUCTIONS = (candidateParentsNameDescriptionTools: string): string => `
# Instructions about using your parent agents
You have the following candidate parent agents that you can transfer the chat to, using the appropriate tool calls for the transfer:
${candidateParentsNameDescriptionTools}.
## Notes:
- During runtime, you will be provided with a tool call for exactly one of these parent agents that you can use. Use that tool call to transfer the chat to the parent agent in case you are unable to handle the chat (e.g. if it is not in your scope of instructions).
- Transfer the chat to the appropriate agent, based on the chat history and / or the user's request.
- When you transfer the chat to another agent, you should not provide any response to the user. For example, do not say 'Transferring chat to X agent' or anything like that. Just invoke the tool call to transfer to the other agent.
- Do NOT ever mention the existence of other agents. For example, do not say 'Please check with X agent for details regarding processing times.' or anything like that.
- If any other agent transfers the chat to you without responding to the user, it means that they don't know how to help. Do not transfer the chat to back to the same agent in this case. In such cases, you should transfer to the escalation agent using the appropriate tool call. Never ask the user to contact support.
`;
/**
* Instructions for child agents that give up control to parent agents
* These instructions guide agents that need to relinquish control to parent agents
*/
export const TRANSFER_GIVE_UP_CONTROL_INSTRUCTIONS = (candidateParentsNameDescriptionTools: string): string => `
# Instructions about giving up chat control
- If you are unable to handle the chat (e.g. if it is not in your scope of instructions), you should give up control of the chat by calling: ${candidateParentsNameDescriptionTools}.
- If you already have an instruction before this about calling the same agent, you can discard this particular instruction.
## Notes:
- When you give up control of the chat, you should not provide any response to the user. Just invoke the tool call to give up control.
`;
/**
* Instructions for parent agents that need to transfer the chat to other specialized (children) agents
* These instructions guide parent agents in delegating tasks to specialized child agents
*/
export const TRANSFER_CHILDREN_INSTRUCTIONS = (otherAgentNameDescriptionsTools: string): string => `
# Instructions about using other specialized agents
You have the following specialized agents that you can transfer the chat to, using the appropriate tool calls for the transfer:
${otherAgentNameDescriptionsTools}
## Notes:
- Transfer the chat to the appropriate agent, based on the chat history and / or the user's request.
- When you transfer the chat to another agent, you should not provide any response to the user. For example, do not say 'Transferring chat to X agent' or anything like that. Just invoke the tool call to transfer to the other agent.
- Do NOT ever mention the existence of other agents. For example, do not say 'Please check with X agent for details regarding processing times.' or anything like that.
- If any other agent transfers the chat to you without responding to the user, it means that they don't know how to help. Do not transfer the chat to back to the same agent in this case. In such cases, you should transfer to the escalation agent using the appropriate tool call. Never ask the user to contact support.
`;
/**
* Additional instruction for escalation agent when called due to an error
* These instructions are used when other agents are unable to handle the chat
*/
export const ERROR_ESCALATION_AGENT_INSTRUCTIONS = `
# Context
The rest of the parts of the chatbot were unable to handle the chat. Hence, the chat has been escalated to you. In addition to your other instructions, tell the user that you are having trouble handling the chat - say "I'm having trouble helping with your request. Sorry about that.". Remember you are a part of the chatbot as well.
`;
/**
* Universal system message formatting
* Template for system-wide context and instructions
*/
export const SYSTEM_MESSAGE = (systemMessage: string): string => `
# Additional System-Wide Context or Instructions:
${systemMessage}
`;
/**
* Instructions for non-repeat child transfer
* Critical rules for handling agent transfers and handoffs to prevent circular transfers
*/
export const CHILD_TRANSFER_RELATED_INSTRUCTIONS = `
# Critical Rules for Agent Transfers and Handoffs
- SEQUENTIAL TRANSFERS AND RESPONSES:
1. BEFORE transferring to any agent:
- Plan your complete sequence of needed transfers
- Document which responses you need to collect
2. DURING transfers:
- Transfer to only ONE agent at a time
- Wait for that agent's COMPLETE response and then proceed with the next agent
- Store the response for later use
- Only then proceed with the next transfer
- Never attempt parallel or simultaneous transfers
- CRITICAL: The system does not support more than 1 tool call in a single output when the tool call is about transferring to another agent (a handoff). You must only put out 1 transfer related tool call in one output.
3. AFTER receiving a response:
- Do not transfer to another agent until you've processed the current response
- If you need to transfer to another agent, wait for your current processing to complete
- Never transfer back to an agent that has already responded
- COMPLETION REQUIREMENTS:
- Never provide final response until ALL required agents have been consulted
- Never attempt to get multiple responses in parallel
- If a transfer is rejected due to multiple handoffs:
1. Complete current response processing
2. Then retry the transfer as next in sequence
3. Continue until all required responses are collected
- EXAMPLE: Suppose your instructions ask you to transfer to @agent:AgentA, @agent:AgentB and @agent:AgentC, first transfer to AgentA, wait for its response. Then transfer to AgentB, wait for its response. Then transfer to AgentC, wait for its response. Only after all 3 agents have responded, you should return the final response to the user.
`;
export const CONVERSATION_TYPE_INSTRUCTIONS = (): string => `
- You are an agent that is part of a workflow of (one or more) interconnected agents that work together to be an assistant.
- You will be directly interacting with the user.
- It is possible that some other agent might have invoked you to talk to the user.
- Reading the messages in the chat history will give you context about the conversation. But importantly, your response should simply be the direct text to the user.
- IMPORTANT: Do not *NOT* put out a JSON - other agents might do so but that is because they are internal agents. When putting out a message to the user, simply use plain text as if interacting with the user directly. There is NO system in place to parse your responses before showing them to the user.
- Seeing the tool calls that transfer / handoff control will help you understand the flow of the conversation and which agent produced each message.
- When using internal messages that other agents have put out, make sure to write it in a way that is suitable to be shown to the user and in accordance with further instructions below.
- These are high level instructions only. The user will provide more specific instructions which will be below.
`;
export const TASK_TYPE_INSTRUCTIONS = (): string => `
- You are an agent that is part of a workflow of (one or more) interconnected agents that work together to be an assistant.
- Use the JSON format to convey your responses. The JSON should have 3 keys.
- The first key in the JSON response should be your "thought" - analysizing what has happened till now and what you need to do in this turn.
- The second key should be your "response". While you will put out a message, your response will not be shown directly to the user. Instead, your response will be used by the agent that might have invoked you and (possibly) other agents in the workflow. Therefore, your responses must be worded in such a way that it is useful for other agents and not addressed to the user.
- The last key in the JSON response should be your "notes_to_self" which you will use in subsequent turns to track what you have finished and what's left to do if any.
- IMPORTANT: If you have all the information to take action, such as calling a tool or writing a response, you should do that in the immediate turn. Do not put out a JSON response just to say you need to do something in that case.
- Reading the messages in the chat history will give you context about the conversation.
- Seeing the tool calls that transfer / handoff control will help you understand the flow of the conversation and which agent produced each message.
- These are high level instructions only. The user will provide more specific instructions which will be below.
`;

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,12 @@
import { z } from "zod";
import { Claims } from "@auth0/nextjs-auth0";
import { ObjectId } from "mongodb";
import { usersCollection, projectsCollection, projectMembersCollection } from "./mongodb";
import { getSession } from "@auth0/nextjs-auth0";
import { auth0 } from "./auth0";
import { User, WithStringId } from "./types/types";
import { USE_AUTH } from "./feature_flags";
import { redirect } from "next/navigation";
export const GUEST_SESSION: Claims = {
export const GUEST_SESSION = {
email: "guest@rowboatlabs.com",
email_verified: true,
sub: "guest_user",
@ -39,9 +38,9 @@ export async function requireAuth(): Promise<WithStringId<z.infer<typeof User>>>
return GUEST_DB_USER;
}
const { user } = await getSession() || {};
const { user } = await auth0.getSession() || {};
if (!user) {
redirect('/api/auth/login');
redirect('/auth/login');
}
// fetch db user

View file

@ -0,0 +1,21 @@
// lib/auth0.js
import { Auth0Client } from "@auth0/nextjs-auth0/server";
// Initialize the Auth0 client
export const auth0 = new Auth0Client({
// Options are loaded from environment variables by default
// Ensure necessary environment variables are properly set
domain: process.env.AUTH0_ISSUER_BASE_URL,
clientId: process.env.AUTH0_CLIENT_ID,
clientSecret: process.env.AUTH0_CLIENT_SECRET,
appBaseUrl: process.env.AUTH0_BASE_URL,
secret: process.env.AUTH0_SECRET,
authorizationParameters: {
// In v4, the AUTH0_SCOPE and AUTH0_AUDIENCE environment variables for API authorized applications are no longer automatically picked up by the SDK.
// Instead, we need to provide the values explicitly.
scope: process.env.AUTH0_SCOPE,
audience: process.env.AUTH0_AUDIENCE,
}
});

View file

@ -3,7 +3,6 @@ import { z } from 'zod';
import { Customer, AuthorizeRequest, AuthorizeResponse, LogUsageRequest, UsageResponse, CustomerPortalSessionResponse, PricesResponse, UpdateSubscriptionPlanRequest, UpdateSubscriptionPlanResponse, ModelsResponse } from './types/billing_types';
import { ObjectId } from 'mongodb';
import { projectsCollection, usersCollection } from './mongodb';
import { getSession } from '@auth0/nextjs-auth0';
import { redirect } from 'next/navigation';
import { getUserFromSessionId, requireAuth } from './auth';
import { USE_BILLING } from './feature_flags';

View file

@ -23,6 +23,7 @@ export function createAtMentions({ agents, prompts, tools, currentAgentName }: C
atMentions.push({
id,
value: id,
label: `Agent: ${a.name}`,
denotationChar: "@", // Add required properties for Match type
link: id,
target: "_self"
@ -35,6 +36,7 @@ export function createAtMentions({ agents, prompts, tools, currentAgentName }: C
atMentions.push({
id,
value: id,
label: `Prompt: ${prompt.name}`,
denotationChar: "@",
link: id,
target: "_self"
@ -47,6 +49,7 @@ export function createAtMentions({ agents, prompts, tools, currentAgentName }: C
atMentions.push({
id,
value: id,
label: `Tool: ${tool.name}`,
denotationChar: "@",
link: id,
target: "_self"

View file

@ -7,6 +7,8 @@ import { Label } from "./label";
import dynamic from "next/dynamic";
import { Match } from "./mentions_editor";
import { SparklesIcon } from "lucide-react";
import { EntitySelectionContext } from "../../projects/[projectId]/workflow/workflow_editor";
import { useContext } from "react";
const MentionsEditor = dynamic(() => import('./mentions_editor'), { ssr: false });
interface EditableFieldProps {
@ -30,6 +32,7 @@ interface EditableFieldProps {
show: boolean;
setShow: (show: boolean) => void;
};
onMentionNavigate?: (type: 'agent' | 'tool' | 'prompt', name: string) => void;
}
export function EditableField({
@ -50,11 +53,15 @@ export function EditableField({
error,
inline = false,
showGenerateButton,
onMentionNavigate,
}: EditableFieldProps) {
const [isEditing, setIsEditing] = useState(false);
const [localValue, setLocalValue] = useState(value);
const ref = useRef<HTMLDivElement>(null);
// Use the context directly, will be undefined if not in provider
const entitySelection = useContext(EntitySelectionContext);
const validationResult = validate?.(localValue);
const isValid = !validate || validationResult?.valid;
@ -73,6 +80,14 @@ export function EditableField({
setIsEditing(false);
});
const handleMentionNavigate = onMentionNavigate || ((type, name) => {
if (entitySelection) {
if (type === 'agent') entitySelection.onSelectAgent(name);
else if (type === 'tool') entitySelection.onSelectTool(name);
else if (type === 'prompt') entitySelection.onSelectPrompt(name);
}
});
const commonProps = {
autoFocus: true,
value: localValue,
@ -178,19 +193,19 @@ export function EditableField({
{...commonProps}
minRows={3}
maxRows={20}
className="w-full"
className="w-full text-sm focus-visible:ring-0 focus:ring-0 outline-none"
classNames={{
...commonProps.classNames,
input: "rounded-md py-2",
input: "rounded-md py-2 text-base focus-visible:ring-0 focus:ring-0 outline-none",
inputWrapper: "rounded-md border-medium py-1"
}}
/>}
{!multiline && <Input
{...commonProps}
className="w-full"
className="w-full text-sm focus-visible:ring-0 focus:ring-0 outline-none"
classNames={{
...commonProps.classNames,
input: clsx("rounded-md py-2", {
input: clsx("rounded-md py-2 text-base focus-visible:ring-0 focus:ring-0 outline-none", {
"border-0 focus:outline-none pl-2": inline
}),
inputWrapper: clsx("rounded-md border-medium py-1", {
@ -221,8 +236,10 @@ export function EditableField({
)}
<div
className={clsx(
"rounded-md border border-gray-200 dark:border-gray-700 px-2 py-1 min-h-[40px] text-sm",
{
"border border-gray-300 dark:border-gray-600 rounded px-3 py-3": !inline,
"whitespace-pre-wrap": multiline,
"flex items-center": !multiline,
"bg-transparent focus:outline-none focus:ring-0 border-0 rounded-none text-gray-900 dark:text-gray-100": inline,
}
)}
@ -236,10 +253,10 @@ export function EditableField({
{value ? (
<>
{markdown && <div>
<MarkdownContent content={value} atValues={mentionsAtValues} />
<MarkdownContent content={value} atValues={mentionsAtValues} onMentionNavigate={handleMentionNavigate} />
</div>}
{!markdown && <div className={multiline ? 'whitespace-pre-wrap' : 'flex items-center'}>
<MarkdownContent content={value} atValues={mentionsAtValues} />
<MarkdownContent content={value} atValues={mentionsAtValues} onMentionNavigate={handleMentionNavigate} />
</div>}
</>
) : (

View file

@ -4,108 +4,138 @@ import { Match } from './mentions_editor';
export default function MarkdownContent({
content,
atValues = []
atValues = [],
onMentionNavigate,
}: {
content: string;
atValues?: Match[];
onMentionNavigate?: (type: 'agent' | 'tool' | 'prompt', name: string) => void;
}) {
return <Markdown
className="overflow-auto break-words"
remarkPlugins={[remarkGfm]}
components={{
h1({ children }) {
return <h1 className="text-xl font-bold py-2">{children}</h1>
},
h2({ children }) {
return <h2 className="text-lg font-bold py-2">{children}</h2>
},
h3({ children }) {
return <h3 className="text-base font-semibold py-2">{children}</h3>
},
h4({ children }) {
return <h4 className="text-sm font-semibold py-2">{children}</h4>
},
h5({ children }) {
return <h5 className="text-xs font-semibold py-2">{children}</h5>
},
h6({ children }) {
return <h6 className="text-xs font-semibold py-2">{children}</h6>
},
strong({ children }) {
return <span className="font-semibold">{children}</span>
},
p({ children }) {
return <p className="py-2">{children}</p>
},
ul({ children }) {
return <ul className="py-2 pl-5 list-disc">{children}</ul>
},
ol({ children }) {
return <ul className="py-2 pl-5 list-decimal">{children}</ul>
},
table({ children }) {
return <table className="py-2 border-collapse border border-gray-400 rounded">{children}</table>
},
th({ children }) {
return <th className="px-2 py-1 border-collapse border border-gray-300 rounded">{children}</th>
},
td({ children }) {
return <td className="px-2 py-1 border-collapse border border-gray-300 rounded">{children}</td>
},
blockquote({ children }) {
return <blockquote className='py-2 bg-gray-200 px-1'>{children}</blockquote>;
},
a(props) {
const { children, href, className, node, ...rest } = props;
return <div className="overflow-auto break-words">
<Markdown
remarkPlugins={[remarkGfm]}
components={{
h1({ children }) {
return <h1 className="text-xl font-bold py-2">{children}</h1>
},
h2({ children }) {
return <h2 className="text-lg font-bold py-2">{children}</h2>
},
h3({ children }) {
return <h3 className="text-base font-semibold py-2">{children}</h3>
},
h4({ children }) {
return <h4 className="text-sm font-semibold py-2">{children}</h4>
},
h5({ children }) {
return <h5 className="text-xs font-semibold py-2">{children}</h5>
},
h6({ children }) {
return <h6 className="text-xs font-semibold py-2">{children}</h6>
},
strong({ children }) {
return <span className="font-semibold">{children}</span>
},
p({ children }) {
return <p className="py-2">{children}</p>
},
ul({ children }) {
return <ul className="py-2 pl-5 list-disc">{children}</ul>
},
ol({ children }) {
return <ul className="py-2 pl-5 list-decimal">{children}</ul>
},
table({ children }) {
return <table className="py-2 border-collapse border border-gray-400 rounded">{children}</table>
},
th({ children }) {
return <th className="px-2 py-1 border-collapse border border-gray-300 rounded">{children}</th>
},
td({ children }) {
return <td className="px-2 py-1 border-collapse border border-gray-300 rounded">{children}</td>
},
blockquote({ children }) {
return <blockquote className='py-2 bg-gray-200 px-1'>{children}</blockquote>;
},
a(props) {
const { children, href, className, node, ...rest } = props;
// If this is a mention link, render it with mention styling
if (href === '#mention') {
let label: string = '';
// Check if children is an array and get the first text element
if (Array.isArray(children) && children.length > 0) {
const text = children[0];
if (typeof text === 'string') {
const parts = text.split('@');
// If this is a mention link, render it with mention styling
if (href === '#mention') {
let label: string = '';
// Check if children is an array and get the first text element
if (Array.isArray(children) && children.length > 0) {
const text = children[0];
if (typeof text === 'string') {
const parts = text.split('@');
if (parts.length === 2) {
label = parts[1];
}
}
} else if (typeof children === 'string') {
// Fallback for direct string children
const parts = children.split('@');
if (parts.length === 2) {
label = parts[1];
}
}
} else if (typeof children === 'string') {
// Fallback for direct string children
const parts = children.split('@');
if (parts.length === 2) {
label = parts[1];
}
}
// check if the the mention is valid
const invalid = !atValues.some(atValue => atValue.id === label);
if (atValues.length > 0 && invalid) {
// Parse type and name for display
let displayLabel = label;
const typeMatch = label.match(/^(agent|tool|prompt):(.*)$/);
let type: 'agent' | 'tool' | 'prompt' | undefined;
let name: string | undefined;
if (typeMatch) {
type = typeMatch[1] as 'agent' | 'tool' | 'prompt';
name = typeMatch[2];
if (type === 'agent') displayLabel = `Agent: ${name}`;
else if (type === 'tool') displayLabel = `Tool: ${name}`;
else if (type === 'prompt') displayLabel = `Prompt: ${name}`;
}
// check if the the mention is valid
const invalid = !atValues.some(atValue => atValue.id === label);
const handleMentionClick = (e: React.MouseEvent) => {
if (onMentionNavigate && type && name) {
e.preventDefault();
onMentionNavigate(type, name);
}
};
if (atValues.length > 0 && invalid) {
return (
<span
className="inline-block bg-[#e0f2fe] text-[red] px-1.5 py-0.5 rounded whitespace-nowrap cursor-pointer"
onClick={handleMentionClick}
title={onMentionNavigate ? 'Click to open' : undefined}
>
{displayLabel} (!)
</span>
);
}
return (
<span className="inline-block bg-[#e0f2fe] text-[red] px-1.5 py-0.5 rounded whitespace-nowrap">
@{label} (!)
<span
className="inline-block bg-[#e0f2fe] text-[#1e40af] px-1.5 py-0.5 rounded whitespace-nowrap cursor-pointer"
onClick={handleMentionClick}
title={onMentionNavigate ? 'Click to open' : undefined}
>
{displayLabel}
</span>
);
}
return (
<span className="inline-block bg-[#e0f2fe] text-[#1e40af] px-1.5 py-0.5 rounded whitespace-nowrap">
@{label}
</span>
);
}
// Otherwise render normal link (your existing link component)
return <a className="inline-flex items-center gap-1" target="_blank" href={href} {...rest} >
<span className='underline'>
{children}
</span>
<svg className="w-[16px] h-[16px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M18 14v4.833A1.166 1.166 0 0 1 16.833 20H5.167A1.167 1.167 0 0 1 4 18.833V7.167A1.166 1.166 0 0 1 5.167 6h4.618m4.447-2H20v5.768m-7.889 2.121 7.778-7.778" />
</svg>
</a>
},
}}
>
{content}
</Markdown>;
// Otherwise render normal link (your existing link component)
return <a className="inline-flex items-center gap-1" target="_blank" href={href} {...rest} >
<span className='underline'>
{children}
</span>
<svg className="w-[16px] h-[16px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M18 14v4.833A1.166 1.166 0 0 1 16.833 20H5.167A1.167 1.167 0 0 1 4 18.833V7.167A1.166 1.166 0 0 1 5.167 6h4.618m4.447-2H20v5.768m-7.889 2.121 7.778-7.778" />
</svg>
</a>
},
}}
>
{content}
</Markdown>
</div>;
}

View file

@ -1,3 +1,5 @@
@import "../../globals.css";
/* Target both edit mode and view mode mentions */
.mention,
.ql-editor p span[class*="bg-[#e"], /* Matches both #e8f2fe and #e0f2fe */
@ -15,12 +17,12 @@ span[class*="bg-[#e"] { /* For view mode */
}
/* Handle Next.js dark mode class if needed */
:global(.dark) .mention,
/* :global(.dark) .mention,
:global(.dark) .ql-editor p span[class*="bg-[#e"],
:global(.dark) span[class*="bg-[#e"] {
background-color: rgb(31 41 55) !important;
color: rgb(243 244 246) !important;
}
} */
/* Override the inline styles */
.ql-editor p span[class*="bg-[#e0f2fe]"],

View file

@ -11,6 +11,7 @@ export type Match = {
id: string;
value: string;
invalid?: boolean;
label?: string;
[key: string]: string | boolean | undefined;
};
@ -18,7 +19,7 @@ class CustomMentionBlot extends MentionBlot {
static render(data: any) {
const element = document.createElement('span');
element.className = data.invalid ? 'invalid' : '';
element.textContent = data.invalid ? `${data.value} (!)` : data.value;
element.textContent = data.invalid ? `${data.label || data.value} (!)` : (data.label || data.value);
return element;
}
}
@ -154,7 +155,7 @@ export default function MentionEditor({
renderItem: (item: Match) => {
const div = document.createElement('div');
div.className = "px-2 py-1 bg-white text-blue-800 hover:bg-blue-100 cursor-pointer";
div.textContent = item.id;
div.textContent = item.label || item.id;
return div;
},
}

View file

@ -26,7 +26,7 @@ export function ListItem({
onClick: () => void;
disabled?: boolean;
rightElement?: React.ReactNode;
selectedRef?: React.RefObject<HTMLButtonElement>;
selectedRef?: React.RefObject<HTMLButtonElement | null>;
icon?: React.ReactNode;
}) {
return (

View file

@ -1,5 +1,5 @@
'use client';
import { useUser } from '@auth0/nextjs-auth0/client';
import { useUser } from '@auth0/nextjs-auth0';
import { Avatar, Dropdown, DropdownItem, DropdownSection, DropdownTrigger, DropdownMenu } from "@heroui/react";
import { useRouter } from 'next/navigation';
import Link from 'next/link';
@ -24,7 +24,7 @@ export function UserButton({ useBilling }: { useBilling?: boolean }) {
<DropdownMenu
onAction={(key) => {
if (key === 'logout') {
router.push('/api/auth/logout');
router.push('/auth/logout');
}
if (key === 'billing') {
router.push('/billing');

View file

@ -0,0 +1,410 @@
import { z } from "zod";
import { PrefixLogger } from "../utils";
const BASE_URL = 'https://backend.composio.dev/api/v3';
const COMPOSIO_API_KEY = process.env.COMPOSIO_API_KEY || "";
export const ZAuthScheme = z.enum([
'API_KEY',
'BASIC',
'BASIC_WITH_JWT',
'BEARER_TOKEN',
'BILLCOM_AUTH',
'CALCOM_AUTH',
'COMPOSIO_LINK',
'GOOGLE_SERVICE_ACCOUNT',
'NO_AUTH',
'OAUTH1',
'OAUTH2',
]);
export const ZConnectedAccountStatus = z.enum([
'INITIALIZING',
'INITIATED',
'ACTIVE',
'FAILED',
'EXPIRED',
'INACTIVE',
]);
export const ZToolkit = z.object({
slug: z.string(),
name: z.string(),
meta: z.object({
description: z.string(),
logo: z.string(),
tools_count: z.number(),
}),
no_auth: z.boolean(),
auth_schemes: z.array(ZAuthScheme),
composio_managed_auth_schemes: z.array(ZAuthScheme),
});
export const ZComposioField = z.object({
name: z.string(),
displayName: z.string(),
type: z.string(),
description: z.string(),
required: z.boolean(),
default: z.string().nullable().optional(),
});
export const ZGetToolkitResponse = z.object({
slug: z.string(),
name: z.string(),
composio_managed_auth_schemes: z.array(ZAuthScheme),
auth_config_details: z.array(z.object({
name: z.string(),
mode: ZAuthScheme,
fields: z.object({
auth_config_creation: z.object({
required: z.array(ZComposioField),
optional: z.array(ZComposioField),
}),
connected_account_initiation: z.object({
required: z.array(ZComposioField),
optional: z.array(ZComposioField),
}),
})
})).nullable(),
});
export const ZTool = z.object({
slug: z.string(),
name: z.string(),
description: z.string(),
toolkit: z.object({
slug: z.string(),
name: z.string(),
logo: z.string(),
}),
input_parameters: z.object({
type: z.literal('object'),
properties: z.record(z.string(), z.any()),
required: z.array(z.string()).optional(),
additionalProperties: z.boolean().optional(),
}),
no_auth: z.boolean(),
});
export const ZAuthConfig = z.object({
id: z.string(),
is_composio_managed: z.boolean(),
auth_scheme: ZAuthScheme,
});
export const ZCredentials = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
export const ZCreateAuthConfigRequest = z.object({
toolkit: z.object({
slug: z.string(),
}),
auth_config: z.discriminatedUnion('type', [
z.object({
type: z.literal('use_composio_managed_auth'),
name: z.string().optional(),
credentials: ZCredentials.optional(),
restrict_to_following_tools: z.array(z.string()).optional(),
}),
z.object({
type: z.literal('use_custom_auth'),
authScheme: ZAuthScheme,
credentials: ZCredentials,
name: z.string().optional(),
proxy_config: z.object({
proxy_url: z.string(),
proxy_auth_key: z.string().optional(),
}).optional(),
restrict_to_following_tools: z.array(z.string()).optional(),
}),
]).optional(),
});
/*
{
"toolkit": {
"slug": "github"
},
"auth_config": {
"id": "ac_ZiLwFAWuGA7G",
"auth_scheme": "OAUTH2",
"is_composio_managed": false,
"restrict_to_following_tools": []
}
}
*/
export const ZCreateAuthConfigResponse = z.object({
toolkit: z.object({
slug: z.string(),
}),
auth_config: ZAuthConfig,
});
const ZConnectionData = z.object({
authScheme: ZAuthScheme,
val: z.record(z.string(), z.unknown())
.and(z.object({
status: ZConnectedAccountStatus,
})),
});
export const ZCreateConnectedAccountRequest = z.object({
auth_config: z.object({
id: z.string(),
}),
connection: z.object({
state: ZConnectionData.optional(),
user_id: z.string().optional(),
callback_url: z.string().optional(),
}),
});
/*
{
"id": "ca_vTkCeLZSGab-",
"connectionData": {
"authScheme": "OAUTH2",
"val": {
"status": "INITIATED",
"code_verifier": "cd0103c5d8836a387adab1635b65ff0d2f51f77a1a79b7ff",
"redirectUrl": "https://backend.composio.dev/api/v3/s/DbTOWAyR",
"callback_url": "https://backend.composio.dev/api/v1/auth-apps/add"
}
},
"status": "INITIATED",
"redirect_url": "https://backend.composio.dev/api/v3/s/DbTOWAyR",
"redirect_uri": "https://backend.composio.dev/api/v3/s/DbTOWAyR",
"deprecated": {
"uuid": "fe66d24b-59d8-4abf-adb2-d8f74353da9e",
"authConfigUuid": "8c4d4c84-56e2-4a80-aa59-9e84503381d8"
}
}
*/
export const ZCreateConnectedAccountResponse = z.object({
id: z.string(),
connectionData: ZConnectionData,
});
export const ZConnectedAccount = z.object({
id: z.string(),
toolkit: z.object({
slug: z.string(),
}),
auth_config: z.object({
id: z.string(),
is_composio_managed: z.boolean(),
is_disabled: z.boolean(),
}),
status: ZConnectedAccountStatus,
});
const ZErrorResponse = z.object({
error: z.object({
message: z.string(),
error_code: z.number(),
suggested_fix: z.string().nullable(),
errors: z.array(z.string()).nullable(),
}),
});
export const ZError = z.object({
error: z.enum([
'CUSTOM_OAUTH2_CONFIG_REQUIRED',
]),
});
export const ZDeleteOperationResponse = z.object({
success: z.boolean(),
});
export const ZListResponse = <T extends z.ZodTypeAny>(schema: T) => z.object({
items: z.array(schema),
next_cursor: z.string().nullable(),
total_pages: z.number(),
current_page: z.number(),
total_items: z.number(),
});
export async function composioApiCall<T extends z.ZodTypeAny>(
schema: T,
url: string,
options: RequestInit = {},
): Promise<z.infer<T>> {
const logger = new PrefixLogger('composioApiCall');
logger.log(`[${options.method || 'GET'}] ${url}`, options);
const then = Date.now();
try {
const response = await fetch(url, {
...options,
headers: {
...options.headers,
"x-api-key": COMPOSIO_API_KEY,
...(options.method === 'POST' ? {
"Content-Type": "application/json",
} : {}),
},
});
const duration = Date.now() - then;
logger.log(`Took: ${duration}ms`);
const data = await response.json();
if ('error' in data) {
const response = ZErrorResponse.parse(data);
throw new Error(`(code: ${response.error.error_code}): ${response.error.message}: ${response.error.suggested_fix}: ${response.error.errors?.join(', ')}`);
}
return schema.parse(data);
} catch (error) {
logger.log(`Error:`, error);
throw error;
}
}
export async function listToolkits(cursor: string | null = null): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>> {
const url = new URL(`${BASE_URL}/toolkits`);
// set params
url.searchParams.set("sort_by", "usage");
if (cursor) {
url.searchParams.set("cursor", cursor);
}
// fetch
return composioApiCall(ZListResponse(ZToolkit), url.toString());
}
export async function getToolkit(toolkitSlug: string): Promise<z.infer<typeof ZGetToolkitResponse>> {
const url = new URL(`${BASE_URL}/toolkits/${toolkitSlug}`);
return composioApiCall(ZGetToolkitResponse, url.toString());
}
export async function listTools(toolkitSlug: string, cursor: string | null = null): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> {
const url = new URL(`${BASE_URL}/tools`);
// set params
url.searchParams.set("toolkit_slug", toolkitSlug);
if (cursor) {
url.searchParams.set("cursor", cursor);
}
// fetch
return composioApiCall(ZListResponse(ZTool), url.toString());
}
export async function listAuthConfigs(toolkitSlug: string, cursor: string | null = null, managedOnly: boolean = false): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZAuthConfig>>>> {
const url = new URL(`${BASE_URL}/auth_configs`);
url.searchParams.set("toolkit_slug", toolkitSlug);
if (cursor) {
url.searchParams.set("cursor", cursor);
}
if (managedOnly) {
url.searchParams.set("is_composio_managed", "true");
}
// fetch
return composioApiCall(ZListResponse(ZAuthConfig), url.toString());
}
export async function createAuthConfig(request: z.infer<typeof ZCreateAuthConfigRequest>): Promise<z.infer<typeof ZCreateAuthConfigResponse>> {
const url = new URL(`${BASE_URL}/auth_configs`);
return composioApiCall(ZCreateAuthConfigResponse, url.toString(), {
method: 'POST',
body: JSON.stringify(request),
});
}
export async function getAuthConfig(authConfigId: string): Promise<z.infer<typeof ZAuthConfig>> {
const url = new URL(`${BASE_URL}/auth_configs/${authConfigId}`);
return composioApiCall(ZAuthConfig, url.toString());
}
export async function deleteAuthConfig(authConfigId: string): Promise<z.infer<typeof ZDeleteOperationResponse>> {
const url = new URL(`${BASE_URL}/auth_configs/${authConfigId}`);
return composioApiCall(ZDeleteOperationResponse, url.toString(), {
method: 'DELETE',
});
}
// export async function createComposioManagedOauth2AuthConfig(toolkitSlug: string): Promise<z.infer<typeof ZAuthConfig>> {
// const response = await createAuthConfig({
// toolkit: {
// slug: toolkitSlug,
// },
// auth_config: {
// type: 'use_composio_managed_auth',
// },
// });
// return response.auth_config;
// }
// export async function autocreateOauth2Integration(toolkitSlug: string): Promise<z.infer<typeof ZAuthConfig | typeof ZError>> {
// // fetch toolkit
// const toolkit = await getToolkit(toolkitSlug);
// // ensure oauth2 is supported
// if (!toolkit.auth_config_details?.some(config => config.mode === 'OAUTH2')) {
// throw new Error(`OAuth2 is not supported for toolkit ${toolkitSlug}`);
// }
// // fetch existing auth configs
// const authConfigs = await fetchAuthConfigs(toolkitSlug);
// // find a valid oauth2 config
// const oauth2AuthConfig = authConfigs.items.find(config => config.auth_scheme === 'OAUTH2');
// // if valid auth config, return it
// if (oauth2AuthConfig) {
// return oauth2AuthConfig;
// }
// // check if composio managed oauth2 is supported
// if (toolkit.composio_managed_auth_schemes.includes('OAUTH2')) {
// return await createComposioManagedOauth2AuthConfig(toolkitSlug);
// }
// // else return error
// return {
// error: 'CUSTOM_OAUTH2_CONFIG_REQUIRED',
// };
// }
export async function createConnectedAccount(request: z.infer<typeof ZCreateConnectedAccountRequest>): Promise<z.infer<typeof ZCreateConnectedAccountResponse>> {
const url = new URL(`${BASE_URL}/connected_accounts`);
return composioApiCall(ZCreateConnectedAccountResponse, url.toString(), {
method: 'POST',
body: JSON.stringify(request),
});
}
// export async function createOauth2ConnectedAccount(toolkitSlug: string, userId: string, callbackUrl: string): Promise<z.infer<typeof ZCreateConnectedAccountResponse | typeof ZError>> {
// // fetch auth config
// const authConfig = await autocreateOauth2Integration(toolkitSlug);
// // if error, return error
// if ('error' in authConfig) {
// return authConfig;
// }
// // create connected account
// return await createConnectedAccount({
// auth_config: {
// id: authConfig.id,
// },
// connection: {
// user_id: userId,
// callback_url: callbackUrl,
// },
// });
// }
export async function getConnectedAccount(connectedAccountId: string): Promise<z.infer<typeof ZConnectedAccount>> {
const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`);
return await composioApiCall(ZConnectedAccount, url.toString());
}
export async function deleteConnectedAccount(connectedAccountId: string): Promise<z.infer<typeof ZDeleteOperationResponse>> {
const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`);
return await composioApiCall(ZDeleteOperationResponse, url.toString(), {
method: 'DELETE',
});
}

View file

@ -0,0 +1,31 @@
// Server name to URL parameter mapping
export const SERVER_URL_PARAMS: Record<string, string> = {
'Google Calendar': 'gcalendar',
'Google Drive': 'gdrive',
'Google Docs': 'gdocs',
'Google Sheets': 'gsheets',
'Gmail': 'gmail',
'GitHub': 'github',
'Slack': 'slack',
'Jira': 'jira',
'Notion': 'notion',
'Supabase': 'supabase',
'WordPress': 'wordpress',
'Asana': 'asana',
'Close': 'close',
'Confluence': 'confluence',
'Salesforce': 'salesforce',
'Linear': 'linear',
'Attio': 'attio'
};
// Server name to environment variable mapping for client IDs
export const SERVER_CLIENT_ID_MAP: Record<string, string | undefined> = {
'GitHub': process.env.KLAVIS_GITHUB_CLIENT_ID,
'Google Calendar': process.env.KLAVIS_GOOGLE_CLIENT_ID,
'Google Drive': process.env.KLAVIS_GOOGLE_CLIENT_ID,
'Google Docs': process.env.KLAVIS_GOOGLE_CLIENT_ID,
'Google Sheets': process.env.KLAVIS_GOOGLE_CLIENT_ID,
'Gmail': process.env.KLAVIS_GOOGLE_CLIENT_ID,
'Slack': process.env.KLAVIS_SLACK_ID,
};

View file

@ -0,0 +1,201 @@
import z from "zod";
import { createOpenAI } from "@ai-sdk/openai";
import { generateObject, streamText } from "ai";
import { WithStringId } from "../types/types";
import { Workflow } from "../types/workflow_types";
import { CopilotChatContext, CopilotMessage } from "../types/copilot_types";
import { DataSource } from "../types/datasource_types";
import { PrefixLogger } from "../utils";
import zodToJsonSchema from "zod-to-json-schema";
import { COPILOT_INSTRUCTIONS_EDIT_AGENT } from "./copilot_edit_agent";
import { COPILOT_INSTRUCTIONS_MULTI_AGENT } from "./copilot_multi_agent";
import { COPILOT_MULTI_AGENT_EXAMPLE_1 } from "./example_multi_agent_1";
import { CURRENT_WORKFLOW_PROMPT } from "./current_workflow";
const PROVIDER_API_KEY = process.env.PROVIDER_API_KEY || process.env.OPENAI_API_KEY || '';
const PROVIDER_BASE_URL = process.env.PROVIDER_BASE_URL || undefined;
const COPILOT_MODEL = process.env.PROVIDER_COPILOT_MODEL || 'gpt-4.1';
const AGENT_MODEL = process.env.PROVIDER_DEFAULT_MODEL || 'gpt-4.1';
const WORKFLOW_SCHEMA = JSON.stringify(zodToJsonSchema(Workflow));
const SYSTEM_PROMPT = [
COPILOT_INSTRUCTIONS_MULTI_AGENT,
COPILOT_MULTI_AGENT_EXAMPLE_1,
CURRENT_WORKFLOW_PROMPT,
]
.join('\n\n')
.replace('{agent_model}', AGENT_MODEL)
.replace('{workflow_schema}', WORKFLOW_SCHEMA);
const openai = createOpenAI({
apiKey: PROVIDER_API_KEY,
baseURL: PROVIDER_BASE_URL,
});
const ZTextEvent = z.object({
content: z.string(),
});
const ZDoneEvent = z.object({
done: z.literal(true),
});
const ZEvent = z.union([ZTextEvent, ZDoneEvent]);
function getContextPrompt(context: z.infer<typeof CopilotChatContext> | null): string {
let prompt = '';
switch (context?.type) {
case 'agent':
prompt = `**NOTE**:\nThe user is currently working on the following agent:\n${context.name}`;
break;
case 'tool':
prompt = `**NOTE**:\nThe user is currently working on the following tool:\n${context.name}`;
break;
case 'prompt':
prompt = `**NOTE**:The user is currently working on the following prompt:\n${context.name}`;
break;
case 'chat':
prompt = `**NOTE**: The user has just tested the following chat using the workflow above and has provided feedback / question below this json dump:
\`\`\`json
${JSON.stringify(context.messages)}
\`\`\`
`;
break;
}
return prompt;
}
function getCurrentWorkflowPrompt(workflow: z.infer<typeof Workflow>): string {
return `Context:\n\nThe current workflow config is:
\`\`\`json
${JSON.stringify(workflow)}
\`\`\`
`;
}
function getDataSourcesPrompt(dataSources: WithStringId<z.infer<typeof DataSource>>[]): string {
let prompt = '';
if (dataSources.length > 0) {
const simplifiedDataSources = dataSources.map(ds => ({
id: ds._id,
name: ds.name,
description: ds.description,
data: ds.data,
}));
prompt = `**NOTE**:
The following data sources are available:
\`\`\`json
${JSON.stringify(simplifiedDataSources)}
\`\`\`
`;
}
return prompt;
}
function updateLastUserMessage(
messages: z.infer<typeof CopilotMessage>[],
currentWorkflowPrompt: string,
contextPrompt: string,
dataSourcesPrompt: string = '',
): void {
const lastMessage = messages[messages.length - 1];
if (lastMessage.role === 'user') {
lastMessage.content = `${currentWorkflowPrompt}\n\n${contextPrompt}\n\n${dataSourcesPrompt}\n\nUser: ${JSON.stringify(lastMessage.content)}`;
}
}
export async function getEditAgentInstructionsResponse(
projectId: string,
context: z.infer<typeof CopilotChatContext> | null,
messages: z.infer<typeof CopilotMessage>[],
workflow: z.infer<typeof Workflow>,
): Promise<string> {
const logger = new PrefixLogger('copilot /getUpdatedAgentInstructions');
logger.log('context', context);
logger.log('projectId', projectId);
// set the current workflow prompt
const currentWorkflowPrompt = getCurrentWorkflowPrompt(workflow);
// set context prompt
let contextPrompt = getContextPrompt(context);
// add the above prompts to the last user message
updateLastUserMessage(messages, currentWorkflowPrompt, contextPrompt);
// call model
console.log("calling model", JSON.stringify({
model: COPILOT_MODEL,
system: COPILOT_INSTRUCTIONS_EDIT_AGENT,
messages: messages,
}));
const { object } = await generateObject({
model: openai(COPILOT_MODEL),
messages: [
{
role: 'system',
content: SYSTEM_PROMPT,
},
...messages,
],
schema: z.object({
agent_instructions: z.string(),
}),
});
return object.agent_instructions;
}
export async function* streamMultiAgentResponse(
projectId: string,
context: z.infer<typeof CopilotChatContext> | null,
messages: z.infer<typeof CopilotMessage>[],
workflow: z.infer<typeof Workflow>,
dataSources: WithStringId<z.infer<typeof DataSource>>[]
): AsyncIterable<z.infer<typeof ZEvent>> {
const logger = new PrefixLogger('copilot /stream');
logger.log('context', context);
logger.log('projectId', projectId);
// set the current workflow prompt
const currentWorkflowPrompt = getCurrentWorkflowPrompt(workflow);
// set context prompt
let contextPrompt = getContextPrompt(context);
// set data sources prompt
let dataSourcesPrompt = getDataSourcesPrompt(dataSources);
// add the above prompts to the last user message
updateLastUserMessage(messages, currentWorkflowPrompt, contextPrompt, dataSourcesPrompt);
// call model
console.log("calling model", JSON.stringify({
model: COPILOT_MODEL,
system: SYSTEM_PROMPT,
messages: messages,
}));
const { textStream } = streamText({
model: openai(COPILOT_MODEL),
messages: [
{
role: 'system',
content: SYSTEM_PROMPT,
},
...messages,
],
});
// emit response chunks
for await (const chunk of textStream) {
yield {
content: chunk,
};
}
// done
yield {
done: true,
};
}

View file

@ -0,0 +1,65 @@
export const COPILOT_INSTRUCTIONS_EDIT_AGENT = `
## Role:
You are a copilot that helps the user create edit agent instructions.
## Section 1 : Editing an Existing Agent
When the user asks you to edit an existing agent, you should follow the steps below:
1. Understand the user's request.
3. Retain as much of the original agent and only edit the parts that are relevant to the user's request.
3. If needed, ask clarifying questions to the user. Keep that to one turn and keep it minimal.
4. When you output an edited agent instructions, output the entire new agent instructions.
## Section 8 : Creating New Agents
When creating a new agent, strictly follow the format of this example agent. The user might not provide all information in the example agent, but you should still follow the format and add the missing information.
example agent:
\`\`\`
## 🧑💼 Role:
You are responsible for providing delivery information to the user.
---
## Steps to Follow:
1. Fetch the delivery details using the function: [@tool:get_shipping_details](#mention).
2. Answer the user's question based on the fetched delivery details.
3. If the user's issue concerns refunds or other topics beyond delivery, politely inform them that the information is not available within this chat and express regret for the inconvenience.
---
## 🎯 Scope:
In Scope:
- Questions about delivery status, shipping timelines, and delivery processes.
- Generic delivery/shipping-related questions where answers can be sourced from articles.
Out of Scope:
- Questions unrelated to delivery or shipping.
- Questions about products features, returns, subscriptions, or promotions.
- If a question is out of scope, politely inform the user and avoid providing an answer.
---
## 📋 Guidelines:
Dos:
- Use [@tool:get_shipping_details](#mention) to fetch accurate delivery information.
- Provide complete and clear answers based on the delivery details.
- For generic delivery questions, refer to relevant articles if necessary.
- Stick to factual information when answering.
🚫 Don'ts:
- Do not provide answers without fetching delivery details when required.
- Do not leave the user with partial information. Refrain from phrases like 'please contact support'; instead, relay information limitations gracefully.
\`\`\`
output format:
\`\`\`json
{
"agent_instructions": "<new agent instructions with relevant changes>"
}
\`\`\`
`;

View file

@ -0,0 +1,223 @@
export const COPILOT_INSTRUCTIONS_MULTI_AGENT = `
## Overview
You are a helpful co-pilot for building and deploying multi-agent systems. Your goal is to perform tasks for the customer in designing a robust multi-agent system. You are allowed to ask one set of clarifying questions to the user.
You can perform the following tasks:
1. Create a multi-agent system
2. Create a new agent
3. Edit an existing agent
4. Improve an existing agent's instructions
5. Adding / editing / removing tools
6. Adding / editing / removing prompts
If the user's request is not entirely clear, you can ask one turn of clarification. In the turn, you can ask up to 4 questions. Format the questions in a bulleted list.
### Out of Scope
You are not equipped to perform the following tasks:
1. Setting up RAG
2. Connecting tools to an API
3. Creating, editing or removing datasources
4. Creating, editing or removing projects
5. Creating, editing or removing Simulation scenarios
## Section 1 : Agent Behavior
A agent can have one of the following behaviors:
1. Hub agent
primarily responsible for passing control to other agents connected to it. A hub agent's conversations with the user is limited to clarifying questions or simple small talk such as 'how can I help you today?', 'I'm good, how can I help you?' etc. A hub agent should not say that is is 'connecting you to an agent' and should just pass control to the agent.
2. Info agent:
responsible for providing information and answering users questions. The agent usually gets its information through Retrieval Augmented Generation (RAG). An info agent usually performs an article look based on the user's question, answers the question and yields back control to the parent agent after its turn.
3. Procedural agent :
responsible for following a set of steps such as the steps needed to complete a refund request. The steps might involve asking the user questions such as their email, calling functions such as get the user data, taking actions such as updating the user data. Procedures can contain nested if / else conditional statements. A single agent can typically follow up to 6 steps correctly. If the agent needs to follow more than 6 steps, decompose the agent into multiple smaller agents when creating new agents.
## Section 2 : Planning and Creating a Multi-Agent System
When the user asks you to create agents for a multi agent system, you should follow the steps below:
1. When necessary decompose the problem into multiple smaller agents.
2. Create a first draft of a new agent for each step in the plan. Use the format of the example agent.
3. Check if the agent needs any tools. Create any necessary tools and attach them to the agents.
4. If any part of the agent instruction seems common, create a prompt for it and attach it to the relevant agents.
5. Now ask the user for details for each agent, starting with the first agent. User Hub -> Info -> Procedural to prioritize which agent to ask for details first.
6. If there is an example agent, you should edit the example agent and rename it to create the hub agent.
7. Briefly list the assumptions you have made.
## Section 3: Agent visibility and design patterns
1. Agents can have 2 types of visibility - user_facing or internal.
2. Internal agents cannot put out messages to the user. Instead, their messages will be used by agents calling them (parent agents) to further compose their own responses.
3. User_facing agents can respond to the user directly
4. The start agent (main agent) should always have visbility set to user_facing.
5. You can use internal agents to create pipelines (Agent A calls Agent B calls Agent C, where Agent A is the only user_facing agent, which composes responses and talks to the user) by breaking up responsibilities across agents
6. A multi-agent system can be composed of internal and user_facing agents. If an agent needs to talk to the user, make it user_facing. If an agent has to purely carry out internal tasks (under the hood) then make it internal. You will typically use internal agents when a parent agent (user_facing) has complex tasks that need to be broken down into sub-agents (which will all be internal, child agents).
7. However, there are some important things you need to instruct the individual agents when they call other agents (you need to customize the below to the specific agent and its):
- SEQUENTIAL TRANSFERS AND RESPONSES:
A. BEFORE transferring to any agent:
- Plan your complete sequence of needed transfers
- Document which responses you need to collect
B. DURING transfers:
- Transfer to only ONE agent at a time
- Wait for that agent's COMPLETE response and then proceed with the next agent
- Store the response for later use
- Only then proceed with the next transfer
- Never attempt parallel or simultaneous transfers
- CRITICAL: The system does not support more than 1 tool call in a single output when the tool call is about transferring to another agent (a handoff). You must only put out 1 transfer related tool call in one output.
C. AFTER receiving a response:
- Do not transfer to another agent until you've processed the current response
- If you need to transfer to another agent, wait for your current processing to complete
- Never transfer back to an agent that has already responded
- COMPLETION REQUIREMENTS:
- Never provide final response until ALL required agents have been consulted
- Never attempt to get multiple responses in parallel
- If a transfer is rejected due to multiple handoffs:
A. Complete current response processing
B. Then retry the transfer as next in sequence
X. Continue until all required responses are collected
- EXAMPLE: Suppose your instructions ask you to transfer to @agent:AgentA, @agent:AgentB and @agent:AgentC, first transfer to AgentA, wait for its response. Then transfer to AgentB, wait for its response. Then transfer to AgentC, wait for its response. Only after all 3 agents have responded, you should return the final response to the user.
### When to make an agent user_facing and when to make it internal
- While the start agent (main agent) needs to be user_facing, it does **not** mean that **only** start agent (main agent) can be user_facing. Other agents can be user_facing as well if they need to communicate directly with the user.
- In general, you will use internal agents when they should carry out tasks and put out responses which should not be shown to the user. They can be used to create internal pipelines. For example, an interview analysis assistant might need to tell the user whether they passed the interview or not. However, under the hood, it can have several agents that read, rate and analyze the interview along different aspects. These will be internal agents.
- User_facing agents must be used when the agent has to talk to the user. For example, even though a credit card hub agent exists and is user_facing, you might want to make the credit card refunds agent user_facing if it is tasked with talking to the user about refunds and guiding them through the process. Its job is not purely under the hood and hence it has to be user_facing.
- The system works in such a way that every turn ends when a user_facing agent puts out a response, i.e., it is now the user's turn to respond back. However, internal agent responses do not end turns. Multiple internal agents can respond, which will all be used by a user_facing agent to respond to the user.
## Section 4 : Editing an Existing Agent
When the user asks you to edit an existing agent, you should follow the steps below:
1. Understand the user's request. You can ask one set of clarifying questions if needed - keep it to at most 4 questions in a bulletted list.
2. Retain as much of the original agent and only edit the parts that are relevant to the user's request.
3. If needed, ask clarifying questions to the user. Keep that to one turn and keep it minimal.
4. When you output an edited agent instructions, output the entire new agent instructions.
### Section 4.1 : Adding Examples to an Agent
When adding examples to an agent use the below format for each example you create. Add examples to the example field in the agent config. Always add examples when creating a new agent, unless the user specifies otherwise.
\`\`\`
- **User** : <user's message>
- **Agent actions**: <actions like if applicable>
- **Agent response**: "<response to the user if applicable>
\`\`\`
Action involving calling other agents
1. If the action is calling another agent, denote it by 'Call [@agent:<agent_name>](#mention)'
2. If the action is calling another agent, don't include the agent response
Action involving calling tools
1. If the action involves calling one or more tools, denote it by 'Call [@tool:tool_name_1](#mention), Call [@tool:tool_name_2](#mention) ... '
2. If the action involves calling one or more tools, the corresponding response should have a placeholder to denote the output of tool call if necessary. e.g. 'Your order will be delivered on <delivery_date>'
Style of Response
1. If there is a Style prompt or other prompts which mention how the agent should respond, use that as guide when creating the example response
If the user doesn't specify how many examples, always add 5 examples.
### Section 4.2 : Adding RAG data sources to an Agent
When rag data sources are available you will be given the information on it like this:
\`\`\`
The following data sources are available:
[{"id": "6822e76aa1358752955a455e", "name": "Handbook", "description": "This is a employee handbook", "active": true, "status": "ready", "error": null, "data": {"type": "text"}}]
User: "can you add the handbook to the agent"]
\`\`\`
You should use the name and description to understand the data source, and use the id to attach the data source to the agent. Example:
'ragDataSources' = ["6822e76aa1358752955a455e"]
Once you add the datasource ID to the agent, add a section to the agent instructions called RAG. Under that section, inform the agent that here are a set of data sources available to it and add the name and description of each attached data source. Instruct the agent to 'Call [@tool:rag_search](#mention) to pull information from any of the data sources before answering any questions on them'.
Note: the rag_search tool searches across all data sources - it cannot call a specific data source.
## Section 5 : Improving an Existing Agent
When the user asks you to improve an existing agent, you should follow the steps below:
1. Understand the user's request.
2. Go through the agents instructions line by line and check if any of the instrcution is underspecified. Come up with possible test cases.
3. Now look at each test case and edit the agent so that it has enough information to pass the test case.
4. If needed, ask clarifying questions to the user. Keep that to one turn and keep it minimal.
## Section 6 : Adding / Editing / Removing Tools
1. Follow the user's request and output the relevant actions and data based on the user's needs.
2. If you are removing a tool, make sure to remove it from all the agents that use it.
3. If you are adding a tool, make sure to add it to all the agents that need it.
## Section 7 : Adding / Editing / Removing Prompts
1. Follow the user's request and output the relevant actions and data based on the user's needs.
2. If you are removing a prompt, make sure to remove it from all the agents that use it.
3. If you are adding a prompt, make sure to add it to all the agents that need it.
4. Add all the fields for a new agent including a description, instructions, tools, prompts, etc.
## Section 8 : Doing Multiple Actions at a Time
1. you should present your changes in order of : tools, prompts, agents.
2. Make sure to add, remove tools and prompts from agents as required.
## Section 9 : Creating New Agents
When creating a new agent, strictly follow the format of this example agent. The user might not provide all information in the example agent, but you should still follow the format and add the missing information.
example agent:
\`\`\`
## 🧑💼 Role:\nYou are the hub agent responsible for orchestrating the evaluation of interview transcripts between an executive search agency (Assistant) and a CxO candidate (User).\n\n---\n## Steps to Follow:\n1. Receive the transcript in the specified format.\n2. FIRST: Send the transcript to [@agent:Evaluation Agent] for evaluation.\n3. Wait to receive the complete evaluation from the Evaluation Agent.\n4. THEN: Send the received evaluation to [@agent:Call Decision] to determine if the call quality is sufficient.\n5. Based on the Call Decision response:\n - If approved: Inform the user that the call has been approved and will proceed to profile creation.\n - If rejected: Inform the user that the call quality was insufficient and provide the reason.\n6. Return the final result (rejection reason or approval confirmation) to the user.\n\n---\n## 🎯 Scope:\n In Scope:\n- Orchestrating the sequential evaluation and decision process for interview transcripts.\n\n Out of Scope:\n- Directly evaluating or creating profiles.\n- Handling transcripts not in the specified format.\n- Interacting with the individual evaluation agents.\n\n---\n## 📋 Guidelines:\n Dos:\n- Follow the strict sequence: Evaluation Agent first, then Call Decision.\n- Wait for each agent's complete response before proceeding.\n- Only interact with the user for final results or format clarification.\n\n🚫 Don'ts:\n- Do not perform evaluation or profile creation yourself.\n- Do not modify the transcript.\n- Do not try to get evaluations simultaneously.\n- Do not reference the individual evaluation agents.\n- CRITICAL: The system does not support more than 1 tool call in a single output when the tool call is about transferring to another agent (a handoff). You must only put out 1 transfer related tool call in one output.\n\n# Examples\n- **User** : Here is the interview transcript: [2024-04-25, 10:00] User: I have 20 years of experience... [2024-04-25, 10:01] Assistant: Can you describe your leadership style?\n - **Agent actions**: \n 1. First call [@agent:Evaluation Agent](#mention)\n 2. Wait for complete evaluation\n 3. Then call [@agent:Call Decision](#mention)\n\n- **Agent receives evaluation and decision (approved)** :\n - **Agent response**: The call has been approved. Proceeding to candidate profile creation.\n\n- **Agent receives evaluation and decision (rejected)** :\n - **Agent response**: The call quality was insufficient to proceed. [Provide reason from Call Decision agent]\n\n- **User** : The transcript is in a different format.\n - **Agent response**: Please provide the transcript in the specified format: [<date>, <time>] User: <user-message> [<date>, <time>] Assistant: <assistant-message>\n\n# Examples\n- **User** : Here is the interview transcript: [2024-04-25, 10:00] User: I have 20 years of experience... [2024-04-25, 10:01] Assistant: Can you describe your leadership style?\n - **Agent actions**: Call [@agent:Evaluation Agent](#mention)\n\n- **Agent receives Evaluation Agent result** :\n - **Agent actions**: Call [@agent:Call Decision](#mention)\n\n- **Agent receives Call Decision result (approved)** :\n - **Agent response**: The call has been approved. Proceeding to candidate profile creation.\n\n- **Agent receives Call Decision result (rejected)** :\n - **Agent response**: The call quality was insufficient to proceed. [Provide reason from Call Decision agent]\n\n- **User** : The transcript is in a different format.\n - **Agent response**: Please provide the transcript in the specified format: [<date>, <time>] User: <user-message> [<date>, <time>] Assistant: <assistant-message>\n\n- **User** : What happens after evaluation?\n - **Agent response**: After evaluation, if the call quality is sufficient, a candidate profile will be generated. Otherwise, you will receive feedback on why the call was rejected.
\`\`\`
IMPORTANT: Use {agent_model} as the default model for new agents.
## Section 10: General Guidelines
The user will provide the current config of the multi-agent system and ask you to make changes to it. Talk to the user and output the relevant actions and data based on the user's needs. You should output a set of actions required to accomplish the user's request.
Note:
1. The main agent is only responsible for orchestrating between the other agents. It should not perform any actions.
2. You should not edit the main agent unless absolutely necessary.
3. Make sure the there are no special characters in the agent names.
4. Add any escalation related request to the escalation agent.
5. After providing the actions, add a text section with something like 'Once you review and apply the changes, you can try out a basic chat first. I can then help you better configure each agent.'
6. If the user asks you to do anything that is out of scope, politely inform the user that you are not equipped to perform that task yet. E.g. "I'm sorry, adding simulation scenarios is currently out of scope for my capabilities. Is there anything else you would like me to do?"
7. Always speak with agency like "I'll do ... ", "I'll create ..."
8. Don't mention the style prompt
9. If the agents needs access to data and there is no RAG source provided, either use the web_search tool or create a mock tool to get the required information.
10. In agent instructions, make sure to mention that when agents need to take an action, they must just take action and not preface it by saying "I'm going to do X". Instead, they should just do X (e.g. call tools, invoke other agents) and respond with a message that comes about as a result of doing X.
If the user says 'Hi' or 'Hello', you should respond with a friendly greeting such as 'Hello! How can I help you today?'
**NOTE**: If a chat is attached but it only contains assistant's messages, you should ignore it.
## Section 11 : In-product Support
Below are FAQ's you should use when a use asks a questions on how to use the product (Rowboat).
User Question : How do I connect an MCP server?
Your Answer: Refer to https://docs.rowboatlabs.com/add_tools/ on how to connect MCP tools. Once you have imported the tools, I can help you in adding them to the agents.
User Question : How do I connect an Webhook?
Your Answer: Refer to https://docs.rowboatlabs.com/add_tools/ on how to connect a webhook. Once you have the tools setup, I can help you in adding them to the agents.
User Question: How do I use the Rowboat API?
Your Answer: Refer to https://docs.rowboatlabs.com/using_the_api/ on using the Rowboat API.
User Question: How do I use the SDK?
Your Answer: Refer to https://docs.rowboatlabs.com/using_the_sdk/ on using the Rowboat SDK.
User Question: I want to add RAG?
Your Answer: You can add data sources by using the data source menu in the left pane. You can fine more details in our docs: https://docs.rowboatlabs.com/using_rag.
`;

View file

@ -0,0 +1,13 @@
export const CURRENT_WORKFLOW_PROMPT = `
## Section: State of the Current Multi-Agent System
The design of the multi-agent system is represented by the following JSON schema:
\`\`\`
{workflow_schema}
\`\`\`
If the workflow has an 'Example Agent' as the main agent, it means the user is yet to create the main agent. You should treat the user's first request as a request to plan out and create the multi-agent system.
---
`;

View file

@ -0,0 +1,240 @@
export const COPILOT_MULTI_AGENT_EXAMPLE_1 = `
## Examples
### Example 1:
User: create a system to handle 2fa related customer support queries for a banking app. The queries can be: 1. setting up 2fa : ask the users preferred methods 2. changing 2fa : changing the 2fa method 3. troubleshooting : not getting 2fa codes etc.
Copilot output:
I'm creating the get_current_2fa_method tool to fetch the user's current 2FA method and assist the 2FA Change agent:
\`\`\`copilot_change
// action: create_new
// config_type: tool
// name: get_current_2fa_method
{
"change_description": "Created a tool to fetch the current 2FA method to assist in changing 2FA settings, with detailed parameters.",
"config_changes": {
"name": "get_current_2fa_method",
"description": "Tool to fetch the user's current 2FA method.",
"mockInstructions": "Return a random 2FA method for a banking app.",
"parameters": {
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "The unique identifier for the user whose 2FA method is being queried."
}
},
"required": [
"user_id"
]
}
}
}
\`\`\`
I'm creating the 2FA Setup agent to assist users in setting up their preferred 2FA method:
\`\`\`copilot_change
// action: create_new
// config_type: agent
// name: 2FA Setup
{
"change_description": "Created an agent to guide users in setting up their 2FA method, with added prompts for user interaction.",
"config_changes": {
"name": "2FA Setup",
"type": "conversation",
"description": "Agent to guide users in setting up 2FA.",
"instructions": "## 🧑‍💼 Role:\nHelp users set up their 2FA preferences.\n\n---\n## ⚙️ Steps to Follow:\n1. Ask the user about their preferred 2FA method (e.g., SMS, Email).\n2. Confirm the setup method with the user.\n3. Guide them through the setup steps.\n4. If the user request is out of scope, call [@agent:2FA Hub](#mention)\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Setting up 2FA preferences\n\n❌ Out of Scope:\n- Changing existing 2FA settings\n- Handling queries outside 2FA setup.\n- General knowledge queries.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Clearly explain setup options and steps.\n\n🚫 Don'ts:\n- Assume preferences without user confirmation.\n- Extend the conversation beyond 2FA setup.",
"examples": "- **User** : I'd like to set up 2FA for my account.\n - **Agent response**: Sure, can you tell me your preferred method for 2FA? Options include SMS, Email, or an Authenticator App.\n\n- **User** : I want to use SMS for 2FA.\n - **Agent response**: Great, I'll guide you through the steps to set up 2FA via SMS.\n\n- **User** : How about using an Authenticator App?\n - **Agent response**: Sure, let's set up 2FA with an Authenticator App. I'll walk you through the necessary steps.\n\n- **User** : Can you help me set up 2FA through Email?\n - **Agent response**: No problem, I'll explain how to set up 2FA via Email now.\n\n- **User** : I changed my mind, can we start over?\n - **Agent response**: Of course, let's begin again. Please select your preferred 2FA method from SMS, Email, or Authenticator App.",
"model": "gpt-4o",
"toggleAble": true,
"outputVisibility": "user_facing"
}
}
\`\`\`
I'm creating the 2FA Change agent to help users change their 2FA methods effectively:
\`\`\`copilot_change
// action: create_new
// config_type: agent
// name: 2FA Change
{
"change_description": "Created an agent to help users change their 2FA methods, with additional tool integration for better functionality.",
"config_changes": {
"name": "2FA Change",
"type": "conversation",
"description": "Agent to assist users in changing their 2FA method.",
"instructions": "## 🧑‍💼 Role:\nAssist users in changing their 2FA method preferences.\n\n---\n## ⚙️ Steps to Follow:\n1. Fetch the current 2FA method using the [@tool:get_current_2fa_method](#mention) tool.\n2. Confirm with the user if they want to change the method.\n3. Guide them through the process of changing the method.\n4. If the user request is out of scope, call [@agent:2FA Hub](#mention)\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Changing existing 2FA settings\n\n❌ Out of Scope:\n- Initial setup of 2FA\n- Handling queries outside 2FA setup.\n- General knowledge queries.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Ensure the user is aware of the current method before change.\n\n🚫 Don'ts:\n- Change methods without explicit user confirmation.\n- Extend the conversation beyond 2FA change.",
"examples": "- **User** : I want to change my 2FA method from SMS to Email.\n - **Agent response**: I can help with that. Let me fetch your current 2FA setting first.\n - **Agent actions**: Call [@tool:get_current_2fa_method](#mention)\n\n- **User** : Can I switch to using an Authenticator App instead of Email?\n - **Agent response**: Sure, I'll guide you through switching to an Authenticator App.\n - **Agent actions**: Call [@tool:get_current_2fa_method](#mention)\n\n- **User** : I don't want to use 2FA via phone anymore, can you change it?\n - **Agent response**: Let's check your current method and proceed with the change.\n - **Agent actions**: Call [@tool:get_current_2fa_method](#mention)\n\n- **User** : I'd like to update my 2FA to be more secure, what do you suggest?\n - **Agent response**: For enhanced security, consider using an Authenticator App. Let's fetch your current method and update it.\n - **Agent actions**: Call [@tool:get_current_2fa_method](#mention)\n\n- **User** : I'm having trouble changing my 2FA method, can you assist?\n - **Agent response**: Certainly, let's see what your current setup is and I'll walk you through the change.",
"model": "gpt-4o",
"toggleAble": true,
"outputVisibility": "user_facing"
}
}
\`\`\`
I'm creating the 2FA Troubleshooting agent to assist users in resolving issues with receiving 2FA codes:
\`\`\`copilot_change
// action: create_new
// config_type: agent
// name: 2FA Troubleshooting
{
"change_description": "Created an agent to troubleshoot problems with receiving 2FA codes, with added verification prompts.",
"config_changes": {
"name": "2FA Troubleshooting",
"type": "conversation",
"description": "Agent to troubleshoot issues related to not receiving 2FA codes.",
"instructions": "## 🧑‍💼 Role:\nTroubleshoot and resolve issues with 2FA codes.\n\n---\n## ⚙️ Steps to Follow:\n1. Confirm the contact details for 2FA are correct.\n2. Ask about the issue specifics (e.g., not receiving codes at all, delayed codes).\n3. Provide troubleshooting steps or escalate if unresolved.\n4. If the user request is out of scope, call [@agent:2FA Hub](#mention)\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Troubleshooting issues with receiving 2FA codes\n\n❌ Out of Scope:\n- Setting up or changing 2FA methods\n- Handling queries outside 2FA Troubleshooting.\n- General knowledge queries.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Verify contact information for accuracy.\n\n🚫 Don'ts:\n- Extend the conversation beyond 2FA change.",
"examples": "- **User** : I haven't received my 2FA code.\n - **Agent response**: Let's ensure your contact details are correct. Could you verify if the phone number or email you are expecting the code on is correct?\n\n- **User** : My 2FA codes are always delayed.\n - **Agent response**: I apologize for the inconvenience. Have you checked your spam or junk folder in case of email, or experienced network issues if it's through SMS?\n\n- **User** : The codes aren't coming to my phone.\n - **Agent response**: Let's confirm the number on file for you. Could you also check if your phone's SMS settings might be blocking incoming messages?\n\n- **User** : I'm facing issues with receiving 2FA codes via email.\n - **Agent response**: Please double-check the email address on record and ensure the codes aren't found in spam or trash. I can assist further if needed.\n\n- **User** : My codes are coming to the wrong email address.\n - **Agent response**: I recommend updating your 2FA contact information. Would you like assistance with how to change your email for 2FA notifications?",
"model": "gpt-4o",
"toggleAble": true,
"outputVisibility": "user_facing"
}
}
\`\`\`
I'm creating the hub agent 2FA Hub by modifying the Example Agent to manage and direct 2FA-related queries to specific agents:
\`\`\`copilot_change
// action: edit
// config_type: agent
// name: Example Agent
{
"change_description": "Created a hub agent for 2FA-related queries to manage directing queries to specific agents, with updated fallback actions and clarifying instructions.",
"config_changes": {
"name": "2FA Hub",
"description": "Hub agent to manage 2FA-related queries.",
"instructions": "## 🧑‍💼 Role:\nYou are responsible for directing 2FA-related queries to appropriate agents.\n\n---\n## ⚙️ Steps to Follow:\n1. Greet the user and ask which 2FA-related query they need help with (e.g., 'Are you setting up, changing, or troubleshooting your 2FA?').\n2. If the query matches a specific task, direct the user to the corresponding agent:\n - Setup → [@agent:2FA Setup](#mention)\n - Change → [@agent:2FA Change](#mention)\n - Troubleshooting → [@agent:2FA Troubleshooting](#mention)\n3. If the query doesn't match any specific task, respond with 'I'm sorry, I didn't understand. Could you clarify your request?' or escalate to human support.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Initialization of 2FA setup\n- Changing 2FA methods\n- Troubleshooting 2FA issues\n\n❌ Out of Scope:\n- Issues unrelated to 2FA\n- General knowledge queries\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Direct queries to specific 2FA agents promptly.\n\n🚫 Don'ts:\n- Engage in detailed support.\n- Extend the conversation beyond 2FA.\n- Provide user-facing text such as 'I will connect you now...' when calling another agent",
"examples": "- **User** : I need help setting up 2FA for my account.\n - **Agent actions**: [@agent:2FA Setup](#mention)\n\n- **User** : How do I change my 2FA method?\n - **Agent actions**: Call [@agent:2FA Change](#mention)\n\n- **User** : I'm not getting my 2FA codes.\n - **Agent actions**: Call [@agent:2FA Troubleshooting](#mention)\n\n- **User** : How are you today?\n - **Agent response**: I'm doing great. What would like help with today?",
"outputVisibility": "user_facing"
}
}
\`\`\`
Once you review and apply the changes, you can try out a basic chat first. I can then help you better configure each agent.
This concludes my changes. Would you like some more help?
---
### Example 2:
User: create a system to evaluate executive search interview transcripts between an agency and CxO candidates. The system should evaluate both the interviewer's questions and candidate's responses to determine if the call quality is sufficient for profile creation.
Copilot output:
I'm creating the Exec Search Evaluation agent to evaluate the quality of questions asked by the executive search agency:
\`\`\`copilot_change
// action: create_new
// config_type: agent
// name: Exec Search Evaluation
{
"change_description": "Created an agent to evaluate the quality and relevance of questions asked by the executive search agency.",
"config_changes": {
"name": "Exec Search Evaluation",
"type": "conversation",
"description": "Evaluates the relevance and quality of questions asked by the executive search agency in the transcript.",
"instructions": "## 🧑‍💼 Role:\nYou are responsible for evaluating the questions asked by the executive search agency (Assistant) in the interview transcript.\n\n---\n## ⚙️ Steps to Follow:\n1. Review all 'Assistant' messages in the transcript.\n2. Assess whether the questions are relevant and sufficient to create a comprehensive CxO candidate job profile.\n3. Rate the overall quality of the questions as 'Excellent', 'Good', 'Average', or 'Below Average'.\n4. Provide a brief justification for your rating.\n5. Return your evaluation and justification.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Evaluating the relevance and depth of questions asked by the executive search agency.\n\n❌ Out of Scope:\n- Evaluating candidate responses.\n- Creating candidate profiles.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Focus on whether the questions cover key areas: skills, experience, leadership, fit, etc.\n- Be objective and concise in your justification.\n\n🚫 Don'ts:\n- Do not evaluate candidate answers.\n- Do not suggest improvements unless specifically asked.",
"model": "gpt-4o",
"toggleAble": true,
"outputVisibility": "internal"
}
}
\`\`\`
I'm creating the Candidate Evaluation agent to evaluate the quality of responses from the CxO candidate:
\`\`\`copilot_change
// action: create_new
// config_type: agent
// name: Candidate Evaluation
{
"change_description": "Created an agent to evaluate the quality and relevance of candidate responses in the interview.",
"config_changes": {
"name": "Candidate Evaluation",
"type": "conversation",
"description": "Evaluates the quality, relevance, and clarity of the candidate's (User) responses in the transcript.",
"instructions": "## 🧑‍💼 Role:\nYou are responsible for evaluating the candidate's (User) responses in the interview transcript.\n\n---\n## ⚙️ Steps to Follow:\n1. Review all 'User' messages in the transcript.\n2. Assess whether the responses are relevant, clear, and directly address the questions asked.\n3. Identify if any responses are meandering, off-topic, or spam.\n4. Rate the overall quality of the responses as 'Excellent', 'Good', 'Average', or 'Below Average'.\n5. Provide a brief justification for your rating.\n6. Return your evaluation and justification.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Evaluating the quality and relevance of candidate responses.\n\n❌ Out of Scope:\n- Evaluating the questions asked by the executive search agency.\n- Creating candidate profiles.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Focus on clarity, relevance, and completeness of responses.\n- Be objective and concise in your justification.\n\n🚫 Don'ts:\n- Do not evaluate the questions asked.\n- Do not suggest improvements unless specifically asked.",
"model": "gpt-4o",
"toggleAble": true,
"outputVisibility": "internal"
}
}
\`\`\`
I'm creating the Call Decision agent to determine if the call quality is sufficient based on both evaluations:
\`\`\`copilot_change
// action: create_new
// config_type: agent
// name: Call Decision
{
"change_description": "Created an agent to make decisions about call quality based on evaluations from both sides.",
"config_changes": {
"name": "Call Decision",
"type": "conversation",
"description": "Decides whether the call quality is sufficient to proceed to candidate profile creation based on the evaluations.",
"instructions": "## 🧑‍💼 Role:\nYou are responsible for making a decision based on the evaluations from the Exec Search and Candidate Evaluation agents.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the ratings and justifications from both evaluation agents.\n2. If either rating is 'Below Average', reject the call and provide the reason (which side was below average and why).\n3. If both ratings are 'Average' or above, approve the call to proceed to candidate profile creation.\n4. Return your decision and, if rejected, the reason for rejection.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Deciding whether to proceed to profile creation or reject the call.\n\n❌ Out of Scope:\n- Evaluating the transcript directly.\n- Creating candidate profiles.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Be clear and concise in your decision.\n- Use the justifications from the evaluation agents to support your decision.\n\n🚫 Don'ts:\n- Do not override the evaluation agents' ratings.",
"model": "gpt-4o",
"toggleAble": true,
"outputVisibility": "internal"
}
}
\`\`\`
I'm creating the Evaluation Agent to coordinate between the two evaluation agents:
\`\`\`copilot_change
// action: create_new
// config_type: agent
// name: Evaluation Agent
{
"change_description": "Created a coordinator agent to manage the evaluation process between the two evaluation agents.",
"config_changes": {
"name": "Evaluation Agent",
"type": "conversation",
"description": "Coordinates the evaluation of the interview transcript by both the Exec Search Evaluation and Candidate Evaluation agents.",
"instructions": "## 🧑‍💼 Role:\nYou are responsible for coordinating the evaluation of the interview transcript by both the Exec Search Evaluation and Candidate Evaluation agents.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the transcript from the hub agent.\n2. FIRST: Send the transcript to [@agent:Exec Search Evaluation] to evaluate the questions asked by the executive search agency.\n3. After receiving the Exec Search Evaluation response, THEN send the transcript to [@agent:Candidate Evaluation] to evaluate the candidate's responses.\n4. Once you have BOTH evaluations (ratings and justifications), combine them into a single evaluation response.\n5. Return the combined evaluation to the hub agent.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Coordinating the sequential evaluation process between the two evaluation agents.\n\n❌ Out of Scope:\n- Making decisions about call quality.\n- Creating candidate profiles.\n- Interacting directly with the user.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Follow the strict sequence: Exec Search first, then Candidate.\n- Wait for each evaluation to complete before proceeding.\n- Combine both evaluations into a single response.\n\n🚫 Don'ts:\n- Do not evaluate the transcript yourself.\n- Do not try to get both evaluations simultaneously.\n- Do not interact with the user.\n- CRITICAL: The system does not support more than 1 tool call in a single output when the tool call is about transferring to another agent.",
"model": "gpt-4o",
"toggleAble": true,
"outputVisibility": "internal",
"connectedAgents": [
"Exec Search Evaluation",
"Candidate Evaluation"
]
}
}
\`\`\`
I'm creating the Interview Evaluation Hub agent to orchestrate the entire evaluation process:
\`\`\`copilot_change
// action: create_new
// config_type: agent
// name: Interview Evaluation Hub
{
"change_description": "Created a hub agent to orchestrate the entire interview evaluation process.",
"config_changes": {
"name": "Interview Evaluation Hub",
"type": "conversation",
"description": "Hub agent to orchestrate the evaluation of interview transcripts between an executive search agency and a CxO candidate.",
"instructions": "## 🧑‍💼 Role:\nYou are the hub agent responsible for orchestrating the evaluation of interview transcripts between an executive search agency (Assistant) and a CxO candidate (User).\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the transcript in the specified format.\n2. FIRST: Send the transcript to [@agent:Evaluation Agent] for evaluation.\n3. Wait to receive the complete evaluation from the Evaluation Agent.\n4. THEN: Send the received evaluation to [@agent:Call Decision] to determine if the call quality is sufficient.\n5. Based on the Call Decision response:\n - If approved: Inform the user that the call has been approved and will proceed to profile creation.\n - If rejected: Inform the user that the call quality was insufficient and provide the reason.\n6. Return the final result (rejection reason or approval confirmation) to the user.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Orchestrating the sequential evaluation and decision process for interview transcripts.\n\n❌ Out of Scope:\n- Directly evaluating or creating profiles.\n- Handling transcripts not in the specified format.\n- Interacting with the individual evaluation agents.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Follow the strict sequence: Evaluation Agent first, then Call Decision.\n- Wait for each agent's complete response before proceeding.\n- Only interact with the user for final results or format clarification.\n\n🚫 Don'ts:\n- Do not perform evaluation or profile creation yourself.\n- Do not modify the transcript.\n- Do not try to get evaluations simultaneously.\n- Do not reference the individual evaluation agents.\n- CRITICAL: The system does not support more than 1 tool call in a single output when the tool call is about transferring to another agent.",
"model": "gpt-4o",
"toggleAble": true,
"outputVisibility": "user_facing",
"connectedAgents": [
"Evaluation Agent",
"Call Decision"
]
}
}
\`\`\`
Once you review and apply the changes, you can try out a basic chat first. I can then help you better configure each agent.
This concludes my changes. Would you like some more help?
`;

View file

@ -6,11 +6,13 @@ export const USE_AUTH = process.env.USE_AUTH === 'true';
export const USE_RAG_S3_UPLOADS = process.env.USE_RAG_S3_UPLOADS === 'true';
export const USE_GEMINI_FILE_PARSING = process.env.USE_GEMINI_FILE_PARSING === 'true';
export const USE_BILLING = process.env.USE_BILLING === 'true';
export const USE_COMPOSIO_TOOLS = process.env.USE_COMPOSIO_TOOLS === 'true';
export const USE_KLAVIS_TOOLS = process.env.USE_KLAVIS_TOOLS === 'true';
// Hardcoded flags
export const USE_MULTIPLE_PROJECTS = true;
export const USE_TESTING_FEATURE = false;
export const USE_VOICE_FEATURE = false;
export const USE_TRANSFER_CONTROL_OPTIONS = false;
export const USE_TRANSFER_CONTROL_OPTIONS = true;
export const USE_PRODUCT_TOUR = true;
export const SHOW_COPILOT_MARQUEE = false;

View file

@ -0,0 +1,32 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
// Helper to get MCP client
export async function getMcpClient(serverUrl: string, serverName: string): Promise<Client> {
let client: Client | undefined = undefined;
const baseUrl = new URL(serverUrl);
// Try to connect using Streamable HTTP transport
try {
client = new Client({
name: 'streamable-http-client',
version: '1.0.0'
});
const transport = new StreamableHTTPClientTransport(baseUrl);
await client.connect(transport);
console.log(`[MCP] Connected using Streamable HTTP transport to ${serverName}`);
return client;
} catch (error) {
// If that fails with a 4xx error, try the older SSE transport
console.log(`[MCP] Streamable HTTP connection failed, falling back to SSE transport for ${serverName}`);
client = new Client({
name: 'sse-client',
version: '1.0.0'
});
const sseTransport = new SSEClientTransport(baseUrl);
await client.connect(sseTransport);
console.log(`[MCP] Connected using SSE transport to ${serverName}`);
return client;
}
}

View file

@ -8,7 +8,7 @@ import { EmbeddingDoc } from "./types/datasource_types";
import { DataSourceDoc } from "./types/datasource_types";
import { DataSource } from "./types/datasource_types";
import { TestScenario, TestResult, TestRun, TestProfile, TestSimulation } from "./types/testing_types";
import { TwilioConfig } from "./types/voice_types";
import { TwilioConfig, TwilioInboundCall } from "./types/voice_types";
import { z } from 'zod';
import { apiV1 } from "rowboat-shared";
@ -32,6 +32,7 @@ export const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats"
export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages");
export const twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("twilio_configs");
export const usersCollection = db.collection<z.infer<typeof User>>("users");
export const twilioInboundCallsCollection = db.collection<z.infer<typeof TwilioInboundCall>>("twilio_inbound_calls");
// Create indexes
twilioConfigsCollection.createIndexes([

View file

@ -2,50 +2,59 @@ import { z } from "zod";
import { projectsCollection } from "./mongodb";
import { WorkflowTool } from "./types/workflow_types";
export async function fetchProjectMcpTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
// Get project's MCP servers and their tools
const project = await projectsCollection.findOne({ _id: projectId });
if (!project?.mcpServers) return [];
export async function collectProjectTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
const tools: z.infer<typeof WorkflowTool>[] = [];
console.log('[MCP] Getting tools from project:', {
serverCount: project.mcpServers.length,
servers: project.mcpServers.map(s => ({
name: s.name,
isReady: s.isReady,
toolCount: s.tools.length,
tools: s.tools.map(t => ({
name: t.name,
hasParams: !!t.parameters,
paramCount: t.parameters ? Object.keys(t.parameters.properties).length : 0,
required: t.parameters?.required || []
}))
}))
});
// Get project data
const project = await projectsCollection.findOne({ _id: projectId });
if (!project) {
throw new Error(`Project ${projectId} not found`);
}
// Convert MCP tools to workflow tools format, but only from ready servers
const mcpTools = project.mcpServers
.filter(server => server.isReady) // Only include tools from ready servers
.flatMap(server => {
return server.tools.map(tool => ({
name: tool.name,
if (project.mcpServers) {
for (const server of project.mcpServers) {
if (server.isReady) {
for (const tool of server.tools) {
tools.push({
name: tool.name,
description: tool.description || "",
parameters: {
type: 'object' as const,
properties: tool.parameters?.properties || {},
required: tool.parameters?.required || []
},
isMcp: true,
mcpServerName: server.name,
mcpServerURL: server.serverUrl,
});
}
}
}
}
// Add Composio tools
if (project.composioSelectedTools) {
for (const tool of project.composioSelectedTools) {
tools.push({
name: tool.slug,
description: tool.description || "",
parameters: {
type: 'object' as const,
properties: tool.parameters?.properties || {},
required: tool.parameters?.required || []
properties: tool.input_parameters?.properties || {},
required: tool.input_parameters?.required || []
},
isMcp: true,
mcpServerName: server.name,
mcpServerURL: server.serverUrl,
}));
});
isComposio: true,
composioData: {
slug: tool.slug,
noAuth: tool.no_auth,
toolkitName: tool.toolkit.name,
toolkitSlug: tool.toolkit.slug,
logo: tool.toolkit.logo,
},
});
}
}
console.log('[MCP] Converted tools from ready servers:', mcpTools.map(t => ({
name: t.name,
hasParams: !!t.parameters,
paramCount: t.parameters ? Object.keys(t.parameters.properties).length : 0,
required: t.parameters?.required || []
})));
return mcpTools;
return tools;
}

View file

@ -1,7 +1,3 @@
import { createClient } from 'redis';
import Redis from 'ioredis';
export const redisClient = createClient({
url: process.env.REDIS_URL,
});
redisClient.connect();
export const redisClient = new Redis(process.env.REDIS_URL || '');

View file

@ -1,334 +0,0 @@
import { z } from "zod";
import { sanitizeTextWithMentions, Workflow, WorkflowAgent, WorkflowPrompt, WorkflowTool } from "./workflow_types";
import { apiV1 } from "rowboat-shared";
import { ApiMessage } from "./types";
import { TestProfile } from "./testing_types";
import { MCPServer, MCPServerMinimal } from "./types";
import { mergeProjectTools } from "./project_types";
export const AgenticAPIChatMessage = z.object({
role: z.union([z.literal('user'), z.literal('assistant'), z.literal('tool'), z.literal('system')]),
content: z.string().nullable(),
tool_calls: z.array(z.object({
id: z.string(),
function: z.object({
name: z.string(),
arguments: z.string(),
}),
type: z.literal('function'),
})).nullable(),
tool_call_id: z.string().nullable(),
tool_name: z.string().nullable(),
sender: z.string().nullable(),
response_type: z.union([
z.literal('internal'),
z.literal('external'),
]).optional(),
});
export const AgenticAPIAgent = WorkflowAgent
.omit({
disabled: true,
examples: true,
locked: true,
toggleAble: true,
global: true,
})
.extend({
tools: z.array(z.string()),
prompts: z.array(z.string()),
connectedAgents: z.array(z.string()),
});
export const AgenticAPIPrompt = WorkflowPrompt;
export const AgenticAPITool = WorkflowTool
.omit({
autoSubmitMockedResponse: true,
})
export const AgenticAPIChatRequest = z.object({
projectId: z.string(),
messages: z.array(AgenticAPIChatMessage),
state: z.unknown(),
agents: z.array(AgenticAPIAgent),
tools: z.array(AgenticAPITool),
prompts: z.array(WorkflowPrompt),
startAgent: z.string(),
testProfile: TestProfile.optional(),
mcpServers: z.array(MCPServerMinimal),
toolWebhookUrl: z.string(),
});
export const AgenticAPIChatResponse = z.object({
messages: z.array(AgenticAPIChatMessage),
state: z.unknown(),
});
export const AgenticAPIInitStreamResponse = z.object({
streamId: z.string(),
});
export function convertWorkflowToAgenticAPI(workflow: z.infer<typeof Workflow>, projectTools: z.infer<typeof WorkflowTool>[]): {
agents: z.infer<typeof AgenticAPIAgent>[];
tools: z.infer<typeof AgenticAPITool>[];
prompts: z.infer<typeof AgenticAPIPrompt>[];
startAgent: string;
} {
const mergedTools = mergeProjectTools(workflow.tools, projectTools);
return {
agents: workflow.agents
.filter(agent => !agent.disabled)
.map(agent => {
const compiledInstructions = agent.instructions +
(agent.examples ? '\n\n# Examples\n' + agent.examples : '');
const { sanitized, entities } = sanitizeTextWithMentions(compiledInstructions, workflow, mergedTools);
const agenticAgent: z.infer<typeof AgenticAPIAgent> = {
name: agent.name,
type: agent.type,
description: agent.description,
instructions: sanitized,
model: agent.model,
controlType: agent.controlType,
ragDataSources: agent.ragDataSources,
ragK: agent.ragK,
ragReturnType: agent.ragReturnType,
outputVisibility: agent.outputVisibility,
tools: entities.filter(e => e.type == 'tool').map(e => e.name),
prompts: entities.filter(e => e.type == 'prompt').map(e => e.name),
connectedAgents: entities.filter(e => e.type === 'agent').map(e => e.name),
maxCallsPerParentAgent: agent.maxCallsPerParentAgent,
};
return agenticAgent;
}),
tools: mergedTools,
prompts: workflow.prompts
.map(p => {
const { sanitized } = sanitizeTextWithMentions(p.prompt, workflow, mergedTools);
return {
...p,
prompt: sanitized,
};
}),
startAgent: workflow.startAgent,
};
}
export function convertToAgenticAPIChatMessages(messages: z.infer<typeof apiV1.ChatMessage>[]): z.infer<typeof AgenticAPIChatMessage>[] {
const converted: z.infer<typeof AgenticAPIChatMessage>[] = [];
for (const m of messages) {
const baseMessage: z.infer<typeof AgenticAPIChatMessage> = {
content: null,
role: m.role,
sender: null,
tool_calls: null,
tool_call_id: null,
tool_name: null,
};
switch (m.role) {
case 'system':
converted.push({
...baseMessage,
content: m.content,
});
break;
case 'user':
converted.push({
...baseMessage,
content: m.content,
});
break;
case 'assistant':
if ('tool_calls' in m) {
converted.push({
...baseMessage,
tool_calls: m.tool_calls,
sender: m.agenticSender ?? null,
response_type: m.agenticResponseType,
});
} else {
converted.push({
...baseMessage,
content: m.content,
sender: m.agenticSender ?? null,
response_type: m.agenticResponseType,
});
}
break;
case 'tool':
converted.push({
...baseMessage,
content: m.content,
tool_call_id: m.tool_call_id,
tool_name: m.tool_name,
});
break;
default:
continue;
}
}
return converted;
}
export function convertFromAgenticAPIChatMessages(messages: z.infer<typeof AgenticAPIChatMessage>[]): z.infer<typeof apiV1.ChatMessage>[] {
const converted: z.infer<typeof apiV1.ChatMessage>[] = [];
for (const m of messages) {
const baseMessage = {
version: 'v1' as const,
chatId: '',
createdAt: new Date().toISOString(),
};
switch (m.role) {
case 'user':
converted.push({
...baseMessage,
role: 'user',
content: m.content ?? '',
});
break;
case 'assistant':
if (m.tool_calls) {
// TODO: handle tool calls
converted.push({
...baseMessage,
role: 'assistant',
tool_calls: m.tool_calls,
agenticSender: m.sender ?? undefined,
agenticResponseType: m.response_type ?? 'internal',
});
} else {
converted.push({
...baseMessage,
role: 'assistant',
content: m.content ?? '',
agenticSender: m.sender ?? undefined,
agenticResponseType: m.response_type ?? 'internal',
});
}
break;
case 'tool':
converted.push({
...baseMessage,
role: 'tool',
content: m.content ?? '',
tool_call_id: m.tool_call_id ?? '',
tool_name: m.tool_name ?? '',
});
break;
}
}
return converted;
}
export function convertFromApiToAgenticApiMessages(messages: z.infer<typeof ApiMessage>[]): z.infer<typeof AgenticAPIChatMessage>[] {
return messages.map(m => {
switch (m.role) {
case 'system':
return {
role: 'system',
content: m.content,
tool_calls: null,
tool_call_id: null,
tool_name: null,
sender: null,
};
case 'user':
return {
role: 'user',
content: m.content,
tool_calls: null,
tool_call_id: null,
tool_name: null,
sender: null,
};
case 'assistant':
if ('tool_calls' in m) {
return {
role: 'assistant',
content: m.content ?? null,
tool_calls: m.tool_calls,
tool_call_id: null,
tool_name: null,
sender: m.agenticSender ?? null,
response_type: m.agenticResponseType ?? 'external',
};
} else {
return {
role: 'assistant',
content: m.content ?? null,
sender: m.agenticSender ?? null,
response_type: m.agenticResponseType ?? 'external',
tool_call_id: null,
tool_calls: null,
tool_name: null,
};
}
case 'tool':
return {
role: 'tool',
content: m.content ?? null,
tool_calls: null,
tool_call_id: m.tool_call_id ?? null,
tool_name: m.tool_name ?? null,
sender: null,
};
default:
return {
role: "user",
content: "foo",
tool_calls: null,
tool_call_id: null,
tool_name: null,
sender: null,
};
}
});
}
export function convertFromAgenticApiToApiMessages(messages: z.infer<typeof AgenticAPIChatMessage>[]): z.infer<typeof ApiMessage>[] {
const converted: z.infer<typeof ApiMessage>[] = [];
for (const m of messages) {
switch (m.role) {
case 'user':
converted.push({
role: 'user',
content: m.content ?? '',
});
break;
case 'assistant':
if (m.tool_calls) {
converted.push({
role: 'assistant',
tool_calls: m.tool_calls,
agenticSender: m.sender ?? undefined,
agenticResponseType: m.response_type ?? 'internal',
});
} else {
converted.push({
role: 'assistant',
content: m.content ?? '',
agenticSender: m.sender ?? undefined,
agenticResponseType: m.response_type ?? 'internal',
});
}
break;
case 'tool':
converted.push({
role: 'tool',
content: m.content ?? '',
tool_call_id: m.tool_call_id ?? '',
tool_name: m.tool_name ?? '',
});
break;
}
}
return converted;
}

View file

@ -1,43 +1,8 @@
import { z } from "zod";
import { Workflow } from "./workflow_types";
import { apiV1 } from "rowboat-shared"
import { AgenticAPIChatMessage } from "./agents_api_types";
import { convertToAgenticAPIChatMessages } from "./agents_api_types";
import { Message } from "./types";
import { DataSource } from "./datasource_types";
// Create a filtered version of DataSource for copilot
export const CopilotDataSource = z.object({
_id: z.string(),
name: z.string(),
description: z.string().optional(),
active: z.boolean().default(true),
status: z.union([
z.literal('pending'),
z.literal('ready'),
z.literal('error'),
z.literal('deleted'),
]),
error: z.string().optional(),
data: z.discriminatedUnion('type', [
z.object({
type: z.literal('urls'),
}),
z.object({
type: z.literal('files_local'),
}),
z.object({
type: z.literal('files_s3'),
}),
z.object({
type: z.literal('text'),
})
]),
}).passthrough();
export const CopilotWorkflow = Workflow.omit({
lastUpdatedAt: true,
projectId: true,
});
export const CopilotUserMessage = z.object({
role: z.literal('user'),
content: z.string(),
@ -63,14 +28,10 @@ export const CopilotAssistantMessage = z.object({
});
export const CopilotMessage = z.union([CopilotUserMessage, CopilotAssistantMessage]);
export const CopilotApiMessage = z.object({
role: z.union([z.literal('assistant'), z.literal('user')]),
content: z.string(),
});
export const CopilotChatContext = z.union([
z.object({
type: z.literal('chat'),
messages: z.array(apiV1.ChatMessage),
messages: z.array(Message),
}),
z.object({
type: z.literal('agent'),
@ -85,31 +46,15 @@ export const CopilotChatContext = z.union([
name: z.string(),
}),
]);
export const CopilotApiChatContext = z.union([
z.object({
type: z.literal('chat'),
messages: z.array(AgenticAPIChatMessage),
}),
z.object({
type: z.literal('agent'),
agentName: z.string(),
}),
z.object({
type: z.literal('tool'),
toolName: z.string(),
}),
z.object({
type: z.literal('prompt'),
promptName: z.string(),
}),
]);
export const CopilotAPIRequest = z.object({
projectId: z.string(),
messages: z.array(CopilotApiMessage),
workflow_schema: z.string(),
current_workflow_config: z.string(),
context: CopilotApiChatContext.nullable(),
dataSources: z.array(CopilotDataSource).optional(),
messages: z.array(CopilotMessage),
workflow: Workflow,
context: CopilotChatContext.nullable(),
dataSources: z.array(DataSource.extend({
_id: z.string(),
})).optional(),
});
export const CopilotAPIResponse = z.union([
z.object({
@ -118,57 +63,4 @@ export const CopilotAPIResponse = z.union([
z.object({
error: z.string(),
}),
]);
export function convertToCopilotApiChatContext(context: z.infer<typeof CopilotChatContext>): z.infer<typeof CopilotApiChatContext> {
switch (context.type) {
case 'chat':
return {
type: 'chat',
messages: convertToAgenticAPIChatMessages(context.messages),
};
case 'agent':
return {
type: 'agent',
agentName: context.name,
};
case 'tool':
return {
type: 'tool',
toolName: context.name,
};
case 'prompt':
return {
type: 'prompt',
promptName: context.name,
};
}
}
export function convertToCopilotApiMessage(message: z.infer<typeof CopilotMessage>): z.infer<typeof CopilotApiMessage> {
return {
role: message.role,
content: JSON.stringify(message.content),
};
}
export function convertToCopilotMessage(message: z.infer<typeof CopilotApiMessage>): z.infer<typeof CopilotMessage> {
switch (message.role) {
case 'assistant':
return CopilotAssistantMessage.parse({
role: 'assistant',
content: JSON.parse(message.content),
});
case 'user':
return {
role: 'user',
content: message.content,
};
default:
throw new Error(`Unknown role: ${message.role}`);
}
}
export function convertToCopilotWorkflow(workflow: z.infer<typeof Workflow>): z.infer<typeof CopilotWorkflow> {
const { lastUpdatedAt, projectId, ...rest } = workflow;
return {
...rest,
};
}
]);

View file

@ -1,6 +1,19 @@
import { z } from "zod";
import { MCPServer } from "./types";
import { WorkflowTool } from "./workflow_types";
import { ZTool } from "../composio/composio";
export const ComposioConnectedAccount = z.object({
id: z.string(),
authConfigId: z.string(),
status: z.enum([
'INITIATED',
'ACTIVE',
'FAILED',
]),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});
export const Project = z.object({
_id: z.string().uuid(),
@ -15,6 +28,8 @@ export const Project = z.object({
nextWorkflowNumber: z.number().optional(),
testRunCounter: z.number().default(0),
mcpServers: z.array(MCPServer).optional(),
composioConnectedAccounts: z.record(z.string(), ComposioConnectedAccount).optional(),
composioSelectedTools: z.array(ZTool).optional(),
});
export const ProjectMember = z.object({
@ -38,32 +53,11 @@ export function mergeProjectTools(
// Filter out any existing MCP tools from workflow tools
const nonMcpTools = workflowTools.filter(t => !t.isMcp);
// Merge with MCP tools
// Merge with project tools
const merged = [
...nonMcpTools,
...projectTools.map(tool => ({
...tool,
isMcp: true as const, // Ensure isMcp is set
parameters: {
type: 'object' as const,
properties: tool.parameters?.properties || {},
required: tool.parameters?.required || []
}
}))
...projectTools
];
console.log('[mergeMcpTools] Merged tools:', {
totalCount: merged.length,
nonMcpCount: nonMcpTools.length,
mcpCount: projectTools.length,
tools: merged.map(t => ({
name: t.name,
isMcp: t.isMcp,
hasParams: !!t.parameters,
paramCount: t.parameters ? Object.keys(t.parameters.properties).length : 0,
parameters: t.parameters
}))
});
return merged;
}

View file

@ -1,8 +1,52 @@
import { CoreMessage, ToolCallPart } from "ai";
import { z } from "zod";
import { apiV1 } from "rowboat-shared";
import { WorkflowTool } from "./workflow_types";
export const SystemMessage = z.object({
role: z.literal("system"),
content: z.string(),
});
export const UserMessage = z.object({
role: z.literal("user"),
content: z.string(),
});
export const AssistantMessage = z.object({
role: z.literal("assistant"),
content: z.string(),
agentName: z.string().nullable(),
responseType: z.enum(['internal', 'external']),
});
export const AssistantMessageWithToolCalls = z.object({
role: z.literal("assistant"),
content: z.null(),
toolCalls: z.array(z.object({
id: z.string(),
type: z.literal("function"),
function: z.object({
name: z.string(),
arguments: z.string(),
}),
})),
agentName: z.string().nullable(),
});
export const ToolMessage = z.object({
role: z.literal("tool"),
content: z.string(),
toolCallId: z.string(),
toolName: z.string(),
});
export const Message = z.union([
SystemMessage,
UserMessage,
AssistantMessage,
AssistantMessageWithToolCalls,
ToolMessage,
]);
export const McpToolInputSchema = z.object({
type: z.literal('object'),
properties: z.record(z.object({
@ -89,7 +133,7 @@ export const PlaygroundChat = z.object({
createdAt: z.string().datetime(),
projectId: z.string(),
title: z.string().optional(),
messages: z.array(apiV1.ChatMessage),
messages: z.array(Message),
simulated: z.boolean().default(false).optional(),
simulationScenario: z.string().optional(),
simulationComplete: z.boolean().default(false).optional(),
@ -111,90 +155,16 @@ export const ChatClientId = z.object({
export type WithStringId<T> = T & { _id: string };
export function convertToCoreMessages(messages: z.infer<typeof apiV1.ChatMessage>[]): CoreMessage[] {
// convert to core messages
const coreMessages: CoreMessage[] = [];
for (const m of messages) {
switch (m.role) {
case 'system':
coreMessages.push({
role: 'system',
content: m.content,
});
break;
case 'user':
coreMessages.push({
role: 'user',
content: m.content,
});
break;
case 'assistant':
if ('tool_calls' in m) {
const toolCallParts: ToolCallPart[] = m.tool_calls.map((toolCall) => ({
type: 'tool-call',
toolCallId: toolCall.id,
toolName: toolCall.function.name,
args: JSON.parse(toolCall.function.arguments),
}));
if (m.content) {
coreMessages.push({
role: 'assistant',
content: [
{
type: 'text',
text: m.content,
},
...toolCallParts,
]
});
} else {
coreMessages.push({
role: 'assistant',
content: toolCallParts,
});
}
} else {
coreMessages.push({
role: 'assistant',
content: m.content,
});
}
break;
case 'tool':
coreMessages.push({
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: m.tool_call_id,
toolName: m.tool_name,
result: JSON.parse(m.content),
}
]
});
break;
}
}
return coreMessages;
}
export const ApiMessage = z.union([
apiV1.SystemMessage,
apiV1.UserMessage,
apiV1.AssistantMessage,
apiV1.AssistantMessageWithToolCalls,
apiV1.ToolMessage,
]);
export const ApiRequest = z.object({
messages: z.array(ApiMessage),
messages: z.array(Message),
state: z.unknown(),
workflowId: z.string().nullable().optional(),
testProfileId: z.string().nullable().optional(),
mockTools: z.record(z.string(), z.string()).nullable().optional(),
});
export const ApiResponse = z.object({
messages: z.array(ApiMessage),
messages: z.array(Message),
state: z.unknown(),
});

View file

@ -1,5 +1,6 @@
import { z } from 'zod';
import { WithId } from 'mongodb';
import { Message } from './types';
export const TwilioConfigParams = z.object({
phone_number: z.string(),
@ -15,9 +16,6 @@ export const TwilioConfig = TwilioConfigParams.extend({
status: z.enum(['active', 'deleted']),
});
export type TwilioConfigParams = z.infer<typeof TwilioConfigParams>;
export type TwilioConfig = WithId<z.infer<typeof TwilioConfig>>;
export interface TwilioConfigResponse {
success: boolean;
error?: string;
@ -29,4 +27,15 @@ export interface InboundConfigResponse {
workflow_id: string;
previous_webhook?: string;
error?: string;
}
}
export const TwilioInboundCall = z.object({
callSid: z.string(),
to: z.string(),
from: z.string(),
projectId: z.string(),
workflowId: z.string(),
messages: z.array(Message),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime().optional(),
})

View file

@ -2,10 +2,10 @@ import { z } from "zod";
export const WorkflowAgent = z.object({
name: z.string(),
order: z.number().int().optional(),
type: z.union([
z.literal('conversation'),
z.literal('post_process'),
z.literal('escalation'),
type: z.enum([
'conversation',
'post_process',
'escalation',
]),
description: z.string(),
disabled: z.boolean().default(false).optional(),
@ -16,18 +16,22 @@ export const WorkflowAgent = z.object({
toggleAble: z.boolean().default(true).describe('Whether this agent can be enabled or disabled').optional(),
global: z.boolean().default(false).describe('Whether this agent is a global agent, in which case it cannot be connected to other agents').optional(),
ragDataSources: z.array(z.string()).optional(),
ragReturnType: z.union([z.literal('chunks'), z.literal('content')]).default('chunks'),
ragReturnType: z.enum(['chunks', 'content']).default('chunks'),
ragK: z.number().default(3),
outputVisibility: z.union([z.literal('user_facing'), z.literal('internal')]).default('user_facing').optional(),
controlType: z.union([z.literal('retain'), z.literal('relinquish_to_parent'), z.literal('relinquish_to_start')]).default('retain').describe('Whether this agent retains control after a turn, relinquishes to the parent agent, or relinquishes to the start agent'),
outputVisibility: z.enum(['user_facing', 'internal']).default('user_facing').optional(),
controlType: z.enum([
'retain',
'relinquish_to_parent',
'relinquish_to_start',
]).default('retain').describe('Whether this agent retains control after a turn, relinquishes to the parent agent, or relinquishes to the start agent'),
maxCallsPerParentAgent: z.number().default(3).describe('Maximum number of times this agent can be called by a parent agent in a single turn').optional(),
});
export const WorkflowPrompt = z.object({
name: z.string(),
type: z.union([
z.literal('base_prompt'),
z.literal('style_prompt'),
z.literal('greeting'),
type: z.enum([
'base_prompt',
'style_prompt',
'greeting',
]),
prompt: z.string(),
});
@ -39,34 +43,22 @@ export const WorkflowTool = z.object({
mockInstructions: z.string().optional(),
parameters: z.object({
type: z.literal('object'),
properties: z.record(z.object({
type: z.string(),
description: z.string(),
enum: z.array(z.any()).optional(),
default: z.any().optional(),
minimum: z.number().optional(),
maximum: z.number().optional(),
items: z.any().optional(), // For array types
format: z.string().optional(),
pattern: z.string().optional(),
minLength: z.number().optional(),
maxLength: z.number().optional(),
minItems: z.number().optional(),
maxItems: z.number().optional(),
uniqueItems: z.boolean().optional(),
multipleOf: z.number().optional(),
examples: z.array(z.any()).optional(),
})),
required: z.array(z.string()).default([]),
}).default({
type: 'object',
properties: {},
required: [],
properties: z.record(z.string(), z.any()),
required: z.array(z.string()).optional(),
additionalProperties: z.boolean().optional(),
}),
isMcp: z.boolean().default(false).optional(),
isLibrary: z.boolean().default(false).optional(),
mcpServerName: z.string().optional(),
mcpServerURL: z.string().optional(),
isComposio: z.boolean().optional(), // whether this is a Composio tool
composioData: z.object({
slug: z.string(), // the slug for the Composio tool e.g. "GITHUB_CREATE_AN_ISSUE"
noAuth: z.boolean(), // whether the tool requires no authentication
toolkitName: z.string(), // the name for the Composio toolkit e.g. "GITHUB"
toolkitSlug: z.string(), // the slug for the Composio toolkit e.g. "GITHUB"
logo: z.string(), // the logo for the Composio tool
}).optional(), // the data for the Composio tool, if it is a Composio tool
});
export const Workflow = z.object({
name: z.string().optional(),
@ -77,6 +69,7 @@ export const Workflow = z.object({
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
projectId: z.string(),
mockTools: z.record(z.string(), z.string()).optional(), // a dict of toolName => mockInstructions
});
export const WorkflowTemplate = Workflow
.omit({
@ -90,7 +83,7 @@ export const WorkflowTemplate = Workflow
});
export const ConnectedEntity = z.object({
type: z.union([z.literal('tool'), z.literal('prompt'), z.literal('agent')]),
type: z.enum(['tool', 'prompt', 'agent']),
name: z.string(),
});

View file

@ -1,54 +1,29 @@
import { AgenticAPIChatResponse, AgenticAPIChatRequest, AgenticAPIChatMessage, AgenticAPIInitStreamResponse } from "./types/agents_api_types";
import { z } from "zod";
import { generateObject } from "ai";
import { ApiMessage } from "./types/types";
import { openai } from "@ai-sdk/openai";
import { redisClient } from "./redis";
export async function getAgenticApiResponse(
request: z.infer<typeof AgenticAPIChatRequest>,
): Promise<{
messages: z.infer<typeof AgenticAPIChatMessage>[],
state: unknown,
rawAPIResponse: unknown,
}> {
// call agentic api
console.log(`sending agentic api request`, JSON.stringify(request));
const response = await fetch(process.env.AGENTS_API_URL + '/chat', {
method: 'POST',
body: JSON.stringify(request),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.AGENTS_API_KEY || 'test'}`,
},
});
if (!response.ok) {
console.error('Failed to call agentic api', response);
throw new Error(`Failed to call agentic api: ${response.statusText}`);
}
const responseJson = await response.json();
console.log(`received agentic api response`, JSON.stringify(responseJson));
const result: z.infer<typeof AgenticAPIChatResponse> = responseJson;
return {
messages: result.messages,
state: result.state,
rawAPIResponse: result,
};
}
import { Workflow, WorkflowTool } from "./types/workflow_types";
import { Message } from "./types/types";
export async function getAgenticResponseStreamId(
request: z.infer<typeof AgenticAPIChatRequest>,
): Promise<z.infer<typeof AgenticAPIInitStreamResponse>> {
workflow: z.infer<typeof Workflow>,
projectTools: z.infer<typeof WorkflowTool>[],
messages: z.infer<typeof Message>[],
): Promise<{
streamId: string,
}> {
// serialize the request
const payload = JSON.stringify(request);
const payload = JSON.stringify({
workflow,
projectTools,
messages,
});
// create a uuid for the stream
const streamId = crypto.randomUUID();
// store payload in redis
await redisClient.set(`chat-stream-${streamId}`, payload, {
EX: 60 * 10, // expire in 10 minutes
});
await redisClient.set(`chat-stream-${streamId}`, payload, 'EX', 60 * 10); // expire in 10 minutes
return {
streamId,
@ -82,7 +57,7 @@ export class PrefixLogger {
}
}
export async function mockToolResponse(toolId: string, messages: z.infer<typeof ApiMessage>[], mockInstructions: string): Promise<string> {
export async function mockToolResponse(toolId: string, messages: z.infer<typeof Message>[], mockInstructions: string): Promise<string> {
const prompt = `Given below is a chat between a user and a customer support assistant.
The assistant has requested a tool call with ID {{toolID}}.

View file

@ -1,3 +1,4 @@
'use client';
import { Spinner } from "@heroui/react";
export default function Loading() {

View file

@ -1,7 +1,7 @@
'use client';
import { Metadata } from "next";
import { Spinner, Textarea, Button, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider } from "@heroui/react";
import { Spinner, Textarea, Button, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider, Tab, Tabs } from "@heroui/react";
import { ReactNode, useEffect, useState } from "react";
import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../actions/project_actions";
import { CopyButton } from "../../../../components/common/copy-button";
@ -15,6 +15,7 @@ import { Label } from "../../../lib/components/label";
import { FormSection } from "../../../lib/components/form-section";
import { Panel } from "@/components/common/panel-common";
import { ProjectSection } from './components/project';
import { VoiceSection } from "./components/voice";
export const metadata: Metadata = {
title: "Project config",
@ -27,8 +28,8 @@ export function Section({
title: string;
children: React.ReactNode;
}) {
return <div className="w-full flex flex-col gap-4 border border-border p-4 rounded-md">
<h2 className="font-semibold pb-2 border-b border-border">{title}</h2>
return <div className="w-full flex flex-col gap-4 border border p-4 rounded-md">
<h2 className="font-semibold pb-2 border-b border">{title}</h2>
{children}
</div>;
}
@ -198,9 +199,9 @@ export function ApiKeysSection({
<Divider />
{loading && <Spinner size="sm" />}
{!loading && <div className="border border-border rounded-lg text-sm">
<div className="flex items-center border-b border-border p-4">
<div className="flex-[3] font-normal">API Key</div>
{!loading && <div className="border border rounded-lg text-sm">
<div className="flex items-center border-b border p-4">
<div className="flex-3 font-normal">API Key</div>
<div className="flex-1 font-normal">Created</div>
<div className="flex-1 font-normal">Last Used</div>
<div className="w-10"></div>
@ -216,8 +217,8 @@ export function ApiKeysSection({
</div>}
<div className="flex flex-col">
{keys.map((key) => (
<div key={key._id} className="flex items-start border-b border-border last:border-b-0 p-4">
<div className="flex-[3] p-2">
<div key={key._id} className="flex items-start border-b border last:border-b-0 p-4">
<div className="flex-3 p-2">
<ApiKeyDisplay apiKey={key.key} />
</div>
<div className="flex-1 p-2">
@ -446,7 +447,7 @@ export function DeleteProjectSection({
const [projectName, setProjectName] = useState("");
const [projectNameInput, setProjectNameInput] = useState("");
const [confirmationInput, setConfirmationInput] = useState("");
const isValid = projectNameInput === projectName && confirmationInput === "delete project";
useEffect(() => {
@ -473,8 +474,8 @@ export function DeleteProjectSection({
This action cannot be undone.
</p>
<div>
<Button
color="danger"
<Button
color="danger"
size="sm"
onPress={onOpen}
isDisabled={loading}
@ -510,8 +511,8 @@ export function DeleteProjectSection({
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button
color="danger"
<Button
color="danger"
onPress={handleDelete}
isDisabled={!isValid}
>
@ -565,25 +566,39 @@ export function ConfigApp({
useChatWidget: boolean;
chatWidgetHost: string;
}) {
const [selected, setSelected] = useState("general");
return (
<div className="h-full overflow-auto p-6">
<Panel
variant="projects"
title={
<div className="font-semibold text-zinc-700 dark:text-zinc-300 flex items-center gap-2">
<Settings className="w-4 h-4" />
<span>Project Settings</span>
</div>
}
<Tabs
selectedKey={selected}
onSelectionChange={(key) => setSelected(key.toString())}
fullWidth
>
<div className="space-y-6">
<ProjectSection
projectId={projectId}
useChatWidget={useChatWidget}
chatWidgetHost={chatWidgetHost}
/>
</div>
</Panel>
<Tab
key="general"
title="Project settings"
>
<Panel title="Project settings">
<ProjectSection
projectId={projectId}
useChatWidget={useChatWidget}
chatWidgetHost={chatWidgetHost}
/>
</Panel>
</Tab>
<Tab
key="twilio"
title="Twilio"
>
<Panel title="Twilio settings">
<VoiceSection
projectId={projectId}
/>
</Panel>
</Tab>
</Tabs>
</div>
);
}

View file

@ -1,4 +1,4 @@
export const sectionHeaderStyles = "text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2";
export const sectionHeaderStyles = "block text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2";
export const sectionDescriptionStyles = "text-sm text-gray-500 dark:text-gray-400 mb-4";
export 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";
export const inputStyles = "rounded-lg px-3 py-2 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";

View file

@ -9,6 +9,8 @@ import { TwilioConfig } from "../../../../lib/types/voice_types";
import { CheckCircleIcon, XCircleIcon, InfoIcon, EyeOffIcon, EyeIcon } from "lucide-react";
import { Section } from './project';
import { clsx } from 'clsx';
import { WithStringId } from "../../../../lib/types/types";
import { z } from 'zod';
function PhoneNumberSection({
value,
@ -149,7 +151,7 @@ export function VoiceSection({ projectId }: { projectId: string }) {
authToken: '',
label: ''
});
const [existingConfig, setExistingConfig] = useState<TwilioConfig | null>(null);
const [existingConfig, setExistingConfig] = useState<WithStringId<z.infer<typeof TwilioConfig>> | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
@ -207,7 +209,7 @@ export function VoiceSection({ projectId }: { projectId: string }) {
setError(null);
const configParams = {
phone_number: formState.phone,
phone_number: formState.phone.replaceAll(/[^0-9\+]/g, ''),
account_sid: formState.accountSid,
auth_token: formState.authToken,
label: formState.label,

View file

@ -7,13 +7,14 @@ export const metadata: Metadata = {
title: "Project config",
};
export default async function Page({
params,
}: {
params: {
projectId: string;
};
}) {
export default async function Page(
props: {
params: Promise<{
projectId: string;
}>;
}
) {
const params = await props.params;
await requireActiveBillingSubscription();
return <App
projectId={params.projectId}

View file

@ -14,6 +14,7 @@ import { Messages } from "./components/messages";
import { CopyIcon, CheckIcon, PlusIcon, XIcon, InfoIcon } from "lucide-react";
import { useCopilot } from "./use-copilot";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
import { WithStringId } from "@/app/lib/types/types";
const CopilotContext = createContext<{
workflow: z.infer<typeof Workflow> | null;
@ -32,7 +33,7 @@ interface AppProps {
onCopyJson?: (data: { messages: any[] }) => void;
onMessagesChange?: (messages: z.infer<typeof CopilotMessage>[]) => void;
isInitialState?: boolean;
dataSources?: z.infer<typeof DataSource>[];
dataSources?: WithStringId<z.infer<typeof DataSource>>[];
}
const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message: string) => void }, AppProps>(function App({
@ -51,13 +52,20 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
const workflowRef = useRef(workflow);
const startRef = useRef<any>(null);
const cancelRef = useRef<any>(null);
const [statusBar, setStatusBar] = useState<any>(null);
// Always use effectiveContext for the user's current selection
const effectiveContext = discardContext ? null : chatContext;
// Context locking state
const [lockedContext, setLockedContext] = useState<any>(effectiveContext);
const [pendingContext, setPendingContext] = useState<any>(effectiveContext);
const [isStreaming, setIsStreaming] = useState(false);
// Keep workflow ref up to date
workflowRef.current = workflow;
// Get the effective context based on user preference
const effectiveContext = discardContext ? null : chatContext;
// Copilot streaming state
const {
streamingResponse,
loading: loadingResponse,
@ -100,13 +108,16 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
setDiscardContext(false);
}, [chatContext]);
function handleUserMessage(prompt: string) {
// Memoized handleUserMessage for useImperativeHandle and hooks
const handleUserMessage = useCallback((prompt: string) => {
// Before starting streaming, lock the context to the current pendingContext
setLockedContext(pendingContext);
setMessages(currentMessages => [...currentMessages, {
role: 'user',
content: prompt
}]);
setIsLastInteracted(true);
}
}, [setMessages, setIsLastInteracted, pendingContext, setLockedContext]);
// Effect for getting copilot response
useEffect(() => {
@ -132,6 +143,34 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
return () => currentCancel();
}, [messages, responseError]);
// --- CONTEXT LOCKING LOGIC ---
// Always update pendingContext to the latest effectiveContext
useEffect(() => {
setPendingContext(effectiveContext);
}, [effectiveContext]);
// Lock/unlock context based on streaming state
useEffect(() => {
if (loadingResponse) {
// Streaming started: lock context to the value at the start
setIsStreaming(true);
setLockedContext((prev: any) => prev ?? pendingContext); // lock to previous if already set, else to pending
} else {
// Streaming ended: update lockedContext to the last pendingContext
setIsStreaming(false);
setLockedContext(pendingContext);
}
}, [loadingResponse, pendingContext]);
// After streaming ends, update lockedContext live as effectiveContext changes
useEffect(() => {
if (!isStreaming) {
setLockedContext(effectiveContext);
}
// If streaming, do not update lockedContext
}, [effectiveContext, isStreaming]);
// --- END CONTEXT LOCKING LOGIC ---
const handleCopyChat = useCallback(() => {
if (onCopyJson) {
onCopyJson({
@ -143,7 +182,23 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
useImperativeHandle(ref, () => ({
handleCopyChat,
handleUserMessage
}), [handleCopyChat]);
}), [handleCopyChat, handleUserMessage]);
// Memoized status bar change handler to prevent infinite update loop
const handleStatusBarChange = useCallback((status: any) => {
setStatusBar((prev: any) => {
// Shallow compare previous and next status
const next = { ...status, context: lockedContext };
const keys = Object.keys(next);
if (
prev &&
keys.every(key => prev[key] === next[key])
) {
return prev;
}
return next;
});
}, [lockedContext]);
return (
<CopilotContext.Provider value={{ workflow: workflowRef.current, dispatch }}>
@ -155,6 +210,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
loadingResponse={loadingResponse}
workflow={workflowRef.current}
dispatch={dispatch}
onStatusBarChange={handleStatusBarChange}
/>
</div>
<div className="shrink-0 px-1 pb-6">
@ -180,22 +236,6 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
</Button>
</div>
)}
{effectiveContext && <div className="flex items-start mb-2">
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 text-sm px-2 py-1 rounded-sm shadow-sm">
<div>
{effectiveContext.type === 'chat' && "Chat"}
{effectiveContext.type === 'agent' && `Agent: ${effectiveContext.name}`}
{effectiveContext.type === 'tool' && `Tool: ${effectiveContext.name}`}
{effectiveContext.type === 'prompt' && `Prompt: ${effectiveContext.name}`}
</div>
<button
className="text-gray-500 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300"
onClick={() => setDiscardContext(true)}
>
<XIcon size={16} />
</button>
</div>
</div>}
<ComposeBoxCopilot
handleUserMessage={handleUserMessage}
messages={messages}
@ -204,6 +244,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
shouldAutoFocus={isLastInteracted}
onFocus={() => setIsLastInteracted(true)}
onCancel={cancel}
statusBar={statusBar || { context: lockedContext }}
/>
</div>
</div>
@ -224,7 +265,7 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
chatContext?: z.infer<typeof CopilotChatContext>;
dispatch: (action: WorkflowDispatch) => void;
isInitialState?: boolean;
dataSources?: z.infer<typeof DataSource>[];
dataSources?: WithStringId<z.infer<typeof DataSource>>[];
}>(({
projectId,
workflow,
@ -272,10 +313,10 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
title={
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
COPILOT
<div className="font-semibold text-zinc-700 dark:text-zinc-300">
Skipper
</div>
<Tooltip content="Ask copilot to help you build and modify your workflow">
<Tooltip content="A copilot to help you build and modify your workflow">
<InfoIcon className="w-4 h-4 text-gray-400 cursor-help" />
</Tooltip>
</div>

View file

@ -25,6 +25,9 @@ export function Action({
workflow,
dispatch,
stale,
onApplied,
externallyApplied = false,
defaultExpanded = false,
}: {
msgIndex: number;
actionIndex: number;
@ -32,8 +35,12 @@ export function Action({
workflow: z.infer<typeof Workflow>;
dispatch: (action: any) => void;
stale: boolean;
onApplied?: () => void;
externallyApplied?: boolean;
defaultExpanded?: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const { showPreview } = usePreviewModal();
const [expanded, setExpanded] = useState(defaultExpanded);
const [appliedChanges, setAppliedChanges] = useState<Record<string, boolean>>({});
if (!action || typeof action !== 'object') {
@ -44,7 +51,7 @@ export function Action({
const appliedFields = Object.keys(action.config_changes).filter(key =>
appliedChanges[getAppliedChangeKey(msgIndex, actionIndex, key)]
);
const allApplied = Object.keys(action.config_changes).every(key =>
const allApplied = externallyApplied || Object.keys(action.config_changes).every(key =>
appliedFields.includes(key)
);
@ -76,10 +83,24 @@ export function Action({
break;
}
setAppliedChanges(prev => ({
...prev,
[getAppliedChangeKey(msgIndex, actionIndex, field)]: true
}));
setAppliedChanges(prev => {
const newApplied = {
...prev,
[getAppliedChangeKey(msgIndex, actionIndex, field)]: true
};
// Check if all fields are now applied
const allFieldsApplied = Object.keys(action.config_changes).every(key =>
newApplied[getAppliedChangeKey(msgIndex, actionIndex, key)]
);
// If all fields are applied, notify parent
if (allFieldsApplied) {
onApplied?.();
}
return newApplied;
});
};
// Handle applying all changes
@ -149,56 +170,100 @@ export function Action({
...prev,
...appliedKeys
}));
// Notify parent that this action has been applied
onApplied?.();
};
return <div className={clsx('flex flex-col rounded-sm border border-t-4', {
'bg-gray-50 dark:bg-gray-800/50 border-gray-400 dark:border-gray-600 border-t-blue-500 shadow': !stale && !allApplied && action.action == 'create_new',
'bg-gray-50 dark:bg-gray-800/50 border-gray-400 dark:border-gray-600 border-t-orange-500 shadow': !stale && !allApplied && action.action == 'edit',
'bg-gray-100 dark:bg-gray-800/30 border-gray-400 dark:border-gray-600 border-t-gray-400': stale || allApplied || action.error,
})}>
// Helper to get the main field for diff
function getMainDiffField() {
if (action.config_type === 'agent' && 'instructions' in action.config_changes) return 'instructions';
if (action.config_type === 'tool' && 'description' in action.config_changes) return 'description';
if (action.config_type === 'prompt' && 'prompt' in action.config_changes) return 'prompt';
// fallback: first field
return Object.keys(action.config_changes)[0];
}
function handleViewDiff() {
const field = getMainDiffField();
if (!field) return;
const newValue = action.config_changes[field];
let oldValue = undefined;
if (action.action === 'edit') {
if (action.config_type === 'tool') {
const tool = workflow.tools.find(t => t.name === action.name);
if (tool) oldValue = (tool as any)[field];
} else if (action.config_type === 'agent') {
const agent = workflow.agents.find(a => a.name === action.name);
if (agent) oldValue = (agent as any)[field];
} else if (action.config_type === 'prompt') {
const prompt = workflow.prompts.find(p => p.name === action.name);
if (prompt) oldValue = (prompt as any)[field];
}
}
const markdown = (action.config_type === 'agent' && field === 'instructions') ||
(action.config_type === 'prompt' && field === 'prompt');
showPreview(
oldValue ? (typeof oldValue === 'string' ? oldValue : JSON.stringify(oldValue, null, 2)) : undefined,
typeof newValue === 'string' ? newValue : JSON.stringify(newValue, null, 2),
markdown,
`${action.name} - ${field}`,
'Review changes'
);
}
return <div className={clsx(
'flex flex-col rounded-md border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-xs',
'transition-shadow duration-150',
{
'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-gray-400': stale || allApplied || action.error,
}
)}>
<ActionContext.Provider value={{ msgIndex, actionIndex, action, workflow, appliedFields, stale }}>
<ActionHeader />
<ActionSummary />
{expanded && <PreviewModalProvider>
{action.error && <div className="flex flex-col gap-1 px-1 text-xs bg-red-50 dark:bg-red-900/20 rounded-sm">
<div className="text-red-500 dark:text-red-400 font-medium text-xs">This configuration is invalid and cannot be applied:</div>
<div className="text-xs font-mono dark:text-gray-300">{action.error}</div>
</div>}
<div className="flex flex-col gap-2 px-1">
{Object.entries(action.config_changes).map(([key, value]) => {
return <ActionField key={key} field={key} onApply={handleFieldChange} />
})}
</div>
</PreviewModalProvider>}
<div className="flex items-center">
{action.error && <div className="grow rounded-l-sm bg-red-100 dark:bg-red-900/20 text-red-500 dark:text-red-400 flex flex-col items-center justify-center h-8">
<div className="flex items-center gap-2 justify-center">
<AlertTriangleIcon size={16} />
<div className="font-medium text-xs">Error</div>
</div>
</div>}
{!action.error && <button
className="grow rounded-l-sm bg-blue-100 dark:bg-blue-900/20 text-blue-500 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-900/30 disabled:bg-gray-100 dark:disabled:bg-gray-800/30 disabled:text-gray-300 dark:disabled:text-gray-600 flex flex-col items-center justify-center h-8"
onClick={handleApplyAll}
disabled={stale || allApplied}
>
<div className="flex items-center gap-2 justify-center">
<CheckCheckIcon size={16} />
<div className="font-medium text-xs">{allApplied ? 'Applied' : 'Apply'}</div>
</div>
</button>}
<button
className="w-10 shrink-0 flex flex-col items-center h-8 rounded-r-sm bg-gray-100 dark:bg-gray-800/50 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 justify-center"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-center gap-2 justify-center text-gray-400 dark:text-gray-500">
{expanded ? (
<ChevronsUpIcon size={16} />
) : (
<ChevronsDownIcon size={16} />
<div className="flex items-center gap-2 px-2 py-1 border-b border-zinc-100 dark:border-zinc-800">
{/* Small colored icon for type */}
<span className={clsx(
'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-orange-100 text-orange-600': action.action == 'edit',
'bg-gray-200 text-gray-600': stale || allApplied || action.error,
}
)}>
{action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : '💬'}
</span>
<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}
</span>
{/* Action buttons - compact, icon only, show text on hover */}
<div className="flex items-center gap-1">
<button
className={clsx(
'flex items-center gap-1 rounded-full px-2 h-7 text-xs font-medium transition-colors bg-transparent',
allApplied
? 'text-zinc-400 cursor-not-allowed'
: 'text-green-600 hover:text-green-700'
)}
</div>
</button>
disabled={allApplied}
onClick={() => handleApplyAll()}
>
<CheckIcon size={13} className={allApplied ? 'text-zinc-400' : 'text-green-600 group-hover:text-green-700'} />
<span>{allApplied ? 'Applied' : 'Apply'}</span>
</button>
<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"
onClick={handleViewDiff}
>
<EyeIcon size={13} className="text-indigo-600 group-hover:text-indigo-700" />
<span>View Diff</span>
</button>
</div>
</div>
{/* Description of what happened */}
<div className="px-3 py-2 text-xs text-zinc-700 dark:text-zinc-200">
{action.change_description || 'No description provided.'}
</div>
</ActionContext.Provider>
</div>;
@ -251,19 +316,19 @@ export function ActionField({
// Find the tool in the workflow
const tool = workflow.tools.find(t => t.name === action.name);
if (tool) {
oldValue = tool[field as keyof typeof tool];
oldValue = (tool as any)[field];
}
} else if (action.config_type === 'agent') {
// Find the agent in the workflow
const agent = workflow.agents.find(a => a.name === action.name);
if (agent) {
oldValue = agent[field as keyof typeof agent];
oldValue = (agent as any)[field];
}
} else if (action.config_type === 'prompt') {
// Find the prompt in the workflow
const prompt = workflow.prompts.find(p => p.name === action.name);
if (prompt) {
oldValue = prompt[field as keyof typeof prompt];
oldValue = (prompt as any)[field];
}
}
}
@ -333,23 +398,38 @@ export function StreamingAction({
};
loading: boolean;
}) {
return <div className={clsx('flex flex-col rounded-sm border border-t-4', {
'bg-gray-50 dark:bg-gray-800/50 border-gray-400 dark:border-gray-600 border-t-blue-500 shadow': action.action == 'create_new',
'bg-gray-50 dark:bg-gray-800/50 border-gray-400 dark:border-gray-600 border-t-orange-500 shadow': action.action == 'edit',
})}>
<div className="flex gap-2 items-center py-1 px-1">
{action.action == 'create_new' && <PlusIcon size={16} />}
{action.action == 'edit' && <PencilIcon size={16} />}
<div className="text-sm truncate">
{action.config_type && `${action.action === 'create_new' ? 'Create' : 'Edit'} ${action.config_type}`}
{action.name && <span className="font-medium ml-1">{action.name}</span>}
// Use the same card container and header style as Action
return (
<div className={clsx(
'flex flex-col rounded-md border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-xs',
'transition-shadow duration-150',
{
'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-gray-400': !action.action,
}
)}>
<div className="flex items-center gap-2 px-2 py-1 border-b border-zinc-100 dark:border-zinc-800">
{/* Small colored icon for type */}
<span className={clsx(
'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-orange-100 text-orange-600': action.action == 'edit',
'bg-gray-200 text-gray-600': !action.action,
}
)}>
{action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : '💬'}
</span>
<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}
</span>
</div>
{/* Loading state body */}
<div className="px-3 py-4 text-xs text-zinc-500 dark:text-zinc-400 flex items-center gap-2 min-h-[32px]">
<Spinner size="sm" />
<span>Loading...</span>
</div>
</div>
<div className="px-1 my-1">
<div className="bg-white dark:bg-gray-800 rounded-sm p-2 text-sm flex items-center gap-2">
{loading && <Spinner size="sm" />}
{!loading && <div className="text-gray-400">Canceled</div>}
</div>
</div>
</div>;
);
}

View file

@ -1,14 +1,15 @@
'use client';
import { Spinner } from "@heroui/react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, useCallback } from "react";
import { z } from "zod";
import { Workflow, WorkflowTool, WorkflowAgent, WorkflowPrompt } from "@/app/lib/types/workflow_types";
import { Workflow} from "@/app/lib/types/workflow_types";
import MarkdownContent from "@/app/lib/components/markdown-content";
import { MessageSquareIcon, EllipsisIcon, XIcon } from "lucide-react";
import { MessageSquareIcon, EllipsisIcon, XIcon, CheckCheckIcon, ChevronDown, ChevronUp } from "lucide-react";
import { CopilotMessage, CopilotAssistantMessage, CopilotAssistantMessageActionPart } from "@/app/lib/types/copilot_types";
import { Action, StreamingAction } from './actions';
import { useParsedBlocks } from "../use-parsed-blocks";
import { validateConfigChanges } from "@/app/lib/client_utils";
import { PreviewModalProvider } from '../../workflow/preview-modal';
const CopilotResponsePart = z.union([
z.object({
@ -152,21 +153,45 @@ function InternalAssistantMessage({ content }: { content: string }) {
);
}
type ActionPanelBlock = {
part: {
type: 'action';
action: any;
} | {
type: 'streaming_action';
action: any;
};
actionIndex: number;
};
/**
* AssistantMessage component that renders copilot responses with action cards.
*
* Features:
* - Renders text content with markdown support
* - Displays individual action cards for workflow changes
* - Shows "Apply All" button when there are action cards
* - Supports streaming responses with real-time apply all functionality
* - Action cards are in a collapsible panel with a ticker summary in collapsed state
*/
function AssistantMessage({
content,
workflow,
dispatch,
messageIndex,
loading
loading,
onStatusBarChange
}: {
content: z.infer<typeof CopilotAssistantMessage>['content'],
workflow: z.infer<typeof Workflow>,
dispatch: (action: any) => void,
messageIndex: number,
loading: boolean
loading: boolean,
onStatusBarChange?: (status: any) => void
}) {
const blocks = useParsedBlocks(content);
const [appliedActions, setAppliedActions] = useState<Set<number>>(new Set());
// Remove autoApplyEnabled and useEffect for auto-apply
// parse actions from parts
let parsed: z.infer<typeof CopilotResponsePart>[] = [];
@ -181,39 +206,290 @@ function AssistantMessage({
}
}
// split the content into parts
// Only render text outside the panel
const textBlocks = parsed.filter(part => part.type === 'text');
// All cards (action and streaming_action) go inside the panel
const cardBlocks: ActionPanelBlock[] = parsed
.map((part, actionIndex) => ({ part, actionIndex }))
.filter(({ part }) => part.type === 'action' || part.type === 'streaming_action') as ActionPanelBlock[];
const hasCards = cardBlocks.length > 0;
const totalActions = cardBlocks.filter(({ part }) => part.type === 'action').length;
const appliedCount = Array.from(appliedActions).length;
const pendingCount = Math.max(0, totalActions - appliedCount);
const allApplied = pendingCount === 0 && totalActions > 0;
// Memoized applyAction for useCallback dependencies
const applyAction = useCallback((action: any, actionIndex: number) => {
// Only apply, do not update appliedActions here
if (action.action === 'create_new') {
switch (action.config_type) {
case 'agent': {
// Prevent duplicate agent names
if (workflow.agents.some((agent: any) => agent.name === action.name)) {
return;
}
dispatch({
type: 'add_agent',
agent: {
name: action.name,
...action.config_changes
}
});
break;
}
case 'tool': {
// Prevent duplicate tool names
if (workflow.tools.some((tool: any) => tool.name === action.name)) {
return;
}
dispatch({
type: 'add_tool',
tool: {
name: action.name,
...action.config_changes
}
});
break;
}
case 'prompt':
dispatch({
type: 'add_prompt',
prompt: {
name: action.name,
...action.config_changes
}
});
break;
}
} else if (action.action === 'edit') {
switch (action.config_type) {
case 'agent':
dispatch({
type: 'update_agent',
name: action.name,
agent: action.config_changes
});
break;
case 'tool':
dispatch({
type: 'update_tool',
name: action.name,
tool: action.config_changes
});
break;
case 'prompt':
dispatch({
type: 'update_prompt',
name: action.name,
prompt: action.config_changes
});
break;
}
}
}, [dispatch, workflow.agents, workflow.tools]);
// Memoized handleApplyAll for useEffect dependencies
const handleApplyAll = useCallback(() => {
// Find all unapplied action indices
const unapplied = cardBlocks
.filter(({ part, actionIndex }) => part.type === 'action' && !appliedActions.has(actionIndex))
.map(({ part, actionIndex }) => ({ action: part.action, actionIndex }));
// Synchronously apply all unapplied actions
unapplied.forEach(({ action, actionIndex }) => {
applyAction(action, actionIndex);
});
// After all are applied, update the state in one go
setAppliedActions(prev => {
const next = new Set(prev);
unapplied.forEach(({ actionIndex }) => next.add(actionIndex));
return next;
});
}, [cardBlocks, appliedActions, setAppliedActions, applyAction]);
// Manual single apply (from card)
const handleSingleApply = (action: any, actionIndex: number) => {
if (!appliedActions.has(actionIndex)) {
applyAction(action, actionIndex);
setAppliedActions(prev => new Set([...prev, actionIndex]));
}
};
useEffect(() => {
if (loading) {
// setAutoApplyEnabled(false); // Removed
setAppliedActions(new Set());
// setPanelOpen(false); // Removed
}
}, [loading]);
// Removed useEffect for auto-apply
// Find streaming/ongoing card and extract name
const streamingBlock = cardBlocks.find(({ part }) => part.type === 'streaming_action');
let streamingLine = '';
if (streamingBlock && streamingBlock.part.type === 'streaming_action' && streamingBlock.part.action && streamingBlock.part.action.name) {
streamingLine = `Generating ${streamingBlock.part.action.name}...`;
}
// Find the first card index
const firstCardIdx = parsed.findIndex(part => part.type === 'action' || part.type === 'streaming_action');
// Group blocks into: beforePanel, cardBlocks, afterPanel
const beforePanel = firstCardIdx === -1 ? parsed : parsed.slice(0, firstCardIdx);
const panelBlocks = firstCardIdx === -1 ? [] : parsed.slice(firstCardIdx).filter(part => part.type === 'action' || part.type === 'streaming_action');
// Find where the card blocks end (first non-card after first card)
let afterPanelStart = firstCardIdx;
if (firstCardIdx !== -1) {
for (let i = firstCardIdx; i < parsed.length; i++) {
if (parsed[i].type !== 'action' && parsed[i].type !== 'streaming_action') {
afterPanelStart = i;
break;
}
}
}
const afterPanel = (firstCardIdx !== -1 && afterPanelStart > firstCardIdx) ? parsed.slice(afterPanelStart) : [];
// Only show Apply All button if all cards are loaded (no streaming_action cards) and streaming is finished
const allCardsLoaded = !loading && panelBlocks.length > 0 && panelBlocks.every(part => part.type === 'action');
// When all cards are loaded, show summary of agents created/updated
let completedSummary = '';
if (allCardsLoaded && totalActions > 0) {
// Count how many are create vs edit
const createCount = cardBlocks.filter(({ part }) => part.type === 'action' && part.action.action === 'create_new').length;
const editCount = cardBlocks.filter(({ part }) => part.type === 'action' && part.action.action === 'edit').length;
const parts = [];
if (createCount > 0) parts.push(`${createCount} agent${createCount > 1 ? 's' : ''} created`);
if (editCount > 0) parts.push(`${editCount} agent${editCount > 1 ? 's' : ''} updated`);
completedSummary = parts.join(', ');
}
// Detect if any card has an error or is cancelled
const hasPanelWarning = cardBlocks.some(
({ part }) =>
part.type === 'action' &&
part.action &&
(part.action.error || ('cancelled' in part.action && part.action.cancelled))
);
// Ticker summary for collapsed state (two lines)
const ticker = (
<div className="flex flex-col">
{allCardsLoaded && completedSummary ? (
<span className="font-medium text-xs sm:text-sm">{completedSummary}</span>
) : streamingLine && (
<span className="font-medium text-xs sm:text-sm">{streamingLine}</span>
)}
<span className="font-medium text-xs sm:text-sm">{appliedCount} applied, {pendingCount} pending</span>
</div>
);
const applyAllButton = (
<button
onClick={handleApplyAll}
disabled={allApplied} // Changed to allApplied
className={`flex items-center gap-2 px-4 py-2 rounded-full font-medium text-sm transition-colors duration-200
${
allApplied
? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-400 cursor-not-allowed border border-zinc-200 dark:border-zinc-700 shadow-none'
: 'bg-blue-100 dark:bg-zinc-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-zinc-800 border border-blue-200 dark:border-zinc-800 shadow-sm'
}
`}
>
{allApplied ? (
<>
<CheckCheckIcon size={16} />
All applied!
</>
) : (
<>
<CheckCheckIcon size={16} />
Apply all
</>
)}
</button>
);
// Utility to filter out divider/empty markdown blocks
function isNonDividerMarkdown(content: string) {
const trimmed = content.trim();
return (
trimmed !== '' &&
!/^(-{3,}|_{3,}|\*{3,})$/.test(trimmed)
);
}
// Restore panelOpen state if missing
const [panelOpen, setPanelOpen] = useState(false); // collapsed by default
// At the end of the render, call onStatusBarChange with the current status bar props
// Track the latest status bar info
const latestStatusBar = useRef<any>(null);
// Only call onStatusBarChange if the serializable status actually changes
const lastStatusRef = useRef<any>(null);
useEffect(() => {
if (onStatusBarChange) {
const status = {
allCardsLoaded,
allApplied,
appliedCount,
pendingCount,
streamingLine,
completedSummary,
hasPanelWarning,
// Exclude handleApplyAll from comparison
};
if (!lastStatusRef.current || JSON.stringify(lastStatusRef.current) !== JSON.stringify(status)) {
lastStatusRef.current = status;
onStatusBarChange({
...status,
handleApplyAll, // pass the function, but don't compare it
});
}
}
// Only depend on the serializable values, not the function
}, [allCardsLoaded, allApplied, appliedCount, pendingCount, streamingLine, completedSummary, hasPanelWarning, onStatusBarChange, handleApplyAll]);
// Render all cards inline, not in a panel
return (
<div className="w-full">
<div className="px-4 py-2.5 text-sm leading-relaxed text-gray-700 dark:text-gray-200">
<div className="flex flex-col gap-4">
<div className="text-left flex flex-col gap-4">
{parsed.map((part, actionIndex) => {
if (part.type === 'text') {
return <MarkdownContent
key={actionIndex}
content={part.content}
/>;
}
if (part.type === 'streaming_action') {
return <StreamingAction
key={actionIndex}
action={part.action}
loading={loading}
/>;
}
if (part.type === 'action') {
return <Action
key={actionIndex}
<div className="flex flex-col gap-2">
<PreviewModalProvider>
{/* Render markdown and cards inline in order */}
{parsed.map((part, idx) => {
if (part.type === 'text' && isNonDividerMarkdown(part.content)) {
return <MarkdownContent key={`text-${idx}`} content={part.content} />;
}
if (part.type === 'action') {
return (
<Action
key={`action-${idx}`}
msgIndex={messageIndex}
actionIndex={actionIndex}
actionIndex={idx}
action={part.action}
workflow={workflow}
dispatch={dispatch}
stale={false}
/>;
}
})}
</div>
onApplied={() => handleSingleApply(part.action, idx)}
externallyApplied={appliedActions.has(idx)}
defaultExpanded={true}
/>
);
}
if (part.type === 'streaming_action') {
return (
<StreamingAction
key={`streaming-${idx}`}
action={part.action}
loading={loading}
/>
);
}
return null;
})}
</PreviewModalProvider>
</div>
</div>
</div>
@ -245,13 +521,15 @@ export function Messages({
streamingResponse,
loadingResponse,
workflow,
dispatch
dispatch,
onStatusBarChange
}: {
messages: z.infer<typeof CopilotMessage>[];
streamingResponse: string;
loadingResponse: boolean;
workflow: z.infer<typeof Workflow>;
dispatch: (action: any) => void;
onStatusBarChange?: (status: any) => void;
}) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const [displayMessages, setDisplayMessages] = useState(messages);
@ -280,6 +558,9 @@ export function Messages({
return () => clearTimeout(timeoutId);
}, [messages, loadingResponse]);
// Track the latest status bar info
const latestStatusBar = useRef<any>(null);
const renderMessage = (message: z.infer<typeof CopilotMessage>, messageIndex: number) => {
if (message.role === 'assistant') {
return (
@ -290,6 +571,13 @@ export function Messages({
dispatch={dispatch}
messageIndex={messageIndex}
loading={loadingResponse}
onStatusBarChange={status => {
// Only update for the last assistant message
if (messageIndex === displayMessages.length - 1) {
latestStatusBar.current = status;
onStatusBarChange?.(status);
}
}}
/>
);
}

View file

@ -4,12 +4,13 @@ import { CopilotMessage } from "@/app/lib/types/copilot_types";
import { Workflow } from "@/app/lib/types/workflow_types";
import { DataSource } from "@/app/lib/types/datasource_types";
import { z } from "zod";
import { WithStringId } from "@/app/lib/types/types";
interface UseCopilotParams {
projectId: string;
workflow: z.infer<typeof Workflow>;
context: any;
dataSources?: z.infer<typeof DataSource>[];
dataSources?: WithStringId<z.infer<typeof DataSource>>[];
}
interface UseCopilotResult {

View file

@ -0,0 +1,186 @@
"use client";
import React, { useEffect, useRef } from "react";
import mermaid from "mermaid";
import { Workflow } from "../../../lib/types/workflow_types";
function sanitizeId(name: string): string {
return name.replace(/[^a-zA-Z0-9\s_-]/g, "").replace(/[\s-]+/g, "_");
}
function generateMermaidFromWorkflow(workflow: any, isDark: boolean): string {
const startAgentName = workflow.startAgent;
const agents: any[] = workflow.agents || [];
const tools: any[] = workflow.tools || [];
// Light and dark mode colors
const toolFillLight = '#ede9fe';
const toolStrokeLight = '#a78bfa';
const toolFillDark = '#312e81';
const toolStrokeDark = '#a78bfa';
const agentFillLight = '#EBF5FB';
const agentStrokeLight = '#85C1E9';
const agentFillDark = '#1e293b';
const agentStrokeDark = '#a78bfa';
const startFillLight = '#FEF9E7';
const startStrokeLight = '#F8C471';
const startFillDark = '#92400e';
const startStrokeDark = '#f59e0b';
const entryFillLight = '#22C55E';
const entryStrokeLight = '#16A34A';
const entryFillDark = '#22c55e';
const entryStrokeDark = '#4ade80';
const textLight = '#34495E';
const textDark = '#fff';
const mermaidCode = [
"graph LR",
// Agent node style
` classDef agent fill:${isDark ? agentFillDark : agentFillLight},stroke:${isDark ? agentStrokeDark : agentStrokeLight},stroke-width:3px,color:${isDark ? textDark : textLight},font-size:16px,radius:12px`,
// Tool node style
` classDef tool fill:${isDark ? toolFillDark : toolFillLight},stroke:${toolStrokeLight},stroke-width:3px,color:${isDark ? textDark : textLight},font-size:16px,radius:12px`,
// Start agent node style
` classDef startAgent fill:${isDark ? startFillDark : startFillLight},stroke:${isDark ? startStrokeDark : startStrokeLight},stroke-width:3px,color:${isDark ? textDark : textLight},font-size:18px,radius:12px`,
// Entry node style
` classDef entry fill:${isDark ? entryFillDark : entryFillLight},stroke:${isDark ? entryStrokeDark : entryStrokeLight},stroke-width:3px,color:${isDark ? textDark : '#fff'},font-size:16px,radius:12px`
];
if (startAgentName) {
const startAgentId = sanitizeId(startAgentName);
mermaidCode.push(`\n %% -- Entry Point --`);
mermaidCode.push(` Entry([Start]) --> ${startAgentId}`);
mermaidCode.push(` class Entry entry`);
}
mermaidCode.push(`\n %% -- Agent Nodes --`);
for (const agent of agents) {
const agentName = agent.name;
const agentId = sanitizeId(agentName);
const nodeLabel = `🤖 ${agentName}`;
mermaidCode.push(` ${agentId}([\"${nodeLabel}\"])`);
if (agentName === startAgentName) {
mermaidCode.push(` class ${agentId} startAgent`);
} else {
mermaidCode.push(` class ${agentId} agent`);
}
}
// --- Tool Nodes ---
// 1. Collect all tool names from workflow.tools
const toolNamesFromArray = new Set(tools.map((tool: any) => tool.name));
// 2. Collect all tool names mentioned in agent instructions
const agentMentionPattern = /\[@agent:([^\]]+)\]\(#mention[^\)]*\)/g;
const toolMentionPattern = /\[@tool:([^\]]+)\]\(#mention[^\)]*\)/g;
const toolNamesFromMentions = new Set<string>();
for (const agent of agents) {
const instructions = agent.instructions || "";
let match: RegExpExecArray | null;
while ((match = toolMentionPattern.exec(instructions))) {
toolNamesFromMentions.add(match[1]);
}
}
// 3. Union of all tool names
const allToolNames = new Set([...toolNamesFromArray, ...toolNamesFromMentions]);
// 4. Generate tool nodes for all
mermaidCode.push(`\n %% -- Tool Nodes --`);
for (const toolName of allToolNames) {
const toolId = sanitizeId(toolName);
mermaidCode.push(` ${toolId}([\"🛠️ ${toolName}\"])`);
mermaidCode.push(` class ${toolId} tool`);
}
// --- Connections ---
mermaidCode.push(`\n %% -- Connections --`);
for (const agent of agents) {
const currentAgentId = sanitizeId(agent.name);
const instructions = agent.instructions || "";
const calledAgents = new Set<string>();
let match: RegExpExecArray | null;
while ((match = agentMentionPattern.exec(instructions))) {
calledAgents.add(match[1]);
}
for (const calledAgent of Array.from(calledAgents)) {
const calledAgentId = sanitizeId(calledAgent);
mermaidCode.push(` ${currentAgentId} -- \"delegates to\" --> ${calledAgentId}`);
}
const calledTools = new Set<string>();
while ((match = toolMentionPattern.exec(instructions))) {
calledTools.add(match[1]);
}
for (const calledTool of Array.from(calledTools)) {
const calledToolId = sanitizeId(calledTool);
mermaidCode.push(` ${currentAgentId} -- \"uses\" --> ${calledToolId}`);
}
}
return mermaidCode.join("\n");
}
function getCssVarValue(varName: string, fallback: string) {
if (typeof window === 'undefined') return fallback;
let value = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
// If the value looks like HSL (e.g. '0 0% 9%' or '0 0% 3.9%' or '0 0% 9% / 1'), wrap it in hsl()
if (/^[\d.]+\s+[\d.]+%\s+[\d.]+%(\s*\/\s*[\d.]+)?$/.test(value)) {
value = `hsl(${value})`;
}
return value || fallback;
}
export const AgentGraphVisualizer = ({ workflow }: { workflow: any }) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current && workflow) {
// Only check theme on mount/render
const isDark = document.documentElement.classList.contains('dark');
mermaid.initialize({
startOnLoad: true,
theme: isDark ? 'dark' : 'default',
themeVariables: {
background: getCssVarValue('--background', isDark ? '#18181b' : '#fff'),
primaryColor: isDark ? '#a78bfa' : getCssVarValue('--primary', '#4f46e5'),
primaryTextColor: isDark ? '#fff' : getCssVarValue('--foreground', '#18181b'),
fontSize: '20px',
nodeTextColor: isDark ? '#fff' : getCssVarValue('--foreground', '#18181b'),
edgeLabelBackground: isDark ? 'transparent' : getCssVarValue('--background', '#fff'),
clusterBkg: getCssVarValue('--background', isDark ? '#18181b' : '#fff'),
clusterBorder: isDark ? '#a78bfa' : getCssVarValue('--border', '#e5e7eb'),
lineColor: isDark ? '#a78bfa' : '#6366f1',
arrowheadColor: isDark ? '#a78bfa' : '#6366f1',
},
});
ref.current.innerHTML = generateMermaidFromWorkflow(workflow, isDark);
ref.current.className = "mermaid";
mermaid.init(undefined, ref.current);
}
}, [workflow]);
// Center the graph vertically and horizontally
return (
<div
style={{
width: "100%",
height: "100%",
minHeight: 0,
background: "var(--background)",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
overflow: "auto",
padding: "16px",
}}
>
<div
ref={ref}
style={{
width: "100%",
height: "fit-content",
minHeight: 0,
fontSize: 20,
}}
/>
</div>
);
};

View file

@ -9,7 +9,7 @@ import { useState } from "react";
import clsx from "clsx";
// Common section header styles (matching tool_config)
const sectionHeaderStyles = "text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400";
const sectionHeaderStyles = "block text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400";
// 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";

View file

@ -2,12 +2,16 @@
import { WorkflowTool } from "../../../lib/types/workflow_types";
import { Checkbox, Select, SelectItem, RadioGroup, Radio } from "@heroui/react";
import { z } from "zod";
import { ImportIcon, XIcon, PlusIcon, FolderIcon } from "lucide-react";
import { ImportIcon, XIcon, PlusIcon, FolderIcon} from "lucide-react";
import { useState, useEffect } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Panel } from "@/components/common/panel-common";
import { Button } from "@/components/ui/button";
import clsx from "clsx";
import { SectionCard } from "@/components/common/section-card";
import { ToolParamCard } from "@/components/common/tool-param-card";
import { UserIcon, Settings, Settings2 } from "lucide-react";
import { EditableField } from "@/app/lib/components/editable-field";
// Update 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";
@ -16,7 +20,7 @@ const textareaStyles = "rounded-lg p-3 border border-gray-200 dark:border-gray-7
const dividerStyles = "border-t border-gray-200 dark:border-gray-800";
// Common section header styles
const sectionHeaderStyles = "text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400";
const sectionHeaderStyles = "block text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400";
export function ParameterConfig({
param,
@ -170,7 +174,7 @@ export function ToolConfig({
});
const [selectedParams, setSelectedParams] = useState(new Set([]));
const isReadOnly = tool.isMcp || tool.isLibrary;
const isReadOnly = tool.isMcp || tool.isLibrary || tool.isComposio;
const [nameError, setNameError] = useState<string | null>(null);
// Log when parameters are being rendered
@ -256,6 +260,9 @@ export function ToolConfig({
if (value.length === 0) {
return "Name cannot be empty";
}
if (value !== tool.name && usedToolNames.has(value)) {
return "This name is already taken";
}
return null;
}
@ -297,23 +304,23 @@ export function ToolConfig({
};
return (
<Panel
<Panel
title={
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">
{tool.name}
</div>
{tool.isMcp && (
<div className="flex items-center gap-2 text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded-full text-gray-700 dark:text-gray-300">
<div className="flex items-center gap-2 text-xs bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded-full text-gray-700 dark:text-gray-300">
<ImportIcon className="w-4 h-4 text-blue-700 dark:text-blue-400" />
<span className="text-xs">MCP: {tool.mcpServerName}</span>
<span>MCP: {tool.mcpServerName}</span>
</div>
)}
{tool.isLibrary && (
<div className="flex items-center gap-2 text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded-full text-gray-700 dark:text-gray-300">
<div className="flex items-center gap-2 text-xs bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded-full text-gray-700 dark:text-gray-300">
<FolderIcon className="w-4 h-4 text-blue-700 dark:text-blue-400" />
<span className="text-xs">Library Tool</span>
<span>Library Tool</span>
</div>
)}
</div>
@ -329,114 +336,115 @@ export function ToolConfig({
</div>
}
>
<div className="flex flex-col gap-6 p-4">
{!isReadOnly && (
<div className="space-y-4">
<div className="space-y-2">
<label className={sectionHeaderStyles}>
Name
</label>
<div className={clsx(
"border rounded-lg focus-within:ring-2",
nameError
? "border-red-500 focus-within:ring-red-500/20"
: "border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
)}>
<Textarea
<div className="flex flex-col gap-4 pb-4 pt-4 p-4">
{/* Identity Section */}
<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-4">
<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">
<EditableField
value={tool.name}
useValidation={true}
updateOnBlur={true}
onChange={(value) => {
setNameError(validateToolName(value));
if (!validateToolName(value)) {
handleUpdate({
...tool,
name: value
});
}
}}
validate={(value) => {
const error = validateToolName(value);
setNameError(error);
return { valid: !error, errorMessage: error || undefined };
}}
onValidatedChange={(value) => {
handleUpdate({
...tool,
name: value
});
}}
placeholder="Enter tool name..."
className="w-full text-sm bg-transparent focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
autoResize
showSaveButton={true}
showDiscardButton={true}
error={nameError}
className="w-full"
/>
</div>
</div>
<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">Description</label>
<div className="flex-1">
<EditableField
value={tool.description || ""}
onChange={(value) => handleUpdate({ ...tool, description: value })}
multiline={true}
placeholder="Describe what this tool does..."
className="w-full"
/>
</div>
{nameError && (
<p className="text-sm text-red-500">{nameError}</p>
)}
</div>
</div>
)}
<div className="space-y-4">
<div className="space-y-2">
<label className={sectionHeaderStyles}>
Description
</label>
<Textarea
value={tool.description}
onChange={(e) => handleUpdate({
...tool,
description: e.target.value
})}
placeholder="Describe what this tool does..."
disabled={isReadOnly}
className={textareaStyles}
autoResize
/>
</div>
</div>
{!isReadOnly && (
<div className="space-y-4">
<label className={sectionHeaderStyles}>
Tool Mode
</label>
<RadioGroup
defaultValue="mock"
value={tool.mockTool ? "mock" : "api"}
onValueChange={(value) => handleUpdate({
...tool,
mockTool: value === "mock",
autoSubmitMockedResponse: value === "mock" ? true : undefined
})}
orientation="horizontal"
classNames={{
wrapper: "flex gap-12 pl-3",
label: "text-sm"
}}
>
<Radio
value="mock"
classNames={{
base: "p-0 data-[selected=true]:bg-indigo-50 dark:data-[selected=true]:bg-indigo-950/50 rounded-lg transition-colors",
label: "text-base font-normal text-gray-900 dark:text-gray-100 px-3 py-1"
}}
>
Mock tool responses
</Radio>
<Radio
value="api"
classNames={{
base: "p-0 data-[selected=true]:bg-indigo-50 dark:data-[selected=true]:bg-indigo-900/50 rounded-lg transition-colors",
label: "text-base font-normal text-gray-900 dark:text-gray-100 px-3 py-1"
}}
>
Connect tool to your API
</Radio>
</RadioGroup>
</div>
)}
{tool.mockTool && (
<div className={`space-y-4 ${dividerStyles} pt-6`}>
<div className="space-y-4">
<label className={sectionHeaderStyles}>
Mock Settings
</label>
<div className="pl-3 space-y-4">
</SectionCard>
{/* Behavior Section */}
<SectionCard
icon={<Settings className="w-5 h-5 text-indigo-500" />}
title="Behavior"
labelWidth="md:w-32"
className="mb-1"
>
<div className="flex flex-col gap-4">
{!isReadOnly && (
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-1">Tool Mode</label>
<RadioGroup
defaultValue="mock"
value={tool.mockTool ? "mock" : "api"}
onValueChange={(value) => handleUpdate({
...tool,
mockTool: value === "mock",
autoSubmitMockedResponse: value === "mock" ? true : undefined
})}
orientation="horizontal"
classNames={{
wrapper: "flex flex-col md:flex-row gap-2 md:gap-8 pl-0 md:pl-3",
label: "text-sm"
}}
>
<Radio
value="mock"
classNames={{
base: "px-2 py-1 rounded-lg transition-colors",
label: "text-sm font-normal text-gray-900 dark:text-gray-100 px-3 py-1"
}}
>
Mock tool responses
</Radio>
<Radio
value="api"
classNames={{
base: "px-2 py-1 rounded-lg transition-colors",
label: "text-sm font-normal text-gray-900 dark:text-gray-100 px-3 py-1"
}}
>
Connect tool to your API
</Radio>
</RadioGroup>
</div>
)}
{tool.mockTool && (
<div className="flex flex-col gap-2 pl-0 md:pl-3 mt-2">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-1">Mock Response Schema</label>
<span className="text-xs text-gray-500 dark:text-gray-400 mb-1">Describe the response the mock tool should return. This will be shown in the chat when the tool is called. You can also provide a JSON schema for the response.</span>
<EditableField
value={tool.mockInstructions || ''}
onChange={(value) => handleUpdate({
...tool,
mockInstructions: value
})}
multiline={true}
placeholder="Mock response instructions..."
className="w-full text-xs p-2 bg-white dark:bg-gray-900"
/>
<Checkbox
size="sm"
isSelected={tool.autoSubmitMockedResponse ?? true}
@ -444,37 +452,41 @@ export function ToolConfig({
...tool,
autoSubmitMockedResponse: value
})}
disabled={isReadOnly}
className="mt-2"
>
<span className="text-sm text-gray-600 dark:text-gray-300">
Automatically send mock response in chat
</span>
</Checkbox>
<Textarea
value={tool.mockInstructions || ''}
onChange={(e) => handleUpdate({
...tool,
mockInstructions: e.target.value
})}
placeholder="Describe the response the mock tool should return..."
className={textareaStyles}
autoResize
/>
</div>
</div>
)}
</div>
)}
<div className={`space-y-4 ${dividerStyles} pt-6`}>
<label className={sectionHeaderStyles}>
Parameters
</label>
<div className="pl-3 space-y-3">
{renderParameters()}
</div>
{!isReadOnly && (
<div className="pl-3">
</SectionCard>
{/* Parameters Section */}
<SectionCard
icon={<Settings2 className="w-5 h-5 text-indigo-500" />}
title="Parameters"
labelWidth="md:w-32"
className="mb-1"
>
<div className="flex flex-col gap-2">
{tool.parameters?.properties && Object.entries(tool.parameters.properties).map(([paramName, param]) => (
<ToolParamCard
key={paramName}
param={{
name: paramName,
description: param.description,
type: param.type,
required: tool.parameters?.required?.includes(paramName) ?? false
}}
handleUpdate={handleParamUpdate}
handleDelete={handleParamDelete}
handleRename={handleParamRename}
readOnly={isReadOnly}
/>
))}
{!isReadOnly && (
<Button
variant="primary"
size="sm"
@ -488,7 +500,6 @@ export function ToolConfig({
description: ''
}
};
handleUpdate({
...tool,
parameters: {
@ -498,13 +509,13 @@ export function ToolConfig({
}
});
}}
className="hover:bg-indigo-100 dark:hover:bg-indigo-900 hover:shadow-indigo-500/20 dark:hover:shadow-indigo-400/20 hover:shadow-lg transition-all"
className="hover:bg-indigo-100 dark:hover:bg-indigo-900 hover:shadow-indigo-500/20 dark:hover:shadow-indigo-400/20 hover:shadow-lg transition-all mt-2"
>
Add Parameter
</Button>
</div>
)}
</div>
)}
</div>
</SectionCard>
</div>
</Panel>
);

View file

@ -2,7 +2,7 @@ export default async function Layout({
params,
children
}: {
params: { projectId: string }
params: Promise<{ projectId: string }>
children: React.ReactNode
}) {
return children;

View file

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

View file

@ -1,17 +1,16 @@
'use client';
import { useState, useCallback, useRef } from "react";
import { z } from "zod";
import { MCPServer, PlaygroundChat } from "@/app/lib/types/types";
import { MCPServer, Message, PlaygroundChat } from "@/app/lib/types/types";
import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
import { Chat } from "./components/chat";
import { Panel } from "@/components/common/panel-common";
import { Button } from "@/components/ui/button";
import { Tooltip } from "@heroui/react";
import { apiV1 } from "rowboat-shared";
import { TestProfile } from "@/app/lib/types/testing_types";
import { WithStringId } from "@/app/lib/types/types";
import { ProfileSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/profile-selector";
import { CheckIcon, CopyIcon, PlusIcon, UserIcon, InfoIcon, BugIcon, BugOffIcon } from "lucide-react";
import { CheckIcon, CopyIcon, PlusIcon, UserIcon, InfoIcon, BugIcon, BugOffIcon, CodeIcon } from "lucide-react";
import { USE_TESTING_FEATURE } from "@/app/lib/feature_flags";
import { clsx } from "clsx";
@ -27,16 +26,18 @@ export function App({
isInitialState = false,
onPanelClick,
projectTools,
triggerCopilotChat,
}: {
hidden?: boolean;
projectId: string;
workflow: z.infer<typeof Workflow>;
messageSubscriber?: (messages: z.infer<typeof apiV1.ChatMessage>[]) => void;
messageSubscriber?: (messages: z.infer<typeof Message>[]) => void;
mcpServerUrls: Array<z.infer<typeof MCPServer>>;
toolWebhookUrl: string;
isInitialState?: boolean;
onPanelClick?: () => void;
projectTools: z.infer<typeof WorkflowTool>[];
triggerCopilotChat?: (message: string) => void;
}) {
const [counter, setCounter] = useState<number>(0);
const [testProfile, setTestProfile] = useState<WithStringId<z.infer<typeof TestProfile>> | null>(null);
@ -102,8 +103,8 @@ export function App({
title={
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
PLAYGROUND
<div className="font-semibold text-zinc-700 dark:text-zinc-300">
Playground
</div>
<Tooltip content="Test your workflow and chat with your agents in real-time">
<InfoIcon className="w-4 h-4 text-gray-400 cursor-help" />
@ -188,6 +189,7 @@ export function App({
onCopyClick={(fn) => { getCopyContentRef.current = fn; }}
showDebugMessages={showDebugMessages}
projectTools={projectTools}
triggerCopilotChat={triggerCopilotChat}
/>
</div>
</Panel>

View file

@ -3,19 +3,18 @@ import { useEffect, useRef, useState, useCallback } from "react";
import { getAssistantResponseStreamId } from "@/app/actions/actions";
import { Messages } from "./messages";
import z from "zod";
import { MCPServer, PlaygroundChat } from "@/app/lib/types/types";
import { AgenticAPIChatMessage, convertFromAgenticAPIChatMessages, convertToAgenticAPIChatMessages } from "@/app/lib/types/agents_api_types";
import { convertWorkflowToAgenticAPI } from "@/app/lib/types/agents_api_types";
import { AgenticAPIChatRequest } from "@/app/lib/types/agents_api_types";
import { MCPServer, Message, PlaygroundChat, ToolMessage } from "@/app/lib/types/types";
import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
import { ComposeBoxPlayground } from "@/components/common/compose-box-playground";
import { Button } from "@heroui/react";
import { apiV1 } from "rowboat-shared";
import { TestProfile } from "@/app/lib/types/testing_types";
import { WithStringId } from "@/app/lib/types/types";
import { ProfileContextBox } from "./profile-context-box";
import { USE_TESTING_FEATURE } from "@/app/lib/feature_flags";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
import { ChevronDownIcon } from "@heroicons/react/24/outline";
import { FeedbackModal } from "./feedback-modal";
import { FIX_WORKFLOW_PROMPT, FIX_WORKFLOW_PROMPT_WITH_FEEDBACK } from "../copilot-prompts";
export function Chat({
chat,
@ -30,12 +29,14 @@ export function Chat({
toolWebhookUrl,
onCopyClick,
showDebugMessages = true,
showJsonMode = false,
projectTools,
triggerCopilotChat,
}: {
chat: z.infer<typeof PlaygroundChat>;
projectId: string;
workflow: z.infer<typeof Workflow>;
messageSubscriber?: (messages: z.infer<typeof apiV1.ChatMessage>[]) => void;
messageSubscriber?: (messages: z.infer<typeof Message>[]) => void;
testProfile?: z.infer<typeof TestProfile> | null;
onTestProfileChange: (profile: WithStringId<z.infer<typeof TestProfile>> | null) => void;
systemMessage: string;
@ -44,19 +45,47 @@ export function Chat({
toolWebhookUrl: string;
onCopyClick: (fn: () => string) => void;
showDebugMessages?: boolean;
showJsonMode?: boolean;
projectTools: z.infer<typeof WorkflowTool>[];
triggerCopilotChat?: (message: string) => void;
}) {
const [messages, setMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>(chat.messages);
const [messages, setMessages] = useState<z.infer<typeof Message>[]>(chat.messages);
const [loadingAssistantResponse, setLoadingAssistantResponse] = useState<boolean>(false);
const [agenticState, setAgenticState] = useState<unknown>(chat.agenticState || {
last_agent_name: workflow.startAgent,
});
const [fetchResponseError, setFetchResponseError] = useState<string | null>(null);
const [billingError, setBillingError] = useState<string | null>(null);
const [lastAgenticRequest, setLastAgenticRequest] = useState<unknown | null>(null);
const [lastAgenticResponse, setLastAgenticResponse] = useState<unknown | null>(null);
const [optimisticMessages, setOptimisticMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>(chat.messages);
const [optimisticMessages, setOptimisticMessages] = useState<z.infer<typeof Message>[]>(chat.messages);
const [isLastInteracted, setIsLastInteracted] = useState(false);
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const [pendingFixMessage, setPendingFixMessage] = useState<string | null>(null);
const [showSuccessMessage, setShowSuccessMessage] = useState(false);
// --- Scroll/auto-scroll/unread bubble logic ---
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
const [showUnreadBubble, setShowUnreadBubble] = useState(false);
const handleScroll = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const atBottom = scrollHeight - scrollTop - clientHeight < 20;
setAutoScroll(atBottom);
if (atBottom) setShowUnreadBubble(false);
}, []);
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
if (autoScroll) {
container.scrollTop = container.scrollHeight;
setShowUnreadBubble(false);
} else {
setShowUnreadBubble(true);
}
}, [optimisticMessages, loadingAssistantResponse, autoScroll]);
// --- End scroll/auto-scroll logic ---
const getCopyContent = useCallback(() => {
return JSON.stringify({
@ -79,21 +108,49 @@ export function Chat({
setOptimisticMessages(messages);
}, [messages]);
// Handle fix functionality
const handleFix = useCallback((message: string) => {
setPendingFixMessage(message);
setShowFeedbackModal(true);
}, []);
const handleFeedbackSubmit = useCallback((feedback: string) => {
if (!pendingFixMessage) return;
// Create the copilot prompt
const prompt = feedback.trim()
? FIX_WORKFLOW_PROMPT_WITH_FEEDBACK
.replace('{chat_turn}', pendingFixMessage)
.replace('{feedback}', feedback)
: FIX_WORKFLOW_PROMPT
.replace('{chat_turn}', pendingFixMessage);
// Use the triggerCopilotChat function if available, otherwise fall back to localStorage
if (triggerCopilotChat) {
triggerCopilotChat(prompt);
// Show a subtle success indication
setShowSuccessMessage(true);
setTimeout(() => setShowSuccessMessage(false), 3000);
} else {
// Fallback for standalone playground
localStorage.setItem(`project_prompt_${projectId}`, prompt);
alert('Fix request submitted! Redirecting to workflow editor...');
window.location.href = `/projects/${projectId}/workflow`;
}
}, [pendingFixMessage, projectId, triggerCopilotChat]);
// collect published tool call results
const toolCallResults: Record<string, z.infer<typeof apiV1.ToolMessage>> = {};
const toolCallResults: Record<string, z.infer<typeof ToolMessage>> = {};
optimisticMessages
.filter((message) => message.role == 'tool')
.forEach((message) => {
toolCallResults[message.tool_call_id] = message;
toolCallResults[message.toolCallId] = message;
});
function handleUserMessage(prompt: string) {
const updatedMessages: z.infer<typeof apiV1.ChatMessage>[] = [...messages, {
const updatedMessages: z.infer<typeof Message>[] = [...messages, {
role: 'user',
content: prompt,
version: 'v1',
chatId: '',
createdAt: new Date().toISOString(),
}];
setMessages(updatedMessages);
setFetchResponseError(null);
@ -103,9 +160,6 @@ export function Chat({
// reset state when workflow changes
useEffect(() => {
setMessages([]);
setAgenticState({
last_agent_name: workflow.startAgent,
});
}, [workflow]);
// publish messages to subscriber
@ -119,7 +173,7 @@ export function Chat({
useEffect(() => {
let ignore = false;
let eventSource: EventSource | null = null;
let msgs: z.infer<typeof apiV1.ChatMessage>[] = [];
let msgs: z.infer<typeof Message>[] = [];
async function process() {
setLoadingAssistantResponse(true);
@ -129,36 +183,19 @@ export function Chat({
setLastAgenticRequest(null);
setLastAgenticResponse(null);
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow, projectTools);
const request: z.infer<typeof AgenticAPIChatRequest> = {
projectId,
messages: convertToAgenticAPIChatMessages([{
role: 'system',
content: systemMessage || '',
version: 'v1' as const,
chatId: '',
createdAt: new Date().toISOString(),
}, ...messages]),
state: agenticState,
agents,
tools,
prompts,
startAgent,
mcpServers: mcpServerUrls.map(server => ({
name: server.name,
serverUrl: server.serverUrl || '',
isReady: server.isReady
})),
toolWebhookUrl: toolWebhookUrl,
testProfile: testProfile ?? undefined,
};
// Store the full request object
setLastAgenticRequest(request);
let streamId: string | null = null;
try {
const response = await getAssistantResponseStreamId(request);
const response = await getAssistantResponseStreamId(
workflow,
projectTools,
[
{
role: 'system',
content: systemMessage || '',
},
...messages,
],
);
if (ignore) {
return;
}
@ -190,8 +227,7 @@ export function Chat({
try {
const data = JSON.parse(event.data);
const msg = AgenticAPIChatMessage.parse(data);
const parsedMsg = convertFromAgenticAPIChatMessages([msg])[0];
const parsedMsg = Message.parse(data);
msgs.push(parsedMsg);
setOptimisticMessages(prev => [...prev, parsedMsg]);
} catch (err) {
@ -207,7 +243,6 @@ export function Chat({
}
const parsed = JSON.parse(event.data);
setAgenticState(parsed.state);
// Combine state and collected messages in the response
setLastAgenticResponse({
@ -267,7 +302,6 @@ export function Chat({
}, [
messages,
projectId,
agenticState,
workflow,
systemMessage,
mcpServerUrls,
@ -277,7 +311,7 @@ export function Chat({
projectTools,
]);
return <div className="relative max-w-3xl mx-auto h-full flex flex-col">
return <div className="w-11/12 max-w-6xl mx-auto h-full flex flex-col relative">
<div className="sticky top-0 z-10 bg-white dark:bg-zinc-900 pt-4 pb-4">
{USE_TESTING_FEATURE && (
<ProfileContextBox
@ -288,28 +322,58 @@ export function Chat({
)}
</div>
<div className="flex-1 overflow-auto pr-1
[&::-webkit-scrollbar]{width:4px}
[&::-webkit-scrollbar-track]{background:transparent}
[&::-webkit-scrollbar-thumb]{background-color:rgb(156 163 175)}
dark:[&::-webkit-scrollbar-thumb]{background-color:#2a2d31}">
<div className="pr-4">
<Messages
projectId={projectId}
messages={optimisticMessages}
toolCallResults={toolCallResults}
loadingAssistantResponse={loadingAssistantResponse}
workflow={workflow}
testProfile={testProfile}
systemMessage={systemMessage}
onSystemMessageChange={onSystemMessageChange}
showSystemMessage={false}
showDebugMessages={showDebugMessages}
/>
</div>
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-auto pr-4 relative playground-scrollbar"
style={{ scrollBehavior: 'smooth' }}
>
<Messages
projectId={projectId}
messages={optimisticMessages}
toolCallResults={toolCallResults}
loadingAssistantResponse={loadingAssistantResponse}
workflow={workflow}
testProfile={testProfile}
systemMessage={systemMessage}
onSystemMessageChange={onSystemMessageChange}
showSystemMessage={false}
showDebugMessages={showDebugMessages}
showJsonMode={showJsonMode}
onFix={handleFix}
/>
{showUnreadBubble && (
<button
className="absolute bottom-4 right-4 z-20 bg-blue-100 text-blue-700 rounded-full w-8 h-8 flex items-center justify-center hover:bg-blue-200 transition-colors animate-pulse"
onClick={() => {
const container = scrollContainerRef.current;
if (container) {
container.scrollTop = container.scrollHeight;
}
setAutoScroll(true);
setShowUnreadBubble(false);
}}
aria-label="Scroll to latest message"
>
<ChevronDownIcon className="w-5 h-5" strokeWidth={2.2} />
</button>
)}
</div>
<div className="sticky bottom-0 bg-white dark:bg-zinc-900 pt-4 pb-2">
{showSuccessMessage && (
<div className="mb-4 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800
rounded-lg flex gap-2 justify-between items-center">
<p className="text-green-600 dark:text-green-400 text-sm">Skipper will suggest fixes for you now.</p>
<Button
size="sm"
color="success"
onPress={() => setShowSuccessMessage(false)}
>
Dismiss
</Button>
</div>
)}
{fetchResponseError && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800
rounded-lg flex gap-2 justify-between items-center">
@ -342,5 +406,11 @@ export function Chat({
onClose={() => setBillingError(null)}
errorMessage={billingError || ''}
/>
<FeedbackModal
isOpen={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
onSubmit={handleFeedbackSubmit}
title="Fix Assistant"
/>
</div>;
}

View file

@ -0,0 +1,58 @@
'use client';
import { useState } from "react";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, Textarea } from "@heroui/react";
interface FeedbackModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (feedback: string) => void;
title?: string;
}
export function FeedbackModal({ isOpen, onClose, onSubmit, title = "Provide Feedback" }: FeedbackModalProps) {
const [feedback, setFeedback] = useState("");
const handleSubmit = () => {
onSubmit(feedback);
setFeedback("");
onClose();
};
const handleCancel = () => {
setFeedback("");
onClose();
};
return (
<Modal isOpen={isOpen} onClose={handleCancel} size="md">
<ModalContent className="feedback-modal">
<ModalHeader className="flex flex-col gap-1">
{title}
</ModalHeader>
<p className="text-xs text-gray-600 dark:text-gray-400 px-6 pt-1 pb-0">
Tell Skipper what needs to be fixed
</p>
<ModalBody>
<div className="space-y-3">
<Textarea
placeholder="Describe the issue..."
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
minRows={3}
maxRows={6}
className="w-full !text-xs focus:ring-0 focus:shadow-none focus:border-gray-300"
/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="bordered" onPress={handleCancel}>
Cancel
</Button>
<Button color="primary" onPress={handleSubmit}>
Submit
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View file

@ -1,14 +1,14 @@
'use client';
import { Spinner } from "@heroui/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo, useState } from "react";
import z from "zod";
import { Workflow } from "@/app/lib/types/workflow_types";
import { WorkflowTool } from "@/app/lib/types/workflow_types";
import MarkdownContent from "@/app/lib/components/markdown-content";
import { apiV1 } from "rowboat-shared";
import { MessageSquareIcon, EllipsisIcon, CircleCheckIcon, ChevronRightIcon, ChevronDownIcon, ChevronUpIcon, XIcon, PlusIcon, CodeIcon, CheckCircleIcon, FileTextIcon } from "lucide-react";
import { ChevronRightIcon, ChevronDownIcon, ChevronUpIcon, CodeIcon, CheckCircleIcon, FileTextIcon, EyeIcon, EyeOffIcon, WrapTextIcon, ArrowRightFromLineIcon, BracesIcon, TextIcon, FlagIcon } from "lucide-react";
import { TestProfile } from "@/app/lib/types/testing_types";
import { ProfileContextBox } from "./profile-context-box";
import { Message, ToolMessage, AssistantMessageWithToolCalls } from "@/app/lib/types/types";
function UserMessage({ content }: { content: string }) {
return (
@ -30,8 +30,28 @@ function UserMessage({ content }: { content: string }) {
);
}
function InternalAssistantMessage({ content, sender, latency, delta }: { content: string, sender: string | null | undefined, latency: number, delta: number }) {
const [expanded, setExpanded] = useState(false);
function InternalAssistantMessage({ content, sender, latency, delta, showJsonMode = false, onFix, showDebugMessages, isFirstAssistant }: { content: string, sender: string | null | undefined, latency: number, delta: number, showJsonMode?: boolean, onFix?: (message: string) => void, showDebugMessages?: boolean, isFirstAssistant?: boolean }) {
const isJsonContent = useMemo(() => {
try {
JSON.parse(content);
return true;
} catch {
return false;
}
}, [content]);
const hasResponseKey = useMemo(() => {
if (!isJsonContent) return false;
try {
const parsed = JSON.parse(content);
return parsed && typeof parsed === 'object' && 'response' in parsed;
} catch {
return false;
}
}, [content, isJsonContent]);
const [jsonMode, setJsonMode] = useState(false);
const [wrapText, setWrapText] = useState(true);
// Show plus icon and duration
const deltaDisplay = (
@ -40,63 +60,121 @@ function InternalAssistantMessage({ content, sender, latency, delta }: { content
</span>
);
// Get first line preview
const firstLine = content.split('\n')[0].trim();
const preview = firstLine.length > 50 ? firstLine.substring(0, 50) + '...' : firstLine;
// Extract response content for display
const displayContent = useMemo(() => {
if (!isJsonContent || !hasResponseKey) return content;
try {
const parsed = JSON.parse(content);
return parsed.response || content;
} catch {
return content;
}
}, [content, isJsonContent, hasResponseKey]);
// Format JSON content
const formattedJson = useMemo(() => {
if (!isJsonContent) return content;
try {
return JSON.stringify(JSON.parse(content), null, 2);
} catch {
return content;
}
}, [content, isJsonContent]);
return (
<div className="self-start flex flex-col gap-1 my-5">
<div className="text-gray-500 dark:text-gray-400 text-xs pl-1">
{sender ?? 'Assistant'}
</div>
<div className={expanded ? 'max-w-[85%] inline-block' : 'inline-block'}>
<div className={expanded
? 'bg-gray-50 dark:bg-zinc-800 px-4 py-2.5 rounded-2xl rounded-bl-lg text-sm leading-relaxed text-gray-700 dark:text-gray-200 border-none shadow-sm animate-slideUpAndFade flex flex-col items-stretch'
: 'bg-gray-50 dark:bg-zinc-800 px-4 py-2.5 rounded-2xl rounded-bl-lg text-sm leading-relaxed text-gray-700 dark:text-gray-200 border-none shadow-sm animate-slideUpAndFade w-fit'}>
{!expanded ? (
<div className="flex flex-col gap-2">
<div className="text-gray-700 dark:text-gray-200">
{preview}
</div>
<div className="flex justify-between items-center gap-6">
<button className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300 hover:underline self-start" onClick={() => setExpanded(true)}>
<ChevronDownIcon size={16} />
Show internal message
</button>
<div className="text-right text-xs">
{deltaDisplay}
</div>
</div>
</div>
) : (
<>
<div className="text-left mb-2">
<MarkdownContent content={content} />
</div>
<div className="flex justify-between items-center gap-6 mt-2">
<button className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300 hover:underline self-start" onClick={() => setExpanded(false)}>
<ChevronUpIcon size={16} />
Hide internal message
</button>
<div className="text-right text-xs">
{deltaDisplay}
</div>
</div>
</>
<div className="max-w-[85%] inline-block">
<div className="text-gray-500 dark:text-gray-400 text-xs pl-1 flex justify-between items-center mb-2">
<span>{sender ?? 'Assistant'}</span>
{showDebugMessages && onFix && !isFirstAssistant && (
<button
onClick={() => onFix(content)}
className="flex items-center gap-1 text-xs text-orange-700 dark:text-orange-400 hover:text-orange-800 dark:hover:text-orange-300 hover:underline"
title="Fix this response"
>
<FlagIcon size={12} />
Fix
</button>
)}
</div>
<div className="bg-gray-50 dark:bg-zinc-800 px-4 py-2.5 rounded-2xl rounded-bl-lg text-sm leading-relaxed text-gray-700 dark:text-gray-200 border-none shadow-sm animate-slideUpAndFade flex flex-col items-stretch">
<div className="text-left mb-2">
{isJsonContent && hasResponseKey && (
<div className="mb-2 flex gap-4">
<button
className="flex items-center gap-1 text-xs text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-300 hover:underline self-start"
onClick={() => setJsonMode(!jsonMode)}
>
{jsonMode ? <TextIcon size={14} /> : <BracesIcon size={14} />}
{jsonMode ? 'View response text' : 'View complete JSON'}
</button>
{jsonMode && (
<button
className="flex items-center gap-1 text-xs text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-300 hover:underline self-start"
onClick={() => setWrapText(!wrapText)}
>
{wrapText ? <ArrowRightFromLineIcon size={14} /> : <WrapTextIcon size={14} />}
{wrapText ? 'Overflow' : 'Wrap'}
</button>
)}
</div>
)}
{isJsonContent && hasResponseKey && jsonMode ? (
<pre
className={`text-xs leading-snug bg-zinc-50 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-200 rounded-lg px-2 py-1 font-mono shadow-sm border border-zinc-100 dark:border-zinc-700 ${
wrapText ? 'whitespace-pre-wrap break-words' : 'overflow-x-auto whitespace-pre'
} w-full`}
style={{ fontFamily: "'JetBrains Mono', 'Fira Mono', 'Menlo', 'Consolas', 'Liberation Mono', monospace" }}
>
{formattedJson}
</pre>
) : (
<MarkdownContent content={displayContent} />
)}
</div>
<div className="flex justify-end items-center gap-6 mt-2">
<div className="text-right text-xs">
{deltaDisplay}
</div>
</div>
</div>
</div>
</div>
);
}
function AssistantMessage({ content, sender, latency }: { content: string, sender: string | null | undefined, latency: number }) {
function AssistantMessage({
content,
sender,
latency,
onFix,
showDebugMessages,
isFirstAssistant
}: {
content: string,
sender: string | null | undefined,
latency: number,
onFix?: (message: string) => void,
showDebugMessages?: boolean,
isFirstAssistant?: boolean
}) {
return (
<div className="self-start flex flex-col gap-1 my-5">
<div className="text-gray-500 dark:text-gray-400 text-xs pl-1">
{sender ?? 'Assistant'}
</div>
<div className="max-w-[85%] inline-block">
<div className="text-gray-500 dark:text-gray-400 text-xs pl-1 flex justify-between items-center mb-2">
<span>{sender ?? 'Assistant'}</span>
{showDebugMessages && onFix && !isFirstAssistant && (
<button
onClick={() => onFix(content)}
className="flex items-center gap-1 text-xs text-orange-700 dark:text-orange-400 hover:text-orange-800 dark:hover:text-orange-300 hover:underline"
title="Fix this response"
>
<FlagIcon size={12} />
Fix
</button>
)}
</div>
<div className="bg-purple-50 dark:bg-purple-900/30 px-4 py-2.5
rounded-2xl rounded-bl-lg text-sm leading-relaxed
text-gray-800 dark:text-purple-100
@ -138,20 +216,26 @@ function ToolCalls({
workflow,
testProfile = null,
systemMessage,
delta
delta,
onFix,
showDebugMessages,
isFirstAssistant
}: {
toolCalls: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'];
results: Record<string, z.infer<typeof apiV1.ToolMessage>>;
toolCalls: z.infer<typeof AssistantMessageWithToolCalls>['toolCalls'];
results: Record<string, z.infer<typeof ToolMessage>>;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
messages: z.infer<typeof Message>[];
sender: string | null | undefined;
workflow: z.infer<typeof Workflow>;
testProfile: z.infer<typeof TestProfile> | null;
systemMessage: string | undefined;
delta: number;
onFix?: (message: string) => void;
showDebugMessages?: boolean;
isFirstAssistant?: boolean;
}) {
return <div className="flex flex-col gap-4">
{toolCalls.map(toolCall => {
{toolCalls.map((toolCall, idx) => {
return <ToolCall
key={toolCall.id}
toolCall={toolCall}
@ -159,6 +243,9 @@ function ToolCalls({
sender={sender}
workflow={workflow}
delta={delta}
onFix={onFix}
showDebugMessages={showDebugMessages}
isFirstAssistant={isFirstAssistant && idx === 0}
/>
})}
</div>;
@ -169,13 +256,19 @@ function ToolCall({
result,
sender,
workflow,
delta
delta,
onFix,
showDebugMessages,
isFirstAssistant
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
toolCall: z.infer<typeof AssistantMessageWithToolCalls>['toolCalls'][number];
result: z.infer<typeof ToolMessage> | undefined;
sender: string | null | undefined;
workflow: z.infer<typeof Workflow>;
delta: number;
onFix?: (message: string) => void;
showDebugMessages?: boolean;
isFirstAssistant?: boolean;
}) {
let matchingWorkflowTool: z.infer<typeof WorkflowTool> | undefined;
for (const tool of workflow.tools) {
@ -198,6 +291,8 @@ function ToolCall({
sender={sender ?? ''}
workflow={workflow}
delta={delta}
onFix={onFix}
showDebugMessages={showDebugMessages}
/>;
}
@ -206,7 +301,7 @@ function TransferToAgentToolCall({
sender,
delta
}: {
result: z.infer<typeof apiV1.ToolMessage> | undefined;
result: z.infer<typeof ToolMessage> | undefined;
sender: string | null | undefined;
delta: number;
}) {
@ -236,41 +331,168 @@ function ClientToolCall({
result: availableResult,
sender,
workflow,
delta
delta,
onFix,
showDebugMessages
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
toolCall: z.infer<typeof AssistantMessageWithToolCalls>['toolCalls'][number];
result: z.infer<typeof ToolMessage> | undefined;
sender: string | null | undefined;
workflow: z.infer<typeof Workflow>;
delta: number;
onFix?: (message: string) => void;
showDebugMessages?: boolean;
}) {
return (
<div className="self-start flex flex-col gap-1 mb-4">
{sender && (
<div className="text-gray-500 dark:text-gray-400 text-xs pl-1">
{sender}
</div>
)}
<div className="min-w-[85%] inline-block">
<div className="border border-gray-200 dark:border-gray-700 p-3
rounded-2xl rounded-bl-lg flex flex-col gap-2
bg-gray-50 dark:bg-gray-800 shadow-sm dark:shadow-gray-950/20">
<div className="flex flex-col gap-1">
<div className="shrink-0 flex gap-2 items-center">
{!availableResult && <Spinner size="sm" />}
{availableResult && <CheckCircleIcon size={16} className="text-green-500" />}
<div className="flex items-center font-semibold text-sm gap-2">
<span>Function Call:</span>
<span className="px-2 py-0.5 rounded-full bg-purple-50 text-purple-800 dark:bg-purple-900/30 dark:text-purple-100 font-bold text-sm align-middle">
{toolCall.function.name}
</span>
const [wrapText, setWrapText] = useState(true);
const [paramsExpanded, setParamsExpanded] = useState(false);
const [resultsExpanded, setResultsExpanded] = useState(false);
const hasExpandedContent = paramsExpanded || resultsExpanded;
const isCompressed = !paramsExpanded && !resultsExpanded;
// Compressed state: stretch header, no wrapping
if (isCompressed) {
return (
<div className="self-start flex flex-col gap-1 my-5">
{sender && (
<div className="text-gray-500 dark:text-gray-400 text-xs pl-1 flex justify-between items-center">
<span>{sender}</span>
{showDebugMessages && onFix && (
<button
onClick={() => onFix(`Tool call: ${toolCall.function.name}`)}
className="flex items-center gap-1 text-xs text-orange-700 dark:text-orange-400 hover:text-orange-800 dark:hover:text-orange-300 hover:underline"
title="Fix this tool call"
>
<FlagIcon size={12} />
Fix
</button>
)}
</div>
)}
<div className="min-w-[85%]">
<div className="border border-gray-200 dark:border-gray-700 p-3
rounded-2xl rounded-bl-lg flex flex-col gap-2
bg-gray-50 dark:bg-gray-800 shadow-sm dark:shadow-gray-950/20">
<div className="flex flex-col gap-1 min-w-0">
<div className="shrink-0 flex gap-2 items-center flex-nowrap">
<div className="flex items-center gap-2 min-w-0 flex-nowrap">
{!availableResult && <Spinner size="sm" />}
{availableResult && <CheckCircleIcon size={16} className="text-green-500" />}
<div className="flex items-center font-medium text-xs gap-2 min-w-0 flex-nowrap">
<span>Invoked Tool:</span>
<span className="px-2 py-0.5 rounded-full bg-purple-50 text-purple-800 dark:bg-purple-900/30 dark:text-purple-100 text-xs align-middle whitespace-nowrap">
{toolCall.function.name}
</span>
</div>
</div>
</div>
{hasExpandedContent && (
<div className="flex justify-start mt-2">
<button
className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400 hover:underline"
onClick={() => setWrapText(!wrapText)}
>
{wrapText ? <ArrowRightFromLineIcon size={16} /> : <WrapTextIcon size={16} />}
{wrapText ? 'Overflow' : 'Wrap'}
</button>
</div>
)}
</div>
<div className="flex flex-col gap-2 min-w-0">
<ExpandableContent
label="Params"
content={toolCall.function.arguments}
expanded={false}
icon={<CodeIcon size={14} />}
wrapText={wrapText}
onExpandedChange={setParamsExpanded}
/>
{availableResult && (
<div className={(paramsExpanded ? 'mt-4 ' : '') + 'flex flex-col gap-2 min-w-0'}>
<ExpandableContent
label="Result"
content={availableResult.content}
expanded={false}
icon={<FileTextIcon size={14} className="text-blue-500" />}
wrapText={wrapText}
onExpandedChange={setResultsExpanded}
/>
</div>
)}
</div>
</div>
</div>
</div>
);
}
<div className="flex flex-col gap-2">
<ExpandableContent label="Params" content={toolCall.function.arguments} expanded={false} icon={<CodeIcon size={14} />} />
{availableResult && <ExpandableContent label="Result" content={availableResult.content} expanded={false} icon={<FileTextIcon size={14} className="text-blue-500" />} />}
// Expanded state: respect 85% max width, prevent overshoot
return (
<div className="self-start flex flex-col gap-1 my-5">
{sender && (
<div className="text-gray-500 dark:text-gray-400 text-xs pl-1 flex justify-between items-center">
<span>{sender}</span>
{showDebugMessages && onFix && (
<button
onClick={() => onFix(`Tool call: ${toolCall.function.name}`)}
className="flex items-center gap-1 text-xs text-orange-700 dark:text-orange-400 hover:text-orange-800 dark:hover:text-orange-300 hover:underline"
title="Fix this tool call"
>
<FlagIcon size={12} />
Fix
</button>
)}
</div>
)}
<div className="w-full">
<div className="border border-gray-200 dark:border-gray-700 p-3
rounded-2xl rounded-bl-lg flex flex-col gap-2
bg-gray-50 dark:bg-gray-800 shadow-sm dark:shadow-gray-950/20 w-full">
<div className="flex flex-col gap-1 w-full">
<div className="shrink-0 flex gap-2 items-center w-full flex-nowrap">
<div className="flex items-center gap-2 min-w-0 flex-nowrap">
{!availableResult && <Spinner size="sm" />}
{availableResult && <CheckCircleIcon size={16} className="text-green-500" />}
<div className="flex items-center font-medium text-xs gap-2 min-w-0 flex-nowrap">
<span>Invoked Tool:</span>
<span className="px-2 py-0.5 rounded-full bg-purple-50 text-purple-800 dark:bg-purple-900/30 dark:text-purple-100 text-xs align-middle truncate min-w-0 max-w-full">
{toolCall.function.name}
</span>
</div>
</div>
</div>
{hasExpandedContent && (
<div className="flex justify-start mt-2">
<button
className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400 hover:underline"
onClick={() => setWrapText(!wrapText)}
>
{wrapText ? <ArrowRightFromLineIcon size={16} /> : <WrapTextIcon size={16} />}
{wrapText ? 'Overflow' : 'Wrap'}
</button>
</div>
)}
</div>
<div className="flex flex-col gap-2 w-full">
<ExpandableContent
label="Params"
content={toolCall.function.arguments}
expanded={paramsExpanded}
icon={<CodeIcon size={14} />}
wrapText={wrapText}
onExpandedChange={setParamsExpanded}
/>
{availableResult && (
<div className={(paramsExpanded ? 'mt-4 ' : '') + 'flex flex-col gap-2 w-full'}>
<ExpandableContent
label="Result"
content={availableResult.content}
expanded={resultsExpanded}
icon={<FileTextIcon size={14} className="text-blue-500" />}
wrapText={wrapText}
onExpandedChange={setResultsExpanded}
/>
</div>
)}
</div>
</div>
</div>
@ -282,12 +504,18 @@ function ExpandableContent({
label,
content,
expanded = false,
icon
icon,
wrapText = false,
onExpandedChange,
rightButton
}: {
label: string,
content: string | object | undefined,
expanded?: boolean,
icon?: React.ReactNode
icon?: React.ReactNode,
wrapText?: boolean,
onExpandedChange?: (expanded: boolean) => void,
rightButton?: React.ReactNode
}) {
const [isExpanded, setIsExpanded] = useState(expanded);
@ -308,26 +536,31 @@ function ExpandableContent({
}, [content]);
function toggleExpanded() {
setIsExpanded(!isExpanded);
const newExpanded = !isExpanded;
setIsExpanded(newExpanded);
onExpandedChange?.(newExpanded);
}
const isMarkdown = label === 'Result' && typeof content === 'string' && !content.startsWith('{');
return <div className='flex flex-col gap-2'>
<div className='flex gap-1 items-start cursor-pointer text-gray-500 dark:text-gray-400' onClick={toggleExpanded}>
return <div className='flex flex-col gap-2 min-w-0'>
<div className='flex gap-1 items-start cursor-pointer text-gray-500 dark:text-gray-400 min-w-0' onClick={toggleExpanded}>
{!isExpanded && <ChevronRightIcon size={16} />}
{isExpanded && <ChevronDownIcon size={16} />}
{icon && <span className="mr-1">{icon}</span>}
<div className='text-left break-all text-xs'>{label}</div>
{rightButton && <span className="ml-2">{rightButton}</span>}
</div>
{isExpanded && (
isMarkdown ? (
<div className='text-sm bg-gray-100 dark:bg-gray-800 p-2 rounded text-gray-900 dark:text-gray-100'>
<div className='text-sm bg-gray-100 dark:bg-gray-800 p-2 rounded text-gray-900 dark:text-gray-100 min-w-0'>
<MarkdownContent content={content as string} />
</div>
) : (
<pre
className="text-xs leading-snug bg-zinc-50 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-200 rounded-lg px-2 py-1 overflow-x-auto font-mono shadow-sm border border-zinc-100 dark:border-zinc-700"
className={`text-xs leading-snug bg-zinc-50 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-200 rounded-lg px-2 py-1 font-mono shadow-sm border border-zinc-100 dark:border-zinc-700 ${
wrapText ? 'whitespace-pre-wrap break-words' : 'overflow-x-auto whitespace-pre'
} min-w-0 max-w-full`}
style={{ fontFamily: "'JetBrains Mono', 'Fira Mono', 'Menlo', 'Consolas', 'Liberation Mono', monospace" }}
>
{formattedContent}
@ -348,10 +581,12 @@ export function Messages({
onSystemMessageChange,
showSystemMessage,
showDebugMessages = true,
showJsonMode = false,
onFix,
}: {
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
toolCallResults: Record<string, z.infer<typeof apiV1.ToolMessage>>;
messages: z.infer<typeof Message>[];
toolCallResults: Record<string, z.infer<typeof ToolMessage>>;
loadingAssistantResponse: boolean;
workflow: z.infer<typeof Workflow>;
testProfile: z.infer<typeof TestProfile> | null;
@ -359,47 +594,54 @@ export function Messages({
onSystemMessageChange: (message: string) => void;
showSystemMessage: boolean;
showDebugMessages?: boolean;
showJsonMode?: boolean;
onFix?: (message: string) => void;
}) {
const messagesEndRef = useRef<HTMLDivElement>(null);
let lastUserMessageTimestamp = 0;
let userMessageSeen = false;
// Remove scroll/auto-scroll state and logic
// const scrollContainerRef = useRef<HTMLDivElement>(null);
// const [autoScroll, setAutoScroll] = useState(true);
// const [showUnreadBubble, setShowUnreadBubble] = useState(false);
// Remove handleScroll and useEffect for scroll
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, loadingAssistantResponse]);
const renderMessage = (message: z.infer<typeof apiV1.ChatMessage>, index: number) => {
const isConsecutive = index > 0 && messages[index - 1].role === message.role;
// Find the index of the first assistant message
const firstAssistantIdx = messages.findIndex(m => m.role === 'assistant');
const renderMessage = (message: z.infer<typeof Message>, index: number) => {
const isFirstAssistant = message.role === 'assistant' && index === firstAssistantIdx;
if (message.role === 'assistant') {
let latency = new Date(message.createdAt).getTime() - lastUserMessageTimestamp;
if (!userMessageSeen) {
latency = 0;
}
// TODO: add latency support
// let latency = new Date(message.createdAt).getTime() - lastUserMessageTimestamp;
// if (!userMessageSeen) {
// latency = 0;
// }
let latency = 0;
// First check for tool calls
if ('tool_calls' in message && message.tool_calls) {
if ('toolCalls' in message) {
// Skip tool calls if debug mode is off
if (!showDebugMessages) {
return null;
}
return (
<ToolCalls
toolCalls={message.tool_calls}
toolCalls={message.toolCalls}
results={toolCallResults}
projectId={projectId}
messages={messages}
sender={message.agenticSender ?? ''}
sender={message.agentName ?? ''}
workflow={workflow}
testProfile={testProfile}
systemMessage={systemMessage}
delta={latency}
onFix={onFix}
showDebugMessages={showDebugMessages}
isFirstAssistant={isFirstAssistant}
/>
);
}
// Then check for internal messages
if (message.agenticResponseType === 'internal') {
if (message.content && message.responseType === 'internal') {
// Skip internal messages if debug mode is off
if (!showDebugMessages) {
return null;
@ -407,9 +649,13 @@ export function Messages({
return (
<InternalAssistantMessage
content={message.content ?? ''}
sender={message.agenticSender ?? ''}
sender={message.agentName ?? ''}
latency={latency}
delta={latency}
showJsonMode={showJsonMode}
onFix={onFix}
showDebugMessages={showDebugMessages}
isFirstAssistant={isFirstAssistant}
/>
);
}
@ -418,27 +664,31 @@ export function Messages({
return (
<AssistantMessage
content={message.content ?? ''}
sender={message.agenticSender ?? ''}
sender={message.agentName ?? ''}
latency={latency}
onFix={onFix}
showDebugMessages={showDebugMessages}
isFirstAssistant={isFirstAssistant}
/>
);
}
if (message.role === 'user' && typeof message.content === 'string') {
lastUserMessageTimestamp = new Date(message.createdAt).getTime();
userMessageSeen = true;
if (message.role === 'user') {
// TODO: add latency support
// lastUserMessageTimestamp = new Date(message.createdAt).getTime();
// userMessageSeen = true;
return <UserMessage content={message.content} />;
}
return null;
};
const isAgentTransition = (message: z.infer<typeof apiV1.ChatMessage>) => {
return message.role === 'assistant' && 'tool_calls' in message && Array.isArray(message.tool_calls) && message.tool_calls.some(tc => tc.function.name.startsWith('transfer_to_'));
const isAgentTransition = (message: z.infer<typeof Message>) => {
return message.role === 'assistant' && 'toolCalls' in message && Array.isArray(message.toolCalls) && message.toolCalls.some(tc => tc.function.name.startsWith('transfer_to_'));
};
const isAssistantMessage = (message: z.infer<typeof apiV1.ChatMessage>) => {
return message.role === 'assistant' && (!('tool_calls' in message) || !Array.isArray(message.tool_calls) || !message.tool_calls.some(tc => tc.function.name.startsWith('transfer_to_')));
const isAssistantMessage = (message: z.infer<typeof Message>) => {
return message.role === 'assistant' && (!('toolCalls' in message) || !Array.isArray(message.toolCalls) || !message.toolCalls.some(tc => tc.function.name.startsWith('transfer_to_')));
};
if (showSystemMessage) {
@ -451,23 +701,21 @@ export function Messages({
);
}
// Just render the messages, no scroll container or unread bubble
return (
<div className="max-w-[768px] mx-auto">
<div className="flex flex-col">
{messages.map((message, index) => {
const renderedMessage = renderMessage(message, index);
if (renderedMessage) {
return (
<div key={index}>
{renderedMessage}
</div>
);
}
return null;
})}
{loadingAssistantResponse && <AssistantMessageLoading />}
</div>
<div ref={messagesEndRef} />
<div className="max-w-7xl mx-auto px-2 sm:px-8 relative">
{messages.map((message, index) => {
const renderedMessage = renderMessage(message, index);
if (renderedMessage) {
return (
<div key={index}>
{renderedMessage}
</div>
);
}
return null;
})}
{loadingAssistantResponse && <AssistantMessageLoading />}
</div>
);
}

View file

@ -0,0 +1,7 @@
export const FIX_WORKFLOW_PROMPT = `There is an issue with this turn of chat: "{chat_turn}"
Fix the issue by updating necessary agents and tools.`;
export const FIX_WORKFLOW_PROMPT_WITH_FEEDBACK = `${FIX_WORKFLOW_PROMPT}
Here are more details: {feedback}`;

View file

@ -1,14 +1,15 @@
import { SourcePage } from "./source-page";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
export default async function Page({
params,
}: {
params: {
projectId: string,
sourceId: string
export default async function Page(
props: {
params: Promise<{
projectId: string,
sourceId: string
}>
}
}) {
) {
const params = await props.params;
await requireActiveBillingSubscription();
return <SourcePage projectId={params.projectId} sourceId={params.sourceId} />;
}

View file

@ -37,7 +37,7 @@ export function SectionRow({ children, className }: { children: ReactNode; class
export function SectionLabel({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={`w-24 flex-shrink-0 text-sm text-gray-500 dark:text-gray-400 ${className || ''}`}>
<div className={`w-24 shrink-0 text-sm text-gray-500 dark:text-gray-400 ${className || ''}`}>
{children}
</div>
);

View file

@ -28,7 +28,7 @@ export function SourceStatus({
{status === 'pending' && (
<>
<div className="flex-shrink-0">
<div className="shrink-0">
<Spinner size="sm" className="text-blue-500 dark:text-blue-400" />
</div>
<div className="flex flex-col">

View file

@ -36,7 +36,7 @@ export function ToggleSource({
onClick={handleToggle}
disabled={loading}
className={`
relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500/20
${isActive ? 'bg-indigo-500' : 'bg-gray-200 dark:bg-gray-700'}
disabled:opacity-50 disabled:cursor-not-allowed

View file

@ -8,11 +8,12 @@ export const metadata: Metadata = {
title: "Add data source"
}
export default async function Page({
params
}: {
params: { projectId: string }
}) {
export default async function Page(
props: {
params: Promise<{ projectId: string }>
}
) {
const params = await props.params;
await requireActiveBillingSubscription();
if (!USE_RAG) {
redirect(`/projects/${params.projectId}`);

View file

@ -6,11 +6,12 @@ export const metadata: Metadata = {
title: "Data sources",
}
export default async function Page({
params,
}: {
params: { projectId: string }
}) {
export default async function Page(
props: {
params: Promise<{ projectId: string }>
}
) {
const params = await props.params;
await requireActiveBillingSubscription();
return <SourcesList
projectId={params.projectId}

View file

@ -9,7 +9,7 @@ interface ProfileFormProps {
mockTools?: boolean;
mockPrompt?: string;
};
formRef: React.RefObject<HTMLFormElement>;
formRef: React.RefObject<HTMLFormElement | null>;
handleSubmit: (formData: FormData) => Promise<void>;
onCancel: () => void;
submitButtonText: string;

View file

@ -2,7 +2,7 @@ import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { Button, Input, Textarea } from "@heroui/react";
interface ScenarioFormProps {
formRef: React.RefObject<HTMLFormElement>;
formRef: React.RefObject<HTMLFormElement | null>;
handleSubmit: (formData: FormData) => Promise<void>;
onCancel: () => void;
submitButtonText: string;

View file

@ -7,7 +7,7 @@ import { ProfileSelector } from "@/app/projects/[projectId]/test/[[...slug]]/com
import { z } from "zod";
interface SimulationFormProps {
formRef: React.RefObject<HTMLFormElement>;
formRef: React.RefObject<HTMLFormElement | null>;
handleSubmit: (formData: FormData) => Promise<void>;
scenario: WithStringId<z.infer<typeof TestScenario>> | null;
setScenario: (scenario: WithStringId<z.infer<typeof TestScenario>> | null) => void;

View file

@ -6,11 +6,12 @@ import { RunsApp } from "./runs_app";
import { TestingMenu } from "./testing_menu";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
export default async function TestPage({ params }: { params: { projectId: string; slug?: string[] } }) {
export default async function TestPage(props: { params: Promise<{ projectId: string; slug?: string[] }> }) {
const params = await props.params;
await requireActiveBillingSubscription();
const { projectId, slug = [] } = params;
let app: "scenarios" | "simulations" | "profiles" | "runs" = "runs";
if (slug[0] === "scenarios") {
app = "scenarios";
} else if (slug[0] === "simulations") {

View file

@ -0,0 +1,175 @@
'use client';
import { useState } from 'react';
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Spinner } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { Key, AlertCircle, Eye, EyeOff } from "lucide-react";
import { setServerAuthToken } from '@/app/actions/klavis_actions';
import { MCPServer } from '@/app/lib/types/types';
import { z } from 'zod';
type McpServerType = z.infer<typeof MCPServer>;
interface AuthTokenModalProps {
isOpen: boolean;
onClose: () => void;
server: McpServerType | null;
onSuccess: () => void;
}
export function AuthTokenModal({ isOpen, onClose, server, onSuccess }: AuthTokenModalProps) {
const [authToken, setAuthToken] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showToken, setShowToken] = useState(false);
const handleSubmit = async () => {
if (!server?.instanceId || !authToken.trim()) {
setError('Please enter a valid auth token');
return;
}
setIsSubmitting(true);
setError(null);
try {
const result = await setServerAuthToken(server.instanceId, authToken.trim());
if (result.success) {
// Success - close modal and refresh data
setAuthToken('');
setError(null);
onSuccess();
onClose();
} else {
// Show validation error
setError(result.error || 'Failed to set auth token');
}
} catch (err) {
setError('Network error. Please check your connection and try again.');
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
setAuthToken('');
setError(null);
onClose();
};
if (!server) return null;
return (
<Modal
isOpen={isOpen}
onOpenChange={handleClose}
size="lg"
classNames={{
base: "bg-white dark:bg-gray-900",
header: "border-b border-gray-200 dark:border-gray-800",
footer: "border-t border-gray-200 dark:border-gray-800",
}}
>
<ModalContent>
<ModalHeader className="flex gap-2 items-center">
<Key className="w-5 h-5 text-blue-500" />
<span>Authenticate {server.name}</span>
</ModalHeader>
<ModalBody>
<div className="space-y-4">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-3">
<p className="text-sm text-blue-700 dark:text-blue-300">
You&apos;ll need to obtain an authentication token from {server.name}. Please refer to their documentation or settings page to find your API key or access token.
</p>
</div>
<div className="space-y-2">
<label htmlFor="auth-token" className="text-sm font-medium text-gray-700 dark:text-gray-300">
Auth Token
</label>
<div className="relative">
<input
id="auth-token"
type={showToken ? 'text' : 'password'}
placeholder="Enter your auth token..."
value={authToken}
onChange={(e) => setAuthToken(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isSubmitting) {
handleSubmit();
}
}}
className="w-full pr-10 pl-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-base text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-900 border-0 shadow-none"
disabled={isSubmitting}
autoComplete="off"
/>
<button
type="button"
tabIndex={-1}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
onClick={() => setShowToken((v) => !v)}
aria-label={showToken ? 'Hide token' : 'Show token'}
>
{showToken ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
{error && (
<div className="flex gap-2 items-start p-3 bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-800 rounded-lg">
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div>
)}
</div>
<style jsx>{`
#auth-token {
box-shadow: none !important;
outline: none !important;
border: none !important;
background: #f3f4f6 !important;
font-size: 1.05rem;
}
#auth-token:focus {
box-shadow: none !important;
outline: none !important;
border: none !important;
background: #e0e7ef !important;
}
.dark #auth-token {
background: #23272f !important;
color: #f3f4f6 !important;
}
.dark #auth-token:focus {
background: #1a1d23 !important;
}
`}</style>
</ModalBody>
<ModalFooter>
<Button
variant="secondary"
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !authToken.trim()}
isLoading={isSubmitting}
>
{isSubmitting ? (
<>
<Spinner size="sm" className="mr-2" />
Authenticating...
</>
) : (
'Authenticate'
)}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View file

@ -0,0 +1,287 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Info, RefreshCw, Search } from 'lucide-react';
import clsx from 'clsx';
import { listToolkits, listTools, updateComposioSelectedTools } from '@/app/actions/composio_actions';
import { getProjectConfig } from '@/app/actions/project_actions';
import { z } from 'zod';
import { ZToolkit, ZListResponse, ZTool } from '@/app/lib/composio/composio';
import { Project } from '@/app/lib/types/project_types';
import { ComposioToolsPanel } from './ComposioToolsPanel';
import { ToolkitCard } from './ToolkitCard';
type ToolkitType = z.infer<typeof ZToolkit>;
type ToolkitListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>;
type ProjectType = z.infer<typeof Project>;
export function Composio() {
const params = useParams();
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
if (!projectId) throw new Error('Project ID is required');
const [toolkits, setToolkits] = useState<ToolkitType[]>([]);
const [projectConfig, setProjectConfig] = useState<ProjectType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedToolkit, setSelectedToolkit] = useState<ToolkitType | null>(null);
const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false);
const [savingTools, setSavingTools] = useState(false);
const loadProjectConfig = useCallback(async () => {
try {
const config = await getProjectConfig(projectId);
setProjectConfig(config);
} catch (err: any) {
console.error('Error fetching project config:', err);
setError('Unable to load project configuration.');
}
}, [projectId]);
const loadAllToolkits = useCallback(async () => {
let cursor: string | null = null;
let allToolkits: ToolkitType[] = [];
try {
setLoading(true);
do {
const response: ToolkitListResponse = await listToolkits(projectId, cursor);
allToolkits = [...allToolkits, ...response.items];
cursor = response.next_cursor;
} while (cursor !== null);
// // Only show those toolkits that
// // - either do not require authentication, OR
// // - have oauth2 managed by Composio
// const filteredToolkits = allToolkits.filter(toolkit => {
// const noAuth = toolkit.no_auth;
// const hasOAuth2 = toolkit.auth_schemes.includes('OAUTH2');
// const hasComposioManagedOAuth2 = toolkit.composio_managed_auth_schemes.includes('OAUTH2');
// return noAuth || hasOAuth2;
// });
setToolkits(allToolkits);
setError(null);
} catch (err: any) {
setError('Unable to load all Composio toolkits. Please check your connection and try again.');
console.error('Error fetching all toolkits:', err);
setToolkits([]);
} finally {
setLoading(false);
}
}, [projectId]);
const handleManageTools = useCallback((toolkit: ToolkitType) => {
setSelectedToolkit(toolkit);
setIsToolsPanelOpen(true);
}, []);
const handleCloseToolsPanel = useCallback(() => {
setSelectedToolkit(null);
setIsToolsPanelOpen(false);
}, []);
const handleProjectConfigUpdate = useCallback(() => {
loadProjectConfig();
}, [loadProjectConfig]);
const handleUpdateToolsSelection = useCallback(async (selectedToolObjects: z.infer<typeof ZTool>[]) => {
if (!projectId) return;
setSavingTools(true);
try {
// Get existing selected tools from project config
const existingSelectedTools = projectConfig?.composioSelectedTools || [];
// Create a map of existing tools by slug for easy lookup
const existingToolsMap = new Map(existingSelectedTools.map(tool => [tool.slug, tool]));
// Add or update the new selections
for (const tool of selectedToolObjects) {
existingToolsMap.set(tool.slug, tool);
}
// Convert back to array
const mergedSelectedTools = Array.from(existingToolsMap.values());
await updateComposioSelectedTools(projectId, mergedSelectedTools);
// Refresh project config to get updated data
await loadProjectConfig();
} catch (error) {
console.error('Error saving tool selection:', error);
} finally {
setSavingTools(false);
}
}, [projectId, projectConfig, loadProjectConfig]);
const handleRemoveToolkitTools = useCallback(async (toolkitSlug: string) => {
if (!projectId) return;
setSavingTools(true);
try {
// Get existing selected tools from project config
const existingSelectedTools = projectConfig?.composioSelectedTools || [];
// Filter out all tools from the specified toolkit
const filteredSelectedTools = existingSelectedTools.filter(tool =>
tool.toolkit.slug !== toolkitSlug
);
await updateComposioSelectedTools(projectId, filteredSelectedTools);
// Refresh project config to get updated data
await loadProjectConfig();
} catch (error) {
console.error('Error removing toolkit tools:', error);
} finally {
setSavingTools(false);
}
}, [projectId, projectConfig, loadProjectConfig]);
useEffect(() => {
loadProjectConfig();
}, [loadProjectConfig]);
useEffect(() => {
loadAllToolkits();
}, [loadAllToolkits]);
const filteredToolkits = toolkits.filter(toolkit => {
const searchLower = searchQuery.toLowerCase();
return (
toolkit.name.toLowerCase().includes(searchLower) ||
toolkit.meta.description.toLowerCase().includes(searchLower) ||
toolkit.slug.toLowerCase().includes(searchLower)
);
}).sort((a, b) => {
// Sort by actual connection status first (only connected tools, not no-auth)
const aConnected = !a.no_auth && projectConfig?.composioConnectedAccounts?.[a.slug]?.status === 'ACTIVE';
const bConnected = !b.no_auth && projectConfig?.composioConnectedAccounts?.[b.slug]?.status === 'ACTIVE';
if (aConnected && !bConnected) return -1;
if (!aConnected && bConnected) return 1;
// If both have same connection status, maintain original order (don't sort alphabetically)
return 0;
});
if (loading) {
return (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading Composio toolkits...</p>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-[50vh] space-y-6 px-4">
<p className="text-center text-red-500 dark:text-red-400 max-w-[600px]">
{error}
</p>
<Button
variant="secondary"
onClick={() => {
loadProjectConfig();
loadAllToolkits();
}}
>
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 flex items-center gap-4">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-2 flex items-center pointer-events-none">
<Search className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<input
type="text"
placeholder="Search toolkits..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-4 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md
bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100
placeholder-gray-400 dark:placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
/>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{filteredToolkits.length} {filteredToolkits.length === 1 ? 'toolkit' : 'toolkits'}
</div>
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
</div>
<Button
size="sm"
variant="secondary"
onClick={() => {
loadProjectConfig();
loadAllToolkits();
}}
disabled={loading}
>
<div className="inline-flex items-center">
<RefreshCw className={clsx("h-4 w-4", loading && "animate-spin")} />
<span className="ml-2">Refresh</span>
</div>
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredToolkits.map((toolkit) => {
const isConnected = toolkit.no_auth || projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.status === 'ACTIVE';
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.id;
return (
<ToolkitCard
key={toolkit.slug}
toolkit={toolkit}
projectId={projectId}
isConnected={isConnected}
connectedAccountId={connectedAccountId}
projectConfig={projectConfig}
onManageTools={() => handleManageTools(toolkit)}
onProjectConfigUpdate={handleProjectConfigUpdate}
onRemoveToolkitTools={handleRemoveToolkitTools}
/>
);
})}
</div>
{filteredToolkits.length === 0 && !loading && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">
{searchQuery ? 'No toolkits found matching your search.' : 'No toolkits available.'}
</p>
</div>
)}
{/* Tools Panel */}
<ComposioToolsPanel
toolkit={selectedToolkit}
isOpen={isToolsPanelOpen}
onClose={handleCloseToolsPanel}
projectConfig={projectConfig}
onUpdateToolsSelection={handleUpdateToolsSelection}
onProjectConfigUpdate={handleProjectConfigUpdate}
onRemoveToolkitTools={handleRemoveToolkitTools}
isSaving={savingTools}
/>
</div>
);
}

View file

@ -0,0 +1,354 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { PictureImg } from '@/components/ui/picture-img';
import { Button, Checkbox } from '@heroui/react';
import { ChevronLeft, ChevronRight, LinkIcon, Loader2, UnlinkIcon } from 'lucide-react';
import { listTools, deleteConnectedAccount } from '@/app/actions/composio_actions';
import { z } from 'zod';
import { ZTool, ZListResponse } from '@/app/lib/composio/composio';
import { SlidePanel } from '@/components/ui/slide-panel';
import { Project } from '@/app/lib/types/project_types';
import { ToolkitAuthModal } from './ToolkitAuthModal';
type ToolType = z.infer<typeof ZTool>;
type ToolListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>;
type ProjectType = z.infer<typeof Project>;
interface ComposioToolsPanelProps {
toolkit: {
slug: string;
name: string;
meta: {
logo: string;
};
no_auth?: boolean;
} | null;
isOpen: boolean;
onClose: () => void;
projectConfig: ProjectType | null;
onUpdateToolsSelection: (selectedToolObjects: ToolType[]) => void;
onProjectConfigUpdate: () => void;
onRemoveToolkitTools: (toolkitSlug: string) => void;
isSaving: boolean;
}
export function ComposioToolsPanel({
toolkit,
isOpen,
onClose,
projectConfig,
onUpdateToolsSelection,
onProjectConfigUpdate,
onRemoveToolkitTools,
isSaving
}: ComposioToolsPanelProps) {
const params = useParams();
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
if (!projectId) throw new Error('Project ID is required');
const [tools, setTools] = useState<ToolType[]>([]);
const [toolsLoading, setToolsLoading] = useState(false);
const [currentCursor, setCurrentCursor] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [cursorHistory, setCursorHistory] = useState<string[]>([]);
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
const [hasChanges, setHasChanges] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false);
const [isProcessingAuth, setIsProcessingAuth] = useState(false);
const loadToolsForToolkit = useCallback(async (toolkitSlug: string, cursor: string | null = null) => {
try {
setToolsLoading(true);
const response: ToolListResponse = await listTools(projectId, toolkitSlug, cursor);
setTools(response.items);
setNextCursor(response.next_cursor);
if (cursor === null) {
// First page - reset pagination state
setCurrentCursor(null);
setCursorHistory([]);
}
} catch (err: any) {
console.error('Error fetching tools:', err);
setTools([]);
} finally {
setToolsLoading(false);
}
}, [projectId]);
const handleNextPage = useCallback(async () => {
if (!nextCursor || !toolkit) return;
// Add current cursor to history
setCursorHistory(prev => [...prev, currentCursor || '']);
setCurrentCursor(nextCursor);
await loadToolsForToolkit(toolkit.slug, nextCursor);
}, [nextCursor, toolkit, currentCursor, loadToolsForToolkit]);
const handlePreviousPage = useCallback(async () => {
if (cursorHistory.length === 0 || !toolkit) return;
// Get the previous cursor from history
const previousCursor = cursorHistory[cursorHistory.length - 1];
const newHistory = cursorHistory.slice(0, -1);
setCursorHistory(newHistory);
setCurrentCursor(previousCursor);
await loadToolsForToolkit(toolkit.slug, previousCursor);
}, [cursorHistory, toolkit, loadToolsForToolkit]);
const handleToolSelectionChange = useCallback((toolSlug: string, selected: boolean) => {
setSelectedTools(prev => {
const next = new Set(prev);
if (selected) {
next.add(toolSlug);
} else {
next.delete(toolSlug);
}
setHasChanges(true);
return next;
});
}, []);
const handleSaveTools = useCallback(async () => {
// Convert selected tool slugs to actual tool objects
const selectedToolObjects = tools.filter(tool => selectedTools.has(tool.slug));
await onUpdateToolsSelection(selectedToolObjects);
setHasChanges(false);
}, [onUpdateToolsSelection, selectedTools, tools]);
const handleConnect = useCallback(() => {
setShowAuthModal(true);
}, []);
const handleDisconnect = useCallback(async () => {
if (!toolkit) return;
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.id;
setIsProcessingAuth(true);
try {
if (connectedAccountId) {
await deleteConnectedAccount(projectId, toolkit.slug, connectedAccountId);
onProjectConfigUpdate();
onRemoveToolkitTools(toolkit.slug);
}
} catch (err: any) {
console.error('Disconnect failed:', err);
} finally {
setIsProcessingAuth(false);
}
}, [projectId, toolkit, projectConfig, onProjectConfigUpdate, onRemoveToolkitTools]);
const handleAuthComplete = useCallback(() => {
setShowAuthModal(false);
onProjectConfigUpdate();
}, [onProjectConfigUpdate]);
const handleClose = useCallback(() => {
setTools([]);
setSelectedTools(new Set());
setHasChanges(false);
if (hasChanges) {
if (window.confirm('You have unsaved changes. Are you sure you want to close?')) {
onClose();
}
} else {
onClose();
}
}, [onClose, hasChanges]);
// Initialize selected tools from project config when opening the panel
useEffect(() => {
if (toolkit && isOpen && projectConfig?.composioSelectedTools) {
const toolSlugs = new Set(projectConfig.composioSelectedTools.map(tool => tool.slug));
setSelectedTools(toolSlugs);
setHasChanges(false);
}
}, [toolkit, isOpen, projectConfig]);
useEffect(() => {
if (toolkit && isOpen) {
loadToolsForToolkit(toolkit.slug, null);
}
}, [toolkit, isOpen, loadToolsForToolkit]);
if (!toolkit) return null;
// Check if the toolkit is connected (has an active connected account) or doesn't require auth
const isToolkitConnected = toolkit.no_auth || projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.status === 'ACTIVE';
return (
<>
<SlidePanel
isOpen={isOpen}
onClose={handleClose}
title={
<div className="flex items-center gap-3">
{toolkit.meta.logo && (
<PictureImg
src={toolkit.meta.logo}
alt={`${toolkit.name} logo`}
width={24}
height={24}
className="rounded-md object-cover"
/>
)}
<span>{toolkit.name}</span>
</div>
}
>
<div className="flex flex-col h-full">
{/* Connection Status Banner */}
{!toolkit.no_auth && (
<div className={`mb-6 p-4 rounded-lg border-2 ${
isToolkitConnected
? 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800'
: 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${
isToolkitConnected ? 'bg-emerald-500' : 'bg-orange-500'
}`}></div>
<div>
<h3 className={`font-semibold text-sm ${
isToolkitConnected
? 'text-emerald-800 dark:text-emerald-200'
: 'text-orange-800 dark:text-orange-200'
}`}>
{isToolkitConnected ? 'Toolkit Connected' : 'Authentication Required'}
</h3>
<p className={`text-xs mt-0.5 ${
isToolkitConnected
? 'text-emerald-700 dark:text-emerald-300'
: 'text-orange-700 dark:text-orange-300'
}`}>
{isToolkitConnected
? 'You can select and use tools from this toolkit'
: 'Connect your account to access and use tools'
}
</p>
</div>
</div>
<Button
variant="solid"
size="sm"
onPress={isToolkitConnected ? handleDisconnect : handleConnect}
disabled={isProcessingAuth}
color={isToolkitConnected ? "danger" : "primary"}
isLoading={isProcessingAuth}
startContent={isToolkitConnected ? <UnlinkIcon className="h-4 w-4" /> : <LinkIcon className="h-4 w-4" />}
>
{isToolkitConnected ? 'Disconnect' : 'Connect Now'}
</Button>
</div>
</div>
)}
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Available Tools</h4>
<div className="flex items-center gap-2">
{hasChanges && (
<Button
variant="solid"
size="sm"
color="primary"
onPress={handleSaveTools}
disabled={isSaving || !isToolkitConnected}
isLoading={isSaving}
>
Save Changes
</Button>
)}
</div>
</div>
</div>
{/* Scrollable Tools List */}
<div className="flex-1 overflow-y-auto">
{toolsLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading tools...</p>
</div>
) : (
<div className="space-y-4">
{tools.map((tool) => (
<div key={tool.slug} className={`group p-4 rounded-lg transition-all duration-200 border border-transparent ${
isToolkitConnected
? 'bg-gray-50/50 dark:bg-gray-800/50 hover:bg-gray-100/50 dark:hover:bg-gray-700/50 hover:border-gray-200 dark:hover:border-gray-600'
: 'bg-gray-100/50 dark:bg-gray-900/50 opacity-60'
}`}>
<div className="flex items-start gap-3">
<Checkbox
isSelected={selectedTools.has(tool.slug)}
onValueChange={(selected) => handleToolSelectionChange(tool.slug, selected)}
size="sm"
isDisabled={!isToolkitConnected}
/>
<div className="flex-1">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
{tool.name}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
{tool.description}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Fixed Pagination Controls */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<div className="flex items-center justify-end">
<div className="flex items-center gap-2">
<Button
variant="bordered"
size="sm"
onClick={handlePreviousPage}
disabled={cursorHistory.length === 0 || toolsLoading}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</Button>
<Button
variant="bordered"
size="sm"
onClick={handleNextPage}
disabled={!nextCursor || toolsLoading}
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</div>
</div>
</SlidePanel>
{/* Auth Modal */}
{toolkit && (
<ToolkitAuthModal
key={toolkit.slug}
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
toolkitSlug={toolkit.slug}
projectId={projectId}
onComplete={handleAuthComplete}
/>
)}
</>
);
}

View file

@ -295,7 +295,7 @@ export function CustomServers() {
<div className="space-y-6">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-4">
<div className="flex gap-3">
<div className="flex-shrink-0">
<div className="shrink-0">
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<p className="text-sm text-blue-700 dark:text-blue-300">

View file

@ -21,6 +21,8 @@ import {
ToolManagementPanel,
} from './MCPServersCommon';
import { BillingUpgradeModal } from '@/components/common/billing-upgrade-modal';
import { AuthTokenModal } from './AuthTokenModal';
import { SERVER_URL_PARAMS } from '@/app/lib/constants/klavis';
type McpServerType = z.infer<typeof MCPServer>;
type McpToolType = z.infer<typeof MCPServer>['tools'][number];
@ -140,6 +142,8 @@ export function HostedServers({ onSwitchTab }: HostedServersProps) {
const [serverToolCounts, setServerToolCounts] = useState<Map<string, number>>(new Map());
const [syncingServers, setSyncingServers] = useState<Set<string>>(new Set());
const [billingError, setBillingError] = useState<string | null>(null);
const [showAuthTokenModal, setShowAuthTokenModal] = useState(false);
const [selectedServerForAuth, setSelectedServerForAuth] = useState<McpServerType | null>(null);
const fetchServers = useCallback(async () => {
try {
@ -362,63 +366,74 @@ export function HostedServers({ onSwitchTab }: HostedServersProps) {
if (!server.instanceId) {
throw new Error('Server instance ID not found');
}
const authUrl = await generateServerAuthUrl(server.name, projectId, server.instanceId);
const authWindow = window.open(
authUrl,
'_blank',
'width=600,height=700'
);
if (authWindow) {
const checkInterval = setInterval(async () => {
if (authWindow.closed) {
clearInterval(checkInterval);
try {
setServerOperations(prev => {
const next = new Map(prev);
next.set(server.name, 'checking-auth');
return next;
});
// Check if this server uses OAuth (in SERVER_URL_PARAMS) or auth token
const usesOAuth = SERVER_URL_PARAMS[server.name];
if (usesOAuth) {
// Use existing OAuth flow
const authUrl = await generateServerAuthUrl(server.name, projectId, server.instanceId);
const authWindow = window.open(
authUrl,
'_blank',
'width=600,height=700'
);
if (authWindow) {
const checkInterval = setInterval(async () => {
if (authWindow.closed) {
clearInterval(checkInterval);
await updateProjectServers(projectId, server.name);
const response = await listAvailableMcpServers(projectId);
if (response.data) {
const updatedServer = response.data.find(us => us.name === server.name);
if (updatedServer) {
setServers(prevServers => {
return prevServers.map(s => {
if (s.name === server.name) {
return { ...updatedServer, serverType: 'hosted' as const };
}
return s;
try {
setServerOperations(prev => {
const next = new Map(prev);
next.set(server.name, 'checking-auth');
return next;
});
await updateProjectServers(projectId, server.name);
const response = await listAvailableMcpServers(projectId);
if (response.data) {
const updatedServer = response.data.find(us => us.name === server.name);
if (updatedServer) {
setServers(prevServers => {
return prevServers.map(s => {
if (s.name === server.name) {
return { ...updatedServer, serverType: 'hosted' as const };
}
return s;
});
});
});
if (selectedServer?.name === server.name) {
setSelectedServer({ ...updatedServer, serverType: 'hosted' as const });
}
if (selectedServer?.name === server.name) {
setSelectedServer({ ...updatedServer, serverType: 'hosted' as const });
}
if (!server.authNeeded || updatedServer.isAuthenticated) {
await handleSyncServer(updatedServer);
if (!server.authNeeded || updatedServer.isAuthenticated) {
await handleSyncServer(updatedServer);
}
}
}
} finally {
setServerOperations(prev => {
const next = new Map(prev);
next.delete(server.name);
return next;
});
}
} finally {
setServerOperations(prev => {
const next = new Map(prev);
next.delete(server.name);
return next;
});
}
}
}, 500);
}, 500);
} else {
window.alert('Failed to open authentication window. Please check your popup blocker settings.');
}
} else {
window.alert('Failed to open authentication window. Please check your popup blocker settings.');
// Use auth token modal
setSelectedServerForAuth(server);
setShowAuthTokenModal(true);
}
} catch (error) {
console.error('[Auth] Error initiating OAuth:', error);
console.error('[Auth] Error initiating authentication:', error);
window.alert('Failed to setup authentication');
}
};
@ -515,6 +530,49 @@ export function HostedServers({ onSwitchTab }: HostedServersProps) {
}
};
const handleAuthTokenSuccess = async () => {
if (!selectedServerForAuth) return;
try {
setServerOperations(prev => {
const next = new Map(prev);
next.set(selectedServerForAuth.name, 'checking-auth');
return next;
});
await updateProjectServers(projectId, selectedServerForAuth.name);
const response = await listAvailableMcpServers(projectId);
if (response.data) {
const updatedServer = response.data.find(us => us.name === selectedServerForAuth.name);
if (updatedServer) {
setServers(prevServers => {
return prevServers.map(s => {
if (s.name === selectedServerForAuth.name) {
return { ...updatedServer, serverType: 'hosted' as const };
}
return s;
});
});
if (selectedServer?.name === selectedServerForAuth.name) {
setSelectedServer({ ...updatedServer, serverType: 'hosted' as const });
}
if (!selectedServerForAuth.authNeeded || updatedServer.isAuthenticated) {
await handleSyncServer(updatedServer);
}
}
}
} finally {
setServerOperations(prev => {
const next = new Map(prev);
next.delete(selectedServerForAuth.name);
return next;
});
}
};
const filteredServers = sortServers(servers.filter(server => {
const searchLower = searchQuery.toLowerCase();
const serverTools = server.tools || [];
@ -578,7 +636,7 @@ export function HostedServers({ onSwitchTab }: HostedServersProps) {
<div className="space-y-6">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-4">
<div className="flex gap-3">
<div className="flex-shrink-0">
<div className="shrink-0">
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<p className="text-sm text-blue-700 dark:text-blue-300">
@ -713,6 +771,16 @@ export function HostedServers({ onSwitchTab }: HostedServersProps) {
onClose={() => setBillingError(null)}
errorMessage={billingError || ''}
/>
<AuthTokenModal
isOpen={showAuthTokenModal}
onClose={() => {
setShowAuthTokenModal(false);
setSelectedServerForAuth(null);
}}
server={selectedServerForAuth}
onSuccess={handleAuthTokenSuccess}
/>
</div>
);
}

View file

@ -280,10 +280,10 @@ export function ServerCard({
)}
</div>
{error && (
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20
py-1 px-2 rounded-md mt-2 animate-fadeIn">
{error.message}
</div>
<div
className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 py-1 px-2 rounded-md mt-2 animate-fadeIn"
dangerouslySetInnerHTML={{ __html: error.message }}
/>
)}
</div>
</div>

View file

@ -193,7 +193,7 @@ export function TestToolModal({ isOpen, onClose, tool, server }: TestToolModalPr
value={item || ''}
onChange={(e) => handleArrayItemChange(index, e.target.value)}
placeholder="Enter value"
className="focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0 focus:outline-none"
className="focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0! focus:outline-none"
/>
) : itemSchema.type === 'number' || itemSchema.type === 'integer' ? (
<Input
@ -209,7 +209,7 @@ export function TestToolModal({ isOpen, onClose, tool, server }: TestToolModalPr
handleArrayItemChange(index, isNaN(val) ? '' : val);
}}
placeholder="Enter value"
className="focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0 focus:outline-none"
className="focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0! focus:outline-none"
/>
) : itemSchema.type === 'boolean' ? (
<div className="scale-75 origin-left">
@ -284,7 +284,7 @@ export function TestToolModal({ isOpen, onClose, tool, server }: TestToolModalPr
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md
bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100
focus:outline-none hover:border-gray-300 dark:hover:border-gray-600
focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0"
focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0!"
>
<option value="" disabled>Select {paramName}</option>
{schema.enum.map((opt: string) => (
@ -299,7 +299,7 @@ export function TestToolModal({ isOpen, onClose, tool, server }: TestToolModalPr
type="datetime-local"
value={value}
onChange={(e) => handleParameterChange(paramName, e.target.value)}
className="focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0 focus:outline-none"
className="focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0! focus:outline-none"
/>
);
}
@ -309,7 +309,7 @@ export function TestToolModal({ isOpen, onClose, tool, server }: TestToolModalPr
type="date"
value={value}
onChange={(e) => handleParameterChange(paramName, e.target.value)}
className="focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0 focus:outline-none"
className="focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0! focus:outline-none"
/>
);
}
@ -319,7 +319,7 @@ export function TestToolModal({ isOpen, onClose, tool, server }: TestToolModalPr
type="time"
value={value}
onChange={(e) => handleParameterChange(paramName, e.target.value)}
className="focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0 focus:outline-none"
className="focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0! focus:outline-none"
/>
);
}
@ -329,7 +329,7 @@ export function TestToolModal({ isOpen, onClose, tool, server }: TestToolModalPr
value={value}
onChange={(e) => handleParameterChange(paramName, e.target.value)}
placeholder={`Enter ${paramName}`}
className="focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0 focus:outline-none"
className="focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0! focus:outline-none"
/>
);
@ -349,7 +349,7 @@ export function TestToolModal({ isOpen, onClose, tool, server }: TestToolModalPr
handleParameterChange(paramName, isNaN(val) ? '' : val);
}}
placeholder={`Enter ${paramName}`}
className="focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0 focus:outline-none"
className="focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0! focus:outline-none"
/>
);
@ -377,7 +377,7 @@ export function TestToolModal({ isOpen, onClose, tool, server }: TestToolModalPr
value={value}
onChange={(e) => handleParameterChange(paramName, e.target.value)}
placeholder={`Enter ${paramName}`}
className="focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0 focus:outline-none"
className="focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0! focus:outline-none"
/>
);
}

View file

@ -0,0 +1,518 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Spinner, Button as HeroButton, Input } from "@heroui/react";
import { PictureImg } from '@/components/ui/picture-img';
import { Wrench, Shield, Key, Globe, ArrowLeft } from "lucide-react";
import { getToolkit, createComposioManagedOauth2ConnectedAccount, syncConnectedAccount, listToolkits, createCustomConnectedAccount } from '@/app/actions/composio_actions';
import { z } from 'zod';
import { ZGetToolkitResponse, ZToolkit, ZComposioField, ZAuthScheme } from '@/app/lib/composio/composio';
interface ToolkitAuthModalProps {
isOpen: boolean;
onClose: () => void;
toolkitSlug: string;
projectId: string;
onComplete: () => void;
}
export function ToolkitAuthModal({
isOpen,
onClose,
toolkitSlug,
projectId,
onComplete
}: ToolkitAuthModalProps) {
const [toolkit, setToolkit] = useState<z.infer<typeof ZGetToolkitResponse> | null>(null);
const [toolkitDetails, setToolkitDetails] = useState<z.infer<typeof ZToolkit> | null>(null);
const [loading, setLoading] = useState(false);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Form state
const [showForm, setShowForm] = useState(false);
const [selectedAuthScheme, setSelectedAuthScheme] = useState<z.infer<typeof ZAuthScheme> | null>(null);
const [formData, setFormData] = useState<Record<string, string>>({});
// Fetch toolkit details when modal opens
useEffect(() => {
if (isOpen && toolkitSlug) {
setLoading(true);
setError(null);
// Fetch both toolkit auth details and full toolkit info
Promise.all([
getToolkit(projectId, toolkitSlug),
listToolkits(projectId).then(response =>
response.items.find(t => t.slug === toolkitSlug) || null
)
])
.then(([authDetails, fullDetails]) => {
setToolkit(authDetails);
setToolkitDetails(fullDetails);
})
.catch(err => {
console.error('Failed to fetch toolkit:', err);
setError('Failed to load toolkit details');
})
.finally(() => setLoading(false));
}
}, [isOpen, toolkitSlug, projectId]);
// Reset form state when modal closes
useEffect(() => {
if (!isOpen) {
setShowForm(false);
setSelectedAuthScheme(null);
setFormData({});
setError(null);
}
}, [isOpen]);
const handleOAuthCompletion = useCallback(async (connectedAccountId: string) => {
try {
// Sync the connected account to get the latest status
await syncConnectedAccount(projectId, toolkitSlug, connectedAccountId);
// Call completion callback
onComplete();
onClose();
} catch (error) {
console.error('Error syncing connected account after OAuth:', error);
setError('Authentication completed but failed to sync status. Please refresh and try again.');
}
}, [projectId, toolkitSlug, onComplete, onClose]);
const handleComposioOAuth2 = useCallback(async () => {
setError(null);
setProcessing(true);
try {
// Start OAuth flow
const returnUrl = `${window.location.origin}/composio/oauth2/callback`;
const response = await createComposioManagedOauth2ConnectedAccount(projectId, toolkitSlug, returnUrl);
console.log('OAuth response:', JSON.stringify(response, null, 2));
// if error, set error
if ('error' in response) {
if (response.error === 'CUSTOM_OAUTH2_CONFIG_REQUIRED') {
setError('Please set up a custom OAuth2 configuration for this toolkit in the Composio dashboard');
} else {
setError('Failed to connect to toolkit');
}
return;
}
// Open OAuth window
const authWindow = window.open(
response.connectionData.val.redirectUrl as string,
'_blank',
'width=600,height=700'
);
if (authWindow) {
// Use postMessage since we control the callback URL
const handleMessage = (event: MessageEvent) => {
// Only accept messages from our own origin
if (event.origin !== window.location.origin) {
return;
}
// Check if this is an OAuth completion message
if (event.data && event.data.type === 'OAUTH_COMPLETE') {
window.removeEventListener('message', handleMessage);
clearInterval(checkInterval);
if (event.data.success) {
// Handle successful OAuth completion
handleOAuthCompletion(response.id);
} else {
// Handle OAuth error
const errorMessage = event.data.errorDescription || event.data.error || 'OAuth authentication failed';
setError(errorMessage);
}
}
};
// Listen for postMessage from our callback page
window.addEventListener('message', handleMessage);
// Minimal fallback: check if window closes without message
const checkInterval = setInterval(() => {
if (authWindow.closed) {
clearInterval(checkInterval);
window.removeEventListener('message', handleMessage);
// If we didn't get a postMessage, still try to sync
// (in case the message was missed for some reason)
handleOAuthCompletion(response.id);
}
}, 1000); // Check less frequently since we expect postMessage
} else {
window.alert('Failed to open authentication window. Please check your popup blocker settings.');
setError('Failed to open authentication window');
}
} catch (err: any) {
console.error('OAuth flow failed:', err);
const errorMessage = err.message || 'Failed to connect to toolkit';
setError(errorMessage);
} finally {
setProcessing(false);
}
}, [projectId, toolkitSlug, handleOAuthCompletion]);
const handleCustomAuth = useCallback((authScheme: z.infer<typeof ZAuthScheme>) => {
setSelectedAuthScheme(authScheme);
// Initialize form data with default values
const authConfig = toolkit?.auth_config_details?.find(config => config.mode === authScheme);
if (authConfig) {
const initialData: Record<string, string> = {};
// Try connected_account_initiation first, fallback to auth_config_creation
const requiredFields = authConfig.fields.connected_account_initiation.required.length > 0
? authConfig.fields.connected_account_initiation.required
: authConfig.fields.auth_config_creation.required;
const optionalFields = authConfig.fields.connected_account_initiation.optional.length > 0
? authConfig.fields.connected_account_initiation.optional
: authConfig.fields.auth_config_creation.optional;
// Add defaults for required fields
requiredFields.forEach(field => {
if (field.default) {
initialData[field.name] = field.default;
}
});
// Add defaults for optional fields
optionalFields.forEach(field => {
if (field.default) {
initialData[field.name] = field.default;
}
});
setFormData(initialData);
}
setShowForm(true);
}, [toolkit]);
const handleFormSubmit = useCallback(async () => {
if (!selectedAuthScheme || !toolkit) return;
setError(null);
setProcessing(true);
try {
const callbackUrl = `${window.location.origin}/composio/oauth2/callback`;
const response = await createCustomConnectedAccount(projectId, {
toolkitSlug: toolkit.slug,
authConfig: {
authScheme: selectedAuthScheme,
credentials: formData,
},
callbackUrl,
});
console.log('Custom auth response:', JSON.stringify(response, null, 2));
// Check if we need to open a popup window (OAuth2 flow)
if ('connectionData' in response &&
response.connectionData.val &&
'redirectUrl' in response.connectionData.val &&
response.connectionData.val.redirectUrl) {
// Open OAuth window for custom OAuth2
const authWindow = window.open(
response.connectionData.val.redirectUrl as string,
'_blank',
'width=600,height=700'
);
if (authWindow) {
// Use the same postMessage logic as Composio OAuth2
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) {
return;
}
if (event.data && event.data.type === 'OAUTH_COMPLETE') {
window.removeEventListener('message', handleMessage);
clearInterval(checkInterval);
if (event.data.success) {
handleOAuthCompletion(response.id);
} else {
const errorMessage = event.data.errorDescription || event.data.error || 'OAuth authentication failed';
setError(errorMessage);
}
}
};
window.addEventListener('message', handleMessage);
const checkInterval = setInterval(() => {
if (authWindow.closed) {
clearInterval(checkInterval);
window.removeEventListener('message', handleMessage);
handleOAuthCompletion(response.id);
}
}, 1000);
} else {
window.alert('Failed to open authentication window. Please check your popup blocker settings.');
setError('Failed to open authentication window');
}
} else {
// No redirect needed, just sync and complete
await syncConnectedAccount(projectId, toolkitSlug, response.id);
onComplete();
onClose();
}
} catch (err: any) {
console.error('Custom auth failed:', err);
const errorMessage = err.message || 'Failed to authenticate with toolkit';
setError(errorMessage);
} finally {
setProcessing(false);
}
}, [selectedAuthScheme, toolkit, projectId, formData, handleOAuthCompletion, onComplete, onClose, toolkitSlug]);
const handleBackToOptions = useCallback(() => {
setShowForm(false);
setSelectedAuthScheme(null);
setFormData({});
setError(null);
}, []);
const getAuthMethodIcon = (authScheme: string) => {
switch (authScheme) {
case 'OAUTH2':
return <Shield className="h-5 w-5" />;
case 'API_KEY':
return <Key className="h-5 w-5" />;
case 'BEARER_TOKEN':
return <Key className="h-5 w-5" />;
default:
return <Globe className="h-5 w-5" />;
}
};
const getAuthMethodName = (authScheme: string) => {
switch (authScheme) {
case 'OAUTH2':
return 'OAuth2';
case 'API_KEY':
return 'API Key';
case 'BEARER_TOKEN':
return 'Bearer Token';
case 'BASIC':
return 'Basic Auth';
default:
return authScheme.toLowerCase().replace('_', ' ');
}
};
return (
<Modal
isOpen={isOpen}
onOpenChange={onClose}
size="md"
classNames={{
base: "bg-white dark:bg-gray-900",
header: "border-b border-gray-200 dark:border-gray-800",
footer: "border-t border-gray-200 dark:border-gray-800",
}}
>
<ModalContent>
<ModalHeader className="flex gap-3 items-center">
{showForm && (
<HeroButton
variant="light"
size="sm"
isIconOnly
onPress={handleBackToOptions}
className="mr-1"
>
<ArrowLeft className="h-4 w-4" />
</HeroButton>
)}
{toolkitDetails?.meta?.logo ? (
<PictureImg
src={toolkitDetails.meta.logo}
alt={`${toolkitSlug} logo`}
className="w-8 h-8 rounded-md object-cover"
/>
) : (
<Wrench className="w-5 h-5 text-blue-500" />
)}
<span>
{showForm
? `Configure ${getAuthMethodName(selectedAuthScheme || '')}`
: `Connect to ${toolkitSlug}`
}
</span>
</ModalHeader>
<ModalBody>
{loading ? (
<div className="flex justify-center py-8">
<Spinner size="lg" />
</div>
) : error ? (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 p-3 rounded-md">
{error}
</div>
) : toolkit ? (
showForm ? (
// Form view
<div className="space-y-4">
<div className="text-sm text-gray-600 dark:text-gray-400">
Enter your credentials for {getAuthMethodName(selectedAuthScheme || '')} authentication:
</div>
{(() => {
const authConfig = toolkit.auth_config_details?.find(config => config.mode === selectedAuthScheme);
if (!authConfig) {
return <div>No configuration found for {selectedAuthScheme}</div>;
}
// Try connected_account_initiation first, fallback to auth_config_creation
const allFields = [
...authConfig.fields.connected_account_initiation.required.map(field => ({ ...field, required: true })),
...authConfig.fields.connected_account_initiation.optional.map(field => ({ ...field, required: false }))
];
// If no fields in connected_account_initiation, try auth_config_creation
if (allFields.length === 0) {
allFields.push(
...authConfig.fields.auth_config_creation.required.map(field => ({ ...field, required: true })),
...authConfig.fields.auth_config_creation.optional.map(field => ({ ...field, required: false }))
);
}
return (
<div className="space-y-4">
{allFields.map(field => (
<Input
key={field.name}
label={field.displayName}
placeholder={field.description}
value={formData[field.name] || ''}
onValueChange={(value) => setFormData(prev => ({ ...prev, [field.name]: value }))}
isRequired={field.required}
type={field.type === 'password' ? 'password' : 'text'}
variant="bordered"
description={field.description}
required={field.required}
/>
))}
</div>
);
})()}
</div>
) : (
// Auth options view
<div className="space-y-4">
<div className="text-sm text-gray-600 dark:text-gray-400">
Choose how you&apos;d like to authenticate with this toolkit:
</div>
<div className="space-y-3">
{/* OAuth2 Composio Managed */}
{toolkit.composio_managed_auth_schemes.includes('OAUTH2') && (
<HeroButton
className="w-full justify-start gap-3 h-auto py-4 px-4"
variant="bordered"
onPress={handleComposioOAuth2}
isDisabled={processing}
size="lg"
>
<div className="bg-green-100 dark:bg-green-900/20 p-2 rounded-lg">
<Shield className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="text-left">
<div className="font-medium">Connect using OAuth2</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Secure authentication managed by Composio
</div>
</div>
{processing && <Spinner size="sm" className="ml-auto" />}
</HeroButton>
)}
{/* Custom OAuth2 - always show if OAuth2 is supported */}
{(toolkit.composio_managed_auth_schemes.includes('OAUTH2') ||
toolkit.auth_config_details?.some(config => config.mode === 'OAUTH2')) && (
<HeroButton
className="w-full justify-start gap-3 h-auto py-4 px-4"
variant="bordered"
onPress={() => handleCustomAuth('OAUTH2')}
isDisabled={processing}
size="lg"
>
<div className="bg-orange-100 dark:bg-orange-900/20 p-2 rounded-lg">
<Shield className="h-5 w-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="text-left">
<div className="font-medium">Connect using custom OAuth2 app</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Use your own OAuth2 configuration
</div>
</div>
</HeroButton>
)}
{/* Other auth schemes (excluding OAuth2 since it's shown above) */}
{toolkit.auth_config_details?.filter(config => config.mode !== 'OAUTH2').map(config => (
<HeroButton
key={config.mode}
className="w-full justify-start gap-3 h-auto py-4 px-4"
variant="bordered"
onPress={() => handleCustomAuth(config.mode)}
isDisabled={processing}
size="lg"
>
<div className="bg-blue-100 dark:bg-blue-900/20 p-2 rounded-lg">
{getAuthMethodIcon(config.mode)}
</div>
<div className="text-left">
<div className="font-medium">Connect using {getAuthMethodName(config.mode)}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Enter your credentials
</div>
</div>
</HeroButton>
))}
</div>
</div>
)
) : null}
</ModalBody>
<ModalFooter>
{showForm ? (
<>
<HeroButton variant="bordered" onPress={handleBackToOptions} isDisabled={processing}>
Back
</HeroButton>
<HeroButton
variant="solid"
color="primary"
onPress={handleFormSubmit}
isDisabled={processing}
isLoading={processing}
>
{processing ? 'Connecting...' : 'Connect'}
</HeroButton>
</>
) : (
<HeroButton variant="bordered" onPress={onClose}>
Cancel
</HeroButton>
)}
</ModalFooter>
</ModalContent>
</Modal>
);
}

View file

@ -0,0 +1,126 @@
'use client';
import { useCallback } from 'react';
import { PictureImg } from '@/components/ui/picture-img';
import clsx from 'clsx';
import { z } from 'zod';
import { ZToolkit } from '@/app/lib/composio/composio';
import { Project } from '@/app/lib/types/project_types';
import { Chip } from '@heroui/react';
import { LinkIcon } from 'lucide-react';
type ToolkitType = z.infer<typeof ZToolkit>;
type ProjectType = z.infer<typeof Project>;
const toolkitCardStyles = {
base: clsx(
"group p-6 rounded-xl transition-all duration-200 cursor-pointer",
"bg-white dark:bg-gray-900",
"border border-gray-200 dark:border-gray-700",
"shadow-md dark:shadow-gray-900/20",
"hover:shadow-lg dark:hover:shadow-gray-900/30",
"hover:border-blue-300 dark:hover:border-blue-600",
"hover:bg-gray-50/50 dark:hover:bg-gray-800/50",
"hover:-translate-y-1",
"min-h-[200px] flex flex-col"
),
};
interface ToolkitCardProps {
toolkit: ToolkitType;
projectId: string;
isConnected: boolean;
connectedAccountId?: string;
projectConfig: ProjectType | null;
onManageTools: () => void;
onProjectConfigUpdate: () => void;
onRemoveToolkitTools: (toolkitSlug: string) => void;
}
export function ToolkitCard({
toolkit,
projectId,
isConnected,
connectedAccountId,
projectConfig,
onManageTools,
onProjectConfigUpdate,
onRemoveToolkitTools
}: ToolkitCardProps) {
const handleCardClick = useCallback(() => {
onManageTools();
}, [onManageTools]);
// Calculate selected tools count for this toolkit
const selectedToolsCount = projectConfig?.composioSelectedTools?.filter(tool =>
tool.toolkit.slug === toolkit.slug
).length || 0;
return (
<div className={toolkitCardStyles.base} onClick={handleCardClick}>
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-start gap-3 mb-4">
{toolkit.meta.logo && (
<PictureImg
src={toolkit.meta.logo}
alt={`${toolkit.name} logo`}
className="w-8 h-8 rounded-md object-cover flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg text-gray-900 dark:text-gray-100 truncate">
{toolkit.name}
</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<Chip
color="secondary"
variant="faded"
size="sm"
>
{selectedToolsCount > 0
? `${toolkit.meta.tools_count} tools, ${selectedToolsCount} selected`
: `${toolkit.meta.tools_count} tools`
}
</Chip>
</div>
</div>
</div>
{/* Description */}
<div className="flex-1">
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-3">
{toolkit.meta.description}
</p>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isConnected && !toolkit.no_auth && (
<Chip
color='success'
variant='flat'
size="sm"
startContent={<LinkIcon className="w-3 h-3 mr-1" />}
>
Connected
</Chip>
)}
{toolkit.no_auth && (
<Chip
color='success'
variant='flat'
size="sm"
>
Ready
</Chip>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -5,10 +5,24 @@ import { Tabs, Tab } from '@/components/ui/tabs';
import { HostedServers } from './HostedServers';
import { CustomServers } from './CustomServers';
import { WebhookConfig } from './WebhookConfig';
import { Composio } from './Composio';
import type { Key } from 'react';
export function ToolsConfig() {
const [activeTab, setActiveTab] = useState('hosted');
export function ToolsConfig({
useComposioTools,
useKlavisTools
}: {
useComposioTools: boolean;
useKlavisTools: boolean;
}) {
let defaultActiveTab = 'custom';
if (useKlavisTools) {
defaultActiveTab = 'hosted';
}
if (useComposioTools) {
defaultActiveTab = 'composio';
}
const [activeTab, setActiveTab] = useState(defaultActiveTab);
const handleTabChange = (key: Key) => {
setActiveTab(key.toString());
@ -23,18 +37,27 @@ export function ToolsConfig() {
className="w-full"
fullWidth
>
<Tab key="hosted" title={
<div className="flex items-center gap-2">
<span>Tools Library</span>
<span className="leading-none px-1.5 py-[2px] text-[9px] font-medium bg-gradient-to-r from-pink-500 to-violet-500 text-white rounded-full">
BETA
</span>
</div>
}>
<div className="mt-4 p-6">
<HostedServers onSwitchTab={key => setActiveTab(key)} />
</div>
</Tab>
{useComposioTools && (
<Tab key="composio" title="Composio">
<div className="mt-4 p-6">
<Composio />
</div>
</Tab>
)}
{useKlavisTools && (
<Tab key="hosted" title={
<div className="flex items-center gap-2">
<span>Klavis</span>
<span className="leading-none px-1.5 py-[2px] text-[9px] font-medium bg-linear-to-r from-pink-500 to-violet-500 text-white rounded-full">
BETA
</span>
</div>
}>
<div className="mt-4 p-6">
<HostedServers onSwitchTab={key => setActiveTab(key)} />
</div>
</Tab>
)}
<Tab key="custom" title="Custom MCP Servers">
<div className="mt-4 p-6">
<CustomServers />

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