Merge branch 'main' into fix-ux-issues

This commit is contained in:
Saumya 2025-07-21 10:53:56 +05:30
commit 8188a83a6d
43 changed files with 4421 additions and 1544 deletions

View File

@ -576,7 +576,7 @@ For casual conversation and social interactions:
* Attach all relevant files with the **'ask'** tool when asking a question related to them, or when delivering final results before completion.
* Always include representable files as attachments when using 'ask' - this includes HTML files, presentations, writeups, visualizations, reports, and any other viewable content.
* For any created files that can be viewed or presented (such as index.html, slides, documents, charts, etc.), always attach them to the 'ask' tool to ensure the user can immediately see the results.
* Share results and deliverables before entering complete state (use 'ask' with attachments as appropriate).
* Always share results and deliverables using 'ask' tool with attachments before entering complete state, or include them directly with the 'complete' tool. Do not use 'complete' tool directly before using 'ask' tool unless you're including all necessary attachments.
* Ensure users have access to all necessary resources.
- Communication Tools Summary:

View File

@ -217,31 +217,62 @@ If you encounter any issues or need to take additional steps, please let me know
"type": "function",
"function": {
"name": "complete",
"description": "A special tool to indicate you have completed all tasks and are about to enter complete state. Use ONLY when: 1) All tasks in todo.md are marked complete [x], 2) The user's original request has been fully addressed, 3) There are no pending actions or follow-ups required, 4) You've delivered all final outputs and results to the user. IMPORTANT: This is the ONLY way to properly terminate execution. Never use this tool unless ALL tasks are complete and verified. Always ensure you've provided all necessary outputs and references before using this tool.",
"description": "A special tool to indicate you have completed all tasks and are about to enter complete state. Use ONLY when: 1) All tasks in todo.md are marked complete [x], 2) The user's original request has been fully addressed, 3) There are no pending actions or follow-ups required, 4) You've delivered all final outputs and results to the user. IMPORTANT: This is the ONLY way to properly terminate execution. Never use this tool unless ALL tasks are complete and verified. Always ensure you've provided all necessary outputs and references before using this tool. Include relevant attachments when the completion relates to specific files or resources.",
"parameters": {
"type": "object",
"properties": {},
"properties": {
"text": {
"type": "string",
"description": "Completion message or summary to present to user - should provide clear indication of what was accomplished. Include: 1) Summary of completed tasks, 2) Key deliverables or outputs, 3) Any important notes or next steps, 4) Impact or benefits achieved."
},
"attachments": {
"anyOf": [
{"type": "string"},
{"items": {"type": "string"}, "type": "array"}
],
"description": "(Optional) List of files or URLs to attach to the completion message. Include when: 1) Completion relates to specific files or configurations, 2) User needs to review final outputs, 3) Deliverables are documented in files, 4) Supporting evidence or context is needed. Always use relative paths to /workspace directory."
}
},
"required": []
}
}
})
@xml_schema(
tag_name="complete",
mappings=[],
mappings=[
{"param_name": "text", "node_type": "content", "path": ".", "required": False},
{"param_name": "attachments", "node_type": "attribute", "path": ".", "required": False}
],
example='''
<function_calls>
<invoke name="complete">
<parameter name="text">I have successfully completed all tasks for your project. Here's what was accomplished:
1. Created the web application with modern UI components
2. Implemented user authentication and database integration
3. Deployed the application to production
4. Created comprehensive documentation
All deliverables are attached for your review.</parameter>
<parameter name="attachments">app/src/main.js,docs/README.md,deployment-config.yaml</parameter>
</invoke>
</function_calls>
'''
)
async def complete(self) -> ToolResult:
async def complete(self, text: Optional[str] = None, attachments: Optional[Union[str, List[str]]] = None) -> ToolResult:
"""Indicate that the agent has completed all tasks and is entering complete state.
Args:
text: Optional completion message or summary to present to the user
attachments: Optional file paths or URLs to attach to the completion message
Returns:
ToolResult indicating successful transition to complete state
"""
try:
# Convert single attachment to list for consistent handling
if attachments and isinstance(attachments, str):
attachments = [attachments]
return self.success_response({"status": "complete"})
except Exception as e:
return self.fail_response(f"Error entering complete state: {str(e)}")

View File

@ -155,7 +155,7 @@ app.add_middleware(
allow_origin_regex=allow_origin_regex,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-Project-Id", "X-MCP-URL", "X-MCP-Type", "X-MCP-Headers"],
allow_headers=["Content-Type", "Authorization", "X-Project-Id", "X-MCP-URL", "X-MCP-Type", "X-MCP-Headers", "X-Refresh-Token"],
)
# Create a main API router
@ -187,6 +187,9 @@ api_router.include_router(workflows_router, prefix="/workflows")
from pipedream import api as pipedream_api
api_router.include_router(pipedream_api.router)
from auth import phone_verification_supabase_mfa
api_router.include_router(phone_verification_supabase_mfa.router)
@api_router.get("/health")
async def health_check():
logger.info("Health check endpoint called")

1
backend/auth/__init__.py Normal file
View File

@ -0,0 +1 @@
# Auth Module

View File

@ -0,0 +1,591 @@
"""
Auth MFA endpoints for Supabase Phone-based Multi-Factor Authentication (MFA).
Currently, only SMS is supported as a second factor. Users can enroll their phone number for SMS-based 2FA.
No recovery codes are supported, but users can update their phone number for backup.
This API provides endpoints to:
- Enroll a phone number for SMS 2FA
- Create a challenge for a phone factor (sends SMS)
- Verify a challenge with SMS code
- Create and verify a challenge in a single step
- List enrolled factors
- Unenroll a factor
- Get Authenticator Assurance Level (AAL)
"""
import json
import os
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from typing import List, Optional
import jwt
from datetime import datetime, timezone
from supabase import create_client, Client
from utils.auth_utils import get_current_user_id_from_jwt
from utils.config import config
from utils.logger import logger, structlog
router = APIRouter(prefix="/mfa", tags=["MFA"])
# Initialize Supabase client with anon key for user operations
supabase_url = config.SUPABASE_URL
supabase_anon_key = config.SUPABASE_ANON_KEY
# Cutoff date for new user phone verification requirement
# Users created after this date will be required to have phone verification
# Users created before this date are grandfathered in and not required to verify
PHONE_VERIFICATION_CUTOFF_DATE = datetime(2025, 7, 21, 0, 0, 0, tzinfo=timezone.utc)
def is_phone_verification_mandatory() -> bool:
"""Check if phone verification is mandatory based on environment variable."""
env_val = os.getenv("PHONE_NUMBER_MANDATORY")
if env_val is None:
return False
return env_val.lower() in ('true', 't', 'yes', 'y', '1')
def get_authenticated_client(request: Request) -> Client:
"""
Create a Supabase client authenticated with the user's JWT token.
This approach uses the JWT token directly for server-side authentication.
"""
# Extract the JWT token from the Authorization header
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(
status_code=401, detail="Missing or invalid Authorization header"
)
token = auth_header.split(" ")[1]
# Extract the refresh token from the custom header
refresh_token = request.headers.get("X-Refresh-Token")
# Create a new Supabase client with the anon key
client = create_client(supabase_url, supabase_anon_key)
# Set the session with the JWT token
# For server-side operations, we can use the token directly
try:
# Verify the token is valid by getting the user
user_response = client.auth.get_user(token)
if not user_response.user:
raise HTTPException(status_code=401, detail="Invalid token")
# Set the session with both access and refresh tokens
client.auth.set_session(token, refresh_token)
return client
except Exception as e:
raise HTTPException(status_code=401, detail=f"Authentication failed: {str(e)}")
# Request/Response Models
class EnrollFactorRequest(BaseModel):
friendly_name: str = Field(..., description="User-friendly name for the factor")
phone_number: str = Field(
..., description="Phone number in E.164 format (e.g., +1234567890)"
)
class EnrollFactorResponse(BaseModel):
id: str
friendly_name: str
phone_number: str
# Note: Supabase response may not include status, created_at, updated_at
qr_code: Optional[str] = None
secret: Optional[str] = None
class ChallengeRequest(BaseModel):
factor_id: str = Field(..., description="ID of the factor to challenge")
class ChallengeResponse(BaseModel):
id: str
expires_at: Optional[str] = None
# Note: Supabase response may not include factor_type, created_at
class VerifyRequest(BaseModel):
factor_id: str = Field(..., description="ID of the factor to verify")
challenge_id: str = Field(..., description="ID of the challenge to verify")
code: str = Field(..., description="SMS code received on phone")
class ChallengeAndVerifyRequest(BaseModel):
factor_id: str = Field(..., description="ID of the factor to challenge and verify")
code: str = Field(..., description="SMS code received on phone")
class FactorInfo(BaseModel):
id: str
friendly_name: Optional[str] = None
factor_type: Optional[str] = None
status: Optional[str] = None
phone: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
class ListFactorsResponse(BaseModel):
factors: List[FactorInfo]
class UnenrollRequest(BaseModel):
factor_id: str = Field(..., description="ID of the factor to unenroll")
class AALResponse(BaseModel):
current_level: Optional[str] = None
next_level: Optional[str] = None
current_authentication_methods: Optional[List[str]] = None
# Add action guidance based on AAL status
action_required: Optional[str] = None
message: Optional[str] = None
# Phone verification requirement fields
phone_verification_required: Optional[bool] = None
user_created_at: Optional[str] = None
cutoff_date: Optional[str] = None
# Computed verification status fields
verification_required: Optional[bool] = None
is_verified: Optional[bool] = None
factors: Optional[List[dict]] = None
@router.post("/enroll", response_model=EnrollFactorResponse)
async def enroll_factor(
request_data: EnrollFactorRequest,
client: Client = Depends(get_authenticated_client),
user_id: str = Depends(get_current_user_id_from_jwt),
):
"""
Enroll a new phone number for SMS-based 2FA.
Currently only supports 'phone' factor type.
Phone number must be in E.164 format (e.g., +1234567890).
"""
structlog.contextvars.bind_contextvars(
user_id=user_id,
action="mfa_enroll",
phone_number=request_data.phone_number,
friendly_name=request_data.friendly_name
)
try:
response = client.auth.mfa.enroll(
{
"factor_type": "phone",
"friendly_name": request_data.friendly_name,
"phone": request_data.phone_number,
}
)
# Build response with defensive field access
enroll_response = EnrollFactorResponse(
id=response.id,
friendly_name=request_data.friendly_name, # Use request data as fallback
phone_number=request_data.phone_number,
)
# Add optional fields if they exist
if hasattr(response, "qr_code"):
enroll_response.qr_code = response.qr_code
if hasattr(response, "secret"):
enroll_response.secret = response.secret
return enroll_response
except Exception as e:
raise HTTPException(
status_code=400, detail=f"Failed to enroll phone factor: {str(e)}"
)
@router.post("/challenge", response_model=ChallengeResponse)
async def create_challenge(
request_data: ChallengeRequest,
client: Client = Depends(get_authenticated_client),
user_id: str = Depends(get_current_user_id_from_jwt),
):
"""
Create a challenge for an enrolled phone factor.
This will send an SMS code to the registered phone number.
The challenge must be verified within the time limit.
"""
structlog.contextvars.bind_contextvars(
user_id=user_id,
action="mfa_challenge",
factor_id=request_data.factor_id
)
try:
response = client.auth.mfa.challenge(
{
"factor_id": request_data.factor_id,
}
)
# Build response with defensive field access
challenge_response = ChallengeResponse(id=response.id)
# Add optional fields if they exist
if hasattr(response, "expires_at"):
challenge_response.expires_at = response.expires_at
return challenge_response
except Exception as e:
raise HTTPException(
status_code=400, detail=f"Failed to create SMS challenge: {str(e)}"
)
@router.post("/verify")
async def verify_challenge(
request_data: VerifyRequest,
client: Client = Depends(get_authenticated_client),
user_id: str = Depends(get_current_user_id_from_jwt),
):
"""
Verify a challenge with an SMS code.
The challenge must be active and the SMS code must be valid.
"""
structlog.contextvars.bind_contextvars(
user_id=user_id,
action="mfa_verify",
factor_id=request_data.factor_id,
challenge_id=request_data.challenge_id
)
try:
logger.info(f"🔵 Starting MFA verification for user {user_id}: "
f"factor_id={request_data.factor_id}, "
f"challenge_id={request_data.challenge_id}")
# Check AAL BEFORE verification
try:
aal_before = client.auth.mfa.get_authenticator_assurance_level()
logger.info(f"📊 AAL BEFORE verification: "
f"current={aal_before.current_level}, "
f"next={aal_before.next_level}")
except Exception as e:
logger.warning(f"Failed to get AAL before verification: {e}")
# Verify the challenge
response = client.auth.mfa.verify(
{
"factor_id": request_data.factor_id,
"challenge_id": request_data.challenge_id,
"code": request_data.code,
}
)
logger.info(f"✅ MFA verification successful for user {user_id}")
logger.info(f"Verification response type: {type(response)}")
logger.info(f"Verification response attributes: {dir(response)}")
# Check if response has session info
if hasattr(response, 'session') and response.session:
logger.info(f"New session info: access_token present: {bool(getattr(response.session, 'access_token', None))}")
logger.info(f"New session user: {getattr(response.session, 'user', None)}")
# Check AAL AFTER verification
try:
aal_after = client.auth.mfa.get_authenticator_assurance_level()
logger.info(f"📊 AAL AFTER verification: "
f"current={aal_after.current_level}, "
f"next={aal_after.next_level}")
except Exception as e:
logger.warning(f"Failed to get AAL after verification: {e}")
# Check factor status AFTER verification
try:
user_response = client.auth.get_user()
if user_response.user and hasattr(user_response.user, "factors"):
for factor in user_response.user.factors:
if factor.id == request_data.factor_id:
logger.info(f"Factor {request_data.factor_id} status after verification: {getattr(factor, 'status', 'unknown')}")
break
except Exception as e:
logger.warning(f"Failed to check factor status after verification: {e}")
return {
"success": True,
"message": "SMS code verified successfully",
"session": response,
}
except Exception as e:
logger.error(f"❌ MFA verification failed for user {user_id}: {str(e)}")
raise HTTPException(
status_code=400, detail=f"Failed to verify SMS code: {str(e)}"
)
@router.post("/challenge-and-verify")
async def challenge_and_verify(
request_data: ChallengeAndVerifyRequest,
client: Client = Depends(get_authenticated_client),
user_id: str = Depends(get_current_user_id_from_jwt),
):
"""
Create a challenge and verify it in a single step.
This will send an SMS code and verify it immediately when provided.
This is a convenience method that combines challenge creation and verification.
"""
structlog.contextvars.bind_contextvars(
user_id=user_id,
action="mfa_challenge_and_verify",
factor_id=request_data.factor_id
)
try:
response = client.auth.mfa.challenge_and_verify(
{"factor_id": request_data.factor_id, "code": request_data.code}
)
return {
"success": True,
"message": "SMS challenge created and verified successfully",
"session": response,
}
except Exception as e:
raise HTTPException(
status_code=400, detail=f"Failed to challenge and verify SMS: {str(e)}"
)
@router.get("/factors", response_model=ListFactorsResponse)
async def list_factors(
client: Client = Depends(get_authenticated_client),
user_id: str = Depends(get_current_user_id_from_jwt),
):
"""
List all enrolled factors for the authenticated user.
"""
structlog.contextvars.bind_contextvars(
user_id=user_id,
action="mfa_list_factors"
)
try:
# Get user info which includes factors
user_response = client.auth.get_user()
if not user_response.user:
raise HTTPException(status_code=401, detail="User not found")
# Extract factors from user data with defensive access
factors = []
if hasattr(user_response.user, "factors") and user_response.user.factors:
for factor in user_response.user.factors:
# Convert datetime objects to strings for Pydantic validation
created_at = getattr(factor, "created_at", None)
if created_at and hasattr(created_at, "isoformat"):
created_at = created_at.isoformat()
updated_at = getattr(factor, "updated_at", None)
if updated_at and hasattr(updated_at, "isoformat"):
updated_at = updated_at.isoformat()
factor_info = FactorInfo(
id=factor.id if hasattr(factor, "id") else str(factor),
friendly_name=getattr(factor, "friendly_name", None),
factor_type=getattr(factor, "factor_type", None),
status=getattr(factor, "status", None),
phone=getattr(factor, "phone", None),
created_at=created_at,
updated_at=updated_at,
)
factors.append(factor_info)
return ListFactorsResponse(factors=factors)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to list factors: {str(e)}")
@router.post("/unenroll")
async def unenroll_factor(
request_data: UnenrollRequest,
client: Client = Depends(get_authenticated_client),
user_id: str = Depends(get_current_user_id_from_jwt),
):
"""
Unenroll a phone factor for the authenticated user.
This will remove the phone number and invalidate any active sessions if the factor was verified.
"""
structlog.contextvars.bind_contextvars(
user_id=user_id,
action="mfa_unenroll",
factor_id=request_data.factor_id
)
try:
response = client.auth.mfa.unenroll({"factor_id": request_data.factor_id})
return {"success": True, "message": "Phone factor unenrolled successfully"}
except Exception as e:
raise HTTPException(
status_code=400, detail=f"Failed to unenroll phone factor: {str(e)}"
)
@router.get("/aal", response_model=AALResponse)
async def get_authenticator_assurance_level(
client: Client = Depends(get_authenticated_client),
user_id: str = Depends(get_current_user_id_from_jwt),
):
"""
Get the Authenticator Assurance Level (AAL) for the current session.
This endpoint combines AAL status with phone verification requirements:
- aal1 -> aal1: User does not have MFA enrolled
- aal1 -> aal2: User has MFA enrolled but not verified (requires verification)
- aal2 -> aal2: User has verified their MFA factor
- aal2 -> aal1: User has disabled MFA (stale JWT, requires reauthentication)
Also includes phone verification requirement based on account creation date.
"""
structlog.contextvars.bind_contextvars(
user_id=user_id,
action="mfa_get_aal"
)
try:
# Get the current AAL from Supabase
response = client.auth.mfa.get_authenticator_assurance_level()
# Extract AAL levels from response first
current = response.current_level
next_level = response.next_level
# Get user creation date and factors for phone verification requirement
user_response = client.auth.get_user()
if not user_response.user:
raise HTTPException(status_code=401, detail="User not found")
user_created_at = None
if hasattr(user_response.user, 'created_at') and user_response.user.created_at:
try:
# Handle different possible formats for created_at
created_at_value = user_response.user.created_at
if isinstance(created_at_value, str):
# Parse ISO format string
user_created_at = datetime.fromisoformat(created_at_value.replace('Z', '+00:00'))
elif hasattr(created_at_value, 'isoformat'):
# Already a datetime object
user_created_at = created_at_value
if user_created_at.tzinfo is None:
user_created_at = user_created_at.replace(tzinfo=timezone.utc)
else:
logger.warning(f"Unexpected created_at type: {type(created_at_value)}")
except Exception as e:
logger.error(f"Failed to parse user created_at: {e}")
# Fall back to treating as new user for safety
user_created_at = datetime.now(timezone.utc)
# Determine if this is a new user who needs phone verification
is_new_user = (
user_created_at is not None and
user_created_at >= PHONE_VERIFICATION_CUTOFF_DATE
)
# Get factors and compute phone verification status
factors = []
phone_factors = []
has_verified_phone = False
if hasattr(user_response.user, "factors") and user_response.user.factors:
for factor in user_response.user.factors:
# Convert datetime objects to strings for JSON serialization
created_at = getattr(factor, "created_at", None)
if created_at and hasattr(created_at, "isoformat"):
created_at = created_at.isoformat()
updated_at = getattr(factor, "updated_at", None)
if updated_at and hasattr(updated_at, "isoformat"):
updated_at = updated_at.isoformat()
factor_dict = {
"id": factor.id if hasattr(factor, "id") else str(factor),
"friendly_name": getattr(factor, "friendly_name", None),
"factor_type": getattr(factor, "factor_type", None),
"status": getattr(factor, "status", None),
"phone": getattr(factor, "phone", None),
"created_at": created_at,
"updated_at": updated_at,
}
factors.append(factor_dict)
# Track phone factors
if factor_dict.get("factor_type") == "phone":
phone_factors.append(factor_dict)
if factor_dict.get("status") == "verified":
has_verified_phone = True
# Determine action required based on AAL combination
action_required = None
message = None
if current == "aal1" and next_level == "aal1":
# User does not have MFA enrolled
action_required = "none"
message = "MFA is not enrolled for this account"
elif current == "aal1" and next_level == "aal2":
# User has MFA enrolled but needs to verify it
action_required = "verify_mfa"
message = "MFA verification required to access full features"
elif current == "aal2" and next_level == "aal2":
# User has verified their MFA factor
action_required = "none"
message = "MFA is verified and active"
elif current == "aal2" and next_level == "aal1":
# User has disabled MFA or has stale JWT
action_required = "reauthenticate"
message = "Session needs refresh due to MFA changes"
else:
# Unknown combination
action_required = "unknown"
message = f"Unknown AAL combination: {current} -> {next_level}"
# Determine verification_required based on AAL status AND grandfathering logic
verification_required = False
if is_new_user:
# New users (created after cutoff date) must have phone verification
if current == 'aal1' and next_level == 'aal1':
# No MFA enrolled - new users must enroll
verification_required = True
elif action_required == 'verify_mfa':
# MFA enrolled but needs verification
verification_required = True
else:
# Existing users (grandfathered) - only require verification if AAL demands it
verification_required = action_required == 'verify_mfa'
phone_verification_required = is_new_user and is_phone_verification_mandatory()
verification_required = is_new_user and verification_required and is_phone_verification_mandatory()
logger.info(f"AAL check for user {user_id}: "
f"current_level={current}, "
f"next_level={next_level}, "
f"action_required={action_required}, "
f"phone_verification_required={phone_verification_required}, "
f"verification_required={verification_required}, "
f"is_verified={has_verified_phone}")
return AALResponse(
current_level=current,
next_level=next_level,
current_authentication_methods=[x.method for x in response.current_authentication_methods],
action_required=action_required,
message=message,
phone_verification_required=phone_verification_required,
user_created_at=user_created_at.isoformat() if user_created_at else None,
cutoff_date=PHONE_VERIFICATION_CUTOFF_DATE.isoformat(),
verification_required=verification_required,
is_verified=has_verified_phone,
factors=factors,
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to get AAL: {str(e)}")

View File

@ -28,7 +28,7 @@ dependencies = [
"python-multipart==0.0.20",
"redis==5.2.1",
"upstash-redis==1.3.0",
"supabase==2.15.0",
"supabase==2.17.0",
"pyjwt==2.10.1",
"exa-py==1.9.1",
"e2b-code-interpreter==1.2.0",

View File

@ -24,13 +24,14 @@ You can modify the sandbox environment for development or to add new capabilitie
```
3. Test your changes locally using docker-compose
## Using a Custom Image
## Using a Custom Snapshot
To use your custom sandbox image:
To use your custom sandbox snapshot:
1. Change the `image` parameter in `docker-compose.yml` (that defines the image name `kortix/suna:___`)
2. Update the same image name in `backend/sandbox/sandbox.py` in the `create_sandbox` function
3. If using Daytona for deployment, update the image reference there as well
2. Build and create a snapshot in Daytona with the same name
3. Update the snapshot name in `backend/sandbox/sandbox.py` in the `create_sandbox` function
4. If using Daytona for deployment, update the snapshot reference there as well
## Publishing New Versions
@ -39,7 +40,8 @@ When publishing a new version of the sandbox:
1. Update the version number in `docker-compose.yml` (e.g., from `0.1.2` to `0.1.3`)
2. Build the new image: `docker compose build`
3. Push the new version: `docker push kortix/suna:0.1.3`
4. Update all references to the image version in:
4. Create a new snapshot in Daytona with the same name
5. Update all references to the snapshot version in:
- `backend/utils/config.py`
- Daytona images
- Any other services using this image
- Daytona snapshots
- Any other services using this snapshot

View File

@ -1,4 +1,4 @@
from daytona_sdk import AsyncDaytona, DaytonaConfig, CreateSandboxFromImageParams, AsyncSandbox, SessionExecuteRequest, Resources, SandboxState
from daytona_sdk import AsyncDaytona, DaytonaConfig, CreateSandboxFromSnapshotParams, AsyncSandbox, SessionExecuteRequest, Resources, SandboxState
from dotenv import load_dotenv
from utils.logger import logger
from utils.config import config
@ -82,15 +82,15 @@ async def create_sandbox(password: str, project_id: str = None) -> AsyncSandbox:
"""Create a new sandbox with all required services configured and running."""
logger.debug("Creating new Daytona sandbox environment")
logger.debug("Configuring sandbox with browser-use image and environment variables")
logger.debug("Configuring sandbox with snapshot and environment variables")
labels = None
if project_id:
logger.debug(f"Using sandbox_id as label: {project_id}")
labels = {'id': project_id}
params = CreateSandboxFromImageParams(
image=Configuration.SANDBOX_IMAGE_NAME,
params = CreateSandboxFromSnapshotParams(
snapshot=Configuration.SANDBOX_SNAPSHOT_NAME,
public=True,
labels=labels,
env_vars={
@ -112,7 +112,7 @@ async def create_sandbox(password: str, project_id: str = None) -> AsyncSandbox:
disk=5,
),
auto_stop_interval=15,
auto_archive_interval=24 * 60,
auto_archive_interval=2 * 60,
)
# Create the sandbox

View File

@ -236,7 +236,7 @@ def prepare_params(
if is_kimi_k2:
params["provider"] = {
"order": ["groq", "together/fp8"]
"order": ["baseten/fp8", "together/fp8", "novita/fp8", "moonshotai", "groq"]
}
if is_anthropic and use_thinking:

View File

@ -222,6 +222,7 @@ class Configuration:
# Sandbox configuration
SANDBOX_IMAGE_NAME = "kortix/suna:0.1.3"
SANDBOX_SNAPSHOT_NAME = "kortix/suna:0.1.3"
SANDBOX_ENTRYPOINT = "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf"
# LangFuse configuration

View File

@ -8,7 +8,7 @@ MODELS = {
"input_cost_per_million_tokens": 3.00,
"output_cost_per_million_tokens": 15.00
},
"tier_availability": ["free", "paid"]
"tier_availability": ["paid"]
},
# "openrouter/deepseek/deepseek-chat": {
# "aliases": ["deepseek"],
@ -56,7 +56,7 @@ MODELS = {
"input_cost_per_million_tokens": 5.00,
"output_cost_per_million_tokens": 15.00
},
"tier_availability": ["free", "paid"]
"tier_availability": ["paid"]
},
# Paid tier only models

File diff suppressed because it is too large Load Diff

View File

@ -73,6 +73,7 @@
"json5": "^2.2.3",
"jsonrepair": "^3.12.0",
"jszip": "^3.10.1",
"libphonenumber-js": "^1.12.10",
"lucide-react": "^0.479.0",
"marked": "^15.0.7",
"motion": "^12.5.0",
@ -89,6 +90,7 @@
"react-markdown": "^10.1.0",
"react-papaparse": "^4.4.0",
"react-pdf": "^9.2.1",
"react-phone-number-input": "^3.4.12",
"react-scan": "^0.0.44",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
@ -6981,6 +6983,12 @@
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -7418,6 +7426,12 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/country-flag-icons": {
"version": "1.5.19",
"resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.5.19.tgz",
"integrity": "sha512-D/ZkRyj+ywJC6b2IrAN3/tpbReMUqmuRLlcKFoY/o0+EPQN9Ev/e8tV+D3+9scvu/tarxwLErNwS73C3yzxs/g==",
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
@ -9597,6 +9611,27 @@
"integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==",
"license": "MIT"
},
"node_modules/input-format": {
"version": "0.3.14",
"resolved": "https://registry.npmjs.org/input-format/-/input-format-0.3.14.tgz",
"integrity": "sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">=18.1.0",
"react-dom": ">=18.1.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@ -10318,6 +10353,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/libphonenumber-js": {
"version": "1.12.10",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.10.tgz",
"integrity": "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ==",
"license": "MIT"
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
@ -12756,6 +12797,23 @@
}
}
},
"node_modules/react-phone-number-input": {
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.12.tgz",
"integrity": "sha512-Raob77KdtLGm49iC6nuOX9qy6Mg16idkgC7Y1mHmvG2WBYoauHpzxYNlfmFskQKeiztrJIwPhPzBhjFwjenNCA==",
"license": "MIT",
"dependencies": {
"classnames": "^2.5.1",
"country-flag-icons": "^1.5.17",
"input-format": "^0.3.10",
"libphonenumber-js": "^1.11.20",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.0.tgz",

View File

@ -76,6 +76,7 @@
"json5": "^2.2.3",
"jsonrepair": "^3.12.0",
"jszip": "^3.10.1",
"libphonenumber-js": "^1.12.10",
"lucide-react": "^0.479.0",
"marked": "^15.0.7",
"motion": "^12.5.0",
@ -92,6 +93,7 @@
"react-markdown": "^10.1.0",
"react-papaparse": "^4.4.0",
"react-pdf": "^9.2.1",
"react-phone-number-input": "^3.4.12",
"react-scan": "^0.0.44",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",

View File

@ -1,31 +1,34 @@
import { cn } from "@/lib/utils";
import { DashboardContent } from "../../../components/dashboard/dashboard-content";
import { BackgroundAALChecker } from "@/components/auth/background-aal-checker";
import { Suspense } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { isFlagEnabled } from "@/lib/feature-flags";
export default async function DashboardPage() {
return (
<Suspense
fallback={
<div className="flex flex-col h-full w-full">
<div className="flex-1 flex flex-col items-center justify-center px-4">
<div className={cn(
"flex flex-col items-center text-center w-full space-y-8",
"max-w-[850px] sm:max-w-full sm:px-4"
)}>
<Skeleton className="h-10 w-40 sm:h-8 sm:w-32" />
<Skeleton className="h-7 w-56 sm:h-6 sm:w-48" />
<Skeleton className="w-full h-[100px] rounded-xl sm:h-[80px]" />
<div className="block sm:hidden lg:block w-full">
<Skeleton className="h-20 w-full" />
<BackgroundAALChecker>
<Suspense
fallback={
<div className="flex flex-col h-full w-full">
<div className="flex-1 flex flex-col items-center justify-center px-4">
<div className={cn(
"flex flex-col items-center text-center w-full space-y-8",
"max-w-[850px] sm:max-w-full sm:px-4"
)}>
<Skeleton className="h-10 w-40 sm:h-8 sm:w-32" />
<Skeleton className="h-7 w-56 sm:h-6 sm:w-48" />
<Skeleton className="w-full h-[100px] rounded-xl sm:h-[80px]" />
<div className="block sm:hidden lg:block w-full">
<Skeleton className="h-20 w-full" />
</div>
</div>
</div>
</div>
</div>
}
>
<DashboardContent />
</Suspense>
}
>
<DashboardContent />
</Suspense>
</BackgroundAALChecker>
);
}

View File

@ -509,8 +509,11 @@ export default function ThreadPage({
setDebugMode(debugParam === 'true');
}, [searchParams]);
const hasCheckedUpgradeDialog = useRef(false);
useEffect(() => {
if (initialLoadCompleted && subscriptionData) {
if (initialLoadCompleted && subscriptionData && !hasCheckedUpgradeDialog.current) {
hasCheckedUpgradeDialog.current = true;
const hasSeenUpgradeDialog = localStorage.getItem('suna_upgrade_dialog_displayed');
const isFreeTier = subscriptionStatus === 'no_subscription';
if (!hasSeenUpgradeDialog && isFreeTier && !isLocalMode()) {
@ -619,7 +622,7 @@ export default function ThreadPage({
<WorkflowInfo workflowId={workflowId} />
</div>
)} */}
<ThreadContent
messages={messages}
streamingTextContent={streamingTextContent}
@ -670,6 +673,7 @@ export default function ThreadPage({
setIsSidePanelOpen(true);
userClosedPanelRef.current = false;
}}
defaultShowSnackbar="tokens"
/>
</div>
</div>

View File

@ -10,33 +10,36 @@ import { PricingSection } from '@/components/home/sections/pricing-section';
import { UseCasesSection } from '@/components/home/sections/use-cases-section';
import { ModalProviders } from '@/providers/modal-providers';
import { HeroVideoSection } from '@/components/home/sections/hero-video-section';
import { BackgroundAALChecker } from '@/components/auth/background-aal-checker';
export default function Home() {
return (
<>
<ModalProviders />
<main className="flex flex-col items-center justify-center min-h-screen w-full">
<div className="w-full divide-y divide-border">
<HeroSection />
<UseCasesSection />
{/* <CompanyShowcase /> */}
{/* <BentoSection /> */}
{/* <QuoteSection /> */}
{/* <FeatureSection /> */}
{/* <GrowthSection /> */}
<OpenSourceSection />
<div className='flex flex-col items-center px-4'>
<PricingSection />
<BackgroundAALChecker>
<main className="flex flex-col items-center justify-center min-h-screen w-full">
<div className="w-full divide-y divide-border">
<HeroSection />
<UseCasesSection />
{/* <CompanyShowcase /> */}
{/* <BentoSection /> */}
{/* <QuoteSection /> */}
{/* <FeatureSection /> */}
{/* <GrowthSection /> */}
<OpenSourceSection />
<div className='flex flex-col items-center px-4'>
<PricingSection />
</div>
<div className="pb-10 mx-auto">
<HeroVideoSection />
</div>
{/* <TestimonialSection /> */}
{/* <FAQSection /> */}
<CTASection />
<FooterSection />
</div>
<div className="pb-10 mx-auto">
<HeroVideoSection />
</div>
{/* <TestimonialSection /> */}
{/* <FAQSection /> */}
<CTASection />
<FooterSection />
</div>
</main>
</main>
</BackgroundAALChecker>
</>
);
}

View File

@ -247,7 +247,7 @@ function LoginContent() {
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex flex-col sm:flex-row gap-3 justify-center items-center">
<Link
href="/"
className="flex h-11 items-center justify-center px-6 text-center rounded-lg border border-border bg-background hover:bg-accent transition-colors"

View File

@ -0,0 +1,5 @@
import { PhoneVerificationPage } from "@/components/auth/phone-verification/phone-verification-page";
export default function PhoneVerificationRoute() {
return <PhoneVerificationPage />;
}

View File

@ -31,7 +31,7 @@ export const ToolCallsContext = createContext<{
setToolCalls: React.Dispatch<React.SetStateAction<ParsedTag[]>>;
}>({
toolCalls: [],
setToolCalls: () => { },
setToolCalls: () => {},
});
export function Providers({ children }: { children: React.ReactNode }) {

View File

@ -0,0 +1,103 @@
"use client";
import { useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { useGetAAL } from '@/hooks/react-query/phone-verification';
import { useAuth } from '@/components/AuthProvider';
interface BackgroundAALCheckerProps {
children: React.ReactNode;
redirectTo?: string;
enabled?: boolean;
}
/**
* BackgroundAALChecker runs MFA checks silently in the background without blocking the UI.
*
* Only redirects when:
* - New users (created after cutoff) who don't have MFA enrolled
* - Users who have MFA enrolled but need verification
* - Users who need to reauthenticate due to MFA changes
*
* Does NOT show loading states or block the UI - runs entirely in background.
*/
export function BackgroundAALChecker({
children,
redirectTo = '/auth/phone-verification',
enabled = true
}: BackgroundAALCheckerProps) {
const { user, isLoading: authLoading } = useAuth();
const router = useRouter();
const pathname = usePathname();
// Only run queries if user is authenticated and check is enabled
const { data: aalData } = useGetAAL();
useEffect(() => {
// Only check if user is authenticated, not loading, and checks are enabled
if (!authLoading && user && enabled && aalData) {
const { action_required, current_level, next_level, verification_required } = aalData;
console.log('Background AAL Check:', {
action_required,
current_level,
next_level,
phone_verification_required: verification_required,
message: aalData.message
});
// Only redirect if the user is trying to access protected routes
// Allow users to stay on the home page "/" even if phone verification fails
const isProtectedRoute = pathname.startsWith('/dashboard') ||
pathname.startsWith('/agents') ||
pathname.startsWith('/projects') ||
pathname.startsWith('/settings');
if (!isProtectedRoute) {
// Don't redirect from home page or other public routes
console.log('Background: On public route, skipping phone verification redirect');
return;
}
// Handle new users who need phone verification enrollment
if (verification_required) {
if (current_level === "aal1" && next_level === "aal1") {
// New user has no MFA enrolled - redirect to enrollment
console.log('Background: New user without MFA enrolled, redirecting to phone verification');
router.push(redirectTo);
return;
}
// If new user has MFA enrolled, follow standard AAL flow below
}
// Standard AAL flow (for all users)
switch (action_required) {
case 'verify_mfa':
// User has MFA enrolled but needs to verify it
console.log('Background: Redirecting to MFA verification');
router.push(redirectTo);
break;
case 'reauthenticate':
// User has stale JWT due to MFA changes, force reauthentication
console.log('Background: MFA state changed, forcing reauthentication');
router.push('/auth?message=Please sign in again due to security changes');
break;
case 'none':
// No action required, user can proceed
console.log('Background: AAL check passed, no action required');
break;
case 'unknown':
default:
// Unknown AAL state, log and allow access (fail open)
console.warn('Background: Unknown AAL state:', { current_level, next_level, action_required });
break;
}
}
}, [user, authLoading, enabled, aalData, router, redirectTo, pathname]);
// Always render children immediately - no loading states
return <>{children}</>;
}

View File

@ -0,0 +1,285 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Loader2,
Shield,
RotateCcw,
Trash2,
MessageSquare,
} from 'lucide-react';
interface OtpVerificationProps {
phoneNumber?: string;
onVerify: (otp: string) => Promise<void>;
onResend: () => Promise<void>;
onSendCode?: () => Promise<void>;
onRemovePhone?: () => Promise<void>;
isLoading?: boolean;
error?: string | null;
showExistingOptions?: boolean;
challengeId?: string;
}
export function OtpVerification({
phoneNumber,
onVerify,
onResend,
onSendCode,
onRemovePhone,
isLoading = false,
error = null,
showExistingOptions = false,
challengeId,
}: OtpVerificationProps) {
const [otp, setOtp] = useState(['', '', '', '', '', '']);
const [localError, setLocalError] = useState<string | null>(null);
const [canResend, setCanResend] = useState(false);
const [countdown, setCountdown] = useState(30);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
useEffect(() => {
if (challengeId) {
// Focus first input when challenge is available
inputRefs.current[0]?.focus();
// Start countdown timer
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
setCanResend(true);
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}
}, [challengeId]);
const handleOtpChange = (index: number, value: string) => {
setLocalError(null);
// Only allow single digit
if (value.length > 1) {
value = value.slice(-1);
}
// Only allow digits
if (value && !/^\d$/.test(value)) {
return;
}
const newOtp = [...otp];
newOtp[index] = value;
setOtp(newOtp);
// Auto-focus next input
if (value && index < 5) {
inputRefs.current[index + 1]?.focus();
}
};
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
if (e.key === 'Backspace' && !otp[index] && index > 0) {
// Move to previous input on backspace if current is empty
inputRefs.current[index - 1]?.focus();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const pastedData = e.clipboardData.getData('text');
const digits = pastedData.replace(/\D/g, '').slice(0, 6);
if (digits.length === 6) {
const newOtp = digits.split('');
setOtp(newOtp);
inputRefs.current[5]?.focus();
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLocalError(null);
const otpCode = otp.join('');
if (otpCode.length !== 6) {
setLocalError('Please enter a 6-digit code');
return;
}
await onVerify(otpCode);
};
const handleResend = async () => {
setCanResend(false);
setCountdown(30);
setOtp(['', '', '', '', '', '']);
setLocalError(null);
await onResend();
// Restart countdown
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
setCanResend(true);
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
};
const handleSendCode = async () => {
if (onSendCode) {
setOtp(['', '', '', '', '', '']);
setLocalError(null);
setCanResend(false);
setCountdown(30);
await onSendCode();
}
};
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>
{showExistingOptions
? 'Verify Phone Number'
: 'Enter Verification Code'}
</CardTitle>
<CardDescription>
{challengeId
? "We've sent a 6-digit code to your phone"
: showExistingOptions
? 'Phone already registered. Verify it by OTP.'
: 'Enter the 6-digit code sent to your phone'}
</CardDescription>
</CardHeader>
<CardContent>
{(error || localError) && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{error || localError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="otp">Verification Code</Label>
<div className="flex gap-2 justify-center">
{otp.map((digit, index) => (
<Input
key={index}
ref={(el) => {
inputRefs.current[index] = el;
}}
type="text"
inputMode="numeric"
maxLength={1}
value={digit}
onChange={(e) => handleOtpChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onPaste={handlePaste}
className="w-12 h-12 text-center text-lg font-bold"
disabled={isLoading || !challengeId}
/>
))}
</div>
</div>
{/* Action buttons - different layout based on whether code has been sent */}
{challengeId ? (
// Code has been sent - show verify and resend
<>
<Button
type="submit"
className="w-full"
disabled={isLoading || otp.join('').length !== 6}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
<>
<Shield className="mr-2 h-4 w-4" />
Verify Code
</>
)}
</Button>
<div className="text-center">
<Button
type="button"
variant="link"
onClick={handleResend}
disabled={!canResend || isLoading}
className="text-sm"
>
<RotateCcw className="mr-2 h-4 w-4" />
{canResend ? 'Resend code' : `Resend in ${countdown}s`}
</Button>
</div>
</>
) : (
// No code sent yet - show send and remove options
<div className="space-y-3">
{onSendCode && (
<Button
type="button"
onClick={handleSendCode}
disabled={isLoading}
className="w-full"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<MessageSquare className="mr-2 h-4 w-4" />
Send Verification Code
</>
)}
</Button>
)}
{onRemovePhone && (
<Button
type="button"
onClick={onRemovePhone}
disabled={isLoading}
variant="outline"
size="sm"
className="w-full"
>
<Trash2 className="mr-2 h-4 w-4" />
Remove Phone Number
</Button>
)}
</div>
)}
</form>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2, Phone } from "lucide-react";
import { PhoneInput as PhoneInputComponent } from "@/components/ui/phone-input";
interface PhoneInputFormProps {
onSubmit: (phoneNumber: string) => Promise<void>;
isLoading?: boolean;
error?: string | null;
}
export function PhoneInput({ onSubmit, isLoading = false, error = null }: PhoneInputFormProps) {
const [phoneNumber, setPhoneNumber] = useState("");
const [localError, setLocalError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLocalError(null);
// Basic validation
if (!phoneNumber.trim()) {
setLocalError("Please enter a phone number");
return;
}
// Simple phone number validation (international format)
const phoneRegex = /^\+?[1-9]\d{1,14}$/;
if (!phoneRegex.test(phoneNumber.replace(/\s/g, ""))) {
setLocalError("Please enter a valid phone number");
return;
}
await onSubmit(phoneNumber);
};
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Phone Verification</CardTitle>
<CardDescription>
Enter your phone number to receive a verification code via SMS
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="phone">Phone Number</Label>
<PhoneInputComponent
value={phoneNumber}
onChange={(value) => setPhoneNumber(value || "")}
defaultCountry="US"
placeholder="Enter your phone number"
disabled={isLoading}
/>
<p className="text-sm text-muted-foreground">
We'll send you a verification code to confirm your number
</p>
</div>
{(error || localError) && (
<Alert variant="destructive">
<AlertDescription>
{error || localError}
</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
disabled={isLoading || !phoneNumber.trim()}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending code...
</>
) : (
<>
<Phone className="mr-2 h-4 w-4" />
Send Verification Code
</>
)}
</Button>
</form>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,365 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { PhoneInput } from './phone-input';
import { OtpVerification } from './otp-verification';
import {
useEnrollPhoneNumber,
useCreateChallenge,
useVerifyChallenge,
useListFactors,
useGetAAL,
useUnenrollFactor,
} from '@/hooks/react-query/phone-verification';
import { signOut } from '@/app/auth/actions';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { LogOut, Loader2 } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
interface PhoneVerificationPageProps {
onSuccess?: () => void;
}
export function PhoneVerificationPage({
onSuccess,
}: PhoneVerificationPageProps) {
const [step, setStep] = useState<'phone' | 'otp'>('phone');
const [phoneNumber, setPhoneNumber] = useState('');
const [factorId, setFactorId] = useState('');
const [challengeId, setChallengeId] = useState('');
const [success, setSuccess] = useState<string | null>(null);
const [debugInfo, setDebugInfo] = useState<any>(null);
const [isSubmittingPhone, setIsSubmittingPhone] = useState(false);
const [hasExistingFactor, setHasExistingFactor] = useState(false);
const router = useRouter();
console.log({ step, challengeId, hasExistingFactor });
// Use React Query hooks
const enrollMutation = useEnrollPhoneNumber();
const challengeMutation = useCreateChallenge();
const verifyMutation = useVerifyChallenge();
const unenrollMutation = useUnenrollFactor();
// Add debugging hooks
const { data: factors } = useListFactors();
const { data: aalData } = useGetAAL();
// Check for existing verified factors on component mount
useEffect(() => {
// Don't interfere while we're submitting a phone number
if (isSubmittingPhone) {
return;
}
if (factors?.factors) {
const phoneFactors = factors.factors.filter(
(f) => f.factor_type === 'phone',
);
const verifiedPhoneFactor = phoneFactors.find(
(f) => f.status === 'verified',
);
console.log('📱 Checking existing factors:', {
allFactors: factors.factors,
phoneFactors,
verifiedPhoneFactor,
aalData,
isSubmittingPhone,
});
if (verifiedPhoneFactor) {
// User already has a verified factor - show options
console.log(
'✅ Found existing verified phone factor:',
verifiedPhoneFactor,
);
setStep('otp');
setFactorId(verifiedPhoneFactor.id);
setPhoneNumber(verifiedPhoneFactor.phone || '');
setHasExistingFactor(true);
// Don't set challengeId yet - let user choose to send code
} else {
// No verified factor found - check for unverified factors
const unverifiedPhoneFactor = phoneFactors.find(
(f) => f.status !== 'verified',
);
if (unverifiedPhoneFactor) {
console.log(
'⚠️ Found unverified phone factor:',
unverifiedPhoneFactor,
);
setFactorId(unverifiedPhoneFactor.id);
setPhoneNumber(unverifiedPhoneFactor.phone || '');
setStep('otp');
setHasExistingFactor(true);
// Don't set challengeId yet - let user choose to send code
}
}
}
}, [factors, aalData, isSubmittingPhone]);
const handleCreateChallengeForExistingFactor = async () => {
try {
console.log('🔵 Creating challenge for existing factor:', factorId);
const challengeResponse = await challengeMutation.mutateAsync({
factor_id: factorId,
});
console.log(
'✅ Challenge created for existing factor:',
challengeResponse,
);
setChallengeId(challengeResponse.id);
setSuccess('Verification code sent to your phone');
} catch (err) {
console.error('❌ Failed to create challenge for existing factor:', err);
}
};
const handleUnenrollFactor = async () => {
try {
console.log('🔵 Unenrolling factor:', factorId);
await unenrollMutation.mutateAsync(factorId);
console.log('✅ Factor unenrolled successfully');
// Reset state and go back to phone input
setStep('phone');
setFactorId('');
setPhoneNumber('');
setChallengeId('');
setHasExistingFactor(false);
setSuccess('Phone number removed. You can now add a new one.');
} catch (err) {
console.error('❌ Failed to unenroll factor:', err);
}
};
const handlePhoneSubmit = async (phone: string) => {
try {
setIsSubmittingPhone(true);
console.log('🔵 Starting phone enrollment for:', phone);
// Step 1: Enroll the phone number
const enrollResponse = await enrollMutation.mutateAsync({
friendly_name: 'Primary Phone',
phone_number: phone,
});
console.log('✅ Enrollment response:', enrollResponse);
// Step 2: Create a challenge (sends SMS)
const challengeResponse = await challengeMutation.mutateAsync({
factor_id: enrollResponse.id,
});
console.log('✅ Challenge response:', challengeResponse);
setPhoneNumber(phone);
setFactorId(enrollResponse.id);
setChallengeId(challengeResponse.id);
setStep('otp');
setHasExistingFactor(false);
setSuccess('Verification code sent to your phone');
} catch (err) {
console.error('❌ Phone submission failed:', err);
// If enrollment fails because factor already exists, try to handle existing factor
if (err instanceof Error && err.message.includes('already exists')) {
console.log(
'🔄 Factor already exists, checking for existing factors...',
);
// Force refetch of factors
window.location.reload();
}
} finally {
setIsSubmittingPhone(false);
}
};
const handleOtpVerify = async (otp: string) => {
try {
console.log('🔵 Starting OTP verification with:', {
factor_id: factorId,
challenge_id: challengeId,
code: otp,
});
// Check status BEFORE verification
console.log('📊 Status BEFORE verification:', {
factors: factors,
aalData: aalData,
});
// Verify the challenge with the OTP code - this will automatically invalidate caches
const verifyResponse = await verifyMutation.mutateAsync({
factor_id: factorId,
challenge_id: challengeId,
code: otp,
});
console.log('✅ Verification response:', verifyResponse);
// Store debug info to display
setDebugInfo({
verifyResponse,
beforeFactors: factors,
beforeAAL: aalData,
timestamp: new Date().toISOString(),
});
setSuccess('Phone number verified successfully!');
// Wait a bit for cache invalidation, then redirect
setTimeout(() => {
console.log('🔄 Redirecting after successful verification...');
if (onSuccess) {
onSuccess();
} else {
router.push('/dashboard');
}
}, 2000);
} catch (err) {
console.error('❌ OTP verification failed:', err);
}
};
const handleResendCode = async () => {
try {
console.log('🔵 Resending code for factor:', factorId);
// Create a new challenge for the enrolled factor
const challengeResponse = await challengeMutation.mutateAsync({
factor_id: factorId,
});
console.log('✅ Resend challenge response:', challengeResponse);
setChallengeId(challengeResponse.id);
setSuccess('New verification code sent');
} catch (err) {
console.error('❌ Resend failed:', err);
}
};
const signOutMutation = useMutation({
mutationFn: async () => {
await signOut().catch(() => void 0);
window.location.href = '/';
},
});
const handleSignOut = () => {
signOutMutation.mutate();
};
const isLoading =
enrollMutation.isPending ||
challengeMutation.isPending ||
verifyMutation.isPending ||
unenrollMutation.isPending;
const error =
enrollMutation.error?.message ||
challengeMutation.error?.message ||
verifyMutation.error?.message ||
unenrollMutation.error?.message ||
null;
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4 relative">
{/* Logout Button */}
<div className="absolute top-4 right-4">
<Button
variant="ghost"
size="sm"
onClick={handleSignOut}
disabled={signOutMutation.isPending}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
{signOutMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogOut className="h-4 w-4" />
)}
<span className="hidden sm:inline">
{signOutMutation.isPending ? 'Signing out...' : 'Sign out'}
</span>
</Button>
</div>
<div className="w-full max-w-md space-y-6">
{/* Debug Information */}
{false && (factors || aalData || debugInfo) && (
<div className="p-4 rounded-lg text-xs space-y-2">
<h3 className="font-semibold">Debug Info:</h3>
{aalData && (
<div>
<strong>AAL:</strong> {aalData.current_level} {' '}
{aalData.next_level}
(action: {aalData.action_required})
</div>
)}
{factors && (
<div>
<strong>Factors:</strong>{' '}
{factors.factors
?.map((f) => `${f.factor_type}:${f.status}:${f.id}`)
.join(', ') || 'none'}
</div>
)}
{debugInfo && (
<div>
<strong>Last Verification:</strong> {debugInfo.timestamp}
<br />
<strong>Response:</strong>{' '}
{JSON.stringify(debugInfo.verifyResponse)}
</div>
)}
</div>
)}
{error && (
<Alert variant="destructive">
<AlertDescription>
{error}
</AlertDescription>
</Alert>
)}
{success && (
<Alert>
<AlertDescription>
{success}
</AlertDescription>
</Alert>
)}
{step === 'phone' ? (
<PhoneInput
onSubmit={handlePhoneSubmit}
isLoading={isLoading}
error={null}
/>
) : (
<OtpVerification
phoneNumber={phoneNumber}
onVerify={handleOtpVerify}
onResend={handleResendCode}
onSendCode={handleCreateChallengeForExistingFactor}
onRemovePhone={handleUnenrollFactor}
isLoading={isLoading}
error={null}
showExistingOptions={hasExistingFactor}
challengeId={challengeId}
/>
)}
</div>
</div>
);
}

View File

@ -25,7 +25,7 @@ interface BillingModalProps {
returnUrl?: string;
}
export function BillingModal({ open, onOpenChange, returnUrl = window?.location?.href || '/' }: BillingModalProps) {
export function BillingModal({ open, onOpenChange, returnUrl = typeof window !== 'undefined' ? window?.location?.href || '/' : '/' }: BillingModalProps) {
const { session, isLoading: authLoading } = useAuth();
const [subscriptionData, setSubscriptionData] = useState<SubscriptionStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);

View File

@ -104,11 +104,11 @@ export default function DashboardLayoutContent({
</SidebarInset>
{/* <PricingAlert
open={showPricingAlert}
onOpenChange={setShowPricingAlert}
closeable={false}
accountId={personalAccount?.account_id}
/> */}
open={showPricingAlert}
onOpenChange={setShowPricingAlert}
closeable={false}
accountId={personalAccount?.account_id}
/> */}
<MaintenanceAlert
open={showMaintenanceAlert}

View File

@ -5,11 +5,11 @@ import { useState, useEffect, useMemo } from 'react';
import { isLocalMode } from '@/lib/config';
import { useAvailableModels } from '@/hooks/react-query/subscriptions/use-model';
export const STORAGE_KEY_MODEL = 'suna-preferred-model-v2';
export const STORAGE_KEY_MODEL = 'suna-preferred-model-v3';
export const STORAGE_KEY_CUSTOM_MODELS = 'customModels';
export const DEFAULT_PREMIUM_MODEL_ID = 'claude-sonnet-4';
// export const DEFAULT_FREE_MODEL_ID = 'deepseek';
export const DEFAULT_FREE_MODEL_ID = 'claude-sonnet-4';
export const DEFAULT_FREE_MODEL_ID = 'moonshotai/kimi-k2';
// export const DEFAULT_FREE_MODEL_ID = 'claude-sonnet-4';
export type SubscriptionStatus = 'no_subscription' | 'active';
@ -32,7 +32,7 @@ export interface CustomModel {
export const MODELS = {
// Free tier models (available to all users)
'claude-sonnet-4': {
tier: 'free',
tier: 'premium',
priority: 100,
recommended: true,
lowQuality: false
@ -59,7 +59,7 @@ export const MODELS = {
lowQuality: false
},
'grok-4': {
tier: 'free',
tier: 'premium',
priority: 98,
recommended: false,
lowQuality: false

View File

@ -7,6 +7,7 @@ import React, {
forwardRef,
useImperativeHandle,
} from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { handleFiles } from './file-upload-handler';
import { MessageInput } from './message-input';
@ -14,10 +15,17 @@ import { AttachmentGroup } from '../attachment-group';
import { useModelSelection } from './_use-model-selection';
import { useFileDelete } from '@/hooks/react-query/files';
import { useQueryClient } from '@tanstack/react-query';
import { FloatingToolPreview, ToolCallInput } from './floating-tool-preview';
import { Settings2, Sparkles, Brain, ChevronRight, Zap, Workflow, Database, Wrench } from 'lucide-react';
import { ToolCallInput } from './floating-tool-preview';
import { ChatSnack } from './chat-snack';
import { Brain, Zap, Workflow, Database } from 'lucide-react';
import { FaGoogle, FaDiscord } from 'react-icons/fa';
import { SiNotion } from 'react-icons/si';
import { AgentConfigModal } from '@/components/agents/agent-config-modal';
import { PipedreamRegistry } from '@/components/agents/pipedream/pipedream-registry';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useSubscriptionWithStreaming } from '@/hooks/react-query/subscriptions/use-subscriptions';
import { isLocalMode } from '@/lib/config';
import { BillingModal } from '@/components/billing/billing-modal';
import { useRouter } from 'next/navigation';
export interface ChatInputHandles {
@ -54,6 +62,8 @@ export interface ChatInputProps {
enableAdvancedConfig?: boolean;
onConfigureAgent?: (agentId: string) => void;
hideAgentSelection?: boolean;
defaultShowSnackbar?: 'tokens' | 'upgrade' | false;
showToLowCreditUsers?: boolean;
}
export interface UploadedFile {
@ -64,6 +74,8 @@ export interface UploadedFile {
localUrl?: string;
}
export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
(
{
@ -92,11 +104,14 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
enableAdvancedConfig = false,
onConfigureAgent,
hideAgentSelection = false,
defaultShowSnackbar = false,
showToLowCreditUsers = true,
},
ref,
) => {
const isControlled =
controlledValue !== undefined && controlledOnChange !== undefined;
const router = useRouter();
const [uncontrolledValue, setUncontrolledValue] = useState('');
const value = isControlled ? controlledValue : uncontrolledValue;
@ -105,7 +120,12 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [isDraggingOver, setIsDraggingOver] = useState(false);
const router = useRouter();
const [configModalOpen, setConfigModalOpen] = useState(false);
const [configModalTab, setConfigModalTab] = useState('integrations');
const [registryDialogOpen, setRegistryDialogOpen] = useState(false);
const [showSnackbar, setShowSnackbar] = useState(defaultShowSnackbar);
const [userDismissedUsage, setUserDismissedUsage] = useState(false);
const [billingModalOpen, setBillingModalOpen] = useState(false);
const {
selectedModel,
@ -117,9 +137,37 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
refreshCustomModels,
} = useModelSelection();
const { data: subscriptionData } = useSubscriptionWithStreaming(isAgentRunning);
const deleteFileMutation = useFileDelete();
const queryClient = useQueryClient();
// Show usage preview logic:
// - Always show to free users when showToLowCreditUsers is true
// - For paid users, only show when they're at 70% or more of their cost limit (30% or below remaining)
const shouldShowUsage = !isLocalMode() && subscriptionData && showToLowCreditUsers && (() => {
// Free users: always show
if (subscriptionStatus === 'no_subscription') {
return true;
}
// Paid users: only show when at 70% or more of cost limit
const currentUsage = subscriptionData.current_usage || 0;
const costLimit = subscriptionData.cost_limit || 0;
if (costLimit === 0) return false; // No limit set
return currentUsage >= (costLimit * 0.7); // 70% or more used (30% or less remaining)
})();
// Auto-show usage preview when we have subscription data
useEffect(() => {
if (shouldShowUsage && defaultShowSnackbar !== false && !userDismissedUsage && (showSnackbar === false || showSnackbar === defaultShowSnackbar)) {
setShowSnackbar('upgrade');
} else if (!shouldShowUsage && showSnackbar !== false) {
setShowSnackbar(false);
}
}, [subscriptionData, showSnackbar, defaultShowSnackbar, shouldShowUsage, subscriptionStatus, showToLowCreditUsers, userDismissedUsage]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const hasLoadedFromLocalStorage = useRef(false);
@ -133,7 +181,7 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
if (typeof window !== 'undefined' && onAgentSelect && !hasLoadedFromLocalStorage.current) {
const urlParams = new URLSearchParams(window.location.search);
const hasAgentIdInUrl = urlParams.has('agent_id');
if (!selectedAgentId && !hasAgentIdInUrl) {
const savedAgentId = localStorage.getItem('lastSelectedAgentId');
if (savedAgentId) {
@ -279,85 +327,95 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
setIsDraggingOver(false);
};
return (
<div className="mx-auto w-full max-w-4xl">
<FloatingToolPreview
toolCalls={toolCalls}
currentIndex={toolCallIndex}
onExpand={onExpandToolPreview || (() => { })}
agentName={agentName}
isVisible={showToolPreview}
/>
<Card
className={`-mb-2 shadow-none w-full max-w-4xl mx-auto bg-transparent border-none overflow-hidden ${enableAdvancedConfig && selectedAgentId ? '' : 'rounded-3xl'}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(false);
if (fileInputRef.current && e.dataTransfer.files.length > 0) {
const files = Array.from(e.dataTransfer.files);
handleFiles(
files,
sandboxId,
setPendingFiles,
setUploadedFiles,
setIsUploading,
messages,
queryClient,
);
}
}}
>
<div className="w-full text-sm flex flex-col justify-between items-start rounded-lg">
<CardContent className={`w-full p-1.5 ${enableAdvancedConfig && selectedAgentId ? 'pb-1' : 'pb-2'} ${bgColor} border ${enableAdvancedConfig && selectedAgentId ? 'rounded-t-3xl' : 'rounded-3xl'}`}>
<AttachmentGroup
files={uploadedFiles || []}
sandboxId={sandboxId}
onRemove={removeUploadedFile}
layout="inline"
maxHeight="216px"
showPreviews={true}
/>
<MessageInput
ref={textareaRef}
value={value}
onChange={handleChange}
onSubmit={handleSubmit}
onTranscription={handleTranscription}
placeholder={placeholder}
loading={loading}
disabled={disabled}
isAgentRunning={isAgentRunning}
onStopAgent={onStopAgent}
isDraggingOver={isDraggingOver}
uploadedFiles={uploadedFiles}
<div className="mx-auto w-full max-w-4xl relative">
<div className="relative">
<ChatSnack
toolCalls={toolCalls}
toolCallIndex={toolCallIndex}
onExpandToolPreview={onExpandToolPreview}
agentName={agentName}
showToolPreview={showToolPreview}
showUsagePreview={showSnackbar}
subscriptionData={subscriptionData}
onCloseUsage={() => { setShowSnackbar(false); setUserDismissedUsage(true); }}
onOpenUpgrade={() => setBillingModalOpen(true)}
isVisible={showToolPreview || !!showSnackbar}
/>
<Card
className={`-mb-2 shadow-none w-full max-w-4xl mx-auto bg-transparent border-none overflow-visible ${enableAdvancedConfig && selectedAgentId ? '' : 'rounded-3xl'} relative`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(false);
if (fileInputRef.current && e.dataTransfer.files.length > 0) {
const files = Array.from(e.dataTransfer.files);
handleFiles(
files,
sandboxId,
setPendingFiles,
setUploadedFiles,
setIsUploading,
messages,
queryClient,
);
}
}}
>
fileInputRef={fileInputRef}
isUploading={isUploading}
sandboxId={sandboxId}
setPendingFiles={setPendingFiles}
setUploadedFiles={setUploadedFiles}
setIsUploading={setIsUploading}
hideAttachments={hideAttachments}
messages={messages}
selectedModel={selectedModel}
onModelChange={handleModelChange}
modelOptions={modelOptions}
subscriptionStatus={subscriptionStatus}
canAccessModel={canAccessModel}
refreshCustomModels={refreshCustomModels}
isLoggedIn={isLoggedIn}
<div className="w-full text-sm flex flex-col justify-between items-start rounded-lg">
<CardContent className={`w-full p-1.5 ${enableAdvancedConfig && selectedAgentId ? 'pb-1' : 'pb-2'} ${bgColor} border ${enableAdvancedConfig && selectedAgentId ? 'rounded-t-3xl' : 'rounded-3xl'}`}>
<AttachmentGroup
files={uploadedFiles || []}
sandboxId={sandboxId}
onRemove={removeUploadedFile}
layout="inline"
maxHeight="216px"
showPreviews={true}
/>
<MessageInput
ref={textareaRef}
value={value}
onChange={handleChange}
onSubmit={handleSubmit}
onTranscription={handleTranscription}
placeholder={placeholder}
loading={loading}
disabled={disabled}
isAgentRunning={isAgentRunning}
onStopAgent={onStopAgent}
isDraggingOver={isDraggingOver}
uploadedFiles={uploadedFiles}
selectedAgentId={selectedAgentId}
onAgentSelect={onAgentSelect}
hideAgentSelection={hideAgentSelection}
/>
</CardContent>
{enableAdvancedConfig && selectedAgentId && (
fileInputRef={fileInputRef}
isUploading={isUploading}
sandboxId={sandboxId}
setPendingFiles={setPendingFiles}
setUploadedFiles={setUploadedFiles}
setIsUploading={setIsUploading}
hideAttachments={hideAttachments}
messages={messages}
selectedModel={selectedModel}
onModelChange={handleModelChange}
modelOptions={modelOptions}
subscriptionStatus={subscriptionStatus}
canAccessModel={canAccessModel}
refreshCustomModels={refreshCustomModels}
isLoggedIn={isLoggedIn}
selectedAgentId={selectedAgentId}
onAgentSelect={onAgentSelect}
hideAgentSelection={hideAgentSelection}
/>
</CardContent>
{enableAdvancedConfig && selectedAgentId && (
<div className="w-full border-t border-border/30 bg-muted/20 px-4 py-1.5 rounded-b-3xl border-l border-r border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 sm:gap-2 overflow-x-auto scrollbar-none">
@ -422,8 +480,35 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
</div>
</div>
)}
</div>
</Card>
</div>
</Card>
<AgentConfigModal
isOpen={configModalOpen}
onOpenChange={setConfigModalOpen}
selectedAgentId={selectedAgentId}
onAgentSelect={onAgentSelect}
initialTab={configModalTab}
/>
<Dialog open={registryDialogOpen} onOpenChange={setRegistryDialogOpen}>
<DialogContent className="p-0 max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader className="sr-only">
<DialogTitle>Integrations</DialogTitle>
</DialogHeader>
<PipedreamRegistry
showAgentSelector={true}
selectedAgentId={selectedAgentId}
onAgentChange={onAgentSelect}
onToolsSelected={(profileId, selectedTools, appName, appSlug) => {
console.log('Tools selected:', { profileId, selectedTools, appName, appSlug });
}}
/>
</DialogContent>
</Dialog>
<BillingModal
open={billingModalOpen}
onOpenChange={setBillingModalOpen}
/>
</div>
</div>
);
},

View File

@ -0,0 +1,182 @@
'use client';
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { UsagePreview } from './usage-preview';
import { FloatingToolPreview, ToolCallInput } from './floating-tool-preview';
import { isLocalMode } from '@/lib/config';
export interface ChatSnackProps {
// Tool preview props
toolCalls?: ToolCallInput[];
toolCallIndex?: number;
onExpandToolPreview?: () => void;
agentName?: string;
showToolPreview?: boolean;
// Usage preview props
showUsagePreview?: 'tokens' | 'upgrade' | false;
subscriptionData?: any;
onCloseUsage?: () => void;
onOpenUpgrade?: () => void;
// General props
isVisible?: boolean;
}
const SNACK_LAYOUT_ID = 'chat-snack-float';
const SNACK_CONTENT_LAYOUT_ID = 'chat-snack-content';
export const ChatSnack: React.FC<ChatSnackProps> = ({
toolCalls = [],
toolCallIndex = 0,
onExpandToolPreview,
agentName,
showToolPreview = false,
showUsagePreview = false,
subscriptionData,
onCloseUsage,
onOpenUpgrade,
isVisible = false,
}) => {
const [currentView, setCurrentView] = React.useState(0);
// Determine what notifications we have - match exact rendering conditions
const notifications = [];
// Tool notification: only if we have tool calls and showToolPreview is true
if (showToolPreview && toolCalls.length > 0) {
notifications.push('tool');
}
// Usage notification: must match ALL rendering conditions
if (showUsagePreview && !isLocalMode() && subscriptionData) {
notifications.push('usage');
}
const totalNotifications = notifications.length;
const hasMultiple = totalNotifications > 1;
// Reset currentView when notifications change
React.useEffect(() => {
if (currentView >= totalNotifications && totalNotifications > 0) {
setCurrentView(0);
}
}, [totalNotifications, currentView]);
// Auto-cycle through notifications
React.useEffect(() => {
if (!hasMultiple || !isVisible) return;
const interval = setInterval(() => {
setCurrentView((prev) => (prev + 1) % totalNotifications);
}, 20000);
return () => clearInterval(interval);
}, [hasMultiple, isVisible, totalNotifications, currentView]); // Reset timer when currentView changes
if (!isVisible || totalNotifications === 0) return null;
const currentNotification = notifications[currentView];
const renderContent = () => {
if (currentNotification === 'tool' && showToolPreview) {
return (
<FloatingToolPreview
toolCalls={toolCalls}
currentIndex={toolCallIndex}
onExpand={onExpandToolPreview || (() => { })}
agentName={agentName}
isVisible={true}
showIndicators={hasMultiple}
indicatorIndex={currentView}
indicatorTotal={totalNotifications}
onIndicatorClick={(index) => setCurrentView(index)}
/>
);
}
if (currentNotification === 'usage' && showUsagePreview && !isLocalMode()) {
return (
<motion.div
layoutId={SNACK_LAYOUT_ID}
layout
transition={{
layout: {
type: "spring",
stiffness: 300,
damping: 30
}
}}
className="-mb-4 w-full"
style={{ pointerEvents: 'auto' }}
>
<motion.div
layoutId={SNACK_CONTENT_LAYOUT_ID}
className={cn(
"bg-card border border-border rounded-3xl p-2 w-full transition-all duration-200",
onOpenUpgrade && "cursor-pointer hover:shadow-md"
)}
whileHover={onOpenUpgrade ? { scale: 1.02 } : undefined}
whileTap={onOpenUpgrade ? { scale: 0.98 } : undefined}
onClick={(e) => {
// Don't trigger if clicking on indicators or close button
const target = e.target as HTMLElement;
const isIndicatorClick = target.closest('[data-indicator-click]');
const isCloseClick = target.closest('[data-close-click]');
if (!isIndicatorClick && !isCloseClick && onOpenUpgrade) {
onOpenUpgrade();
}
}}
>
<UsagePreview
type={showUsagePreview}
subscriptionData={subscriptionData}
onClose={() => {
// First close the usage notification
if (onCloseUsage) onCloseUsage();
// Check what notifications will remain after closing usage
const willHaveToolNotification = showToolPreview && toolCalls.length > 0;
// If there will be other notifications, switch to them
if (willHaveToolNotification) {
setCurrentView(0); // Switch to tool notification
}
}}
hasMultiple={hasMultiple}
showIndicators={hasMultiple}
currentIndex={currentView}
totalCount={totalNotifications}
onIndicatorClick={(index) => setCurrentView(index)}
onOpenUpgrade={onOpenUpgrade}
/>
</motion.div>
</motion.div>
);
}
return null;
};
return (
<AnimatePresence mode="wait">
{isVisible && (
<motion.div
key={currentNotification}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
>
{renderContent()}
</motion.div>
)}
</AnimatePresence>
);
};

View File

@ -25,6 +25,11 @@ interface FloatingToolPreviewProps {
onExpand: () => void;
agentName?: string;
isVisible: boolean;
// Indicators for multiple notification types (not tool calls)
showIndicators?: boolean;
indicatorIndex?: number;
indicatorTotal?: number;
onIndicatorClick?: (index: number) => void;
}
const FLOATING_LAYOUT_ID = 'tool-panel-float';
@ -61,6 +66,10 @@ export const FloatingToolPreview: React.FC<FloatingToolPreviewProps> = ({
onExpand,
agentName,
isVisible,
showIndicators = false,
indicatorIndex = 0,
indicatorTotal = 1,
onIndicatorClick,
}) => {
const [isExpanding, setIsExpanding] = React.useState(false);
const currentToolCall = toolCalls[currentIndex];
@ -137,11 +146,6 @@ export const FloatingToolPreview: React.FC<FloatingToolPreviewProps> = ({
<h4 className="text-sm font-medium text-foreground truncate">
{getUserFriendlyToolName(toolName)}
</h4>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">
{currentIndex + 1}/{totalCalls}
</span>
</div>
</motion.div>
<motion.div layoutId="tool-status" className="flex items-center gap-2">
@ -164,6 +168,32 @@ export const FloatingToolPreview: React.FC<FloatingToolPreviewProps> = ({
</motion.div>
</div>
{/* Apple-style notification indicators - only for multiple notification types */}
{showIndicators && indicatorTotal === 2 && (
<button
onClick={(e) => {
e.stopPropagation(); // Prevent tool expansion
// Toggle between the two notifications (binary switch)
const nextIndex = indicatorIndex === 0 ? 1 : 0;
onIndicatorClick?.(nextIndex);
}}
className="flex items-center gap-1.5 mr-3 px-2 py-1.5 rounded-lg hover:bg-muted/30 transition-colors"
style={{ opacity: isExpanding ? 0 : 1 }}
>
{Array.from({ length: indicatorTotal }).map((_, index) => (
<div
key={index}
className={cn(
"transition-all duration-300 ease-out rounded-full",
index === indicatorIndex
? "w-6 h-2 bg-foreground"
: "w-3 h-2 bg-muted-foreground/40"
)}
/>
))}
</button>
)}
<Button value='ghost' className="bg-transparent hover:bg-transparent flex-shrink-0" style={{ opacity: isExpanding ? 0 : 1 }}>
<Maximize2 className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
</Button>

View File

@ -156,7 +156,7 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
const renderDropdown = () => {
if (isLoggedIn) {
const showAdvancedFeatures = enableAdvancedConfig || (customAgentsEnabled && !flagsLoading);
return (
<div className="flex items-center gap-2">
{showAdvancedFeatures && !hideAgentSelection && (
@ -223,7 +223,7 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
</div>
{subscriptionStatus === 'no_subscription' && !isLocalMode() &&
{/* {subscriptionStatus === 'no_subscription' && !isLocalMode() &&
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
@ -234,7 +234,7 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
</TooltipContent>
</Tooltip>
</TooltipProvider>
}
} */}
<div className='flex items-center gap-2'>
{renderDropdown()}
@ -277,13 +277,13 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
</Button>
</div>
</div>
{subscriptionStatus === 'no_subscription' && !isLocalMode() &&
{/* {subscriptionStatus === 'no_subscription' && !isLocalMode() &&
<div className='sm:hidden absolute -bottom-8 left-0 right-0 flex justify-center'>
<p className='text-xs text-amber-500 px-2 py-1'>
Upgrade for better performance
</p>
</div>
}
} */}
</div>
);
},

View File

@ -599,8 +599,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
{/* Premium Models Section */}
<div className="mt-4 border-t border-border pt-2">
<div className="px-3 py-1.5 text-xs font-medium text-blue-500 flex items-center">
{/* <Crown className="h-3.5 w-3.5 mr-1.5" /> */}
Additional Models
<Crown className="h-3.5 w-3.5 mr-1.5" />
Premium Models
</div>
{/* Premium models container with paywall overlay */}

View File

@ -0,0 +1,132 @@
import React from 'react';
import { motion } from 'framer-motion';
import { X, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
import { isLocalMode } from '@/lib/config';
import { Button } from '@/components/ui/button';
export interface UsagePreviewProps {
type: 'tokens' | 'upgrade';
subscriptionData?: any;
onClose?: () => void;
onOpenUpgrade?: () => void;
hasMultiple?: boolean;
showIndicators?: boolean;
currentIndex?: number;
totalCount?: number;
onIndicatorClick?: (index: number) => void;
}
export const UsagePreview: React.FC<UsagePreviewProps> = ({
type,
subscriptionData,
onClose,
onOpenUpgrade,
hasMultiple = false,
showIndicators = false,
currentIndex = 0,
totalCount = 1,
onIndicatorClick,
}) => {
if (isLocalMode()) return null;
const formatCurrency = (amount: number) => {
return `$${amount.toFixed(2)}`;
};
const getUsageDisplay = () => {
if (!subscriptionData) return 'Loading usage...';
const current = subscriptionData.current_usage || 0;
const limit = subscriptionData.cost_limit || 0;
if (limit === 0) return 'No usage limit set';
const isOverLimit = current > limit;
const usageText = `${formatCurrency(current)} / ${formatCurrency(limit)}`;
if (isOverLimit) {
return `${usageText} (over limit)`;
}
return usageText;
};
const isOverLimit = () => {
if (!subscriptionData) return false;
const current = subscriptionData.current_usage || 0;
const limit = subscriptionData.cost_limit || 0;
return current > limit;
};
return (
<div className="flex items-center gap-3">
{/* Icon */}
<div className="flex-shrink-0">
<motion.div
className={cn(
"w-10 h-10 rounded-2xl flex items-center justify-center",
isOverLimit()
? "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
: "bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800"
)}
>
<Zap className={cn(
"h-5 w-5",
isOverLimit()
? "text-red-500 dark:text-red-400"
: "text-blue-500 dark:text-blue-400"
)} />
</motion.div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<motion.div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-medium text-foreground truncate">
Upgrade for more usage & better AI Models
</h4>
</motion.div>
<motion.div className="flex items-center gap-2">
<div className={cn(
"w-2 h-2 rounded-full",
isOverLimit() ? "bg-red-500" : "bg-blue-500"
)} />
<span className="text-xs text-muted-foreground truncate">
{getUsageDisplay()}
</span>
</motion.div>
</div>
{/* Apple-style notification indicators - only for multiple notification types */}
{showIndicators && totalCount === 2 && (
<button
data-indicator-click
onClick={(e) => {
e.stopPropagation();
const nextIndex = currentIndex === 0 ? 1 : 0;
onIndicatorClick?.(nextIndex);
}}
className="flex items-center gap-1.5 mr-3 px-2 py-1.5 rounded-lg hover:bg-muted/30 transition-colors"
>
{Array.from({ length: totalCount }).map((_, index) => (
<div
key={index}
className={cn(
"transition-all duration-300 ease-out rounded-full",
index === currentIndex
? "w-6 h-2 bg-foreground"
: "w-3 h-2 bg-muted-foreground/40"
)}
/>
))}
</button>
)}
<Button variant="ghost" size="icon" className="h-8 w-8 flex-shrink-0 hover:bg-muted/50" onClick={(e) => { e.stopPropagation(); onClose?.(); }}>
<X className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
</Button>
</div>
);
};

View File

@ -150,6 +150,22 @@ export function renderMarkdownContent(
{renderAttachments(attachmentArray, fileViewerHandler, sandboxId, project)}
</div>
);
} else if (toolName === 'complete') {
// Handle complete tool specially - extract text and attachments
const completeText = toolCall.parameters.text || '';
const attachments = toolCall.parameters.attachments || '';
// Convert single attachment to array for consistent handling
const attachmentArray = Array.isArray(attachments) ? attachments :
(typeof attachments === 'string' ? attachments.split(',').map(a => a.trim()) : []);
// Render complete tool content with attachment UI
contentParts.push(
<div key={`complete-${match.index}-${index}`} className="space-y-3">
<Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words [&>:first-child]:mt-0 prose-headings:mt-3">{completeText}</Markdown>
{renderAttachments(attachmentArray, fileViewerHandler, sandboxId, project)}
</div>
);
} else {
const IconComponent = getToolIcon(toolName);
@ -242,6 +258,24 @@ export function renderMarkdownContent(
{renderAttachments(attachments, fileViewerHandler, sandboxId, project)}
</div>
);
} else if (toolName === 'complete') {
// Extract attachments from the XML attributes
const attachmentsMatch = rawXml.match(/attachments=["']([^"']*)["']/i);
const attachments = attachmentsMatch
? attachmentsMatch[1].split(',').map(a => a.trim())
: [];
// Extract content from the complete tag
const contentMatch = rawXml.match(/<complete[^>]*>([\s\S]*?)<\/complete>/i);
const completeContent = contentMatch ? contentMatch[1] : '';
// Render <complete> tag content with attachment UI (using the helper)
contentParts.push(
<div key={`complete-${match.index}`} className="space-y-3">
<Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words [&>:first-child]:mt-0 prose-headings:mt-3">{completeContent}</Markdown>
{renderAttachments(attachments, fileViewerHandler, sandboxId, project)}
</div>
);
} else {
const IconComponent = getToolIcon(toolName);
const paramDisplay = extractPrimaryParam(toolName, rawXml);

View File

@ -18,12 +18,14 @@ import {
extractToolData,
getFileIconAndColor,
} from './utils';
import { extractCompleteData } from './complete-tool/_utils';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from "@/components/ui/scroll-area";
import { Progress } from '@/components/ui/progress';
import { Markdown } from '@/components/ui/markdown';
import { FileAttachment } from '../file-attachment';
interface CompleteContent {
summary?: string;
@ -46,10 +48,26 @@ export function CompleteToolView({
isSuccess = true,
isStreaming = false,
onFileClick,
project,
}: CompleteToolViewProps) {
const [completeData, setCompleteData] = useState<CompleteContent>({});
const [progress, setProgress] = useState(0);
const {
text,
attachments,
status,
actualIsSuccess,
actualToolTimestamp,
actualAssistantTimestamp
} = extractCompleteData(
assistantContent,
toolContent,
isSuccess,
toolTimestamp,
assistantTimestamp
);
useEffect(() => {
if (assistantContent) {
try {
@ -120,6 +138,16 @@ export function CompleteToolView({
}
}, [isStreaming]);
const isImageFile = (filePath: string): boolean => {
const filename = filePath.split('/').pop() || '';
return filename.match(/\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i) !== null;
};
const isPreviewableFile = (filePath: string): boolean => {
const ext = filePath.split('.').pop()?.toLowerCase() || '';
return ext === 'html' || ext === 'htm' || ext === 'md' || ext === 'markdown' || ext === 'csv' || ext === 'tsv';
};
const toolTitle = getToolTitle(name) || 'Task Complete';
const handleFileClick = (filePath: string) => {
@ -147,17 +175,17 @@ export function CompleteToolView({
<Badge
variant="secondary"
className={
isSuccess
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"
}
>
{isSuccess ? (
{actualIsSuccess ? (
<CheckCircle className="h-3.5 w-3.5 mr-1" />
) : (
<AlertTriangle className="h-3.5 w-3.5 mr-1" />
)}
{isSuccess ? 'Completed' : 'Failed'}
{actualIsSuccess ? 'Completed' : 'Failed'}
</Badge>
)}
@ -173,8 +201,8 @@ export function CompleteToolView({
<CardContent className="p-0 flex-1 overflow-hidden relative">
<ScrollArea className="h-full w-full">
<div className="p-4 space-y-6">
{/* Success Animation/Icon - Only show when completed successfully */}
{!isStreaming && isSuccess && !completeData.summary && !completeData.tasksCompleted && !completeData.attachments && (
{/* Success Animation/Icon - Only show when completed successfully and no text/attachments */}
{!isStreaming && actualIsSuccess && !text && !attachments && !completeData.summary && !completeData.tasksCompleted && (
<div className="flex justify-center">
<div className="relative">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-emerald-100 to-emerald-200 dark:from-emerald-800/40 dark:to-emerald-900/60 flex items-center justify-center">
@ -187,19 +215,95 @@ export function CompleteToolView({
</div>
)}
{/* Summary Section */}
{completeData.summary && (
{/* Text/Summary Section */}
{(text || completeData.summary || completeData.result) && (
<div className="space-y-2">
<div className="bg-muted/50 rounded-2xl p-4 border border-border">
<Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3">
{completeData.summary}
{text || completeData.summary || completeData.result}
</Markdown>
</div>
</div>
)}
{/* Attachments Section */}
{completeData.attachments && completeData.attachments.length > 0 && (
{attachments && attachments.length > 0 ? (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Paperclip className="h-4 w-4" />
Files ({attachments.length})
</div>
<div className={cn(
"grid gap-3",
attachments.length === 1 ? "grid-cols-1" :
attachments.length > 4 ? "grid-cols-1 sm:grid-cols-2 md:grid-cols-3" :
"grid-cols-1 sm:grid-cols-2"
)}>
{attachments
.sort((a, b) => {
const aIsImage = isImageFile(a);
const bIsImage = isImageFile(b);
const aIsPreviewable = isPreviewableFile(a);
const bIsPreviewable = isPreviewableFile(b);
if (aIsImage && !bIsImage) return -1;
if (!aIsImage && bIsImage) return 1;
if (aIsPreviewable && !bIsPreviewable) return -1;
if (!aIsPreviewable && bIsPreviewable) return 1;
return 0;
})
.map((attachment, index) => {
const isImage = isImageFile(attachment);
const isPreviewable = isPreviewableFile(attachment);
const shouldSpanFull = (attachments!.length % 2 === 1 &&
attachments!.length > 1 &&
index === attachments!.length - 1);
return (
<div
key={index}
className={cn(
"relative group",
isImage ? "flex items-center justify-center h-full" : "",
isPreviewable ? "w-full" : ""
)}
style={(shouldSpanFull || isPreviewable) ? { gridColumn: '1 / -1' } : undefined}
>
<FileAttachment
filepath={attachment}
onClick={handleFileClick}
sandboxId={project?.sandbox?.id}
showPreview={true}
className={cn(
"w-full",
isImage ? "h-auto min-h-[54px]" :
isPreviewable ? "min-h-[240px] max-h-[400px] overflow-auto" : "h-[54px]"
)}
customStyle={
isImage ? {
width: '100%',
height: 'auto',
'--attachment-height': shouldSpanFull ? '240px' : '180px'
} as React.CSSProperties :
isPreviewable ? {
gridColumn: '1 / -1'
} :
shouldSpanFull ? {
gridColumn: '1 / -1'
} : {
width: '100%'
}
}
collapsed={false}
project={project}
/>
</div>
);
})}
</div>
</div>
) : completeData.attachments && completeData.attachments.length > 0 ? (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Paperclip className="h-4 w-4" />
@ -243,7 +347,7 @@ export function CompleteToolView({
})}
</div>
</div>
)}
) : null}
{/* Tasks Completed Section */}
{completeData.tasksCompleted && completeData.tasksCompleted.length > 0 && (
@ -288,7 +392,7 @@ export function CompleteToolView({
)}
{/* Empty State */}
{!completeData.summary && !completeData.result && !completeData.attachments && !completeData.tasksCompleted && !isStreaming && (
{!text && !attachments && !completeData.summary && !completeData.result && !completeData.attachments && !completeData.tasksCompleted && !isStreaming && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<CheckCircle2 className="h-8 w-8 text-muted-foreground" />

View File

@ -0,0 +1,234 @@
import { extractToolData, normalizeContentToString } from '../utils';
export interface CompleteData {
text: string | null;
attachments: string[] | null;
status: string | null;
success?: boolean;
timestamp?: string;
}
const parseContent = (content: any): any => {
if (typeof content === 'string') {
try {
return JSON.parse(content);
} catch (e) {
return content;
}
}
return content;
};
const extractFromNewFormat = (content: any): {
text: string | null;
attachments: string[] | null;
status: string | null;
success?: boolean;
timestamp?: string;
} => {
const parsedContent = parseContent(content);
if (!parsedContent || typeof parsedContent !== 'object') {
return { text: null, attachments: null, status: null, success: undefined, timestamp: undefined };
}
if ('tool_execution' in parsedContent && typeof parsedContent.tool_execution === 'object') {
const toolExecution = parsedContent.tool_execution;
const args = toolExecution.arguments || {};
let parsedOutput = toolExecution.result?.output;
if (typeof parsedOutput === 'string') {
try {
parsedOutput = JSON.parse(parsedOutput);
} catch (e) {
}
}
let attachments: string[] | null = null;
if (args.attachments) {
if (typeof args.attachments === 'string') {
attachments = args.attachments.split(',').map((a: string) => a.trim()).filter((a: string) => a.length > 0);
} else if (Array.isArray(args.attachments)) {
attachments = args.attachments;
}
}
let status: string | null = null;
if (parsedOutput && typeof parsedOutput === 'object' && parsedOutput.status) {
status = parsedOutput.status;
}
const extractedData = {
text: args.text || null,
attachments,
status: status || parsedContent.summary || null,
success: toolExecution.result?.success,
timestamp: toolExecution.execution_details?.timestamp
};
console.log('CompleteToolView: Extracted from new format:', {
hasText: !!extractedData.text,
attachmentCount: extractedData.attachments?.length || 0,
hasStatus: !!extractedData.status,
success: extractedData.success
});
return extractedData;
}
if ('role' in parsedContent && 'content' in parsedContent) {
return extractFromNewFormat(parsedContent.content);
}
return { text: null, attachments: null, status: null, success: undefined, timestamp: undefined };
};
const extractFromLegacyFormat = (content: any): {
text: string | null;
attachments: string[] | null;
status: string | null;
} => {
const toolData = extractToolData(content);
if (toolData.toolResult && toolData.arguments) {
console.log('CompleteToolView: Extracted from legacy format (extractToolData):', {
hasText: !!toolData.arguments.text,
attachmentCount: toolData.arguments.attachments ?
(Array.isArray(toolData.arguments.attachments) ? toolData.arguments.attachments.length : 1) : 0
});
let attachments: string[] | null = null;
if (toolData.arguments.attachments) {
if (Array.isArray(toolData.arguments.attachments)) {
attachments = toolData.arguments.attachments;
} else if (typeof toolData.arguments.attachments === 'string') {
attachments = toolData.arguments.attachments.split(',').map(a => a.trim()).filter(a => a.length > 0);
}
}
return {
text: toolData.arguments.text || null,
attachments,
status: null
};
}
const contentStr = normalizeContentToString(content);
if (!contentStr) {
return { text: null, attachments: null, status: null };
}
let attachments: string[] | null = null;
const attachmentsMatch = contentStr.match(/attachments=["']([^"']*)["']/i);
if (attachmentsMatch) {
attachments = attachmentsMatch[1].split(',').map(a => a.trim()).filter(a => a.length > 0);
}
let text: string | null = null;
const textMatch = contentStr.match(/<complete[^>]*>([^<]*)<\/complete>/i);
if (textMatch) {
text = textMatch[1].trim();
}
console.log('CompleteToolView: Extracted from legacy format (manual parsing):', {
hasText: !!text,
attachmentCount: attachments?.length || 0
});
return {
text,
attachments,
status: null
};
};
export function extractCompleteData(
assistantContent: any,
toolContent: any,
isSuccess: boolean,
toolTimestamp?: string,
assistantTimestamp?: string
): {
text: string | null;
attachments: string[] | null;
status: string | null;
actualIsSuccess: boolean;
actualToolTimestamp?: string;
actualAssistantTimestamp?: string;
} {
let text: string | null = null;
let attachments: string[] | null = null;
let status: string | null = null;
let actualIsSuccess = isSuccess;
let actualToolTimestamp = toolTimestamp;
let actualAssistantTimestamp = assistantTimestamp;
const assistantNewFormat = extractFromNewFormat(assistantContent);
const toolNewFormat = extractFromNewFormat(toolContent);
console.log('CompleteToolView: Format detection results:', {
assistantNewFormat: {
hasText: !!assistantNewFormat.text,
attachmentCount: assistantNewFormat.attachments?.length || 0,
hasStatus: !!assistantNewFormat.status
},
toolNewFormat: {
hasText: !!toolNewFormat.text,
attachmentCount: toolNewFormat.attachments?.length || 0,
hasStatus: !!toolNewFormat.status
}
});
if (assistantNewFormat.text || assistantNewFormat.attachments || assistantNewFormat.status) {
text = assistantNewFormat.text;
attachments = assistantNewFormat.attachments;
status = assistantNewFormat.status;
if (assistantNewFormat.success !== undefined) {
actualIsSuccess = assistantNewFormat.success;
}
if (assistantNewFormat.timestamp) {
actualAssistantTimestamp = assistantNewFormat.timestamp;
}
console.log('CompleteToolView: Using assistant new format data');
} else if (toolNewFormat.text || toolNewFormat.attachments || toolNewFormat.status) {
text = toolNewFormat.text;
attachments = toolNewFormat.attachments;
status = toolNewFormat.status;
if (toolNewFormat.success !== undefined) {
actualIsSuccess = toolNewFormat.success;
}
if (toolNewFormat.timestamp) {
actualToolTimestamp = toolNewFormat.timestamp;
}
console.log('CompleteToolView: Using tool new format data');
} else {
const assistantLegacy = extractFromLegacyFormat(assistantContent);
const toolLegacy = extractFromLegacyFormat(toolContent);
text = assistantLegacy.text || toolLegacy.text;
attachments = assistantLegacy.attachments || toolLegacy.attachments;
status = assistantLegacy.status || toolLegacy.status;
console.log('CompleteToolView: Using legacy format data:', {
hasText: !!text,
attachmentCount: attachments?.length || 0,
hasStatus: !!status
});
}
console.log('CompleteToolView: Final extracted data:', {
hasText: !!text,
attachmentCount: attachments?.length || 0,
hasStatus: !!status,
actualIsSuccess
});
return {
text,
attachments,
status,
actualIsSuccess,
actualToolTimestamp,
actualAssistantTimestamp
};
}

View File

@ -0,0 +1,212 @@
import * as React from "react";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import * as RPNInput from "react-phone-number-input";
import flags from "react-phone-number-input/flags";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
type PhoneInputProps = Omit<
React.ComponentProps<"input">,
"onChange" | "value" | "ref"
> &
Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
onChange?: (value: RPNInput.Value) => void;
};
const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>(
({ className, onChange, value, ...props }, ref) => {
return (
<RPNInput.default
ref={ref}
className={cn("flex", className)}
flagComponent={FlagComponent}
countrySelectComponent={CountrySelect}
inputComponent={InputComponent}
smartCaret={false}
value={value || undefined}
/**
* Handles the onChange event.
*
* react-phone-number-input might trigger the onChange event as undefined
* when a valid phone number is not entered. To prevent this,
* the value is coerced to an empty string.
*
* @param {E164Number | undefined} value - The entered value
*/
onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
{...props}
/>
);
},
);
PhoneInput.displayName = "PhoneInput";
const InputComponent = React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
>(({ className, ...props }, ref) => (
<Input
className={cn("rounded-e-lg rounded-s-none", className)}
{...props}
ref={ref}
/>
));
InputComponent.displayName = "InputComponent";
type CountryEntry = { label: string; value: RPNInput.Country | undefined };
type CountrySelectProps = {
disabled?: boolean;
value: RPNInput.Country;
options: CountryEntry[];
onChange: (country: RPNInput.Country) => void;
};
const CountrySelect = ({
disabled,
value: selectedCountry,
options: countryList,
onChange,
}: CountrySelectProps) => {
const scrollAreaRef = React.useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = React.useState(false);
return (
<Popover
open={isOpen}
modal
onOpenChange={setIsOpen}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn("flex gap-1 rounded-e-none rounded-s-lg px-3", {
"opacity-50 cursor-not-allowed": disabled,
})}
disabled={disabled}
>
<FlagComponent
country={selectedCountry}
countryName={selectedCountry}
/>
<ChevronsUpDown
className={cn(
"h-4 w-4 opacity-50",
disabled ? "hidden" : "flex",
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command
filter={(value, search) => {
const searchLower = search.toLowerCase();
const valueLower = value.toLowerCase();
// Check if search matches country name, calling code, or country code
if (valueLower.includes(searchLower)) {
return 1;
}
return 0;
}}
>
<CommandInput
placeholder="Search country..."
/>
<CommandList>
<ScrollArea ref={scrollAreaRef} className="h-72">
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countryList.map(({ value, label }) =>
value ? (
<CountrySelectOption
key={value}
country={value}
countryName={label}
selectedCountry={selectedCountry}
onChange={onChange}
onSelectComplete={() => setIsOpen(false)}
/>
) : null,
)}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
interface CountrySelectOptionProps {
country: RPNInput.Country;
countryName: string;
selectedCountry: RPNInput.Country;
onChange: (country: RPNInput.Country) => void;
onSelectComplete: () => void;
}
const CountrySelectOption = ({
country,
countryName,
selectedCountry,
onChange,
onSelectComplete,
}: CountrySelectOptionProps) => {
const handleSelect = () => {
onChange(country);
onSelectComplete();
};
return (
<CommandItem
className="gap-2"
onSelect={handleSelect}
value={`${countryName} +${RPNInput.getCountryCallingCode(country)} ${country}`}
>
<FlagComponent country={country} countryName={countryName} />
<span className="flex-1 text-sm">{countryName}</span>
<span className="text-sm text-foreground/50">
{`+${RPNInput.getCountryCallingCode(country)}`}
</span>
<CheckIcon
className={`ml-auto size-4 ${
country === selectedCountry ? "opacity-100" : "opacity-0"
}`}
/>
</CommandItem>
);
};
const FlagComponent = ({ country, countryName }: { country: RPNInput.Country; countryName?: string }) => {
const Flag = flags[country];
return (
<span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20 [&_svg:not([class*='size-'])]:size-full">
{Flag && <Flag title={countryName} />}
</span>
);
};
export { PhoneInput };

View File

@ -23,6 +23,7 @@ function ScrollArea({
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollBar orientation="horizontal" />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);

View File

@ -0,0 +1,102 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { phoneVerificationService } from '@/lib/api/phone-verification';
import { useAuth } from '@/components/AuthProvider';
export const useEnrollPhoneNumber = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: phoneVerificationService.enrollPhoneNumber,
onSuccess: () => {
// Invalidate factors list after enrollment
queryClient.invalidateQueries({ queryKey: ['phone-verification-factors'] });
queryClient.invalidateQueries({ queryKey: ['mfa-aal'] });
},
});
};
export const useCreateChallenge = () => {
return useMutation({
mutationFn: phoneVerificationService.createChallenge,
});
};
export const useVerifyChallenge = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: phoneVerificationService.verifyChallenge,
onSuccess: () => {
// Invalidate all phone verification related caches after successful verification
queryClient.invalidateQueries({ queryKey: ['mfa-aal'] });
queryClient.invalidateQueries({ queryKey: ['phone-verification-factors'] });
},
});
};
export const useChallengeAndVerify = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: phoneVerificationService.challengeAndVerify,
onSuccess: () => {
// Invalidate all phone verification related caches after successful verification
queryClient.invalidateQueries({ queryKey: ['mfa-aal'] });
queryClient.invalidateQueries({ queryKey: ['phone-verification-factors'] });
},
});
};
export const useListFactors = () => {
const { user } = useAuth();
return useQuery({
queryKey: ['phone-verification-factors'],
queryFn: phoneVerificationService.listFactors,
enabled: !!user, // Only run when user is authenticated
staleTime: Infinity, // 2 minutes
retry: 2,
});
};
export const useUnenrollFactor = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: phoneVerificationService.unenrollFactor,
onSuccess: () => {
// Invalidate caches after unenrolling
queryClient.invalidateQueries({ queryKey: ['phone-verification-factors'] });
queryClient.invalidateQueries({ queryKey: ['mfa-aal'] });
},
});
};
export const useUnenrollPhoneFactor = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: phoneVerificationService.unenrollFactor,
onSuccess: () => {
// Invalidate caches after unenrolling
queryClient.invalidateQueries({ queryKey: ['phone-verification-factors'] });
queryClient.invalidateQueries({ queryKey: ['mfa-aal'] });
},
});
};
export const useGetAAL = () => {
const { user } = useAuth();
return useQuery({
queryKey: ['mfa-aal'],
queryFn: phoneVerificationService.getAAL,
enabled: !!user, // Only run when user is authenticated
staleTime: Infinity, // 1 minute
retry: 2,
});
};

View File

@ -7,6 +7,8 @@ import {
SubscriptionStatus,
} from '@/lib/api';
import { subscriptionKeys } from './keys';
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect } from 'react';
export const useSubscription = createQueryHook(
subscriptionKeys.details(),
@ -17,6 +19,39 @@ export const useSubscription = createQueryHook(
},
);
// Smart subscription hook that adapts refresh based on streaming state
export const useSubscriptionWithStreaming = (isStreaming: boolean = false) => {
const [isVisible, setIsVisible] = useState(true);
// Track page visibility
useEffect(() => {
const handleVisibilityChange = () => {
setIsVisible(!document.hidden);
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, []);
return useQuery({
queryKey: subscriptionKeys.details(),
queryFn: getSubscription,
staleTime: 1000 * 60 * 2, // 2 minutes
refetchOnWindowFocus: true,
refetchInterval: (data) => {
// No refresh if tab is hidden
if (!isVisible) return false;
// If actively streaming: refresh every 5s (costs are changing)
if (isStreaming) return 5 * 1000;
// If visible but not streaming: refresh every 5min
return 5 * 60 * 1000;
},
refetchIntervalInBackground: false, // Stop when tab backgrounded
});
};
export const useCreatePortalSession = createMutationHook(
(params: { return_url: string }) => createPortalSession(params),
{

View File

@ -43,6 +43,10 @@ export const apiClient = {
headers['Authorization'] = `Bearer ${session.access_token}`;
}
if (session?.refresh_token) {
headers['X-Refresh-Token'] = session.refresh_token;
}
const response = await fetch(url, {
...fetchOptions,
headers,

View File

@ -0,0 +1,172 @@
import { backendApi } from '@/lib/api-client';
import { createClient } from '@/lib/supabase/client';
export interface FactorInfo {
id: string;
friendly_name?: string;
factor_type?: string;
status?: string;
phone?: string;
created_at?: string;
updated_at?: string;
}
export interface PhoneVerificationEnroll {
friendly_name: string;
phone_number: string;
}
export interface PhoneVerificationChallenge {
factor_id: string;
}
export interface PhoneVerificationVerify {
factor_id: string;
challenge_id: string;
code: string;
}
export interface PhoneVerificationChallengeAndVerify {
factor_id: string;
code: string;
}
export interface PhoneVerificationResponse {
success: boolean;
message?: string;
id?: string;
expires_at?: string;
}
export interface EnrollFactorResponse {
id: string;
friendly_name: string;
phone_number: string;
qr_code?: string;
secret?: string;
}
export interface ChallengeResponse {
id: string;
expires_at?: string;
}
export interface ListFactorsResponse {
factors: FactorInfo[];
}
export interface AALResponse {
current_level?: string;
next_level?: string;
current_authentication_methods?: string[];
// Add action guidance based on AAL status
action_required?: string;
message?: string;
// Phone verification requirement fields
phone_verification_required?: boolean;
user_created_at?: string;
cutoff_date?: string;
// Computed verification status fields (same as PhoneVerificationStatus)
verification_required?: boolean;
is_verified?: boolean;
factors?: FactorInfo[];
}
export const phoneVerificationService = {
/**
* Enroll phone number for SMS-based 2FA
*/
async enrollPhoneNumber(data: PhoneVerificationEnroll): Promise<EnrollFactorResponse> {
const response = await backendApi.post<EnrollFactorResponse>('/mfa/enroll', data);
return response.data;
},
/**
* Create a challenge for an enrolled phone factor (sends SMS)
*/
async createChallenge(data: PhoneVerificationChallenge): Promise<ChallengeResponse> {
const response = await backendApi.post<ChallengeResponse>('/mfa/challenge', data);
return response.data;
},
/**
* Verify SMS code for phone verification
*/
async verifyChallenge(data: PhoneVerificationVerify): Promise<PhoneVerificationResponse> {
try {
const response = await backendApi.post('/mfa/verify', data);
// After successful verification, refresh the Supabase session
// This ensures the frontend client gets the updated session with AAL2 tokens
try {
const supabase = createClient();
await supabase.auth.refreshSession();
console.log("🔄 Frontend Supabase session refreshed after verification");
} catch (refreshError) {
console.warn("⚠️ Failed to refresh Supabase session:", refreshError);
}
return {
success: response.data.success || true,
message: response.data.message || 'SMS code verified successfully'
};
} catch (error) {
console.error("❌ Verify challenge failed:", error);
throw error;
}
},
/**
* Create challenge and verify in one step
*/
async challengeAndVerify(data: PhoneVerificationChallengeAndVerify): Promise<PhoneVerificationResponse> {
const response = await backendApi.post('/mfa/challenge-and-verify', data);
return {
success: response.data.success || true,
message: response.data.message || 'SMS challenge created and verified successfully'
};
},
/**
* Resend SMS code (create new challenge for existing factor)
*/
async resendSMS(factorId: string): Promise<ChallengeResponse> {
const response = await backendApi.post<ChallengeResponse>('/mfa/challenge', { factor_id: factorId });
return response.data;
},
/**
* List all enrolled MFA factors
*/
async listFactors(): Promise<ListFactorsResponse> {
const response = await backendApi.get<ListFactorsResponse>('/mfa/factors');
return response.data;
},
/**
* Remove phone verification from account
*/
async unenrollFactor(factorId: string): Promise<PhoneVerificationResponse> {
const response = await backendApi.post('/mfa/unenroll', { factor_id: factorId });
return {
success: response.data.success || true,
message: response.data.message || 'Phone factor unenrolled successfully'
};
},
/**
* Get Authenticator Assurance Level
*/
async getAAL(): Promise<AALResponse> {
const response = await backendApi.get<AALResponse>('/mfa/aal');
return response.data;
}
};

View File

@ -649,7 +649,7 @@ class SetupWizard:
)
print_info("Create a snapshot with these exact settings:")
print_info(f" - Name:\t\t{Colors.GREEN}kortix/suna:0.1.3{Colors.ENDC}")
print_info(f" - Image name:\t{Colors.GREEN}kortix/suna:0.1.3{Colors.ENDC}")
print_info(f" - Snapshot name:\t{Colors.GREEN}kortix/suna:0.1.3{Colors.ENDC}")
print_info(
f" - Entrypoint:\t{Colors.GREEN}/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf{Colors.ENDC}"
)