Merge pull request #209 from rowboatlabs/dev

dev-changes
This commit is contained in:
Ramnique Singh 2025-08-18 23:29:34 +05:30 committed by GitHub
commit bbc6d8e9f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
466 changed files with 30203 additions and 29959 deletions

View file

@ -1,2 +0,0 @@
__pycache__/
venv/

View file

@ -1,2 +0,0 @@
__pycache__/
venv/

View file

@ -1,21 +0,0 @@
# Use official Python runtime as base image
FROM python:3.11-slim
# Set working directory in container
WORKDIR /app
# Copy requirements file
COPY requirements.txt .
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy project files
COPY . .
# Expose port if your app needs it (adjust as needed)
ENV FLASK_APP=app
ENV PYTHONUNBUFFERED=1
# Command to run Flask development server
CMD ["flask", "run", "--host=0.0.0.0", "--port=3002"]

View file

@ -1,79 +0,0 @@
# AI Workflow Copilot
A Flask-based application that helps design and manage multi-agent AI systems for customer support.
## Prerequisites
- Python 3.8+
- OpenAI API key
## Installation
1. Clone the repository:
2. Create and activate a virtual environment:
```bash
python -m venv venv
source venv/bin/activate # On Windows, use: venv\Scripts\activate
```
3. Install required dependencies:
```bash
pip install -r requirements.txt
```
4. Set up your OpenAI API key:
```bash
export OPENAI_API_KEY='your-api-key-here' # On Windows, use: set OPENAI_API_KEY=your-api-key-here
export API_KEY='test-api-key' # set a shared API key for the application
```
## Running the Application
1. Start the Flask server:
```bash
python app.py
```
The server will start on `http://localhost:3002`
## API Usage
The application exposes a single endpoint at `/chat` that accepts POST requests.
### Example Request:
```bash
curl -X POST http://localhost:3002/chat \
-H "Content-Type: application/json" \
-H "Authorization: Bearer test-api-key" \
-d '{
"messages": [
{
"role": "user",
"content": "Your message here"
}
],
"workflow_schema": "Your workflow schema here",
"current_workflow_config": "Your current workflow configuration here"
}'
```
### Example Response:
```json
{
"response": "Assistant's response here"
}
```
## Error Handling
The API returns appropriate HTTP status codes:
- 400: Invalid request format or data
- 500: Internal server error
## Development
To run the server in debug mode, ensure `debug=True` is set in `app.py` (already included).
## License
[Add your license information here]

View file

@ -1,161 +0,0 @@
from flask import Flask, request, jsonify, Response, stream_with_context
from pydantic import BaseModel, ValidationError, Field
from typing import List, Optional
from copilot import UserMessage, AssistantMessage, get_response
from streaming import get_streaming_response
from lib import AgentContext, PromptContext, ToolContext, ChatContext
import os
from functools import wraps
from copilot import copilot_instructions_edit_agent
import json
class DataSource(BaseModel):
id: str = Field(alias='_id')
name: str
description: Optional[str] = None
active: bool = True
status: str # 'pending' | 'ready' | 'error' | 'deleted'
error: Optional[str] = None
data: dict # The discriminated union based on type
class Config:
populate_by_name = True
class ApiRequest(BaseModel):
projectId: str
messages: List[UserMessage | AssistantMessage]
workflow_schema: str
current_workflow_config: str
context: AgentContext | PromptContext | ToolContext | ChatContext | None = None
dataSources: Optional[List[DataSource]] = None
class ApiResponse(BaseModel):
response: str
app = Flask(__name__)
def validate_request(request_data: ApiRequest) -> None:
"""Validate the chat request data."""
if not request_data.messages:
raise ValueError('Messages list cannot be empty')
if not isinstance(request_data.messages[-1], UserMessage):
raise ValueError('Last message must be a user message')
def require_api_key(f):
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'error': 'Missing or invalid authorization header'}), 401
token = auth_header.split('Bearer ')[1]
actual = os.environ.get('API_KEY', '').strip()
if actual and token != actual:
return jsonify({'error': 'Invalid API key'}), 403
return f(*args, **kwargs)
return decorated
@app.route('/health', methods=['GET'])
def health():
return jsonify({'status': 'ok'})
@app.route('/chat_stream', methods=['POST'])
@require_api_key
def chat_stream():
try:
raw_data = request.json
print(f"Raw request JSON: {json.dumps(raw_data)}")
request_data = ApiRequest(**raw_data)
print(f"received /chat_stream request: {request_data}")
validate_request(request_data)
def generate():
stream = get_streaming_response(
messages=request_data.messages,
workflow_schema=request_data.workflow_schema,
current_workflow_config=request_data.current_workflow_config,
context=request_data.context,
dataSources=request_data.dataSources
)
for chunk in stream:
if chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
yield f"data: {json.dumps({'content': content})}\n\n"
yield "event: done\ndata: {}\n\n"
return Response(
stream_with_context(generate()),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no'
}
)
except ValidationError as ve:
print(ve)
return jsonify({
'error': 'Invalid request format',
'details': str(ve)
}), 400
except ValueError as ve:
print(ve)
return jsonify({
'error': 'Invalid request data',
'details': str(ve)
}), 400
except Exception as e:
print(e)
return jsonify({
'error': 'Internal server error',
'details': str(e)
}), 500
@app.route('/edit_agent_instructions', methods=['POST'])
@require_api_key
def edit_agent_instructions():
try:
request_data = ApiRequest(**request.json)
print(f"received /edit_agent_instructions request: {request_data}")
validate_request(request_data)
response = get_response(
messages=request_data.messages,
workflow_schema=request_data.workflow_schema,
current_workflow_config=request_data.current_workflow_config,
context=request_data.context,
copilot_instructions=copilot_instructions_edit_agent
)
api_response = ApiResponse(response=response).model_dump()
print(f"sending /edit_agent_instructions response: {api_response}")
return jsonify(api_response)
except ValidationError as ve:
print(ve)
return jsonify({
'error': 'Invalid request format',
'details': str(ve)
}), 400
except ValueError as ve:
print(ve)
return jsonify({
'error': 'Invalid request data',
'details': str(ve)
}), 400
except Exception as e:
print(e)
return jsonify({
'error': 'Internal server error',
'details': str(e)
}), 500
if __name__ == '__main__':
print("Starting Flask server...")
app.run(port=3002, host='0.0.0.0', debug=True)

View file

@ -1,34 +0,0 @@
import os
from openai import OpenAI
import dotenv
dotenv.load_dotenv()
PROVIDER_BASE_URL = os.getenv('PROVIDER_BASE_URL', '')
PROVIDER_API_KEY = os.getenv('PROVIDER_API_KEY')
PROVIDER_DEFAULT_MODEL = os.getenv('PROVIDER_DEFAULT_MODEL')
PROVIDER_COPILOT_MODEL = os.getenv('PROVIDER_COPILOT_MODEL')
if not PROVIDER_COPILOT_MODEL:
PROVIDER_COPILOT_MODEL = 'gpt-4.1'
if not PROVIDER_API_KEY:
PROVIDER_API_KEY = os.getenv('OPENAI_API_KEY')
if not PROVIDER_API_KEY:
raise(ValueError("No LLM Provider API key found"))
if not PROVIDER_DEFAULT_MODEL:
PROVIDER_DEFAULT_MODEL = 'gpt-4.1'
completions_client = None
if PROVIDER_BASE_URL:
print(f"Using provider {PROVIDER_BASE_URL}, for completions")
completions_client = OpenAI(
base_url=PROVIDER_BASE_URL,
api_key=PROVIDER_API_KEY
)
else:
print(f"Using OpenAI directly for completions")
completions_client = OpenAI(
api_key=PROVIDER_API_KEY
)

View file

@ -1,105 +0,0 @@
from openai import OpenAI
from flask import Flask, request, jsonify
from pydantic import BaseModel, ValidationError
from typing import List, Dict, Any, Literal, Optional
import json
from lib import AgentContext, PromptContext, ToolContext, ChatContext
from client import PROVIDER_COPILOT_MODEL
from client import completions_client
class UserMessage(BaseModel):
role: Literal["user"]
content: str
class AssistantMessage(BaseModel):
role: Literal["assistant"]
content: str
class DataSource(BaseModel):
_id: str
name: str
description: Optional[str] = None
active: bool = True
status: str # 'pending' | 'ready' | 'error' | 'deleted'
error: Optional[str] = None
data: dict # The discriminated union based on type
with open('copilot_edit_agent.md', 'r', encoding='utf-8') as file:
copilot_instructions_edit_agent = file.read()
def get_response(
messages: List[UserMessage | AssistantMessage],
workflow_schema: str,
current_workflow_config: str,
context: AgentContext | PromptContext | ToolContext | ChatContext | None = None,
dataSources: Optional[List[DataSource]] = None,
copilot_instructions: str = copilot_instructions_edit_agent
) -> str:
# if context is provided, create a prompt for the context
if context:
match context:
case AgentContext():
context_prompt = f"""
**NOTE**: The user is currently working on the following agent:
{context.agentName}
"""
case PromptContext():
context_prompt = f"""
**NOTE**: The user is currently working on the following prompt:
{context.promptName}
"""
case ToolContext():
context_prompt = f"""
**NOTE**: The user is currently working on the following tool:
{context.toolName}
"""
case ChatContext():
context_prompt = f"""
**NOTE**: The user has just tested the following chat using the workflow above and has provided feedback / question below this json dump:
```json
{json.dumps(context.messages)}
```
"""
else:
context_prompt = ""
# Add dataSources to the context if provided
data_sources_prompt = ""
if dataSources:
data_sources_prompt = f"""
**NOTE**: The following data sources are available:
```json
{json.dumps([ds.model_dump() for ds in dataSources])}
```
"""
# add the workflow schema to the system prompt
sys_prompt = copilot_instructions.replace("{workflow_schema}", workflow_schema)
# add the current workflow config to the last user message
last_message = messages[-1]
last_message.content = f"""
Context:
The current workflow config is:
```
{current_workflow_config}
```
{context_prompt}
{data_sources_prompt}
User: {last_message.content}
"""
updated_msgs = [{"role": "system", "content": sys_prompt}] + [
message.model_dump() for message in messages
]
response = completions_client.chat.completions.create(
model=PROVIDER_COPILOT_MODEL,
messages=updated_msgs,
temperature=0.0,
response_format={"type": "json_object"}
)
return response.choices[0].message.content

View file

@ -1,64 +0,0 @@
## 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

@ -1,216 +0,0 @@
## 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:\n```json\n[{"id": "6822e76aa1358752955a455e", "name": "Handbook", "description": "This is a employee handbook", "active": true, "status": "ready", "error": null, "data": {"type": "text"}}]\n```\n\n\nUser: "can you add the handbook to the agent"\n'}]```'
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

@ -1,11 +0,0 @@
## 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

@ -1,238 +0,0 @@
## 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

@ -1,18 +0,0 @@
from pydantic import BaseModel
from typing import Literal, List, Any
class AgentContext(BaseModel):
type: Literal['agent']
agentName: str
class PromptContext(BaseModel):
type: Literal['prompt']
promptName: str
class ToolContext(BaseModel):
type: Literal['tool']
toolName: str
class ChatContext(BaseModel):
type: Literal['chat']
messages: List[Any]

View file

@ -1,25 +0,0 @@
annotated-types==0.7.0
anyio==4.7.0
blinker==1.9.0
certifi==2024.8.30
click==8.1.7
distro==1.9.0
Flask==3.1.0
gunicorn==23.0.0
h11==0.14.0
httpcore==1.0.7
httpx==0.28.0
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.4
jiter==0.8.0
MarkupSafe==3.0.2
openai==1.61.0
packaging==24.2
pydantic==2.10.3
pydantic_core==2.27.1
python-dotenv
sniffio==1.3.1
tqdm==4.67.1
typing_extensions==4.12.2
Werkzeug==3.1.3

View file

@ -1,201 +0,0 @@
from openai import OpenAI
from flask import Flask, request, jsonify, Response, stream_with_context
from pydantic import BaseModel, ValidationError, Field
from typing import List, Dict, Any, Literal, Optional
import json
from lib import AgentContext, PromptContext, ToolContext, ChatContext
from client import PROVIDER_COPILOT_MODEL, PROVIDER_DEFAULT_MODEL
from client import completions_client
class UserMessage(BaseModel):
role: Literal["user"]
content: str
class AssistantMessage(BaseModel):
role: Literal["assistant"]
content: str
class DataSource(BaseModel):
id: str = Field(alias='_id')
name: str
description: Optional[str] = None
active: bool = True
status: str # 'pending' | 'ready' | 'error' | 'deleted'
error: Optional[str] = None
data: dict # The discriminated union based on type
class Config:
populate_by_name = True
with open('copilot_multi_agent.md', 'r', encoding='utf-8') as file:
copilot_instructions_multi_agent = file.read()
with open('copilot_edit_agent.md', 'r', encoding='utf-8') as file:
copilot_instructions_edit_agent = file.read()
with open('example_multi_agent_1.md', 'r', encoding='utf-8') as file:
copilot_multi_agent_example1 = file.read()
with open('current_workflow.md', 'r', encoding='utf-8') as file:
current_workflow_prompt = file.read()
# Combine the instruction files to create the full multi-agent instructions
streaming_instructions = "\n\n".join([
copilot_instructions_multi_agent,
copilot_multi_agent_example1,
current_workflow_prompt
])
def get_streaming_response(
messages: List[UserMessage | AssistantMessage],
workflow_schema: str,
current_workflow_config: str,
context: AgentContext | PromptContext | ToolContext | ChatContext | None = None,
dataSources: Optional[List[DataSource]] = None,
) -> Any:
# if context is provided, create a prompt for the context
if context:
match context:
case AgentContext():
context_prompt = f"""
**NOTE**: The user is currently working on the following agent:
{context.agentName}
"""
case PromptContext():
context_prompt = f"""
**NOTE**: The user is currently working on the following prompt:
{context.promptName}
"""
case ToolContext():
context_prompt = f"""
**NOTE**: The user is currently working on the following tool:
{context.toolName}
"""
case ChatContext():
context_prompt = f"""
**NOTE**: The user has just tested the following chat using the workflow above and has provided feedback / question below this json dump:
```json
{json.dumps(context.messages)}
```
"""
else:
context_prompt = ""
# Add dataSources to the context if provided
data_sources_prompt = ""
if dataSources:
print(f"Data sources found at project level: {dataSources}")
print(f"Data source IDs: {[ds.id for ds in dataSources]}")
data_sources_prompt = f"""
**NOTE**: The following data sources are available:
```json
{json.dumps([ds.model_dump() for ds in dataSources])}
```
"""
else:
print("No data sources found at project level")
# add the workflow schema to the system prompt
sys_prompt = streaming_instructions.replace("{workflow_schema}", workflow_schema)
# add the agent model to the system prompt
sys_prompt = sys_prompt.replace("{agent_model}", PROVIDER_DEFAULT_MODEL)
# add the current workflow config to the last user message
last_message = messages[-1]
last_message.content = f"""
Context:
The current workflow config is:
```
{current_workflow_config}
```
{context_prompt}
{data_sources_prompt}
User: {last_message.content}
"""
updated_msgs = [{"role": "system", "content": sys_prompt}] + [
message.model_dump() for message in messages
]
print(f"Input to copilot chat completions: {updated_msgs}")
return completions_client.chat.completions.create(
model=PROVIDER_COPILOT_MODEL,
messages=updated_msgs,
temperature=0.0,
stream=True
)
def create_app():
app = Flask(__name__)
@app.route('/health', methods=['GET'])
def health():
return jsonify({'status': 'ok'})
@app.route('/chat_stream', methods=['POST'])
def chat_stream():
try:
request_data = request.json
if not request_data or 'messages' not in request_data:
return jsonify({'error': 'No messages provided'}), 400
print(f"Raw request data: {request_data}")
messages = [
UserMessage(**msg) if msg['role'] == 'user' else AssistantMessage(**msg)
for msg in request_data['messages']
]
workflow_schema = request_data.get('workflow_schema', '')
current_workflow_config = request_data.get('current_workflow_config', '')
context = None # You can add context handling if needed
dataSources = None
if 'dataSources' in request_data and request_data['dataSources']:
print(f"Raw dataSources from request: {request_data['dataSources']}")
dataSources = [DataSource(**ds) for ds in request_data['dataSources']]
print(f"Parsed dataSources: {dataSources}")
def generate():
stream = get_streaming_response(
messages=messages,
workflow_schema=workflow_schema,
current_workflow_config=current_workflow_config,
context=context,
dataSources=dataSources
)
for chunk in stream:
if chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
yield f"data: {json.dumps({'content': content})}\n\n"
yield "event: done\ndata: {}\n\n"
return Response(
stream_with_context(generate()),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no'
}
)
except ValidationError as ve:
return jsonify({
'error': 'Invalid request format',
'details': str(ve)
}), 400
except Exception as e:
return jsonify({
'error': 'Internal server error',
'details': str(e)
}), 500
return app
if __name__ == '__main__':
app = create_app()
print("Starting Flask server...")
app.run(port=3002, host='0.0.0.0', debug=True)

View file

@ -1,2 +0,0 @@
__pycache__
.venv/

View file

@ -1,24 +0,0 @@
# Environment variables for the Voice API application
# Twilio configuration
TWILIO_ACCOUNT_SID=your_account_sid_here
TWILIO_AUTH_TOKEN=your_auth_token_here
BASE_URL=https://your-public-url-here.ngrok.io
# RowBoat API configuration
ROWBOAT_API_HOST=http://localhost:3000
ROWBOAT_PROJECT_ID=your_project_id_here
ROWBOAT_API_KEY=your_api_key_here
# Speech processing APIs
DEEPGRAM_API_KEY=your_deepgram_api_key_here
ELEVENLABS_API_KEY=your_elevenlabs_api_key_here
# Server configuration
PORT=3009
WHATSAPP_PORT=3010
# Redis configuration for persistent state
REDIS_URL=redis://localhost:6379/0
REDIS_EXPIRY_SECONDS=86400
SERVICE_NAME=rowboat-voice

View file

@ -1,2 +0,0 @@
__pycache__
.venv

View file

@ -1,18 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
# Copy requirements first to leverage Docker cache
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Set environment variables
ENV FLASK_APP=app
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app
# Command to run Flask development server
CMD ["flask", "run", "--host=0.0.0.0", "--port=4010"]

View file

@ -1,633 +0,0 @@
from flask import Flask, request, jsonify, Response
from twilio.twiml.voice_response import VoiceResponse, Gather
import os
import logging
import uuid
from typing import Dict, Any, Optional
import json
from time import time
from rowboat.schema import SystemMessage, UserMessage, ApiMessage
import elevenlabs
# Load environment variables
from load_env import load_environment
load_environment()
from twilio_api import process_conversation_turn
# Import MongoDB utility functions
from util import (
get_call_state,
save_call_state,
delete_call_state,
get_mongodb_status,
get_twilio_config,
CallState
)
Message = SystemMessage | UserMessage
ELEVENLABS_API_KEY = os.environ.get("ELEVENLABS_API_KEY")
elevenlabs_client = elevenlabs.ElevenLabs(api_key=ELEVENLABS_API_KEY)
app = Flask(__name__)
# Configure logging to stdout for Docker compatibility
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler()] # Send logs to stdout
)
logger = logging.getLogger(__name__)
# Local in-memory cache of call state (temporary cache only - not primary storage)
# MongoDB is the primary storage for state across multiple instances
active_calls = {}
# TTS configuration
TTS_VOICE = "Markus - Mature and Chill"
TTS_MODEL = "eleven_flash_v2_5"
@app.route('/inbound', methods=['POST'])
def handle_inbound_call():
"""Handle incoming calls to Twilio numbers configured for RowBoat"""
try:
# Log the entire request for debugging
logger.info(f"Received inbound call request: {request.values}")
# Get the Twilio phone number that received the call
to_number = request.values.get('To')
call_sid = request.values.get('CallSid')
from_number = request.values.get('From')
logger.info(f"Inbound call from {from_number} to {to_number}, CallSid: {call_sid}")
logger.info(f"Raw To number value: '{to_number}', Type: {type(to_number)}")
# Get configuration ONLY from MongoDB
system_prompt = "You are a helpful assistant. Provide concise and clear answers."
workflow_id = None
project_id = None
# Look up configuration in MongoDB
twilio_config = get_twilio_config(to_number)
if twilio_config:
workflow_id = twilio_config['workflow_id']
project_id = twilio_config['project_id']
system_prompt = twilio_config.get('system_prompt', system_prompt)
logger.info(f"Found MongoDB configuration for {to_number}: project_id={project_id}, workflow_id={workflow_id}")
else:
logger.warning(f"No active configuration found in MongoDB for phone number {to_number}")
if not workflow_id:
# No workflow found - provide error message
logger.error(f"No workflow_id found for inbound call to {to_number}")
response = VoiceResponse()
response.say("I'm sorry, this phone number is not properly configured in our system. Please contact support.", voice='alice')
# Include additional information in TwiML for debugging
response.say(f"Received call to number {to_number}", voice='alice')
response.hangup()
return str(response)
# Initialize call state with stateless API fields
call_state = CallState(
workflow_id=workflow_id,
project_id=project_id,
system_prompt=system_prompt,
conversation_history=[],
messages=[], # For stateless API
state=None, # For stateless API state
turn_count=0,
inbound=True,
to_number=to_number,
created_at=int(time()) # Add timestamp for expiration tracking
)
# Save to MongoDB (primary source of truth)
try:
save_call_state(call_sid, call_state)
logger.info(f"Saved initial call state to MongoDB for inbound call {call_sid}")
except Exception as e:
logger.error(f"Error saving inbound call state to MongoDB: {str(e)}")
raise RuntimeError(f"Failed to save call state to MongoDB: {str(e)}")
# Only use memory storage as a temporary cache
# The service that handles the next request might be different
active_calls[call_sid] = call_state
logger.info(f"Initialized call state for {call_sid}, proceeding to handle_call")
# Create a direct response instead of redirecting
return handle_call(call_sid, workflow_id, project_id)
except Exception as e:
# Log the full error with traceback
import traceback
logger.error(f"Error in handle_inbound_call: {str(e)}")
logger.error(traceback.format_exc())
# Return a basic TwiML response so Twilio doesn't get a 500 error
response = VoiceResponse()
response.say("I'm sorry, we encountered an error processing your call. Please try again later.", voice='alice')
response.hangup()
return str(response)
@app.route('/twiml', methods=['POST'])
def handle_twiml_call():
"""TwiML endpoint for outbound call handling"""
call_sid = request.values.get('CallSid')
# Get call state to retrieve workflow_id and project_id
call_state = get_call_state(call_sid)
if call_state:
workflow_id = call_state.get('workflow_id')
project_id = call_state.get('project_id')
return handle_call(call_sid, workflow_id, project_id)
else:
# No call state found - error response
response = VoiceResponse()
response.say("I'm sorry, your call session has expired. Please try again.", voice='alice')
response.hangup()
return str(response)
def handle_call(call_sid, workflow_id, project_id=None):
"""Common handler for both inbound and outbound calls"""
try:
logger.info(f"handle_call: processing call {call_sid} with workflow {workflow_id}, project_id {project_id}")
# Get or initialize call state, first from MongoDB
call_state = None
try:
# Query MongoDB for the call state
call_state = get_call_state(call_sid)
if call_state:
logger.info(f"Loaded and restored call state from MongoDB for {call_sid}")
except Exception as e:
logger.error(f"Error retrieving MongoDB state for {call_sid}: {str(e)}")
call_state = None
# Try in-memory cache as fallback (temporary local cache)
if call_state is None and call_sid in active_calls:
call_state = active_calls.get(call_sid)
logger.info(f"Using in-memory cache for call state of {call_sid}")
# Initialize new state if needed
if call_state is None and workflow_id:
call_state = CallState(
workflow_id=workflow_id,
project_id=project_id,
system_prompt="You are a helpful assistant. Provide concise and clear answers.",
conversation_history=[],
messages=[], # For stateless API
state=None, # For stateless API state
turn_count=0,
inbound=False, # Default for outbound calls
to_number="", # This will be set properly for inbound calls
created_at=int(time()), # Add timestamp for expiration tracking
last_transcription=""
)
# Save to MongoDB (primary source of truth)
try:
save_call_state(call_sid, call_state)
logger.info(f"Initialized and saved new call state to MongoDB for {call_sid}")
except Exception as e:
logger.error(f"Error saving new call state to MongoDB: {str(e)}")
raise RuntimeError(f"Failed to save call state to MongoDB: {str(e)}")
# Only use memory as temporary cache for this request
active_calls[call_sid] = call_state
logger.info(f"Initialized new call state for {call_sid}")
logger.info(f"Using call state: {call_state}")
# Create TwiML response
response = VoiceResponse()
# Check if this is a new call (no turns yet)
if call_state.get('turn_count', 0) == 0:
logger.info("First turn: generating AI greeting using an empty user input...")
# Generate greeting by calling process_conversation_turn with empty user input
try:
ai_greeting, updated_messages, updated_state = process_conversation_turn(
user_input="", # empty to signal "give me your greeting"
workflow_id=call_state['workflow_id'],
system_prompt=call_state['system_prompt'],
previous_messages=[],
previous_state=None,
project_id=call_state.get('project_id')
)
except Exception as e:
logger.error(f"Error generating AI greeting: {str(e)}")
ai_greeting = "Hello, I encountered an issue creating a greeting. How can I help you?"
# Fallback: no changes to updated_messages/updated_state
updated_messages = []
updated_state = None
# Update call_state with AI greeting
call_state['messages'] = updated_messages
call_state['state'] = updated_state
call_state['conversation_history'].append({
'user': "", # empty user
'assistant': ai_greeting
})
call_state['turn_count'] = 1
# Save changes to MongoDB
try:
save_call_state(call_sid, call_state)
logger.info(f"Saved greeting state to MongoDB for {call_sid}")
except Exception as e:
logger.error(f"Error saving greeting state to MongoDB: {str(e)}")
raise RuntimeError(f"Failed to save greeting state to MongoDB: {str(e)}")
active_calls[call_sid] = call_state
# Play the greeting via streaming audio
unique_id = str(uuid.uuid4())
audio_url = f"/stream-audio/{call_sid}/greeting/{unique_id}"
logger.info(f"Will stream greeting from {audio_url}")
response.play(audio_url)
# Gather user input next
gather = Gather(
input='speech',
action=f'/process_speech?call_sid={call_sid}',
speech_timeout='auto',
language='en-US',
enhanced=True,
speechModel='phone_call'
)
response.append(gather)
response.redirect('/twiml')
logger.info(f"Returning response: {str(response)}")
return str(response)
except Exception as e:
# Log the full error with traceback
import traceback
logger.error(f"Error in handle_call: {str(e)}")
logger.error(traceback.format_exc())
# Return a basic TwiML response
response = VoiceResponse()
response.say("I'm sorry, we encountered an error processing your call. Please try again later.", voice='alice')
response.hangup()
return str(response)
@app.route('/process_speech', methods=['POST'])
def process_speech():
"""Process user speech input and generate AI response"""
try:
logger.info(f"Processing speech: {request.values}")
call_sid = request.args.get('call_sid')
# Log all request values for debugging
logger.info(f"FULL REQUEST VALUES: {dict(request.values)}")
logger.info(f"FULL REQUEST ARGS: {dict(request.args)}")
# Get the speech result directly from Twilio
# We're now relying on Twilio's enhanced speech recognition instead of Deepgram
speech_result = request.values.get('SpeechResult')
confidence = request.values.get('Confidence')
logger.info(f"Twilio SpeechResult: {speech_result}")
logger.info(f"Twilio Confidence: {confidence}")
if not call_sid:
logger.warning(f"Missing call_sid: {call_sid}")
response = VoiceResponse()
response.say("I'm sorry, I couldn't process that request.", voice='alice')
response.hangup()
return str(response)
if not speech_result:
logger.warning("No speech result after transcription attempts")
response = VoiceResponse()
response.say("I'm sorry, I didn't catch what you said. Could you please try again?", voice='alice')
# Gather user input again
gather = Gather(
input='speech',
action=f'/process_speech?call_sid={call_sid}',
speech_timeout='auto',
language='en-US',
enhanced=True,
speechModel='phone_call'
)
response.append(gather)
# Redirect to twiml endpoint which will get call state from MongoDB
response.redirect('/twiml')
return str(response)
# Load call state from MongoDB (primary source of truth)
call_state = None
try:
call_state = get_call_state(call_sid)
if call_state:
logger.info(f"Loaded call state from MongoDB for speech processing: {call_sid}")
except Exception as e:
logger.error(f"Error retrieving MongoDB state for speech processing: {str(e)}")
call_state = None
# Try memory cache as fallback
if call_state is None and call_sid in active_calls:
call_state = active_calls[call_sid]
logger.info(f"Using in-memory state for speech processing: {call_sid}")
# Check if we have valid state
if not call_state:
logger.warning(f"No call state found for speech processing: {call_sid}")
response = VoiceResponse()
response.say("I'm sorry, your call session has expired. Please call back.", voice='alice')
response.hangup()
return str(response)
# Extract key information
workflow_id = call_state.get('workflow_id')
project_id = call_state.get('project_id')
system_prompt = call_state.get('system_prompt', "You are a helpful assistant.")
# Check if we have a Deepgram transcription stored in the call state
if 'last_transcription' in call_state and call_state['last_transcription']:
deepgram_transcription = call_state['last_transcription']
logger.info(f"Found stored Deepgram transcription: {deepgram_transcription}")
logger.info(f"Comparing with Twilio transcription: {speech_result}")
# Use the Deepgram transcription instead of Twilio's
speech_result = deepgram_transcription
# Remove it so we don't use it again
del call_state['last_transcription']
logger.info(f"Using Deepgram transcription instead")
# Log final user input that will be used
logger.info(f"Final user input: {speech_result}")
# Process with RowBoat agent
try:
# Clean up the speech result if needed
if speech_result:
# Remove any common filler words or fix typical transcription issues
import re
# Convert to lowercase for easier pattern matching
cleaned_input = speech_result.lower()
# Remove filler words that might be at the beginning
cleaned_input = re.sub(r'^(um|uh|like|so|okay|well)\s+', '', cleaned_input)
# Capitalize first letter for better appearance
if cleaned_input:
speech_result = cleaned_input[0].upper() + cleaned_input[1:]
logger.info(f"Sending to RowBoat: '{speech_result}'")
# Get previous messages and state from call state
previous_messages = call_state.get('messages', [])
previous_state = call_state.get('state')
# Process with stateless API
ai_response, updated_messages, updated_state = process_conversation_turn(
user_input=speech_result,
workflow_id=workflow_id,
system_prompt=system_prompt,
previous_messages=previous_messages,
previous_state=previous_state,
project_id=project_id
)
# Update the messages and state in call state
call_state['messages'] = updated_messages
call_state['state'] = updated_state
logger.info(f"RowBoat response: {ai_response}")
except Exception as e:
logger.error(f"Error processing with RowBoat: {str(e)}")
ai_response = "I'm sorry, I encountered an issue processing your request. Could you please try again?"
# Conversation history is updated in the streaming response section below
# Create TwiML response
response = VoiceResponse()
# Use streaming audio for the response
logger.info("Setting up response streaming with ElevenLabs")
try:
# Store the AI response in conversation history first
# (The stream-audio endpoint will read it from here)
# Update conversation history (do this before streaming so the endpoint can access it)
call_state['conversation_history'].append({
'user': speech_result,
'assistant': ai_response
})
call_state['turn_count'] += 1
# Save to MongoDB (primary source of truth)
try:
save_call_state(call_sid, call_state)
logger.info(f"Saved response state to MongoDB for {call_sid}")
except Exception as e:
logger.error(f"Error saving response state to MongoDB: {str(e)}")
raise RuntimeError(f"Failed to save response state to MongoDB: {str(e)}")
# Update local memory cache
active_calls[call_sid] = call_state
# Generate a unique ID to prevent caching
unique_id = str(uuid.uuid4())
# Use a relative URL - Twilio will use the same host as the webhook
audio_url = f"/stream-audio/{call_sid}/response/{unique_id}"
logger.info(f"Streaming response from relative URL: {audio_url}")
# Play the response via streaming
response.play(audio_url)
except Exception as e:
logger.error(f"Error with audio streaming for response: {str(e)}")
import traceback
logger.error(traceback.format_exc())
# Fallback to Twilio TTS
response.say(ai_response, voice='alice')
# Gather next user input with enhanced speech recognition
gather = Gather(
input='speech',
action=f'/process_speech?call_sid={call_sid}',
speech_timeout='auto',
language='en-US',
enhanced=True, # Enable enhanced speech recognition
speechModel='phone_call' # Optimize for phone calls
)
response.append(gather)
# If no input detected, redirect to twiml endpoint
# Call state will be retrieved from MongoDB
response.redirect('/twiml')
logger.info(f"Returning TwiML response for speech processing")
return str(response)
except Exception as e:
# Log the full error with traceback
import traceback
logger.error(f"Error in process_speech: {str(e)}")
logger.error(traceback.format_exc())
# Return a basic TwiML response
response = VoiceResponse()
response.say("I'm sorry, we encountered an error processing your speech. Please try again.", voice='alice')
response.gather(
input='speech',
action=f'/process_speech?call_sid={request.args.get("call_sid")}',
speech_timeout='auto'
)
return str(response)
@app.route('/stream-audio/<call_sid>/<text_type>/<unique_id>', methods=['GET'])
def stream_audio(call_sid, text_type, unique_id):
"""Stream audio directly from ElevenLabs to Twilio without saving to disk"""
try:
logger.info(f"Audio streaming requested for call {call_sid}, type {text_type}")
# Determine what text to synthesize
text_to_speak = ""
if text_type == "greeting" or text_type == "response":
# Get the text from call state (try MongoDB first, then memory)
call_state = None
# Try MongoDB first
try:
call_state = get_call_state(call_sid)
if call_state:
logger.info(f"Loaded call state from MongoDB for streaming: {call_sid}")
except Exception as e:
logger.error(f"Error retrieving MongoDB state for streaming: {str(e)}")
call_state = None
# Fall back to memory if needed
if call_state is None:
if call_sid not in active_calls:
logger.error(f"Call SID not found for streaming: {call_sid}")
return "Call not found", 404
call_state = active_calls[call_sid]
logger.info(f"Using in-memory state for streaming: {call_sid}")
if call_state.get('conversation_history') and len(call_state['conversation_history']) > 0:
# Get the most recent AI response
text_to_speak = call_state['conversation_history'][-1]['assistant']
else:
logger.warning(f"No conversation history found for call {call_sid}")
text_to_speak = "I'm sorry, I don't have a response ready. Could you please repeat?"
else:
# Direct text may be passed as the text_type (for testing)
text_to_speak = text_type
if not text_to_speak:
logger.error("No text to synthesize")
return "No text to synthesize", 400
logger.info(f"Streaming audio for text: {text_to_speak[:50]}...")
def generate():
try:
# Generate and stream the audio directly
audio_stream = elevenlabs_client.generate(
text=text_to_speak,
voice=TTS_VOICE,
model=TTS_MODEL,
output_format="mp3_44100_128"
)
# Stream chunks directly to the response
for chunk in audio_stream:
yield chunk
logger.info(f"Finished streaming audio for call {call_sid}")
except Exception as e:
logger.error(f"Error in audio stream generator: {str(e)}")
import traceback
logger.error(traceback.format_exc())
# Return a streaming response
response = Response(generate(), mimetype='audio/mpeg')
return response
except Exception as e:
logger.error(f"Error setting up audio stream: {str(e)}")
import traceback
logger.error(traceback.format_exc())
return "Error streaming audio", 500
@app.route('/call-status', methods=['POST'])
def call_status_callback():
"""Handle call status callbacks from Twilio"""
call_sid = request.values.get('CallSid')
call_status = request.values.get('CallStatus')
logger.info(f"Call {call_sid} status: {call_status}")
# Clean up resources when call completes
if call_status in ['completed', 'failed', 'busy', 'no-answer', 'canceled']:
# Get call state from MongoDB or memory
call_state = None
# Try to load from MongoDB first
try:
call_state = get_call_state(call_sid)
if call_state:
logger.info(f"Loaded final state from MongoDB for {call_sid}")
except Exception as e:
logger.error(f"Error retrieving final state from MongoDB: {str(e)}")
call_state = None
# Fall back to memory if needed
if call_state is None and call_sid in active_calls:
call_state = active_calls[call_sid]
logger.info(f"Using in-memory state for final call state of {call_sid}")
if call_state:
# Remove from active calls in both memory and MongoDB
if call_sid in active_calls:
del active_calls[call_sid]
logger.info(f"Removed call {call_sid} from active calls memory")
try:
# Remove the document from MongoDB
delete_call_state(call_sid)
logger.info(f"Removed call {call_sid} from MongoDB")
except Exception as e:
logger.error(f"Error removing call state from MongoDB: {str(e)}")
return '', 204
@app.route('/health', methods=['GET'])
def health_check():
"""Simple health check endpoint"""
health_data = {
"status": "healthy",
"active_calls_memory": len(active_calls)
}
# Get MongoDB status
try:
mongodb_status = get_mongodb_status()
health_data["mongodb"] = mongodb_status
health_data["active_calls_mongodb"] = mongodb_status.get("active_calls", 0)
except Exception as e:
health_data["mongodb_error"] = str(e)
health_data["status"] = "degraded"
return jsonify(health_data)
if __name__ == '__main__':
# Log startup information
logger.info(f"Starting Twilio-RowBoat server")
# Remove the explicit run configuration since Flask CLI will handle it
app.run()

View file

@ -1,6 +0,0 @@
from dotenv import load_dotenv
import os
def load_environment():
"""Load environment variables from .env file"""
load_dotenv()

View file

@ -1,39 +0,0 @@
aiohappyeyeballs==2.5.0
aiohttp==3.11.13
aiohttp-retry==2.9.1
aiosignal==1.3.2
annotated-types==0.7.0
anyio==4.8.0
attrs==25.1.0
blinker==1.9.0
certifi==2025.1.31
charset-normalizer==3.4.1
click==8.1.8
dnspython==2.7.0
dotenv==0.9.9
elevenlabs==1.52.0
Flask==3.1.0
frozenlist==1.5.0
h11==0.14.0
httpcore==1.0.7
httpx==0.28.1
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.2
multidict==6.1.0
propcache==0.3.0
pydantic==2.10.6
pydantic_core==2.27.2
PyJWT==2.10.1
pymongo==4.11.2
python-dotenv==1.0.1
requests==2.32.3
rowboat==2.1.0
sniffio==1.3.1
twilio==9.4.6
typing_extensions==4.12.2
urllib3==2.3.0
websockets==15.0.1
Werkzeug==3.1.3
yarl==1.18.3

View file

@ -1,96 +0,0 @@
from twilio.rest import Client as TwilioClient
from rowboat.client import Client
from rowboat.schema import UserMessage, SystemMessage
import os
from typing import Dict, List, Optional, Tuple, Any
import logging
from util import get_api_key
import time
import json
# Load environment variables
from load_env import load_environment
load_environment()
# Configure logging to stdout for Docker compatibility
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler()] # Send logs to stdout
)
logger = logging.getLogger(__name__)
# Environment variables and configuration
ROWBOAT_API_HOST = os.environ.get("ROWBOAT_API_HOST").strip()
Message = UserMessage | SystemMessage
def process_conversation_turn(
user_input: str,
workflow_id: str,
system_prompt: str = "You are a helpful assistant. Provide concise and clear answers.",
previous_messages: List[Message] = None,
previous_state: Any = None,
project_id: str = None
) -> Tuple[str, List[Message], Any]:
"""
Process a single conversation turn with the RowBoat agent using the stateless API.
Args:
user_input: User's transcribed input
workflow_id: RowBoat workflow ID
system_prompt: System prompt for the agent
previous_messages: Previous messages in the conversation
previous_state: Previous state from RowBoat
project_id: RowBoat project ID (if different from default)
Returns:
A tuple of (response_text, updated_messages, updated_state)
"""
try:
# Initialize messages list if not provided
messages = [] if previous_messages is None else previous_messages.copy()
# If we're starting a new conversation, add the system message
if not messages or not any(msg.role == 'system' for msg in messages):
messages.append(SystemMessage(role='system', content=system_prompt))
# Add the user's new
if user_input:
messages.append(UserMessage(role='user', content=user_input))
# Process the conversation using the stateless API
logger.info(f"Sending to RowBoat API with {len(messages)} messages")
# Create client with custom project_id if provided
client = Client(
host=ROWBOAT_API_HOST,
project_id=project_id,
api_key=get_api_key(project_id)
)
response_messages, new_state = client.chat(
messages=messages,
workflow_id=workflow_id,
state=previous_state
)
# Extract the assistant's response (last message)
if response_messages and len(response_messages) > 0:
assistant_response = response_messages[-1].content
else:
assistant_response = "I'm sorry, I didn't receive a proper response."
# Update messages list with the new responses
final_messages = messages + response_messages
logger.info(f"Got response from RowBoat API: {assistant_response[:100]}...")
return assistant_response, final_messages, new_state
except Exception as e:
logger.error(f"Error processing conversation turn: {str(e)}")
import traceback
logger.error(traceback.format_exc())
return "I'm sorry, I encountered an error processing your request.", previous_messages, previous_state

View file

@ -1,423 +0,0 @@
import os
import logging
import datetime
from typing import Dict, Any, Optional, List, Union
import copy
from pymongo import MongoClient
from pymongo.errors import ConnectionFailure, PyMongoError
from pymongo.collection import Collection
from bson import json_util
from pydantic import BaseModel
from rowboat.schema import ApiMessage
# Configure logging to stdout for Docker compatibility
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler()] # Send logs to stdout
)
logger = logging.getLogger(__name__)
# MongoDB Configuration
MONGODB_URI = os.environ.get('MONGODB_URI')
MONGODB_DB = 'rowboat'
CALL_STATE_COLLECTION = 'call-state'
MONGODB_EXPIRY_SECONDS = 86400 # Default 24 hours
API_KEYS_COLLECTION = "api_keys"
# MongoDB client singleton
_mongo_client = None
_db = None
_call_state_collection = None
_api_keys_collection = None
# Define chat state pydantic model
class CallState(BaseModel):
messages: List[ApiMessage] = []
workflow_id: str
project_id: str
system_prompt: str
turn_count: int = 0
inbound: bool = False
conversation_history: List[Dict[str, str]] = [] # Using Dict instead of ApiMessage for chat history
to_number: str = ""
created_at: int
state: Any = None # Allow any type since the API might return a complex state object
last_transcription: Optional[str] = None
# Enable dictionary-style access for compatibility with existing code
def __getitem__(self, key):
return getattr(self, key)
def __setitem__(self, key, value):
setattr(self, key, value)
def get(self, key, default=None):
return getattr(self, key, default)
model_config = {
# Allow extra fields for flexibility
"extra": "allow",
# More lenient type validation
"arbitrary_types_allowed": True,
# Allow population by field name
"populate_by_name": True
}
def init_mongodb():
"""Initialize MongoDB connection and set up indexes."""
global _mongo_client, _db, _call_state_collection, _api_keys_collection
try:
_mongo_client = MongoClient(MONGODB_URI)
# Force a command to check the connection
_mongo_client.admin.command('ping')
# Set up database and collection
_db = _mongo_client[MONGODB_DB]
_call_state_collection = _db[CALL_STATE_COLLECTION]
_api_keys_collection = _db[API_KEYS_COLLECTION]
# Create TTL index if it doesn't exist
if 'expires_at_1' not in _call_state_collection.index_information():
_call_state_collection.create_index('expires_at', expireAfterSeconds=0)
logger.info(f"Connected to MongoDB at {MONGODB_URI}")
return True
except ConnectionFailure as e:
logger.error(f"Failed to connect to MongoDB: {str(e)}")
raise RuntimeError(f"Could not connect to MongoDB: {str(e)}")
def get_collection() -> Collection:
"""Get the MongoDB collection, initializing if needed."""
global _call_state_collection
if _call_state_collection is None:
init_mongodb()
return _call_state_collection
def get_api_keys_collection() -> Collection:
"""Get the MongoDB collection, initializing if needed."""
global _api_keys_collection
if _api_keys_collection is None:
init_mongodb()
return _api_keys_collection
def get_api_key(project_id: str) -> Optional[str]:
"""Get the API key for a given project ID."""
collection = get_api_keys_collection()
doc = collection.find_one({"projectId": project_id})
return doc["key"] if doc else None
def save_call_state(call_sid: str, call_state: CallState) -> bool:
"""
Save call state to MongoDB.
Args:
call_sid: The call SID to use as document ID
call_state: The call state dictionary to save
Returns:
True if successful, False otherwise
"""
try:
# Validate call_state is a CallState object
if not isinstance(call_state, CallState):
raise ValueError(f"call_state must be a CallState object, got {type(call_state)}")
collection = get_collection()
# Use call_sid as document ID
collection.update_one(
{'_id': call_sid},
{'$set': call_state.model_dump()},
upsert=True
)
logger.info(f"Saved call state to MongoDB for call {call_sid}")
return True
except PyMongoError as e:
logger.error(f"Error saving call state to MongoDB for call {call_sid}: {str(e)}")
raise RuntimeError(f"Failed to save call state to MongoDB: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error in save_call_state: {str(e)}")
raise RuntimeError(f"Failed to save call state: {str(e)}")
def get_call_state(call_sid: str) -> Optional[CallState]:
"""
Retrieve call state from MongoDB.
Args:
call_sid: The call SID to retrieve
Returns:
Call state dictionary or None if not found
"""
try:
collection = get_collection()
# Query MongoDB for the call state
state_doc = collection.find_one({'_id': call_sid})
if not state_doc:
logger.info(f"No call state found in MongoDB for call {call_sid}")
return None
call_state = CallState.model_validate(state_doc)
logger.info(f"Retrieved call state from MongoDB for call {call_sid}")
return call_state
except PyMongoError as e:
logger.error(f"Error retrieving call state from MongoDB for call {call_sid}: {str(e)}")
raise RuntimeError(f"Failed to retrieve call state from MongoDB: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error in get_call_state: {str(e)}")
raise RuntimeError(f"Failed to retrieve call state: {str(e)}")
def delete_call_state(call_sid: str) -> bool:
"""
Delete call state from MongoDB.
Args:
call_sid: The call SID to delete
Returns:
True if successful, False if not found
"""
try:
collection = get_collection()
# Delete the document from MongoDB
result = collection.delete_one({'_id': call_sid})
if result.deleted_count > 0:
logger.info(f"Deleted call state from MongoDB for call {call_sid}")
return True
else:
logger.info(f"No call state found to delete in MongoDB for call {call_sid}")
return False
except PyMongoError as e:
logger.error(f"Error deleting call state from MongoDB for call {call_sid}: {str(e)}")
raise RuntimeError(f"Failed to delete call state from MongoDB: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error in delete_call_state: {str(e)}")
raise RuntimeError(f"Failed to delete call state: {str(e)}")
def count_active_calls() -> int:
"""
Count active call documents in MongoDB.
Returns:
Number of active call documents
"""
try:
collection = get_collection()
return collection.count_documents({})
except PyMongoError as e:
logger.error(f"Error counting active calls in MongoDB: {str(e)}")
raise RuntimeError(f"Failed to count active calls in MongoDB: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error in count_active_calls: {str(e)}")
raise RuntimeError(f"Failed to count active calls: {str(e)}")
def get_mongodb_status() -> Dict[str, Any]:
"""
Get MongoDB connection status information.
Returns:
Dictionary with status information
"""
status = {
"status": "connected",
"uri": MONGODB_URI,
"database": MONGODB_DB,
"collection": CALL_STATE_COLLECTION
}
try:
# First check connection with a simple command
collection = get_collection()
db = collection.database
db.command('ping')
status["connection"] = "ok"
# Count active calls
count = count_active_calls()
status["active_calls"] = count
# Get collection stats
try:
stats = db.command("collStats", CALL_STATE_COLLECTION)
status["size_bytes"] = stats.get("size", 0)
status["document_count"] = stats.get("count", 0)
status["index_count"] = len(stats.get("indexSizes", {}))
except Exception as stats_error:
status["stats_error"] = str(stats_error)
except Exception as e:
status["status"] = "error"
status["error"] = str(e)
status["timestamp"] = datetime.datetime.utcnow().isoformat()
return status
# Twilio configuration functions
def get_twilio_config(phone_number: str) -> Optional[Dict[str, Any]]:
"""
Get Twilio configuration for a specific phone number from MongoDB.
Args:
phone_number: The phone number to look up configuration for
Returns:
Configuration dictionary or None if not found/active
"""
try:
# Get MongoDB client and database
client = get_collection().database.client
db = client[MONGODB_DB]
# Use the twilio_configs collection
config_collection = db['twilio_configs']
# Enhanced logging for phone number format
logger.info(f"Looking up configuration for phone number: '{phone_number}'")
# Try different formats of the phone number
cleaned_number = phone_number.strip().replace(' ', '').replace('-', '').replace('(', '').replace(')', '')
possible_formats = [
phone_number, # Original format from Twilio
cleaned_number, # Thoroughly cleaned number
'+' + cleaned_number if not cleaned_number.startswith('+') else cleaned_number, # Ensure + prefix
# Try with different country code formats
'+1' + cleaned_number[-10:] if len(cleaned_number) >= 10 else cleaned_number, # US format with +1
'1' + cleaned_number[-10:] if len(cleaned_number) >= 10 else cleaned_number, # US format with 1
cleaned_number[-10:] if len(cleaned_number) >= 10 else cleaned_number, # US format without country code
]
# Remove duplicates while preserving order
unique_formats = []
for fmt in possible_formats:
if fmt not in unique_formats:
unique_formats.append(fmt)
possible_formats = unique_formats
# Log the formats we're trying
logger.info(f"Trying phone number formats: {possible_formats}")
# Try each format
for phone_format in possible_formats:
# Look up the configuration for this phone number format with status=active
config = config_collection.find_one({'phone_number': phone_format, 'status': 'active'})
if config:
logger.info(f"Found active configuration for '{phone_format}': project_id={config.get('project_id')}, workflow_id={config.get('workflow_id')}")
break # Found a match, exit the loop
# If we didn't find any match
if not config:
# Try a more generic query to see what configurations exist
try:
all_configs = list(config_collection.find({'phone_number': {'$regex': phone_number[-10:] if len(phone_number) >= 10 else phone_number}}))
if all_configs:
logger.warning(f"Found {len(all_configs)} configurations that match phone number {phone_number}, but none are active:")
for cfg in all_configs:
logger.warning(f" - Phone: {cfg.get('phone_number')}, Status: {cfg.get('status')}, Workflow: {cfg.get('workflow_id')}")
else:
logger.warning(f"No configurations found at all for phone number {phone_number} or related formats")
except Exception as e:
logger.error(f"Error running regex query: {str(e)}")
logger.warning(f"No active configuration found for any format of phone number {phone_number}")
return None
# Make sure required fields are present
if 'project_id' not in config or 'workflow_id' not in config:
logger.error(f"Configuration for {phone_number} is missing required fields")
return None
logger.info(f"Found active configuration for {phone_number}: project_id={config['project_id']}, workflow_id={config['workflow_id']}")
return config
except Exception as e:
logger.error(f"Error retrieving Twilio configuration for {phone_number}: {str(e)}")
# Return None instead of raising an exception to allow fallback to default behavior
return None
def list_active_twilio_configs() -> List[Dict[str, Any]]:
"""
List all active Twilio configurations from MongoDB.
Returns:
List of active configuration dictionaries
"""
try:
# Get MongoDB client and database
client = get_collection().database.client
db = client[MONGODB_DB]
# Use the twilio_configs collection
config_collection = db['twilio_configs']
# Find all active configurations
configs = list(config_collection.find({'status': 'active'}))
logger.info(f"Found {len(configs)} active Twilio configurations")
return configs
except Exception as e:
logger.error(f"Error retrieving active Twilio configurations: {str(e)}")
return []
def save_twilio_config(config: Dict[str, Any]) -> bool:
"""
Save a Twilio configuration to MongoDB.
Args:
config: Configuration dictionary with at least phone_number, project_id, and workflow_id
Returns:
True if successful, False otherwise
"""
required_fields = ['phone_number', 'project_id', 'workflow_id']
for field in required_fields:
if field not in config:
logger.error(f"Missing required field '{field}' in Twilio configuration")
return False
try:
# Get MongoDB client and database
client = get_collection().database.client
db = client[MONGODB_DB]
# Use the twilio_configs collection
config_collection = db['twilio_configs']
# Ensure status is set to active
if 'status' not in config:
config['status'] = 'active'
# Add timestamp
config['updated_at'] = datetime.datetime.utcnow()
if 'created_at' not in config:
config['created_at'] = config['updated_at']
# Use phone_number as the ID
phone_number = config['phone_number']
# Update or insert the configuration
result = config_collection.update_one(
{'phone_number': phone_number},
{'$set': config},
upsert=True
)
if result.matched_count > 0:
logger.info(f"Updated Twilio configuration for {phone_number}")
else:
logger.info(f"Created new Twilio configuration for {phone_number}")
return True
except Exception as e:
logger.error(f"Error saving Twilio configuration: {str(e)}")
return False
# Initialize MongoDB on module import
init_mongodb()

View file

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

View file

@ -1,4 +1,4 @@
from .client import Client, StatefulChat from .client import Client
from .schema import ( from .schema import (
ApiMessage, ApiMessage,
UserMessage, UserMessage,
@ -8,21 +8,4 @@ from .schema import (
ToolMessage, ToolMessage,
ApiRequest, ApiRequest,
ApiResponse ApiResponse
) )
__version__ = "0.1.0"
__all__ = [
"Client",
"StatefulChat",
# Message types
"ApiMessage",
"UserMessage",
"SystemMessage",
"AssistantMessage",
"AssistantMessageWithToolCalls",
"ToolMessage",
# Request/Response types
"ApiRequest",
"ApiResponse",
]

View file

@ -1,36 +1,30 @@
from typing import Dict, List, Optional, Any, Union from typing import Dict, List, Optional
import requests import requests
from .schema import ( from .schema import (
ApiRequest, ApiRequest,
ApiResponse, ApiResponse,
ApiMessage, ApiMessage,
UserMessage, UserMessage,
AssistantMessage,
AssistantMessageWithToolCalls
) )
class Client: class Client:
def __init__(self, host: str, project_id: str, api_key: str) -> None: def __init__(self, host: str, projectId: str, apiKey: str) -> None:
self.base_url: str = f'{host}/api/v1/{project_id}/chat' self.base_url: str = f'{host}/api/v1/{projectId}/chat'
self.headers: Dict[str, str] = { self.headers: Dict[str, str] = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key}' 'Authorization': f'Bearer {apiKey}'
} }
def _call_api( def _call_api(
self, self,
messages: List[ApiMessage], messages: List[ApiMessage],
state: Optional[Dict[str, Any]] = None, conversationId: Optional[str] = None,
workflow_id: Optional[str] = None, mockTools: Optional[Dict[str, str]] = None
test_profile_id: Optional[str] = None,
mock_tools: Optional[Dict[str, str]] = None
) -> ApiResponse: ) -> ApiResponse:
request = ApiRequest( request = ApiRequest(
messages=messages, messages=messages,
state=state, conversationId=conversationId,
workflowId=workflow_id, mockTools=mockTools
testProfileId=test_profile_id,
mockTools=mock_tools
) )
json_data = request.model_dump() json_data = request.model_dump()
response = requests.post(self.base_url, headers=self.headers, json=json_data) response = requests.post(self.base_url, headers=self.headers, json=json_data)
@ -38,86 +32,23 @@ class Client:
if not response.status_code == 200: if not response.status_code == 200:
raise ValueError(f"Error: {response.status_code} - {response.text}") raise ValueError(f"Error: {response.status_code} - {response.text}")
response_data = ApiResponse.model_validate(response.json()) return ApiResponse.model_validate(response.json())
if not response_data.messages:
raise ValueError("No response")
last_message = response_data.messages[-1]
if not isinstance(last_message, (AssistantMessage, AssistantMessageWithToolCalls)):
raise ValueError("Last message was not an assistant message")
return response_data def run_turn(
def chat(
self, self,
messages: List[ApiMessage], messages: List[ApiMessage],
state: Optional[Dict[str, Any]] = None, conversationId: Optional[str] = None,
workflow_id: Optional[str] = None, mockTools: Optional[Dict[str, str]] = None,
test_profile_id: Optional[str] = None,
mock_tools: Optional[Dict[str, str]] = None,
) -> ApiResponse: ) -> ApiResponse:
"""Stateless chat method that handles a single conversation turn""" """Stateless chat method that handles a single conversation turn"""
# call api # call api
response_data = self._call_api( return self._call_api(
messages=messages, messages=messages,
state=state, conversationId=conversationId,
workflow_id=workflow_id, mockTools=mockTools,
test_profile_id=test_profile_id,
mock_tools=mock_tools,
) )
if not response_data.messages[-1].responseType == 'external':
raise ValueError("Last message was not an external message")
return response_data
class StatefulChat:
"""Maintains conversation state across multiple turns"""
def __init__(
self,
client: Client,
workflow_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"""
# Process the message
user_msg = UserMessage(role='user', content=message)
self.messages.append(user_msg)
# Get response using the client's chat method
response_data = self.client.chat(
messages=self.messages,
state=self.state,
workflow_id=self.workflow_id,
test_profile_id=self.test_profile_id,
mock_tools=self.mock_tools,
)
# Update internal state
self.messages.extend(response_data.messages)
self.state = response_data.state
# Return only the final message content
last_message = self.messages[-1]
return last_message.content
def weather_lookup_tool(city_name: str) -> str:
return f"The weather in {city_name} is 22°C."
if __name__ == "__main__": if __name__ == "__main__":
host: str = "<HOST>" host: str = "<HOST>"
@ -125,13 +56,18 @@ if __name__ == "__main__":
api_key: str = "<API_KEY>" api_key: str = "<API_KEY>"
client = Client(host, project_id, api_key) client = Client(host, project_id, api_key)
result = client.chat( result = client.run_turn(
messages=[ messages=[
UserMessage(role='user', content="Hello") UserMessage(role='user', content="list my github repos")
] ]
) )
print(result.messages[-1].content) print(result.turn.output[-1].content)
print(result.conversationId)
chat_session = StatefulChat(client) result = client.run_turn(
resp = chat_session.run("Hello") messages=[
print(resp) UserMessage(role='user', content="how many did you find?")
],
conversationId=result.conversationId
)
print(result.turn.output[-1].content)

View file

@ -1,4 +1,4 @@
from typing import List, Optional, Union, Any, Literal, Dict from typing import List, Optional, Union, Literal, Dict
from pydantic import BaseModel from pydantic import BaseModel
class SystemMessage(BaseModel): class SystemMessage(BaseModel):
@ -44,13 +44,15 @@ ApiMessage = Union[
ToolMessage ToolMessage
] ]
class Turn(BaseModel):
id: str
output: List[ApiMessage]
class ApiRequest(BaseModel): class ApiRequest(BaseModel):
conversationId: Optional[str] = None
messages: List[ApiMessage] messages: List[ApiMessage]
state: Any
workflowId: Optional[str] = None
testProfileId: Optional[str] = None
mockTools: Optional[Dict[str, str]] = None mockTools: Optional[Dict[str, str]] = None
class ApiResponse(BaseModel): class ApiResponse(BaseModel):
messages: List[ApiMessage] conversationId: str
state: Optional[Any] = None turn: Turn

View file

@ -1,87 +0,0 @@
'use server';
import { WebpageCrawlResponse } from "../lib/types/tool_types";
import { webpagesCollection } from "../lib/mongodb";
import { z } from 'zod';
import FirecrawlApp, { ScrapeResponse } from '@mendable/firecrawl-js';
import { getAgenticResponseStreamId } from "../lib/utils";
import { check_query_limit } from "../lib/rate_limiting";
import { QueryLimitError } from "../lib/client_utils";
import { projectAuthCheck } from "./project_actions";
import { 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 || '' });
export async function scrapeWebpage(url: string): Promise<z.infer<typeof WebpageCrawlResponse>> {
const page = await webpagesCollection.findOne({
"_id": url,
lastUpdatedAt: {
'$gte': new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), // 24 hours
},
});
if (page) {
// console.log("found webpage in db", url);
return {
title: page.title,
content: page.contentSimple,
};
}
// otherwise use firecrawl
const scrapeResult = await crawler.scrapeUrl(
url,
{
formats: ['markdown'],
onlyMainContent: true
}
) as ScrapeResponse;
// save the webpage using upsert
await webpagesCollection.updateOne(
{ _id: url },
{
$set: {
title: scrapeResult.metadata?.title || '',
contentSimple: scrapeResult.markdown || '',
lastUpdatedAt: (new Date()).toISOString(),
}
},
{ upsert: true }
);
// console.log("crawled webpage", url);
return {
title: scrapeResult.metadata?.title || '',
content: scrapeResult.markdown || '',
};
}
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 = workflow.agents.reduce((acc, agent) => {
acc.push(agent.model);
return acc;
}, [] as string[]);
const { success, error } = await authorizeUserAction({
type: 'agent_response',
data: {
agentModels,
},
});
if (!success) {
return { billingError: error || 'Billing error' };
}
const response = await getAgenticResponseStreamId(workflow, projectTools, messages);
return response;
}

View file

@ -8,7 +8,7 @@ import {
updateSubscriptionPlan as libUpdateSubscriptionPlan, updateSubscriptionPlan as libUpdateSubscriptionPlan,
getEligibleModels as libGetEligibleModels getEligibleModels as libGetEligibleModels
} from "../lib/billing"; } from "../lib/billing";
import { authCheck } from "./auth_actions"; import { authCheck } from "./auth.actions";
import { USE_BILLING } from "../lib/feature_flags"; import { USE_BILLING } from "../lib/feature_flags";
import { import {
AuthorizeRequest, AuthorizeRequest,

View file

@ -0,0 +1,185 @@
"use server";
import { z } from "zod";
import { ZListResponse } from "@/src/application/lib/composio/types";
import { ZCreateConnectedAccountResponse } from "@/src/application/lib/composio/types";
import { ZCredentials } from "@/src/application/lib/composio/types";
import { ZTool } from "@/src/application/lib/composio/types";
import { ZGetToolkitResponse } from "@/src/application/lib/composio/types";
import { ZToolkit } from "@/src/application/lib/composio/types";
import { ZAuthScheme } from "@/src/application/lib/composio/types";
import { ComposioConnectedAccount } from "@/src/entities/models/project";
import { container } from "@/di/container";
import { ICreateComposioTriggerDeploymentController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/create-composio-trigger-deployment.controller";
import { IListComposioTriggerDeploymentsController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-deployments.controller";
import { IDeleteComposioTriggerDeploymentController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/delete-composio-trigger-deployment.controller";
import { IListComposioTriggerTypesController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-types.controller";
import { IDeleteComposioConnectedAccountController } from "@/src/interface-adapters/controllers/projects/delete-composio-connected-account.controller";
import { authCheck } from "./auth.actions";
import { ICreateComposioManagedConnectedAccountController } from "@/src/interface-adapters/controllers/projects/create-composio-managed-connected-account.controller";
import { ICreateCustomConnectedAccountController } from "@/src/interface-adapters/controllers/projects/create-custom-connected-account.controller";
import { ISyncConnectedAccountController } from "@/src/interface-adapters/controllers/projects/sync-connected-account.controller";
import { IListComposioToolkitsController } from "@/src/interface-adapters/controllers/projects/list-composio-toolkits.controller";
import { IGetComposioToolkitController } from "@/src/interface-adapters/controllers/projects/get-composio-toolkit.controller";
import { IListComposioToolsController } from "@/src/interface-adapters/controllers/projects/list-composio-tools.controller";
const createComposioTriggerDeploymentController = container.resolve<ICreateComposioTriggerDeploymentController>("createComposioTriggerDeploymentController");
const listComposioTriggerDeploymentsController = container.resolve<IListComposioTriggerDeploymentsController>("listComposioTriggerDeploymentsController");
const deleteComposioTriggerDeploymentController = container.resolve<IDeleteComposioTriggerDeploymentController>("deleteComposioTriggerDeploymentController");
const listComposioTriggerTypesController = container.resolve<IListComposioTriggerTypesController>("listComposioTriggerTypesController");
const deleteComposioConnectedAccountController = container.resolve<IDeleteComposioConnectedAccountController>("deleteComposioConnectedAccountController");
const createComposioManagedConnectedAccountController = container.resolve<ICreateComposioManagedConnectedAccountController>("createComposioManagedConnectedAccountController");
const createCustomConnectedAccountController = container.resolve<ICreateCustomConnectedAccountController>("createCustomConnectedAccountController");
const syncConnectedAccountController = container.resolve<ISyncConnectedAccountController>("syncConnectedAccountController");
const listComposioToolkitsController = container.resolve<IListComposioToolkitsController>("listComposioToolkitsController");
const getComposioToolkitController = container.resolve<IGetComposioToolkitController>("getComposioToolkitController");
const listComposioToolsController = container.resolve<IListComposioToolsController>("listComposioToolsController");
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>>>> {
const user = await authCheck();
return await listComposioToolkitsController.execute({
caller: 'user',
userId: user._id,
projectId,
cursor,
});
}
export async function getToolkit(projectId: string, toolkitSlug: string): Promise<z.infer<typeof ZGetToolkitResponse>> {
const user = await authCheck();
return await getComposioToolkitController.execute({
caller: 'user',
userId: user._id,
projectId,
toolkitSlug,
});
}
export async function listTools(projectId: string, toolkitSlug: string, searchQuery: string | null, cursor: string | null = null): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> {
const user = await authCheck();
return await listComposioToolsController.execute({
caller: 'user',
userId: user._id,
projectId,
toolkitSlug,
searchQuery,
cursor,
});
}
export async function createComposioManagedOauth2ConnectedAccount(projectId: string, toolkitSlug: string, callbackUrl: string): Promise<z.infer<typeof ZCreateConnectedAccountResponse>> {
const user = await authCheck();
return await createComposioManagedConnectedAccountController.execute({
caller: 'user',
userId: user._id,
projectId,
toolkitSlug,
callbackUrl,
});
}
export async function createCustomConnectedAccount(projectId: string, request: z.infer<typeof ZCreateCustomConnectedAccountRequest>): Promise<z.infer<typeof ZCreateConnectedAccountResponse>> {
const user = await authCheck();
return await createCustomConnectedAccountController.execute({
caller: 'user',
userId: user._id,
projectId,
toolkitSlug: request.toolkitSlug,
authConfig: request.authConfig,
callbackUrl: request.callbackUrl,
});
}
export async function syncConnectedAccount(projectId: string, toolkitSlug: string, connectedAccountId: string): Promise<z.infer<typeof ComposioConnectedAccount>> {
const user = await authCheck();
return await syncConnectedAccountController.execute({
caller: 'user',
userId: user._id,
projectId,
toolkitSlug,
connectedAccountId,
});
}
export async function deleteConnectedAccount(projectId: string, toolkitSlug: string): Promise<boolean> {
const user = await authCheck();
await deleteComposioConnectedAccountController.execute({
caller: 'user',
userId: user._id,
projectId,
toolkitSlug,
});
return true;
}
export async function listComposioTriggerTypes(toolkitSlug: string, cursor?: string) {
await authCheck();
return await listComposioTriggerTypesController.execute({
toolkitSlug,
cursor,
});
}
export async function createComposioTriggerDeployment(request: {
projectId: string,
toolkitSlug: string,
triggerTypeSlug: string,
connectedAccountId: string,
triggerConfig?: Record<string, unknown>,
}) {
const user = await authCheck();
// create trigger deployment
return await createComposioTriggerDeploymentController.execute({
caller: 'user',
userId: user._id,
data: {
projectId: request.projectId,
toolkitSlug: request.toolkitSlug,
triggerTypeSlug: request.triggerTypeSlug,
connectedAccountId: request.connectedAccountId,
triggerConfig: request.triggerConfig ?? {},
},
});
}
export async function listComposioTriggerDeployments(request: {
projectId: string,
cursor?: string,
}) {
const user = await authCheck();
// list trigger deployments
return await listComposioTriggerDeploymentsController.execute({
caller: 'user',
userId: user._id,
projectId: request.projectId,
cursor: request.cursor,
});
}
export async function deleteComposioTriggerDeployment(request: {
projectId: string,
deploymentId: string,
}) {
const user = await authCheck();
// delete trigger deployment
return await deleteComposioTriggerDeploymentController.execute({
caller: 'user',
userId: user._id,
projectId: request.projectId,
deploymentId: request.deploymentId,
});
}

View file

@ -1,226 +0,0 @@
"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

@ -0,0 +1,37 @@
"use server";
import { container } from "@/di/container";
import { IListConversationsController } from "@/src/interface-adapters/controllers/conversations/list-conversations.controller";
import { IFetchConversationController } from "@/src/interface-adapters/controllers/conversations/fetch-conversation.controller";
import { authCheck } from "./auth.actions";
const listConversationsController = container.resolve<IListConversationsController>('listConversationsController');
const fetchConversationController = container.resolve<IFetchConversationController>('fetchConversationController');
export async function listConversations(request: {
projectId: string,
cursor?: string,
limit?: number,
}) {
const user = await authCheck();
return await listConversationsController.execute({
caller: 'user',
userId: user._id,
projectId: request.projectId,
cursor: request.cursor,
limit: request.limit,
});
}
export async function fetchConversation(request: {
conversationId: string,
}) {
const user = await authCheck();
return await fetchConversationController.execute({
caller: 'user',
userId: user._id,
conversationId: request.conversationId,
});
}

View file

@ -2,63 +2,49 @@
import { import {
CopilotAPIRequest, CopilotAPIRequest,
CopilotChatContext, CopilotMessage, CopilotChatContext, CopilotMessage,
} from "../lib/types/copilot_types"; DataSourceSchemaForCopilot,
} from "../../src/application/lib/copilot/types";
import { import {
Workflow} from "../lib/types/workflow_types"; Workflow} from "../lib/types/workflow_types";
import { DataSource } from "../lib/types/datasource_types";
import { z } from 'zod'; import { z } from 'zod';
import { check_query_limit } from "../lib/rate_limiting"; import { projectAuthCheck } from "./project.actions";
import { QueryLimitError } from "../lib/client_utils";
import { projectAuthCheck } from "./project_actions";
import { redisClient } from "../lib/redis"; import { redisClient } from "../lib/redis";
import { collectProjectTools } from "../lib/project_tools"; import { authorizeUserAction, logUsage } from "./billing.actions";
import { mergeProjectTools } from "../lib/types/project_types";
import { authorizeUserAction, logUsage } from "./billing_actions";
import { USE_BILLING } from "../lib/feature_flags"; import { USE_BILLING } from "../lib/feature_flags";
import { WithStringId } from "../lib/types/types"; import { getEditAgentInstructionsResponse } from "../../src/application/lib/copilot/copilot";
import { getEditAgentInstructionsResponse } from "../lib/copilot/copilot"; import { container } from "@/di/container";
import { IUsageQuotaPolicy } from "@/src/application/policies/usage-quota.policy.interface";
import { UsageTracker } from "../lib/billing";
const usageQuotaPolicy = container.resolve<IUsageQuotaPolicy>('usageQuotaPolicy');
export async function getCopilotResponseStream( export async function getCopilotResponseStream(
projectId: string, projectId: string,
messages: z.infer<typeof CopilotMessage>[], messages: z.infer<typeof CopilotMessage>[],
current_workflow_config: z.infer<typeof Workflow>, current_workflow_config: z.infer<typeof Workflow>,
context: z.infer<typeof CopilotChatContext> | null, context: z.infer<typeof CopilotChatContext> | null,
dataSources?: WithStringId<z.infer<typeof DataSource>>[] dataSources?: z.infer<typeof DataSourceSchemaForCopilot>[]
): Promise<{ ): Promise<{
streamId: string; streamId: string;
} | { billingError: string }> { } | { billingError: string }> {
await projectAuthCheck(projectId); await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) { await usageQuotaPolicy.assertAndConsume(projectId);
throw new QueryLimitError();
}
// Check billing authorization // Check billing authorization
const authResponse = await authorizeUserAction({ const authResponse = await authorizeUserAction({
type: 'copilot_request', type: 'use_credits',
data: {},
}); });
if (!authResponse.success) { if (!authResponse.success) {
return { billingError: authResponse.error || 'Billing error' }; return { billingError: authResponse.error || 'Billing error' };
} }
if (!await check_query_limit(projectId)) { await usageQuotaPolicy.assertAndConsume(projectId);
throw new QueryLimitError();
}
// Get MCP tools from project and merge with workflow tools
const projectTools = await collectProjectTools(projectId);
// Convert workflow to copilot format with both workflow and project tools
const wflow = {
...current_workflow_config,
tools: mergeProjectTools(current_workflow_config.tools, projectTools)
};
// prepare request // prepare request
const request: z.infer<typeof CopilotAPIRequest> = { const request: z.infer<typeof CopilotAPIRequest> = {
projectId, projectId,
messages, messages,
workflow: wflow, workflow: current_workflow_config,
context, context,
dataSources: dataSources, dataSources: dataSources,
}; };
@ -84,41 +70,32 @@ export async function getCopilotAgentInstructions(
agentName: string, agentName: string,
): Promise<string | { billingError: string }> { ): Promise<string | { billingError: string }> {
await projectAuthCheck(projectId); await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) { await usageQuotaPolicy.assertAndConsume(projectId);
throw new QueryLimitError();
}
// Check billing authorization // Check billing authorization
const authResponse = await authorizeUserAction({ const authResponse = await authorizeUserAction({
type: 'copilot_request', type: 'use_credits',
data: {},
}); });
if (!authResponse.success) { if (!authResponse.success) {
return { billingError: authResponse.error || 'Billing error' }; return { billingError: authResponse.error || 'Billing error' };
} }
// Get MCP tools from project and merge with workflow tools
const projectTools = await collectProjectTools(projectId);
// Convert workflow to copilot format with both workflow and project tools
const wflow = {
...current_workflow_config,
tools: mergeProjectTools(current_workflow_config.tools, projectTools)
};
// prepare request // prepare request
const request: z.infer<typeof CopilotAPIRequest> = { const request: z.infer<typeof CopilotAPIRequest> = {
projectId, projectId,
messages, messages,
workflow: wflow, workflow: current_workflow_config,
context: { context: {
type: 'agent', type: 'agent',
name: agentName, name: agentName,
} }
}; };
const usageTracker = new UsageTracker();
// call copilot api // call copilot api
const agent_instructions = await getEditAgentInstructionsResponse( const agent_instructions = await getEditAgentInstructionsResponse(
usageTracker,
projectId, projectId,
request.context, request.context,
request.messages, request.messages,
@ -128,8 +105,7 @@ export async function getCopilotAgentInstructions(
// log the billing usage // log the billing usage
if (USE_BILLING) { if (USE_BILLING) {
await logUsage({ await logUsage({
type: 'copilot_requests', items: usageTracker.flush(),
amount: 1,
}); });
} }

View file

@ -0,0 +1,72 @@
'use server';
import { z } from 'zod';
import { CustomMcpServer } from "@/src/entities/models/project";
import { getMcpClient } from '../lib/mcp';
import { WorkflowTool } from '../lib/types/workflow_types';
import { authCheck } from './auth.actions';
import { container } from '@/di/container';
import { IAddCustomMcpServerController } from '@/src/interface-adapters/controllers/projects/add-custom-mcp-server.controller';
import { IRemoveCustomMcpServerController } from '@/src/interface-adapters/controllers/projects/remove-custom-mcp-server.controller';
type McpServerType = z.infer<typeof CustomMcpServer>;
function validateUrl(url: string): string {
try {
const parsedUrl = new URL(url);
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error('Invalid protocol');
}
return parsedUrl.toString();
} catch (error) {
throw new Error('Invalid URL');
}
}
const addCustomMcpServerController = container.resolve<IAddCustomMcpServerController>('addCustomMcpServerController');
const removeCustomMcpServerController = container.resolve<IRemoveCustomMcpServerController>('removeCustomMcpServerController');
export async function addServer(projectId: string, name: string, server: McpServerType): Promise<void> {
const user = await authCheck();
// validate early for UX; use-case will validate again
validateUrl(server.serverUrl);
await addCustomMcpServerController.execute({
caller: 'user',
userId: user._id,
projectId,
name,
server,
});
}
export async function removeServer(projectId: string, name: string): Promise<void> {
const user = await authCheck();
await removeCustomMcpServerController.execute({
caller: 'user',
userId: user._id,
projectId,
name,
});
}
export async function fetchTools(serverUrl: string, serverName: string): Promise<z.infer<typeof WorkflowTool>[]> {
await authCheck();
const client = await getMcpClient(serverUrl, serverName);
const result = await client.listTools();
return result.tools.map(tool => {
return {
name: tool.name,
description: tool.description || '',
parameters: {
type: 'object',
properties: tool.inputSchema?.properties || {},
required: tool.inputSchema?.required || [],
additionalProperties: true,
},
isMcp: true,
mcpServerName: serverName,
mcpServerURL: serverUrl,
};
});
}

View file

@ -1,93 +0,0 @@
'use server';
import { projectsCollection } from '../lib/mongodb';
import { MCPServer } from '../lib/types/types';
import { z } from 'zod';
import { projectAuthCheck } from './project_actions';
type McpServerType = z.infer<typeof MCPServer>;
function formatServerUrl(url: string): string {
// Ensure URL starts with http:// or https://
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'http://' + url;
}
// Remove trailing slash if present
return url.replace(/\/$/, '');
}
export async function fetchCustomServers(projectId: string) {
await projectAuthCheck(projectId);
const project = await projectsCollection.findOne({ _id: projectId });
return (project?.mcpServers || [])
.filter(server => server.serverType === 'custom')
.map(server => ({
...server,
serverType: 'custom' as const,
isReady: true // Custom servers are always ready
}));
}
export async function addCustomServer(projectId: string, server: McpServerType) {
await projectAuthCheck(projectId);
// Format the server URL and ensure isReady is true for custom servers
const formattedServer = {
...server,
serverUrl: formatServerUrl(server.serverUrl || ''),
isReady: true // Custom servers are always ready
};
await projectsCollection.updateOne(
{ _id: projectId },
{ $push: { mcpServers: formattedServer } }
);
return formattedServer;
}
export async function removeCustomServer(projectId: string, serverName: string) {
await projectAuthCheck(projectId);
await projectsCollection.updateOne(
{ _id: projectId },
{ $pull: { mcpServers: { name: serverName } } }
);
}
export async function toggleCustomServer(projectId: string, serverName: string, isActive: boolean) {
await projectAuthCheck(projectId);
await projectsCollection.updateOne(
{ _id: projectId, "mcpServers.name": serverName },
{
$set: {
"mcpServers.$.isActive": isActive,
"mcpServers.$.isReady": isActive // Update isReady along with isActive
}
}
);
}
export async function updateCustomServerTools(
projectId: string,
serverName: string,
tools: McpServerType['tools'],
availableTools?: McpServerType['availableTools']
) {
await projectAuthCheck(projectId);
const update: Record<string, any> = {
"mcpServers.$.tools": tools
};
if (availableTools) {
update["mcpServers.$.availableTools"] = availableTools;
}
await projectsCollection.updateOne(
{ _id: projectId, "mcpServers.name": serverName },
{ $set: update }
);
}

View file

@ -0,0 +1,217 @@
'use server';
import { z } from 'zod';
import { DataSourceDoc } from "@/src/entities/models/data-source-doc";
import { DataSource } from "@/src/entities/models/data-source";
import { container } from "@/di/container";
import { IFetchDataSourceController } from "@/src/interface-adapters/controllers/data-sources/fetch-data-source.controller";
import { authCheck } from "./auth.actions";
import { IListDataSourcesController } from "@/src/interface-adapters/controllers/data-sources/list-data-sources.controller";
import { ICreateDataSourceController } from "@/src/interface-adapters/controllers/data-sources/create-data-source.controller";
import { IRecrawlWebDataSourceController } from "@/src/interface-adapters/controllers/data-sources/recrawl-web-data-source.controller";
import { IDeleteDataSourceController } from "@/src/interface-adapters/controllers/data-sources/delete-data-source.controller";
import { IToggleDataSourceController } from "@/src/interface-adapters/controllers/data-sources/toggle-data-source.controller";
import { IAddDocsToDataSourceController } from "@/src/interface-adapters/controllers/data-sources/add-docs-to-data-source.controller";
import { IListDocsInDataSourceController } from "@/src/interface-adapters/controllers/data-sources/list-docs-in-data-source.controller";
import { IDeleteDocFromDataSourceController } from "@/src/interface-adapters/controllers/data-sources/delete-doc-from-data-source.controller";
import { IGetDownloadUrlForFileController } from "@/src/interface-adapters/controllers/data-sources/get-download-url-for-file.controller";
import { IGetUploadUrlsForFilesController } from "@/src/interface-adapters/controllers/data-sources/get-upload-urls-for-files.controller";
import { IUpdateDataSourceController } from "@/src/interface-adapters/controllers/data-sources/update-data-source.controller";
const fetchDataSourceController = container.resolve<IFetchDataSourceController>("fetchDataSourceController");
const listDataSourcesController = container.resolve<IListDataSourcesController>("listDataSourcesController");
const createDataSourceController = container.resolve<ICreateDataSourceController>("createDataSourceController");
const recrawlWebDataSourceController = container.resolve<IRecrawlWebDataSourceController>("recrawlWebDataSourceController");
const deleteDataSourceController = container.resolve<IDeleteDataSourceController>("deleteDataSourceController");
const toggleDataSourceController = container.resolve<IToggleDataSourceController>("toggleDataSourceController");
const addDocsToDataSourceController = container.resolve<IAddDocsToDataSourceController>("addDocsToDataSourceController");
const listDocsInDataSourceController = container.resolve<IListDocsInDataSourceController>("listDocsInDataSourceController");
const deleteDocFromDataSourceController = container.resolve<IDeleteDocFromDataSourceController>("deleteDocFromDataSourceController");
const getDownloadUrlForFileController = container.resolve<IGetDownloadUrlForFileController>("getDownloadUrlForFileController");
const getUploadUrlsForFilesController = container.resolve<IGetUploadUrlsForFilesController>("getUploadUrlsForFilesController");
const updateDataSourceController = container.resolve<IUpdateDataSourceController>("updateDataSourceController");
export async function getDataSource(sourceId: string): Promise<z.infer<typeof DataSource>> {
const user = await authCheck();
return await fetchDataSourceController.execute({
caller: 'user',
userId: user._id,
sourceId,
});
}
export async function listDataSources(projectId: string): Promise<z.infer<typeof DataSource>[]> {
const user = await authCheck();
return await listDataSourcesController.execute({
caller: 'user',
userId: user._id,
projectId,
});
}
export async function createDataSource({
projectId,
name,
description,
data,
status = 'pending',
}: {
projectId: string,
name: string,
description?: string,
data: z.infer<typeof DataSource>['data'],
status?: 'pending' | 'ready',
}): Promise<z.infer<typeof DataSource>> {
const user = await authCheck();
return await createDataSourceController.execute({
caller: 'user',
userId: user._id,
data: {
projectId,
name,
description: description || '',
status,
data,
},
});
}
export async function recrawlWebDataSource(sourceId: string) {
const user = await authCheck();
return await recrawlWebDataSourceController.execute({
caller: 'user',
userId: user._id,
sourceId,
});
}
export async function deleteDataSource(sourceId: string) {
const user = await authCheck();
return await deleteDataSourceController.execute({
caller: 'user',
userId: user._id,
sourceId,
});
}
export async function toggleDataSource(sourceId: string, active: boolean) {
const user = await authCheck();
return await toggleDataSourceController.execute({
caller: 'user',
userId: user._id,
sourceId,
active,
});
}
export async function addDocsToDataSource({
sourceId,
docData,
}: {
sourceId: string,
docData: {
name: string,
data: z.infer<typeof DataSourceDoc>['data']
}[]
}): Promise<void> {
const user = await authCheck();
return await addDocsToDataSourceController.execute({
caller: 'user',
userId: user._id,
sourceId,
docs: docData,
});
}
export async function listDocsInDataSource({
sourceId,
page = 1,
limit = 10,
}: {
sourceId: string,
page?: number,
limit?: number,
}): Promise<{
files: z.infer<typeof DataSourceDoc>[],
total: number
}> {
const user = await authCheck();
const docs = await listDocsInDataSourceController.execute({
caller: 'user',
userId: user._id,
sourceId,
});
return {
files: docs,
total: docs.length,
};
}
export async function deleteDocFromDataSource({
docId,
}: {
docId: string,
}): Promise<void> {
const user = await authCheck();
return await deleteDocFromDataSourceController.execute({
caller: 'user',
userId: user._id,
docId,
});
}
export async function getDownloadUrlForFile(
fileId: string
): Promise<string> {
const user = await authCheck();
return await getDownloadUrlForFileController.execute({
caller: 'user',
userId: user._id,
fileId,
});
}
export async function getUploadUrlsForFilesDataSource(
sourceId: string,
files: { name: string; type: string; size: number }[]
): Promise<{
fileId: string,
uploadUrl: string,
path: string,
}[]> {
const user = await authCheck();
return await getUploadUrlsForFilesController.execute({
caller: 'user',
userId: user._id,
sourceId,
files,
});
}
export async function updateDataSource({
sourceId,
description,
}: {
sourceId: string,
description: string,
}) {
const user = await authCheck();
return await updateDataSourceController.execute({
caller: 'user',
userId: user._id,
sourceId,
data: {
description,
},
});
}

View file

@ -1,396 +0,0 @@
'use server';
import { redirect } from "next/navigation";
import { ObjectId, WithId } from "mongodb";
import { dataSourcesCollection, dataSourceDocsCollection } from "../lib/mongodb";
import { z } from 'zod';
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { projectAuthCheck } from "./project_actions";
import { WithStringId } from "../lib/types/types";
import { DataSourceDoc } from "../lib/types/datasource_types";
import { DataSource } from "../lib/types/datasource_types";
import { uploadsS3Client } from "../lib/uploads_s3_client";
import { USE_RAG_S3_UPLOADS } from "../lib/feature_flags";
export async function getDataSource(projectId: string, sourceId: string): Promise<WithStringId<z.infer<typeof DataSource>>> {
await projectAuthCheck(projectId);
const source = await dataSourcesCollection.findOne({
_id: new ObjectId(sourceId),
projectId,
});
if (!source) {
throw new Error('Invalid data source');
}
const { _id, ...rest } = source;
return {
...rest,
_id: _id.toString(),
};
}
export async function listDataSources(projectId: string): Promise<WithStringId<z.infer<typeof DataSource>>[]> {
await projectAuthCheck(projectId);
const sources = await dataSourcesCollection.find({
projectId: projectId,
status: { $ne: 'deleted' },
}).toArray();
return sources.map((s) => ({
...s,
_id: s._id.toString(),
}));
}
export async function createDataSource({
projectId,
name,
description,
data,
status = 'pending',
}: {
projectId: string,
name: string,
description?: string,
data: z.infer<typeof DataSource>['data'],
status?: 'pending' | 'ready',
}): Promise<WithStringId<z.infer<typeof DataSource>>> {
await projectAuthCheck(projectId);
const source: z.infer<typeof DataSource> = {
projectId: projectId,
active: true,
name: name,
description,
createdAt: (new Date()).toISOString(),
attempts: 0,
version: 1,
data,
};
// Only set status for non-file data sources
if (data.type !== 'files_local' && data.type !== 'files_s3') {
source.status = status;
}
await dataSourcesCollection.insertOne(source);
const { _id, ...rest } = source as WithId<z.infer<typeof DataSource>>;
return {
...rest,
_id: _id.toString(),
};
}
export async function recrawlWebDataSource(projectId: string, sourceId: string) {
await projectAuthCheck(projectId);
const source = await getDataSource(projectId, sourceId);
if (source.data.type !== 'urls') {
throw new Error('Invalid data source type');
}
// mark all files as queued
await dataSourceDocsCollection.updateMany({
sourceId: sourceId,
}, {
$set: {
status: 'pending',
lastUpdatedAt: (new Date()).toISOString(),
attempts: 0,
}
});
// mark data source as pending
await dataSourcesCollection.updateOne({
_id: new ObjectId(sourceId),
}, {
$set: {
status: 'pending',
billingError: undefined,
lastUpdatedAt: (new Date()).toISOString(),
attempts: 0,
},
$inc: {
version: 1,
},
});
}
export async function deleteDataSource(projectId: string, sourceId: string) {
await projectAuthCheck(projectId);
await getDataSource(projectId, sourceId);
// mark data source as deleted
await dataSourcesCollection.updateOne({
_id: new ObjectId(sourceId),
}, {
$set: {
status: 'deleted',
billingError: undefined,
lastUpdatedAt: (new Date()).toISOString(),
attempts: 0,
},
$inc: {
version: 1,
},
});
redirect(`/projects/${projectId}/sources`);
}
export async function toggleDataSource(projectId: string, sourceId: string, active: boolean) {
await projectAuthCheck(projectId);
await getDataSource(projectId, sourceId);
await dataSourcesCollection.updateOne({
"_id": new ObjectId(sourceId),
"projectId": projectId,
}, {
$set: {
"active": active,
}
});
}
export async function addDocsToDataSource({
projectId,
sourceId,
docData,
}: {
projectId: string,
sourceId: string,
docData: {
_id?: string,
name: string,
data: z.infer<typeof DataSourceDoc>['data']
}[]
}): Promise<void> {
await projectAuthCheck(projectId);
const source = await getDataSource(projectId, sourceId);
await dataSourceDocsCollection.insertMany(docData.map(doc => {
const record: z.infer<typeof DataSourceDoc> = {
sourceId,
name: doc.name,
status: 'pending',
createdAt: new Date().toISOString(),
data: doc.data,
version: 1,
};
if (!doc._id) {
return record;
}
const recordWithId = record as WithId<z.infer<typeof DataSourceDoc>>;
recordWithId._id = new ObjectId(doc._id);
return recordWithId;
}));
// Only set status to pending when files are added
if (docData.length > 0 && (source.data.type === 'files_local' || source.data.type === 'files_s3')) {
await dataSourcesCollection.updateOne(
{ _id: new ObjectId(sourceId) },
{
$set: {
status: 'pending',
billingError: undefined,
attempts: 0,
lastUpdatedAt: new Date().toISOString(),
},
$inc: {
version: 1,
},
}
);
}
}
export async function listDocsInDataSource({
projectId,
sourceId,
page = 1,
limit = 10,
}: {
projectId: string,
sourceId: string,
page?: number,
limit?: number,
}): Promise<{
files: WithStringId<z.infer<typeof DataSourceDoc>>[],
total: number
}> {
await projectAuthCheck(projectId);
await getDataSource(projectId, sourceId);
// Get total count
const total = await dataSourceDocsCollection.countDocuments({
sourceId,
status: { $ne: 'deleted' },
});
// Fetch docs with pagination
const docs = await dataSourceDocsCollection.find({
sourceId,
status: { $ne: 'deleted' },
})
.skip((page - 1) * limit)
.limit(limit)
.toArray();
return {
files: docs.map(f => ({ ...f, _id: f._id.toString() })),
total
};
}
export async function deleteDocsFromDataSource({
projectId,
sourceId,
docIds,
}: {
projectId: string,
sourceId: string,
docIds: string[],
}): Promise<void> {
await projectAuthCheck(projectId);
await getDataSource(projectId, sourceId);
// mark for deletion
await dataSourceDocsCollection.updateMany(
{
sourceId,
_id: {
$in: docIds.map(id => new ObjectId(id))
}
},
{
$set: {
status: "deleted",
lastUpdatedAt: new Date().toISOString(),
},
$inc: {
version: 1,
},
}
);
// mark data source as pending
await dataSourcesCollection.updateOne({
_id: new ObjectId(sourceId),
}, {
$set: {
status: 'pending',
billingError: undefined,
attempts: 0,
lastUpdatedAt: new Date().toISOString(),
},
$inc: {
version: 1,
},
});
}
export async function getDownloadUrlForFile(
projectId: string,
sourceId: string,
fileId: string
): Promise<string> {
await projectAuthCheck(projectId);
await getDataSource(projectId, sourceId);
const file = await dataSourceDocsCollection.findOne({
sourceId,
_id: new ObjectId(fileId),
'data.type': { $in: ['file_local', 'file_s3'] },
});
if (!file) {
throw new Error('File not found');
}
// if local, return path
if (file.data.type === 'file_local') {
return `/api/uploads/${fileId}`;
} else if (file.data.type === 'file_s3') {
const command = new GetObjectCommand({
Bucket: process.env.RAG_UPLOADS_S3_BUCKET,
Key: file.data.s3Key,
});
return await getSignedUrl(uploadsS3Client, command, { expiresIn: 60 }); // URL valid for 1 minute
}
throw new Error('Invalid file type');
}
export async function getUploadUrlsForFilesDataSource(
projectId: string,
sourceId: string,
files: { name: string; type: string; size: number }[]
): Promise<{
fileId: string,
uploadUrl: string,
path: string,
}[]> {
await projectAuthCheck(projectId);
const source = await getDataSource(projectId, sourceId);
if (source.data.type !== 'files_local' && source.data.type !== 'files_s3') {
throw new Error('Invalid files data source');
}
const urls: {
fileId: string,
uploadUrl: string,
path: string,
}[] = [];
for (const file of files) {
const fileId = new ObjectId().toString();
if (source.data.type === 'files_s3') {
// Generate presigned URL
const projectIdPrefix = projectId.slice(0, 2); // 2 characters from the start of the projectId
const path = `datasources/files/${projectIdPrefix}/${projectId}/${sourceId}/${fileId}/${file.name}`;
const command = new PutObjectCommand({
Bucket: process.env.RAG_UPLOADS_S3_BUCKET,
Key: path,
ContentType: file.type,
});
const uploadUrl = await getSignedUrl(uploadsS3Client, command, { expiresIn: 10 * 60 }); // valid for 10 minutes
urls.push({
fileId,
uploadUrl,
path,
});
} else if (source.data.type === 'files_local') {
// Generate local upload URL
urls.push({
fileId,
uploadUrl: '/api/uploads/' + fileId,
path: '/api/uploads/' + fileId,
});
}
}
return urls;
}
export async function updateDataSource({
projectId,
sourceId,
description,
}: {
projectId: string,
sourceId: string,
description: string,
}) {
await projectAuthCheck(projectId);
await getDataSource(projectId, sourceId);
await dataSourcesCollection.updateOne({
_id: new ObjectId(sourceId),
}, {
$set: {
description,
lastUpdatedAt: (new Date()).toISOString(),
},
$inc: {
version: 1,
},
});
}

View file

@ -0,0 +1,41 @@
"use server";
import { container } from "@/di/container";
import { IListJobsController } from "@/src/interface-adapters/controllers/jobs/list-jobs.controller";
import { IFetchJobController } from "@/src/interface-adapters/controllers/jobs/fetch-job.controller";
import { authCheck } from "./auth.actions";
import { JobFiltersSchema } from "@/src/application/repositories/jobs.repository.interface";
import { z } from "zod";
const listJobsController = container.resolve<IListJobsController>('listJobsController');
const fetchJobController = container.resolve<IFetchJobController>('fetchJobController');
export async function listJobs(request: {
projectId: string,
filters?: z.infer<typeof JobFiltersSchema>,
cursor?: string,
limit?: number,
}) {
const user = await authCheck();
return await listJobsController.execute({
caller: 'user',
userId: user._id,
projectId: request.projectId,
filters: request.filters,
cursor: request.cursor,
limit: request.limit,
});
}
export async function fetchJob(request: {
jobId: string,
}) {
const user = await authCheck();
return await fetchJobController.execute({
caller: 'user',
userId: user._id,
jobId: request.jobId,
});
}

View file

@ -1,906 +0,0 @@
'use server';
import { projectAuthCheck } from './project_actions';
import { z } from 'zod';
import { MCPServer, McpTool, McpServerResponse, McpServerTool } from '../lib/types/types';
import { projectsCollection } from '../lib/mongodb';
import { fetchMcpTools, toggleMcpTool } from './mcp_actions';
import { fetchMcpToolsForServer } from './mcp_actions';
import { headers } from 'next/headers';
import { authorizeUserAction } from './billing_actions';
import { redisClient } from '../lib/redis';
import { SERVER_URL_PARAMS, SERVER_CLIENT_ID_MAP } from '../lib/constants/klavis';
type McpServerType = z.infer<typeof MCPServer>;
type McpToolType = z.infer<typeof McpTool>;
type McpServerResponseType = z.infer<typeof McpServerResponse>;
// Internal API Response Types
interface KlavisServerMetadata {
id: string;
name: string;
description: string;
tools: {
name: string;
description: string;
}[];
authNeeded: boolean;
}
interface GetAllServersResponse {
servers: KlavisServerMetadata[];
}
interface CreateServerInstanceResponse {
serverUrl: string;
instanceId: string;
}
interface DeleteServerInstanceResponse {
success: boolean;
message: string;
}
interface UserInstance {
id: string;
name: string;
description: string | null;
tools: {
name: string;
description: string;
authNeeded: boolean;
isAuthenticated: boolean;
}[] | null;
authNeeded: boolean;
isAuthenticated: boolean;
}
interface GetUserInstancesResponse {
instances: UserInstance[];
}
// Add type for raw MCP tool response at the top with other types
interface RawMcpTool {
name: string;
description: string;
inputSchema: string | {
type: string;
properties: Record<string, any>;
required?: string[];
};
}
const KLAVIS_BASE_URL = 'https://api.klavis.ai';
interface KlavisApiCallOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: Record<string, any>;
additionalHeaders?: Record<string, string>;
}
async function klavisApiCall<T>(
endpoint: string,
options: KlavisApiCallOptions = {}
): Promise<T> {
const { method = 'GET', body, additionalHeaders = {} } = options;
const startTime = performance.now();
const url = `${KLAVIS_BASE_URL}${endpoint}`;
try {
const headers = {
'Authorization': `Bearer ${process.env.KLAVIS_API_KEY}`,
'Content-Type': 'application/json',
...additionalHeaders
};
const fetchOptions: RequestInit = {
method,
headers,
...(body ? { body: JSON.stringify(body) } : {})
};
const response = await fetch(url, fetchOptions);
const endTime = performance.now();
console.log('[Klavis API] Response time:', {
url,
method,
durationMs: Math.round(endTime - startTime)
});
if (!response.ok) {
const error = await response.text();
throw new Error(error);
}
return await response.json() as T;
} catch (error) {
const endTime = performance.now();
console.error('[Klavis API] Failed call:', {
url,
method,
durationMs: Math.round(endTime - startTime),
error
});
throw error;
}
}
// Lists all active server instances for a given project
export async function listActiveServerInstances(projectId: string): Promise<UserInstance[]> {
try {
await projectAuthCheck(projectId);
const queryParams = new URLSearchParams({
user_id: projectId,
platform_name: 'Rowboat'
});
console.log('[Klavis API] Fetching active instances:', { projectId, platformName: 'Rowboat' });
const endpoint = `/user/instances?${queryParams}`;
const data = await klavisApiCall<GetUserInstancesResponse>(endpoint);
// Only show instances that are authenticated or need auth
const relevantInstances = data.instances.filter(i => i.isAuthenticated || i.authNeeded);
console.log('[Klavis API] Active instances:', {
count: relevantInstances.length,
authenticated: relevantInstances.filter(i => i.isAuthenticated).map(i => i.name).join(', '),
needsAuth: relevantInstances.filter(i => i.authNeeded && !i.isAuthenticated).map(i => i.name).join(', ')
});
return data.instances;
} catch (error) {
console.error('[Klavis API] Error listing active instances:', error);
throw error;
}
}
async function enrichToolsWithParameters(
projectId: string,
serverName: string,
basicTools: { name: string; description: string }[],
isNewlyEnabled: boolean = false
): Promise<McpToolType[]> {
try {
console.log(`[Klavis API] Starting tool enrichment for ${serverName}`);
const enrichedTools = await fetchMcpToolsForServer(projectId, serverName);
if (enrichedTools.length === 0) {
console.log(`[Klavis API] No tools enriched for ${serverName}`);
return basicTools.map(tool => ({
id: tool.name,
name: tool.name,
description: tool.description,
parameters: {
type: 'object',
properties: {},
required: []
}
}));
}
console.log(`[Klavis API] Processing ${enrichedTools.length} tools for ${serverName}`);
// Create a map of enriched tools for this server
const enrichedToolMap = new Map(
enrichedTools.map(tool => [tool.name, {
name: tool.name,
description: tool.description,
parameters: {
type: 'object' as const,
properties: tool.parameters?.properties || {},
required: tool.parameters?.required || []
}
}])
);
// Find tools that couldn't be enriched
const unenrichedTools = basicTools
.filter(tool => !enrichedToolMap.has(tool.name))
.map(tool => tool.name);
if (unenrichedTools.length > 0) {
console.log('[Klavis API] Tools that could not be enriched:', {
serverName,
unenrichedTools: unenrichedTools.join(', ')
});
}
// Enrich the basic tools with parameters and descriptions
const result = basicTools.map(basicTool => {
const enrichedTool = enrichedToolMap.get(basicTool.name);
const tool: McpToolType = {
id: basicTool.name,
name: basicTool.name,
description: enrichedTool?.description || basicTool.description || '',
parameters: enrichedTool?.parameters || {
type: 'object',
properties: {},
required: []
}
};
return tool;
});
console.log('[Klavis API] Tools processed:', {
serverName,
toolCount: result.length,
tools: result.map(t => ({
name: t.name,
paramCount: Object.keys(t.parameters?.properties || {}).length,
hasParams: t.parameters && Object.keys(t.parameters.properties).length > 0
}))
});
return result;
} catch (error) {
console.error('[Klavis API] Error enriching tools with parameters:', {
serverName,
error: error instanceof Error ? error.message : 'Unknown error',
basicToolCount: basicTools.length
});
// Return basic tools with empty parameters if enrichment fails
return basicTools.map(tool => ({
id: tool.name,
name: tool.name,
description: tool.description,
parameters: {
type: 'object',
properties: {},
required: []
}
}));
}
}
// Modify listAvailableMcpServers to use enriched tools
export async function listAvailableMcpServers(projectId: string): Promise<McpServerResponseType> {
try {
await projectAuthCheck(projectId);
console.log('[Klavis API] Starting server list fetch:', { projectId });
// Get MongoDB project data first
const project = await projectsCollection.findOne({ _id: projectId });
const mongodbServers = project?.mcpServers || [];
const mongodbServerMap = new Map(mongodbServers.map(server => [server.name, server]));
console.log('[Klavis API] Found ', mongodbServers.length, ' MongoDB servers');
const serversEndpoint = '/mcp-server/servers';
const rawData = await klavisApiCall<GetAllServersResponse>(serversEndpoint, {
additionalHeaders: { 'Accept': 'application/json' }
});
console.log('[Klavis API] Raw server response:', {
serverCount: rawData.servers.length,
servers: rawData.servers.map(s => s.name).join(', ')
});
if (!rawData || !rawData.servers || !Array.isArray(rawData.servers)) {
console.error('[Klavis API] Invalid response format:', rawData);
return { data: null, error: 'Invalid response format from server' };
}
// Get active instances for comparison
const queryParams = new URLSearchParams({
user_id: projectId,
platform_name: 'Rowboat'
});
const instancesEndpoint = `/user/instances?${queryParams}`;
let activeInstances: UserInstance[] = [];
try {
const instancesData = await klavisApiCall<GetUserInstancesResponse>(instancesEndpoint);
activeInstances = instancesData.instances;
console.log('[Klavis API] Active instances:', {
count: activeInstances.length,
authenticated: activeInstances.filter(i => i.isAuthenticated).map(i => i.name).join(', '),
needsAuth: activeInstances.filter(i => i.authNeeded && !i.isAuthenticated).map(i => i.name).join(', ')
});
} catch (error) {
console.error('[Klavis API] Failed to fetch user instances:', error);
}
const activeInstanceMap = new Map(activeInstances.map(instance => [instance.name, instance]));
// Transform and enrich the data
const transformedServers = [];
let eligibleCount = 0;
let serversWithToolsCount = 0;
for (const server of rawData.servers) {
const activeInstance = activeInstanceMap.get(server.name);
const mongodbServer = mongodbServerMap.get(server.name);
// Determine server eligibility
const isActive = !!activeInstance;
const authNeeded = activeInstance ? activeInstance.authNeeded : (server.authNeeded || false);
const isAuthenticated = activeInstance ? activeInstance.isAuthenticated : false;
const isEligible = isActive && (!authNeeded || isAuthenticated);
// Get basic tools data first
const basicTools = (server.tools || []).map(tool => ({
id: tool.name || '',
name: tool.name || '',
description: tool.description || '',
}));
let availableTools: McpToolType[];
let selectedTools: McpToolType[];
// Only use MongoDB data for eligible servers
if (isEligible) {
eligibleCount++;
console.log('[Klavis API] Processing server:', server.name);
// Use MongoDB data if available
availableTools = mongodbServer?.availableTools || basicTools;
selectedTools = mongodbServer?.tools || [];
if (selectedTools.length > 0) {
serversWithToolsCount++;
}
} else {
// For non-eligible servers, just use basic data
availableTools = basicTools;
selectedTools = [];
}
transformedServers.push({
...server,
instanceId: activeInstance?.id || server.id,
serverName: server.name,
tools: selectedTools,
availableTools,
isActive,
authNeeded,
isAuthenticated,
requiresAuth: server.authNeeded || false,
serverUrl: mongodbServer?.serverUrl
});
}
console.log('[Klavis API] Server processing complete:', {
totalServers: transformedServers.length,
eligibleServers: eligibleCount,
serversWithTools: serversWithToolsCount
});
return { data: transformedServers, error: null };
} catch (error: any) {
console.error('[Klavis API] Server list error:', error.message);
return { data: null, error: error.message || 'An unexpected error occurred' };
}
}
export async function createMcpServerInstance(
serverName: string,
projectId: string,
platformName: string,
): Promise<CreateServerInstanceResponse> {
try {
await projectAuthCheck(projectId);
const requestBody = {
serverName,
userId: projectId,
platformName,
connectionType: "StreamableHttp",
};
console.log('[Klavis API] Creating server instance:', requestBody);
const endpoint = '/mcp-server/instance/create';
const result = await klavisApiCall<CreateServerInstanceResponse>(endpoint, {
method: 'POST',
body: requestBody
});
console.log('[Klavis API] Created server instance:', result);
return result;
} catch (error: any) {
console.error('[Klavis API] Error creating instance:', error);
throw error;
}
}
// Helper function to filter eligible servers
function getEligibleServers(servers: McpServerType[]): McpServerType[] {
return servers.filter(server =>
server.isActive && (!server.authNeeded || server.isAuthenticated)
);
}
async function getServerInstance(instanceId: string): Promise<{
instanceId: string;
authNeeded: boolean;
isAuthenticated: boolean;
serverName: string;
serverUrl?: string;
}> {
const endpoint = `/mcp-server/instance/get/${instanceId}`;
return await klavisApiCall(endpoint);
}
export async function updateProjectServers(projectId: string, targetServerName?: string): Promise<void> {
try {
await projectAuthCheck(projectId);
console.log('[Auth] Starting server data update:', { projectId, targetServerName });
// Get current MongoDB data
const project = await projectsCollection.findOne({ _id: projectId });
if (!project) {
console.error('[Auth] Project not found in MongoDB:', { projectId });
throw new Error("Project not found");
}
const mcpServers = project.mcpServers || [];
// Get active instances to find auth status
const instances = await listActiveServerInstances(projectId);
// If targetServerName is provided, only process that server
const instancesToProcess = targetServerName
? instances.filter(i => i.name === targetServerName)
: instances;
// For each active instance, get its current status
for (const instance of instancesToProcess) {
if (!instance.id) continue;
try {
// Get fresh instance data
const serverInstance = await getServerInstance(instance.id);
// Find this server in MongoDB
const serverIndex = mcpServers.findIndex(s => s.name === instance.name);
if (serverIndex === -1) continue;
// Update server readiness based on auth status
const isReady = !serverInstance.authNeeded || serverInstance.isAuthenticated;
// Update existing server
const updatedServer = {
...mcpServers[serverIndex],
isAuthenticated: serverInstance.isAuthenticated,
isReady
};
mcpServers[serverIndex] = updatedServer;
// If server is now ready and has no tools, try to enrich them
if (isReady && (!updatedServer.tools || updatedServer.tools.length === 0)) {
try {
console.log(`[Auth] Enriching tools for ${instance.name}`);
const enrichedTools = await enrichToolsWithParameters(
projectId,
instance.name,
updatedServer.availableTools || [],
true
);
if (enrichedTools.length > 0) {
console.log(`[Auth] Writing ${enrichedTools.length} tools to DB for ${instance.name}`);
updatedServer.availableTools = enrichedTools;
await batchAddTools(projectId, instance.name, enrichedTools);
}
} catch (enrichError) {
console.error(`[Auth] Tool enrichment failed for ${instance.name}:`, enrichError);
}
}
} catch (error) {
console.error(`[Auth] Error updating ${instance.name}:`, error);
}
}
// Update MongoDB with new server data
await projectsCollection.updateOne(
{ _id: projectId },
{ $set: { mcpServers } }
);
console.log('[Auth] MongoDB update completed');
} catch (error) {
console.error('[Auth] Error updating server data:', error);
throw error;
}
}
async function batchAddTools(projectId: string, serverName: string, tools: McpToolType[]): Promise<void> {
console.log(`[Klavis API] Writing ${tools.length} tools to ${serverName}`);
const toolsToWrite = tools.map(tool => ({
id: tool.id,
name: tool.name,
description: tool.description,
parameters: tool.parameters || {
type: 'object',
properties: {},
required: []
}
}));
console.log('[Klavis API] DB Write - batchAddTools:', {
serverName,
toolCount: tools.length,
tools: tools.map(t => t.name).join(', ')
});
// Update MongoDB in a single operation
await projectsCollection.updateOne(
{ _id: projectId, "mcpServers.name": serverName },
{
$set: {
"mcpServers.$.tools": toolsToWrite
}
}
);
console.log(`[Klavis API] Tools written to ${serverName}`);
}
export async function enableServer(
serverName: string,
projectId: string,
enabled: boolean
): Promise<CreateServerInstanceResponse | {} | { billingError: string }> {
try {
await projectAuthCheck(projectId);
console.log('[Klavis API] Toggle server request:', { serverName, projectId, enabled });
if (enabled) {
// get count of enabled hosted mcp servers for this project
const existingInstances = await listActiveServerInstances(projectId);
// billing limit check
const authResponse = await authorizeUserAction({
type: 'enable_hosted_tool_server',
data: {
existingServerCount: existingInstances.length,
},
});
if (!authResponse.success) {
return { billingError: authResponse.error || 'Billing error' };
}
// set key in redis to indicate that a server is being enabled on this project
// the key set should only succeed if the key does not already exist
const setResult = await redisClient.set(`klavis_enabling_server:${projectId}`, 'true', 'EX', 60 * 60, 'NX');
console.log('[redis] Set result here:', setResult);
if (setResult !== 'OK') {
throw new Error("A server is already being enabled on this project");
}
console.log(`[Klavis API] Creating server instance for ${serverName}...`);
const result = await createMcpServerInstance(serverName, projectId, "Rowboat");
console.log('[Klavis API] Server instance created:', {
serverName,
instanceId: result.instanceId,
serverUrl: result.serverUrl
});
// Get the current server list from MongoDB
const project = await projectsCollection.findOne({ _id: projectId });
if (!project) throw new Error("Project not found");
const mcpServers = project.mcpServers || [];
// Find the server we're enabling
const serverIndex = mcpServers.findIndex(s => s.name === serverName);
const rawServerData = (await klavisApiCall<GetAllServersResponse>('/mcp-server/servers')).servers
.find(s => s.name === serverName);
if (!rawServerData) throw new Error("Server data not found");
// Get basic tools data
const basicTools = (rawServerData.tools || []).map(tool => ({
id: tool.name || '',
name: tool.name || '',
description: tool.description || '',
}));
// Update server status in MongoDB
const updatedServer = {
...rawServerData,
instanceId: result.instanceId,
serverName: serverName,
serverUrl: result.serverUrl,
tools: basicTools, // Select all tools by default
availableTools: basicTools, // Use basic tools initially
isActive: true, // Keep isActive true to indicate server is enabled
isReady: !rawServerData.authNeeded, // Use isReady to indicate eligibility
authNeeded: rawServerData.authNeeded || false,
isAuthenticated: false,
requiresAuth: rawServerData.authNeeded || false
};
if (serverIndex === -1) {
mcpServers.push(updatedServer);
} else {
mcpServers[serverIndex] = updatedServer;
}
// Update MongoDB with server status
await projectsCollection.updateOne(
{ _id: projectId },
{ $set: { mcpServers } }
);
// Wait for server warm-up (increased from 2s to 5s)
console.log(`[Klavis API] New server detected, waiting 5s for ${serverName} to initialize...`);
await new Promise(resolve => setTimeout(resolve, 5000));
console.log(`[Klavis API] Warm-up period complete for ${serverName}`);
// Try to enrich tools regardless of auth status
try {
console.log(`[Klavis API] Enriching tools for ${serverName}`);
const enrichedTools = await enrichToolsWithParameters(
projectId,
serverName,
basicTools,
true
);
if (enrichedTools.length > 0) {
console.log(`[Klavis API] Writing ${enrichedTools.length} tools to DB for ${serverName}`);
// First update availableTools
await projectsCollection.updateOne(
{ _id: projectId, "mcpServers.name": serverName },
{
$set: {
"mcpServers.$.availableTools": enrichedTools,
"mcpServers.$.isReady": true // Mark server as ready after successful enrichment
}
}
);
// Then write the tools
await batchAddTools(projectId, serverName, enrichedTools);
console.log(`[Klavis API] Successfully wrote tools for ${serverName}`);
}
} catch (enrichError) {
console.error(`[Klavis API] Tool enrichment failed for ${serverName}:`, enrichError);
}
// remove key from redis
await redisClient.del(`klavis_enabling_server:${projectId}`);
return result;
} else {
// Get active instances to find the one to delete
const instances = await listActiveServerInstances(projectId);
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 });
// Remove from MongoDB
await projectsCollection.updateOne(
{ _id: projectId },
{ $pull: { mcpServers: { name: serverName } } }
);
} else {
console.log('[Klavis API] No instance found to disable:', { serverName });
}
return {};
}
} catch (error: any) {
console.error('[Klavis API] Toggle error:', {
server: serverName,
error: error.message,
stack: error.stack,
timestamp: new Date().toISOString()
});
throw error;
}
}
export async function deleteMcpServerInstance(
instanceId: string,
projectId: string,
): Promise<void> {
try {
await projectAuthCheck(projectId);
console.log('[Klavis API] Deleting instance:', { instanceId });
const endpoint = `/mcp-server/instance/delete/${instanceId}`;
try {
await klavisApiCall<DeleteServerInstanceResponse>(endpoint, {
method: 'DELETE'
});
console.log('[Klavis API] Instance deleted successfully:', { instanceId });
// Get the server info from MongoDB to find its name
const project = await projectsCollection.findOne({ _id: projectId });
const server = project?.mcpServers?.find(s => s.instanceId === instanceId);
if (server) {
// Update just this server's status in MongoDB
await projectsCollection.updateOne(
{ _id: projectId, "mcpServers.name": server.name },
{
$set: {
"mcpServers.$.isActive": false,
"mcpServers.$.serverUrl": null,
"mcpServers.$.tools": [],
"mcpServers.$.availableTools": [],
"mcpServers.$.instanceId": null
}
}
);
console.log('[MongoDB] Server status updated:', { serverName: server.name });
}
} catch (error: any) {
if (error.message.includes('404')) {
console.log('[Klavis API] Instance already deleted:', { instanceId });
return;
}
throw error;
}
} catch (error: any) {
console.error('[Klavis API] Error deleting instance:', error);
throw error;
}
}
export async function generateServerAuthUrl(
serverName: string,
projectId: string,
instanceId: string,
): Promise<string> {
try {
await projectAuthCheck(projectId);
// Get the origin from request headers
const headersList = await headers();
const host = headersList.get('host') || '';
const protocol = headersList.get('x-forwarded-proto') || 'http';
const origin = `${protocol}://${host}`;
// Get the URL parameter for this server
const serverUrlParam = SERVER_URL_PARAMS[serverName] || serverName.toLowerCase();
// Build base params
const params: Record<string, string> = {
instance_id: instanceId,
redirect_url: `${origin}/projects/${projectId}/tools/oauth/callback`
};
// Add client_id if available for this server
const clientId = SERVER_CLIENT_ID_MAP[serverName];
if (clientId) {
params.client_id = clientId;
}
let authUrl = `${KLAVIS_BASE_URL}/oauth/${serverUrlParam}/authorize?${new URLSearchParams(params).toString()}`
console.log('authUrl', authUrl);
return authUrl;
} catch (error) {
console.error('[Klavis API] Error generating auth URL:', error);
throw error;
}
}
export async function syncServerTools(projectId: string, serverName: string): Promise<void> {
try {
await projectAuthCheck(projectId);
console.log('[Klavis API] Starting server tool sync:', { projectId, serverName });
// Get enriched tools from MCP
const enrichedTools = await fetchMcpToolsForServer(projectId, serverName);
console.log('[Klavis API] Received enriched tools:', {
serverName,
toolCount: enrichedTools.length
});
// Convert enriched tools to the correct format
const formattedTools = enrichedTools.map(tool => {
return {
id: tool.name,
name: tool.name,
description: tool.description,
parameters: {
type: 'object' as const,
properties: tool.parameters?.properties || {},
required: tool.parameters?.required || []
}
};
});
// First verify the server exists
const project = await projectsCollection.findOne({ _id: projectId });
if (!project) {
throw new Error(`Project ${projectId} not found`);
}
const server = project.mcpServers?.find(s => s.name === serverName);
if (!server) {
throw new Error(`Server ${serverName} not found in project ${projectId}`);
}
// Update MongoDB with enriched tools
const updateResult = await projectsCollection.updateOne(
{ _id: projectId, "mcpServers.name": serverName },
{
$set: {
"mcpServers.$.availableTools": formattedTools,
"mcpServers.$.tools": formattedTools // Also update selected tools to match
}
}
);
console.log('[Klavis API] Tools synced:', {
serverName,
toolCount: formattedTools.length,
success: updateResult.modifiedCount > 0
});
} catch (error) {
console.error('[Klavis API] Error syncing server tools:', {
serverName,
error: error instanceof Error ? error.message : 'Unknown error'
});
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,361 +0,0 @@
"use server";
import { z } from "zod";
import { WorkflowTool } from "../lib/types/workflow_types";
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";
import { getMcpClient } from "../lib/mcp";
export async function fetchMcpTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
await projectAuthCheck(projectId);
const project = await projectsCollection.findOne({
_id: projectId,
});
const mcpServers = project?.mcpServers ?? [];
const tools: z.infer<typeof WorkflowTool>[] = [];
for (const mcpServer of mcpServers) {
if (!mcpServer.isActive) continue;
try {
const client = await getMcpClient(mcpServer.serverUrl!, mcpServer.name);
// List tools
const result = await client.listTools();
// Validate and parse each tool
const validTools = await Promise.all(
result.tools.map(async (tool) => {
try {
return McpServerTool.parse(tool);
} catch (error) {
console.error(`Invalid tool response from ${mcpServer.name}:`, {
tool: tool.name,
error: error instanceof Error ? error.message : 'Unknown error'
});
return null;
}
})
);
// Filter out invalid tools and convert valid ones
tools.push(...validTools
.filter((tool): tool is z.infer<typeof McpServerTool> =>
tool !== null &&
mcpServer.tools.some(t => t.id === tool.name)
)
.map(mcpTool => convertMcpServerToolToWorkflowTool(mcpTool, mcpServer))
);
} catch (e) {
console.error(`Error fetching MCP tools from ${mcpServer.name}:`, {
error: e instanceof Error ? e.message : 'Unknown error',
serverUrl: mcpServer.serverUrl
});
}
}
return tools;
}
export async function fetchMcpToolsForServer(projectId: string, serverName: string): Promise<z.infer<typeof WorkflowTool>[]> {
await projectAuthCheck(projectId);
console.log('[Klavis API] Fetching tools for specific server:', { projectId, serverName });
const project = await projectsCollection.findOne({
_id: projectId,
});
const mcpServer = project?.mcpServers?.find(server => server.name === serverName);
if (!mcpServer) {
console.error('[Klavis API] Server not found:', { serverName });
return [];
}
if (!mcpServer.isActive || !mcpServer.serverUrl) {
console.log('[Klavis API] Server is not active or missing URL:', {
serverName,
isActive: mcpServer.isActive,
hasUrl: !!mcpServer.serverUrl
});
return [];
}
const tools: z.infer<typeof WorkflowTool>[] = [];
try {
console.log('[Klavis API] Attempting MCP connection:', {
serverName,
url: mcpServer.serverUrl
});
const client = await getMcpClient(mcpServer.serverUrl, mcpServer.name);
// List tools
const result = await client.listTools();
// Log just essential info about tools
console.log('[Klavis API] Received tools from server:', {
serverName,
toolCount: result.tools.length,
tools: result.tools.map(tool => tool.name).join(', ')
});
// Get all available tools from the server
const availableToolNames = new Set(mcpServer.availableTools?.map(t => t.name) || []);
// Validate and parse each tool
const validTools = await Promise.all(
result.tools.map(async (tool) => {
try {
const parsedTool = McpServerTool.parse(tool);
return parsedTool;
} catch (error) {
console.error(`Invalid tool response from ${mcpServer.name}:`, {
tool: tool.name,
error: error instanceof Error ? error.message : 'Unknown error'
});
return null;
}
})
);
// Filter out invalid tools and convert valid ones
const convertedTools = validTools
.filter((tool): tool is z.infer<typeof McpServerTool> => tool !== null)
.map(mcpTool => {
const converted = convertMcpServerToolToWorkflowTool(mcpTool, mcpServer);
return converted;
});
tools.push(...convertedTools);
// Find tools that weren't enriched
const enrichedToolNames = new Set(convertedTools.map(t => t.name));
const unenrichedTools = Array.from(availableToolNames).filter(name => !enrichedToolNames.has(name));
if (unenrichedTools.length > 0) {
console.log('[Klavis API] Tools that could not be enriched:', {
serverName,
unenrichedTools,
totalAvailable: availableToolNames.size,
totalEnriched: enrichedToolNames.size
});
}
console.log('[Klavis API] Successfully fetched tools for server:', {
serverName,
toolCount: tools.length,
availableToolCount: availableToolNames.size,
tools: tools.map(t => t.name).join(', ')
});
} catch (e) {
console.error(`[Klavis API] Error fetching MCP tools from ${mcpServer.name}:`, {
error: e instanceof Error ? e.message : 'Unknown error',
serverUrl: mcpServer.serverUrl
});
}
return tools;
}
export async function updateMcpServers(projectId: string, mcpServers: z.infer<typeof Project>['mcpServers']): Promise<void> {
await projectAuthCheck(projectId);
await projectsCollection.updateOne({
_id: projectId,
}, { $set: { mcpServers } });
}
export async function listMcpServers(projectId: string): Promise<z.infer<typeof MCPServer>[]> {
await projectAuthCheck(projectId);
const project = await projectsCollection.findOne({
_id: projectId,
});
return project?.mcpServers ?? [];
}
export async function updateToolInAllWorkflows(
projectId: string,
mcpServer: z.infer<typeof MCPServer>,
toolId: string,
shouldAdd: boolean
): Promise<void> {
await projectAuthCheck(projectId);
// 1. Get all workflows in the project
const workflows = await agentWorkflowsCollection.find({ projectId }).toArray();
// 2. For each workflow
for (const workflow of workflows) {
// 3. Find if the tool already exists in this workflow
const existingTool = workflow.tools.find(t =>
t.isMcp &&
t.mcpServerName === mcpServer.name &&
t.name === toolId
);
if (shouldAdd && !existingTool) {
// 4a. If adding and tool doesn't exist, add it
const tool = mcpServer.tools.find(t => t.id === toolId);
if (tool) {
const workflowTool = convertMcpServerToolToWorkflowTool(
{
name: tool.name,
description: tool.description,
inputSchema: {
type: 'object',
properties: tool.parameters?.properties ?? {},
required: tool.parameters?.required ?? [],
},
},
mcpServer
);
workflow.tools.push(workflowTool);
}
} else if (!shouldAdd && existingTool) {
// 4b. If removing and tool exists, remove it
workflow.tools = workflow.tools.filter(t =>
!(t.isMcp && t.mcpServerName === mcpServer.name && t.name === toolId)
);
}
// 5. Update the workflow
await agentWorkflowsCollection.updateOne(
{ _id: workflow._id },
{
$set: {
tools: workflow.tools,
lastUpdatedAt: new Date().toISOString()
}
}
);
}
}
export async function toggleMcpTool(
projectId: string,
serverName: string,
toolId: string,
shouldAdd: boolean
): Promise<void> {
await projectAuthCheck(projectId);
// 1. Get the project and find the server
const project = await projectsCollection.findOne({ _id: projectId });
if (!project) throw new Error("Project not found");
const mcpServers = project.mcpServers || [];
const serverIndex = mcpServers.findIndex(s => s.serverName === serverName);
if (serverIndex === -1) throw new Error("Server not found");
const server = mcpServers[serverIndex];
if (shouldAdd) {
// Add tool if it doesn't exist
const toolExists = server.tools.some(t => t.id === toolId);
if (!toolExists) {
// Find the tool in availableTools to get its parameters
const availableTool = server.availableTools?.find(t => t.name === toolId);
// Create a new tool with the parameters from availableTools
const newTool = {
id: toolId,
name: toolId,
description: availableTool?.description || '',
parameters: availableTool?.parameters || {
type: 'object' as const,
properties: {},
required: []
}
};
server.tools.push(newTool);
}
} else {
// Remove tool if it exists
server.tools = server.tools.filter(t => t.id !== toolId);
}
// Update the project
await projectsCollection.updateOne(
{ _id: projectId },
{ $set: { mcpServers } }
);
}
export async function getSelectedMcpTools(projectId: string, serverName: string): Promise<string[]> {
await projectAuthCheck(projectId);
const project = await projectsCollection.findOne({ _id: projectId });
if (!project) return [];
const server = project.mcpServers?.find(s => s.serverName === serverName);
if (!server) return [];
return server.tools.map(t => t.id);
}
export async function testMcpTool(
projectId: string,
serverName: string,
toolId: string,
parameters: Record<string, any>
): Promise<any> {
await projectAuthCheck(projectId);
const project = await projectsCollection.findOne({
_id: projectId,
});
// Find the server by name in mcpServers array
const mcpServer = project?.mcpServers?.find(server => server.name === serverName);
if (!mcpServer) {
throw new Error(`Server ${serverName} not found`);
}
if (!mcpServer.isActive) {
throw new Error(`Server ${serverName} is not active`);
}
if (!mcpServer.serverUrl) {
throw new Error(`Server ${serverName} has no URL configured`);
}
try {
console.log('[MCP Test] Attempting to test tool:', {
serverName,
serverUrl: mcpServer.serverUrl,
toolId
});
const client = await getMcpClient(mcpServer.serverUrl, mcpServer.name);
console.log('[MCP Test] Connected to server, calling tool:', {
toolId,
parameters
});
// Execute the tool with the correct parameter format
const result = await client.callTool({
name: toolId,
arguments: parameters
});
console.log('[MCP Test] Tool execution completed:', {
toolId,
success: true
});
return result;
} catch (e) {
console.error(`[MCP Test] Error testing tool from ${mcpServer.name}:`, {
error: e instanceof Error ? e.message : 'Unknown error',
serverUrl: mcpServer.serverUrl,
toolId,
parameters
});
throw e;
}
}

View file

@ -0,0 +1,54 @@
'use server';
import { z } from 'zod';
import { Workflow } from "../lib/types/workflow_types";
import { Message } from "@/app/lib/types/types";
import { authCheck } from './auth.actions';
import { container } from '@/di/container';
import { Conversation } from '@/src/entities/models/conversation';
import { ICreatePlaygroundConversationController } from '@/src/interface-adapters/controllers/conversations/create-playground-conversation.controller';
import { ICreateCachedTurnController } from '@/src/interface-adapters/controllers/conversations/create-cached-turn.controller';
export async function createConversation({
projectId,
workflow,
isLiveWorkflow,
}: {
projectId: string;
workflow: z.infer<typeof Workflow>;
isLiveWorkflow: boolean;
}): Promise<z.infer<typeof Conversation>> {
const user = await authCheck();
const controller = container.resolve<ICreatePlaygroundConversationController>("createPlaygroundConversationController");
return await controller.execute({
userId: user._id,
projectId,
workflow,
isLiveWorkflow,
});
}
export async function createCachedTurn({
conversationId,
messages,
}: {
conversationId: string;
messages: z.infer<typeof Message>[];
}): Promise<{ key: string }> {
const user = await authCheck();
const createCachedTurnController = container.resolve<ICreateCachedTurnController>("createCachedTurnController");
const { key } = await createCachedTurnController.execute({
caller: "user",
userId: user._id,
conversationId,
input: {
messages,
},
});
return {
key,
};
}

View file

@ -0,0 +1,242 @@
'use server';
import { z } from 'zod';
import { container } from "@/di/container";
import { redirect } from "next/navigation";
import { templates } from "../lib/project_templates";
import { authCheck } from "./auth.actions";
import { ApiKey } from "@/src/entities/models/api-key";
import { Project } from "@/src/entities/models/project";
import { USE_AUTH } from "../lib/feature_flags";
import { Workflow } from "../lib/types/workflow_types";
import { IProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy";
import { ICreateApiKeyController } from "@/src/interface-adapters/controllers/api-keys/create-api-key.controller";
import { IListApiKeysController } from "@/src/interface-adapters/controllers/api-keys/list-api-keys.controller";
import { IDeleteApiKeyController } from "@/src/interface-adapters/controllers/api-keys/delete-api-key.controller";
import { ICreateProjectController } from "@/src/interface-adapters/controllers/projects/create-project.controller";
import { BillingError } from "@/src/entities/errors/common";
import { IFetchProjectController } from "@/src/interface-adapters/controllers/projects/fetch-project.controller";
import { IListProjectsController } from "@/src/interface-adapters/controllers/projects/list-projects.controller";
import { IRotateSecretController } from "@/src/interface-adapters/controllers/projects/rotate-secret.controller";
import { IUpdateWebhookUrlController } from "@/src/interface-adapters/controllers/projects/update-webhook-url.controller";
import { IUpdateProjectNameController } from "@/src/interface-adapters/controllers/projects/update-project-name.controller";
import { IDeleteProjectController } from "@/src/interface-adapters/controllers/projects/delete-project.controller";
import { IUpdateDraftWorkflowController } from "@/src/interface-adapters/controllers/projects/update-draft-workflow.controller";
import { IUpdateLiveWorkflowController } from "@/src/interface-adapters/controllers/projects/update-live-workflow.controller";
import { IRevertToLiveWorkflowController } from "@/src/interface-adapters/controllers/projects/revert-to-live-workflow.controller";
const projectActionAuthorizationPolicy = container.resolve<IProjectActionAuthorizationPolicy>('projectActionAuthorizationPolicy');
const createApiKeyController = container.resolve<ICreateApiKeyController>('createApiKeyController');
const listApiKeysController = container.resolve<IListApiKeysController>('listApiKeysController');
const deleteApiKeyController = container.resolve<IDeleteApiKeyController>('deleteApiKeyController');
const createProjectController = container.resolve<ICreateProjectController>('createProjectController');
const fetchProjectController = container.resolve<IFetchProjectController>('fetchProjectController');
const listProjectsController = container.resolve<IListProjectsController>('listProjectsController');
const rotateSecretController = container.resolve<IRotateSecretController>('rotateSecretController');
const updateWebhookUrlController = container.resolve<IUpdateWebhookUrlController>('updateWebhookUrlController');
const updateProjectNameController = container.resolve<IUpdateProjectNameController>('updateProjectNameController');
const deleteProjectController = container.resolve<IDeleteProjectController>('deleteProjectController');
const updateDraftWorkflowController = container.resolve<IUpdateDraftWorkflowController>('updateDraftWorkflowController');
const updateLiveWorkflowController = container.resolve<IUpdateLiveWorkflowController>('updateLiveWorkflowController');
const revertToLiveWorkflowController = container.resolve<IRevertToLiveWorkflowController>('revertToLiveWorkflowController');
export async function listTemplates() {
const templatesArray = Object.entries(templates)
.filter(([key]) => key !== 'default') // Exclude the default template
.map(([key, template]) => ({
id: key,
...template
}));
return templatesArray;
}
export async function projectAuthCheck(projectId: string) {
if (!USE_AUTH) {
return;
}
const user = await authCheck();
await projectActionAuthorizationPolicy.authorize({
caller: 'user',
userId: user._id,
projectId,
});
}
export async function createProject(formData: FormData): Promise<{ id: string } | { billingError: string }> {
const user = await authCheck();
const name = formData.get('name') as string | null;
const templateKey = formData.get('template') as string | null;
try {
const project = await createProjectController.execute({
userId: user._id,
data: {
name: name || '',
mode: {
template: templateKey || 'default',
},
},
});
return { id: project.id };
} catch (error) {
if (error instanceof BillingError) {
return { billingError: error.message };
}
throw error;
}
}
export async function createProjectFromWorkflowJson(formData: FormData): Promise<{ id: string } | { billingError: string }> {
const user = await authCheck();
const name = formData.get('name') as string | null;
const workflowJson = formData.get('workflowJson') as string;
try {
const project = await createProjectController.execute({
userId: user._id,
data: {
name: name || '',
mode: {
workflowJson,
},
},
});
return { id: project.id };
} catch (error) {
if (error instanceof BillingError) {
return { billingError: error.message };
}
throw error;
}
}
export async function fetchProject(projectId: string): Promise<z.infer<typeof Project>> {
const user = await authCheck();
const project = await fetchProjectController.execute({
caller: 'user',
userId: user._id,
projectId,
});
if (!project) {
throw new Error('Project not found');
}
return project;
}
export async function listProjects(): Promise<z.infer<typeof Project>[]> {
const user = await authCheck();
const projects = [];
let cursor = undefined;
do {
const result = await listProjectsController.execute({
userId: user._id,
cursor,
});
projects.push(...result.items);
cursor = result.nextCursor;
} while (cursor);
return projects;
}
export async function rotateSecret(projectId: string): Promise<string> {
const user = await authCheck();
return await rotateSecretController.execute({
caller: 'user',
userId: user._id,
projectId,
});
}
export async function updateWebhookUrl(projectId: string, url: string) {
const user = await authCheck();
await updateWebhookUrlController.execute({
caller: 'user',
userId: user._id,
projectId,
url,
});
}
export async function createApiKey(projectId: string): Promise<z.infer<typeof ApiKey>> {
const user = await authCheck();
return await createApiKeyController.execute({
caller: 'user',
userId: user._id,
projectId,
});
}
export async function deleteApiKey(projectId: string, id: string) {
const user = await authCheck();
return await deleteApiKeyController.execute({
caller: 'user',
userId: user._id,
projectId,
id,
});
}
export async function listApiKeys(projectId: string): Promise<z.infer<typeof ApiKey>[]> {
const user = await authCheck();
return await listApiKeysController.execute({
caller: 'user',
userId: user._id,
projectId,
});
}
export async function updateProjectName(projectId: string, name: string) {
const user = await authCheck();
await updateProjectNameController.execute({
caller: 'user',
userId: user._id,
projectId,
name,
});
}
export async function deleteProject(projectId: string) {
const user = await authCheck();
await deleteProjectController.execute({
caller: 'user',
userId: user._id,
projectId,
});
redirect('/projects');
}
export async function saveWorkflow(projectId: string, workflow: z.infer<typeof Workflow>) {
const user = await authCheck();
await updateDraftWorkflowController.execute({
caller: 'user',
userId: user._id,
projectId,
workflow,
});
}
export async function publishWorkflow(projectId: string, workflow: z.infer<typeof Workflow>) {
const user = await authCheck();
await updateLiveWorkflowController.execute({
caller: 'user',
userId: user._id,
projectId,
workflow,
});
}
export async function revertToLiveWorkflow(projectId: string) {
const user = await authCheck();
await revertToLiveWorkflowController.execute({
caller: 'user',
userId: user._id,
projectId,
});
}

View file

@ -1,353 +0,0 @@
'use server';
import { redirect } from "next/navigation";
import { ObjectId } from "mongodb";
import { dataSourcesCollection, embeddingsCollection, projectsCollection, agentWorkflowsCollection, testScenariosCollection, projectMembersCollection, apiKeysCollection, dataSourceDocsCollection, testProfilesCollection } from "../lib/mongodb";
import { z } from 'zod';
import crypto from 'crypto';
import { revalidatePath } from "next/cache";
import { templates } from "../lib/project_templates";
import { authCheck } from "./auth_actions";
import { User, WithStringId } from "../lib/types/types";
import { ApiKey } from "../lib/types/project_types";
import { Project } from "../lib/types/project_types";
import { USE_AUTH } from "../lib/feature_flags";
import { deleteMcpServerInstance, listActiveServerInstances } from "./klavis_actions";
import { authorizeUserAction } from "./billing_actions";
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 || '';
export async function projectAuthCheck(projectId: string) {
if (!USE_AUTH) {
return;
}
const user = await authCheck();
const membership = await projectMembersCollection.findOne({
projectId,
userId: user._id,
});
if (!membership) {
throw new Error('User not a member of project');
}
}
async function createBaseProject(name: string, user: WithStringId<z.infer<typeof User>>): Promise<{ id: string } | { billingError: string }> {
// fetch project count for this user
const projectCount = await projectsCollection.countDocuments({
createdByUserId: user._id,
});
// billing limit check
const authResponse = await authorizeUserAction({
type: 'create_project',
data: {
existingProjectCount: projectCount,
},
});
if (!authResponse.success) {
return { billingError: authResponse.error || 'Billing error' };
}
const projectId = crypto.randomUUID();
const chatClientId = crypto.randomBytes(16).toString('base64url');
const secret = crypto.randomBytes(32).toString('hex');
// Create project
await projectsCollection.insertOne({
_id: projectId,
name,
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
createdByUserId: user._id,
chatClientId,
secret,
nextWorkflowNumber: 1,
testRunCounter: 0,
});
// Add user to project
await projectMembersCollection.insertOne({
userId: user._id,
projectId: projectId,
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
});
// Add first api key
await createApiKey(projectId);
return { id: projectId };
}
export async function createProject(formData: FormData): Promise<{ id: string } | { billingError: string }> {
const user = await authCheck();
const name = formData.get('name') as string;
const templateKey = formData.get('template') as string;
const response = await createBaseProject(name, user);
if ('billingError' in response) {
return response;
}
const projectId = response.id;
// Add first workflow version with specified template
const { agents, prompts, tools, startAgent } = templates[templateKey];
await agentWorkflowsCollection.insertOne({
projectId,
agents,
prompts,
tools,
startAgent,
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
name: `Version 1`,
});
return { id: projectId };
}
export async function getProjectConfig(projectId: string): Promise<WithStringId<z.infer<typeof Project>>> {
await projectAuthCheck(projectId);
const project = await projectsCollection.findOne({
_id: projectId,
});
if (!project) {
throw new Error('Project config not found');
}
return project;
}
export async function listProjects(): Promise<z.infer<typeof Project>[]> {
const user = await authCheck();
const memberships = await projectMembersCollection.find({
userId: user._id,
}).toArray();
const projectIds = memberships.map((m) => m.projectId);
const projects = await projectsCollection.find({
_id: { $in: projectIds },
}).toArray();
return projects;
}
export async function rotateSecret(projectId: string): Promise<string> {
await projectAuthCheck(projectId);
const secret = crypto.randomBytes(32).toString('hex');
await projectsCollection.updateOne(
{ _id: projectId },
{ $set: { secret } }
);
return secret;
}
export async function updateWebhookUrl(projectId: string, url: string) {
await projectAuthCheck(projectId);
await projectsCollection.updateOne(
{ _id: projectId },
{ $set: { webhookUrl: url } }
);
}
export async function createApiKey(projectId: string): Promise<WithStringId<z.infer<typeof ApiKey>>> {
await projectAuthCheck(projectId);
// count existing keys
const count = await apiKeysCollection.countDocuments({ projectId });
if (count >= 3) {
throw new Error('Maximum number of API keys reached');
}
// create key
const key = crypto.randomBytes(32).toString('hex');
const doc: z.infer<typeof ApiKey> = {
projectId,
key,
createdAt: new Date().toISOString(),
};
await apiKeysCollection.insertOne(doc);
const { _id, ...rest } = doc as WithStringId<z.infer<typeof ApiKey>>;
return { ...rest, _id: _id.toString() };
}
export async function deleteApiKey(projectId: string, id: string) {
await projectAuthCheck(projectId);
await apiKeysCollection.deleteOne({ projectId, _id: new ObjectId(id) });
}
export async function listApiKeys(projectId: string): Promise<WithStringId<z.infer<typeof ApiKey>>[]> {
await projectAuthCheck(projectId);
const keys = await apiKeysCollection.find({ projectId }).toArray();
return keys.map(k => ({ ...k, _id: k._id.toString() }));
}
export async function updateProjectName(projectId: string, name: string) {
await projectAuthCheck(projectId);
await projectsCollection.updateOne({ _id: projectId }, { $set: { name } });
revalidatePath(`/projects/${projectId}`, 'layout');
}
interface McpServerDeletionError {
serverName: string;
error: string;
}
async function cleanupMcpServers(projectId: string): Promise<McpServerDeletionError[]> {
// Get all active instances directly from Klavis
const activeInstances = await listActiveServerInstances(projectId);
if (activeInstances.length === 0) return [];
console.log(`[Project Cleanup] Found ${activeInstances.length} active Klavis instances`);
// Track deletion errors
const deletionErrors: McpServerDeletionError[] = [];
// Delete each instance
const deletionPromises = activeInstances.map(async (instance) => {
if (!instance.id) return; // Skip if no instance ID
try {
await deleteMcpServerInstance(instance.id, projectId);
console.log(`[Project Cleanup] Deleted Klavis instance: ${instance.name} (${instance.id})`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error(`[Project Cleanup] Failed to delete Klavis instance: ${instance.name}`, error);
deletionErrors.push({
serverName: instance.name,
error: errorMessage
});
}
});
// Wait for all deletions to complete
await Promise.all(deletionPromises);
return deletionErrors;
}
export async function deleteProject(projectId: string) {
await projectAuthCheck(projectId);
// First cleanup any Klavis instances
if (KLAVIS_API_KEY) {
const deletionErrors = await cleanupMcpServers(projectId);
// If there were any errors deleting instances, throw an error
if (deletionErrors.length > 0) {
const failedServers = deletionErrors.map(e => `${e.serverName} (${e.error})`).join(', ');
throw new Error(`Cannot delete project because the following Klavis instances could not be deleted: ${failedServers}. Please try again or contact support if the issue persists.`);
}
}
// delete api keys
await apiKeysCollection.deleteMany({
projectId,
});
// delete embeddings
const sources = await dataSourcesCollection.find({
projectId,
}, {
projection: {
_id: true,
}
}).toArray();
const ids = sources.map(s => s._id);
// delete data sources
await embeddingsCollection.deleteMany({
sourceId: { $in: ids.map(i => i.toString()) },
});
await dataSourcesCollection.deleteMany({
_id: {
$in: ids,
}
});
// delete project members
await projectMembersCollection.deleteMany({
projectId,
});
// delete workflows
await agentWorkflowsCollection.deleteMany({
projectId,
});
// delete scenarios
await testScenariosCollection.deleteMany({
projectId,
});
// delete project
await projectsCollection.deleteOne({
_id: projectId,
});
redirect('/projects');
}
export async function createProjectFromPrompt(formData: FormData): Promise<{ id: string } | { billingError: string }> {
const user = await authCheck();
const name = formData.get('name') as string;
const response = await createBaseProject(name, user);
if ('billingError' in response) {
return response;
}
const projectId = response.id;
// Add first workflow version with default template
const { agents, prompts, tools, startAgent } = templates['default'];
await agentWorkflowsCollection.insertOne({
projectId,
agents,
prompts,
tools,
startAgent,
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
name: `Version 1`,
});
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

@ -0,0 +1,91 @@
"use server";
import { container } from "@/di/container";
import { ICreateRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/create-recurring-job-rule.controller";
import { IListRecurringJobRulesController } from "@/src/interface-adapters/controllers/recurring-job-rules/list-recurring-job-rules.controller";
import { IFetchRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/fetch-recurring-job-rule.controller";
import { IToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller";
import { IDeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller";
import { authCheck } from "./auth.actions";
import { z } from "zod";
import { Message } from "@/app/lib/types/types";
const createRecurringJobRuleController = container.resolve<ICreateRecurringJobRuleController>('createRecurringJobRuleController');
const listRecurringJobRulesController = container.resolve<IListRecurringJobRulesController>('listRecurringJobRulesController');
const fetchRecurringJobRuleController = container.resolve<IFetchRecurringJobRuleController>('fetchRecurringJobRuleController');
const toggleRecurringJobRuleController = container.resolve<IToggleRecurringJobRuleController>('toggleRecurringJobRuleController');
const deleteRecurringJobRuleController = container.resolve<IDeleteRecurringJobRuleController>('deleteRecurringJobRuleController');
export async function createRecurringJobRule(request: {
projectId: string,
input: {
messages: z.infer<typeof Message>[],
},
cron: string,
}) {
const user = await authCheck();
return await createRecurringJobRuleController.execute({
caller: 'user',
userId: user._id,
projectId: request.projectId,
input: request.input,
cron: request.cron,
});
}
export async function listRecurringJobRules(request: {
projectId: string,
cursor?: string,
limit?: number,
}) {
const user = await authCheck();
return await listRecurringJobRulesController.execute({
caller: 'user',
userId: user._id,
projectId: request.projectId,
cursor: request.cursor,
limit: request.limit,
});
}
export async function fetchRecurringJobRule(request: {
ruleId: string,
}) {
const user = await authCheck();
return await fetchRecurringJobRuleController.execute({
caller: 'user',
userId: user._id,
ruleId: request.ruleId,
});
}
export async function toggleRecurringJobRule(request: {
ruleId: string,
disabled: boolean,
}) {
const user = await authCheck();
return await toggleRecurringJobRuleController.execute({
caller: 'user',
userId: user._id,
ruleId: request.ruleId,
disabled: request.disabled,
});
}
export async function deleteRecurringJobRule(request: {
projectId: string,
ruleId: string,
}) {
const user = await authCheck();
return await deleteRecurringJobRuleController.execute({
caller: 'user',
userId: user._id,
projectId: request.projectId,
ruleId: request.ruleId,
});
}

View file

@ -0,0 +1,75 @@
"use server";
import { container } from "@/di/container";
import { ICreateScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/create-scheduled-job-rule.controller";
import { IListScheduledJobRulesController } from "@/src/interface-adapters/controllers/scheduled-job-rules/list-scheduled-job-rules.controller";
import { IFetchScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/fetch-scheduled-job-rule.controller";
import { IDeleteScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/delete-scheduled-job-rule.controller";
import { authCheck } from "./auth.actions";
import { z } from "zod";
import { Message } from "@/app/lib/types/types";
const createScheduledJobRuleController = container.resolve<ICreateScheduledJobRuleController>('createScheduledJobRuleController');
const listScheduledJobRulesController = container.resolve<IListScheduledJobRulesController>('listScheduledJobRulesController');
const fetchScheduledJobRuleController = container.resolve<IFetchScheduledJobRuleController>('fetchScheduledJobRuleController');
const deleteScheduledJobRuleController = container.resolve<IDeleteScheduledJobRuleController>('deleteScheduledJobRuleController');
export async function createScheduledJobRule(request: {
projectId: string,
input: {
messages: z.infer<typeof Message>[],
},
scheduledTime: string, // ISO datetime string
}) {
const user = await authCheck();
return await createScheduledJobRuleController.execute({
caller: 'user',
userId: user._id,
projectId: request.projectId,
input: request.input,
scheduledTime: request.scheduledTime,
});
}
export async function listScheduledJobRules(request: {
projectId: string,
cursor?: string,
limit?: number,
}) {
const user = await authCheck();
return await listScheduledJobRulesController.execute({
caller: 'user',
userId: user._id,
projectId: request.projectId,
cursor: request.cursor,
limit: request.limit,
});
}
export async function fetchScheduledJobRule(request: {
ruleId: string,
}) {
const user = await authCheck();
return await fetchScheduledJobRuleController.execute({
caller: 'user',
userId: user._id,
ruleId: request.ruleId,
});
}
export async function deleteScheduledJobRule(request: {
projectId: string,
ruleId: string,
}) {
const user = await authCheck();
return await deleteScheduledJobRuleController.execute({
caller: 'user',
userId: user._id,
projectId: request.projectId,
ruleId: request.ruleId,
});
}

View file

@ -1,610 +0,0 @@
'use server';
import { ObjectId } from "mongodb";
import { testScenariosCollection, testSimulationsCollection, testProfilesCollection, testRunsCollection, testResultsCollection, projectsCollection } from "../lib/mongodb";
import { z } from 'zod';
import { projectAuthCheck } from "./project_actions";
import { type WithStringId } from "../lib/types/types";
import { TestScenario, TestSimulation, TestProfile, TestRun, TestResult } from "../lib/types/testing_types";
export async function listScenarios(
projectId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
scenarios: WithStringId<z.infer<typeof TestScenario>>[];
total: number;
}> {
await projectAuthCheck(projectId);
// Calculate skip value for pagination
const skip = (page - 1) * pageSize;
// Get total count for pagination
const total = await testScenariosCollection.countDocuments({ projectId });
// Get paginated scenarios
const scenarios = await testScenariosCollection
.find({ projectId })
.skip(skip)
.limit(pageSize)
.toArray();
return {
scenarios: scenarios.map(scenario => ({
...scenario,
_id: scenario._id.toString(),
})),
total,
};
}
export async function getScenario(projectId: string, scenarioId: string): Promise<WithStringId<z.infer<typeof TestScenario>> | null> {
await projectAuthCheck(projectId);
// fetch scenario
const scenario = await testScenariosCollection.findOne({
_id: new ObjectId(scenarioId),
projectId,
});
if (!scenario) {
return null;
}
const { _id, ...rest } = scenario;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteScenario(projectId: string, scenarioId: string): Promise<void> {
await projectAuthCheck(projectId);
await testScenariosCollection.deleteOne({
_id: new ObjectId(scenarioId),
projectId,
});
}
export async function createScenario(
projectId: string,
data: {
name: string;
description: string;
}
): Promise<WithStringId<z.infer<typeof TestScenario>>> {
await projectAuthCheck(projectId);
const doc = {
...data,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
const result = await testScenariosCollection.insertOne(doc);
return {
...doc,
_id: result.insertedId.toString(),
};
}
export async function updateScenario(
projectId: string,
scenarioId: string,
updates: {
name?: string;
description?: string;
}
): Promise<void> {
await projectAuthCheck(projectId);
const updateData: any = {
...updates,
lastUpdatedAt: new Date().toISOString(),
};
await testScenariosCollection.updateOne(
{
_id: new ObjectId(scenarioId),
projectId,
},
{
$set: updateData,
}
);
}
export async function listSimulations(
projectId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
simulations: WithStringId<z.infer<typeof TestSimulation>>[];
total: number;
}> {
await projectAuthCheck(projectId);
const skip = (page - 1) * pageSize;
const total = await testSimulationsCollection.countDocuments({ projectId });
const simulations = await testSimulationsCollection
.find({ projectId })
.skip(skip)
.limit(pageSize)
.toArray();
return {
simulations: simulations.map(simulation => ({
...simulation,
_id: simulation._id.toString(),
})),
total,
};
}
export async function getSimulation(projectId: string, simulationId: string): Promise<WithStringId<z.infer<typeof TestSimulation>> | null> {
await projectAuthCheck(projectId);
const simulation = await testSimulationsCollection.findOne({
_id: new ObjectId(simulationId),
projectId,
});
if (!simulation) {
return null;
}
const { _id, ...rest } = simulation;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteSimulation(projectId: string, simulationId: string): Promise<void> {
await projectAuthCheck(projectId);
await testSimulationsCollection.deleteOne({
_id: new ObjectId(simulationId),
projectId,
});
}
export async function createSimulation(
projectId: string,
data: {
name: string;
description?: string;
scenarioId: string;
profileId: string | null;
passCriteria: string;
}
): Promise<WithStringId<z.infer<typeof TestSimulation>>> {
await projectAuthCheck(projectId);
const doc: z.infer<typeof TestSimulation> = {
...data,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
const result = await testSimulationsCollection.insertOne(doc);
return {
...doc,
_id: result.insertedId.toString(),
};
}
export async function updateSimulation(
projectId: string,
simulationId: string,
updates: {
name?: string;
description?: string;
scenarioId?: string;
profileId?: string | null;
passCriteria?: string;
}
): Promise<void> {
await projectAuthCheck(projectId);
const updateData: any = {
...updates,
lastUpdatedAt: new Date().toISOString(),
};
await testSimulationsCollection.updateOne(
{
_id: new ObjectId(simulationId),
projectId,
},
{
$set: updateData,
}
);
}
export async function listProfiles(
projectId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
profiles: WithStringId<z.infer<typeof TestProfile>>[];
total: number;
}> {
await projectAuthCheck(projectId);
const skip = (page - 1) * pageSize;
const total = await testProfilesCollection.countDocuments({ projectId });
const profiles = await testProfilesCollection
.find({ projectId })
.skip(skip)
.limit(pageSize)
.toArray();
return {
profiles: profiles.map(profile => ({
...profile,
_id: profile._id.toString(),
})),
total,
};
}
export async function getProfile(projectId: string, profileId: string): Promise<WithStringId<z.infer<typeof TestProfile>> | null> {
await projectAuthCheck(projectId);
const profile = await testProfilesCollection.findOne({
_id: new ObjectId(profileId),
projectId,
});
if (!profile) {
return null;
}
const { _id, ...rest } = profile;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteProfile(projectId: string, profileId: string): Promise<void> {
await projectAuthCheck(projectId);
await testProfilesCollection.deleteOne({
_id: new ObjectId(profileId),
projectId,
});
}
export async function createProfile(
projectId: string,
data: {
name: string;
context: string;
mockTools: boolean;
mockPrompt?: string;
}
): Promise<WithStringId<z.infer<typeof TestProfile>>> {
await projectAuthCheck(projectId);
const doc = {
...data,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
const result = await testProfilesCollection.insertOne(doc);
return {
...doc,
_id: result.insertedId.toString(),
};
}
export async function updateProfile(
projectId: string,
profileId: string,
updates: {
name?: string;
context?: string;
mockTools?: boolean;
mockPrompt?: string;
}
): Promise<void> {
await projectAuthCheck(projectId);
const updateData: any = {
...updates,
lastUpdatedAt: new Date().toISOString(),
};
await testProfilesCollection.updateOne(
{
_id: new ObjectId(profileId),
projectId,
},
{
$set: updateData,
}
);
}
export async function listRuns(
projectId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
runs: WithStringId<z.infer<typeof TestRun>>[];
total: number;
}> {
await projectAuthCheck(projectId);
const skip = (page - 1) * pageSize;
const total = await testRunsCollection.countDocuments({ projectId });
const runs = await testRunsCollection
.find({ projectId })
.sort({ startedAt: -1 }) // Sort by most recent first
.skip(skip)
.limit(pageSize)
.toArray();
return {
runs: runs.map(run => ({
...run,
_id: run._id.toString(),
})),
total,
};
}
export async function getRun(projectId: string, runId: string): Promise<WithStringId<z.infer<typeof TestRun>> | null> {
await projectAuthCheck(projectId);
const run = await testRunsCollection.findOne({
_id: new ObjectId(runId),
projectId,
});
if (!run) {
return null;
}
const { _id, ...rest } = run;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteRun(projectId: string, runId: string): Promise<void> {
await projectAuthCheck(projectId);
await testRunsCollection.deleteOne({
_id: new ObjectId(runId),
projectId,
});
}
export async function createRun(
projectId: string,
data: {
simulationIds: string[];
workflowId: string;
}
): Promise<WithStringId<z.infer<typeof TestRun>>> {
await projectAuthCheck(projectId);
// Increment the testRunCounter and get the new value
const result = await projectsCollection.findOneAndUpdate(
{ _id: projectId },
{ $inc: { testRunCounter: 1 } },
{ returnDocument: 'after' }
);
if (!result) {
throw new Error("Project not found");
}
const runNumber = result.testRunCounter || 1;
const doc = {
...data,
projectId,
name: `Run #${runNumber}`,
status: 'pending' as const,
startedAt: new Date().toISOString(),
aggregateResults: {
total: 0,
passCount: 0,
failCount: 0,
},
};
const insertResult = await testRunsCollection.insertOne(doc);
return {
...doc,
_id: insertResult.insertedId.toString(),
};
}
export async function updateRun(
projectId: string,
runId: string,
updates: {
status?: 'pending' | 'running' | 'completed' | 'cancelled' | 'failed' | 'error';
completedAt?: string;
aggregateResults?: {
total: number;
passCount: number;
failCount: number;
};
}
): Promise<void> {
await projectAuthCheck(projectId);
const updateData: any = {
...updates,
};
await testRunsCollection.updateOne(
{
_id: new ObjectId(runId),
projectId,
},
{
$set: updateData,
}
);
}
export async function cancelRun(projectId: string, runId: string): Promise<void> {
await projectAuthCheck(projectId);
await testRunsCollection.updateOne(
{ _id: new ObjectId(runId), projectId },
{ $set: { status: 'cancelled' } }
);
}
export async function listResults(
projectId: string,
runId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
results: WithStringId<z.infer<typeof TestResult>>[];
total: number;
}> {
await projectAuthCheck(projectId);
const skip = (page - 1) * pageSize;
const total = await testResultsCollection.countDocuments({ projectId, runId });
const results = await testResultsCollection
.find({ projectId, runId })
.skip(skip)
.limit(pageSize)
.toArray();
return {
results: results.map(result => ({
...result,
_id: result._id.toString(),
})),
total,
};
}
export async function getResult(projectId: string, resultId: string): Promise<WithStringId<z.infer<typeof TestResult>> | null> {
await projectAuthCheck(projectId);
const result = await testResultsCollection.findOne({
_id: new ObjectId(resultId),
projectId,
});
if (!result) {
return null;
}
const { _id, ...rest } = result;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteResult(projectId: string, resultId: string): Promise<void> {
await projectAuthCheck(projectId);
await testResultsCollection.deleteOne({
_id: new ObjectId(resultId),
projectId,
});
}
export async function createResult(
projectId: string,
data: {
runId: string;
simulationId: string;
result: 'pass' | 'fail';
details: string;
transcript: string;
}
): Promise<WithStringId<z.infer<typeof TestResult>>> {
await projectAuthCheck(projectId);
const doc = {
...data,
projectId,
};
const result = await testResultsCollection.insertOne(doc);
return {
...doc,
_id: result.insertedId.toString(),
};
}
export async function updateResult(
projectId: string,
resultId: string,
updates: {
result?: 'pass' | 'fail';
details?: string;
}
): Promise<void> {
await projectAuthCheck(projectId);
await testResultsCollection.updateOne(
{
_id: new ObjectId(resultId),
projectId,
},
{
$set: updates,
}
);
}
export async function getSimulationResult(
projectId: string,
runId: string,
simulationId: string
): Promise<WithStringId<z.infer<typeof TestResult>> | null> {
await projectAuthCheck(projectId);
const result = await testResultsCollection.findOne({
projectId,
runId,
simulationId
});
if (!result) {
return null;
}
const { _id, ...rest } = result;
return {
...rest,
_id: _id.toString(),
};
}
export async function listRunSimulations(
projectId: string,
simulationIds: string[]
): Promise<WithStringId<z.infer<typeof TestSimulation>>[]> {
await projectAuthCheck(projectId);
const simulations = await testSimulationsCollection
.find({
_id: { $in: simulationIds.map(id => new ObjectId(id)) },
projectId
})
.toArray();
// Fetch associated scenario and profile names
const enrichedSimulations = await Promise.all(simulations.map(async (simulation) => {
const scenario = simulation.scenarioId ? await testScenariosCollection.findOne({ _id: new ObjectId(simulation.scenarioId) }) : null;
const profile = simulation.profileId ? await testProfilesCollection.findOne({ _id: new ObjectId(simulation.profileId) }) : null;
return {
...simulation,
_id: simulation._id.toString(),
scenarioName: scenario?.name || 'Unknown',
profileName: profile?.name || 'None',
};
}));
return enrichedSimulations;
}

View file

@ -7,6 +7,7 @@ import twilio from 'twilio';
import { Twilio } from 'twilio'; import { Twilio } from 'twilio';
import { z } from "zod"; import { z } from "zod";
import { WithStringId } from "../lib/types/types"; import { WithStringId } from "../lib/types/types";
import { projectAuthCheck } from "./project.actions";
// Helper function to serialize MongoDB documents // Helper function to serialize MongoDB documents
function serializeConfig(config: any) { function serializeConfig(config: any) {
@ -19,6 +20,7 @@ function serializeConfig(config: any) {
// Real implementation for configuring Twilio number // Real implementation for configuring Twilio number
export async function configureTwilioNumber(params: z.infer<typeof TwilioConfigParams>): Promise<TwilioConfigResponse> { export async function configureTwilioNumber(params: z.infer<typeof TwilioConfigParams>): Promise<TwilioConfigResponse> {
await projectAuthCheck(params.project_id);
console.log('configureTwilioNumber - Received params:', params); console.log('configureTwilioNumber - Received params:', params);
try { try {
const client = twilio(params.account_sid, params.auth_token); const client = twilio(params.account_sid, params.auth_token);
@ -88,13 +90,12 @@ async function saveTwilioConfig(params: z.infer<typeof TwilioConfigParams>): Pro
found: existingConfig found: existingConfig
}); });
const configToSave = { const configToSave: z.infer<typeof TwilioConfig> = {
phone_number: params.phone_number, phone_number: params.phone_number,
account_sid: params.account_sid, account_sid: params.account_sid,
auth_token: params.auth_token, auth_token: params.auth_token,
label: params.label || '', // Use empty string instead of undefined label: params.label || '', // Use empty string instead of undefined
project_id: params.project_id, project_id: params.project_id,
workflow_id: params.workflow_id,
createdAt: existingConfig?.createdAt || new Date(), createdAt: existingConfig?.createdAt || new Date(),
status: 'active' as const status: 'active' as const
}; };
@ -106,7 +107,6 @@ async function saveTwilioConfig(params: z.infer<typeof TwilioConfigParams>): Pro
params.phone_number, params.phone_number,
params.account_sid, params.account_sid,
params.auth_token, params.auth_token,
params.workflow_id
); );
// Then save/update the config in database // Then save/update the config in database
@ -143,6 +143,7 @@ async function saveTwilioConfig(params: z.infer<typeof TwilioConfigParams>): Pro
// Get Twilio configuration for a workflow // Get Twilio configuration for a workflow
export async function getTwilioConfigs(projectId: string): Promise<WithStringId<z.infer<typeof TwilioConfig>>[]> { export async function getTwilioConfigs(projectId: string): Promise<WithStringId<z.infer<typeof TwilioConfig>>[]> {
await projectAuthCheck(projectId);
console.log('getTwilioConfigs - Fetching for projectId:', projectId); console.log('getTwilioConfigs - Fetching for projectId:', projectId);
const configs = await twilioConfigsCollection const configs = await twilioConfigsCollection
.find({ .find({
@ -161,6 +162,7 @@ export async function getTwilioConfigs(projectId: string): Promise<WithStringId<
// Delete a Twilio configuration (soft delete) // Delete a Twilio configuration (soft delete)
export async function deleteTwilioConfig(projectId: string, configId: string) { export async function deleteTwilioConfig(projectId: string, configId: string) {
await projectAuthCheck(projectId);
console.log('deleteTwilioConfig - Deleting config:', { projectId, configId }); console.log('deleteTwilioConfig - Deleting config:', { projectId, configId });
const result = await twilioConfigsCollection.updateOne( const result = await twilioConfigsCollection.updateOne(
{ {
@ -186,7 +188,6 @@ async function configureInboundCall(
phone_number: string, phone_number: string,
account_sid: string, account_sid: string,
auth_token: string, auth_token: string,
workflow_id: string
): Promise<InboundConfigResponse> { ): Promise<InboundConfigResponse> {
try { try {
// Normalize phone number format // Normalize phone number format
@ -196,7 +197,6 @@ async function configureInboundCall(
console.log('Configuring inbound call for:', { console.log('Configuring inbound call for:', {
phone_number, phone_number,
workflow_id
}); });
// Initialize Twilio client // Initialize Twilio client
@ -258,7 +258,6 @@ async function configureInboundCall(
return { return {
status: wasPreviouslyConfigured ? 'reconfigured' : 'configured', status: wasPreviouslyConfigured ? 'reconfigured' : 'configured',
phone_number: phone_number, phone_number: phone_number,
workflow_id: workflow_id,
previous_webhook: wasPreviouslyConfigured ? currentVoiceUrl : undefined previous_webhook: wasPreviouslyConfigured ? currentVoiceUrl : undefined
}; };

View file

@ -1,241 +0,0 @@
'use server';
import { ObjectId, WithId } from "mongodb";
import { projectsCollection, agentWorkflowsCollection } from "../lib/mongodb";
import { z } from 'zod';
import { templates } from "../lib/project_templates";
import { projectAuthCheck } from "./project_actions";
import { WithStringId } from "../lib/types/types";
import { Workflow } from "../lib/types/workflow_types";
export async function createWorkflow(projectId: string): Promise<WithStringId<z.infer<typeof Workflow>>> {
await projectAuthCheck(projectId);
// get the next workflow number
const doc = await projectsCollection.findOneAndUpdate({
_id: projectId,
}, {
$inc: {
nextWorkflowNumber: 1,
},
}, {
returnDocument: 'after'
});
if (!doc) {
throw new Error('Project not found');
}
const nextWorkflowNumber = doc.nextWorkflowNumber;
// create the workflow
const { agents, prompts, tools, startAgent } = templates['default'];
const workflow = {
agents,
prompts,
tools,
startAgent,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
name: `Version ${nextWorkflowNumber}`,
};
const { insertedId } = await agentWorkflowsCollection.insertOne(workflow);
const { _id, ...rest } = workflow as WithId<z.infer<typeof Workflow>>;
return {
...rest,
_id: insertedId.toString(),
};
}
export async function cloneWorkflow(projectId: string, workflowId: string): Promise<WithStringId<z.infer<typeof Workflow>>> {
await projectAuthCheck(projectId);
const workflow = await agentWorkflowsCollection.findOne({
_id: new ObjectId(workflowId),
projectId,
});
if (!workflow) {
throw new Error('Workflow not found');
}
// create a new workflow with the same content
const newWorkflow = {
...workflow,
_id: new ObjectId(),
name: `Copy of ${workflow.name || 'Unnamed workflow'}`,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
const { insertedId } = await agentWorkflowsCollection.insertOne(newWorkflow);
const { _id, ...rest } = newWorkflow as WithId<z.infer<typeof Workflow>>;
return {
...rest,
_id: insertedId.toString(),
};
}
export async function renameWorkflow(projectId: string, workflowId: string, name: string) {
await projectAuthCheck(projectId);
await agentWorkflowsCollection.updateOne({
_id: new ObjectId(workflowId),
projectId,
}, {
$set: {
name,
lastUpdatedAt: new Date().toISOString(),
},
});
}
export async function saveWorkflow(projectId: string, workflowId: string, workflow: z.infer<typeof Workflow>) {
await projectAuthCheck(projectId);
// check if workflow exists
const existingWorkflow = await agentWorkflowsCollection.findOne({
_id: new ObjectId(workflowId),
projectId,
});
if (!existingWorkflow) {
throw new Error('Workflow not found');
}
// ensure that this is not the published workflow for this project
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
if (publishedWorkflowId && publishedWorkflowId === workflowId) {
throw new Error('Cannot save published workflow');
}
// update the workflow, except name and description
const { _id, name, ...rest } = workflow as WithId<z.infer<typeof Workflow>>;
await agentWorkflowsCollection.updateOne({
_id: new ObjectId(workflowId),
}, {
$set: {
...rest,
lastUpdatedAt: new Date().toISOString(),
},
});
}
export async function publishWorkflow(projectId: string, workflowId: string) {
await projectAuthCheck(projectId);
// check if workflow exists
const existingWorkflow = await agentWorkflowsCollection.findOne({
_id: new ObjectId(workflowId),
projectId,
});
if (!existingWorkflow) {
throw new Error('Workflow not found');
}
// publish the workflow
await projectsCollection.updateOne({
"_id": projectId,
}, {
$set: {
publishedWorkflowId: workflowId,
}
});
}
export async function fetchPublishedWorkflowId(projectId: string): Promise<string | null> {
await projectAuthCheck(projectId);
const project = await projectsCollection.findOne({
_id: projectId,
});
return project?.publishedWorkflowId || null;
}
export async function fetchWorkflow(projectId: string, workflowId: string): Promise<WithStringId<z.infer<typeof Workflow>>> {
await projectAuthCheck(projectId);
// fetch workflow
const workflow = await agentWorkflowsCollection.findOne({
_id: new ObjectId(workflowId),
projectId,
});
if (!workflow) {
throw new Error('Workflow not found');
}
const { _id, ...rest } = workflow;
return {
...rest,
_id: _id.toString(),
};
}
export async function listWorkflows(
projectId: string,
page: number = 1,
limit: number = 10
): Promise<{
workflows: (WithStringId<z.infer<typeof Workflow>>)[];
total: number;
publishedWorkflowId: string | null;
}> {
await projectAuthCheck(projectId);
// fetch total count
const total = await agentWorkflowsCollection.countDocuments({ projectId });
// fetch published workflow
let publishedWorkflowId: string | null = null;
let publishedWorkflow: WithId<z.infer<typeof Workflow>> | null = null;
if (page === 1) {
publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
if (publishedWorkflowId) {
publishedWorkflow = await agentWorkflowsCollection.findOne({
_id: new ObjectId(publishedWorkflowId),
projectId,
}, {
projection: {
_id: 1,
name: 1,
description: 1,
createdAt: 1,
lastUpdatedAt: 1,
},
});
}
}
// fetch workflows with pagination
let workflows: WithId<z.infer<typeof Workflow>>[] = await agentWorkflowsCollection.find(
{
projectId,
...(publishedWorkflowId ? {
_id: {
$ne: new ObjectId(publishedWorkflowId)
}
} : {}),
},
{
sort: { lastUpdatedAt: -1 },
projection: {
_id: 1,
name: 1,
description: 1,
createdAt: 1,
lastUpdatedAt: 1,
},
skip: (page - 1) * limit,
limit: limit,
}
).toArray();
workflows = [
...(publishedWorkflow ? [publishedWorkflow] : []),
...workflows,
];
// return workflows
return {
workflows: workflows.map((w) => {
const { _id, ...rest } = w;
return {
...rest,
_id: _id.toString(),
};
}),
total,
publishedWorkflowId,
};
}

View file

@ -0,0 +1,69 @@
import { PrefixLogger } from "@/app/lib/utils";
import { container } from "@/di/container";
import { IHandleComposioWebhookRequestController } from "@/src/interface-adapters/controllers/composio/webhook/handle-composio-webhook-request.controller";
import { nanoid } from "nanoid";
const handleComposioWebhookRequestController = container.resolve<IHandleComposioWebhookRequestController>("handleComposioWebhookRequestController");
export async function POST(request: Request) {
const id = nanoid();
const logger = new PrefixLogger(`composio-webhook-[${id}]`);
const payload = await request.text();
const headers = Object.fromEntries(request.headers.entries());
logger.log('received event', JSON.stringify(headers), payload);
// handle webhook
try {
await handleComposioWebhookRequestController.execute({
headers,
payload,
});
} catch (error) {
logger.log('Error handling composio webhook', error);
}
return Response.json({
success: true,
});
}
/*
{
"type": "slack_receive_message",
"timestamp": "2025-08-06T01:49:46.008Z",
"data": {
"bot_id": null,
"channel": "C08PTQKM2DS",
"channel_type": "channel",
"team_id": null,
"text": "test",
"ts": "1754444983.699449",
"user": "U077XPW36V9",
"connection_id": "551d86b3-44e3-4c62-b996-44648ccf77b3",
"connection_nano_id": "ca_2n0cZnluJ1qc",
"trigger_nano_id": "ti_dU7LJMfP5KSr",
"trigger_id": "ec96b753-c745-4f37-b5d8-82a35ce0fa0b",
"user_id": "987dbd2e-c455-4c8f-8d55-a997a2d7680a"
}
}
{
"type": "github_issue_added_event",
"timestamp": "2025-08-06T02:00:13.680Z",
"data": {
"action": "opened",
"createdAt": "2025-08-06T02:00:10Z",
"createdBy": "ramnique",
"description": "this is a test issue",
"issue_id": 3294929549,
"number": 1,
"title": "test issue",
"url": "https://github.com/ramnique/stack-reload-bug/issues/1",
"connection_id": "06d7c6b9-bd41-4ce7-a6b4-b17a65315c99",
"connection_nano_id": "ca_HmQ-SSOdxUEu",
"trigger_nano_id": "ti_IjLPi4O0d4xo",
"trigger_id": "ccbf3ad3-442b-491c-a1c5-e23f8b606592",
"user_id": "987dbd2e-c455-4c8f-8d55-a997a2d7680a"
}
}
*/

View file

@ -1,8 +1,8 @@
import { getCustomerIdForProject, logUsage } from "@/app/lib/billing"; import { getCustomerIdForProject, logUsage, UsageTracker } from "@/app/lib/billing";
import { USE_BILLING } from "@/app/lib/feature_flags"; import { USE_BILLING } from "@/app/lib/feature_flags";
import { redisClient } from "@/app/lib/redis"; import { redisClient } from "@/app/lib/redis";
import { CopilotAPIRequest } from "@/app/lib/types/copilot_types"; import { CopilotAPIRequest } from "@/src/application/lib/copilot/types";
import { streamMultiAgentResponse } from "@/app/lib/copilot/copilot"; import { streamMultiAgentResponse } from "@/src/application/lib/copilot/copilot";
export async function GET(request: Request, props: { params: Promise<{ streamId: string }> }) { export async function GET(request: Request, props: { params: Promise<{ streamId: string }> }) {
const params = await props.params; const params = await props.params;
@ -21,6 +21,7 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
billingCustomerId = await getCustomerIdForProject(projectId); billingCustomerId = await getCustomerIdForProject(projectId);
} }
const usageTracker = new UsageTracker();
const encoder = new TextEncoder(); const encoder = new TextEncoder();
let messageCount = 0; let messageCount = 0;
@ -29,6 +30,7 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
try { try {
// Iterate over the copilot stream generator // Iterate over the copilot stream generator
for await (const event of streamMultiAgentResponse( for await (const event of streamMultiAgentResponse(
usageTracker,
projectId, projectId,
context, context,
messages, messages,
@ -39,27 +41,29 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
if ('content' in event) { if ('content' in event) {
messageCount++; messageCount++;
controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(event)}\n\n`)); controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(event)}\n\n`));
} else if ('type' in event && event.type === 'tool-call') {
controller.enqueue(encoder.encode(`event: tool-call\ndata: ${JSON.stringify(event)}\n\n`));
} else if ('type' in event && event.type === 'tool-result') {
controller.enqueue(encoder.encode(`event: tool-result\ndata: ${JSON.stringify(event)}\n\n`));
} else { } else {
controller.enqueue(encoder.encode(`event: done\ndata: ${JSON.stringify(event)}\n\n`)); controller.enqueue(encoder.encode(`event: done\ndata: ${JSON.stringify(event)}\n\n`));
} }
} }
controller.close();
// increment copilot request count in billing
if (USE_BILLING && billingCustomerId) {
try {
await logUsage(billingCustomerId, {
type: "copilot_requests",
amount: 1,
});
} catch (error) {
console.error("Error logging usage", error);
}
}
} catch (error) { } catch (error) {
console.error('Error processing copilot stream:', error); console.error('Error processing copilot stream:', error);
controller.error(error); controller.error(error);
} finally {
// log copilot usage
if (USE_BILLING && billingCustomerId) {
try {
await logUsage(billingCustomerId, {
items: usageTracker.flush(),
});
} catch (error) {
console.error("Error logging usage", error);
}
}
controller.close();
} }
}, },
}); });

View file

@ -1,75 +1,51 @@
import { getCustomerIdForProject, logUsage } from "@/app/lib/billing"; import { container } from "@/di/container";
import { USE_BILLING } from "@/app/lib/feature_flags"; import { IRunCachedTurnController } from "@/src/interface-adapters/controllers/conversations/run-cached-turn.controller";
import { redisClient } from "@/app/lib/redis"; import { requireAuth } from "@/app/lib/auth";
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"; import { z } from "zod";
import { TurnEvent } from "@/src/entities/models/turn";
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 }> }) { export async function GET(request: Request, props: { params: Promise<{ streamId: string }> }) {
const params = await props.params; const params = await props.params;
// get the payload from redis
const payload = await redisClient.get(`chat-stream-${params.streamId}`); // get user data
if (!payload) { const user = await requireAuth();
return new Response("Stream not found", { status: 404 });
} const runCachedTurnController = container.resolve<IRunCachedTurnController>("runCachedTurnController");
// parse the payload const encoder = new TextEncoder();
const { workflow, projectTools, messages } = PayloadSchema.parse(JSON.parse(payload));
console.log('payload', payload); const stream = new ReadableStream({
async start(controller) {
// fetch billing customer id try {
let billingCustomerId: string | null = null; // Iterate over the generator
if (USE_BILLING) { for await (const event of runCachedTurnController.execute({
billingCustomerId = await getCustomerIdForProject(workflow.projectId); caller: "user",
} userId: user._id,
cachedTurnKey: params.streamId,
const encoder = new TextEncoder(); })) {
let messageCount = 0; controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(event)}\n\n`));
}
const stream = new ReadableStream({ } catch (error) {
async start(controller) { console.error('Error processing stream:', error);
try { const errMessage: z.infer<typeof TurnEvent> = {
// Iterate over the generator type: "error",
for await (const event of streamResponse(workflow, projectTools, messages)) { error: `Error processing stream: ${error}`,
// Check if this is a message event (has role property) isBillingError: false,
if ('role' in event) { };
if (event.role === 'assistant') { controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(errMessage)}\n\n`));
messageCount++; } finally {
console.log("closing stream");
controller.enqueue(encoder.encode("event: end\n\n"));
controller.close();
} }
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`));
} return new Response(stream, {
} headers: {
"Content-Type": "text/event-stream",
controller.close(); "Cache-Control": "no-cache",
"Connection": "keep-alive",
// Log billing usage },
if (USE_BILLING && billingCustomerId) { });
await logUsage(billingCustomerId, {
type: "agent_messages",
amount: messageCount,
});
}
} catch (error) {
console.error('Error processing stream:', error);
controller.error(error);
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
} }

View file

@ -0,0 +1,6 @@
import { NextResponse } from 'next/server';
import { templates } from '@/app/lib/project_templates';
export async function GET() {
return NextResponse.json(templates);
}

View file

@ -1,18 +1,11 @@
import { getResponse } from "@/app/lib/agents"; import { getResponse } from "@/src/application/lib/agents-runtime/agents";
import { agentWorkflowsCollection, twilioConfigsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb"; import { twilioConfigsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb";
import { collectProjectTools } from "@/app/lib/project_tools";
import { PrefixLogger } from "@/app/lib/utils"; import { PrefixLogger } from "@/app/lib/utils";
import VoiceResponse from "twilio/lib/twiml/VoiceResponse"; import VoiceResponse from "twilio/lib/twiml/VoiceResponse";
import { ObjectId } from "mongodb";
import { z } from "zod"; import { z } from "zod";
import { TwilioInboundCall } from "@/app/lib/types/voice_types"; import { TwilioInboundCall } from "@/app/lib/types/voice_types";
import { hangup, reject, XmlResponse, ZStandardRequestParams } from "../utils"; 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 form data example
... ...
@ -46,6 +39,13 @@ export async function POST(request: Request) {
FromState: 'PXXXXXXX' FromState: 'PXXXXXXX'
} }
*/ */
export async function POST(request: Request) {
return new Response('Not implemented', { status: 501 });
/*
let logger = new PrefixLogger("twilioInboundCall");
logger.log("Received inbound call request");
const recvdAt = new Date();
// parse and validate form data // parse and validate form data
const formData = await request.formData(); const formData = await request.formData();
logger.log('request body:', JSON.stringify(Object.fromEntries(formData))); logger.log('request body:', JSON.stringify(Object.fromEntries(formData)));
@ -63,25 +63,26 @@ export async function POST(request: Request) {
return reject('rejected'); return reject('rejected');
} }
// extract workflow and project id and fetch workflow from db // fetch project and extract live workflow
// if workflow not found, reject the call // if workflow not found, reject the call
const projectId = twilioConfig.project_id; const projectId = twilioConfig.project_id;
const workflowId = twilioConfig.workflow_id; const project = await projectsCollection.findOne({
const workflow = await agentWorkflowsCollection.findOne({ _id: projectId,
projectId: projectId,
_id: new ObjectId(workflowId),
}); });
const project = null;
if (!project) {
logger.log(`Project ${projectId} not found`);
return reject('rejected');
}
const workflow = project.liveWorkflow;
if (!workflow) { if (!workflow) {
logger.log(`Workflow ${workflowId} not found for project ${projectId}`); logger.log(`Workflow not found for project ${projectId}`);
return reject('rejected'); return reject('rejected');
} }
// fetch project tools
const projectTools = await collectProjectTools(projectId);
// this is the first turn, get the initial assistant response // this is the first turn, get the initial assistant response
// and validate it // and validate it
const { messages } = await getResponse(workflow, projectTools, []); const { messages } = await getResponse(projectId, workflow, []);
if (messages.length === 0) { if (messages.length === 0) {
logger.log('Agent response is empty'); logger.log('Agent response is empty');
return hangup(); return hangup();
@ -98,7 +99,6 @@ export async function POST(request: Request) {
to: data.To, to: data.To,
from: data.From, from: data.From,
projectId, projectId,
workflowId,
messages, messages,
createdAt: recvdAt.toISOString(), createdAt: recvdAt.toISOString(),
lastUpdatedAt: new Date().toISOString(), lastUpdatedAt: new Date().toISOString(),
@ -117,4 +117,5 @@ export async function POST(request: Request) {
action: `/api/twilio/turn/${data.CallSid}`, action: `/api/twilio/turn/${data.CallSid}`,
}); });
return XmlResponse(response); return XmlResponse(response);
*/
} }

View file

@ -1,9 +1,7 @@
import { getResponse } from "@/app/lib/agents"; import { getResponse } from "@/src/application/lib/agents-runtime/agents";
import { agentWorkflowsCollection, twilioConfigsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb"; import { twilioInboundCallsCollection } from "@/app/lib/mongodb";
import { collectProjectTools } from "@/app/lib/project_tools";
import { PrefixLogger } from "@/app/lib/utils"; import { PrefixLogger } from "@/app/lib/utils";
import VoiceResponse from "twilio/lib/twiml/VoiceResponse"; import VoiceResponse from "twilio/lib/twiml/VoiceResponse";
import { ObjectId } from "mongodb";
import { z } from "zod"; import { z } from "zod";
import { hangup, XmlResponse, ZStandardRequestParams } from "../../utils"; import { hangup, XmlResponse, ZStandardRequestParams } from "../../utils";
import { Message } from "@/app/lib/types/types"; import { Message } from "@/app/lib/types/types";
@ -17,6 +15,8 @@ export async function POST(
request: Request, request: Request,
{ params }: { params: Promise<{ callSid: string }> } { params }: { params: Promise<{ callSid: string }> }
) { ) {
return new Response('Not implemented', { status: 501 });
/*
const { callSid } = await params; const { callSid } = await params;
let logger = new PrefixLogger(`turn:${callSid}`); let logger = new PrefixLogger(`turn:${callSid}`);
logger.log("Received turn"); logger.log("Received turn");
@ -35,20 +35,21 @@ export async function POST(
logger.log('Call not found'); logger.log('Call not found');
return hangup(); return hangup();
} }
const { workflowId, projectId } = call; const { projectId } = call;
// fetch workflow // fetch project and extract live workflow
const workflow = await agentWorkflowsCollection.findOne({ const project = await projectsCollection.findOne({
projectId: projectId, _id: projectId,
_id: new ObjectId(workflowId),
}); });
if (!workflow) { if (!project) {
logger.log(`Workflow ${workflowId} not found for project ${projectId}`); logger.log(`Project ${projectId} not found`);
return hangup();
}
const workflow = project.liveWorkflow;
if (!workflow) {
logger.log(`Workflow not found for project ${projectId}`);
return hangup(); return hangup();
} }
// fetch project tools
const projectTools = await collectProjectTools(projectId);
// add user speech as user message, and get assistant response // add user speech as user message, and get assistant response
const reqMessages: z.infer<typeof Message>[] = [ const reqMessages: z.infer<typeof Message>[] = [
@ -58,7 +59,7 @@ export async function POST(
content: data.SpeechResult, content: data.SpeechResult,
} }
]; ];
const { messages } = await getResponse(workflow, projectTools, reqMessages); const { messages } = await getResponse(projectId, workflow, reqMessages);
if (messages.length === 0) { if (messages.length === 0) {
logger.log('Agent response is empty'); logger.log('Agent response is empty');
return hangup(); return hangup();
@ -94,4 +95,5 @@ export async function POST(
action: `/api/twilio/turn/${callSid}`, action: `/api/twilio/turn/${callSid}`,
}); });
return XmlResponse(response); return XmlResponse(response);
*/
} }

View file

@ -2,11 +2,13 @@ import { NextRequest, NextResponse } from 'next/server';
import path from 'path'; import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
import fsSync from 'fs'; import fsSync from 'fs';
import { dataSourceDocsCollection } from '@/app/lib/mongodb'; import { container } from '@/di/container';
import { ObjectId } from 'mongodb'; import { IDataSourceDocsRepository } from '@/src/application/repositories/data-source-docs.repository.interface';
const UPLOADS_DIR = process.env.RAG_UPLOADS_DIR || '/uploads'; const UPLOADS_DIR = process.env.RAG_UPLOADS_DIR || '/uploads';
const dataSourceDocsRepository = container.resolve<IDataSourceDocsRepository>('dataSourceDocsRepository');
// PUT endpoint to handle file uploads // PUT endpoint to handle file uploads
export async function PUT(request: NextRequest, props: { params: Promise<{ fileId: string }> }) { export async function PUT(request: NextRequest, props: { params: Promise<{ fileId: string }> }) {
const params = await props.params; const params = await props.params;
@ -39,10 +41,8 @@ export async function GET(request: NextRequest, props: { params: Promise<{ fileI
return NextResponse.json({ error: 'Missing file ID' }, { status: 400 }); return NextResponse.json({ error: 'Missing file ID' }, { status: 400 });
} }
const filePath = path.join(UPLOADS_DIR, fileId);
// get mimetype from database // get mimetype from database
const doc = await dataSourceDocsCollection.findOne({ _id: new ObjectId(fileId) }); const doc = await dataSourceDocsRepository.fetch(fileId);
if (!doc) { if (!doc) {
return NextResponse.json({ error: 'File not found' }, { status: 404 }); return NextResponse.json({ error: 'File not found' }, { status: 404 });
} }
@ -54,6 +54,9 @@ export async function GET(request: NextRequest, props: { params: Promise<{ fileI
const fileName = doc.data.name; const fileName = doc.data.name;
try { try {
// strip uploads dir from path
const filePath = path.join(UPLOADS_DIR, doc.data.path.split('/api/uploads/')[1]);
// Check if file exists // Check if file exists
await fs.access(filePath); await fs.access(filePath);
// Create a readable stream // Create a readable stream

View file

@ -1,16 +1,10 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { agentWorkflowsCollection, db, projectsCollection, testProfilesCollection } from "../../../../lib/mongodb";
import { z } from "zod"; import { z } from "zod";
import { ObjectId } from "mongodb"; import { ApiResponse } from "@/app/lib/types/api_types";
import { authCheck } from "../../utils"; import { ApiRequest } from "@/app/lib/types/api_types";
import { ApiRequest, ApiResponse } from "../../../../lib/types/types";
import { check_query_limit } from "../../../../lib/rate_limiting";
import { PrefixLogger } from "../../../../lib/utils"; import { PrefixLogger } from "../../../../lib/utils";
import { TestProfile } from "@/app/lib/types/testing_types"; import { container } from "@/di/container";
import { collectProjectTools } from "@/app/lib/project_tools"; import { IRunTurnController } from "@/src/interface-adapters/controllers/conversations/run-turn.controller";
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 // get next turn / agent response
export async function POST( export async function POST(
@ -21,116 +15,69 @@ export async function POST(
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
const logger = new PrefixLogger(`${requestId}`); const logger = new PrefixLogger(`${requestId}`);
logger.log(`Got chat request for project ${projectId}`); // parse and validate the request body
let data;
try {
const body = await req.json();
data = ApiRequest.parse(body);
} catch (e) {
logger.log(`Invalid JSON in request body: ${e}`);
return Response.json({ error: "Invalid request" }, { status: 400 });
}
const { conversationId, messages, mockTools, stream } = data;
// check query limit const runTurnController = container.resolve<IRunTurnController>("runTurnController");
if (!await check_query_limit(projectId)) {
logger.log(`Query limit exceeded for project ${projectId}`); // get assistant response
return Response.json({ error: "Query limit exceeded" }, { status: 429 }); const response = await runTurnController.execute({
caller: "api",
apiKey: req.headers.get("Authorization")?.split(" ")[1],
projectId,
input: {
messages,
mockTools,
},
conversationId: conversationId || undefined,
stream: Boolean(stream),
});
// if streaming is requested, return SSE stream
if (stream && 'stream' in response) {
const encoder = new TextEncoder();
const readableStream = new ReadableStream({
async start(controller) {
try {
// Iterate over the generator
for await (const event of response.stream) {
controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(event)}\n\n`));
}
controller.close();
} catch (error) {
logger.log(`Error processing stream: ${error}`);
controller.error(error);
}
},
});
return new Response(readableStream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
} }
return await authCheck(projectId, req, async () => { // non-streaming response (existing behavior)
// fetch billing customer id if (!('turn' in response)) {
let billingCustomerId: string | null = null; logger.log(`No turn data found in response`);
if (USE_BILLING) { return Response.json({ error: "No turn data found in response" }, { status: 500 });
billingCustomerId = await getCustomerIdForProject(projectId); }
}
// parse and validate the request body const responseBody: z.infer<typeof ApiResponse> = {
let body; conversationId: response.conversationId,
try { turn: response.turn,
body = await req.json(); };
} catch (e) { return Response.json(responseBody);
logger.log(`Invalid JSON in request body: ${e}`);
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
}
logger.log(`Request json: ${JSON.stringify(body, null, 2)}`);
const result = ApiRequest.safeParse(body);
if (!result.success) {
logger.log(`Invalid request body: ${result.error.message}`);
return Response.json({ error: `Invalid request body: ${result.error.message}` }, { status: 400 });
}
const reqMessages = result.data.messages;
const mockToolOverrides = result.data.mockTools;
// fetch published workflow id
const project = await projectsCollection.findOne({
_id: projectId,
});
if (!project) {
logger.log(`Project ${projectId} not found`);
return Response.json({ error: "Project not found" }, { status: 404 });
}
// fetch project tools
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;
if (!workflowId) {
logger.log(`No workflow id provided in request or project has no published workflow`);
return Response.json({ error: "No workflow id provided in request or project has no published workflow" }, { status: 404 });
}
// 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 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) => {
acc.push(agent.model);
return acc;
}, [] as string[]);
const response = await authorize(billingCustomerId, {
type: 'agent_response',
data: {
agentModels,
},
});
if (!response.success) {
return Response.json({ error: response.error || 'Billing error' }, { status: 402 });
}
}
// if test profile is provided in the request, use it
let testProfile: z.infer<typeof TestProfile> | null = null;
if (result.data.testProfileId) {
testProfile = await testProfilesCollection.findOne({
projectId: projectId,
_id: new ObjectId(result.data.testProfileId),
});
if (!testProfile) {
logger.log(`Test profile ${result.data.testProfileId} not found for project ${projectId}`);
return Response.json({ error: "Test profile not found" }, { status: 404 });
}
}
// get assistant response
const { messages } = await getResponse(workflow, projectTools, reqMessages);
// log billing usage
if (USE_BILLING && billingCustomerId) {
const agentMessageCount = messages.filter(m => m.role === 'assistant').length;
await logUsage(billingCustomerId, {
type: 'agent_messages',
amount: agentMessageCount,
});
}
const responseBody: z.infer<typeof ApiResponse> = {
messages,
};
return Response.json(responseBody);
});
} }

View file

@ -1,28 +0,0 @@
import { NextRequest } from "next/server";
import { apiKeysCollection, projectsCollection } from "../../lib/mongodb";
export async function authCheck(projectId: string, req: NextRequest, handler: () => Promise<Response>): Promise<Response> {
const authHeader = req.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return Response.json({ error: "Authorization header must be a Bearer token" }, { status: 400 });
}
const key = authHeader.split(' ')[1];
if (!key) {
return Response.json({ error: "Missing API key in request" }, { status: 400 });
}
// check if api key is valid
// while also updating last used timestamp
const result = await apiKeysCollection.findOneAndUpdate(
{
projectId,
key,
},
{ $set: { lastUsedAt: new Date().toISOString() } }
);
if (!result) {
return Response.json({ error: "Invalid API key" }, { status: 403 });
}
return await handler();
}

View file

@ -1,16 +1,16 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { apiV1 } from "rowboat-shared"; import { apiV1 } from "rowboat-shared";
import { agentWorkflowsCollection, projectsCollection, chatsCollection, chatMessagesCollection } from "../../../../../../lib/mongodb"; import { chatsCollection, chatMessagesCollection } from "../../../../../../lib/mongodb";
import { z } from "zod"; import { z } from "zod";
import { ObjectId, WithId } from "mongodb"; import { ObjectId, WithId } from "mongodb";
import { authCheck } from "../../../utils"; import { authCheck } from "../../../utils";
import { check_query_limit } from "../../../../../../lib/rate_limiting";
import { PrefixLogger } from "../../../../../../lib/utils"; import { PrefixLogger } from "../../../../../../lib/utils";
import { collectProjectTools } from "@/app/lib/project_tools";
import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing"; import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing";
import { USE_BILLING } from "@/app/lib/feature_flags"; import { USE_BILLING } from "@/app/lib/feature_flags";
import { getResponse } from "@/app/lib/agents"; import { getResponse } from "@/src/application/lib/agents-runtime/agents";
import { Message, AssistantMessage, AssistantMessageWithToolCalls, ToolMessage } from "@/app/lib/types/types"; import { Message, AssistantMessage, AssistantMessageWithToolCalls, ToolMessage } from "@/app/lib/types/types";
import { IUsageQuotaPolicy } from "@/src/application/policies/usage-quota.policy.interface";
import { container } from "@/di/container";
function convert(messages: z.infer<typeof apiV1.ChatMessage>[]): z.infer<typeof Message>[] { function convert(messages: z.infer<typeof apiV1.ChatMessage>[]): z.infer<typeof Message>[] {
const result: z.infer<typeof Message>[] = []; const result: z.infer<typeof Message>[] = [];
@ -112,6 +112,8 @@ export async function POST(
req: NextRequest, req: NextRequest,
{ params }: { params: Promise<{ chatId: string }> } { params }: { params: Promise<{ chatId: string }> }
): Promise<Response> { ): Promise<Response> {
return new Response('Not implemented', { status: 501 });
/*
return await authCheck(req, async (session) => { return await authCheck(req, async (session) => {
const { chatId } = await params; const { chatId } = await params;
const logger = new PrefixLogger(`widget-chat:${chatId}`); const logger = new PrefixLogger(`widget-chat:${chatId}`);
@ -124,11 +126,9 @@ export async function POST(
billingCustomerId = await getCustomerIdForProject(session.projectId); billingCustomerId = await getCustomerIdForProject(session.projectId);
} }
// check query limit // assert and consume quota
if (!await check_query_limit(session.projectId)) { const usageQuotaPolicy = container.resolve<IUsageQuotaPolicy>('usageQuotaPolicy');
logger.log(`Query limit exceeded for project ${session.projectId}`); await usageQuotaPolicy.assertAndConsume(session.projectId);
return Response.json({ error: "Query limit exceeded" }, { status: 429 });
}
// parse and validate the request body // parse and validate the request body
let body; let body;
@ -181,14 +181,8 @@ export async function POST(
throw new Error("Project settings not found"); throw new Error("Project settings not found");
} }
// fetch project tools
const projectTools = await collectProjectTools(session.projectId);
// fetch workflow // fetch workflow
const workflow = await agentWorkflowsCollection.findOne({ const workflow = projectSettings.liveWorkflow;
projectId: session.projectId,
_id: new ObjectId(projectSettings.publishedWorkflowId),
});
if (!workflow) { if (!workflow) {
throw new Error("Workflow not found"); throw new Error("Workflow not found");
} }
@ -214,7 +208,7 @@ export async function POST(
const inMessages: z.infer<typeof Message>[] = convert(messages); const inMessages: z.infer<typeof Message>[] = convert(messages);
inMessages.push(userMessage); inMessages.push(userMessage);
const { messages: responseMessages } = await getResponse(workflow, projectTools, [systemMessage, ...inMessages]); const { messages: responseMessages } = await getResponse(session.projectId, workflow, [systemMessage, ...inMessages]);
const convertedResponseMessages = convertBack(responseMessages); const convertedResponseMessages = convertBack(responseMessages);
const unsavedMessages = [ const unsavedMessages = [
userMessage, userMessage,
@ -228,10 +222,10 @@ export async function POST(
// log billing usage // log billing usage
if (USE_BILLING && billingCustomerId) { if (USE_BILLING && billingCustomerId) {
const agentMessageCount = convertedResponseMessages.filter(m => m.role === 'assistant').length; const agentMessageCount = convertedResponseMessages.filter(m => m.role === 'assistant').length;
await logUsage(billingCustomerId, { // await logUsage(billingCustomerId, {
type: 'agent_messages', // type: 'agent_messages',
amount: agentMessageCount, // amount: agentMessageCount,
}); // });
} }
logger.log(`Turn processing completed successfully`); logger.log(`Turn processing completed successfully`);
@ -242,4 +236,5 @@ export async function POST(
_id: undefined, _id: undefined,
}); });
}); });
*/
} }

View file

@ -4,9 +4,10 @@ import { SignJWT, jwtVerify } from "jose";
import { z } from "zod"; import { z } from "zod";
import { Session } from "../../utils"; import { Session } from "../../utils";
import { apiV1 } from "rowboat-shared"; import { apiV1 } from "rowboat-shared";
import { projectsCollection } from "../../../../../lib/mongodb";
export async function POST(req: NextRequest): Promise<Response> { export async function POST(req: NextRequest): Promise<Response> {
return new Response('Not implemented', { status: 501 });
/*
return await clientIdCheck(req, async (projectId) => { return await clientIdCheck(req, async (projectId) => {
// decode and validate JWT // decode and validate JWT
const json = await req.json(); const json = await req.json();
@ -52,4 +53,5 @@ export async function POST(req: NextRequest): Promise<Response> {
return Response.json(response); return Response.json(response);
}); });
*/
} }

View file

@ -1,7 +1,6 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { jwtVerify } from "jose"; import { jwtVerify } from "jose";
import { projectsCollection } from "../../../lib/mongodb";
export const Session = z.object({ export const Session = z.object({
userId: z.string(), userId: z.string(),
@ -18,6 +17,8 @@ export const Session = z.object({
in the request headers and calls the provided handler function. in the request headers and calls the provided handler function.
*/ */
export async function clientIdCheck(req: NextRequest, handler: (projectId: string) => Promise<Response>): Promise<Response> { export async function clientIdCheck(req: NextRequest, handler: (projectId: string) => Promise<Response>): Promise<Response> {
return new Response('Not implemented', { status: 501 });
/*
const clientId = req.headers.get('x-client-id')?.trim(); const clientId = req.headers.get('x-client-id')?.trim();
if (!clientId) { if (!clientId) {
return Response.json({ error: "Missing client ID in request" }, { status: 400 }); return Response.json({ error: "Missing client ID in request" }, { status: 400 });
@ -31,6 +32,7 @@ export async function clientIdCheck(req: NextRequest, handler: (projectId: strin
// set the project id in the request headers // set the project id in the request headers
req.headers.set('x-project-id', project._id); req.headers.set('x-project-id', project._id);
return await handler(project._id); return await handler(project._id);
*/
} }
/* /*
@ -42,6 +44,8 @@ export async function clientIdCheck(req: NextRequest, handler: (projectId: strin
provided handler function. provided handler function.
*/ */
export async function authCheck(req: NextRequest, handler: (session: z.infer<typeof Session>) => Promise<Response>): Promise<Response> { export async function authCheck(req: NextRequest, handler: (session: z.infer<typeof Session>) => Promise<Response>): Promise<Response> {
return new Response('Not implemented', { status: 501 });
/*
const authHeader = req.headers.get('Authorization'); const authHeader = req.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) { if (!authHeader?.startsWith('Bearer ')) {
return Response.json({ error: "Authorization header must be a Bearer token" }, { status: 400 }); return Response.json({ error: "Authorization header must be a Bearer token" }, { status: 400 });
@ -59,4 +63,5 @@ export async function authCheck(req: NextRequest, handler: (session: z.infer<typ
} }
return await handler(session.payload as z.infer<typeof Session>); return await handler(session.payload as z.infer<typeof Session>);
*/
} }

View file

@ -3,14 +3,14 @@
import { Progress, Badge, Chip } from "@heroui/react"; import { Progress, Badge, Chip } from "@heroui/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/app/lib/components/label"; import { Label } from "@/app/lib/components/label";
import { Customer, UsageResponse, UsageType } from "@/app/lib/types/billing_types"; import { Customer, UsageResponse } from "@/app/lib/types/billing_types";
import { z } from "zod"; import { z } from "zod";
import { tokens } from "@/app/styles/design-tokens"; import { tokens } from "@/app/styles/design-tokens";
import { SectionHeading } from "@/components/ui/section-heading"; import { SectionHeading } from "@/components/ui/section-heading";
import { HorizontalDivider } from "@/components/ui/horizontal-divider"; import { HorizontalDivider } from "@/components/ui/horizontal-divider";
import { WithStringId } from "@/app/lib/types/types"; import { WithStringId } from "@/app/lib/types/types";
import clsx from 'clsx'; import clsx from 'clsx';
import { getCustomerPortalUrl } from "../actions/billing_actions"; import { getCustomerPortalUrl } from "../actions/billing.actions";
const planDetails = { const planDetails = {
free: { free: {
@ -47,6 +47,15 @@ export function BillingPage({ customer, usage }: BillingPageProps) {
const displayStatus = getDisplayStatus(customer.subscriptionStatus); const displayStatus = getDisplayStatus(customer.subscriptionStatus);
const planInfo = planDetails[plan]; const planInfo = planDetails[plan];
// Prepare usage metrics data
const usageData = Object.entries(usage.usage)
.map(([type, credits]) => ({
type,
credits,
totalUsedCredits: usage.sanctionedCredits - usage.availableCredits
}))
.sort((a, b) => b.credits - a.credits);
async function handleManageSubscription() { async function handleManageSubscription() {
const returnUrl = new URL('/billing/callback', window.location.origin); const returnUrl = new URL('/billing/callback', window.location.origin);
returnUrl.searchParams.set('redirect', window.location.href); returnUrl.searchParams.set('redirect', window.location.href);
@ -109,48 +118,175 @@ export function BillingPage({ customer, usage }: BillingPageProps) {
</div> </div>
</section> </section>
{/* Usage Metrics Panel */} {/* Credits Overview Panel */}
<section className="card"> <section className="card">
<div className="px-4 pt-4 pb-6"> <div className="px-4 pt-4 pb-6">
<SectionHeading> <SectionHeading>
Usage Metrics Credits Overview
</SectionHeading> </SectionHeading>
</div> </div>
<HorizontalDivider /> <HorizontalDivider />
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{Object.entries(usage.usage).map(([type, { usage: used, total }]) => { <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
const usageType = type as z.infer<typeof UsageType>; <div className="space-y-2">
const percentage = Math.min((used / total) * 100, 100); <Label label="Sanctioned Credits" />
const isOverLimit = used > total; <p className={clsx(
tokens.typography.sizes.lg,
tokens.typography.weights.semibold,
tokens.colors.light.text.primary,
tokens.colors.dark.text.primary
)}>
{usage.sanctionedCredits.toLocaleString()}
</p>
<p className={clsx(
tokens.typography.sizes.sm,
tokens.colors.light.text.secondary,
tokens.colors.dark.text.secondary
)}>
Total credits allocated to your plan
</p>
</div>
<div className="space-y-2">
<Label label="Used Credits" />
<p className={clsx(
tokens.typography.sizes.lg,
tokens.typography.weights.semibold,
tokens.colors.light.text.primary,
tokens.colors.dark.text.primary
)}>
{(usage.sanctionedCredits - usage.availableCredits).toLocaleString()}
</p>
<p className={clsx(
tokens.typography.sizes.sm,
tokens.colors.light.text.secondary,
tokens.colors.dark.text.secondary
)}>
Credits consumed so far
</p>
</div>
<div className="space-y-2">
<Label label="Available Credits" />
<p className={clsx(
tokens.typography.sizes.lg,
tokens.typography.weights.semibold,
usage.availableCredits < 0 ? "text-red-500" : clsx(
tokens.colors.light.text.primary,
tokens.colors.dark.text.primary
)
)}>
{usage.availableCredits.toLocaleString()}
</p>
<p className={clsx(
tokens.typography.sizes.sm,
tokens.colors.light.text.secondary,
tokens.colors.dark.text.secondary
)}>
Credits remaining for use
</p>
</div>
</div>
{/* Warning for negative credits */}
{usage.availableCredits < 0 && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className={clsx(
tokens.typography.sizes.sm,
"text-red-700 dark:text-red-300"
)}>
You have exceeded your credit limit. Please upgrade your plan or contact support to avoid service interruptions.
</p>
</div>
)}
{/* Warning for high credit usage (>80%) */}
{usage.availableCredits >= 0 && ((usage.sanctionedCredits - usage.availableCredits) / usage.sanctionedCredits) > 0.8 && (
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p className={clsx(
tokens.typography.sizes.sm,
"text-yellow-700 dark:text-yellow-300"
)}>
You have used more than 80% of your credits. Consider upgrading your plan to avoid interruptions.
</p>
</div>
)}
{/* Credits Progress Bar */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label label="Credits Usage" />
<span className={clsx(
tokens.typography.sizes.sm,
tokens.colors.light.text.secondary,
tokens.colors.dark.text.secondary
)}>
{Math.round(((usage.sanctionedCredits - usage.availableCredits) / usage.sanctionedCredits) * 100)}%
</span>
</div>
<Progress
size="lg"
value={((usage.sanctionedCredits - usage.availableCredits) / usage.sanctionedCredits) * 100}
color={usage.availableCredits < 0 ? "danger" : "primary"}
className="h-4"
aria-label="Credits usage"
/>
</div>
</div>
</section>
return ( {/* Usage Metrics Panel */}
<div key={type} className="space-y-2"> <section className="card">
<div className="flex justify-between items-center"> <div className="px-4 pt-4 pb-6">
<div className="space-y-1"> <SectionHeading>
<Label label={type.replace(/_/g, ' ')} /> Usage data
<p className={clsx( </SectionHeading>
</div>
<HorizontalDivider />
<div className="p-6 space-y-6">
{usageData.length === 0 ? (
<div className="text-center py-8">
<p className={clsx(
tokens.typography.sizes.sm,
tokens.colors.light.text.secondary,
tokens.colors.dark.text.secondary
)}>
No usage data yet
</p>
</div>
) : (
usageData.map(({ type, credits, totalUsedCredits }) => {
const percentage = totalUsedCredits > 0 ? (credits / totalUsedCredits) * 100 : 0;
return (
<div key={type} className="space-y-2">
<div className="flex justify-between items-center">
<div className="space-y-1">
<Label label={type.replace(/_/g, ' ')} />
<p className={clsx(
tokens.typography.sizes.sm,
tokens.colors.light.text.secondary,
tokens.colors.dark.text.secondary
)}>
{credits.toLocaleString()} credits
</p>
</div>
<span className={clsx(
tokens.typography.sizes.sm, tokens.typography.sizes.sm,
tokens.colors.light.text.secondary, tokens.colors.light.text.secondary,
tokens.colors.dark.text.secondary tokens.colors.dark.text.secondary
)}> )}>
{used.toLocaleString()} / {total.toLocaleString()} {Math.round(percentage)}%
</p> </span>
</div> </div>
{isOverLimit && ( <Progress
<Badge color="danger" variant="flat"> value={percentage}
Over Limit color="default"
</Badge> className="h-2"
)} aria-label={`${type} credits usage`}
/>
</div> </div>
<Progress );
value={percentage} })
color={isOverLimit ? "danger" : "primary"} )}
className="h-2"
aria-label={`${type} usage`}
/>
</div>
);
})}
</div> </div>
</section> </section>
</div> </div>

View file

@ -6,7 +6,7 @@ export default function Layout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<AppLayout useRag={false} useAuth={true} useBilling={true}> <AppLayout useAuth={true} useBilling={true}>
{children} {children}
</AppLayout> </AppLayout>
); );

View file

@ -0,0 +1,78 @@
'use client';
import { forwardRef, TextareaHTMLAttributes } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { Send } from 'lucide-react';
import clsx from 'clsx';
interface TextareaWithSendProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'> {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
isSubmitting?: boolean;
submitDisabled?: boolean;
placeholder?: string;
className?: string;
rows?: number;
autoFocus?: boolean;
autoResize?: boolean;
}
export const TextareaWithSend = forwardRef<HTMLTextAreaElement, TextareaWithSendProps>(
({
value,
onChange,
onSubmit,
isSubmitting = false,
submitDisabled = false,
placeholder,
className,
rows = 3,
autoFocus = false,
autoResize = false,
...props
}, ref) => {
return (
<div className="relative">
<Textarea
ref={ref}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={clsx("pr-14", className)}
rows={rows}
autoFocus={autoFocus}
autoResize={autoResize}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
}}
{...props}
/>
<div className="absolute right-3 bottom-3">
<button
onClick={onSubmit}
disabled={isSubmitting || submitDisabled || !value.trim()}
className={clsx(
"rounded-full p-2 transition-all duration-200",
value.trim()
? "bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:hover:bg-indigo-800/60 dark:text-indigo-300"
: "bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500",
isSubmitting ? "opacity-50" : "hover:scale-105 active:scale-95"
)}
>
{isSubmitting ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
) : (
<Send size={18} />
)}
</button>
</div>
</div>
);
}
);
TextareaWithSend.displayName = 'TextareaWithSend';

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { ObjectId } from "mongodb"; import { ObjectId } from "mongodb";
import { usersCollection, projectsCollection, projectMembersCollection } from "./mongodb"; import { usersCollection } from "./mongodb";
import { auth0 } from "./auth0"; import { auth0 } from "./auth0";
import { User, WithStringId } from "./types/types"; import { User, WithStringId } from "./types/types";
import { USE_AUTH } from "./feature_flags"; import { USE_AUTH } from "./feature_flags";
@ -59,11 +59,6 @@ export async function requireAuth(): Promise<WithStringId<z.infer<typeof User>>>
console.log(`creating new user id ${doc._id.toString()} for session id ${user.sub}`); console.log(`creating new user id ${doc._id.toString()} for session id ${user.sub}`);
await usersCollection.insertOne(doc); await usersCollection.insertOne(doc);
// since auth feature was rolled out later,
// set all project authors to new user id instead
// of user.sub
await updateProjectRefs(user.sub, doc._id.toString());
dbUser = { dbUser = {
...doc, ...doc,
_id: doc._id.toString(), _id: doc._id.toString(),
@ -77,25 +72,6 @@ export async function requireAuth(): Promise<WithStringId<z.infer<typeof User>>>
}; };
} }
async function updateProjectRefs(sessionUserId: string, dbUserId: string) {
await projectsCollection.updateMany({
createdByUserId: sessionUserId
}, {
$set: {
createdByUserId: dbUserId,
lastUpdatedAt: new Date().toISOString(),
}
});
await projectMembersCollection.updateMany({
userId: sessionUserId
}, {
$set: {
userId: dbUserId,
}
});
}
export async function getUserFromSessionId(sessionUserId: string): Promise<WithStringId<z.infer<typeof User>> | null> { export async function getUserFromSessionId(sessionUserId: string): Promise<WithStringId<z.infer<typeof User>> | null> {
if (!USE_AUTH) { if (!USE_AUTH) {
return GUEST_DB_USER; return GUEST_DB_USER;

View file

@ -1,15 +1,19 @@
import { WithStringId } from './types/types'; import { WithStringId } from './types/types';
import { z } from 'zod'; import { z } from 'zod';
import { Customer, AuthorizeRequest, AuthorizeResponse, LogUsageRequest, UsageResponse, CustomerPortalSessionResponse, PricesResponse, UpdateSubscriptionPlanRequest, UpdateSubscriptionPlanResponse, ModelsResponse } from './types/billing_types'; import { Customer, AuthorizeRequest, AuthorizeResponse, LogUsageRequest, UsageResponse, CustomerPortalSessionResponse, PricesResponse, UpdateSubscriptionPlanRequest, UpdateSubscriptionPlanResponse, ModelsResponse, UsageItem } from './types/billing_types';
import { ObjectId } from 'mongodb'; import { ObjectId } from 'mongodb';
import { projectsCollection, usersCollection } from './mongodb'; import { usersCollection } from './mongodb';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getUserFromSessionId, requireAuth } from './auth'; import { getUserFromSessionId, requireAuth } from './auth';
import { USE_BILLING } from './feature_flags'; import { USE_BILLING } from './feature_flags';
import { container } from '@/di/container';
import { IProjectsRepository } from '@/src/application/repositories/projects.repository.interface';
const BILLING_API_URL = process.env.BILLING_API_URL || 'http://billing'; const BILLING_API_URL = process.env.BILLING_API_URL || 'http://billing';
const BILLING_API_KEY = process.env.BILLING_API_KEY || 'test'; const BILLING_API_KEY = process.env.BILLING_API_KEY || 'test';
let logCounter = 1;
const GUEST_BILLING_CUSTOMER = { const GUEST_BILLING_CUSTOMER = {
_id: "guest-user", _id: "guest-user",
userId: "guest-user", userId: "guest-user",
@ -23,26 +27,42 @@ const GUEST_BILLING_CUSTOMER = {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
export class BillingError extends Error { export class UsageTracker{
constructor(message: string) { private items: z.infer<typeof UsageItem>[] = [];
super(message);
this.name = 'BillingError'; track(item: z.infer<typeof UsageItem>) {
this.items.push(item);
}
flush(): z.infer<typeof UsageItem>[] {
const items = this.items;
this.items = [];
return items;
} }
} }
export async function getCustomerIdForProject(projectId: string): Promise<string> { export async function getCustomerForUserId(userId: string): Promise<WithStringId<z.infer<typeof Customer>> | null> {
const project = await projectsCollection.findOne({ _id: projectId }); const user = await usersCollection.findOne({ _id: new ObjectId(userId) });
if (!project) {
throw new Error("Project not found");
}
const user = await usersCollection.findOne({ _id: new ObjectId(project.createdByUserId) });
if (!user) { if (!user) {
throw new Error("User not found"); throw new Error("User not found");
} }
if (!user.billingCustomerId) { if (!user.billingCustomerId) {
return null;
}
return await getBillingCustomer(user.billingCustomerId);
}
export async function getCustomerIdForProject(projectId: string): Promise<string> {
const projectsRepository = container.resolve<IProjectsRepository>('projectsRepository');
const project = await projectsRepository.fetch(projectId);
if (!project) {
throw new Error("Project not found");
}
const customer = await getCustomerForUserId(project.createdByUserId);
if (!customer) {
throw new Error("User has no billing customer id"); throw new Error("User has no billing customer id");
} }
return user.billingCustomerId; return customer._id;
} }
export async function getBillingCustomer(id: string): Promise<WithStringId<z.infer<typeof Customer>> | null> { export async function getBillingCustomer(id: string): Promise<WithStringId<z.infer<typeof Customer>> | null> {
@ -118,6 +138,8 @@ export async function authorize(customerId: string, request: z.infer<typeof Auth
} }
export async function logUsage(customerId: string, request: z.infer<typeof LogUsageRequest>) { export async function logUsage(customerId: string, request: z.infer<typeof LogUsageRequest>) {
const reqId = logCounter++;
console.log(`[${reqId}] logging billing usage for customer ${customerId} to ${BILLING_API_URL}`, reqId, JSON.stringify(request));
const response = await fetch(`${BILLING_API_URL}/api/customers/${customerId}/log-usage`, { const response = await fetch(`${BILLING_API_URL}/api/customers/${customerId}/log-usage`, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -126,6 +148,7 @@ export async function logUsage(customerId: string, request: z.infer<typeof LogUs
}, },
body: JSON.stringify(request) body: JSON.stringify(request)
}); });
console.log(`[${reqId}] completed logging billing usage for customer ${customerId}`, reqId, response.status, response.statusText);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to log usage: ${response.status} ${response.statusText} ${await response.text()}`); throw new Error(`Failed to log usage: ${response.status} ${response.statusText} ${await response.text()}`);
} }

View file

@ -1,13 +1,6 @@
import { WorkflowTool, WorkflowAgent, WorkflowPrompt } from "./types/workflow_types"; import { WorkflowTool, WorkflowAgent, WorkflowPrompt, WorkflowPipeline } from "./types/workflow_types";
import { z } from "zod"; import { z } from "zod";
export class QueryLimitError extends Error {
constructor(message: string = 'Query limit exceeded') {
super(message);
this.name = 'QueryLimitError';
}
}
export function validateConfigChanges(configType: string, configChanges: Record<string, unknown>, name: string) { export function validateConfigChanges(configType: string, configChanges: Record<string, unknown>, name: string) {
let testObject: any; let testObject: any;
let schema: z.ZodType<any>; let schema: z.ZodType<any>;
@ -54,6 +47,15 @@ export function validateConfigChanges(configType: string, configChanges: Record<
schema = WorkflowPrompt; schema = WorkflowPrompt;
break; break;
} }
case 'pipeline': {
testObject = {
name: 'test',
description: 'test',
agents: [],
} as z.infer<typeof WorkflowPipeline>;
schema = WorkflowPipeline;
break;
}
default: default:
return { error: `Unknown config type: ${configType}` }; return { error: `Unknown config type: ${configType}` };
} }

View file

@ -8,29 +8,52 @@ interface CreateAtMentionsProps {
agents: any[]; agents: any[];
prompts: any[]; prompts: any[];
tools: any[]; tools: any[];
pipelines?: any[];
currentAgentName?: string; currentAgentName?: string;
currentAgent?: any; // Add current agent object to know its outputVisibility
} }
export function createAtMentions({ agents, prompts, tools, currentAgentName }: CreateAtMentionsProps): AtMentionItem[] { export function createAtMentions({ agents, prompts, tools, pipelines = [], currentAgentName, currentAgent }: CreateAtMentionsProps): AtMentionItem[] {
const atMentions: AtMentionItem[] = []; const atMentions: AtMentionItem[] = [];
// Add agents // For pipeline agents, only add tools and prompts - no agents or pipelines
for (const a of agents) { const isCurrentAgentPipeline = currentAgent?.type === 'pipeline';
if (a.disabled || a.name === currentAgentName) {
continue; // Add agents (excluding pipeline agents and disabled agents)
// Also exclude ALL agents if current agent is a pipeline agent
if (!isCurrentAgentPipeline) {
for (const a of agents) {
if (a.disabled || a.name === currentAgentName || a.type === 'pipeline') {
continue;
}
const id = `agent:${a.name}`;
atMentions.push({
id,
value: id,
label: `Agent: ${a.name}`,
denotationChar: "@", // Add required properties for Match type
link: id,
target: "_self"
});
} }
const id = `agent:${a.name}`;
atMentions.push({
id,
value: id,
label: `Agent: ${a.name}`,
denotationChar: "@", // Add required properties for Match type
link: id,
target: "_self"
});
} }
// Add prompts // Add pipelines (only if current agent is not a pipeline agent)
if (!isCurrentAgentPipeline) {
for (const pipeline of pipelines) {
const id = `pipeline:${pipeline.name}`;
atMentions.push({
id,
value: id,
label: `Pipeline: ${pipeline.name}`,
denotationChar: "@",
link: id,
target: "_self"
});
}
}
// Add prompts (always allowed)
for (const prompt of prompts) { for (const prompt of prompts) {
const id = `prompt:${prompt.name}`; const id = `prompt:${prompt.name}`;
atMentions.push({ atMentions.push({
@ -43,7 +66,7 @@ export function createAtMentions({ agents, prompts, tools, currentAgentName }: C
}); });
} }
// Add tools // Add tools (always allowed)
for (const tool of tools) { for (const tool of tools) {
const id = `tool:${tool.name}`; const id = `tool:${tool.name}`;
atMentions.push({ atMentions.push({

View file

@ -1,204 +0,0 @@
import { Button, Input, Textarea } from "@heroui/react";
import { useEffect, useRef, useState } from "react";
import { useClickAway } from "../../../hooks/use-click-away";
import MarkdownContent from "./markdown-content";
import clsx from "clsx";
import { Label } from "./label";
import { SparklesIcon } from "lucide-react";
interface EditableFieldProps {
value: string;
onChange: (value: string) => void;
label?: string;
placeholder?: string;
markdown?: boolean;
multiline?: boolean;
locked?: boolean;
className?: string;
validate?: (value: string) => { valid: boolean; errorMessage?: string };
light?: boolean;
error?: string | null;
inline?: boolean;
showGenerateButton?: {
show: boolean;
setShow: (show: boolean) => void;
};
disabled?: boolean;
type?: string;
}
export function EditableField({
value,
onChange,
label,
placeholder = "Click to edit...",
markdown = false,
multiline = false,
locked = false,
className = "flex flex-col gap-1 w-full",
validate,
light = false,
error,
inline = false,
showGenerateButton,
disabled = false,
type = "text",
}: EditableFieldProps) {
const [isEditing, setIsEditing] = useState(false);
const [localValue, setLocalValue] = useState(value);
const ref = useRef<HTMLDivElement>(null);
const validationResult = validate?.(localValue);
const isValid = !validate || validationResult?.valid;
useEffect(() => {
setLocalValue(value);
}, [value]);
useClickAway(ref, () => {
if (isEditing) {
if (isValid && localValue !== value) {
onChange(localValue);
} else {
setLocalValue(value);
}
setIsEditing(false);
}
});
const onValueChange = (newValue: string) => {
setLocalValue(newValue);
onChange(newValue); // Always save immediately
};
const commonProps = {
autoFocus: true,
value: localValue,
onValueChange: onValueChange,
variant: "bordered" as const,
labelPlacement: "outside" as const,
placeholder: markdown ? '' : placeholder,
classNames: {
input: "rounded-md",
inputWrapper: "rounded-md border-medium"
},
radius: "md" as const,
isInvalid: !isValid,
errorMessage: validationResult?.errorMessage,
onKeyDown: (e: React.KeyboardEvent) => {
if (!multiline && e.key === "Enter") {
e.preventDefault();
if (isValid && localValue !== value) {
onChange(localValue);
}
setIsEditing(false);
}
if (e.key === "Escape") {
setLocalValue(value);
setIsEditing(false);
}
},
};
if (isEditing) {
return (
<div ref={ref} className={clsx("flex flex-col gap-1 w-full", className)}>
{label && (
<div className="flex justify-between items-center">
<Label label={label} />
<div className="flex gap-2 items-center">
{showGenerateButton && (
<Button
variant="light"
size="sm"
startContent={<SparklesIcon size={16} />}
onPress={() => showGenerateButton.setShow(true)}
>
Generate
</Button>
)}
</div>
</div>
)}
{multiline && <Textarea
{...commonProps}
minRows={3}
maxRows={20}
className="w-full"
classNames={{
...commonProps.classNames,
input: "rounded-md py-2",
inputWrapper: "rounded-md border-medium py-1"
}}
/>}
{!multiline && <Input
{...commonProps}
type={type}
className="w-full"
classNames={{
...commonProps.classNames,
input: "rounded-md py-2",
inputWrapper: "rounded-md border-medium py-1"
}}
/>}
</div>
);
}
return (
<div ref={ref} className={clsx("cursor-text", className)}>
{label && (
<div className="flex justify-between items-center">
<Label label={label} />
{showGenerateButton && (
<Button
variant="light"
size="sm"
startContent={<SparklesIcon size={16} />}
onPress={() => showGenerateButton.setShow(true)}
>
Generate
</Button>
)}
</div>
)}
<div
className={clsx(
{
"border border-gray-300 dark:border-gray-600 rounded px-3 py-3": !inline,
"bg-transparent focus:outline-none focus:ring-0 border-0 rounded-none text-gray-900 dark:text-gray-100": inline,
}
)}
style={inline ? {
border: 'none',
borderRadius: '0',
padding: '0'
} : undefined}
onClick={() => !locked && setIsEditing(true)}
>
{value ? (
<>
{markdown && <div className="max-h-[420px] overflow-y-auto">
<MarkdownContent content={value} />
</div>}
{!markdown && <div className={`${multiline ? 'whitespace-pre-wrap max-h-[420px] overflow-y-auto' : 'flex items-center'}`}>
{value}
</div>}
</>
) : (
<>
{markdown && <div className="max-h-[420px] overflow-y-auto text-gray-400">
<MarkdownContent content={placeholder} />
</div>}
{!markdown && <span className="text-gray-400">{placeholder}</span>}
</>
)}
{error && (
<div className="text-xs text-red-500 mt-1">
{error}
</div>
)}
</div>
</div>
);
}

View file

@ -1,278 +0,0 @@
import { Button, Input, InputProps, Kbd, Textarea } from "@heroui/react";
import { useEffect, useRef, useState } from "react";
import { useClickAway } from "../../../hooks/use-click-away";
import MarkdownContent from "./markdown-content";
import clsx from "clsx";
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 {
value: string;
onChange: (value: string) => void;
label?: string;
placeholder?: string;
markdown?: boolean;
multiline?: boolean;
locked?: boolean;
className?: string;
validate?: (value: string) => { valid: boolean; errorMessage?: string };
light?: boolean;
mentions?: boolean;
mentionsAtValues?: Match[];
showSaveButton?: boolean;
showDiscardButton?: boolean;
error?: string | null;
inline?: boolean;
showGenerateButton?: {
show: boolean;
setShow: (show: boolean) => void;
};
onMentionNavigate?: (type: 'agent' | 'tool' | 'prompt', name: string) => void;
}
export function EditableField({
value,
onChange,
label,
placeholder = "Click to edit...",
markdown = false,
multiline = false,
locked = false,
className = "flex flex-col gap-1 w-full",
validate,
light = false,
mentions = false,
mentionsAtValues = [],
showSaveButton = false,
showDiscardButton = false,
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;
useEffect(() => {
setLocalValue(value);
}, [value]);
useClickAway(ref, () => {
if (isEditing) {
if (isValid && localValue !== value) {
onChange(localValue);
} else {
setLocalValue(value);
}
}
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,
onValueChange: setLocalValue,
variant: "bordered" as const,
labelPlacement: "outside" as const,
placeholder: markdown ? '' : placeholder,
classNames: {
input: "rounded-md",
inputWrapper: "rounded-md border-medium"
},
radius: "md" as const,
isInvalid: !isValid,
errorMessage: validationResult?.errorMessage,
onKeyDown: (e: React.KeyboardEvent) => {
if (!multiline && e.key === "Enter") {
e.preventDefault();
if (isValid && localValue !== value) {
onChange(localValue);
}
setIsEditing(false);
}
/* DISABLE shift+enter save for multiline fields
if (multiline && e.key === "Enter" && e.shiftKey) {
e.preventDefault();
if (isValid && localValue !== value) {
onChange(localValue);
}
setIsEditing(false);
}
*/
if (e.key === "Escape") {
setLocalValue(value);
setIsEditing(false);
}
},
};
if (isEditing) {
const hasChanges = localValue !== value;
return (
<div ref={ref} className={clsx("flex flex-col gap-1 w-full", className)}>
{label && (
<div className="flex justify-between items-center">
<Label label={label} />
<div className="flex gap-2 items-center">
{showGenerateButton && (
<Button
variant="light"
size="sm"
startContent={<SparklesIcon size={16} />}
onPress={() => showGenerateButton.setShow(true)}
>
Generate
</Button>
)}
{hasChanges && (
<>
{showDiscardButton && (
<Button
variant="light"
size="sm"
onPress={() => {
setLocalValue(value);
setIsEditing(false);
}}
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
Discard
</Button>
)}
{showSaveButton && (
<Button
color="primary"
size="sm"
onPress={() => {
if (isValid && localValue !== value) {
onChange(localValue);
}
setIsEditing(false);
}}
>
Save
</Button>
)}
</>
)}
</div>
</div>
)}
{mentions && (
<div className="w-full rounded-md border-2 border-default-300">
<MentionsEditor
atValues={mentionsAtValues}
value={value}
placeholder={placeholder}
onValueChange={setLocalValue}
/>
</div>
)}
{multiline && !mentions && <Textarea
{...commonProps}
minRows={3}
maxRows={20}
className="w-full text-sm focus-visible:ring-0 focus:ring-0 outline-none"
classNames={{
...commonProps.classNames,
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 text-sm focus-visible:ring-0 focus:ring-0 outline-none"
classNames={{
...commonProps.classNames,
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", {
"border-0 bg-transparent": inline
})
}}
/>}
</div>
);
}
return (
<div ref={ref} className={clsx("cursor-text", className)}>
{label && (
<div className="flex justify-between items-center">
<Label label={label} />
{showGenerateButton && (
<Button
variant="light"
size="sm"
startContent={<SparklesIcon size={16} />}
onPress={() => showGenerateButton.setShow(true)}
>
Generate
</Button>
)}
</div>
)}
<div
className={clsx(
"rounded-md border border-gray-200 dark:border-gray-700 px-2 py-1 min-h-[40px] text-sm",
{
"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,
}
)}
style={inline ? {
border: 'none',
borderRadius: '0',
padding: '0'
} : undefined}
onClick={() => !locked && setIsEditing(true)}
>
{value ? (
<>
{markdown && <div>
<MarkdownContent content={value} atValues={mentionsAtValues} onMentionNavigate={handleMentionNavigate} />
</div>}
{!markdown && <div className={multiline ? 'whitespace-pre-wrap' : 'flex items-center'}>
<MarkdownContent content={value} atValues={mentionsAtValues} onMentionNavigate={handleMentionNavigate} />
</div>}
</>
) : (
<>
{markdown && <div className="text-gray-400">
<MarkdownContent content={placeholder} atValues={mentionsAtValues} />
</div>}
{!markdown && <span className="text-gray-400">{placeholder}</span>}
</>
)}
{error && (
<div className="text-xs text-red-500 mt-1">
{error}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,590 @@
import { Button, Input, Textarea, Chip, Select, SelectItem, Checkbox } from "@heroui/react";
import { useEffect, useRef, useState } from "react";
import { useClickAway } from "../../../hooks/use-click-away";
import MarkdownContent from "./markdown-content";
import clsx from "clsx";
import { Label } from "./label";
import dynamic from "next/dynamic";
import { Match } from "./mentions_editor";
import { SparklesIcon, Edit3Icon, XIcon, CheckIcon } from "lucide-react";
import { EntitySelectionContext } from "../../projects/[projectId]/workflow/workflow_editor";
import { useContext } from "react";
const MentionsEditor = dynamic(() => import('./mentions_editor'), { ssr: false });
// Base InputField interface
interface BaseInputFieldProps {
value: string;
onChange: (value: string) => void;
label?: string;
placeholder?: string;
className?: string;
validate?: (value: string) => { valid: boolean; errorMessage?: string };
error?: string | null;
disabled?: boolean;
locked?: boolean;
inline?: boolean;
showGenerateButton?: {
show: boolean;
setShow: (show: boolean) => void;
};
onMentionNavigate?: (type: 'agent' | 'tool' | 'prompt', name: string) => void;
}
// Text input specific props
interface TextInputFieldProps extends BaseInputFieldProps {
type: 'text';
multiline?: boolean;
markdown?: boolean;
mentions?: boolean;
mentionsAtValues?: Match[];
showSaveButton?: boolean;
showDiscardButton?: boolean;
immediateSave?: boolean;
}
// Select input specific props
interface SelectInputFieldProps extends BaseInputFieldProps {
type: 'select';
options: { key: string; label: string; disabled?: boolean }[];
selectedKeys?: Set<string>;
onSelectionChange: (keys: any) => void;
}
// Checkbox input specific props
interface CheckboxInputFieldProps extends BaseInputFieldProps {
type: 'checkbox';
isSelected?: boolean;
onValueChange?: (value: boolean) => void;
}
// Number input specific props
interface NumberInputFieldProps extends BaseInputFieldProps {
type: 'number';
min?: number;
max?: number;
step?: number;
immediateSave?: boolean;
}
// Union type for all input field types
type InputFieldProps = TextInputFieldProps | SelectInputFieldProps | CheckboxInputFieldProps | NumberInputFieldProps;
export function InputField(props: InputFieldProps) {
// Handle different input types
if (props.type === 'select') {
return <SelectInputField {...props} />;
}
if (props.type === 'checkbox') {
return <CheckboxInputField {...props} />;
}
if (props.type === 'number') {
return <NumberInputField {...props} />;
}
// Default to text input
return <TextInputField {...props} />;
}
// Text Input Field Component
function TextInputField({
value,
onChange,
label,
placeholder = "Click to edit...",
className = "flex flex-col gap-1 w-full",
validate,
error,
disabled = false,
locked = false,
inline = false,
showGenerateButton,
onMentionNavigate,
multiline = false,
markdown = false,
mentions = false,
mentionsAtValues = [],
showSaveButton = false,
showDiscardButton = false,
immediateSave = false,
}: TextInputFieldProps) {
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;
useEffect(() => {
setLocalValue(value);
}, [value]);
useClickAway(ref, () => {
if (isEditing) {
if (immediateSave) {
if (isValid && localValue !== value) {
onChange(localValue);
}
} else {
if (isValid && localValue !== value) {
onChange(localValue);
} else {
setLocalValue(value);
}
}
}
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 handleSave = () => {
if (isValid && localValue !== value) {
onChange(localValue);
}
setIsEditing(false);
};
const handleDiscard = () => {
setLocalValue(value);
setIsEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!multiline && e.key === "Enter") {
e.preventDefault();
if (immediateSave) {
if (isValid && localValue !== value) {
onChange(localValue);
}
} else {
handleSave();
}
}
if (e.key === "Escape") {
handleDiscard();
}
};
const onValueChange = (newValue: string) => {
setLocalValue(newValue);
if (immediateSave) {
onChange(newValue);
}
};
// Determine input size based on content length and multiline
const getInputSize = () => {
if (multiline) {
if (localValue.length > 1000) return "lg";
if (localValue.length > 500) return "md";
return "sm";
}
return "sm";
};
// Determine if we should show action buttons
const hasChanges = localValue !== value;
const showActions = hasChanges && (showSaveButton || showDiscardButton);
if (isEditing) {
return (
<div ref={ref} className={clsx("flex flex-col gap-2 w-full", className)}>
{/* Header with label and action buttons */}
{(label || showGenerateButton || showActions) && (
<div className="flex justify-between items-center">
{label && <Label label={label} />}
<div className="flex gap-2 items-center">
{showGenerateButton && (
<Button
variant="light"
size="sm"
startContent={<SparklesIcon size={16} />}
onPress={() => showGenerateButton.setShow(true)}
>
Generate
</Button>
)}
{showActions && (
<>
{showDiscardButton && (
<Button
variant="light"
size="sm"
onPress={handleDiscard}
startContent={<XIcon size={16} />}
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
Discard
</Button>
)}
{showSaveButton && (
<Button
color="primary"
size="sm"
onPress={handleSave}
startContent={<CheckIcon size={16} />}
isDisabled={!isValid}
>
Save
</Button>
)}
</>
)}
</div>
</div>
)}
{/* Input field */}
{mentions ? (
<div className="w-full min-h-[300px]">
<MentionsEditor
atValues={mentionsAtValues}
value={value}
placeholder={placeholder}
onValueChange={setLocalValue}
autoFocus
/>
</div>
) : multiline ? (
<Textarea
value={localValue}
onValueChange={onValueChange}
placeholder={placeholder}
variant="bordered"
size={getInputSize()}
minRows={12}
maxRows={20}
isInvalid={!isValid}
errorMessage={validationResult?.errorMessage}
onKeyDown={handleKeyDown}
autoFocus
classNames={{
input: "text-sm focus:outline-none focus:ring-0",
inputWrapper: "border-gray-200 dark:border-gray-700 focus-within:ring-0 focus-within:outline-none",
}}
/>
) : (
<Input
value={localValue}
onValueChange={onValueChange}
placeholder={placeholder}
variant="bordered"
size="sm"
isInvalid={!isValid}
errorMessage={validationResult?.errorMessage}
onKeyDown={handleKeyDown}
autoFocus
classNames={{
input: "text-sm focus:outline-none focus:ring-0",
inputWrapper: clsx("border-gray-200 dark:border-gray-700 focus-within:ring-0 focus-within:outline-none", {
"border-0 bg-transparent": inline
}),
}}
/>
)}
</div>
);
}
// Read-only view
return (
<div ref={ref} className={clsx("w-full", className)}>
{/* Header with label and generate button */}
{(label || showGenerateButton) && (
<div className="flex justify-between items-center mb-2">
{label && <Label label={label} />}
{showGenerateButton && (
<Button
variant="light"
size="sm"
startContent={<SparklesIcon size={16} />}
onPress={() => showGenerateButton.setShow(true)}
>
Generate
</Button>
)}
</div>
)}
{/* Content display */}
<div
className={clsx(
"group relative rounded-lg border border-gray-200 dark:border-gray-700 p-3 transition-all duration-200",
{
"cursor-pointer hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800": !locked && !disabled,
"cursor-not-allowed opacity-60": locked || disabled,
"border-0 bg-transparent p-0": inline,
"min-h-[300px]": multiline,
"min-h-[40px]": !multiline,
}
)}
onClick={() => !locked && !disabled && setIsEditing(true)}
>
{/* Content */}
<div className={clsx("text-sm", {
"whitespace-pre-wrap": multiline,
"flex items-center": !multiline,
})}>
{value ? (
<>
{markdown ? (
<div className={clsx("prose prose-sm max-w-none", {
"max-h-[420px] overflow-y-auto": multiline
})}>
<MarkdownContent
content={value}
atValues={mentionsAtValues}
onMentionNavigate={handleMentionNavigate}
/>
</div>
) : (
<div className={clsx({
"whitespace-pre-wrap": multiline,
"max-h-[420px] overflow-y-auto": multiline
})}>
<MarkdownContent
content={value}
atValues={mentionsAtValues}
onMentionNavigate={handleMentionNavigate}
/>
</div>
)}
</>
) : (
<>
{markdown ? (
<div className="text-gray-400 prose prose-sm max-w-none">
<MarkdownContent content={placeholder} atValues={mentionsAtValues} />
</div>
) : (
<span className="text-gray-400">{placeholder}</span>
)}
</>
)}
</div>
{/* Error display */}
{error && (
<div className="text-xs text-red-500 mt-2">
{error}
</div>
)}
</div>
</div>
);
}
// Select Input Field Component
function SelectInputField({
label,
options,
selectedKeys,
onSelectionChange,
className = "flex flex-col gap-1 w-full",
disabled = false,
locked = false,
}: SelectInputFieldProps) {
return (
<div className={clsx("w-full", className)}>
{label && (
<div className="mb-2">
<Label label={label} />
</div>
)}
<Select
variant="bordered"
selectedKeys={selectedKeys}
onSelectionChange={onSelectionChange}
isDisabled={disabled || locked}
size="sm"
classNames={{
trigger: "border-gray-200 dark:border-gray-700 focus-within:ring-0 focus-within:outline-none",
}}
>
{options.map((option) => (
<SelectItem
key={option.key}
isDisabled={option.disabled}
>
{option.label}
</SelectItem>
))}
</Select>
</div>
);
}
// Checkbox Input Field Component
function CheckboxInputField({
label,
isSelected = false,
onValueChange,
className = "flex flex-col gap-1 w-full",
disabled = false,
locked = false,
}: CheckboxInputFieldProps) {
return (
<div className={clsx("w-full", className)}>
<Checkbox
isSelected={isSelected}
onValueChange={onValueChange}
isDisabled={disabled || locked}
size="sm"
>
{label && <span className="text-sm">{label}</span>}
</Checkbox>
</div>
);
}
// Number Input Field Component
function NumberInputField({
value,
onChange,
label,
placeholder = "Enter number...",
className = "flex flex-col gap-1 w-full",
validate,
error,
disabled = false,
locked = false,
min,
max,
step,
immediateSave = false,
}: NumberInputFieldProps) {
const [isEditing, setIsEditing] = useState(false);
const [localValue, setLocalValue] = useState(value);
const ref = useRef<HTMLDivElement>(null);
const validationResult = validate?.(localValue);
const isValid = !validate || validationResult?.valid;
useEffect(() => {
setLocalValue(value);
}, [value]);
useClickAway(ref, () => {
if (isEditing) {
if (immediateSave) {
if (isValid && localValue !== value) {
onChange(localValue);
}
} else {
if (isValid && localValue !== value) {
onChange(localValue);
} else {
setLocalValue(value);
}
}
}
setIsEditing(false);
});
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
if (immediateSave) {
if (isValid && localValue !== value) {
onChange(localValue);
}
} else {
if (isValid && localValue !== value) {
onChange(localValue);
}
setIsEditing(false);
}
}
if (e.key === "Escape") {
setLocalValue(value);
setIsEditing(false);
}
};
const onValueChange = (newValue: string) => {
setLocalValue(newValue);
if (immediateSave) {
onChange(newValue);
}
};
if (isEditing) {
return (
<div ref={ref} className={clsx("flex flex-col gap-2 w-full", className)}>
{label && (
<div className="mb-2">
<Label label={label} />
</div>
)}
<Input
value={localValue}
onValueChange={onValueChange}
placeholder={placeholder}
variant="bordered"
size="sm"
type="number"
min={min}
max={max}
step={step}
isInvalid={!isValid}
errorMessage={validationResult?.errorMessage}
onKeyDown={handleKeyDown}
autoFocus
classNames={{
input: "text-sm focus:outline-none focus:ring-0",
inputWrapper: "border-gray-200 dark:border-gray-700 focus-within:ring-0 focus-within:outline-none",
}}
/>
</div>
);
}
// Read-only view
return (
<div ref={ref} className={clsx("w-full", className)}>
{label && (
<div className="mb-2">
<Label label={label} />
</div>
)}
<div
className={clsx(
"group relative rounded-lg border border-gray-200 dark:border-gray-700 p-3 min-h-[40px] transition-all duration-200",
{
"cursor-pointer hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800": !locked && !disabled,
"cursor-not-allowed opacity-60": locked || disabled,
}
)}
onClick={() => !locked && !disabled && setIsEditing(true)}
>
{/* Content */}
<div className="text-sm flex items-center">
{value ? (
<span>{value}</span>
) : (
<span className="text-gray-400">{placeholder}</span>
)}
</div>
{/* Error display */}
{error && (
<div className="text-xs text-red-500 mt-2">
{error}
</div>
)}
</div>
</div>
);
}

View file

@ -87,11 +87,13 @@ export default function MentionEditor({
value, value,
placeholder, placeholder,
onValueChange, onValueChange,
autoFocus = false,
}: { }: {
atValues: Match[]; atValues: Match[];
value: string; value: string;
placeholder?: string; placeholder?: string;
onValueChange?: (value: string) => void; onValueChange?: (value: string) => void;
autoFocus?: boolean;
}) { }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const quillRef = useRef<Quill | null>(null); const quillRef = useRef<Quill | null>(null);
@ -175,6 +177,13 @@ export default function MentionEditor({
} }
}); });
quillRef.current = quill; quillRef.current = quill;
// Auto-focus if requested
if (autoFocus) {
setTimeout(() => {
quill.focus();
}, 0);
}
} }
load(); load();
@ -184,7 +193,7 @@ export default function MentionEditor({
quillRef.current.off(Quill.events.TEXT_CHANGE); quillRef.current.off(Quill.events.TEXT_CHANGE);
} }
} }
}, [atValues, onValueChange, placeholder, value]); }, [atValues, onValueChange, placeholder, value, autoFocus]);
return <div className="relative"> return <div className="relative">
<button className="absolute top-2 right-2 z-10"> <button className="absolute top-2 right-2 z-10">

View file

@ -0,0 +1,138 @@
'use client';
import { z } from "zod";
import { Message } from "@/app/lib/types/types";
import Link from "next/link";
function ToolCallDisplay({ toolCall }: { toolCall: any }) {
return (
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-md border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
TOOL CALL: {toolCall.function.name}
</span>
<span className="text-xs text-gray-500 dark:text-gray-500">
ID: {toolCall.id}
</span>
</div>
<div className="text-xs text-gray-700 dark:text-gray-300 font-mono">
<div className="mb-1">
<span className="font-semibold">Arguments:</span>
</div>
<pre className="bg-gray-100 dark:bg-gray-900 p-2 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700">
{toolCall.function.arguments}
</pre>
</div>
</div>
);
}
export function MessageDisplay({ message, index }: { message: z.infer<typeof Message>; index: number }) {
const isUser = 'role' in message && message.role === 'user';
const isAssistant = 'role' in message && message.role === 'assistant';
const isSystem = 'role' in message && message.role === 'system';
const isTool = 'role' in message && message.role === 'tool';
// Check if assistant message is internal
const isInternal = isAssistant && 'responseType' in message && message.responseType === 'internal';
const getBubbleStyle = () => {
if (isUser) {
return 'ml-auto max-w-[80%] bg-blue-100 text-blue-900 border border-blue-200 rounded-2xl rounded-br-md';
} else if (isAssistant) {
if (isInternal) {
return 'mr-auto max-w-[80%] bg-gray-50 text-gray-700 border border-dotted border-gray-300 rounded-2xl rounded-bl-md';
} else {
return 'mr-auto max-w-[80%] bg-green-100 text-green-900 border border-green-200 rounded-2xl rounded-bl-md';
}
} else if (isSystem) {
return 'mx-auto max-w-[90%] bg-yellow-100 text-yellow-900 border border-yellow-200 rounded-2xl';
} else if (isTool) {
return 'mr-auto max-w-[80%] bg-purple-100 text-purple-900 border border-purple-200 rounded-2xl rounded-bl-md';
}
return 'mx-auto max-w-[80%] bg-gray-100 text-gray-900 border border-gray-200 rounded-2xl';
};
const getRoleLabel = () => {
if ('role' in message) {
switch (message.role) {
case 'user':
return 'USER';
case 'assistant':
const baseLabel = 'agentName' in message && message.agentName ? `ASSISTANT (${message.agentName})` : 'ASSISTANT';
return isInternal ? `${baseLabel} [INTERNAL]` : baseLabel;
case 'system':
return 'SYSTEM';
case 'tool':
return 'toolName' in message ? `TOOL (${message.toolName})` : 'TOOL';
default:
return (message as any).role?.toUpperCase() || 'UNKNOWN';
}
}
return 'UNKNOWN';
};
const getMessageContent = () => {
if ('content' in message && message.content) {
return message.content;
}
return '[No content]';
};
const getTimestamp = () => {
if ('timestamp' in message && message.timestamp) {
return new Date(message.timestamp).toLocaleTimeString();
}
return null;
};
const timestamp = getTimestamp();
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`}>
<div className={`${getBubbleStyle()} p-3 shadow-sm`}>
{/* Message Header */}
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold opacity-90">
{getRoleLabel()}
</span>
<div className="flex items-center gap-2">
{timestamp && (
<span className="text-xs opacity-75">
{timestamp}
</span>
)}
<span className="text-xs opacity-75">
#{index + 1}
</span>
</div>
</div>
{/* Message Content */}
<div className="text-sm">
{isTool ? (
<pre className="bg-gray-100 dark:bg-gray-900 p-2 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono whitespace-pre-wrap">
{getMessageContent()}
</pre>
) : (
<div className="whitespace-pre-wrap">
{getMessageContent()}
</div>
)}
</div>
{/* Tool Calls Display */}
{isAssistant && 'toolCalls' in message && message.toolCalls && message.toolCalls.length > 0 && (
<div className="mt-3 space-y-2">
<div className="text-xs font-semibold opacity-90 border-t border-current/20 pt-2">
TOOL CALLS ({message.toolCalls.length})
</div>
{message.toolCalls.map((toolCall, toolIndex) => (
<ToolCallDisplay key={toolCall.id || toolIndex} toolCall={toolCall} />
))}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,50 @@
import Link from "next/link";
import { Turn } from "@/src/entities/models/turn";
import { z } from "zod";
export function ReasonBadge({
reason,
projectId
}: {
reason: z.infer<typeof Turn>['reason'];
projectId?: string;
}) {
const getReasonDisplay = () => {
switch (reason.type) {
case 'chat':
return { label: 'CHAT', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' };
case 'api':
return { label: 'API', color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' };
case 'job':
return {
label: `JOB: ${reason.jobId}`,
color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
isJob: true,
jobId: reason.jobId
};
default:
return { label: 'UNKNOWN', color: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300' };
}
};
const { label, color, isJob, jobId } = getReasonDisplay();
// Job reasons should ALWAYS be linked when we have a projectId
if (isJob && jobId && projectId) {
return (
<Link
href={`/projects/${projectId}/jobs/${jobId}`}
className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-mono font-medium ${color} hover:opacity-80 transition-opacity`}
>
{label}
</Link>
);
}
// Otherwise render as a regular badge
return (
<span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-mono font-medium ${color}`}>
{label}
</span>
);
}

View file

@ -1,31 +0,0 @@
// 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

@ -1,201 +0,0 @@
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

@ -1,240 +0,0 @@
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

@ -5,14 +5,16 @@ export const USE_CHAT_WIDGET = process.env.USE_CHAT_WIDGET === 'true';
export const USE_AUTH = process.env.USE_AUTH === 'true'; export const USE_AUTH = process.env.USE_AUTH === 'true';
export const USE_RAG_S3_UPLOADS = process.env.USE_RAG_S3_UPLOADS === 'true'; export const USE_RAG_S3_UPLOADS = process.env.USE_RAG_S3_UPLOADS === 'true';
export const USE_GEMINI_FILE_PARSING = process.env.USE_GEMINI_FILE_PARSING === 'true'; export const USE_GEMINI_FILE_PARSING = process.env.USE_GEMINI_FILE_PARSING === 'true';
export const USE_BILLING = process.env.USE_BILLING === 'true'; export const USE_BILLING = process.env.NEXT_PUBLIC_USE_BILLING === 'true' || process.env.USE_BILLING === 'true';
export const USE_COMPOSIO_TOOLS = process.env.USE_COMPOSIO_TOOLS === 'true'; export const USE_COMPOSIO_TOOLS = process.env.USE_COMPOSIO_TOOLS === 'true';
export const USE_KLAVIS_TOOLS = process.env.USE_KLAVIS_TOOLS === 'true'; export const USE_KLAVIS_TOOLS = process.env.USE_KLAVIS_TOOLS === 'false';
// Hardcoded flags // Hardcoded flags
export const USE_MULTIPLE_PROJECTS = true; export const USE_MULTIPLE_PROJECTS = true;
export const USE_TESTING_FEATURE = false;
export const USE_VOICE_FEATURE = false; export const USE_VOICE_FEATURE = false;
export const USE_TRANSFER_CONTROL_OPTIONS = true; export const USE_TRANSFER_CONTROL_OPTIONS = false;
export const USE_PRODUCT_TOUR = true; export const USE_PRODUCT_TOUR = false;
export const SHOW_COPILOT_MARQUEE = false; export const SHOW_COPILOT_MARQUEE = false;
export const SHOW_PROMPTS_SECTION = false;
export const SHOW_DARK_MODE_TOGGLE = false;
export const SHOW_VISUALIZATION = false

View file

@ -1,13 +1,5 @@
import { MongoClient } from "mongodb"; import { MongoClient } from "mongodb";
import { User, Webpage } from "./types/types"; import { User } from "./types/types";
import { Workflow } from "./types/workflow_types";
import { ApiKey } from "./types/project_types";
import { ProjectMember } from "./types/project_types";
import { Project } from "./types/project_types";
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, TwilioInboundCall } from "./types/voice_types"; import { TwilioConfig, TwilioInboundCall } from "./types/voice_types";
import { z } from 'zod'; import { z } from 'zod';
import { apiV1 } from "rowboat-shared"; import { apiV1 } from "rowboat-shared";
@ -15,19 +7,6 @@ import { apiV1 } from "rowboat-shared";
const client = new MongoClient(process.env["MONGODB_CONNECTION_STRING"] || "mongodb://localhost:27017"); const client = new MongoClient(process.env["MONGODB_CONNECTION_STRING"] || "mongodb://localhost:27017");
export const db = client.db("rowboat"); export const db = client.db("rowboat");
export const dataSourcesCollection = db.collection<z.infer<typeof DataSource>>("sources");
export const dataSourceDocsCollection = db.collection<z.infer<typeof DataSourceDoc>>("source_docs");
export const embeddingsCollection = db.collection<z.infer<typeof EmbeddingDoc>>("embeddings");
export const projectsCollection = db.collection<z.infer<typeof Project>>("projects");
export const projectMembersCollection = db.collection<z.infer<typeof ProjectMember>>("project_members");
export const webpagesCollection = db.collection<z.infer<typeof Webpage>>('webpages');
export const agentWorkflowsCollection = db.collection<z.infer<typeof Workflow>>("agent_workflows");
export const apiKeysCollection = db.collection<z.infer<typeof ApiKey>>("api_keys");
export const testScenariosCollection = db.collection<z.infer<typeof TestScenario>>("test_scenarios");
export const testProfilesCollection = db.collection<z.infer<typeof TestProfile>>("test_profiles");
export const testSimulationsCollection = db.collection<z.infer<typeof TestSimulation>>("test_simulations");
export const testRunsCollection = db.collection<z.infer<typeof TestRun>>("test_runs");
export const testResultsCollection = db.collection<z.infer<typeof TestResult>>("test_results");
export const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats"); export const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages"); export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages");
export const twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("twilio_configs"); export const twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("twilio_configs");
@ -35,12 +14,12 @@ export const usersCollection = db.collection<z.infer<typeof User>>("users");
export const twilioInboundCallsCollection = db.collection<z.infer<typeof TwilioInboundCall>>("twilio_inbound_calls"); export const twilioInboundCallsCollection = db.collection<z.infer<typeof TwilioInboundCall>>("twilio_inbound_calls");
// Create indexes // Create indexes
twilioConfigsCollection.createIndexes([ // twilioConfigsCollection.createIndexes([
{ // {
key: { workflow_id: 1, status: 1 }, // key: { workflow_id: 1, status: 1 },
name: "workflow_status_idx", // name: "workflow_status_idx",
// This ensures only one active config per workflow // // This ensures only one active config per workflow
unique: true, // unique: true,
partialFilterExpression: { status: "active" } // partialFilterExpression: { status: "active" }
} // }
]); // ]);

View file

@ -24,26 +24,247 @@ export const templates: { [key: string]: z.infer<typeof WorkflowTemplate> } = {
}, },
], ],
prompts: [], prompts: [],
tools: [ tools: [],
},
"meeting-prep": {
"name": "Meeting Prep",
"description": "Fetches meetings from your calendar and prepares you for them",
"agents": [
{ {
"name": "rag_search", "name": "Meeting Prep Hub",
"description": "Fetch articles with knowledge relevant to the query", "type": "conversation",
"description": "Hub agent to orchestrate fetching attendee details and preparing a meeting brief.",
"instructions": "## 🧑‍💼 Role:\nYou orchestrate the workflow to fetch attendee details for a calendar event and prepare a meeting brief by researching attendees and their companies.\n\n---\n## ⚙️ Steps to Follow:\n1. Greet the user and ask which event they want to prepare for (ask for event title and, if needed, time).\n2. FIRST: Send the event details to [@agent:Attendee Fetch Agent] to get attendee details.\n3. Wait for the complete attendee list from Attendee Fetch Agent.\n4. THEN: Send the attendee list to [@agent:Attendee Research Agent] to research and prepare the meeting brief.\n5. Return the meeting brief to the user.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Orchestrating the workflow for meeting preparation.\n\n❌ Out of Scope:\n- Directly fetching attendee details or researching attendees.\n- Handling unrelated user queries.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Follow the strict sequence: fetch attendees, then research, then respond.\n- Only interact with the user for event details and final meeting brief.\n\n🚫 Don'ts:\n- Do not attempt to fetch or research directly.\n- Do not try to get both steps done simultaneously.\n- Do not reference the individual agents in user-facing messages.\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.",
"model": DEFAULT_MODEL,
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"controlType": "retain",
"outputVisibility": "user_facing",
"examples": "- **User** : I want to prepare for my 'Q3 Planning Meeting'.\n - **Agent actions**: Call [@agent:Attendee Fetch Agent](#mention)\n\n- **Agent receives attendee list** :\n - **Agent actions**: Call [@agent:Attendee Research Agent](#mention)\n\n- **Agent receives meeting brief** :\n - **Agent response**: Here is your meeting brief: [summary]\n\n- **User** : I want to prepare for a meeting but don't know the event title.\n - **Agent response**: Please provide the event title (and time, if possible) so I can fetch the attendee details."
},
{
"name": "Attendee Fetch Agent",
"type": "conversation",
"description": "Fetches attendee details for a specified event from the user's primary calendar.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou fetch attendee details (name, email, company if available) for a specified event from the user's primary calendar by searching through events using the List Events tool.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the event title (and optionally time) from the parent agent.\n2. Call [@tool:List Events](#mention) with calendarId='primary' and the event title (and optionally time) as search parameters.\n3. Search through the returned events to find the event(s) that best match the provided title (and time, if given).\n4. Extract the attendee details (name, email, company if available) from the matching event.\n5. Return the list of attendees (name, email, company if available) to the parent agent.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Fetching attendee details for a specified event by searching the user's primary calendar.\n\n❌ Out of Scope:\n- Researching attendees or companies.\n- Interacting directly with the user.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Return all available attendee details from the best-matching event.\n- If multiple events match, use the event time (if provided) to disambiguate.\n- If no matching event is found, return an empty list or a clear indication to the parent agent.\n\n🚫 Don'ts:\n- Do not attempt to research or summarize attendee info.\n- Do not interact with the user directly.",
"model": DEFAULT_MODEL,
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"controlType": "relinquish_to_parent",
"outputVisibility": "internal",
"maxCallsPerParentAgent": 3,
"examples": "- **Parent agent** : Fetch attendees for 'Q3 Planning Meeting' at 2024-07-25T10:00:00Z\n - **Agent actions**: Call [@tool:List Events](#mention) with calendarId='primary', q='Q3 Planning Meeting', timeMin/timeMax as needed\n- **Agent receives event list** :\n - **Agent response**: [List of attendees with name, email, company]\n\n- **Parent agent** : Fetch attendees for 'Weekly Sync'\n - **Agent actions**: Call [@tool:List Events](#mention) with calendarId='primary', q='Weekly Sync'\n- **Agent receives event list** :\n - **Agent response**: [List of attendees with name, email, company]"
},
{
"name": "Attendee Research Agent",
"type": "conversation",
"description": "Researches each attendee and their company using Google search, then summarizes findings for meeting preparation.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou research each attendee and their company using Google search, then summarize findings to prepare the user for a meeting.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive a list of attendees (name, email, company if available) from the parent agent.\n2. For each attendee:\n a. Search for the attendee's name and company using [@tool:Composio Google Search](#mention).\n b. Summarize key information about the attendee (role, background, recent news, etc.).\n c. Search for the company (if available) and summarize key facts (industry, size, recent news, etc.).\n3. Compile a concise meeting brief with all findings.\n4. Return the meeting brief to the parent agent.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Researching attendees and their companies.\n- Summarizing findings for meeting prep.\n\n❌ Out of Scope:\n- Fetching attendee details from the calendar.\n- Interacting with the calendar directly.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Be concise and actionable in your summaries.\n- Highlight anything notable or recent.\n\n🚫 Don'ts:\n- Do not fabricate information.\n- Do not include irrelevant details.",
"model": DEFAULT_MODEL,
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"controlType": "retain",
"outputVisibility": "user_facing",
"maxCallsPerParentAgent": 3,
"examples": "- **User** : (N/A, internal agent)\n- **Parent agent** : Research these attendees: [Jane Doe, Acme Corp, jane@acme.com]\n - **Agent actions**: Call [@tool:Composio Google Search](#mention) for 'Jane Doe Acme Corp', then for 'Acme Corp'\n- **Agent receives search results** :\n - **Agent response**: \nMeeting Brief:\n- Jane Doe (Acme Corp): VP of Product. Recent interview in TechCrunch. ...\n- Acme Corp: Leading SaaS provider, 500 employees, raised Series C in 2023."
}
],
"prompts": [],
"tools": [
{
"name": "List Events",
"description": "Returns events on the specified calendar.",
"parameters": {
"type": "object",
"properties": {
"alwaysIncludeEmail": {
"default": null,
"description": "Deprecated and ignored.",
"nullable": true,
"title": "Always Include Email",
"type": "boolean"
},
"calendarId": {
"description": "Calendar identifier. To retrieve calendar IDs call the calendarList.list method. If you want to access the primary calendar of the currently logged in user, use the \"primary\" keyword.",
"examples": [
"primary"
],
"title": "Calendar Id",
"type": "string"
},
"eventTypes": {
"default": null,
"description": "Event types to return. Optional. This parameter can be repeated multiple times to return events of different types. If unset, returns all event types. Acceptable values are: \"birthday\", \"default\", \"focusTime\", \"fromGmail\", \"outOfOffice\", \"workingLocation\".",
"nullable": true,
"title": "Event Types",
"type": "string"
},
"iCalUID": {
"default": null,
"description": "Specifies an event ID in the iCalendar format to be provided in the response. Optional. Use this if you want to search for an event by its iCalendar ID.",
"nullable": true,
"title": "I Cal Uid",
"type": "string"
},
"maxAttendees": {
"default": null,
"description": "The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional.",
"nullable": true,
"title": "Max Attendees",
"type": "integer"
},
"maxResults": {
"default": null,
"description": "Maximum number of events returned on one result page. The number of events in the resulting page may be less than this value, or none at all, even if there are more events matching the query. Incomplete pages can be detected by a non-empty nextPageToken field in the response. By default the value is 250 events. The page size can never be larger than 2500 events. Optional.",
"nullable": true,
"title": "Max Results",
"type": "integer"
},
"orderBy": {
"default": null,
"description": "The order of the events returned in the result. Optional. The default is an unspecified, stable order. Acceptable values are: \"startTime\", \"updated\".",
"nullable": true,
"title": "Order By",
"type": "string"
},
"pageToken": {
"default": null,
"description": "Token specifying which result page to return. Optional.",
"nullable": true,
"title": "Page Token",
"type": "string"
},
"privateExtendedProperty": {
"default": null,
"description": "Extended properties constraint specified as propertyName=value. Matches only private properties. This parameter might be repeated multiple times to return events that match all given constraints.",
"nullable": true,
"title": "Private Extended Property",
"type": "string"
},
"q": {
"default": null,
"description": "Free text search terms to find events that match these terms in various fields. Optional.",
"nullable": true,
"title": "Q",
"type": "string"
},
"sharedExtendedProperty": {
"default": null,
"description": "Extended properties constraint specified as propertyName=value. Matches only shared properties. This parameter might be repeated multiple times to return events that match all given constraints.",
"nullable": true,
"title": "Shared Extended Property",
"type": "string"
},
"showDeleted": {
"default": null,
"description": "Whether to include deleted events (with status equals \"cancelled\") in the result. Optional. The default is False.",
"nullable": true,
"title": "Show Deleted",
"type": "boolean"
},
"showHiddenInvitations": {
"default": null,
"description": "Whether to include hidden invitations in the result. Optional. The default is False.",
"nullable": true,
"title": "Show Hidden Invitations",
"type": "boolean"
},
"singleEvents": {
"default": null,
"description": "Whether to expand recurring events into instances and only return single one-off events and instances of recurring events. Optional. The default is False.",
"nullable": true,
"title": "Single Events",
"type": "boolean"
},
"syncToken": {
"default": null,
"description": "Token obtained from the nextSyncToken field returned on the last page of results from the previous list request. Optional. The default is to return all entries.",
"nullable": true,
"title": "Sync Token",
"type": "string"
},
"timeMax": {
"default": null,
"description": "Upper bound (exclusive) for an event's start time to filter by. Optional. The default is not to filter by start time. Must be an RFC3339 timestamp with mandatory time zone offset, for example, 2011-06-03T10:00:00-07:00, 2011-06-03T10:00:00Z. Milliseconds may be provided but are ignored. If timeMin is set, timeMax must be greater than timeMin.",
"nullable": true,
"title": "Time Max",
"type": "string"
},
"timeMin": {
"default": null,
"description": "Lower bound (exclusive) for an event's end time to filter by. Optional. The default is not to filter by end time. Must be an RFC3339 timestamp with mandatory time zone offset, for example, 2011-06-03T10:00:00-07:00, 2011-06-03T10:00:00Z. Milliseconds may be provided but are ignored. If timeMax is set, timeMin must be smaller than timeMax.",
"nullable": true,
"title": "Time Min",
"type": "string"
},
"timeZone": {
"default": null,
"description": "Time zone used in the response. Optional. The default is the user's primary time zone.",
"nullable": true,
"title": "Time Zone",
"type": "string"
},
"updatedMin": {
"default": null,
"description": "Lower bound for an event's last modification time (as a RFC3339 timestamp) to filter by. When specified, entries deleted since this time will always be included regardless of showDeleted. Optional. The default is not to filter by last modification time.",
"nullable": true,
"title": "Updated Min",
"type": "string"
}
},
"required": [
"calendarId"
]
},
"mockTool": true,
"isComposio": true,
"composioData": {
"slug": "GOOGLECALENDAR_EVENTS_LIST",
"noAuth": false,
"toolkitName": "Googlecalendar",
"toolkitSlug": "googlecalendar",
"logo": "https://cdn.jsdelivr.net/gh/ComposioHQ/open-logos@master/google-calendar.svg"
}
},
{
"name": "Composio Google Search",
"description": "Perform a google search using the composio google search api.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"query": { "query": {
"type": "string", "description": "The search query for the Composio Google Search API.",
"description": "The query to retrieve articles for" "examples": [
"Coffee"
],
"title": "Query",
"type": "string"
} }
}, },
"required": [ "required": [
"query" "query"
] ]
}, },
"isLibrary": true "mockTool": true,
"isComposio": true,
"composioData": {
"slug": "COMPOSIO_SEARCH_SEARCH",
"noAuth": true,
"toolkitName": "Composio search",
"toolkitSlug": "composio_search",
"logo": "https://cdn.jsdelivr.net/gh/ComposioHQ/open-logos@master//composio-logo.png"
}
} }
], ],
"startAgent": "Meeting Prep Hub",
} }
} }

View file

@ -1,60 +0,0 @@
import { z } from "zod";
import { projectsCollection } from "./mongodb";
import { WorkflowTool } from "./types/workflow_types";
export async function collectProjectTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
const tools: z.infer<typeof WorkflowTool>[] = [];
// 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
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.input_parameters?.properties || {},
required: tool.input_parameters?.required || []
},
isComposio: true,
composioData: {
slug: tool.slug,
noAuth: tool.no_auth,
toolkitName: tool.toolkit.name,
toolkitSlug: tool.toolkit.slug,
logo: tool.toolkit.logo,
},
});
}
}
return tools;
}

View file

@ -1,21 +0,0 @@
import { redisClient } from "./redis";
const MAX_QUERIES_PER_MINUTE = Number(process.env.MAX_QUERIES_PER_MINUTE) || 0;
export async function check_query_limit(projectId: string): Promise<boolean> {
// if the limit is 0, we don't want to check the limit
if (MAX_QUERIES_PER_MINUTE === 0) {
return true;
}
const minutes_since_epoch = Math.floor(Date.now() / 1000 / 60); // 60 second window
const key = `rate_limit:${projectId}:${minutes_since_epoch}`;
// increment the counter and return the count
const count = await redisClient.incr(key);
if (count === 1) {
await redisClient.expire(key, 70); // Set TTL to clean up automatically
}
return count <= MAX_QUERIES_PER_MINUTE;
}

View file

@ -0,0 +1,14 @@
import { Message } from "./types";
import { Turn } from "@/src/entities/models/turn";
import { z } from "zod";
export const ApiRequest = z.object({
messages: z.array(Message),
conversationId: z.string().nullable().optional(),
mockTools: z.record(z.string(), z.string()).nullable().optional(),
stream: z.boolean().optional().nullable().default(false),
});export const ApiResponse = z.object({
turn: Turn,
conversationId: z.string().optional(),
});

View file

@ -2,12 +2,60 @@ import { z } from "zod";
export const SubscriptionPlan = z.enum(["free", "starter", "pro"]); export const SubscriptionPlan = z.enum(["free", "starter", "pro"]);
export const UsageType = z.enum([ export const UsageTypeKey = z.enum([
"copilot_requests", "LLM_USAGE",
"agent_messages", "EMBEDDING_MODEL_USAGE",
"rag_tokens", "COMPOSIO_TOOL_USAGE",
"COMPOSIO_TRIGGER_USAGE",
"FIRECRAWL_SCRAPE_USAGE",
]); ]);
export const LLMUsage = z.object({
type: z.literal(UsageTypeKey.Enum.LLM_USAGE),
modelName: z.string(),
inputTokens: z.number(),
outputTokens: z.number(),
context: z.string(),
});
export const EmbeddingModelUsage = z.object({
type: z.literal(UsageTypeKey.Enum.EMBEDDING_MODEL_USAGE),
modelName: z.string(),
tokens: z.number(),
context: z.string(),
});
export const ComposioToolUsage = z.object({
type: z.literal(UsageTypeKey.Enum.COMPOSIO_TOOL_USAGE),
toolSlug: z.string(),
context: z.string(),
});
export const ComposioTriggerUsage = z.object({
type: z.literal(UsageTypeKey.Enum.COMPOSIO_TRIGGER_USAGE),
triggerSlug: z.string(),
context: z.string(),
});
export const FirecrawlScrapeUsage = z.object({
type: z.literal(UsageTypeKey.Enum.FIRECRAWL_SCRAPE_USAGE),
context: z.string(),
});
export const UsageItem = z.discriminatedUnion("type", [
LLMUsage,
EmbeddingModelUsage,
ComposioToolUsage,
ComposioTriggerUsage,
FirecrawlScrapeUsage,
]);
export const LogUsageRequest = z.object({
items: z.array(UsageItem),
});
export const CustomerUsageData = z.record(z.string(), z.number());
export const Customer = z.object({ export const Customer = z.object({
_id: z.string(), _id: z.string(),
userId: z.string(), userId: z.string(),
@ -19,36 +67,23 @@ export const Customer = z.object({
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
updatedAt: z.string().datetime(), updatedAt: z.string().datetime(),
subscriptionPlanUpdatedAt: z.string().datetime().optional(), subscriptionPlanUpdatedAt: z.string().datetime().optional(),
usage: z.record(UsageType, z.number()).optional(), usage: CustomerUsageData.optional(),
usageUpdatedAt: z.string().datetime().optional(), usageUpdatedAt: z.string().datetime().optional(),
}); creditsOverride: z.number().optional(),
maxProjectsOverride: z.number().optional(),
export const LogUsageRequest = z.object({ agentModelsOverride: z.array(z.string()).optional(),
type: UsageType, });
amount: z.number().int().positive(),
});
export const AuthorizeRequest = z.discriminatedUnion("type", [ export const AuthorizeRequest = z.discriminatedUnion("type", [
z.object({
"type": z.literal("use_credits"),
}),
z.object({ z.object({
"type": z.literal("create_project"), "type": z.literal("create_project"),
"data": z.object({ "data": z.object({
"existingProjectCount": z.number(), "existingProjectCount": z.number(),
}), }),
}), }),
z.object({
"type": z.literal("enable_hosted_tool_server"),
"data": z.object({
"existingServerCount": z.number(),
}),
}),
z.object({
"type": z.literal("process_rag"),
"data": z.object({}),
}),
z.object({
"type": z.literal("copilot_request"),
"data": z.object({}),
}),
z.object({ z.object({
"type": z.literal("agent_response"), "type": z.literal("agent_response"),
"data": z.object({ "data": z.object({
@ -63,10 +98,9 @@ export const AuthorizeResponse = z.object({
}); });
export const UsageResponse = z.object({ export const UsageResponse = z.object({
usage: z.record(UsageType, z.object({ sanctionedCredits: z.number(),
usage: z.number(), availableCredits: z.number(),
total: z.number(), usage: CustomerUsageData,
})),
}); });
export const CustomerPortalSessionRequest = z.object({ export const CustomerPortalSessionRequest = z.object({

View file

@ -1,90 +1,5 @@
import { z } from "zod"; import { z } from "zod";
export const DataSource = z.object({
name: z.string(),
description: z.string().optional(),
projectId: z.string(),
active: z.boolean().default(true),
status: z.union([
z.literal('pending'),
z.literal('ready'),
z.literal('error'),
z.literal('deleted'),
]).optional(),
version: z.number(),
error: z.string().optional(),
billingError: z.string().optional(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime().optional(),
attempts: z.number(),
lastAttemptAt: z.string().datetime().optional(),
pendingRefresh: z.boolean().default(false).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'),
})
]),
});
export const DataSourceDoc = z.object({
sourceId: z.string(),
name: z.string(),
version: z.number(),
status: z.union([
z.literal('pending'),
z.literal('ready'),
z.literal('error'),
z.literal('deleted'),
]),
content: z.string().optional(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime().optional(),
error: z.string().optional(),
data: z.discriminatedUnion('type', [
z.object({
type: z.literal('url'),
url: z.string(),
}),
z.object({
type: z.literal('file_local'),
name: z.string(),
size: z.number(),
mimeType: z.string(),
}),
z.object({
type: z.literal('file_s3'),
name: z.string(),
size: z.number(),
mimeType: z.string(),
s3Key: z.string(),
}),
z.object({
type: z.literal('text'),
content: z.string(),
}),
]),
});
export const EmbeddingDoc = z.object({
content: z.string(),
sourceId: z.string(),
embeddings: z.array(z.number()),
metadata: z.object({
sourceURL: z.string(),
title: z.string(),
score: z.number().optional(),
}),
});
export const EmbeddingRecord = z.object({ export const EmbeddingRecord = z.object({
id: z.string().uuid(), id: z.string().uuid(),
vector: z.array(z.number()), vector: z.array(z.number()),

View file

@ -1,63 +0,0 @@
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(),
name: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
createdByUserId: z.string(),
secret: z.string(),
chatClientId: z.string(),
webhookUrl: z.string().optional(),
publishedWorkflowId: z.string().optional(),
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({
userId: z.string(),
projectId: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});
export const ApiKey = z.object({
projectId: z.string(),
key: z.string(),
createdAt: z.string().datetime(),
lastUsedAt: z.string().datetime().optional(),
});
export function mergeProjectTools(
workflowTools: z.infer<typeof WorkflowTool>[],
projectTools: z.infer<typeof WorkflowTool>[]
): z.infer<typeof WorkflowTool>[] {
// Filter out any existing MCP tools from workflow tools
const nonMcpTools = workflowTools.filter(t => !t.isMcp);
// Merge with project tools
const merged = [
...nonMcpTools,
...projectTools
];
return merged;
}

View file

@ -1,54 +0,0 @@
import { z } from "zod";
export const TestScenario = z.object({
projectId: z.string(),
name: z.string().min(1, "Name cannot be empty"),
description: z.string().min(1, "Description cannot be empty"),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});
export const TestProfile = z.object({
projectId: z.string(),
name: z.string().min(1, "Name cannot be empty"),
context: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
mockTools: z.boolean(),
mockPrompt: z.string().optional(),
});
export const TestSimulation = z.object({
projectId: z.string(),
name: z.string(),
description: z.string().optional().nullable(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
scenarioId: z.string(),
profileId: z.string().nullable(),
passCriteria: z.string(),
});
export const TestRun = z.object({
projectId: z.string(),
name: z.string(),
simulationIds: z.array(z.string()),
workflowId: z.string(),
status: z.enum(['pending', 'running', 'completed', 'cancelled', 'failed', 'error']),
startedAt: z.string(),
completedAt: z.string().optional(),
aggregateResults: z.object({
total: z.number(),
passCount: z.number(),
failCount: z.number(),
}).optional(),
});
export const TestResult = z.object({
projectId: z.string(),
runId: z.string(),
simulationId: z.string(),
result: z.union([z.literal('pass'), z.literal('fail')]),
details: z.string(),
transcript: z.string()
});

View file

@ -1,32 +0,0 @@
import { z } from "zod";
import { apiV1 } from "rowboat-shared"
export const GetInformationToolResultItem = z.object({
title: z.string(),
name: z.string(),
content: z.string(),
docId: z.string(),
sourceId: z.string(),
});export const GetInformationToolResult = z.object({
results: z.array(GetInformationToolResultItem)
});
export const WebpageCrawlResponse = z.object({
title: z.string(),
content: z.string(),
});
export const ClientToolCallRequestBody = z.object({
toolCall: apiV1.AssistantMessageWithToolCalls.shape.tool_calls.element,
});
export const ClientToolCallJwt = z.object({
requestId: z.string().uuid(),
projectId: z.string(),
bodyHash: z.string(),
iat: z.number(),
exp: z.number(),
});
export const ClientToolCallRequest = z.object({
requestId: z.string().uuid(),
content: z.string(), // json stringified ClientToolCallRequestBody
});
export const ClientToolCallResponse = z.unknown();

View file

@ -1,24 +1,28 @@
import { z } from "zod"; import { z } from "zod";
import { WorkflowTool } from "./workflow_types"; import { WorkflowTool } from "./workflow_types";
export const SystemMessage = z.object({ export const BaseMessage = z.object({
timestamp: z.string().datetime().optional(),
});
export const SystemMessage = BaseMessage.extend({
role: z.literal("system"), role: z.literal("system"),
content: z.string(), content: z.string(),
}); });
export const UserMessage = z.object({ export const UserMessage = BaseMessage.extend({
role: z.literal("user"), role: z.literal("user"),
content: z.string(), content: z.string(),
}); });
export const AssistantMessage = z.object({ export const AssistantMessage = BaseMessage.extend({
role: z.literal("assistant"), role: z.literal("assistant"),
content: z.string(), content: z.string(),
agentName: z.string().nullable(), agentName: z.string().nullable(),
responseType: z.enum(['internal', 'external']), responseType: z.enum(['internal', 'external']),
}); });
export const AssistantMessageWithToolCalls = z.object({ export const AssistantMessageWithToolCalls = BaseMessage.extend({
role: z.literal("assistant"), role: z.literal("assistant"),
content: z.null(), content: z.null(),
toolCalls: z.array(z.object({ toolCalls: z.array(z.object({
@ -32,7 +36,7 @@ export const AssistantMessageWithToolCalls = z.object({
agentName: z.string().nullable(), agentName: z.string().nullable(),
}); });
export const ToolMessage = z.object({ export const ToolMessage = BaseMessage.extend({
role: z.literal("tool"), role: z.literal("tool"),
content: z.string(), content: z.string(),
toolCallId: z.string(), toolCallId: z.string(),
@ -129,18 +133,6 @@ export const User = z.object({
updatedAt: z.string().datetime(), updatedAt: z.string().datetime(),
}); });
export const PlaygroundChat = z.object({
createdAt: z.string().datetime(),
projectId: z.string(),
title: z.string().optional(),
messages: z.array(Message),
simulated: z.boolean().default(false).optional(),
simulationScenario: z.string().optional(),
simulationComplete: z.boolean().default(false).optional(),
agenticState: z.unknown().optional(),
systemMessage: z.string().optional(),
});
export const Webpage = z.object({ export const Webpage = z.object({
_id: z.string(), _id: z.string(),
title: z.string(), title: z.string(),
@ -155,19 +147,6 @@ export const ChatClientId = z.object({
export type WithStringId<T> = T & { _id: string }; export type WithStringId<T> = T & { _id: string };
export const ApiRequest = z.object({
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(Message),
state: z.unknown(),
});
// Helper function to convert MCP server tool to WorkflowTool // Helper function to convert MCP server tool to WorkflowTool
export function convertMcpServerToolToWorkflowTool( export function convertMcpServerToolToWorkflowTool(
mcpTool: z.infer<typeof McpServerTool>, mcpTool: z.infer<typeof McpServerTool>,
@ -207,4 +186,4 @@ export function convertMcpServerToolToWorkflowTool(
}; };
return converted; return converted;
} }

View file

@ -1,5 +1,4 @@
import { z } from 'zod'; import { z } from 'zod';
import { WithId } from 'mongodb';
import { Message } from './types'; import { Message } from './types';
export const TwilioConfigParams = z.object({ export const TwilioConfigParams = z.object({
@ -8,7 +7,6 @@ export const TwilioConfigParams = z.object({
auth_token: z.string(), auth_token: z.string(),
label: z.string(), label: z.string(),
project_id: z.string(), project_id: z.string(),
workflow_id: z.string(),
}); });
export const TwilioConfig = TwilioConfigParams.extend({ export const TwilioConfig = TwilioConfigParams.extend({
@ -24,7 +22,6 @@ export interface TwilioConfigResponse {
export interface InboundConfigResponse { export interface InboundConfigResponse {
status: 'configured' | 'reconfigured'; status: 'configured' | 'reconfigured';
phone_number: string; phone_number: string;
workflow_id: string;
previous_webhook?: string; previous_webhook?: string;
error?: string; error?: string;
} }
@ -34,7 +31,6 @@ export const TwilioInboundCall = z.object({
to: z.string(), to: z.string(),
from: z.string(), from: z.string(),
projectId: z.string(), projectId: z.string(),
workflowId: z.string(),
messages: z.array(Message), messages: z.array(Message),
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime().optional(), lastUpdatedAt: z.string().datetime().optional(),

View file

@ -6,6 +6,7 @@ export const WorkflowAgent = z.object({
'conversation', 'conversation',
'post_process', 'post_process',
'escalation', 'escalation',
'pipeline',
]), ]),
description: z.string(), description: z.string(),
disabled: z.boolean().default(false).optional(), disabled: z.boolean().default(false).optional(),
@ -23,9 +24,34 @@ export const WorkflowAgent = z.object({
'retain', 'retain',
'relinquish_to_parent', 'relinquish_to_parent',
'relinquish_to_start', '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'), ]).optional().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(), 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 StrictWorkflowAgent = WorkflowAgent.refine((data) => {
// Pipeline agents should have internal output visibility and relinquish_to_parent control type
if (data.type === 'pipeline' && data.outputVisibility !== 'internal') {
return false;
}
if (data.type === 'pipeline' && data.controlType !== 'relinquish_to_parent') {
return false;
}
// Internal agents should have relinquish_to_parent control type
if (data.outputVisibility === 'internal' && data.controlType !== 'relinquish_to_parent') {
return false;
}
// User-facing agents should not have relinquish_to_parent control type
if (data.outputVisibility === 'user_facing' && data.controlType === 'relinquish_to_parent') {
return false;
}
// All agents should have a control type
if (data.controlType === undefined) {
return false;
}
return true;
}, {
message: "Pipeline agents must have 'internal' output visibility and 'relinquish_to_parent' control type, while other agents must have appropriate control types",
path: ["controlType", "outputVisibility"]
});
export const WorkflowPrompt = z.object({ export const WorkflowPrompt = z.object({
name: z.string(), name: z.string(),
type: z.enum([ type: z.enum([
@ -39,7 +65,6 @@ export const WorkflowTool = z.object({
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
mockTool: z.boolean().default(false).optional(), mockTool: z.boolean().default(false).optional(),
autoSubmitMockedResponse: z.boolean().default(false).optional(),
mockInstructions: z.string().optional(), mockInstructions: z.string().optional(),
parameters: z.object({ parameters: z.object({
type: z.literal('object'), type: z.literal('object'),
@ -48,10 +73,10 @@ export const WorkflowTool = z.object({
additionalProperties: z.boolean().optional(), additionalProperties: z.boolean().optional(),
}), }),
isMcp: z.boolean().default(false).optional(), isMcp: z.boolean().default(false).optional(),
isLibrary: z.boolean().default(false).optional(),
mcpServerName: z.string().optional(), mcpServerName: z.string().optional(),
mcpServerURL: z.string().optional(),
isComposio: z.boolean().optional(), // whether this is a Composio tool isComposio: z.boolean().optional(), // whether this is a Composio tool
isLibrary: z.boolean().default(false).optional(), // whether this is a library tool
isWebhook: z.boolean().optional(), // whether this is a webhook tool
composioData: z.object({ composioData: z.object({
slug: z.string(), // the slug for the Composio tool e.g. "GITHUB_CREATE_AN_ISSUE" slug: z.string(), // the slug for the Composio tool e.g. "GITHUB_CREATE_AN_ISSUE"
noAuth: z.boolean(), // whether the tool requires no authentication noAuth: z.boolean(), // whether the tool requires no authentication
@ -60,22 +85,26 @@ export const WorkflowTool = z.object({
logo: z.string(), // the logo for the Composio tool logo: z.string(), // the logo for the Composio tool
}).optional(), // the data for the Composio tool, if it is a Composio tool }).optional(), // the data for the Composio tool, if it is a Composio tool
}); });
export const WorkflowPipeline = z.object({
name: z.string(),
description: z.string().optional(),
agents: z.array(z.string()), // ordered list of agent names in the pipeline
order: z.number().int().optional(),
});
export const Workflow = z.object({ export const Workflow = z.object({
name: z.string().optional(),
agents: z.array(WorkflowAgent), agents: z.array(WorkflowAgent),
prompts: z.array(WorkflowPrompt), prompts: z.array(WorkflowPrompt),
tools: z.array(WorkflowTool), tools: z.array(WorkflowTool),
pipelines: z.array(WorkflowPipeline).optional(),
startAgent: z.string(), startAgent: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(), lastUpdatedAt: z.string().datetime(),
projectId: z.string(),
mockTools: z.record(z.string(), z.string()).optional(), // a dict of toolName => mockInstructions mockTools: z.record(z.string(), z.string()).optional(), // a dict of toolName => mockInstructions
}); });
export const WorkflowTemplate = Workflow export const WorkflowTemplate = Workflow
.omit({ .omit({
projectId: true,
lastUpdatedAt: true, lastUpdatedAt: true,
createdAt: true,
}) })
.extend({ .extend({
name: z.string(), name: z.string(),
@ -83,7 +112,7 @@ export const WorkflowTemplate = Workflow
}); });
export const ConnectedEntity = z.object({ export const ConnectedEntity = z.object({
type: z.enum(['tool', 'prompt', 'agent']), type: z.enum(['tool', 'prompt', 'agent', 'pipeline']),
name: z.string(), name: z.string(),
}); });
@ -93,14 +122,15 @@ export function sanitizeTextWithMentions(
agents: z.infer<typeof WorkflowAgent>[], agents: z.infer<typeof WorkflowAgent>[],
tools: z.infer<typeof WorkflowTool>[], tools: z.infer<typeof WorkflowTool>[],
prompts: z.infer<typeof WorkflowPrompt>[], prompts: z.infer<typeof WorkflowPrompt>[],
pipelines?: z.infer<typeof WorkflowPipeline>[],
}, },
projectTools: z.infer<typeof WorkflowTool>[] = [] currentAgent?: z.infer<typeof WorkflowAgent>,
): { ): {
sanitized: string; sanitized: string;
entities: z.infer<typeof ConnectedEntity>[]; entities: z.infer<typeof ConnectedEntity>[];
} { } {
// Regex to match [@type:name](#type:something) pattern where type is tool/prompt/agent // Regex to match [@type:name](#type:something) pattern where type is tool/prompt/agent/pipeline
const mentionRegex = /\[@(tool|prompt|agent):([^\]]+)\]\(#mention\)/g; const mentionRegex = /\[@(tool|prompt|agent|pipeline):([^\]]+)\]\(#mention\)/g;
const seen = new Set<string>(); const seen = new Set<string>();
// collect entities // collect entities
@ -115,19 +145,28 @@ export function sanitizeTextWithMentions(
}) })
.map(match => { .map(match => {
return { return {
type: match[1] as 'tool' | 'prompt' | 'agent', type: match[1] as 'tool' | 'prompt' | 'agent' | 'pipeline',
name: match[2], name: match[2],
}; };
}) })
.filter(entity => { .filter(entity => {
seen.add(entity.name); seen.add(entity.name);
// For pipeline agents, only allow tool and prompt mentions
if (currentAgent?.type === 'pipeline') {
return entity.type === 'tool' || entity.type === 'prompt';
}
if (entity.type === 'agent') { if (entity.type === 'agent') {
return workflow.agents.some(a => a.name === entity.name); // Filter out pipeline agents - they should not be @ referenceable
const agent = workflow.agents.find(a => a.name === entity.name);
return agent && agent.type !== 'pipeline';
} else if (entity.type === 'tool') { } else if (entity.type === 'tool') {
return workflow.tools.some(t => t.name === entity.name) || return workflow.tools.some(t => t.name === entity.name);
projectTools.some(t => t.name === entity.name);
} else if (entity.type === 'prompt') { } else if (entity.type === 'prompt') {
return workflow.prompts.some(p => p.name === entity.name); return workflow.prompts.some(p => p.name === entity.name);
} else if (entity.type === 'pipeline') {
return workflow.pipelines?.some(p => p.name === entity.name);
} }
return false; return false;
}) })

View file

@ -1,35 +1,3 @@
import { z } from "zod";
import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { redisClient } from "./redis";
import { Workflow, WorkflowTool } from "./types/workflow_types";
import { Message } from "./types/types";
export async function getAgenticResponseStreamId(
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({
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
return {
streamId,
};
}
// create a PrefixLogger class that wraps console.log with a prefix // create a PrefixLogger class that wraps console.log with a prefix
// and allows chaining with a parent logger // and allows chaining with a parent logger
export class PrefixLogger { export class PrefixLogger {
@ -55,51 +23,4 @@ export class PrefixLogger {
child(childPrefix: string): PrefixLogger { child(childPrefix: string): PrefixLogger {
return new PrefixLogger(childPrefix, this); return new PrefixLogger(childPrefix, this);
} }
}
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}}.
Your job is to come up with the data that the tool call should return.
In order to help you mock the responses, the user has provided some contextual information,
and also some instructions on how to mock the tool call.
>>>CHAT_HISTORY
{{messages}}
<<<END_OF_CHAT_HISTORY
>>>MOCK_INSTRUCTIONS
{{mockInstructions}}
<<<END_OF_MOCK_INSTRUCTIONS
The current date is {{date}}.
`
.replace('{{toolID}}', toolId)
.replace(`{{date}}`, new Date().toISOString())
.replace('{{mockInstructions}}', mockInstructions)
.replace('{{messages}}', JSON.stringify(messages.map((m) => {
let tool_calls;
if ('tool_calls' in m && m.role == 'assistant') {
tool_calls = m.tool_calls;
}
let { role, content } = m;
return {
role,
content,
tool_calls,
}
})));
// console.log(prompt);
const { object } = await generateObject({
model: openai("gpt-4o"),
prompt: prompt,
schema: z.object({
result: z.any(),
}),
});
return JSON.stringify(object);
} }

View file

@ -3,7 +3,7 @@ import { useState } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { FormStatusButton } from "@/app/lib/components/form-status-button"; import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { updateUserEmail } from "../actions/auth_actions"; import { updateUserEmail } from "../actions/auth.actions";
import { tokens } from "@/app/styles/design-tokens"; import { tokens } from "@/app/styles/design-tokens";
import { SectionHeading } from "@/components/ui/section-heading"; import { SectionHeading } from "@/components/ui/section-heading";
import { HorizontalDivider } from "@/components/ui/horizontal-divider"; import { HorizontalDivider } from "@/components/ui/horizontal-divider";

View file

@ -6,7 +6,7 @@ export default function Layout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<AppLayout useRag={false} useAuth={true} useBilling={true}> <AppLayout useAuth={true} useBilling={true}>
{children} {children}
</AppLayout> </AppLayout>
); );

View file

@ -1,21 +1,17 @@
'use client'; 'use client';
import { Metadata } from "next"; import { Metadata } from "next";
import { Spinner, Textarea, Button, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider, Tab, Tabs } from "@heroui/react"; import { Spinner, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider, Textarea } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { ReactNode, useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../actions/project_actions"; import { fetchProject, updateProjectName, updateWebhookUrl, deleteProject, rotateSecret } from "../../../actions/project.actions";
import { CopyButton } from "../../../../components/common/copy-button"; import { CopyButton } from "../../../../components/common/copy-button";
import { EditableField } from "../../../lib/components/editable-field"; import { InputField } from "../../../lib/components/input-field";
import { EyeIcon, EyeOffIcon, Settings, Plus, MoreVertical } from "lucide-react"; import { EyeIcon, EyeOffIcon, Settings, Plus, MoreVertical } from "lucide-react";
import { WithStringId } from "../../../lib/types/types";
import { ApiKey } from "../../../lib/types/project_types";
import { z } from "zod";
import { RelativeTime } from "@primer/react";
import { Label } from "../../../lib/components/label"; import { Label } from "../../../lib/components/label";
import { FormSection } from "../../../lib/components/form-section"; import { FormSection } from "../../../lib/components/form-section";
import { Panel } from "@/components/common/panel-common"; import { Panel } from "@/components/common/panel-common";
import { ProjectSection } from './components/project'; import { ProjectSection, SimpleProjectSection } from './components/project';
import { VoiceSection } from "./components/voice";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Project config", title: "Project config",
@ -68,7 +64,7 @@ export function BasicSettingsSection({
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
getProjectConfig(projectId).then((project) => { fetchProject(projectId).then((project) => {
setProjectName(project?.name); setProjectName(project?.name);
setLoading(false); setLoading(false);
}); });
@ -84,7 +80,7 @@ export function BasicSettingsSection({
return <Section title="Basic settings"> return <Section title="Basic settings">
<FormSection label="Project name"> <FormSection label="Project name">
{loading && <Spinner size="sm" />} {loading && <Spinner size="sm" />}
{!loading && <EditableField {!loading && <InputField type="text"
value={projectName || ''} value={projectName || ''}
onChange={updateName} onChange={updateName}
className="w-full" className="w-full"
@ -108,156 +104,6 @@ export function BasicSettingsSection({
</Section>; </Section>;
} }
export function ApiKeysSection({
projectId,
}: {
projectId: string;
}) {
const [keys, setKeys] = useState<WithStringId<z.infer<typeof ApiKey>>[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<{
type: 'success' | 'error' | 'info';
text: string;
} | null>(null);
useEffect(() => {
const loadKeys = async () => {
const keys = await listApiKeys(projectId);
setKeys(keys);
setLoading(false);
};
loadKeys();
}, [projectId]);
const handleCreateKey = async () => {
setLoading(true);
setMessage(null);
try {
const key = await createApiKey(projectId);
setLoading(false);
setMessage({
type: 'success',
text: 'API key created successfully',
});
setKeys([...keys, key]);
setTimeout(() => {
setMessage(null);
}, 2000);
} catch (error) {
setLoading(false);
setMessage({
type: 'error',
text: error instanceof Error ? error.message : "Failed to create API key",
});
}
};
const handleDeleteKey = async (id: string) => {
if (!window.confirm("Are you sure you want to delete this API key? This action cannot be undone.")) {
return;
}
try {
setLoading(true);
setMessage(null);
await deleteApiKey(projectId, id);
setKeys(keys.filter((k) => k._id !== id));
setLoading(false);
setMessage({
type: 'info',
text: 'API key deleted successfully',
});
setTimeout(() => {
setMessage(null);
}, 2000);
} catch (error) {
setLoading(false);
setMessage({
type: 'error',
text: error instanceof Error ? error.message : "Failed to delete API key",
});
}
};
return <Section title="API keys">
<div className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
API keys are used to authenticate requests to the Rowboat API.
</p>
<Button
onPress={handleCreateKey}
size="sm"
startContent={<Plus className="h-4 w-4" />}
variant="flat"
isDisabled={loading}
>
Create API key
</Button>
</div>
<Divider />
{loading && <Spinner size="sm" />}
{!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>
</div>
{message?.type === 'success' && <div className="flex flex-col p-2">
<div className="text-sm bg-green-50 text-green-500 p-2 rounded-md">{message.text}</div>
</div>}
{message?.type === 'error' && <div className="flex flex-col p-2">
<div className="text-sm bg-red-50 text-red-500 p-2 rounded-md">{message.text}</div>
</div>}
{message?.type === 'info' && <div className="flex flex-col p-2">
<div className="text-sm bg-yellow-50 text-yellow-500 p-2 rounded-md">{message.text}</div>
</div>}
<div className="flex flex-col">
{keys.map((key) => (
<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">
<RelativeTime date={new Date(key.createdAt)} />
</div>
<div className="flex-1 p-2">
{key.lastUsedAt ? <RelativeTime date={new Date(key.lastUsedAt)} /> : 'Never'}
</div>
<div className="w-10 p-2">
<Dropdown>
<DropdownTrigger>
<button className="text-muted-foreground hover:text-foreground">
<MoreVertical className="h-4 w-4" />
</button>
</DropdownTrigger>
<DropdownMenu>
<DropdownItem
key='delete'
className="text-destructive"
onPress={() => handleDeleteKey(key._id)}
>
Delete
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</div>
))}
{keys.length === 0 && (
<div className="p-4 text-center text-muted-foreground">
No API keys created yet
</div>
)}
</div>
</div>}
</div>
</Section>;
}
export function SecretSection({ export function SecretSection({
projectId, projectId,
}: { }: {
@ -271,7 +117,7 @@ export function SecretSection({
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
getProjectConfig(projectId).then((project) => { fetchProject(projectId).then((project) => {
setSecret(project.secret); setSecret(project.secret);
setLoading(false); setLoading(false);
}); });
@ -321,10 +167,10 @@ export function SecretSection({
/> />
<Button <Button
size="sm" size="sm"
variant="flat" variant="primary"
color="warning" color="warning"
onPress={handleRotateSecret} onClick={handleRotateSecret}
isDisabled={loading} disabled={loading}
> >
Rotate Rotate
</Button> </Button>
@ -345,7 +191,7 @@ export function WebhookUrlSection({
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
getProjectConfig(projectId).then((project) => { fetchProject(projectId).then((project) => {
setWebhookUrl(project.webhookUrl || null); setWebhookUrl(project.webhookUrl || null);
setLoading(false); setLoading(false);
}); });
@ -374,7 +220,7 @@ export function WebhookUrlSection({
<Divider /> <Divider />
<FormSection label="Webhook URL"> <FormSection label="Webhook URL">
{loading && <Spinner size="sm" />} {loading && <Spinner size="sm" />}
{!loading && <EditableField {!loading && <InputField type="text"
value={webhookUrl || ''} value={webhookUrl || ''}
onChange={update} onChange={update}
validate={validate} validate={validate}
@ -384,6 +230,7 @@ export function WebhookUrlSection({
</Section>; </Section>;
} }
/*
export function ChatWidgetSection({ export function ChatWidgetSection({
projectId, projectId,
chatWidgetHost, chatWidgetHost,
@ -396,7 +243,7 @@ export function ChatWidgetSection({
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
getProjectConfig(projectId).then((project) => { fetchProject(projectId).then((project) => {
setChatClientId(project.chatClientId); setChatClientId(project.chatClientId);
setLoading(false); setLoading(false);
}); });
@ -436,6 +283,7 @@ export function ChatWidgetSection({
/>} />}
</Section>; </Section>;
} }
*/
export function DeleteProjectSection({ export function DeleteProjectSection({
projectId, projectId,
@ -452,7 +300,7 @@ export function DeleteProjectSection({
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
getProjectConfig(projectId).then((project) => { fetchProject(projectId).then((project) => {
setProjectName(project.name); setProjectName(project.name);
setLoading(false); setLoading(false);
}); });
@ -477,9 +325,8 @@ export function DeleteProjectSection({
<Button <Button
color="danger" color="danger"
size="sm" size="sm"
onPress={onOpen} onClick={onOpen}
isDisabled={loading} disabled={loading}
isLoading={loading}
> >
Delete project Delete project
</Button> </Button>
@ -508,13 +355,13 @@ export function DeleteProjectSection({
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button variant="light" onPress={onClose}> <Button variant="secondary" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button <Button
color="danger" color="danger"
onPress={handleDelete} onClick={handleDelete}
isDisabled={!isValid} disabled={!isValid}
> >
Delete Project Delete Project
</Button> </Button>
@ -566,39 +413,34 @@ export function ConfigApp({
useChatWidget: boolean; useChatWidget: boolean;
chatWidgetHost: string; chatWidgetHost: string;
}) { }) {
const [selected, setSelected] = useState("general");
return ( return (
<div className="h-full overflow-auto p-6"> <div className="h-full overflow-auto p-6">
<Tabs <Panel title="Project settings">
selectedKey={selected} <ProjectSection
onSelectionChange={(key) => setSelected(key.toString())} projectId={projectId}
fullWidth useChatWidget={useChatWidget}
> chatWidgetHost={chatWidgetHost}
<Tab />
key="general" </Panel>
title="Project settings" </div>
> );
<Panel title="Project settings"> }
<ProjectSection
projectId={projectId}
useChatWidget={useChatWidget}
chatWidgetHost={chatWidgetHost}
/>
</Panel>
</Tab>
<Tab export function SimpleConfigApp({
key="twilio" projectId,
title="Twilio" onProjectConfigUpdated,
> }: {
<Panel title="Twilio settings"> projectId: string;
<VoiceSection onProjectConfigUpdated?: () => void;
projectId={projectId} }) {
/> return (
</Panel> <div className="h-full overflow-auto p-6">
</Tab> <Panel title="Project Settings">
</Tabs> <SimpleProjectSection
projectId={projectId}
onProjectConfigUpdated={onProjectConfigUpdated}
/>
</Panel>
</div> </div>
); );
} }

View file

@ -3,17 +3,23 @@
import { ReactNode, useEffect, useState, useCallback } from "react"; import { ReactNode, useEffect, useState, useCallback } from "react";
import { Spinner, Dropdown, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure } from "@heroui/react"; import { Spinner, Dropdown, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure } from "@heroui/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { fetchProject, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret, updateProjectName, saveWorkflow } from "../../../../actions/project.actions";
import { getProjectConfig, updateProjectName, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../../actions/project_actions";
import { CopyButton } from "../../../../../components/common/copy-button"; import { CopyButton } from "../../../../../components/common/copy-button";
import { EyeIcon, EyeOffIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { EyeIcon, EyeOffIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { WithStringId } from "../../../../lib/types/types"; import { ApiKey } from "@/src/entities/models/api-key";
import { ApiKey } from "../../../../lib/types/project_types";
import { z } from "zod"; import { z } from "zod";
import { RelativeTime } from "@primer/react"; import { RelativeTime } from "@primer/react";
import { Label } from "../../../../lib/components/label"; import { Label } from "../../../../lib/components/label";
import { sectionHeaderStyles, sectionDescriptionStyles } from './shared-styles'; import { sectionHeaderStyles, sectionDescriptionStyles } from './shared-styles';
import { clsx } from "clsx"; import { clsx } from "clsx";
import { InputField } from "../../../../lib/components/input-field";
import { ComposioConnectedAccount } from "@/src/entities/models/project";
import { getToolkit, listComposioTriggerDeployments, deleteComposioTriggerDeployment } from "../../../../actions/composio.actions";
import { deleteConnectedAccount } from "../../../../actions/composio.actions";
import { PictureImg } from "@/components/ui/picture-img";
import { UnlinkIcon, AlertTriangle, Trash2 } from "lucide-react";
import { ProjectWideChangeConfirmationModal } from "@/components/common/project-wide-change-confirmation-modal";
import { Workflow } from "../../../../lib/types/workflow_types";
export function Section({ export function Section({
title, title,
@ -61,57 +67,50 @@ export function RightContent({
return <div>{children}</div>; return <div>{children}</div>;
} }
function ProjectNameSection({ projectId }: { projectId: string }) { function ProjectNameSection({
projectId,
onProjectConfigUpdated
}: {
projectId: string;
onProjectConfigUpdated?: () => void;
}) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [projectName, setProjectName] = useState<string | null>(null); const [projectName, setProjectName] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
getProjectConfig(projectId).then((project) => { fetchProject(projectId).then((project) => {
setProjectName(project?.name); setProjectName(project?.name);
setLoading(false); setLoading(false);
}); });
}, [projectId]); }, [projectId]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { async function updateName(name: string) {
const value = e.target.value; setLoading(true);
setProjectName(value); await updateProjectName(projectId, name);
setProjectName(name);
if (!value.trim()) { setLoading(false);
setError("Project name cannot be empty"); if (onProjectConfigUpdated) {
return; onProjectConfigUpdated();
} }
}
setError(null);
updateProjectName(projectId, value);
};
return <Section return <Section
title="Project Name" title="Project Name"
description="The name of your project." description="The name of your project."
> >
{loading ? ( <div className="space-y-4">
<Spinner size="sm" /> {loading ? (
) : ( <Spinner size="sm" />
<div className="space-y-2"> ) : (
<div className={clsx( <InputField
"border rounded-lg focus-within:ring-2", type="text"
error value={projectName || ''}
? "border-red-500 focus-within:ring-red-500/20" onChange={updateName}
: "border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20" className="w-full"
)}> />
<Textarea )}
value={projectName || ''} </div>
onChange={handleChange}
placeholder="Enter project name..."
className="w-full text-sm bg-transparent border-0 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
autoResize
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
)}
</Section>; </Section>;
} }
@ -140,7 +139,7 @@ function SecretSection({ projectId }: { projectId: string }) {
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
getProjectConfig(projectId).then((project) => { fetchProject(projectId).then((project) => {
setSecret(project.secret); setSecret(project.secret);
setLoading(false); setLoading(false);
}); });
@ -231,7 +230,7 @@ function ApiKeyDisplay({ apiKey, onDelete }: { apiKey: string; onDelete: () => v
} }
function ApiKeysSection({ projectId }: { projectId: string }) { function ApiKeysSection({ projectId }: { projectId: string }) {
const [keys, setKeys] = useState<WithStringId<z.infer<typeof ApiKey>>[]>([]); const [keys, setKeys] = useState<z.infer<typeof ApiKey>[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<{ const [message, setMessage] = useState<{
type: 'success' | 'error' | 'info'; type: 'success' | 'error' | 'info';
@ -277,7 +276,7 @@ function ApiKeysSection({ projectId }: { projectId: string }) {
try { try {
setLoading(true); setLoading(true);
await deleteApiKey(projectId, id); await deleteApiKey(projectId, id);
setKeys(keys.filter((k) => k._id !== id)); setKeys(keys.filter((k) => k.id !== id));
setMessage({ setMessage({
type: 'info', type: 'info',
text: 'API key deleted successfully', text: 'API key deleted successfully',
@ -332,11 +331,11 @@ function ApiKeysSection({ projectId }: { projectId: string }) {
)} )}
{keys.map((key) => ( {keys.map((key) => (
<div key={key._id} className="grid grid-cols-12 items-center border-b border-gray-200 dark:border-gray-700 last:border-0 p-4"> <div key={key.id} className="grid grid-cols-12 items-center border-b border-gray-200 dark:border-gray-700 last:border-0 p-4">
<div className="col-span-7"> <div className="col-span-7">
<ApiKeyDisplay <ApiKeyDisplay
apiKey={key.key} apiKey={key.key}
onDelete={() => handleDeleteKey(key._id)} onDelete={() => handleDeleteKey(key.id)}
/> />
</div> </div>
<div className="col-span-3 text-sm text-gray-500"> <div className="col-span-3 text-sm text-gray-500">
@ -361,13 +360,14 @@ function ApiKeysSection({ projectId }: { projectId: string }) {
</Section>; </Section>;
} }
function ChatWidgetSection({ projectId, chatWidgetHost }: { projectId: string, chatWidgetHost: string }) { /*
export function ChatWidgetSection({ projectId, chatWidgetHost }: { projectId: string, chatWidgetHost: string }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [chatClientId, setChatClientId] = useState<string | null>(null); const [chatClientId, setChatClientId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
getProjectConfig(projectId).then((project) => { fetchProject(projectId).then((project) => {
setChatClientId(project.chatClientId); setChatClientId(project.chatClientId);
setLoading(false); setLoading(false);
}); });
@ -414,6 +414,273 @@ function ChatWidgetSection({ projectId, chatWidgetHost }: { projectId: string, c
</Section> </Section>
); );
} }
*/
interface ConnectedToolkit {
slug: string;
name: string;
logo: string;
connectedAccount: z.infer<typeof ComposioConnectedAccount> | null;
}
function DisconnectToolkitsSection({ projectId, onProjectConfigUpdated }: {
projectId: string;
onProjectConfigUpdated?: () => void;
}) {
const [loading, setLoading] = useState(false);
const [connectedToolkits, setConnectedToolkits] = useState<ConnectedToolkit[]>([]);
const [disconnectingToolkit, setDisconnectingToolkit] = useState<string | null>(null);
const [showDisconnectModal, setShowDisconnectModal] = useState(false);
const [selectedToolkit, setSelectedToolkit] = useState<ConnectedToolkit | null>(null);
const loadConnectedToolkits = useCallback(async () => {
setLoading(true);
try {
const project = await fetchProject(projectId);
const connectedAccounts = project.composioConnectedAccounts || {};
const workflow = project.draftWorkflow;
// Get all connected accounts (both active and inactive)
const allConnections = Object.entries(connectedAccounts);
// Get all Composio toolkits used in workflow tools (even if not connected)
const workflowToolkitSlugs = new Set<string>();
if (workflow?.tools) {
workflow.tools.forEach(tool => {
if (tool.isComposio && tool.composioData?.toolkitSlug) {
workflowToolkitSlugs.add(tool.composioData.toolkitSlug);
}
});
}
// Combine connected accounts and workflow toolkits
const allToolkitSlugs = new Set([
...allConnections.map(([slug]) => slug),
...workflowToolkitSlugs
]);
// Fetch toolkit details for each toolkit
const toolkitPromises = Array.from(allToolkitSlugs).map(async (slug) => {
try {
const toolkit = await getToolkit(projectId, slug);
const connectedAccount = connectedAccounts[slug];
return {
slug,
name: toolkit.name,
logo: toolkit.meta.logo,
connectedAccount: connectedAccount || null // null if not connected
};
} catch (error) {
console.error(`Failed to fetch toolkit ${slug}:`, error);
return null;
}
});
const toolkits = (await Promise.all(toolkitPromises)).filter(Boolean) as (ConnectedToolkit | ConnectedToolkit & { connectedAccount: null })[];
setConnectedToolkits(toolkits);
} catch (error) {
console.error('Failed to load connected toolkits:', error);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
loadConnectedToolkits();
}, [loadConnectedToolkits]);
const handleDisconnectClick = (toolkit: ConnectedToolkit) => {
setSelectedToolkit(toolkit);
setShowDisconnectModal(true);
};
const handleConfirmDisconnect = async () => {
if (!selectedToolkit) return;
setDisconnectingToolkit(selectedToolkit.slug);
try {
// Step 1: Get current project and workflow
const project = await fetchProject(projectId);
const currentWorkflow = project.draftWorkflow;
if (currentWorkflow) {
// Step 2: Remove all tools from this toolkit from the workflow
const updatedTools = currentWorkflow.tools.filter(tool =>
!tool.isComposio || tool.composioData?.toolkitSlug !== selectedToolkit.slug
);
// Step 3: Update the workflow
const updatedWorkflow: z.infer<typeof Workflow> = {
...currentWorkflow,
tools: updatedTools
};
await saveWorkflow(projectId, updatedWorkflow);
}
// Step 4: Delete all triggers for this toolkit
const triggers = await listComposioTriggerDeployments({ projectId });
const toolkitTriggers = triggers.items.filter(trigger => trigger.toolkitSlug === selectedToolkit.slug);
for (const trigger of toolkitTriggers) {
try {
await deleteComposioTriggerDeployment({
projectId,
deploymentId: trigger.id
});
} catch (error) {
console.error(`Failed to delete trigger ${trigger.id}:`, error);
// Continue with other triggers
}
}
// Step 5: Disconnect the account (if connected)
if (selectedToolkit.connectedAccount) {
await deleteConnectedAccount(
projectId,
selectedToolkit.slug,
);
}
// Remove from local state
setConnectedToolkits(prev =>
prev.filter(toolkit => toolkit.slug !== selectedToolkit.slug)
);
// Notify parent of config update
onProjectConfigUpdated?.();
} catch (error) {
console.error('Disconnect failed:', error);
} finally {
setDisconnectingToolkit(null);
setShowDisconnectModal(false);
setSelectedToolkit(null);
}
};
return (
<>
<Section
title="Composio Toolkits"
description="Manage your Composio toolkits. Shows all toolkits added to your project, whether connected or not. Disconnect to remove all tools, triggers, and connections."
>
<div className="space-y-4">
{loading ? (
<Spinner size="sm" />
) : connectedToolkits.length > 0 ? (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{connectedToolkits.map((toolkit) => (
<div
key={toolkit.slug}
className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 last:border-0"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 flex items-center justify-center">
{toolkit.logo ? (
<PictureImg
src={toolkit.logo}
alt={`${toolkit.name} logo`}
className="w-full h-full object-contain rounded"
/>
) : (
<div className="w-full h-full bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center">
<span className="text-xs font-medium text-gray-500">
{toolkit.name.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">
{toolkit.name}
</div>
<div className="flex items-center gap-2 mt-1">
{toolkit.connectedAccount?.status === 'ACTIVE' ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border border-green-300 bg-green-50 text-green-700 dark:bg-green-900 dark:text-green-200 dark:border-green-700">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
Connected
</span>
) : toolkit.connectedAccount ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border border-gray-300 bg-gray-50 text-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:border-gray-700">
<span className="w-2 h-2 bg-gray-500 rounded-full"></span>
Disconnected
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border border-yellow-300 bg-yellow-50 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200 dark:border-yellow-700">
<span className="w-2 h-2 bg-yellow-500 rounded-full"></span>
Not Connected
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{toolkit.connectedAccount?.status === 'ACTIVE' ? (
<Button
size="sm"
variant="secondary"
startContent={<UnlinkIcon className="w-4 h-4" />}
onClick={() => handleDisconnectClick(toolkit)}
disabled={disconnectingToolkit === toolkit.slug}
isLoading={disconnectingToolkit === toolkit.slug}
>
{disconnectingToolkit === toolkit.slug ? 'Disconnecting...' : 'Disconnect'}
</Button>
) : toolkit.connectedAccount ? (
<Button
size="sm"
variant="secondary"
disabled={true}
>
Disconnected
</Button>
) : (
<Button
size="sm"
variant="secondary"
disabled={true}
>
Not Connected
</Button>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<AlertTriangle className="w-8 h-8 mx-auto mb-2 text-gray-400" />
<p className="text-sm">No toolkits found</p>
<p className="text-xs mt-1">Connect toolkits from the workflow editor or triggers to manage them here</p>
</div>
)}
</div>
</Section>
{/* Disconnect Confirmation Modal */}
<ProjectWideChangeConfirmationModal
isOpen={showDisconnectModal}
onClose={() => {
setShowDisconnectModal(false);
setSelectedToolkit(null);
}}
onConfirm={handleConfirmDisconnect}
title={`Disconnect ${selectedToolkit?.name || 'Toolkit'}`}
confirmationQuestion={`Are you sure you want to disconnect the ${selectedToolkit?.name || 'toolkit'}? This will permanently remove all its tools, triggers, and connections. Your workflows may stop working properly if they depend on this toolkit.`}
confirmButtonText="Disconnect"
isLoading={disconnectingToolkit !== null}
/>
</>
);
}
function DeleteProjectSection({ projectId }: { projectId: string }) { function DeleteProjectSection({ projectId }: { projectId: string }) {
const [loadingInitial, setLoadingInitial] = useState(false); const [loadingInitial, setLoadingInitial] = useState(false);
@ -428,7 +695,7 @@ function DeleteProjectSection({ projectId }: { projectId: string }) {
useEffect(() => { useEffect(() => {
setLoadingInitial(true); setLoadingInitial(true);
getProjectConfig(projectId).then((project) => { fetchProject(projectId).then((project) => {
setProjectName(project.name); setProjectName(project.name);
setLoadingInitial(false); setLoadingInitial(false);
}); });
@ -534,11 +801,25 @@ export function ProjectSection({
}) { }) {
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<ProjectNameSection projectId={projectId} />
<ProjectIdSection projectId={projectId} /> <ProjectIdSection projectId={projectId} />
<SecretSection projectId={projectId} />
<ApiKeysSection projectId={projectId} /> <ApiKeysSection projectId={projectId} />
{useChatWidget && <ChatWidgetSection projectId={projectId} chatWidgetHost={chatWidgetHost} />} {/*{useChatWidget && <ChatWidgetSection projectId={projectId} chatWidgetHost={chatWidgetHost} />}*/}
</div>
);
}
export function SimpleProjectSection({
projectId,
onProjectConfigUpdated,
}: {
projectId: string;
onProjectConfigUpdated?: () => void;
}) {
return (
<div className="p-6 space-y-6">
<ProjectNameSection projectId={projectId} onProjectConfigUpdated={onProjectConfigUpdated} />
<SecretSection projectId={projectId} />
<DisconnectToolkitsSection projectId={projectId} onProjectConfigUpdated={onProjectConfigUpdated} />
<DeleteProjectSection projectId={projectId} /> <DeleteProjectSection projectId={projectId} />
</div> </div>
); );

View file

@ -4,8 +4,8 @@ import { useState, useEffect, useCallback } from 'react';
import { Spinner } from "@heroui/react"; import { Spinner } from "@heroui/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { configureTwilioNumber, getTwilioConfigs, deleteTwilioConfig } from "../../../../actions/voice_actions"; import { configureTwilioNumber, getTwilioConfigs, deleteTwilioConfig } from "../../../../actions/twilio.actions";
import { TwilioConfig } from "../../../../lib/types/voice_types"; import { TwilioConfig, TwilioConfigParams } from "../../../../lib/types/voice_types";
import { CheckCircleIcon, XCircleIcon, InfoIcon, EyeOffIcon, EyeIcon } from "lucide-react"; import { CheckCircleIcon, XCircleIcon, InfoIcon, EyeOffIcon, EyeIcon } from "lucide-react";
import { Section } from './project'; import { Section } from './project';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
@ -198,23 +198,15 @@ export function VoiceSection({ projectId }: { projectId: string }) {
return; return;
} }
const workflowId = localStorage.getItem(`lastWorkflowId_${projectId}`);
if (!workflowId) {
setError('No workflow selected. Please select a workflow first.');
setConfigurationValid(false);
return;
}
setLoading(true); setLoading(true);
setError(null); setError(null);
const configParams = { const configParams: z.infer<typeof TwilioConfigParams> = {
phone_number: formState.phone.replaceAll(/[^0-9\+]/g, ''), phone_number: formState.phone.replaceAll(/[^0-9\+]/g, ''),
account_sid: formState.accountSid, account_sid: formState.accountSid,
auth_token: formState.authToken, auth_token: formState.authToken,
label: formState.label, label: formState.label,
project_id: projectId, project_id: projectId,
workflow_id: workflowId,
}; };
const result = await configureTwilioNumber(configParams); const result = await configureTwilioNumber(configParams);
@ -310,7 +302,7 @@ export function VoiceSection({ projectId }: { projectId: string }) {
> >
{existingConfig ? 'Update Twilio Config' : 'Import from Twilio'} {existingConfig ? 'Update Twilio Config' : 'Import from Twilio'}
</Button> </Button>
{existingConfig ? ( {existingConfig && (
<Button <Button
variant="primary" variant="primary"
color="red" color="red"
@ -320,24 +312,6 @@ export function VoiceSection({ projectId }: { projectId: string }) {
> >
Delete Configuration Delete Configuration
</Button> </Button>
) : (
<Button
variant="tertiary"
size="sm"
onClick={() => {
setFormState({
phone: '',
accountSid: '',
authToken: '',
label: ''
});
setError(null);
setIsDirty(false);
}}
disabled={loading}
>
Cancel
</Button>
)} )}
</div> </div>
</div> </div>

View file

@ -1,10 +1,9 @@
import { Metadata } from "next"; import { Metadata } from "next";
import App from "./app"; import { SimpleConfigApp } from "./app";
import { USE_CHAT_WIDGET } from "@/app/lib/feature_flags";
import { requireActiveBillingSubscription } from '@/app/lib/billing'; import { requireActiveBillingSubscription } from '@/app/lib/billing';
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Project config", title: "Project Settings",
}; };
export default async function Page( export default async function Page(
@ -16,9 +15,7 @@ export default async function Page(
) { ) {
const params = await props.params; const params = await props.params;
await requireActiveBillingSubscription(); await requireActiveBillingSubscription();
return <App return <SimpleConfigApp
projectId={params.projectId} projectId={params.projectId}
useChatWidget={USE_CHAT_WIDGET}
chatWidgetHost={process.env.CHAT_WIDGET_HOST || ''}
/>; />;
} }

View file

@ -0,0 +1,19 @@
import { Metadata } from "next";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
import { ConversationView } from "../components/conversation-view";
export const metadata: Metadata = {
title: "Conversation",
};
export default async function Page(
props: {
params: Promise<{ projectId: string, conversationId: string }>
}
) {
const params = await props.params;
await requireActiveBillingSubscription();
return <ConversationView projectId={params.projectId} conversationId={params.conversationId} />;
}

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