mirror of https://github.com/kortix-ai/suna.git
Merge branch 'main' into sharath/suna-254-agent-start-fails-failed-to-initialize-sandbox-404-no
This commit is contained in:
commit
3f8681065b
|
@ -83,6 +83,104 @@ RABBITMQ_PORT=5672
|
|||
|
||||
---
|
||||
|
||||
## Feature Flags
|
||||
|
||||
The backend includes a Redis-backed feature flag system that allows you to control feature availability without code deployments.
|
||||
|
||||
### Setup
|
||||
|
||||
The feature flag system uses the existing Redis service and is automatically available when Redis is running.
|
||||
|
||||
### CLI Management
|
||||
|
||||
Use the CLI tool to manage feature flags:
|
||||
|
||||
```bash
|
||||
cd backend/flags
|
||||
python setup.py <command> [arguments]
|
||||
```
|
||||
|
||||
#### Available Commands
|
||||
|
||||
**Enable a feature flag:**
|
||||
```bash
|
||||
python setup.py enable test_flag "Test decsription"
|
||||
```
|
||||
|
||||
**Disable a feature flag:**
|
||||
```bash
|
||||
python setup.py disable test_flag
|
||||
```
|
||||
|
||||
**List all feature flags:**
|
||||
```bash
|
||||
python setup.py list
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
Feature flags are accessible via REST API:
|
||||
|
||||
**Get all feature flags:**
|
||||
```bash
|
||||
GET /feature-flags
|
||||
```
|
||||
|
||||
**Get specific feature flag:**
|
||||
```bash
|
||||
GET /feature-flags/{flag_name}
|
||||
```
|
||||
|
||||
Example response:
|
||||
```json
|
||||
{
|
||||
"test_flag": {
|
||||
"enabled": true,
|
||||
"description": "Test flag",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Backend Integration
|
||||
|
||||
Use feature flags in your Python code:
|
||||
|
||||
```python
|
||||
from flags.flags import is_enabled
|
||||
|
||||
# Check if a feature is enabled
|
||||
if await is_enabled('test_flag'):
|
||||
# Feature-specific logic
|
||||
pass
|
||||
|
||||
# With fallback value
|
||||
enabled = await is_enabled('new_feature', default=False)
|
||||
```
|
||||
|
||||
### Current Feature Flags
|
||||
|
||||
The system currently supports these feature flags:
|
||||
|
||||
- **`custom_agents`**: Controls custom agent creation and management
|
||||
- **`agent_marketplace`**: Controls agent marketplace functionality
|
||||
|
||||
### Error Handling
|
||||
|
||||
The feature flag system includes robust error handling:
|
||||
|
||||
- If Redis is unavailable, flags default to `False`
|
||||
- API endpoints return empty objects on Redis errors
|
||||
- CLI operations show clear error messages
|
||||
|
||||
### Caching
|
||||
|
||||
- Backend operations are direct Redis calls (no caching)
|
||||
- Frontend includes 5-minute caching for performance
|
||||
- Use `clearCache()` in frontend to force refresh
|
||||
|
||||
---
|
||||
|
||||
## Production Setup
|
||||
|
||||
For production deployments, use the following command to set resource limits
|
||||
|
|
|
@ -22,6 +22,7 @@ from sandbox.sandbox import create_sandbox, delete_sandbox, get_or_start_sandbox
|
|||
from services.llm import make_llm_api_call
|
||||
from run_agent_background import run_agent_background, _cleanup_redis_response_list, update_agent_run_status
|
||||
from utils.constants import MODEL_NAME_ALIASES
|
||||
from flags.flags import is_enabled
|
||||
|
||||
# Initialize shared resources
|
||||
router = APIRouter()
|
||||
|
@ -1059,6 +1060,12 @@ async def initiate_agent_with_files(
|
|||
# TODO: Clean up created project/thread if initiation fails mid-way
|
||||
raise HTTPException(status_code=500, detail=f"Failed to initiate agent session: {str(e)}")
|
||||
|
||||
|
||||
|
||||
# Custom agents
|
||||
|
||||
|
||||
|
||||
@router.get("/agents", response_model=AgentsResponse)
|
||||
async def get_agents(
|
||||
user_id: str = Depends(get_current_user_id_from_jwt),
|
||||
|
@ -1073,6 +1080,11 @@ async def get_agents(
|
|||
tools: Optional[str] = Query(None, description="Comma-separated list of tools to filter by")
|
||||
):
|
||||
"""Get agents for the current user with pagination, search, sort, and filter support."""
|
||||
if not await is_enabled("custom_agents"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agents currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
logger.info(f"Fetching agents for user: {user_id} with page={page}, limit={limit}, search='{search}', sort_by={sort_by}, sort_order={sort_order}")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1234,6 +1246,12 @@ async def get_agents(
|
|||
@router.get("/agents/{agent_id}", response_model=AgentResponse)
|
||||
async def get_agent(agent_id: str, user_id: str = Depends(get_current_user_id_from_jwt)):
|
||||
"""Get a specific agent by ID. Only the owner can access non-public agents."""
|
||||
if not await is_enabled("custom_agents"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agents currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
|
||||
logger.info(f"Fetching agent {agent_id} for user: {user_id}")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1283,6 +1301,11 @@ async def create_agent(
|
|||
):
|
||||
"""Create a new agent."""
|
||||
logger.info(f"Creating new agent for user: {user_id}")
|
||||
if not await is_enabled("custom_agents"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agents currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
client = await db.client
|
||||
|
||||
try:
|
||||
|
@ -1350,6 +1373,11 @@ async def update_agent(
|
|||
user_id: str = Depends(get_current_user_id_from_jwt)
|
||||
):
|
||||
"""Update an existing agent."""
|
||||
if not await is_enabled("custom_agents"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agent currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
logger.info(f"Updating agent {agent_id} for user: {user_id}")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1441,6 +1469,11 @@ async def update_agent(
|
|||
@router.delete("/agents/{agent_id}")
|
||||
async def delete_agent(agent_id: str, user_id: str = Depends(get_current_user_id_from_jwt)):
|
||||
"""Delete an agent."""
|
||||
if not await is_enabled("custom_agents"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agent currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
logger.info(f"Deleting agent: {agent_id}")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1503,6 +1536,12 @@ async def get_marketplace_agents(
|
|||
creator: Optional[str] = Query(None, description="Filter by creator name")
|
||||
):
|
||||
"""Get public agents from the marketplace with pagination, search, sort, and filter support."""
|
||||
if not await is_enabled("agent_marketplace"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agent currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
|
||||
logger.info(f"Fetching marketplace agents with page={page}, limit={limit}, search='{search}', tags='{tags}', sort_by={sort_by}")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1569,6 +1608,12 @@ async def publish_agent_to_marketplace(
|
|||
user_id: str = Depends(get_current_user_id_from_jwt)
|
||||
):
|
||||
"""Publish an agent to the marketplace."""
|
||||
if not await is_enabled("agent_marketplace"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agent currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
|
||||
logger.info(f"Publishing agent {agent_id} to marketplace")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1608,6 +1653,12 @@ async def unpublish_agent_from_marketplace(
|
|||
user_id: str = Depends(get_current_user_id_from_jwt)
|
||||
):
|
||||
"""Unpublish an agent from the marketplace."""
|
||||
if not await is_enabled("agent_marketplace"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agent currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
|
||||
logger.info(f"Unpublishing agent {agent_id} from marketplace")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1642,6 +1693,12 @@ async def add_agent_to_library(
|
|||
user_id: str = Depends(get_current_user_id_from_jwt)
|
||||
):
|
||||
"""Add an agent from the marketplace to user's library."""
|
||||
if not await is_enabled("agent_marketplace"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agent currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
|
||||
logger.info(f"Adding marketplace agent {agent_id} to user {user_id} library")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1673,6 +1730,12 @@ async def add_agent_to_library(
|
|||
@router.get("/user/agent-library")
|
||||
async def get_user_agent_library(user_id: str = Depends(get_current_user_id_from_jwt)):
|
||||
"""Get user's agent library (agents added from marketplace)."""
|
||||
if not await is_enabled("agent_marketplace"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agent currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
|
||||
logger.info(f"Fetching agent library for user {user_id}")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1706,6 +1769,12 @@ async def get_agent_builder_chat_history(
|
|||
user_id: str = Depends(get_current_user_id_from_jwt)
|
||||
):
|
||||
"""Get chat history for agent builder sessions for a specific agent."""
|
||||
if not await is_enabled("custom_agents"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agents currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
|
||||
logger.info(f"Fetching agent builder chat history for agent: {agent_id}")
|
||||
client = await db.client
|
||||
|
||||
|
|
|
@ -543,13 +543,16 @@ For casual conversation and social interactions:
|
|||
## 7.3 ATTACHMENT PROTOCOL
|
||||
- **CRITICAL: ALL VISUALIZATIONS MUST BE ATTACHED:**
|
||||
* When using the 'ask' tool <ask attachments="file1, file2, file3"></ask>, ALWAYS attach ALL visualizations, markdown files, charts, graphs, reports, and any viewable content created
|
||||
* **MANDATORY RULE: If you have created ANY files during this conversation, you MUST include them as attachments when using the ask tool**
|
||||
* This includes but is not limited to: HTML files, PDF documents, markdown files, images, data visualizations, presentations, reports, dashboards, and UI mockups
|
||||
* **NEVER use the ask tool without attachments if you have created files** - this is a critical error
|
||||
* NEVER mention a visualization or viewable content without attaching it
|
||||
* If you've created multiple visualizations, attach ALL of them
|
||||
* Always make visualizations available to the user BEFORE marking tasks as complete
|
||||
* For web applications or interactive content, always attach the main HTML file
|
||||
* When creating data analysis results, charts must be attached, not just described
|
||||
* Remember: If the user should SEE it, you must ATTACH it with the 'ask' tool
|
||||
* **EXAMPLE: If you create files like main.py, README.md, config.json, notes.txt, you MUST use: <ask attachments="main.py,README.md,config.json,notes.txt">**
|
||||
* Verify that ALL visual outputs have been attached before proceeding
|
||||
|
||||
- **Attachment Checklist:**
|
||||
|
@ -562,7 +565,7 @@ For casual conversation and social interactions:
|
|||
* Analysis results with visual components
|
||||
* UI designs and mockups
|
||||
* Any file intended for user viewing or interaction
|
||||
|
||||
* **ANY FILES CREATED DURING THE CONVERSATION - ALWAYS ATTACH THEM**
|
||||
|
||||
# 8. COMPLETION PROTOCOLS
|
||||
|
||||
|
|
|
@ -290,7 +290,9 @@ async def run_agent(
|
|||
|
||||
latest_user_message = await client.table('messages').select('*').eq('thread_id', thread_id).eq('type', 'user').order('created_at', desc=True).limit(1).execute()
|
||||
if latest_user_message.data and len(latest_user_message.data) > 0:
|
||||
data = json.loads(latest_user_message.data[0]['content'])
|
||||
data = latest_user_message.data[0]['content']
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
trace.update(input=data['content'])
|
||||
|
||||
while continue_execution and iteration_count < max_iterations:
|
||||
|
@ -327,14 +329,16 @@ async def run_agent(
|
|||
latest_browser_state_msg = await client.table('messages').select('*').eq('thread_id', thread_id).eq('type', 'browser_state').order('created_at', desc=True).limit(1).execute()
|
||||
if latest_browser_state_msg.data and len(latest_browser_state_msg.data) > 0:
|
||||
try:
|
||||
browser_content = json.loads(latest_browser_state_msg.data[0]["content"])
|
||||
browser_content = latest_browser_state_msg.data[0]["content"]
|
||||
if isinstance(browser_content, str):
|
||||
browser_content = json.loads(browser_content)
|
||||
screenshot_base64 = browser_content.get("screenshot_base64")
|
||||
screenshot_url = browser_content.get("screenshot_url")
|
||||
screenshot_url = browser_content.get("image_url")
|
||||
|
||||
# Create a copy of the browser state without screenshot data
|
||||
browser_state_text = browser_content.copy()
|
||||
browser_state_text.pop('screenshot_base64', None)
|
||||
browser_state_text.pop('screenshot_url', None)
|
||||
browser_state_text.pop('image_url', None)
|
||||
|
||||
if browser_state_text:
|
||||
temp_message_content_list.append({
|
||||
|
@ -348,6 +352,7 @@ async def run_agent(
|
|||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": screenshot_url,
|
||||
"format": "image/jpeg"
|
||||
}
|
||||
})
|
||||
elif screenshot_base64:
|
||||
|
@ -369,7 +374,7 @@ async def run_agent(
|
|||
latest_image_context_msg = await client.table('messages').select('*').eq('thread_id', thread_id).eq('type', 'image_context').order('created_at', desc=True).limit(1).execute()
|
||||
if latest_image_context_msg.data and len(latest_image_context_msg.data) > 0:
|
||||
try:
|
||||
image_context_content = json.loads(latest_image_context_msg.data[0]["content"])
|
||||
image_context_content = latest_image_context_msg.data[0]["content"] if isinstance(latest_image_context_msg.data[0]["content"], dict) else json.loads(latest_image_context_msg.data[0]["content"])
|
||||
base64_image = image_context_content.get("base64")
|
||||
mime_type = image_context_content.get("mime_type")
|
||||
file_path = image_context_content.get("file_path", "unknown file")
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from typing import List, Optional, Union
|
||||
from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema
|
||||
from utils.logger import logger
|
||||
|
||||
class MessageTool(Tool):
|
||||
"""Tool for user communication and interaction.
|
||||
|
@ -68,11 +69,11 @@ This information will help me make sure the cake meets your expectations for the
|
|||
Returns:
|
||||
ToolResult indicating the question was successfully sent
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
# Convert single attachment to list for consistent handling
|
||||
if attachments and isinstance(attachments, str):
|
||||
attachments = [attachments]
|
||||
|
||||
|
||||
return self.success_response({"status": "Awaiting user response..."})
|
||||
except Exception as e:
|
||||
return self.fail_response(f"Error asking user: {str(e)}")
|
||||
|
|
|
@ -107,7 +107,8 @@ class SandboxDeployTool(SandboxToolsBase):
|
|||
npx wrangler pages deploy {full_path} --project-name {project_name}))'''
|
||||
|
||||
# Execute the command directly using the sandbox's process.exec method
|
||||
response = self.sandbox.process.exec(deploy_cmd, timeout=300)
|
||||
response = self.sandbox.process.exec(f"/bin/sh -c \"{deploy_cmd}\"",
|
||||
timeout=300)
|
||||
|
||||
print(f"Deployment command output: {response.result}")
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@ This module handles the processing of LLM responses, including:
|
|||
- XML and native tool call detection and parsing
|
||||
- Tool execution orchestration
|
||||
- Message formatting and persistence
|
||||
- Cost calculation and tracking
|
||||
"""
|
||||
|
||||
import json
|
||||
|
@ -20,13 +19,13 @@ from utils.logger import logger
|
|||
from agentpress.tool import ToolResult
|
||||
from agentpress.tool_registry import ToolRegistry
|
||||
from agentpress.xml_tool_parser import XMLToolParser
|
||||
from litellm import completion_cost
|
||||
from langfuse.client import StatefulTraceClient
|
||||
from services.langfuse import langfuse
|
||||
from agentpress.utils.json_helpers import (
|
||||
ensure_dict, ensure_list, safe_json_parse,
|
||||
to_json_string, format_for_yield
|
||||
)
|
||||
from litellm import token_counter
|
||||
|
||||
# Type alias for XML result adding strategy
|
||||
XmlAddingStrategy = Literal["user_message", "assistant_message", "inline_edit"]
|
||||
|
@ -146,6 +145,21 @@ class ResponseProcessor:
|
|||
tool_result_message_objects = {} # tool_index -> full saved message object
|
||||
has_printed_thinking_prefix = False # Flag for printing thinking prefix only once
|
||||
agent_should_terminate = False # Flag to track if a terminating tool has been executed
|
||||
complete_native_tool_calls = [] # Initialize early for use in assistant_response_end
|
||||
|
||||
# Collect metadata for reconstructing LiteLLM response object
|
||||
streaming_metadata = {
|
||||
"model": llm_model,
|
||||
"created": None,
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0
|
||||
},
|
||||
"response_ms": None,
|
||||
"first_chunk_time": None,
|
||||
"last_chunk_time": None
|
||||
}
|
||||
|
||||
logger.info(f"Streaming Config: XML={config.xml_tool_calling}, Native={config.native_tool_calling}, "
|
||||
f"Execute on stream={config.execute_on_stream}, Strategy={config.tool_execution_strategy}")
|
||||
|
@ -172,6 +186,26 @@ class ResponseProcessor:
|
|||
__sequence = 0
|
||||
|
||||
async for chunk in llm_response:
|
||||
# Extract streaming metadata from chunks
|
||||
current_time = datetime.now(timezone.utc).timestamp()
|
||||
if streaming_metadata["first_chunk_time"] is None:
|
||||
streaming_metadata["first_chunk_time"] = current_time
|
||||
streaming_metadata["last_chunk_time"] = current_time
|
||||
|
||||
# Extract metadata from chunk attributes
|
||||
if hasattr(chunk, 'created') and chunk.created:
|
||||
streaming_metadata["created"] = chunk.created
|
||||
if hasattr(chunk, 'model') and chunk.model:
|
||||
streaming_metadata["model"] = chunk.model
|
||||
if hasattr(chunk, 'usage') and chunk.usage:
|
||||
# Update usage information if available (including zero values)
|
||||
if hasattr(chunk.usage, 'prompt_tokens') and chunk.usage.prompt_tokens is not None:
|
||||
streaming_metadata["usage"]["prompt_tokens"] = chunk.usage.prompt_tokens
|
||||
if hasattr(chunk.usage, 'completion_tokens') and chunk.usage.completion_tokens is not None:
|
||||
streaming_metadata["usage"]["completion_tokens"] = chunk.usage.completion_tokens
|
||||
if hasattr(chunk.usage, 'total_tokens') and chunk.usage.total_tokens is not None:
|
||||
streaming_metadata["usage"]["total_tokens"] = chunk.usage.total_tokens
|
||||
|
||||
if hasattr(chunk, 'choices') and chunk.choices and hasattr(chunk.choices[0], 'finish_reason') and chunk.choices[0].finish_reason:
|
||||
finish_reason = chunk.choices[0].finish_reason
|
||||
logger.debug(f"Detected finish_reason: {finish_reason}")
|
||||
|
@ -317,6 +351,38 @@ class ResponseProcessor:
|
|||
# print() # Add a final newline after the streaming loop finishes
|
||||
|
||||
# --- After Streaming Loop ---
|
||||
|
||||
if (
|
||||
streaming_metadata["usage"]["total_tokens"] == 0
|
||||
):
|
||||
logger.info("🔥 No usage data from provider, counting with litellm.token_counter")
|
||||
|
||||
try:
|
||||
# prompt side
|
||||
prompt_tokens = token_counter(
|
||||
model=llm_model,
|
||||
messages=prompt_messages # chat or plain; token_counter handles both
|
||||
)
|
||||
|
||||
# completion side
|
||||
completion_tokens = token_counter(
|
||||
model=llm_model,
|
||||
text=accumulated_content or "" # empty string safe
|
||||
)
|
||||
|
||||
streaming_metadata["usage"]["prompt_tokens"] = prompt_tokens
|
||||
streaming_metadata["usage"]["completion_tokens"] = completion_tokens
|
||||
streaming_metadata["usage"]["total_tokens"] = prompt_tokens + completion_tokens
|
||||
|
||||
logger.info(
|
||||
f"🔥 Estimated tokens – prompt: {prompt_tokens}, "
|
||||
f"completion: {completion_tokens}, total: {prompt_tokens + completion_tokens}"
|
||||
)
|
||||
self.trace.event(name="usage_calculated_with_litellm_token_counter", level="DEFAULT", status_message=(f"Usage calculated with litellm.token_counter"))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to calculate usage: {str(e)}")
|
||||
self.trace.event(name="failed_to_calculate_usage", level="WARNING", status_message=(f"Failed to calculate usage: {str(e)}"))
|
||||
|
||||
|
||||
# Wait for pending tool executions from streaming phase
|
||||
tool_results_buffer = [] # Stores (tool_call, result, tool_index, context)
|
||||
|
@ -409,7 +475,7 @@ class ResponseProcessor:
|
|||
accumulated_content = accumulated_content[:last_chunk_end_pos]
|
||||
|
||||
# ... (Extract complete_native_tool_calls logic) ...
|
||||
complete_native_tool_calls = []
|
||||
# Update complete_native_tool_calls from buffer (initialized earlier)
|
||||
if config.native_tool_calling:
|
||||
for idx, tc_buf in tool_calls_buffer.items():
|
||||
if tc_buf['id'] and tc_buf['function']['name'] and tc_buf['function']['arguments']:
|
||||
|
@ -575,34 +641,6 @@ class ResponseProcessor:
|
|||
self.trace.event(name="failed_to_save_tool_result_for_index", level="ERROR", status_message=(f"Failed to save tool result for index {tool_idx}, not yielding result message."))
|
||||
# Optionally yield error status for saving failure?
|
||||
|
||||
# --- Calculate and Store Cost ---
|
||||
if last_assistant_message_object: # Only calculate if assistant message was saved
|
||||
try:
|
||||
# Use accumulated_content for streaming cost calculation
|
||||
final_cost = completion_cost(
|
||||
model=llm_model,
|
||||
messages=prompt_messages, # Use the prompt messages provided
|
||||
completion=accumulated_content
|
||||
)
|
||||
if final_cost is not None and final_cost > 0:
|
||||
logger.info(f"Calculated final cost for stream: {final_cost}")
|
||||
await self.add_message(
|
||||
thread_id=thread_id,
|
||||
type="cost",
|
||||
content={"cost": final_cost},
|
||||
is_llm_message=False, # Cost is metadata
|
||||
metadata={"thread_run_id": thread_run_id} # Keep track of the run
|
||||
)
|
||||
logger.info(f"Cost message saved for stream: {final_cost}")
|
||||
self.trace.update(metadata={"cost": final_cost})
|
||||
else:
|
||||
logger.info("Stream cost calculation resulted in zero or None, not storing cost message.")
|
||||
self.trace.update(metadata={"cost": 0})
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating final cost for stream: {str(e)}")
|
||||
self.trace.event(name="error_calculating_final_cost_for_stream", level="ERROR", status_message=(f"Error calculating final cost for stream: {str(e)}"))
|
||||
|
||||
|
||||
# --- Final Finish Status ---
|
||||
if finish_reason and finish_reason != "xml_tool_limit_reached":
|
||||
finish_content = {"status_type": "finish", "finish_reason": finish_reason}
|
||||
|
@ -628,9 +666,107 @@ class ResponseProcessor:
|
|||
)
|
||||
if finish_msg_obj: yield format_for_yield(finish_msg_obj)
|
||||
|
||||
# Save assistant_response_end BEFORE terminating
|
||||
if last_assistant_message_object:
|
||||
try:
|
||||
# Calculate response time if we have timing data
|
||||
if streaming_metadata["first_chunk_time"] and streaming_metadata["last_chunk_time"]:
|
||||
streaming_metadata["response_ms"] = (streaming_metadata["last_chunk_time"] - streaming_metadata["first_chunk_time"]) * 1000
|
||||
|
||||
# Create a LiteLLM-like response object for streaming (before termination)
|
||||
# Check if we have any actual usage data
|
||||
has_usage_data = (
|
||||
streaming_metadata["usage"]["prompt_tokens"] > 0 or
|
||||
streaming_metadata["usage"]["completion_tokens"] > 0 or
|
||||
streaming_metadata["usage"]["total_tokens"] > 0
|
||||
)
|
||||
|
||||
assistant_end_content = {
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": finish_reason or "stop",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": accumulated_content,
|
||||
"tool_calls": complete_native_tool_calls or None
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": streaming_metadata.get("created"),
|
||||
"model": streaming_metadata.get("model", llm_model),
|
||||
"usage": streaming_metadata["usage"], # Always include usage like LiteLLM does
|
||||
"streaming": True, # Add flag to indicate this was reconstructed from streaming
|
||||
}
|
||||
|
||||
# Only include response_ms if we have timing data
|
||||
if streaming_metadata.get("response_ms"):
|
||||
assistant_end_content["response_ms"] = streaming_metadata["response_ms"]
|
||||
|
||||
await self.add_message(
|
||||
thread_id=thread_id,
|
||||
type="assistant_response_end",
|
||||
content=assistant_end_content,
|
||||
is_llm_message=False,
|
||||
metadata={"thread_run_id": thread_run_id}
|
||||
)
|
||||
logger.info("Assistant response end saved for stream (before termination)")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving assistant response end for stream (before termination): {str(e)}")
|
||||
self.trace.event(name="error_saving_assistant_response_end_for_stream_before_termination", level="ERROR", status_message=(f"Error saving assistant response end for stream (before termination): {str(e)}"))
|
||||
|
||||
# Skip all remaining processing and go to finally block
|
||||
return
|
||||
|
||||
# --- Save and Yield assistant_response_end ---
|
||||
if last_assistant_message_object: # Only save if assistant message was saved
|
||||
try:
|
||||
# Calculate response time if we have timing data
|
||||
if streaming_metadata["first_chunk_time"] and streaming_metadata["last_chunk_time"]:
|
||||
streaming_metadata["response_ms"] = (streaming_metadata["last_chunk_time"] - streaming_metadata["first_chunk_time"]) * 1000
|
||||
|
||||
# Create a LiteLLM-like response object for streaming
|
||||
# Check if we have any actual usage data
|
||||
has_usage_data = (
|
||||
streaming_metadata["usage"]["prompt_tokens"] > 0 or
|
||||
streaming_metadata["usage"]["completion_tokens"] > 0 or
|
||||
streaming_metadata["usage"]["total_tokens"] > 0
|
||||
)
|
||||
|
||||
assistant_end_content = {
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": finish_reason or "stop",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": accumulated_content,
|
||||
"tool_calls": complete_native_tool_calls or None
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": streaming_metadata.get("created"),
|
||||
"model": streaming_metadata.get("model", llm_model),
|
||||
"usage": streaming_metadata["usage"], # Always include usage like LiteLLM does
|
||||
"streaming": True, # Add flag to indicate this was reconstructed from streaming
|
||||
}
|
||||
|
||||
# Only include response_ms if we have timing data
|
||||
if streaming_metadata.get("response_ms"):
|
||||
assistant_end_content["response_ms"] = streaming_metadata["response_ms"]
|
||||
|
||||
await self.add_message(
|
||||
thread_id=thread_id,
|
||||
type="assistant_response_end",
|
||||
content=assistant_end_content,
|
||||
is_llm_message=False,
|
||||
metadata={"thread_run_id": thread_run_id}
|
||||
)
|
||||
logger.info("Assistant response end saved for stream")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving assistant response end for stream: {str(e)}")
|
||||
self.trace.event(name="error_saving_assistant_response_end_for_stream", level="ERROR", status_message=(f"Error saving assistant response end for stream: {str(e)}"))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing stream: {str(e)}", exc_info=True)
|
||||
self.trace.event(name="error_processing_stream", level="ERROR", status_message=(f"Error processing stream: {str(e)}"))
|
||||
|
@ -759,43 +895,7 @@ class ResponseProcessor:
|
|||
)
|
||||
if err_msg_obj: yield format_for_yield(err_msg_obj)
|
||||
|
||||
# --- Calculate and Store Cost ---
|
||||
if assistant_message_object: # Only calculate if assistant message was saved
|
||||
try:
|
||||
# Use the full llm_response object for potentially more accurate cost calculation
|
||||
final_cost = None
|
||||
if hasattr(llm_response, '_hidden_params') and 'response_cost' in llm_response._hidden_params and llm_response._hidden_params['response_cost'] is not None and llm_response._hidden_params['response_cost'] != 0.0:
|
||||
final_cost = llm_response._hidden_params['response_cost']
|
||||
logger.info(f"Using response_cost from _hidden_params: {final_cost}")
|
||||
|
||||
if final_cost is None: # Fall back to calculating cost if direct cost not available or zero
|
||||
logger.info("Calculating cost using completion_cost function.")
|
||||
# Note: litellm might need 'messages' kwarg depending on model/provider
|
||||
final_cost = completion_cost(
|
||||
completion_response=llm_response,
|
||||
model=llm_model, # Explicitly pass the model name
|
||||
# messages=prompt_messages # Pass prompt messages if needed by litellm for this model
|
||||
)
|
||||
|
||||
if final_cost is not None and final_cost > 0:
|
||||
logger.info(f"Calculated final cost for non-stream: {final_cost}")
|
||||
await self.add_message(
|
||||
thread_id=thread_id,
|
||||
type="cost",
|
||||
content={"cost": final_cost},
|
||||
is_llm_message=False, # Cost is metadata
|
||||
metadata={"thread_run_id": thread_run_id} # Keep track of the run
|
||||
)
|
||||
logger.info(f"Cost message saved for non-stream: {final_cost}")
|
||||
self.trace.update(metadata={"cost": final_cost})
|
||||
else:
|
||||
logger.info("Non-stream cost calculation resulted in zero or None, not storing cost message.")
|
||||
self.trace.update(metadata={"cost": 0})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating final cost for non-stream: {str(e)}")
|
||||
self.trace.event(name="error_calculating_final_cost_for_non_stream", level="ERROR", status_message=(f"Error calculating final cost for non-stream: {str(e)}"))
|
||||
# --- Execute Tools and Yield Results ---
|
||||
# --- Execute Tools and Yield Results ---
|
||||
tool_calls_to_execute = [item['tool_call'] for item in all_tool_data]
|
||||
if config.execute_tools and tool_calls_to_execute:
|
||||
logger.info(f"Executing {len(tool_calls_to_execute)} tools with strategy: {config.tool_execution_strategy}")
|
||||
|
@ -850,6 +950,22 @@ class ResponseProcessor:
|
|||
)
|
||||
if finish_msg_obj: yield format_for_yield(finish_msg_obj)
|
||||
|
||||
# --- Save and Yield assistant_response_end ---
|
||||
if assistant_message_object: # Only save if assistant message was saved
|
||||
try:
|
||||
# Save the full LiteLLM response object directly in content
|
||||
await self.add_message(
|
||||
thread_id=thread_id,
|
||||
type="assistant_response_end",
|
||||
content=llm_response,
|
||||
is_llm_message=False,
|
||||
metadata={"thread_run_id": thread_run_id}
|
||||
)
|
||||
logger.info("Assistant response end saved for non-stream")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving assistant response end for non-stream: {str(e)}")
|
||||
self.trace.event(name="error_saving_assistant_response_end_for_non_stream", level="ERROR", status_message=(f"Error saving assistant response end for non-stream: {str(e)}"))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing non-streaming response: {str(e)}", exc_info=True)
|
||||
self.trace.event(name="error_processing_non_streaming_response", level="ERROR", status_message=(f"Error processing non-streaming response: {str(e)}"))
|
||||
|
@ -1162,7 +1278,7 @@ class ResponseProcessor:
|
|||
"arguments": params # The extracted parameters
|
||||
}
|
||||
|
||||
logger.debug(f"Parsed old format tool call: {tool_call}")
|
||||
# logger.debug(f"Parsed old format tool call: {tool_call["function_name"]}")
|
||||
return tool_call, parsing_details # Return both dicts
|
||||
|
||||
except Exception as e:
|
||||
|
@ -1619,7 +1735,7 @@ class ResponseProcessor:
|
|||
# return summary
|
||||
|
||||
summary_output = result.output if hasattr(result, 'output') else str(result)
|
||||
success_status = structured_result["tool_execution"]["result"]["success"]
|
||||
success_status = structured_result_v1["tool_execution"]["result"]["success"]
|
||||
|
||||
# Create a more comprehensive summary for the LLM
|
||||
if xml_tag_name:
|
||||
|
@ -1636,7 +1752,7 @@ class ResponseProcessor:
|
|||
return summary
|
||||
|
||||
else:
|
||||
return json.dumps(structured_result_v1)
|
||||
return structured_result_v1
|
||||
|
||||
def _format_xml_tool_result(self, tool_call: Dict[str, Any], result: ToolResult) -> str:
|
||||
"""Format a tool result wrapped in a <tool_result> tag.
|
||||
|
@ -1744,4 +1860,4 @@ class ResponseProcessor:
|
|||
saved_message_obj = await self.add_message(
|
||||
thread_id=thread_id, type="status", content=content, is_llm_message=False, metadata=metadata
|
||||
)
|
||||
return saved_message_obj
|
||||
return saved_message_obj
|
||||
|
|
|
@ -61,6 +61,20 @@ class ThreadManager:
|
|||
)
|
||||
self.context_manager = ContextManager()
|
||||
|
||||
def _is_tool_result_message(self, msg: Dict[str, Any]) -> bool:
|
||||
if not ("content" in msg and msg['content']):
|
||||
return False
|
||||
content = msg['content']
|
||||
if isinstance(content, str) and "ToolResult" in content: return True
|
||||
if isinstance(content, dict) and "tool_execution" in content: return True
|
||||
if isinstance(content, str):
|
||||
try:
|
||||
parsed_content = json.loads(content)
|
||||
if isinstance(parsed_content, dict) and "tool_execution" in parsed_content: return True
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return False
|
||||
|
||||
def add_tool(self, tool_class: Type[Tool], function_names: Optional[List[str]] = None, **kwargs):
|
||||
"""Add a tool to the ThreadManager."""
|
||||
self.tool_registry.register_tool(tool_class, function_names, **kwargs)
|
||||
|
@ -336,7 +350,7 @@ Here are the XML tools available with examples:
|
|||
if uncompressed_total_token_count > (llm_max_tokens or (100 * 1000)):
|
||||
_i = 0 # Count the number of ToolResult messages
|
||||
for msg in reversed(prepared_messages): # Start from the end and work backwards
|
||||
if "content" in msg and msg['content'] and "ToolResult" in msg['content']: # Only compress ToolResult messages
|
||||
if self._is_tool_result_message(msg): # Only compress ToolResult messages
|
||||
_i += 1 # Count the number of ToolResult messages
|
||||
msg_token_count = token_counter(messages=[msg]) # Count the number of tokens in the message
|
||||
if msg_token_count > 5000: # If the message is too long
|
||||
|
@ -412,6 +426,7 @@ Here are the XML tools available with examples:
|
|||
logger.error(f"Error in run_thread: {str(e)}", exc_info=True)
|
||||
# Return the error as a dict to be handled by the caller
|
||||
return {
|
||||
"type": "status",
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
|
|
@ -19,9 +19,12 @@ from pydantic import BaseModel
|
|||
from agent import api as agent_api
|
||||
from sandbox import api as sandbox_api
|
||||
from services import billing as billing_api
|
||||
from flags import api as feature_flags_api
|
||||
from services import transcription as transcription_api
|
||||
from services.mcp_custom import discover_custom_tools
|
||||
import sys
|
||||
from services import email_api
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
@ -130,12 +133,16 @@ app.include_router(sandbox_api.router, prefix="/api")
|
|||
|
||||
app.include_router(billing_api.router, prefix="/api")
|
||||
|
||||
app.include_router(feature_flags_api.router, prefix="/api")
|
||||
|
||||
from mcp_local import api as mcp_api
|
||||
|
||||
app.include_router(mcp_api.router, prefix="/api")
|
||||
|
||||
app.include_router(transcription_api.router, prefix="/api")
|
||||
|
||||
app.include_router(email_api.router, prefix="/api")
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint to verify API is working."""
|
||||
|
|
|
@ -10,7 +10,7 @@ services:
|
|||
memory: 32G
|
||||
|
||||
worker:
|
||||
command: python -m dramatiq --processes 20 --threads 16 run_agent_background
|
||||
command: python -m dramatiq --processes 10 --threads 32 run_agent_background
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
from fastapi import APIRouter
|
||||
from utils.logger import logger
|
||||
from .flags import list_flags, is_enabled, get_flag_details
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/feature-flags")
|
||||
async def get_feature_flags():
|
||||
try:
|
||||
flags = await list_flags()
|
||||
return {"flags": flags}
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching feature flags: {str(e)}")
|
||||
return {"flags": {}}
|
||||
|
||||
@router.get("/feature-flags/{flag_name}")
|
||||
async def get_feature_flag(flag_name: str):
|
||||
try:
|
||||
enabled = await is_enabled(flag_name)
|
||||
details = await get_flag_details(flag_name)
|
||||
return {
|
||||
"flag_name": flag_name,
|
||||
"enabled": enabled,
|
||||
"details": details
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching feature flag {flag_name}: {str(e)}")
|
||||
return {
|
||||
"flag_name": flag_name,
|
||||
"enabled": False,
|
||||
"details": None
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from services import redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FeatureFlagManager:
|
||||
def __init__(self):
|
||||
"""Initialize with existing Redis service"""
|
||||
self.flag_prefix = "feature_flag:"
|
||||
self.flag_list_key = "feature_flags:list"
|
||||
|
||||
async def set_flag(self, key: str, enabled: bool, description: str = "") -> bool:
|
||||
"""Set a feature flag to enabled or disabled"""
|
||||
try:
|
||||
flag_key = f"{self.flag_prefix}{key}"
|
||||
flag_data = {
|
||||
'enabled': str(enabled).lower(),
|
||||
'description': description,
|
||||
'updated_at': datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
# Use the existing Redis service
|
||||
redis_client = await redis.get_client()
|
||||
await redis_client.hset(flag_key, mapping=flag_data)
|
||||
await redis_client.sadd(self.flag_list_key, key)
|
||||
|
||||
logger.info(f"Set feature flag {key} to {enabled}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set feature flag {key}: {e}")
|
||||
return False
|
||||
|
||||
async def is_enabled(self, key: str) -> bool:
|
||||
"""Check if a feature flag is enabled"""
|
||||
try:
|
||||
flag_key = f"{self.flag_prefix}{key}"
|
||||
redis_client = await redis.get_client()
|
||||
enabled = await redis_client.hget(flag_key, 'enabled')
|
||||
return enabled == 'true' if enabled else False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check feature flag {key}: {e}")
|
||||
# Return False by default if Redis is unavailable
|
||||
return False
|
||||
|
||||
async def get_flag(self, key: str) -> Optional[Dict[str, str]]:
|
||||
"""Get feature flag details"""
|
||||
try:
|
||||
flag_key = f"{self.flag_prefix}{key}"
|
||||
redis_client = await redis.get_client()
|
||||
flag_data = await redis_client.hgetall(flag_key)
|
||||
return flag_data if flag_data else None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get feature flag {key}: {e}")
|
||||
return None
|
||||
|
||||
async def delete_flag(self, key: str) -> bool:
|
||||
"""Delete a feature flag"""
|
||||
try:
|
||||
flag_key = f"{self.flag_prefix}{key}"
|
||||
redis_client = await redis.get_client()
|
||||
deleted = await redis_client.delete(flag_key)
|
||||
if deleted:
|
||||
await redis_client.srem(self.flag_list_key, key)
|
||||
logger.info(f"Deleted feature flag: {key}")
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete feature flag {key}: {e}")
|
||||
return False
|
||||
|
||||
async def list_flags(self) -> Dict[str, bool]:
|
||||
"""List all feature flags with their status"""
|
||||
try:
|
||||
redis_client = await redis.get_client()
|
||||
flag_keys = await redis_client.smembers(self.flag_list_key)
|
||||
flags = {}
|
||||
|
||||
for key in flag_keys:
|
||||
flags[key] = await self.is_enabled(key)
|
||||
|
||||
return flags
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list feature flags: {e}")
|
||||
return {}
|
||||
|
||||
async def get_all_flags_details(self) -> Dict[str, Dict[str, str]]:
|
||||
"""Get all feature flags with detailed information"""
|
||||
try:
|
||||
redis_client = await redis.get_client()
|
||||
flag_keys = await redis_client.smembers(self.flag_list_key)
|
||||
flags = {}
|
||||
|
||||
for key in flag_keys:
|
||||
flag_data = await self.get_flag(key)
|
||||
if flag_data:
|
||||
flags[key] = flag_data
|
||||
|
||||
return flags
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get all flags details: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
_flag_manager: Optional[FeatureFlagManager] = None
|
||||
|
||||
|
||||
def get_flag_manager() -> FeatureFlagManager:
|
||||
"""Get the global feature flag manager instance"""
|
||||
global _flag_manager
|
||||
if _flag_manager is None:
|
||||
_flag_manager = FeatureFlagManager()
|
||||
return _flag_manager
|
||||
|
||||
|
||||
# Async convenience functions
|
||||
async def set_flag(key: str, enabled: bool, description: str = "") -> bool:
|
||||
return await get_flag_manager().set_flag(key, enabled, description)
|
||||
|
||||
|
||||
async def is_enabled(key: str) -> bool:
|
||||
return await get_flag_manager().is_enabled(key)
|
||||
|
||||
|
||||
async def enable_flag(key: str, description: str = "") -> bool:
|
||||
return await set_flag(key, True, description)
|
||||
|
||||
|
||||
async def disable_flag(key: str, description: str = "") -> bool:
|
||||
return await set_flag(key, False, description)
|
||||
|
||||
|
||||
async def delete_flag(key: str) -> bool:
|
||||
return await get_flag_manager().delete_flag(key)
|
||||
|
||||
|
||||
async def list_flags() -> Dict[str, bool]:
|
||||
return await get_flag_manager().list_flags()
|
||||
|
||||
|
||||
async def get_flag_details(key: str) -> Optional[Dict[str, str]]:
|
||||
return await get_flag_manager().get_flag(key)
|
||||
|
||||
|
||||
async def get_all_flags() -> Dict[str, Dict[str, str]]:
|
||||
"""Get all feature flags with detailed information"""
|
||||
return await get_flag_manager().get_all_flags_details()
|
|
@ -0,0 +1,166 @@
|
|||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import argparse
|
||||
import asyncio
|
||||
from flags import enable_flag, disable_flag, is_enabled, list_flags, delete_flag, get_flag_details
|
||||
|
||||
async def enable_command(flag_name: str, description: str = ""):
|
||||
"""Enable a feature flag"""
|
||||
if await enable_flag(flag_name, description):
|
||||
print(f"✓ Enabled flag: {flag_name}")
|
||||
if description:
|
||||
print(f" Description: {description}")
|
||||
else:
|
||||
print(f"✗ Failed to enable flag: {flag_name}")
|
||||
|
||||
|
||||
async def disable_command(flag_name: str, description: str = ""):
|
||||
"""Disable a feature flag"""
|
||||
if await disable_flag(flag_name, description):
|
||||
print(f"✓ Disabled flag: {flag_name}")
|
||||
if description:
|
||||
print(f" Description: {description}")
|
||||
else:
|
||||
print(f"✗ Failed to disable flag: {flag_name}")
|
||||
|
||||
|
||||
async def list_command():
|
||||
"""List all feature flags"""
|
||||
flags = await list_flags()
|
||||
|
||||
if not flags:
|
||||
print("No feature flags found.")
|
||||
return
|
||||
|
||||
print("Feature Flags:")
|
||||
print("-" * 50)
|
||||
|
||||
for flag_name, enabled in flags.items():
|
||||
details = await get_flag_details(flag_name)
|
||||
description = details.get('description', 'No description') if details else 'No description'
|
||||
updated_at = details.get('updated_at', 'Unknown') if details else 'Unknown'
|
||||
|
||||
status_icon = "✓" if enabled else "✗"
|
||||
status_text = "ENABLED" if enabled else "DISABLED"
|
||||
|
||||
print(f"{status_icon} {flag_name}: {status_text}")
|
||||
print(f" Description: {description}")
|
||||
print(f" Updated: {updated_at}")
|
||||
print()
|
||||
|
||||
|
||||
async def status_command(flag_name: str):
|
||||
"""Show status of a specific feature flag"""
|
||||
details = await get_flag_details(flag_name)
|
||||
|
||||
if not details:
|
||||
print(f"✗ Flag '{flag_name}' not found.")
|
||||
return
|
||||
|
||||
enabled = await is_enabled(flag_name)
|
||||
status_icon = "✓" if enabled else "✗"
|
||||
status_text = "ENABLED" if enabled else "DISABLED"
|
||||
|
||||
print(f"Flag: {flag_name}")
|
||||
print(f"Status: {status_icon} {status_text}")
|
||||
print(f"Description: {details.get('description', 'No description')}")
|
||||
print(f"Updated: {details.get('updated_at', 'Unknown')}")
|
||||
|
||||
|
||||
async def delete_command(flag_name: str):
|
||||
"""Delete a feature flag"""
|
||||
if not await get_flag_details(flag_name):
|
||||
print(f"✗ Flag '{flag_name}' not found.")
|
||||
return
|
||||
|
||||
confirm = input(f"Are you sure you want to delete flag '{flag_name}'? (y/N): ")
|
||||
if confirm.lower() in ['y', 'yes']:
|
||||
if await delete_flag(flag_name):
|
||||
print(f"✓ Deleted flag: {flag_name}")
|
||||
else:
|
||||
print(f"✗ Failed to delete flag: {flag_name}")
|
||||
else:
|
||||
print("Cancelled.")
|
||||
|
||||
|
||||
async def toggle_command(flag_name: str, description: str = ""):
|
||||
"""Toggle a feature flag"""
|
||||
current_status = await is_enabled(flag_name)
|
||||
|
||||
if current_status:
|
||||
await disable_command(flag_name, description)
|
||||
else:
|
||||
await enable_command(flag_name, description)
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Feature Flag Management Tool",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python setup.py enable new_ui "Enable new user interface"
|
||||
python setup.py disable beta_features "Disable beta features"
|
||||
python setup.py list
|
||||
python setup.py status new_ui
|
||||
python setup.py toggle maintenance_mode "Toggle maintenance mode"
|
||||
python setup.py delete old_feature
|
||||
"""
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||
|
||||
# Enable command
|
||||
enable_parser = subparsers.add_parser('enable', help='Enable a feature flag')
|
||||
enable_parser.add_argument('flag_name', help='Name of the feature flag')
|
||||
enable_parser.add_argument('description', nargs='?', default='', help='Optional description')
|
||||
|
||||
# Disable command
|
||||
disable_parser = subparsers.add_parser('disable', help='Disable a feature flag')
|
||||
disable_parser.add_argument('flag_name', help='Name of the feature flag')
|
||||
disable_parser.add_argument('description', nargs='?', default='', help='Optional description')
|
||||
|
||||
# List command
|
||||
subparsers.add_parser('list', help='List all feature flags')
|
||||
|
||||
# Status command
|
||||
status_parser = subparsers.add_parser('status', help='Show status of a feature flag')
|
||||
status_parser.add_argument('flag_name', help='Name of the feature flag')
|
||||
|
||||
# Delete command
|
||||
delete_parser = subparsers.add_parser('delete', help='Delete a feature flag')
|
||||
delete_parser.add_argument('flag_name', help='Name of the feature flag')
|
||||
|
||||
# Toggle command
|
||||
toggle_parser = subparsers.add_parser('toggle', help='Toggle a feature flag')
|
||||
toggle_parser.add_argument('flag_name', help='Name of the feature flag')
|
||||
toggle_parser.add_argument('description', nargs='?', default='', help='Optional description')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
try:
|
||||
if args.command == 'enable':
|
||||
await enable_command(args.flag_name, args.description)
|
||||
elif args.command == 'disable':
|
||||
await disable_command(args.flag_name, args.description)
|
||||
elif args.command == 'list':
|
||||
await list_command()
|
||||
elif args.command == 'status':
|
||||
await status_command(args.flag_name)
|
||||
elif args.command == 'delete':
|
||||
await delete_command(args.flag_name)
|
||||
elif args.command == 'toggle':
|
||||
await toggle_command(args.flag_name, args.description)
|
||||
except KeyboardInterrupt:
|
||||
print("\nOperation cancelled.")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
|
@ -12,14 +12,6 @@ The flow:
|
|||
2. Configure MCP servers with credentials and save to agent's configured_mcps
|
||||
3. When agent runs, it connects to MCP servers using:
|
||||
https://server.smithery.ai/{qualifiedName}/mcp?config={base64_encoded_config}&api_key={smithery_api_key}
|
||||
|
||||
Example MCP configuration stored in agent's configured_mcps:
|
||||
{
|
||||
"name": "Exa Search",
|
||||
"qualifiedName": "exa",
|
||||
"config": {"exaApiKey": "user's-exa-api-key"},
|
||||
"enabledTools": ["search", "find_similar"]
|
||||
}
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
|
|
|
@ -543,6 +543,27 @@ files = [
|
|||
{file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.7.0"
|
||||
description = "DNS toolkit"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"},
|
||||
{file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"]
|
||||
dnssec = ["cryptography (>=43)"]
|
||||
doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"]
|
||||
doq = ["aioquic (>=1.0.0)"]
|
||||
idna = ["idna (>=3.7)"]
|
||||
trio = ["trio (>=0.23)"]
|
||||
wmi = ["wmi (>=1.5.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "dramatiq"
|
||||
version = "1.17.1"
|
||||
|
@ -605,6 +626,22 @@ attrs = ">=21.3.0"
|
|||
e2b = ">=1.3.1,<2.0.0"
|
||||
httpx = ">=0.20.0,<1.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.2.0"
|
||||
description = "A robust email address syntax and deliverability validation library."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"},
|
||||
{file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
dnspython = ">=2.0.0"
|
||||
idna = ">=2.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "entrypoints"
|
||||
version = "0.4"
|
||||
|
@ -1297,6 +1334,21 @@ tokenizers = "*"
|
|||
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0)", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0,<0.9.0)"]
|
||||
proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "backoff", "boto3 (==1.34.34)", "cryptography (>=43.0.1,<44.0.0)", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-proxy-extras (==0.1.7)", "mcp (==1.5.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rq", "uvicorn (>=0.29.0,<0.30.0)", "uvloop (>=0.21.0,<0.22.0)", "websockets (>=13.1.0,<14.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "mailtrap"
|
||||
version = "2.1.0"
|
||||
description = "Official mailtrap.io API client"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "mailtrap-2.1.0-py3-none-any.whl", hash = "sha256:6cef8dc02734e3e3a16161e38d184ea6971e925673c731c8ac968b88556f069e"},
|
||||
{file = "mailtrap-2.1.0.tar.gz", hash = "sha256:22fccf3cd912a7e47d4a1bb86865cf0f0587d59dc73bc78d9e77d596767f5b85"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
requests = ">=2.26.0"
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.2"
|
||||
|
@ -3719,4 +3771,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
|
|||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.11"
|
||||
content-hash = "ed0ccb92ccc81ecff536968c883a2ea96d2ee8a6c06a18d0023b0cd4185f8a28"
|
||||
content-hash = "70ef4a9be6ddd82debb9e9e377d9f47f6f6917b43b75a688e404adeef9e61018"
|
||||
|
|
|
@ -56,6 +56,10 @@ langfuse = "^2.60.5"
|
|||
Pillow = "^10.0.0"
|
||||
mcp = "^1.0.0"
|
||||
sentry-sdk = {extras = ["fastapi"], version = "^2.29.1"}
|
||||
httpx = "^0.28.0"
|
||||
aiohttp = "^3.9.0"
|
||||
email-validator = "^2.0.0"
|
||||
mailtrap = "^2.0.1"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
agentpress = "agentpress.cli:main"
|
||||
|
|
|
@ -38,4 +38,6 @@ Pillow>=10.0.0
|
|||
sentry-sdk[fastapi]>=2.29.1
|
||||
mcp>=1.0.0
|
||||
mcp_use>=1.0.0
|
||||
aiohttp>=3.9.0
|
||||
aiohttp>=3.9.0
|
||||
email-validator>=2.0.0
|
||||
mailtrap>=2.0.1
|
||||
|
|
|
@ -132,6 +132,8 @@ async def run_agent_background(
|
|||
final_status = "running"
|
||||
error_message = None
|
||||
|
||||
pending_redis_operations = []
|
||||
|
||||
async for response in agent_gen:
|
||||
if stop_signal_received:
|
||||
logger.info(f"Agent run {agent_run_id} stopped by signal.")
|
||||
|
@ -141,8 +143,8 @@ async def run_agent_background(
|
|||
|
||||
# Store response in Redis list and publish notification
|
||||
response_json = json.dumps(response)
|
||||
asyncio.create_task(redis.rpush(response_list_key, response_json))
|
||||
asyncio.create_task(redis.publish(response_channel, "new"))
|
||||
pending_redis_operations.append(asyncio.create_task(redis.rpush(response_list_key, response_json)))
|
||||
pending_redis_operations.append(asyncio.create_task(redis.publish(response_channel, "new")))
|
||||
total_responses += 1
|
||||
|
||||
# Check for agent-signaled completion or error
|
||||
|
@ -239,6 +241,12 @@ async def run_agent_background(
|
|||
# Remove the instance-specific active run key
|
||||
await _cleanup_redis_instance_key(agent_run_id)
|
||||
|
||||
# Wait for all pending redis operations to complete, with timeout
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.gather(*pending_redis_operations), timeout=30.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"Timeout waiting for pending Redis operations for {agent_run_id}")
|
||||
|
||||
logger.info(f"Agent run background task fully completed for: {agent_run_id} (Instance: {instance_id}) with final status: {final_status}")
|
||||
|
||||
async def _cleanup_redis_instance_key(agent_run_id: str):
|
||||
|
|
|
@ -20,7 +20,7 @@ You can modify the sandbox environment for development or to add new capabilitie
|
|||
```
|
||||
cd backend/sandbox/docker
|
||||
docker compose build
|
||||
docker push kortix/suna:0.1.2
|
||||
docker push kortix/suna:0.1.3
|
||||
```
|
||||
3. Test your changes locally using docker-compose
|
||||
|
||||
|
|
|
@ -96,11 +96,6 @@ WORKDIR /app
|
|||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy server script
|
||||
COPY . /app
|
||||
COPY server.py /app/server.py
|
||||
COPY browser_api.py /app/browser_api.py
|
||||
|
||||
# Install Playwright and browsers with system dependencies
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
# Install Playwright package first
|
||||
|
@ -111,6 +106,11 @@ RUN playwright install chromium
|
|||
# Verify installation
|
||||
RUN python -c "from playwright.sync_api import sync_playwright; print('Playwright installation verified')"
|
||||
|
||||
# Copy server script
|
||||
COPY . /app
|
||||
COPY server.py /app/server.py
|
||||
COPY browser_api.py /app/browser_api.py
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV CHROME_PATH=/ms-playwright/chromium-*/chrome-linux/chrome
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
from fastapi import FastAPI, APIRouter, HTTPException, Body
|
||||
from playwright.async_api import async_playwright, Browser, Page
|
||||
from playwright.async_api import async_playwright, Browser, BrowserContext, Page
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
import asyncio
|
||||
|
@ -282,6 +282,7 @@ class BrowserAutomation:
|
|||
def __init__(self):
|
||||
self.router = APIRouter()
|
||||
self.browser: Browser = None
|
||||
self.browser_context: BrowserContext = None
|
||||
self.pages: List[Page] = []
|
||||
self.current_page_index: int = 0
|
||||
self.logger = logging.getLogger("browser_automation")
|
||||
|
@ -341,6 +342,7 @@ class BrowserAutomation:
|
|||
|
||||
try:
|
||||
self.browser = await playwright.chromium.launch(**launch_options)
|
||||
self.browser_context = await self.browser.new_context(viewport={'width': 1024, 'height': 768})
|
||||
print("Browser launched successfully")
|
||||
except Exception as browser_error:
|
||||
print(f"Failed to launch browser: {browser_error}")
|
||||
|
@ -348,6 +350,7 @@ class BrowserAutomation:
|
|||
print("Retrying with minimal options...")
|
||||
launch_options = {"timeout": 90000}
|
||||
self.browser = await playwright.chromium.launch(**launch_options)
|
||||
self.browser_context = await self.browser.new_context(viewport={'width': 1024, 'height': 768})
|
||||
print("Browser launched with minimal options")
|
||||
|
||||
try:
|
||||
|
@ -356,13 +359,20 @@ class BrowserAutomation:
|
|||
self.current_page_index = 0
|
||||
except Exception as page_error:
|
||||
print(f"Error finding existing page, creating new one. ( {page_error})")
|
||||
page = await self.browser.new_page(viewport={'width': 1024, 'height': 768})
|
||||
page = await self.browser_context.new_page()
|
||||
print("New page created successfully")
|
||||
self.pages.append(page)
|
||||
self.current_page_index = 0
|
||||
# Navigate directly to google.com instead of about:blank
|
||||
await page.goto("https://www.google.com", wait_until="domcontentloaded", timeout=30000)
|
||||
print("Navigated to google.com")
|
||||
|
||||
try:
|
||||
self.browser_context.on("page", self.handle_page_created)
|
||||
except Exception as e:
|
||||
print(f"Error setting up page event handler: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
print("Browser initialization completed successfully")
|
||||
except Exception as e:
|
||||
|
@ -372,8 +382,17 @@ class BrowserAutomation:
|
|||
|
||||
async def shutdown(self):
|
||||
"""Clean up browser instance on shutdown"""
|
||||
if self.browser_context:
|
||||
await self.browser_context.close()
|
||||
if self.browser:
|
||||
await self.browser.close()
|
||||
|
||||
async def handle_page_created(self, page: Page):
|
||||
"""Handle new page creation"""
|
||||
await asyncio.sleep(0.5)
|
||||
self.pages.append(page)
|
||||
self.current_page_index = len(self.pages) - 1
|
||||
print(f"Page created: {page.url}; current page index: {self.current_page_index}")
|
||||
|
||||
async def get_current_page(self) -> Page:
|
||||
"""Get the current active page"""
|
||||
|
@ -958,6 +977,7 @@ class BrowserAutomation:
|
|||
# Give time for any navigation or DOM updates to occur
|
||||
await page.wait_for_load_state("networkidle", timeout=5000)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
# Get updated state after action
|
||||
dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"click_coordinates({action.x}, {action.y})")
|
||||
|
||||
|
@ -977,6 +997,7 @@ class BrowserAutomation:
|
|||
|
||||
# Try to get state even after error
|
||||
try:
|
||||
await asyncio.sleep(1)
|
||||
dom_state, screenshot, elements, metadata = await self.get_updated_browser_state("click_coordinates_error_recovery")
|
||||
return self.build_action_result(
|
||||
False,
|
||||
|
@ -1076,7 +1097,7 @@ class BrowserAutomation:
|
|||
await page.wait_for_load_state("networkidle", timeout=5000)
|
||||
except Exception as wait_error:
|
||||
print(f"Timeout or error waiting for network idle after click: {wait_error}")
|
||||
await asyncio.sleep(1) # Fallback wait
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Get updated state after action
|
||||
dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"click_element({action.index})")
|
||||
|
@ -1161,6 +1182,7 @@ class BrowserAutomation:
|
|||
# Fallback to xpath
|
||||
await page.fill(f"//{element.tag_name}[{action.index}]", action.text)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
# Get updated state after action
|
||||
dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"input_text({action.index}, '{action.text}')")
|
||||
|
||||
|
@ -1192,6 +1214,7 @@ class BrowserAutomation:
|
|||
page = await self.get_current_page()
|
||||
await page.keyboard.press(action.keys)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
# Get updated state after action
|
||||
dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"send_keys({action.keys})")
|
||||
|
||||
|
@ -1267,7 +1290,7 @@ class BrowserAutomation:
|
|||
try:
|
||||
print(f"Attempting to open new tab with URL: {action.url}")
|
||||
# Create new page in same browser instance
|
||||
new_page = await self.browser.new_page()
|
||||
new_page = await self.browser_context.new_page()
|
||||
print(f"New page created successfully")
|
||||
|
||||
# Navigate to the URL
|
||||
|
|
|
@ -6,12 +6,12 @@ services:
|
|||
dockerfile: ${DOCKERFILE:-Dockerfile}
|
||||
args:
|
||||
TARGETPLATFORM: ${TARGETPLATFORM:-linux/amd64}
|
||||
image: kortix/suna:0.1.2.8
|
||||
image: kortix/suna:0.1.3
|
||||
ports:
|
||||
- "6080:6080" # noVNC web interface
|
||||
- "5901:5901" # VNC port
|
||||
- "9222:9222" # Chrome remote debugging port
|
||||
- "8000:8000" # API server port
|
||||
- "8003:8003" # API server port
|
||||
- "8080:8080" # HTTP server port
|
||||
environment:
|
||||
- ANONYMIZED_TELEMETRY=${ANONYMIZED_TELEMETRY:-false}
|
||||
|
|
|
@ -0,0 +1,188 @@
|
|||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
import mailtrap as mt
|
||||
from utils.config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class EmailService:
|
||||
def __init__(self):
|
||||
self.api_token = os.getenv('MAILTRAP_API_TOKEN')
|
||||
self.sender_email = os.getenv('MAILTRAP_SENDER_EMAIL', 'dom@kortix.ai')
|
||||
self.sender_name = os.getenv('MAILTRAP_SENDER_NAME', 'Suna Team')
|
||||
|
||||
if not self.api_token:
|
||||
logger.warning("MAILTRAP_API_TOKEN not found in environment variables")
|
||||
self.client = None
|
||||
else:
|
||||
self.client = mt.MailtrapClient(token=self.api_token)
|
||||
|
||||
def send_welcome_email(self, user_email: str, user_name: Optional[str] = None) -> bool:
|
||||
if not self.client:
|
||||
logger.error("Cannot send email: MAILTRAP_API_TOKEN not configured")
|
||||
return False
|
||||
|
||||
if not user_name:
|
||||
user_name = user_email.split('@')[0].title()
|
||||
|
||||
subject = "🎉 Welcome to Suna — Let's Get Started "
|
||||
html_content = self._get_welcome_email_template(user_name)
|
||||
text_content = self._get_welcome_email_text(user_name)
|
||||
|
||||
return self._send_email(
|
||||
to_email=user_email,
|
||||
to_name=user_name,
|
||||
subject=subject,
|
||||
html_content=html_content,
|
||||
text_content=text_content
|
||||
)
|
||||
|
||||
def _send_email(
|
||||
self,
|
||||
to_email: str,
|
||||
to_name: str,
|
||||
subject: str,
|
||||
html_content: str,
|
||||
text_content: str
|
||||
) -> bool:
|
||||
try:
|
||||
mail = mt.Mail(
|
||||
sender=mt.Address(email=self.sender_email, name=self.sender_name),
|
||||
to=[mt.Address(email=to_email, name=to_name)],
|
||||
subject=subject,
|
||||
text=text_content,
|
||||
html=html_content,
|
||||
category="welcome"
|
||||
)
|
||||
|
||||
response = self.client.send(mail)
|
||||
|
||||
logger.info(f"Welcome email sent to {to_email}. Response: {response}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending email to {to_email}: {str(e)}")
|
||||
return False
|
||||
|
||||
def _get_welcome_email_template(self, user_name: str) -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Welcome to Kortix Suna</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
padding: 30px;
|
||||
background-color: #ffffff;
|
||||
}}
|
||||
.logo-container {{
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding: 10px 0;
|
||||
}}
|
||||
.logo {{
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 60px;
|
||||
display: inline-block;
|
||||
}}
|
||||
h1 {{
|
||||
font-size: 24px;
|
||||
color: #000000;
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
p {{
|
||||
margin-bottom: 16px;
|
||||
}}
|
||||
a {{
|
||||
color: #3366cc;
|
||||
text-decoration: none;
|
||||
}}
|
||||
a:hover {{
|
||||
text-decoration: underline;
|
||||
}}
|
||||
.button {{
|
||||
display: inline-block;
|
||||
margin-top: 30px;
|
||||
background-color: #3B82F6;
|
||||
color: white !important;
|
||||
padding: 14px 24px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
}}
|
||||
.button:hover {{
|
||||
background-color: #2563EB;
|
||||
text-decoration: none;
|
||||
}}
|
||||
.emoji {{
|
||||
font-size: 20px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo-container">
|
||||
<img src="https://i.postimg.cc/WdNtRx5Z/kortix-suna-logo.png" alt="Kortix Suna Logo" class="logo">
|
||||
</div>
|
||||
<h1>Welcome to Kortix Suna!</h1>
|
||||
|
||||
<p>Hi {user_name},</p>
|
||||
|
||||
<p><em><strong>Welcome to Kortix Suna — we're excited to have you on board!</strong></em></p>
|
||||
|
||||
<p>To get started, we'd like to get to know you better: fill out this short <a href="https://docs.google.com/forms/d/e/1FAIpQLSef1EHuqmIh_iQz-kwhjnzSC3Ml-V_5wIySDpMoMU9W_j24JQ/viewform">form</a>!</p>
|
||||
|
||||
<p>To celebrate your arrival, here's a <strong>15% discount</strong> to try out the best version of Suna (1 month):</p>
|
||||
|
||||
<p>🎁 Use code <strong>WELCOME15</strong> at checkout.</p>
|
||||
|
||||
<p>Let us know if you need help getting started or have questions — we're always here, and join our <a href="https://discord.com/invite/FjD644cfcs">Discord community</a>.</p>
|
||||
|
||||
<p>Thanks again, and welcome to the Suna community <span class="emoji">🌞</span></p>
|
||||
|
||||
<p>— The Suna Team</p>
|
||||
|
||||
<a href="https://www.suna.so/" class="button">Go to the platform</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
def _get_welcome_email_text(self, user_name: str) -> str:
|
||||
return f"""Hi {user_name},
|
||||
|
||||
Welcome to Suna — we're excited to have you on board!
|
||||
|
||||
To get started, we'd like to get to know you better: fill out this short form!
|
||||
https://docs.google.com/forms/d/e/1FAIpQLSef1EHuqmIh_iQz-kwhjnzSC3Ml-V_5wIySDpMoMU9W_j24JQ/viewform
|
||||
|
||||
To celebrate your arrival, here's a 15% discount to try out the best version of Suna (1 month):
|
||||
🎁 Use code WELCOME15 at checkout.
|
||||
|
||||
Let us know if you need help getting started or have questions — we're always here, and join our Discord community: https://discord.com/invite/FjD644cfcs
|
||||
|
||||
Thanks again, and welcome to the Suna community 🌞
|
||||
|
||||
— The Suna Team
|
||||
|
||||
Go to the platform: https://www.suna.so/
|
||||
|
||||
---
|
||||
© 2024 Suna. All rights reserved.
|
||||
You received this email because you signed up for a Suna account."""
|
||||
|
||||
email_service = EmailService()
|
|
@ -0,0 +1,70 @@
|
|||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
from services.email import email_service
|
||||
from utils.logger import logger
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class SendWelcomeEmailRequest(BaseModel):
|
||||
email: EmailStr
|
||||
name: Optional[str] = None
|
||||
|
||||
class EmailResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
@router.post("/send-welcome-email", response_model=EmailResponse)
|
||||
async def send_welcome_email(request: SendWelcomeEmailRequest):
|
||||
try:
|
||||
logger.info(f"Sending welcome email to {request.email}")
|
||||
success = email_service.send_welcome_email(
|
||||
user_email=request.email,
|
||||
user_name=request.name
|
||||
)
|
||||
|
||||
if success:
|
||||
return EmailResponse(
|
||||
success=True,
|
||||
message="Welcome email sent successfully"
|
||||
)
|
||||
else:
|
||||
return EmailResponse(
|
||||
success=False,
|
||||
message="Failed to send welcome email"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending welcome email to {request.email}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Internal server error while sending email"
|
||||
)
|
||||
|
||||
@router.post("/send-welcome-email-background", response_model=EmailResponse)
|
||||
async def send_welcome_email_background(request: SendWelcomeEmailRequest):
|
||||
try:
|
||||
logger.info(f"Queuing welcome email for {request.email}")
|
||||
|
||||
def send_email():
|
||||
return email_service.send_welcome_email(
|
||||
user_email=request.email,
|
||||
user_name=request.name
|
||||
)
|
||||
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(send_email)
|
||||
|
||||
return EmailResponse(
|
||||
success=True,
|
||||
message="Welcome email queued for sending"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error queuing welcome email for {request.email}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Internal server error while queuing email"
|
||||
)
|
|
@ -159,7 +159,7 @@ class Configuration:
|
|||
STRIPE_PRODUCT_ID_STAGING: str = 'prod_SCgIj3G7yPOAWY'
|
||||
|
||||
# Sandbox configuration
|
||||
SANDBOX_IMAGE_NAME = "kortix/suna:0.1.2.8"
|
||||
SANDBOX_IMAGE_NAME = "kortix/suna:0.1.3"
|
||||
SANDBOX_ENTRYPOINT = "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf"
|
||||
|
||||
# LangFuse configuration
|
||||
|
|
|
@ -115,7 +115,7 @@ As part of the setup, you'll need to:
|
|||
1. Create a Daytona account
|
||||
2. Generate an API key
|
||||
3. Create a Docker image:
|
||||
- Image name: `kortix/suna:0.1.2.8`
|
||||
- Image name: `kortix/suna:0.1.3`
|
||||
- Entrypoint: `/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf`
|
||||
|
||||
## Manual Configuration
|
||||
|
|
|
@ -4,4 +4,4 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=""
|
|||
NEXT_PUBLIC_BACKEND_URL=""
|
||||
NEXT_PUBLIC_URL=""
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID=""
|
||||
OPENAI_API_KEY=""
|
||||
OPENAI_API_KEY=""
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { agentPlaygroundFlagFrontend } from '@/flags';
|
||||
import { isFlagEnabled } from '@/lib/feature-flags';
|
||||
import { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Agent Conversation | Kortix Suna',
|
||||
|
@ -10,10 +13,14 @@ export const metadata: Metadata = {
|
|||
},
|
||||
};
|
||||
|
||||
export default function AgentsLayout({
|
||||
export default async function AgentsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { isFlagEnabled } from '@/lib/feature-flags';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create Agent | Kortix Suna',
|
||||
|
@ -10,10 +12,14 @@ export const metadata: Metadata = {
|
|||
},
|
||||
};
|
||||
|
||||
export default function NewAgentLayout({
|
||||
export default async function NewAgentLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ import { Pagination } from './_components/pagination';
|
|||
import { useRouter } from 'next/navigation';
|
||||
import { DEFAULT_AGENTPRESS_TOOLS } from './_data/tools';
|
||||
import { AgentsParams } from '@/hooks/react-query/agents/utils';
|
||||
import { useFeatureFlags } from '@/lib/feature-flags';
|
||||
|
||||
type ViewMode = 'grid' | 'list';
|
||||
type SortOption = 'name' | 'created_at' | 'updated_at' | 'tools_count';
|
||||
|
@ -34,6 +35,7 @@ export default function AgentsPage() {
|
|||
const [editingAgentId, setEditingAgentId] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
|
||||
|
||||
// Server-side parameters
|
||||
const [page, setPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
|
|
@ -0,0 +1,238 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, Suspense, useEffect, useRef } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Menu } from 'lucide-react';
|
||||
import {
|
||||
ChatInput,
|
||||
ChatInputHandles,
|
||||
} from '@/components/thread/chat-input/chat-input';
|
||||
import {
|
||||
BillingError,
|
||||
} from '@/lib/api';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { useSidebar } from '@/components/ui/sidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useBillingError } from '@/hooks/useBillingError';
|
||||
import { BillingErrorAlert } from '@/components/billing/usage-limit-alert';
|
||||
import { useAccounts } from '@/hooks/use-accounts';
|
||||
import { config } from '@/lib/config';
|
||||
import { useInitiateAgentWithInvalidation } from '@/hooks/react-query/dashboard/use-initiate-agent';
|
||||
import { ModalProviders } from '@/providers/modal-providers';
|
||||
import { AgentSelector } from '@/components/dashboard/agent-selector';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useModal } from '@/hooks/use-modal-store';
|
||||
import { Examples } from './suggestions/examples';
|
||||
import { useThreadQuery } from '@/hooks/react-query/threads/use-threads';
|
||||
|
||||
const PENDING_PROMPT_KEY = 'pendingAgentPrompt';
|
||||
|
||||
export function DashboardContent() {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [autoSubmit, setAutoSubmit] = useState(false);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | undefined>();
|
||||
const [initiatedThreadId, setInitiatedThreadId] = useState<string | null>(null);
|
||||
const { billingError, handleBillingError, clearBillingError } =
|
||||
useBillingError();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const isMobile = useIsMobile();
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const { data: accounts } = useAccounts();
|
||||
const personalAccount = accounts?.find((account) => account.personal_account);
|
||||
const chatInputRef = useRef<ChatInputHandles>(null);
|
||||
const initiateAgentMutation = useInitiateAgentWithInvalidation();
|
||||
const { onOpen } = useModal();
|
||||
|
||||
const threadQuery = useThreadQuery(initiatedThreadId || '');
|
||||
|
||||
useEffect(() => {
|
||||
const agentIdFromUrl = searchParams.get('agent_id');
|
||||
if (agentIdFromUrl && agentIdFromUrl !== selectedAgentId) {
|
||||
setSelectedAgentId(agentIdFromUrl);
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete('agent_id');
|
||||
router.replace(newUrl.pathname + newUrl.search, { scroll: false });
|
||||
}
|
||||
}, [searchParams, selectedAgentId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (threadQuery.data && initiatedThreadId) {
|
||||
const thread = threadQuery.data;
|
||||
console.log('Thread data received:', thread);
|
||||
if (thread.project_id) {
|
||||
router.push(`/projects/${thread.project_id}/thread/${initiatedThreadId}`);
|
||||
} else {
|
||||
router.push(`/agents/${initiatedThreadId}`);
|
||||
}
|
||||
setInitiatedThreadId(null);
|
||||
}
|
||||
}, [threadQuery.data, initiatedThreadId, router]);
|
||||
|
||||
const secondaryGradient =
|
||||
'bg-gradient-to-r from-blue-500 to-blue-500 bg-clip-text text-transparent';
|
||||
|
||||
const handleSubmit = async (
|
||||
message: string,
|
||||
options?: {
|
||||
model_name?: string;
|
||||
enable_thinking?: boolean;
|
||||
reasoning_effort?: string;
|
||||
stream?: boolean;
|
||||
enable_context_manager?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (
|
||||
(!message.trim() && !chatInputRef.current?.getPendingFiles().length) ||
|
||||
isSubmitting
|
||||
)
|
||||
return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const files = chatInputRef.current?.getPendingFiles() || [];
|
||||
localStorage.removeItem(PENDING_PROMPT_KEY);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('prompt', message);
|
||||
|
||||
// Add selected agent if one is chosen
|
||||
if (selectedAgentId) {
|
||||
formData.append('agent_id', selectedAgentId);
|
||||
}
|
||||
|
||||
files.forEach((file, index) => {
|
||||
formData.append('files', file, file.name);
|
||||
});
|
||||
|
||||
if (options?.model_name) formData.append('model_name', options.model_name);
|
||||
formData.append('enable_thinking', String(options?.enable_thinking ?? false));
|
||||
formData.append('reasoning_effort', options?.reasoning_effort ?? 'low');
|
||||
formData.append('stream', String(options?.stream ?? true));
|
||||
formData.append('enable_context_manager', String(options?.enable_context_manager ?? false));
|
||||
|
||||
console.log('FormData content:', Array.from(formData.entries()));
|
||||
|
||||
const result = await initiateAgentMutation.mutateAsync(formData);
|
||||
console.log('Agent initiated:', result);
|
||||
|
||||
if (result.thread_id) {
|
||||
setInitiatedThreadId(result.thread_id);
|
||||
} else {
|
||||
throw new Error('Agent initiation did not return a thread_id.');
|
||||
}
|
||||
chatInputRef.current?.clearPendingFiles();
|
||||
} catch (error: any) {
|
||||
console.error('Error during submission process:', error);
|
||||
if (error instanceof BillingError) {
|
||||
console.log('Handling BillingError:', error.detail);
|
||||
onOpen("paymentRequiredDialog");
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
const pendingPrompt = localStorage.getItem(PENDING_PROMPT_KEY);
|
||||
|
||||
if (pendingPrompt) {
|
||||
setInputValue(pendingPrompt);
|
||||
setAutoSubmit(true);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoSubmit && inputValue && !isSubmitting) {
|
||||
const timer = setTimeout(() => {
|
||||
handleSubmit(inputValue);
|
||||
setAutoSubmit(false);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [autoSubmit, inputValue, isSubmitting]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalProviders />
|
||||
<div className="flex flex-col h-screen w-full">
|
||||
{isMobile && (
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setOpenMobile(true)}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open menu</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[650px] max-w-[90%]">
|
||||
<div className="flex flex-col items-center text-center w-full">
|
||||
<div className="flex items-center gap-1">
|
||||
<h1 className="tracking-tight text-4xl text-muted-foreground leading-tight">
|
||||
Hey, I am
|
||||
</h1>
|
||||
<AgentSelector
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={setSelectedAgentId}
|
||||
variant="heading"
|
||||
/>
|
||||
</div>
|
||||
<p className="tracking-tight text-3xl font-normal text-muted-foreground/80 mt-2">
|
||||
What would you like to do today?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"w-full mb-2",
|
||||
"max-w-full",
|
||||
"sm:max-w-3xl"
|
||||
)}>
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSubmit={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
placeholder="Describe what you need help with..."
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
hideAttachments={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Examples onSelectPrompt={setInputValue} />
|
||||
</div>
|
||||
|
||||
<BillingErrorAlert
|
||||
message={billingError?.message}
|
||||
currentUsage={billingError?.currentUsage}
|
||||
limit={billingError?.limit}
|
||||
accountId={personalAccount?.account_id}
|
||||
onDismiss={clearBillingError}
|
||||
isOpen={!!billingError}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,226 +1,10 @@
|
|||
'use client';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DashboardContent } from "./_components/dashboard-content";
|
||||
import { Suspense } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { isFlagEnabled } from "@/lib/feature-flags";
|
||||
|
||||
import React, { useState, Suspense, useEffect, useRef } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Menu } from 'lucide-react';
|
||||
import {
|
||||
ChatInput,
|
||||
ChatInputHandles,
|
||||
} from '@/components/thread/chat-input/chat-input';
|
||||
import {
|
||||
BillingError,
|
||||
} from '@/lib/api';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { useSidebar } from '@/components/ui/sidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useBillingError } from '@/hooks/useBillingError';
|
||||
import { BillingErrorAlert } from '@/components/billing/usage-limit-alert';
|
||||
import { useAccounts } from '@/hooks/use-accounts';
|
||||
import { config } from '@/lib/config';
|
||||
import { useInitiateAgentWithInvalidation } from '@/hooks/react-query/dashboard/use-initiate-agent';
|
||||
import { ModalProviders } from '@/providers/modal-providers';
|
||||
import { AgentSelector } from '@/components/dashboard/agent-selector';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useModal } from '@/hooks/use-modal-store';
|
||||
import { Examples } from './_components/suggestions/examples';
|
||||
|
||||
const PENDING_PROMPT_KEY = 'pendingAgentPrompt';
|
||||
|
||||
function DashboardContent() {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [autoSubmit, setAutoSubmit] = useState(false);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | undefined>();
|
||||
const { billingError, handleBillingError, clearBillingError } =
|
||||
useBillingError();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const isMobile = useIsMobile();
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const { data: accounts } = useAccounts();
|
||||
const personalAccount = accounts?.find((account) => account.personal_account);
|
||||
const chatInputRef = useRef<ChatInputHandles>(null);
|
||||
const initiateAgentMutation = useInitiateAgentWithInvalidation();
|
||||
const { onOpen } = useModal();
|
||||
|
||||
useEffect(() => {
|
||||
const agentIdFromUrl = searchParams.get('agent_id');
|
||||
if (agentIdFromUrl && agentIdFromUrl !== selectedAgentId) {
|
||||
setSelectedAgentId(agentIdFromUrl);
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete('agent_id');
|
||||
router.replace(newUrl.pathname + newUrl.search, { scroll: false });
|
||||
}
|
||||
}, [searchParams, selectedAgentId, router]);
|
||||
|
||||
const secondaryGradient =
|
||||
'bg-gradient-to-r from-blue-500 to-blue-500 bg-clip-text text-transparent';
|
||||
|
||||
const handleSubmit = async (
|
||||
message: string,
|
||||
options?: {
|
||||
model_name?: string;
|
||||
enable_thinking?: boolean;
|
||||
reasoning_effort?: string;
|
||||
stream?: boolean;
|
||||
enable_context_manager?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (
|
||||
(!message.trim() && !chatInputRef.current?.getPendingFiles().length) ||
|
||||
isSubmitting
|
||||
)
|
||||
return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const files = chatInputRef.current?.getPendingFiles() || [];
|
||||
localStorage.removeItem(PENDING_PROMPT_KEY);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('prompt', message);
|
||||
|
||||
// Add selected agent if one is chosen
|
||||
if (selectedAgentId) {
|
||||
formData.append('agent_id', selectedAgentId);
|
||||
}
|
||||
|
||||
files.forEach((file, index) => {
|
||||
formData.append('files', file, file.name);
|
||||
});
|
||||
|
||||
if (options?.model_name) formData.append('model_name', options.model_name);
|
||||
formData.append('enable_thinking', String(options?.enable_thinking ?? false));
|
||||
formData.append('reasoning_effort', options?.reasoning_effort ?? 'low');
|
||||
formData.append('stream', String(options?.stream ?? true));
|
||||
formData.append('enable_context_manager', String(options?.enable_context_manager ?? false));
|
||||
|
||||
console.log('FormData content:', Array.from(formData.entries()));
|
||||
|
||||
const result = await initiateAgentMutation.mutateAsync(formData);
|
||||
console.log('Agent initiated:', result);
|
||||
|
||||
if (result.thread_id) {
|
||||
router.push(`/agents/${result.thread_id}`);
|
||||
} else {
|
||||
throw new Error('Agent initiation did not return a thread_id.');
|
||||
}
|
||||
chatInputRef.current?.clearPendingFiles();
|
||||
} catch (error: any) {
|
||||
console.error('Error during submission process:', error);
|
||||
if (error instanceof BillingError) {
|
||||
console.log('Handling BillingError:', error.detail);
|
||||
onOpen("paymentRequiredDialog");
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
const pendingPrompt = localStorage.getItem(PENDING_PROMPT_KEY);
|
||||
|
||||
if (pendingPrompt) {
|
||||
setInputValue(pendingPrompt);
|
||||
setAutoSubmit(true);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoSubmit && inputValue && !isSubmitting) {
|
||||
const timer = setTimeout(() => {
|
||||
handleSubmit(inputValue);
|
||||
setAutoSubmit(false);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [autoSubmit, inputValue, isSubmitting]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalProviders />
|
||||
<div className="flex flex-col h-screen w-full">
|
||||
{isMobile && (
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setOpenMobile(true)}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open menu</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[650px] max-w-[90%]">
|
||||
<div className="flex flex-col items-center text-center w-full">
|
||||
<div className="flex items-center gap-1">
|
||||
<h1 className="tracking-tight text-4xl text-muted-foreground leading-tight">
|
||||
Hey, I am
|
||||
</h1>
|
||||
<AgentSelector
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={setSelectedAgentId}
|
||||
variant="heading"
|
||||
/>
|
||||
</div>
|
||||
<p className="tracking-tight text-3xl font-normal text-muted-foreground/80 mt-2">
|
||||
What would you like to do today?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"w-full mb-2",
|
||||
"max-w-full",
|
||||
"sm:max-w-3xl"
|
||||
)}>
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSubmit={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
placeholder="Describe what you need help with..."
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
hideAttachments={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Examples onSelectPrompt={setInputValue} />
|
||||
</div>
|
||||
|
||||
<BillingErrorAlert
|
||||
message={billingError?.message}
|
||||
currentUsage={billingError?.currentUsage}
|
||||
limit={billingError?.limit}
|
||||
accountId={personalAccount?.account_id}
|
||||
onDismiss={clearBillingError}
|
||||
isOpen={!!billingError}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
export default async function DashboardPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { isFlagEnabled } from '@/lib/feature-flags';
|
||||
import { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Agent Marketplace | Kortix Suna',
|
||||
|
@ -10,10 +12,14 @@ export const metadata: Metadata = {
|
|||
},
|
||||
};
|
||||
|
||||
export default function MarketplaceLayout({
|
||||
export default async function MarketplaceLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const marketplaceEnabled = await isFlagEnabled('agent_marketplace');
|
||||
if (!marketplaceEnabled) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
|
@ -3,6 +3,30 @@
|
|||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
async function sendWelcomeEmail(email: string, name?: string) {
|
||||
try {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||
const response = await fetch(`${backendUrl}/send-welcome-email-background`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
name,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log(`Welcome email queued for ${email}`);
|
||||
} else {
|
||||
console.error(`Failed to queue welcome email for ${email}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending welcome email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function signIn(prevState: any, formData: FormData) {
|
||||
const email = formData.get('email') as string;
|
||||
const password = formData.get('password') as string;
|
||||
|
@ -64,12 +88,17 @@ export async function signUp(prevState: any, formData: FormData) {
|
|||
return { message: error.message || 'Could not create account' };
|
||||
}
|
||||
|
||||
// Try to sign in immediately
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
const userName = email.split('@')[0].replace(/[._-]/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
|
||||
const { error: signInError, data: signInData } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (signInData) {
|
||||
sendWelcomeEmail(email, userName);
|
||||
}
|
||||
|
||||
if (signInError) {
|
||||
return {
|
||||
message:
|
||||
|
|
|
@ -637,4 +637,20 @@
|
|||
animation: var(--animate-shimmer);
|
||||
width: 100%;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--animate-shiny-text: shiny-text 5s infinite;
|
||||
|
||||
@keyframes shiny-text {
|
||||
0%,
|
||||
90%,
|
||||
100% {
|
||||
background-position: calc(-100% - var(--shiny-width)) 0;
|
||||
}
|
||||
30%,
|
||||
60% {
|
||||
background-position: calc(100% + var(--shiny-width)) 0;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,6 +15,7 @@ import { useAgents } from '@/hooks/react-query/agents/use-agents';
|
|||
import { useRouter } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CreateAgentDialog } from '@/app/(dashboard)/agents/_components/create-agent-dialog';
|
||||
import { useFeatureFlags } from '@/lib/feature-flags';
|
||||
|
||||
interface AgentSelectorProps {
|
||||
onAgentSelect?: (agentId: string | undefined) => void;
|
||||
|
@ -27,13 +28,17 @@ export function AgentSelector({
|
|||
onAgentSelect,
|
||||
selectedAgentId,
|
||||
className,
|
||||
variant = 'default'
|
||||
variant = 'default',
|
||||
}: AgentSelectorProps) {
|
||||
const { data: agentsResponse, isLoading, refetch: loadAgents } = useAgents({
|
||||
limit: 100,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc'
|
||||
});
|
||||
|
||||
|
||||
const { flags, loading: flagsLoading } = useFeatureFlags(['custom_agents']);
|
||||
const customAgentsEnabled = flags.custom_agents;
|
||||
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
@ -69,6 +74,18 @@ export function AgentSelector({
|
|||
setIsOpen(false);
|
||||
};
|
||||
|
||||
if (!customAgentsEnabled) {
|
||||
if (variant === 'heading') {
|
||||
return (
|
||||
<div className={cn("flex items-center", className)}>
|
||||
<span className="tracking-tight text-4xl font-semibold leading-tight text-primary">
|
||||
Suna
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
if (variant === 'heading') {
|
||||
return (
|
||||
|
|
|
@ -82,7 +82,7 @@ export function SidebarSearch() {
|
|||
threadId: thread.thread_id,
|
||||
projectId: projectId,
|
||||
projectName: project.name || 'Unnamed Project',
|
||||
url: `/agents/${thread.thread_id}`,
|
||||
url: `/projects/${projectId}/thread/${thread.thread_id}`,
|
||||
updatedAt:
|
||||
thread.updated_at || project.updated_at || new Date().toISOString(),
|
||||
});
|
||||
|
|
|
@ -31,6 +31,7 @@ import { useIsMobile } from '@/hooks/use-mobile';
|
|||
import { Badge } from '../ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useFeatureFlags } from '@/lib/feature-flags';
|
||||
|
||||
export function SidebarLeft({
|
||||
...props
|
||||
|
@ -48,6 +49,9 @@ export function SidebarLeft({
|
|||
});
|
||||
|
||||
const pathname = usePathname();
|
||||
const { flags, loading: flagsLoading } = useFeatureFlags(['custom_agents', 'agent_marketplace']);
|
||||
const customAgentsEnabled = flags.custom_agents;
|
||||
const marketplaceEnabled = flags.agent_marketplace;
|
||||
|
||||
// Fetch user data
|
||||
useEffect(() => {
|
||||
|
@ -134,35 +138,40 @@ export function SidebarLeft({
|
|||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
|
||||
<SidebarGroup>
|
||||
<Link href="/agents">
|
||||
<SidebarMenuButton className={cn({
|
||||
'bg-primary/10 font-medium': pathname === '/agents',
|
||||
})}>
|
||||
<Bot className="h-4 w-4 mr-2" />
|
||||
<span className="flex items-center justify-between w-full">
|
||||
Agent Playground
|
||||
<Badge variant="new">
|
||||
New
|
||||
</Badge>
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
|
||||
<Link href="/marketplace">
|
||||
<SidebarMenuButton className={cn({
|
||||
'bg-primary/10 font-medium': pathname === '/marketplace',
|
||||
})}>
|
||||
<Store className="h-4 w-4 mr-2" />
|
||||
<span className="flex items-center justify-between w-full">
|
||||
Marketplace
|
||||
<Badge variant="new">
|
||||
New
|
||||
</Badge>
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
</SidebarGroup>
|
||||
{!flagsLoading && (customAgentsEnabled || marketplaceEnabled) && (
|
||||
<SidebarGroup>
|
||||
{customAgentsEnabled && (
|
||||
<Link href="/agents">
|
||||
<SidebarMenuButton className={cn({
|
||||
'bg-primary/10 font-medium': pathname === '/agents',
|
||||
})}>
|
||||
<Bot className="h-4 w-4 mr-2" />
|
||||
<span className="flex items-center justify-between w-full">
|
||||
Agent Playground
|
||||
<Badge variant="new">
|
||||
New
|
||||
</Badge>
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
)}
|
||||
{marketplaceEnabled && (
|
||||
<Link href="/marketplace">
|
||||
<SidebarMenuButton className={cn({
|
||||
'bg-primary/10 font-medium': pathname === '/marketplace',
|
||||
})}>
|
||||
<Store className="h-4 w-4 mr-2" />
|
||||
<span className="flex items-center justify-between w-full">
|
||||
Marketplace
|
||||
<Badge variant="new">
|
||||
New
|
||||
</Badge>
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
)}
|
||||
<NavAgents />
|
||||
</SidebarContent>
|
||||
{state !== 'collapsed' && (
|
||||
|
|
|
@ -250,17 +250,7 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
|||
}}
|
||||
>
|
||||
<div className="w-full text-sm flex flex-col justify-between items-start rounded-lg">
|
||||
<CardContent className={`w-full p-1.5 pb-2 ${bgColor} rounded-2xl border`}>
|
||||
{onAgentSelect && (
|
||||
<div className="mb-2 px-2">
|
||||
<AgentSelector
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={onAgentSelect}
|
||||
disabled={loading || disabled}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className={`w-full p-1.5 pb-2 ${bgColor} rounded-2xl border`}>
|
||||
<AttachmentGroup
|
||||
files={uploadedFiles || []}
|
||||
sandboxId={sandboxId}
|
||||
|
@ -269,7 +259,6 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
|||
maxHeight="216px"
|
||||
showPreviews={true}
|
||||
/>
|
||||
|
||||
<MessageInput
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
|
|
|
@ -421,7 +421,7 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|||
groupedMessages.push(currentGroup);
|
||||
}
|
||||
|
||||
// Handle streaming content
|
||||
// Handle streaming content - only add to existing group or create new one if needed
|
||||
if (streamingTextContent) {
|
||||
const lastGroup = groupedMessages.at(-1);
|
||||
if (!lastGroup || lastGroup.type === 'user') {
|
||||
|
@ -443,7 +443,6 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|||
key: `assistant-group-${assistantGroupCounter}-streaming`
|
||||
});
|
||||
} else if (lastGroup.type === 'assistant_group') {
|
||||
// Add to existing assistant group
|
||||
lastGroup.messages.push({
|
||||
content: streamingTextContent,
|
||||
type: 'assistant',
|
||||
|
@ -513,7 +512,7 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|||
return (
|
||||
<div key={group.key} ref={groupIndex === groupedMessages.length - 1 ? latestMessageRef : null}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Logo positioned above the message content */}
|
||||
{/* Logo positioned above the message content - ONLY ONCE PER GROUP */}
|
||||
<div className="flex items-center">
|
||||
<div className="rounded-md flex items-center justify-center">
|
||||
{agentAvatar}
|
||||
|
@ -521,8 +520,8 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|||
<p className='ml-2 text-sm text-muted-foreground'>{agentName ? agentName : 'Suna'}</p>
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div className="flex max-w-[90%] rounded-lg text-sm break-words overflow-hidden">
|
||||
{/* Message content - ALL messages in the group */}
|
||||
<div className="flex max-w-[90%] rounded-lg text-sm break-words overflow-hidden">
|
||||
<div className="space-y-2 min-w-0 flex-1">
|
||||
{(() => {
|
||||
// In debug mode, just show raw messages content
|
||||
|
@ -566,13 +565,13 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|||
|
||||
const renderedToolResultIds = new Set<string>();
|
||||
const elements: React.ReactNode[] = [];
|
||||
let assistantMessageCount = 0; // Track assistant messages for spacing
|
||||
|
||||
group.messages.forEach((message, msgIndex) => {
|
||||
if (message.type === 'assistant') {
|
||||
const parsedContent = safeJsonParse<ParsedContent>(message.content, {});
|
||||
const msgKey = message.message_id || `submsg-assistant-${msgIndex}`;
|
||||
|
||||
let assistantMessageCount = 0;
|
||||
|
||||
if (!parsedContent.content) return;
|
||||
|
||||
const renderedContent = renderMarkdownContent(
|
||||
|
@ -586,13 +585,12 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|||
);
|
||||
|
||||
elements.push(
|
||||
<div key={msgKey} className={assistantMessageCount > 0 ? "mt-2" : ""}>
|
||||
<div key={msgKey} className={assistantMessageCount > 0 ? "mt-4" : ""}>
|
||||
<div className="prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3 break-words overflow-hidden">
|
||||
{renderedContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
assistantMessageCount++;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AnimatedShinyText } from '@/components/ui/animated-shiny-text';
|
||||
|
||||
const items = [
|
||||
{ id: 1, content: "Initializing neural pathways..." },
|
||||
|
@ -33,10 +34,8 @@ export const AgentLoader = () => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-2 w-full h-16 bg-background">
|
||||
<div className="max-w-[90%] animate-shimmer bg-transparent h-full p-0.5 text-sm border rounded-xl shadow-sm relative overflow-hidden">
|
||||
<div className="rounded-md bg-background flex px-5 items-start justify-start h-full relative z-10">
|
||||
<div className="flex flex-col py-5 items-start w-full space-y-3">
|
||||
<div className="flex py-2 items-center w-full">
|
||||
<div>✨</div>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key={items[index].id}
|
||||
|
@ -45,13 +44,11 @@ export const AgentLoader = () => {
|
|||
exit={{ y: -20, opacity: 0, filter: "blur(8px)" }}
|
||||
transition={{ ease: "easeInOut" }}
|
||||
style={{ position: "absolute" }}
|
||||
className='ml-7'
|
||||
>
|
||||
{items[index].content}
|
||||
<AnimatedShinyText>{items[index].content}</AnimatedShinyText>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -299,6 +299,52 @@ export function ToolCallSidePanel({
|
|||
internalNavigate(totalCalls - 1, 'user_explicit');
|
||||
}, [totalCalls, internalNavigate]);
|
||||
|
||||
const renderStatusButton = React.useCallback(() => {
|
||||
const baseClasses = "flex items-center justify-center gap-1.5 px-2 py-0.5 rounded-full w-[116px]";
|
||||
const dotClasses = "w-1.5 h-1.5 rounded-full";
|
||||
const textClasses = "text-xs font-medium";
|
||||
|
||||
if (isLiveMode) {
|
||||
if (agentStatus === 'running') {
|
||||
return (
|
||||
<div className={`${baseClasses} bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800`}>
|
||||
<div className={`${dotClasses} bg-green-500 animate-pulse`} />
|
||||
<span className={`${textClasses} text-green-700 dark:text-green-400`}>Live Updates</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className={`${baseClasses} bg-neutral-50 dark:bg-neutral-900/20 border border-neutral-200 dark:border-neutral-800`}>
|
||||
<div className={`${dotClasses} bg-neutral-500`} />
|
||||
<span className={`${textClasses} text-neutral-700 dark:text-neutral-400`}>Latest Tool</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (agentStatus === 'running') {
|
||||
return (
|
||||
<div
|
||||
className={`${baseClasses} bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors cursor-pointer`}
|
||||
onClick={jumpToLive}
|
||||
>
|
||||
<div className={`${dotClasses} bg-green-500 animate-pulse`} />
|
||||
<span className={`${textClasses} text-green-700 dark:text-green-400`}>Jump to Live</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
className={`${baseClasses} bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors cursor-pointer`}
|
||||
onClick={jumpToLatest}
|
||||
>
|
||||
<div className={`${dotClasses} bg-blue-500`} />
|
||||
<span className={`${textClasses} text-blue-700 dark:text-blue-400`}>Jump to Latest</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [isLiveMode, agentStatus, jumpToLive, jumpToLatest]);
|
||||
|
||||
const handleSliderChange = React.useCallback(([newValue]: [number]) => {
|
||||
const targetSnapshot = completedToolCalls[newValue];
|
||||
if (targetSnapshot) {
|
||||
|
@ -637,43 +683,9 @@ export function ToolCallSidePanel({
|
|||
<div
|
||||
className={cn(
|
||||
'border-t border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900',
|
||||
isMobile ? 'p-3' : 'p-4 space-y-2',
|
||||
isMobile ? 'p-2' : 'px-4 py-2.5',
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="h-5 w-5 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
|
||||
<CurrentToolIcon className="h-3 w-3 text-zinc-800 dark:text-zinc-300" />
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-medium text-zinc-700 dark:text-zinc-300 truncate"
|
||||
title={currentToolName}
|
||||
>
|
||||
{getUserFriendlyToolName(currentToolName)} {isStreaming && `(Running${dots})`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isLiveMode && agentStatus === 'running' && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-xs font-medium text-green-700 dark:text-green-400">Live</span>
|
||||
</div>
|
||||
)}
|
||||
{!isLiveMode && agentStatus !== 'running' && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-neutral-50 dark:bg-neutral-900/20 border border-neutral-200 dark:border-neutral-800">
|
||||
<div className="w-1.5 h-1.5 bg-neutral-500 rounded-full"></div>
|
||||
<span className="text-xs font-medium text-neutral-700 dark:text-neutral-400">Live</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400 flex-shrink-0">
|
||||
Step {displayIndex + 1} of {displayTotalCalls}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMobile ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
|
@ -681,31 +693,17 @@ export function ToolCallSidePanel({
|
|||
size="sm"
|
||||
onClick={navigateToPrevious}
|
||||
disabled={displayIndex <= 0}
|
||||
className="h-9 px-3"
|
||||
className="h-8 px-2.5 text-xs"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
<span>Previous</span>
|
||||
<ChevronLeft className="h-3.5 w-3.5 mr-1" />
|
||||
<span>Prev</span>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isLiveMode && agentStatus === 'running' ? (
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-xs font-medium text-green-700 dark:text-green-400">Live</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-neutral-50 dark:bg-neutral-900/20 border border-neutral-200 dark:border-neutral-800">
|
||||
<div className="w-1.5 h-1.5 bg-neutral-500 rounded-full"></div>
|
||||
<span className="text-xs font-medium text-neutral-700 dark:text-neutral-400">Live</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{displayIndex + 1} / {displayTotalCalls}
|
||||
{isCurrentToolStreaming && totalCompletedCalls > 0 && (
|
||||
<span className="text-blue-600 dark:text-blue-400"> • Running</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-zinc-600 dark:text-zinc-400 font-medium tabular-nums min-w-[44px]">
|
||||
{displayIndex + 1}/{displayTotalCalls}
|
||||
</span>
|
||||
{renderStatusButton()}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
@ -713,60 +711,52 @@ export function ToolCallSidePanel({
|
|||
size="sm"
|
||||
onClick={navigateToNext}
|
||||
disabled={displayIndex >= displayTotalCalls - 1}
|
||||
className="h-9 px-3"
|
||||
className="h-8 px-2.5 text-xs"
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
<ChevronRight className="h-3.5 w-3.5 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={navigateToPrevious}
|
||||
disabled={displayIndex <= 0}
|
||||
className="h-6 w-6 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
|
||||
className="h-7 w-7 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-zinc-600 dark:text-zinc-400 font-medium tabular-nums px-1 min-w-[44px] text-center">
|
||||
{displayIndex + 1}/{displayTotalCalls}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={navigateToNext}
|
||||
disabled={displayIndex >= displayTotalCalls - 1}
|
||||
className="h-6 w-6 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
|
||||
className="h-7 w-7 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full">
|
||||
{(showJumpToLive || showJumpToLatest) && (
|
||||
<div className="absolute -top-12 left-1/2 transform -translate-x-1/2 z-10">
|
||||
{showJumpToLive && (
|
||||
<Button className='rounded-full bg-red-500 hover:bg-red-400 text-white' onClick={jumpToLive}>
|
||||
Jump to Live
|
||||
</Button>
|
||||
)}
|
||||
{showJumpToLatest && (
|
||||
<Button className='rounded-full' onClick={jumpToLive}>
|
||||
Jump to Latest
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 relative">
|
||||
<Slider
|
||||
min={0}
|
||||
max={displayTotalCalls - 1}
|
||||
step={1}
|
||||
value={[displayIndex]}
|
||||
onValueChange={handleSliderChange}
|
||||
className="w-full [&>span:first-child]:h-1 [&>span:first-child]:bg-zinc-200 dark:[&>span:first-child]:bg-zinc-800 [&>span:first-child>span]:bg-zinc-500 dark:[&>span:first-child>span]:bg-zinc-400 [&>span:first-child>span]:h-1"
|
||||
className="w-full [&>span:first-child]:h-1.5 [&>span:first-child]:bg-zinc-200 dark:[&>span:first-child]:bg-zinc-800 [&>span:first-child>span]:bg-zinc-500 dark:[&>span:first-child>span]:bg-zinc-400 [&>span:first-child>span]:h-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{renderStatusButton()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -65,7 +65,7 @@ export function BrowserToolView({
|
|||
const [imageError, setImageError] = React.useState(false);
|
||||
|
||||
try {
|
||||
const topLevelParsed = safeJsonParse<{ content?: string }>(toolContent, {});
|
||||
const topLevelParsed = safeJsonParse<{ content?: any }>(toolContent, {});
|
||||
const innerContentString = topLevelParsed?.content || toolContent;
|
||||
if (innerContentString && typeof innerContentString === 'string') {
|
||||
const toolResultMatch = innerContentString.match(/ToolResult\([^)]*output='([\s\S]*?)'(?:\s*,|\s*\))/);
|
||||
|
@ -116,7 +116,18 @@ export function BrowserToolView({
|
|||
screenshotUrl = finalParsedOutput?.image_url || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (innerContentString && typeof innerContentString === "object") {
|
||||
screenshotUrl = (() => {
|
||||
if (!innerContentString) return null;
|
||||
if (!("tool_execution" in innerContentString)) return null;
|
||||
if (!("result" in innerContentString.tool_execution)) return null;
|
||||
if (!("output" in innerContentString.tool_execution.result)) return null;
|
||||
if (!("image_url" in innerContentString.tool_execution.result.output)) return null;
|
||||
if (typeof innerContentString.tool_execution.result.output.image_url !== "string") return null;
|
||||
return innerContentString.tool_execution.result.output.image_url;
|
||||
})()
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
|
||||
|
|
|
@ -35,7 +35,7 @@ export function AskToolView({
|
|||
onFileClick,
|
||||
project,
|
||||
}: AskToolViewProps) {
|
||||
|
||||
|
||||
const {
|
||||
text,
|
||||
attachments,
|
||||
|
@ -190,12 +190,7 @@ export function AskToolView({
|
|||
})}
|
||||
</div>
|
||||
|
||||
{actualAssistantTimestamp && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTimestamp(actualAssistantTimestamp)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
|
|
|
@ -68,16 +68,16 @@ export function CommandToolView({
|
|||
}
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
|
||||
processedOutput = String(processedOutput);
|
||||
processedOutput = processedOutput.replace(/\\\\/g, '\\');
|
||||
|
||||
|
||||
processedOutput = processedOutput
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\'/g, "'");
|
||||
|
||||
|
||||
processedOutput = processedOutput.replace(/\\u([0-9a-fA-F]{4})/g, (match, group) => {
|
||||
return String.fromCharCode(parseInt(group, 16));
|
||||
});
|
||||
|
@ -102,13 +102,13 @@ export function CommandToolView({
|
|||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{!isStreaming && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
actualIsSuccess
|
||||
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
|
||||
actualIsSuccess
|
||||
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
|
||||
: "bg-gradient-to-b from-rose-200 to-rose-100 text-rose-700 dark:from-rose-800/50 dark:to-rose-900/60 dark:text-rose-300"
|
||||
}
|
||||
>
|
||||
|
@ -117,8 +117,8 @@ export function CommandToolView({
|
|||
) : (
|
||||
<AlertTriangle className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{actualIsSuccess ?
|
||||
(name === 'check-command-output' ? 'Output retrieved successfully' : 'Command executed successfully') :
|
||||
{actualIsSuccess ?
|
||||
(name === 'check-command-output' ? 'Output retrieved successfully' : 'Command executed successfully') :
|
||||
(name === 'check-command-output' ? 'Failed to retrieve output' : 'Command failed')
|
||||
}
|
||||
</Badge>
|
||||
|
@ -128,7 +128,7 @@ export function CommandToolView({
|
|||
|
||||
<CardContent className="p-0 h-full flex-1 overflow-hidden relative">
|
||||
{isStreaming ? (
|
||||
<LoadingState
|
||||
<LoadingState
|
||||
icon={Terminal}
|
||||
iconColor="text-purple-500 dark:text-purple-400"
|
||||
bgColor="bg-gradient-to-b from-purple-100 to-purple-50 shadow-inner dark:from-purple-800/40 dark:to-purple-900/60 dark:shadow-purple-950/20"
|
||||
|
@ -139,52 +139,12 @@ export function CommandToolView({
|
|||
) : displayText ? (
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="p-4">
|
||||
<div className="mb-4 bg-zinc-100 dark:bg-neutral-900 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-800">
|
||||
<div className="bg-zinc-200 dark:bg-zinc-800 px-4 py-2 flex items-center gap-2">
|
||||
<Code className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">{displayLabel}</span>
|
||||
{sessionName && cwd && (
|
||||
<Badge variant="outline" className="text-xs ml-auto">
|
||||
{cwd}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 font-mono text-sm text-zinc-700 dark:text-zinc-300 flex gap-2">
|
||||
<span className="text-purple-500 dark:text-purple-400 select-none">{displayPrefix}</span>
|
||||
<code className="flex-1 break-all">{displayText}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{output && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-zinc-700 dark:text-zinc-300 flex items-center">
|
||||
<ArrowRight className="h-4 w-4 mr-2 text-zinc-500 dark:text-zinc-400" />
|
||||
Output
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{completed !== null && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
>
|
||||
{completed ? 'Completed' : 'Running'}
|
||||
</Badge>
|
||||
)}
|
||||
{exitCode !== null && (
|
||||
<Badge
|
||||
className={cn(
|
||||
exitCode === 0
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
|
||||
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
)}
|
||||
>
|
||||
{exitCode === 0 ? 'Success' : `Exit ${exitCode}`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="bg-zinc-100 dark:bg-neutral-900 rounded-lg overflow-hidden border border-zinc-200/20">
|
||||
<div className="bg-zinc-300 dark:bg-neutral-800 flex items-center justify-between dark:border-zinc-700/50">
|
||||
<div className="bg-zinc-200 w-full dark:bg-zinc-800 px-4 py-2 flex items-center gap-2">
|
||||
|
@ -201,8 +161,8 @@ export function CommandToolView({
|
|||
<div className="p-4 max-h-96 overflow-auto scrollbar-hide">
|
||||
<pre className="text-xs text-zinc-600 dark:text-zinc-300 font-mono whitespace-pre-wrap break-all overflow-visible">
|
||||
{linesToShow.map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
<div
|
||||
key={index}
|
||||
className="py-0.5 bg-transparent"
|
||||
>
|
||||
{line || ' '}
|
||||
|
@ -218,7 +178,7 @@ export function CommandToolView({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{!output && !isStreaming && (
|
||||
<div className="bg-black rounded-lg overflow-hidden border border-zinc-700/20 shadow-md p-6 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
@ -238,7 +198,7 @@ export function CommandToolView({
|
|||
{name === 'check-command-output' ? 'No Session Found' : 'No Command Found'}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 text-center max-w-md">
|
||||
{name === 'check-command-output'
|
||||
{name === 'check-command-output'
|
||||
? 'No session name was detected. Please provide a valid session name to check.'
|
||||
: 'No command was detected. Please provide a valid command to execute.'
|
||||
}
|
||||
|
@ -246,7 +206,7 @@ export function CommandToolView({
|
|||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
|
||||
<div className="px-4 py-2 h-10 bg-gradient-to-r from-zinc-50/90 to-zinc-100/90 dark:from-zinc-900/90 dark:to-zinc-800/90 backdrop-blur-sm border-t border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-4">
|
||||
<div className="h-full flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{!isStreaming && displayText && (
|
||||
|
@ -256,7 +216,7 @@ export function CommandToolView({
|
|||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400 flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{actualToolTimestamp && !isStreaming
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import { ComponentPropsWithoutRef, CSSProperties, FC } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AnimatedShinyTextProps
|
||||
extends ComponentPropsWithoutRef<"span"> {
|
||||
shimmerWidth?: number;
|
||||
}
|
||||
|
||||
export const AnimatedShinyText: FC<AnimatedShinyTextProps> = ({
|
||||
children,
|
||||
className,
|
||||
shimmerWidth = 100,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<span
|
||||
style={
|
||||
{
|
||||
"--shiny-width": `${shimmerWidth}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"mx-auto max-w-md text-neutral-600/70 dark:text-neutral-400/70",
|
||||
"animate-shiny-text bg-clip-text bg-no-repeat [background-position:0_0] [background-size:var(--shiny-width)_100%] [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]",
|
||||
"bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import { isLocalMode } from './lib/config';
|
||||
|
||||
export const agentPlaygroundFlagFrontend = isLocalMode();
|
||||
export const marketplaceFlagFrontend = isLocalMode();
|
||||
|
||||
export const agentPlaygroundEnabled = isLocalMode();
|
||||
export const marketplaceEnabled = isLocalMode();
|
|
@ -1,4 +1,5 @@
|
|||
import { createClient } from "@/lib/supabase/client";
|
||||
import { isFlagEnabled } from "@/lib/feature-flags";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
||||
|
||||
|
@ -98,6 +99,10 @@ export type AgentUpdateRequest = {
|
|||
|
||||
export const getAgents = async (params: AgentsParams = {}): Promise<AgentsResponse> => {
|
||||
try {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
throw new Error('Custom agents is not enabled');
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -142,6 +147,10 @@ export const getAgents = async (params: AgentsParams = {}): Promise<AgentsRespon
|
|||
|
||||
export const getAgent = async (agentId: string): Promise<Agent> => {
|
||||
try {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
throw new Error('Custom agents is not enabled');
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -173,6 +182,10 @@ export const getAgent = async (agentId: string): Promise<Agent> => {
|
|||
|
||||
export const createAgent = async (agentData: AgentCreateRequest): Promise<Agent> => {
|
||||
try {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
throw new Error('Custom agents is not enabled');
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -205,6 +218,10 @@ export const createAgent = async (agentData: AgentCreateRequest): Promise<Agent>
|
|||
|
||||
export const updateAgent = async (agentId: string, agentData: AgentUpdateRequest): Promise<Agent> => {
|
||||
try {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
throw new Error('Custom agents is not enabled');
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -237,6 +254,10 @@ export const updateAgent = async (agentId: string, agentData: AgentUpdateRequest
|
|||
|
||||
export const deleteAgent = async (agentId: string): Promise<void> => {
|
||||
try {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
throw new Error('Custom agents is not enabled');
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -266,6 +287,10 @@ export const deleteAgent = async (agentId: string): Promise<void> => {
|
|||
|
||||
export const getThreadAgent = async (threadId: string): Promise<ThreadAgentResponse> => {
|
||||
try {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
throw new Error('Custom agents is not enabled');
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -297,6 +322,10 @@ export const getThreadAgent = async (threadId: string): Promise<ThreadAgentRespo
|
|||
|
||||
export const getAgentBuilderChatHistory = async (agentId: string): Promise<{messages: any[], thread_id: string | null}> => {
|
||||
try {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
throw new Error('Custom agents is not enabled');
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -364,6 +393,10 @@ export const startAgentBuilderChat = async (
|
|||
signal?: AbortSignal
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
throw new Error('Custom agents is not enabled');
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { isFlagEnabled } from '@/lib/feature-flags';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
||||
|
||||
|
@ -45,6 +46,11 @@ export function useMarketplaceAgents(params: MarketplaceAgentsParams = {}) {
|
|||
queryKey: ['marketplace-agents', params],
|
||||
queryFn: async (): Promise<MarketplaceAgentsResponse> => {
|
||||
try {
|
||||
const marketplaceEnabled = await isFlagEnabled('agent_marketplace');
|
||||
if (!marketplaceEnabled) {
|
||||
throw new Error('Marketplace is not enabled');
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -97,6 +103,11 @@ export function useAddAgentToLibrary() {
|
|||
return useMutation({
|
||||
mutationFn: async (originalAgentId: string): Promise<string> => {
|
||||
try {
|
||||
const marketplaceEnabled = await isFlagEnabled('agent_marketplace');
|
||||
if (!marketplaceEnabled) {
|
||||
throw new Error('Marketplace is not enabled');
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -138,6 +149,11 @@ export function usePublishAgent() {
|
|||
return useMutation({
|
||||
mutationFn: async ({ agentId, tags = [] }: { agentId: string; tags?: string[] }): Promise<void> => {
|
||||
try {
|
||||
const marketplaceEnabled = await isFlagEnabled('agent_marketplace');
|
||||
if (!marketplaceEnabled) {
|
||||
throw new Error('Marketplace is not enabled');
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -175,6 +191,11 @@ export function useUnpublishAgent() {
|
|||
return useMutation({
|
||||
mutationFn: async (agentId: string): Promise<void> => {
|
||||
try {
|
||||
const marketplaceEnabled = await isFlagEnabled('agent_marketplace');
|
||||
if (!marketplaceEnabled) {
|
||||
throw new Error('Marketplace is not enabled');
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -211,6 +232,11 @@ export function useUserAgentLibrary() {
|
|||
queryKey: ['user-agent-library'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const marketplaceEnabled = await isFlagEnabled('agent_marketplace');
|
||||
if (!marketplaceEnabled) {
|
||||
throw new Error('Marketplace is not enabled');
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
|
|
@ -115,7 +115,7 @@ export const processThreadsWithProjects = (
|
|||
threadId: thread.thread_id,
|
||||
projectId: projectId,
|
||||
projectName: project.name || 'Unnamed Project',
|
||||
url: `/agents/${thread.thread_id}`,
|
||||
url: `/projects/${projectId}/thread/${thread.thread_id}`,
|
||||
updatedAt:
|
||||
thread.updated_at || project.updated_at || new Date().toISOString(),
|
||||
});
|
||||
|
|
|
@ -0,0 +1,254 @@
|
|||
import React from 'react';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
||||
|
||||
export interface FeatureFlag {
|
||||
flag_name: string;
|
||||
enabled: boolean;
|
||||
details?: {
|
||||
description?: string;
|
||||
updated_at?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface FeatureFlagsResponse {
|
||||
flags: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const flagCache = new Map<string, { value: boolean; timestamp: number }>();
|
||||
const CACHE_DURATION = 5 * 60 * 1000;
|
||||
|
||||
let globalFlagsCache: { flags: Record<string, boolean>; timestamp: number } | null = null;
|
||||
|
||||
export class FeatureFlagManager {
|
||||
private static instance: FeatureFlagManager;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): FeatureFlagManager {
|
||||
if (!FeatureFlagManager.instance) {
|
||||
FeatureFlagManager.instance = new FeatureFlagManager();
|
||||
}
|
||||
return FeatureFlagManager.instance;
|
||||
}
|
||||
|
||||
async isEnabled(flagName: string): Promise<boolean> {
|
||||
try {
|
||||
const cached = flagCache.get(flagName);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
||||
return cached.value;
|
||||
}
|
||||
const response = await fetch(`${API_URL}/feature-flags/${flagName}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch feature flag ${flagName}: ${response.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data: FeatureFlag = await response.json();
|
||||
|
||||
flagCache.set(flagName, {
|
||||
value: data.enabled,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return data.enabled;
|
||||
} catch (error) {
|
||||
console.error(`Error checking feature flag ${flagName}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getFlagDetails(flagName: string): Promise<FeatureFlag | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/feature-flags/${flagName}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch feature flag details for ${flagName}: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: FeatureFlag = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching feature flag details for ${flagName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllFlags(): Promise<Record<string, boolean>> {
|
||||
try {
|
||||
if (globalFlagsCache && Date.now() - globalFlagsCache.timestamp < CACHE_DURATION) {
|
||||
return globalFlagsCache.flags;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/feature-flags`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch all feature flags: ${response.status}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
const data: FeatureFlagsResponse = await response.json();
|
||||
globalFlagsCache = {
|
||||
flags: data.flags,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
Object.entries(data.flags).forEach(([flagName, enabled]) => {
|
||||
flagCache.set(flagName, {
|
||||
value: enabled,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
return data.flags;
|
||||
} catch (error) {
|
||||
console.error('Error fetching all feature flags:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
flagCache.clear();
|
||||
globalFlagsCache = null;
|
||||
}
|
||||
|
||||
async preloadFlags(flagNames: string[]): Promise<void> {
|
||||
try {
|
||||
const promises = flagNames.map(flagName => this.isEnabled(flagName));
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
console.error('Error preloading feature flags:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const featureFlagManager = FeatureFlagManager.getInstance();
|
||||
|
||||
export const isEnabled = (flagName: string): Promise<boolean> => {
|
||||
return featureFlagManager.isEnabled(flagName);
|
||||
};
|
||||
|
||||
export const isFlagEnabled = isEnabled;
|
||||
|
||||
export const getFlagDetails = (flagName: string): Promise<FeatureFlag | null> => {
|
||||
return featureFlagManager.getFlagDetails(flagName);
|
||||
};
|
||||
|
||||
export const getAllFlags = (): Promise<Record<string, boolean>> => {
|
||||
return featureFlagManager.getAllFlags();
|
||||
};
|
||||
|
||||
export const clearFlagCache = (): void => {
|
||||
featureFlagManager.clearCache();
|
||||
};
|
||||
|
||||
export const preloadFlags = (flagNames: string[]): Promise<void> => {
|
||||
return featureFlagManager.preloadFlags(flagNames);
|
||||
};
|
||||
|
||||
export const useFeatureFlag = (flagName: string) => {
|
||||
const [enabled, setEnabled] = React.useState<boolean>(false);
|
||||
const [loading, setLoading] = React.useState<boolean>(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const checkFlag = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await isFlagEnabled(flagName);
|
||||
if (mounted) {
|
||||
setEnabled(result);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setEnabled(false);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkFlag();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [flagName]);
|
||||
|
||||
return { enabled, loading, error };
|
||||
};
|
||||
|
||||
export const useFeatureFlags = (flagNames: string[]) => {
|
||||
const [flags, setFlags] = React.useState<Record<string, boolean>>({});
|
||||
const [loading, setLoading] = React.useState<boolean>(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const checkFlags = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const results = await Promise.all(
|
||||
flagNames.map(async (flagName) => {
|
||||
const enabled = await isFlagEnabled(flagName);
|
||||
return [flagName, enabled] as [string, boolean];
|
||||
})
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
const flagsObject = Object.fromEntries(results);
|
||||
setFlags(flagsObject);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
const disabledFlags = Object.fromEntries(
|
||||
flagNames.map(name => [name, false])
|
||||
);
|
||||
setFlags(disabledFlags);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (flagNames.length > 0) {
|
||||
checkFlags();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [flagNames.join(',')]);
|
||||
|
||||
return { flags, loading, error };
|
||||
};
|
2
setup.py
2
setup.py
|
@ -237,7 +237,7 @@ def collect_daytona_info():
|
|||
print_info("Then, generate an API key from 'Keys' menu")
|
||||
print_info("After that, go to Images (https://app.daytona.io/dashboard/images)")
|
||||
print_info("Click '+ Create Image'")
|
||||
print_info(f"Enter 'kortix/suna:0.1.2.8' as the image name")
|
||||
print_info(f"Enter 'kortix/suna:0.1.3' as the image name")
|
||||
print_info(f"Set '/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf' as the Entrypoint")
|
||||
|
||||
input("Press Enter to continue once you've completed these steps...")
|
||||
|
|
Loading…
Reference in New Issue