transfer call

This commit is contained in:
Sabiha Khan 2026-01-22 12:19:34 +05:30
parent 5d14d17ceb
commit 51adfdda66
41 changed files with 2633 additions and 167 deletions

View file

@ -0,0 +1,50 @@
"""add transfer_call tool category
Revision ID: 1a7d74d54e8f
Revises: 02ffd7f23d1d
Create Date: 2026-02-03 11:18:11.417837
"""
from typing import Sequence, Union
from alembic import op
from alembic_postgresql_enum import TableReference
# revision identifiers, used by Alembic.
revision: str = '1a7d74d54e8f'
down_revision: Union[str, None] = '02ffd7f23d1d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.sync_enum_values(
enum_schema="public",
enum_name="tool_category",
new_values=["http_api", "end_call", "transfer_call", "native", "integration"],
affected_columns=[
TableReference(
table_schema="public", table_name="tools", column_name="category"
)
],
enum_values_to_rename=[],
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.sync_enum_values(
enum_schema="public",
enum_name="tool_category",
new_values=["http_api", "end_call", "native", "integration"],
affected_columns=[
TableReference(
table_schema="public", table_name="tools", column_name="category"
)
],
enum_values_to_rename=[],
)
# ### end Alembic commands ###

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -122,7 +122,8 @@ class ToolCategory(Enum):
HTTP_API = "http_api" # Custom HTTP API calls (implemented)
END_CALL = "end_call" # End call tool
NATIVE = "native" # Built-in integrations (future: call_transfer, dtmf_input)
TRANSFER_CALL = "transfer_call" # Transfer call to phone number (Twilio only)
NATIVE = "native" # Built-in integrations (future: dtmf_input)
INTEGRATION = "integration" # Third-party integrations (future: Google Calendar, Salesforce, etc.)

View file

@ -4,13 +4,22 @@ Consolidated from split modules for easier maintenance.
"""
import json
import time
import uuid
from datetime import UTC, datetime
from typing import Optional
from typing import Dict, Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Request, WebSocket
from fastapi import (
APIRouter,
Depends,
Form,
Header,
HTTPException,
Request,
WebSocket,
)
from loguru import logger
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from sqlalchemy import text
from sqlalchemy.future import select
from starlette.responses import HTMLResponse
@ -26,6 +35,8 @@ from api.services.auth.depends import get_user
from api.services.campaign.campaign_call_dispatcher import campaign_call_dispatcher
from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher
from api.services.quota_service import check_dograh_quota, check_dograh_quota_by_user_id
from api.services.telephony.transfer_coordination import get_transfer_coordinator
from api.services.telephony.transfer_event_protocol import TransferContext
from api.services.telephony.factory import (
get_all_telephony_providers,
get_telephony_provider,
@ -37,10 +48,18 @@ from api.utils.telephony_helper import (
numbers_match,
parse_webhook_request,
)
from pipecat.services.llm_service import FunctionCallParams
from pipecat.utils.run_context import set_current_run_id
router = APIRouter(prefix="/telephony")
# Module-level storage for webhook-driven function call completion
# Stores function call contexts that are waiting for webhook completion
pending_function_calls: Dict[str, tuple[FunctionCallParams, float]] = {}
# Note: Transfer contexts now stored in Redis via TransferCoordinator
# pending_transfers dictionary removed in favor of Redis-based coordination
class InitiateCallRequest(BaseModel):
workflow_id: int
@ -157,7 +176,8 @@ async def initiate_call(
if not phone_number:
raise HTTPException(
status_code=400,
detail="Phone number must be provided in request or set in user configuration",
detail="Phone number must be provided in request or set in user "
"configuration",
)
workflow_run_id = request.workflow_run_id
@ -473,12 +493,13 @@ async def _validate_organization_provider_config(
@router.post("/twiml", include_in_schema=False)
async def handle_twiml_webhook(
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int, CallSid: str = Form(...)
):
"""
Handle initial webhook from telephony provider.
Returns provider-specific response (e.g., TwiML for Twilio).
"""
logger.info(f"[TWIML-DEBUG] CallSid received: {CallSid}")
provider = await get_telephony_provider(organization_id)
@ -489,6 +510,26 @@ async def handle_twiml_webhook(
return HTMLResponse(content=response_content, media_type="application/xml")
@router.post("/transfer-twiml/{conference_name}", include_in_schema=False)
async def transfer_twiml(conference_name: str):
"""
TwiML endpoint that puts the caller into a conference.
Called by Twilio when we redirect the call after closing the WebSocket stream.
"""
logger.info(f"[TRANSFER-TWIML] Generating conference TwiML for: {conference_name}")
twiml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>Connecting you now.</Say>
<Dial>
<Conference endConferenceOnExit="false" startConferenceOnEnter="true">{conference_name}</Conference>
</Dial>
</Response>"""
logger.info(f"[TRANSFER-TWIML] Generated TwiML: {twiml_content}")
return HTMLResponse(content=twiml_content, media_type="application/xml")
@router.get("/ncco", include_in_schema=False)
async def handle_ncco_webhook(
workflow_id: int,
@ -1385,6 +1426,7 @@ async def handle_inbound_telephony(
logger.info(
f"Generated {normalized_data.provider} response for call {normalized_data.call_id}"
)
logger.info(f"response is {response}")
return response
except ValueError as e:
@ -1427,8 +1469,6 @@ async def handle_inbound_fallback(request: Request):
)
return generic_hangup_response()
@router.post("/cloudonix/cdr")
async def handle_cloudonix_cdr(request: Request):
"""Handle Cloudonix CDR (Call Detail Record) webhooks.
@ -1479,4 +1519,366 @@ async def handle_cloudonix_cdr(request: Request):
f"disposition: {cdr_data.get('disposition')}, status: {status_update.status}"
)
return {"status": "success"}
class CallTransferRequest(BaseModel):
"""Request model for call transfer"""
target_phone_number: Optional[str] = None
phone_number: Optional[str] = None # Alternative field name
number: Optional[str] = None # Another alternative
current_call_sid: Optional[str] = None
def get_target_number(self) -> str:
"""Get the target phone number from any of the possible fields"""
return self.target_phone_number or self.phone_number or self.number or ""
class TransferCallRequest(BaseModel):
"""Request model for initiating call transfer using webhook-driven completion"""
destination: str # E.164 format phone number (required)
organization_id: int # Organization ID for provider configuration
timeout: Optional[int] = 20 # seconds to wait for answer
tool_call_id: Optional[str] = None # will generate if not provided
tool_uuid: Optional[str] = None # tool UUID for tracing and validation
original_call_sid: Optional[str] = None # original caller's call SID
caller_number: Optional[str] = None # original caller's phone number
@field_validator("destination")
@classmethod
def validate_destination(cls, destination: str) -> str:
"""Validate destination is in E.164 format."""
import re
if not destination or not destination.strip():
raise ValueError("Destination phone number is required")
e164_pattern = r"^\+[1-9]\d{1,14}$"
if not re.match(e164_pattern, destination.strip()):
raise ValueError(f"Invalid phone number format: {destination}. Must be E.164 format (e.g., +1234567890)")
return destination.strip()
@router.post("/call-transfer")
async def initiate_call_transfer(request: TransferCallRequest):
"""Initiate call transfer without blocking the pipeline"""
import aiohttp
logger.info(f"Received call transfer request: {request}")
# Generate tool_call_id if not provided
if not request.tool_call_id:
request.tool_call_id = f"transfer_{int(time.time())}_{uuid.uuid4().hex[:8]}"
# Log tool details for tracing
logger.info(f"Starting non-blocking call transfer to {request.destination} with tool_call_id: {request.tool_call_id}, tool_uuid: {request.tool_uuid}")
# TODO: Add tool UUID validation here if needed
# For example: Validate that the tool UUID corresponds to a valid transfer call tool
# and that the destination matches the tool's configured destination pattern
try:
# Get provider that supports transfers (validates Twilio-only requirement)
from api.services.telephony.factory import get_transfer_provider
try:
provider = await get_transfer_provider(request.organization_id)
except ValueError as e:
# Provider doesn't support transfers or organization not configured
logger.error(f"Transfer provider validation failed: {e}")
raise HTTPException(
status_code=400,
detail=f"Call transfer not supported: {str(e)}"
)
# Validate configuration before attempting transfer
if not provider.validate_config():
logger.error(f"Provider {provider.PROVIDER_NAME} configuration is invalid")
raise HTTPException(
status_code=500,
detail=f"Telephony provider '{provider.PROVIDER_NAME}' is not properly configured for transfers"
)
# Initiate transfer call via provider
logger.info(f"Initiating transfer call via {provider.PROVIDER_NAME} provider")
try:
transfer_result = await provider.transfer_call(
destination=request.destination,
tool_call_id=request.tool_call_id,
timeout=request.timeout
)
except NotImplementedError as e:
# This shouldn't happen due to get_transfer_provider validation, but safety check
logger.error(f"Provider {provider.PROVIDER_NAME} doesn't support transfers: {e}")
raise HTTPException(
status_code=400,
detail=f"Provider '{provider.PROVIDER_NAME}' does not support call transfers"
)
except Exception as e:
# Provider API call failed
logger.error(f"Provider transfer call failed: {e}")
raise HTTPException(
status_code=500,
detail=f"Transfer call failed: {str(e)}"
)
call_sid = transfer_result.get("call_sid")
logger.info(f"Transfer call initiated successfully: {call_sid}")
logger.info(f"Transfer result: {transfer_result}")
# Store the transfer context in Redis for webhook completion
transfer_coordinator = await get_transfer_coordinator()
transfer_context = TransferContext(
tool_call_id=request.tool_call_id,
call_sid=call_sid,
target_number=request.destination,
tool_uuid=request.tool_uuid,
original_call_sid=request.original_call_sid,
caller_number=request.caller_number,
initiated_at=time.time(),
workflow_run_id=0 # TODO: Add workflow_run_id to request if needed
)
await transfer_coordinator.store_transfer_context(transfer_context)
# Return immediately without blocking
return {
"status": "transfer_initiated",
"call_id": call_sid,
"message": f"Calling {request.destination}...",
"tool_call_id": request.tool_call_id,
"provider": provider.PROVIDER_NAME
}
except HTTPException:
# Re-raise HTTP exceptions (already properly formatted)
raise
except Exception as e:
# Catch any other unexpected errors
logger.error(f"Unexpected error during transfer call: {e}")
raise HTTPException(
status_code=500,
detail=f"Internal error during transfer: {str(e)}"
)
@router.post("/transfer-call-handler/{tool_call_id}")
async def handle_transfer_call_answered(tool_call_id: str, request: Request):
"""Handle when target answers the transfer call"""
logger.info(f"Transfer call answered for tool_call_id: {tool_call_id}")
form_data = await request.form()
data = dict(form_data)
call_sid = data.get("CallSid", "")
# Get transfer context from Redis
transfer_coordinator = await get_transfer_coordinator()
transfer_context = await transfer_coordinator.get_transfer_context(tool_call_id)
original_call_sid = transfer_context.original_call_sid if transfer_context else None
caller_number = transfer_context.caller_number if transfer_context else None
# Use original call SID for conference name if available, otherwise fall back to transfer call SID
base_call_sid = original_call_sid or call_sid
conference_name = f"transfer-{base_call_sid}"
logger.info(f"Using conference name: {conference_name}")
# Publish Redis event for transfer answer completion
try:
# Get transfer coordinator and context
transfer_coordinator = await get_transfer_coordinator()
transfer_context = await transfer_coordinator.get_transfer_context(tool_call_id)
if transfer_context:
# Create transfer answered event
from api.services.telephony.transfer_event_protocol import TransferEvent, TransferEventType
transfer_event = TransferEvent(
type=TransferEventType.TRANSFER_ANSWERED,
tool_call_id=tool_call_id,
workflow_run_id=transfer_context.workflow_run_id,
original_call_sid=original_call_sid,
transfer_call_sid=call_sid,
conference_name=conference_name,
message="Great! The person answered. Let me transfer you now.",
status="success",
action="transfer_success"
)
# Publish the event to Redis
await transfer_coordinator.publish_transfer_event(transfer_event)
logger.info(f"Published TRANSFER_ANSWERED event for {tool_call_id}")
else:
logger.warning(f"No transfer context found for {tool_call_id}")
except Exception as e:
logger.error(f"Error publishing transfer answered event for {tool_call_id}: {e}")
# Return TwiML to put the answerer into the conference
twiml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>You have answered a transfer call. Connecting you now.</Say>
<Dial>
<Conference>{conference_name}</Conference>
</Dial>
</Response>"""
return HTMLResponse(content=twiml, media_type="application/xml")
@router.post("/transfer-result/{tool_call_id}")
async def complete_transfer_function_call(tool_call_id: str, request: Request):
"""Webhook endpoint to complete the function call with transfer result"""
form_data = await request.form()
data = dict(form_data)
call_status = data.get("CallStatus", "")
call_sid = data.get("CallSid", "")
logger.info(f"Transfer result webhook: {tool_call_id} status={call_status}")
# Note: All transfer coordination now handled via Redis events
# If this is a "completed" status but we already handled the success case via answer webhook,
# we should still publish the COMPLETED event but not override previous results
if call_status == "completed":
# Check if we already published an ANSWERED event
logger.info(f"Processing 'completed' status for {tool_call_id}")
# We'll still publish COMPLETED event but with different handling
# Import required event classes
from api.services.telephony.transfer_event_protocol import TransferEvent, TransferEventType
# Get transfer context from Redis for additional information
transfer_coordinator = await get_transfer_coordinator()
transfer_context = await transfer_coordinator.get_transfer_context(tool_call_id)
original_call_sid = transfer_context.original_call_sid if transfer_context else None
caller_number = transfer_context.caller_number if transfer_context else None
# Determine the result based on call status with user-friendly messaging
if call_status == "answered":
# Use original call SID for conference name if available, otherwise fall back to transfer call SID
base_call_sid = original_call_sid or call_sid
conference_name = f"transfer-{base_call_sid}"
result = {
"status": "success",
"message": "Great! The person answered. Let me transfer you now.",
"action": "transfer_success",
"conference_id": conference_name,
"transfer_call_sid": call_sid, # The outbound transfer call SID
"original_call_sid": original_call_sid, # The original caller's SID
"caller_number": caller_number,
"end_call": False # Continue with transfer
}
elif call_status == "no-answer":
result = {
"status": "transfer_failed",
"reason": "no_answer",
"message": "The transfer call was not answered. The person may be busy or unavailable right now.",
"action": "transfer_failed",
"call_sid": call_sid,
"end_call": True
}
elif call_status == "busy":
result = {
"status": "transfer_failed",
"reason": "busy",
"message": "The transfer call encountered a busy signal. The person is likely on another call.",
"action": "transfer_failed",
"call_sid": call_sid,
"end_call": True
}
elif call_status == "failed":
result = {
"status": "transfer_failed",
"reason": "call_failed",
"message": "The transfer call failed to connect. There may be a network issue or the number is unavailable.",
"action": "transfer_failed",
"call_sid": call_sid,
"end_call": True
}
elif call_status == "completed":
# Call completed successfully
result = {
"status": "completed",
"message": "Your call has been completed successfully.",
"action": "transfer_completed",
"call_sid": call_sid,
"end_call": True # End call normally
}
else:
# Intermediate status (ringing, in-progress, etc.), don't complete yet
logger.info(f"Received intermediate status {call_status}, waiting for final status")
return {"status": "pending"}
# Complete the function call with Redis event publishing
try:
# Determine event type based on result status
if result["status"] == "success":
event_type = TransferEventType.TRANSFER_COMPLETED
elif result.get("reason") == "timeout":
event_type = TransferEventType.TRANSFER_TIMEOUT
else:
event_type = TransferEventType.TRANSFER_FAILED
# Create and publish transfer event
# Add caller_number to result if not already present
if "caller_number" not in result and caller_number:
result["caller_number"] = caller_number
transfer_event = TransferEvent(
type=event_type,
tool_call_id=tool_call_id,
workflow_run_id=0, # TODO: Extract from context if needed
original_call_sid=original_call_sid or "",
transfer_call_sid=call_sid,
conference_name=result.get("conference_id"),
message=result.get("message", ""),
status=result["status"],
action=result.get("action", ""),
reason=result.get("reason"),
end_call=result.get("end_call", False)
)
# Publish the event via Redis
await transfer_coordinator.publish_transfer_event(transfer_event)
logger.info(f"Published {event_type} event for {tool_call_id}")
# Clean up transfer context from Redis
await transfer_coordinator.remove_transfer_context(tool_call_id)
logger.info(f"Function call {tool_call_id} completed with result: {result['status']}")
except Exception as e:
logger.error(f"Error completing function call {tool_call_id}: {e}")
return {"status": "completed", "result": result}
@router.post("/register-transfer-tool-call")
async def register_transfer_tool_call(request: Request):
"""Register a pending transfer function call for webhook completion"""
data = await request.json()
tool_call_id = data.get("tool_call_id")
function_call_params = data.get("function_call_params")
if not tool_call_id or not function_call_params:
raise HTTPException(status_code=400, detail="Missing required fields")
# Store the function call context for webhook completion
pending_function_calls[tool_call_id] = (function_call_params, time.time())
logger.info(f"Registered transfer tool call: {tool_call_id}")
return {"status": "registered", "tool_call_id": tool_call_id}

View file

@ -2,9 +2,10 @@
from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
import re
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from api.db import db_client
from api.db.models import UserModel
@ -56,6 +57,42 @@ class EndCallConfig(BaseModel):
)
class TransferCallConfig(BaseModel):
"""Configuration for Transfer Call tools."""
destination: str = Field(
description="Phone number to transfer the call to (E.164 format, e.g., +1234567890)"
)
messageType: Literal["none", "custom"] = Field(
default="none", description="Type of message to play before transfer"
)
customMessage: Optional[str] = Field(
default=None, description="Custom message to play before transferring the call"
)
timeout: int = Field(
default=30,
ge=5,
le=120,
description="Maximum time in seconds to wait for destination to answer (5-120 seconds)"
)
@field_validator("destination")
@classmethod
def validate_destination(cls, v: str) -> str:
"""Validate that destination is a valid E.164 phone number."""
# Allow empty string for initial creation (like HTTP API tools with empty URL)
if not v.strip():
return v
# E.164 format: +[1-9]\d{1,14}
e164_pattern = r"^\+[1-9]\d{1,14}$"
if not re.match(e164_pattern, v):
raise ValueError(
"Destination must be a valid E.164 phone number (e.g., +1234567890)"
)
return v
class HttpApiToolDefinition(BaseModel):
"""Tool definition for HTTP API tools."""
@ -72,9 +109,17 @@ class EndCallToolDefinition(BaseModel):
config: EndCallConfig = Field(description="End Call configuration")
class TransferCallToolDefinition(BaseModel):
"""Tool definition for Transfer Call tools."""
schema_version: int = Field(default=1, description="Schema version")
type: Literal["transfer_call"] = Field(description="Tool type")
config: TransferCallConfig = Field(description="Transfer Call configuration")
# Union type for tool definitions - Pydantic will discriminate based on 'type' field
ToolDefinition = Annotated[
Union[HttpApiToolDefinition, EndCallToolDefinition],
Union[HttpApiToolDefinition, EndCallToolDefinition, TransferCallToolDefinition],
Field(discriminator="type"),
]
@ -89,6 +134,15 @@ class CreateToolRequest(BaseModel):
icon_color: Optional[str] = Field(default="#3B82F6", max_length=7)
definition: ToolDefinition
@field_validator("category")
@classmethod
def validate_category(cls, v: str) -> str:
"""Validate that category is a valid ToolCategory value."""
valid_categories = [c.value for c in ToolCategory]
if v not in valid_categories:
raise ValueError(f"Invalid category '{v}'. Must be one of: {', '.join(valid_categories)}")
return v
class UpdateToolRequest(BaseModel):
"""Request schema for updating a tool."""

View file

@ -9,6 +9,7 @@ import asyncio
from typing import Dict, Set
from loguru import logger
from pipecat.audio.utils import mix_audio
from pipecat.frames.frames import (
Frame,

View file

@ -80,14 +80,29 @@ def register_event_handlers(
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(_transport, _participant):
call_disposed = engine.is_call_disposed()
transfer_in_progress = getattr(engine, '_transfer_in_progress', False)
logger.info(f"[TRANSFER-DEBUG] on_client_disconnected triggered")
logger.info(f"[TRANSFER-DEBUG] Engine instance ID in event_handler: {id(engine)}")
logger.info(f"[TRANSFER-DEBUG] Engine type in event_handler: {type(engine)}")
logger.info(f"[TRANSFER-DEBUG] transfer_in_progress attribute exists: {hasattr(engine, '_transfer_in_progress')}")
logger.info(f"[TRANSFER-DEBUG] transfer_in_progress value: {transfer_in_progress}")
logger.info(f"[TRANSFER-DEBUG] Call disposed: {call_disposed}")
logger.debug(
f"In on_client_disconnected callback handler. Call disposed: {call_disposed}"
f"In on_client_disconnected callback handler. Call disposed: {call_disposed}, "
f"Transfer in progress: {transfer_in_progress}"
)
# Stop recordings
await audio_buffer.stop_recording()
# End the call
# Skip auto hang-up if transfer is in progress
if transfer_in_progress:
logger.info("Transfer in progress - skipping auto hang-up, letting redirect handle call")
return
logger.info("Transfer in progress - False, proceeding with hang up")
# Normal hang-up logic for non-transfer disconnections
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)

View file

@ -71,6 +71,12 @@ def build_pipeline(
user_context_aggregator,
llm, # LLM
pipeline_engine_callback_processor,
]
)
processors.extend(
[
tts, # TTS
transport.output(), # Transport bot output
audio_buffer, # AudioBufferProcessor - records both input and output audio

View file

@ -115,6 +115,7 @@ async def run_pipeline_twilio(
# Create audio configuration for Twilio
audio_config = create_audio_config(WorkflowRunMode.TWILIO.value)
transport = await create_twilio_transport(
websocket_client,
stream_sid,
@ -125,6 +126,7 @@ async def run_pipeline_twilio(
vad_config,
ambient_noise_config,
)
await _run_pipeline(
transport,
workflow_id,
@ -543,6 +545,7 @@ async def _run_pipeline(
engine = PipecatEngine(
llm=llm,
transport=transport,
workflow=workflow_graph,
call_context_vars=merged_call_context_vars,
workflow_run_id=workflow_run_id,
@ -552,7 +555,7 @@ async def _run_pipeline(
embeddings_base_url=embeddings_base_url,
)
# Create pipeline components with audio configuration
# Create pipeline components
audio_buffer, context = create_pipeline_components(audio_config)
# Set the context and audio_buffer after creation

View file

@ -24,6 +24,7 @@ from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from loguru import logger
librnnoise_path = os.path.normpath(
str(APP_ROOT_DIR / "native" / "rnnoise" / "librnnoise.so")

View file

@ -21,9 +21,10 @@ from fastapi import (
status,
)
from fastapi.websockets import WebSocketState
from pipecat.audio.turn.smart_turn.local_smart_turn_v2 import LocalSmartTurnAnalyzerV2
from scipy.io import wavfile
from pipecat.audio.turn.smart_turn.local_smart_turn_v2 import LocalSmartTurnAnalyzerV2
LOG_LEVEL = (
logging.DEBUG
if os.environ.get("LOG_LEVEL", "DEBUG").lower() == "debug"

View file

@ -309,3 +309,45 @@ class TelephonyProvider(ABC):
Tuple of (Response, media_type) - Response object and content type
"""
pass
# ======== CALL TRANSFER METHODS ========
@abstractmethod
async def transfer_call(
self,
destination: str,
tool_call_id: str,
timeout: int = 30,
**kwargs: Any
) -> Dict[str, Any]:
"""
Initiate a call transfer to a destination number.
Args:
destination: The destination phone number (E.164 format)
tool_call_id: Unique identifier for tracking this transfer
timeout: Transfer timeout in seconds
**kwargs: Provider-specific additional parameters
Returns:
Dict containing:
- call_sid: Provider's call identifier
- status: Transfer initiation status
- provider: Provider name
- webhook_urls: Dict with answer and status callback URLs
Raises:
NotImplementedError: If provider doesn't support transfers
ValueError: If provider configuration is invalid
"""
pass
@abstractmethod
def supports_transfers(self) -> bool:
"""
Check if this provider supports call transfers.
Returns:
True if provider supports call transfers, False otherwise
"""
pass

View file

@ -128,3 +128,29 @@ async def get_all_telephony_providers() -> List[Type[TelephonyProvider]]:
List of provider classes that can be used for webhook detection
"""
return [CloudonixProvider, TwilioProvider, VobizProvider, VonageProvider]
async def get_transfer_provider(organization_id: int) -> TelephonyProvider:
"""
Get telephony provider that supports call transfers.
Args:
organization_id: Organization ID for provider lookup
Returns:
Configured telephony provider that supports transfers
Raises:
ValueError: If provider doesn't support transfers or org not configured
"""
provider = await get_telephony_provider(organization_id)
if not provider.supports_transfers():
config = await load_telephony_config(organization_id)
provider_type = config.get("provider", "unknown")
raise ValueError(
f"Organization telephony provider '{provider_type}' does not support call transfers. "
f"Only Twilio provider supports transfers."
)
return provider

View file

@ -680,3 +680,29 @@ class CloudonixProvider(TelephonyProvider):
</Response>"""
return Response(content=twiml, media_type="application/xml"), "application/xml"
# ======== CALL TRANSFER METHODS ========
async def transfer_call(
self,
destination: str,
tool_call_id: str,
timeout: int = 30,
**kwargs: Any
) -> Dict[str, Any]:
"""
Cloudonix provider does not support call transfers.
Raises:
NotImplementedError: Always, as Cloudonix doesn't support transfers
"""
raise NotImplementedError("Cloudonix provider does not support call transfers")
def supports_transfers(self) -> bool:
"""
Cloudonix does not support call transfers.
Returns:
False - Cloudonix provider does not support call transfers
"""
return False

View file

@ -10,6 +10,7 @@ import aiohttp
from fastapi import HTTPException
from loguru import logger
from twilio.request_validator import RequestValidator
from pipecat.utils.context import set_current_call_sid
from api.enums import WorkflowRunMode
from api.services.telephony.base import (
@ -72,6 +73,7 @@ class TwilioProvider(TelephonyProvider):
if from_number is None:
from_number = random.choice(self.from_numbers)
logger.info(f"Selected phone number {from_number} for outbound call")
logger.info(f"Webhook url received - {webhook_url}")
# Prepare call data
data = {"To": to_number, "From": from_number, "Url": webhook_url}
@ -172,6 +174,7 @@ class TwilioProvider(TelephonyProvider):
</Connect>
<Pause length="40"/>
</Response>"""
logger.info(f"Twiml content generated - {twiml_content}")
return twiml_content
async def get_call_cost(self, call_id: str) -> Dict[str, Any]:
@ -280,6 +283,11 @@ class TwilioProvider(TelephonyProvider):
try:
stream_sid = start_msg["start"]["streamSid"]
call_sid = start_msg["start"]["callSid"]
# Set call SID in Pipecat context for use throughout the pipeline
set_current_call_sid(call_sid)
logger.info(f"Set call SID context: {call_sid}")
except KeyError:
logger.error("Missing streamSid or callSid in start message")
await websocket.close(code=4400, reason="Missing stream identifiers")
@ -459,3 +467,111 @@ class TwilioProvider(TelephonyProvider):
</Response>"""
return Response(content=twiml_content, media_type="application/xml")
# ======== CALL TRANSFER METHODS ========
async def transfer_call(
self,
destination: str,
tool_call_id: str,
timeout: int = 30,
**kwargs: Any
) -> Dict[str, Any]:
"""
Initiate a call transfer via Twilio.
Args:
destination: The destination phone number (E.164 format)
tool_call_id: Unique identifier for tracking this transfer
timeout: Transfer timeout in seconds
**kwargs: Additional Twilio-specific parameters
Returns:
Dict containing transfer result information
Raises:
ValueError: If provider configuration is invalid
Exception: If Twilio API call fails
"""
if not self.validate_config():
raise ValueError("Twilio provider not properly configured")
# Select a random phone number for the transfer
from_number = random.choice(self.from_numbers)
logger.info(f"Selected phone number {from_number} for transfer call")
backend_endpoint, _ = await get_backend_endpoints()
# Generate webhook URLs for the transfer call
call_url = f"{backend_endpoint}/api/v1/telephony/transfer-call-handler/{tool_call_id}"
status_callback_url = f"{backend_endpoint}/api/v1/telephony/transfer-result/{tool_call_id}"
logger.debug(f"Transfer webhook URLs - Answer: {call_url}, Status: {status_callback_url}")
# Prepare Twilio API call data
endpoint = f"{self.base_url}/Calls.json"
data = {
"To": destination,
"From": from_number,
"Timeout": timeout,
"Url": call_url,
"StatusCallback": status_callback_url,
"StatusCallbackEvent": ["answered", "no-answer", "busy", "failed", "completed"],
"StatusCallbackMethod": "POST"
}
# Add any additional kwargs
data.update(kwargs)
try:
# Make Twilio API call
logger.info(f"Making Twilio transfer API call to: {endpoint}")
logger.info(f"Transfer call data: {data}")
async with aiohttp.ClientSession() as session:
auth = aiohttp.BasicAuth(self.account_sid, self.auth_token)
async with session.post(endpoint, data=data, auth=auth) as response:
response_status = response.status
response_text = await response.text()
logger.info(f"Twilio transfer API response status: {response_status}")
logger.info(f"Twilio transfer API response body: {response_text}")
if response_status in [200, 201]:
try:
response_data = await response.json()
call_sid = response_data.get("sid")
logger.info(f"Transfer call initiated successfully: {call_sid}")
return {
"call_sid": call_sid,
"status": response_data.get("status", "queued"),
"provider": self.PROVIDER_NAME,
"webhook_urls": {
"answer": call_url,
"status": status_callback_url
},
"from_number": from_number,
"to_number": destination,
"raw_response": response_data
}
except Exception as e:
logger.error(f"Failed to parse Twilio transfer response JSON: {e}")
raise Exception(f"Failed to parse transfer response: {e}")
else:
error_msg = f"Twilio API call failed with status {response_status}: {response_text}"
logger.error(error_msg)
raise Exception(error_msg)
except Exception as e:
logger.error(f"Exception during Twilio transfer call: {e}")
raise
def supports_transfers(self) -> bool:
"""
Twilio supports call transfers.
Returns:
True - Twilio provider supports call transfers
"""
return True

View file

@ -533,3 +533,29 @@ class VobizProvider(TelephonyProvider):
</Response>"""
return Response(content=vobiz_xml_content, media_type="application/xml")
# ======== CALL TRANSFER METHODS ========
async def transfer_call(
self,
destination: str,
tool_call_id: str,
timeout: int = 30,
**kwargs: Any
) -> Dict[str, Any]:
"""
Vobiz provider does not support call transfers.
Raises:
NotImplementedError: Always, as Vobiz doesn't support transfers
"""
raise NotImplementedError("Vobiz provider does not support call transfers")
def supports_transfers(self) -> bool:
"""
Vobiz does not support call transfers.
Returns:
False - Vobiz provider does not support call transfers
"""
return False

View file

@ -484,3 +484,29 @@ class VonageProvider(TelephonyProvider):
]
return Response(content=json.dumps(error_ncco), media_type="application/json")
# ======== CALL TRANSFER METHODS ========
async def transfer_call(
self,
destination: str,
tool_call_id: str,
timeout: int = 30,
**kwargs: Any
) -> Dict[str, Any]:
"""
Vonage provider does not support call transfers.
Raises:
NotImplementedError: Always, as Vonage doesn't support transfers
"""
raise NotImplementedError("Vonage provider does not support call transfers")
def supports_transfers(self) -> bool:
"""
Vonage does not support call transfers.
Returns:
False - Vonage provider does not support call transfers
"""
return False

View file

@ -0,0 +1,204 @@
"""Redis-based transfer coordination service for multi-instance scaling.
Handles transfer event publishing, subscription, and context storage
across multiple API server instances.
"""
import asyncio
import time
from typing import Optional, Dict, Any
from loguru import logger
import redis.asyncio as aioredis
from api.constants import REDIS_URL
from api.services.telephony.transfer_event_protocol import (
TransferEvent,
TransferContext,
TransferEventType,
TransferRedisChannels
)
class TransferCoordinator:
"""Coordinates transfer events and context storage using Redis."""
def __init__(self, redis_client: Optional[aioredis.Redis] = None):
self._redis_client = redis_client
self._pubsub_connections: Dict[str, aioredis.client.PubSub] = {}
async def _get_redis(self) -> aioredis.Redis:
"""Get Redis client instance."""
if not self._redis_client:
self._redis_client = await aioredis.from_url(
REDIS_URL, decode_responses=True
)
return self._redis_client
async def store_transfer_context(self, context: TransferContext, ttl: int = 300) -> None:
"""Store transfer context in Redis with TTL.
Args:
context: Transfer context data
ttl: Time to live in seconds (default 5 minutes)
"""
try:
redis = await self._get_redis()
key = TransferRedisChannels.transfer_context_key(context.tool_call_id)
await redis.setex(key, ttl, context.to_json())
logger.debug(f"Stored transfer context for {context.tool_call_id}")
except Exception as e:
logger.error(f"Failed to store transfer context: {e}")
async def get_transfer_context(self, tool_call_id: str) -> Optional[TransferContext]:
"""Retrieve transfer context from Redis.
Args:
tool_call_id: Tool call identifier
Returns:
Transfer context if found, None otherwise
"""
try:
redis = await self._get_redis()
key = TransferRedisChannels.transfer_context_key(tool_call_id)
data = await redis.get(key)
if data:
return TransferContext.from_json(data)
return None
except Exception as e:
logger.error(f"Failed to get transfer context: {e}")
return None
async def remove_transfer_context(self, tool_call_id: str) -> None:
"""Remove transfer context from Redis.
Args:
tool_call_id: Tool call identifier
"""
try:
redis = await self._get_redis()
key = TransferRedisChannels.transfer_context_key(tool_call_id)
await redis.delete(key)
logger.debug(f"Removed transfer context for {tool_call_id}")
except Exception as e:
logger.error(f"Failed to remove transfer context: {e}")
async def publish_transfer_event(self, event: TransferEvent) -> None:
"""Publish transfer event to Redis channel.
Args:
event: Transfer event to publish
"""
try:
# Add timestamp if not present
if event.timestamp is None:
event.timestamp = time.time()
redis = await self._get_redis()
channel = TransferRedisChannels.transfer_events(event.tool_call_id)
await redis.publish(channel, event.to_json())
logger.info(f"Published {event.type} event for {event.tool_call_id}")
except Exception as e:
logger.error(f"Failed to publish transfer event: {e}")
async def wait_for_transfer_completion(
self,
tool_call_id: str,
timeout_seconds: float = 30.0
) -> Optional[TransferEvent]:
"""Wait for transfer completion event using Redis pub/sub.
Args:
tool_call_id: Tool call identifier to wait for
timeout_seconds: Maximum time to wait
Returns:
Transfer completion event if received, None on timeout
"""
channel = TransferRedisChannels.transfer_events(tool_call_id)
redis = await self._get_redis()
pubsub = redis.pubsub()
try:
await pubsub.subscribe(channel)
logger.info(f"Waiting for transfer completion on {channel} (timeout: {timeout_seconds}s)")
# Wait for completion event with timeout
async def wait_for_message():
async for message in pubsub.listen():
if message["type"] == "message":
try:
event = TransferEvent.from_json(message["data"])
logger.info(f"Received {event.type} event for {tool_call_id}")
# Check if this is a completion event
if event.type in [
TransferEventType.TRANSFER_ANSWERED, # Call answered = transfer successful
TransferEventType.TRANSFER_COMPLETED,
TransferEventType.TRANSFER_FAILED,
TransferEventType.TRANSFER_CANCELLED,
TransferEventType.TRANSFER_TIMEOUT
]:
return event
except Exception as e:
logger.error(f"Failed to parse transfer event: {e}")
continue
return None
# Wait with timeout
result = await asyncio.wait_for(wait_for_message(), timeout=timeout_seconds)
return result
except asyncio.TimeoutError:
logger.error(f"Transfer completion wait timed out for {tool_call_id}")
# Publish timeout event for other instances
timeout_event = TransferEvent(
type=TransferEventType.TRANSFER_TIMEOUT,
tool_call_id=tool_call_id,
workflow_run_id=0, # Will be updated by caller
original_call_sid="",
status="failed",
reason="timeout",
end_call=True
)
await self.publish_transfer_event(timeout_event)
return None
except Exception as e:
logger.error(f"Error waiting for transfer completion: {e}")
return None
finally:
try:
await pubsub.unsubscribe(channel)
await pubsub.close()
except Exception as e:
logger.error(f"Error closing pubsub connection: {e}")
async def cleanup(self):
"""Clean up Redis connections."""
try:
# Close pubsub connections
for pubsub in self._pubsub_connections.values():
try:
await pubsub.close()
except:
pass
self._pubsub_connections.clear()
# Close main Redis connection
if self._redis_client:
await self._redis_client.close()
self._redis_client = None
except Exception as e:
logger.error(f"Error during transfer coordinator cleanup: {e}")
# Global transfer coordinator instance
_transfer_coordinator: Optional[TransferCoordinator] = None
async def get_transfer_coordinator() -> TransferCoordinator:
"""Get or create the global transfer coordinator instance."""
global _transfer_coordinator
if not _transfer_coordinator:
_transfer_coordinator = TransferCoordinator()
return _transfer_coordinator

View file

@ -0,0 +1,105 @@
"""Redis communication protocol for transfer coordination.
Defines event formats and Redis channels for coordinating call transfers
across multiple API server instances.
"""
import json
from dataclasses import asdict, dataclass
from enum import Enum
from typing import Any, Dict, Optional
class TransferEventType(str, Enum):
"""Types of transfer events sent between instances."""
TRANSFER_INITIATED = "transfer_initiated"
TRANSFER_ANSWERED = "transfer_answered"
TRANSFER_COMPLETED = "transfer_completed"
TRANSFER_FAILED = "transfer_failed"
TRANSFER_CANCELLED = "transfer_cancelled"
TRANSFER_TIMEOUT = "transfer_timeout"
@dataclass
class TransferEvent:
"""Event data structure for transfer coordination."""
type: TransferEventType
tool_call_id: str
workflow_run_id: int
original_call_sid: str
transfer_call_sid: Optional[str] = None
target_number: Optional[str] = None
conference_name: Optional[str] = None
message: Optional[str] = None
status: Optional[str] = None
action: Optional[str] = None
reason: Optional[str] = None
end_call: bool = False
timestamp: Optional[float] = None
def to_json(self) -> str:
"""Convert event to JSON string."""
return json.dumps(asdict(self))
@classmethod
def from_json(cls, data: str) -> "TransferEvent":
"""Create event from JSON string."""
return cls(**json.loads(data))
def to_result_dict(self) -> Dict[str, Any]:
"""Convert to function call result format."""
result = {
"status": self.status or "success",
"message": self.message or "",
"action": self.action or self.type,
"conference_id": self.conference_name,
"transfer_call_sid": self.transfer_call_sid,
"original_call_sid": self.original_call_sid,
"caller_number": None, # Will be populated by webhook handler
"end_call": self.end_call,
"reason": self.reason
}
return result
@dataclass
class TransferContext:
"""Transfer context data stored in Redis."""
tool_call_id: str
call_sid: Optional[str]
target_number: str
tool_uuid: str
original_call_sid: str
caller_number: Optional[str]
initiated_at: float
workflow_run_id: int
def to_json(self) -> str:
"""Convert context to JSON string."""
return json.dumps(asdict(self))
@classmethod
def from_json(cls, data: str) -> "TransferContext":
"""Create context from JSON string."""
return cls(**json.loads(data))
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return asdict(self)
class TransferRedisChannels:
"""Redis channel naming conventions for transfer events."""
@staticmethod
def transfer_events(tool_call_id: str) -> str:
"""Channel for transfer events for a specific tool call."""
return f"transfer:events:{tool_call_id}"
@staticmethod
def transfer_context_key(tool_call_id: str) -> str:
"""Redis key for transfer context storage."""
return f"transfer:context:{tool_call_id}"

View file

@ -0,0 +1,135 @@
"""Test utilities for transfer coordination.
This module provides utilities to test Redis-based transfer coordination
across multiple instances.
"""
import asyncio
import time
import uuid
from typing import Optional
from loguru import logger
from api.services.telephony.transfer_coordination import get_transfer_coordinator
from api.services.telephony.transfer_event_protocol import (
TransferContext,
TransferEvent,
TransferEventType
)
async def test_redis_coordination():
"""Test basic Redis pub/sub coordination for transfers."""
logger.info("Testing Redis-based transfer coordination...")
transfer_coordinator = await get_transfer_coordinator()
# Test 1: Store and retrieve transfer context
tool_call_id = str(uuid.uuid4())
test_context = TransferContext(
tool_call_id=tool_call_id,
call_sid="test_call_123",
target_number="+1234567890",
tool_uuid="test_tool_uuid",
original_call_sid="original_call_123",
caller_number="+0987654321",
initiated_at=time.time(),
workflow_run_id=123
)
logger.info("Test 1: Storing transfer context...")
await transfer_coordinator.store_transfer_context(test_context)
logger.info("Test 1: Retrieving transfer context...")
retrieved_context = await transfer_coordinator.get_transfer_context(tool_call_id)
if retrieved_context and retrieved_context.tool_call_id == tool_call_id:
logger.info("✅ Test 1 PASSED: Context storage/retrieval works")
else:
logger.error("❌ Test 1 FAILED: Context storage/retrieval failed")
return False
# Test 2: Event publishing and waiting
logger.info("Test 2: Testing event publishing...")
# Start waiting for completion in background
async def wait_for_completion():
return await transfer_coordinator.wait_for_transfer_completion(tool_call_id, 5.0)
wait_task = asyncio.create_task(wait_for_completion())
# Give it a moment to start waiting
await asyncio.sleep(0.5)
# Publish completion event
test_event = TransferEvent(
type=TransferEventType.TRANSFER_COMPLETED,
tool_call_id=tool_call_id,
workflow_run_id=123,
original_call_sid="original_call_123",
transfer_call_sid="transfer_call_456",
conference_name="test-conference",
message="Test transfer completed successfully",
status="success",
action="transfer_success"
)
logger.info("Test 2: Publishing completion event...")
await transfer_coordinator.publish_transfer_event(test_event)
# Wait for the completion
received_event = await wait_task
if received_event and received_event.tool_call_id == tool_call_id:
logger.info("✅ Test 2 PASSED: Event pub/sub works")
else:
logger.error("❌ Test 2 FAILED: Event pub/sub failed")
return False
# Test 3: Cleanup
logger.info("Test 3: Testing cleanup...")
await transfer_coordinator.remove_transfer_context(tool_call_id)
cleanup_context = await transfer_coordinator.get_transfer_context(tool_call_id)
if cleanup_context is None:
logger.info("✅ Test 3 PASSED: Cleanup works")
else:
logger.error("❌ Test 3 FAILED: Cleanup failed")
return False
logger.info("✅ All tests PASSED! Redis coordination is working correctly.")
return True
async def test_timeout_handling():
"""Test timeout handling in transfer coordination."""
logger.info("Testing timeout handling...")
transfer_coordinator = await get_transfer_coordinator()
tool_call_id = str(uuid.uuid4())
# Wait for completion with short timeout (should timeout)
start_time = time.time()
result = await transfer_coordinator.wait_for_transfer_completion(tool_call_id, 2.0)
elapsed = time.time() - start_time
if result is None and elapsed >= 2.0:
logger.info("✅ Timeout test PASSED: Properly timed out after 2 seconds")
return True
else:
logger.error(f"❌ Timeout test FAILED: Expected timeout, got {result} in {elapsed}s")
return False
if __name__ == "__main__":
async def main():
success1 = await test_redis_coordination()
success2 = await test_timeout_handling()
if success1 and success2:
logger.info("🎉 All transfer coordination tests PASSED!")
else:
logger.error("💥 Some tests FAILED!")
asyncio.run(main())

View file

@ -112,6 +112,9 @@ class PipecatEngine:
self._embeddings_api_key: Optional[str] = embeddings_api_key
self._embeddings_model: Optional[str] = embeddings_model
self._embeddings_base_url: Optional[str] = embeddings_base_url
# Transfer state tracking - prevents auto hang-up during call transfers
self._transfer_in_progress: bool = False
async def _get_organization_id(self) -> Optional[int]:
"""Get and cache the organization ID from workflow run."""
@ -207,14 +210,14 @@ class PipecatEngine:
)
logger.info(f"Arguments: {function_call_params.arguments}")
# Perform variable extraction before transitioning to new node
await self._perform_variable_extraction_if_needed(self._current_node)
# Set context for the new node, so that when the function call result
# frame is received by LLMContextAggregator and an LLM generation
# is done, we have updated context and functions
await self.set_node(transition_to_node)
try:
# Perform variable extraction before transitioning to new node
await self._perform_variable_extraction_if_needed(self._current_node)
# Set context for the new node, so that when the function call result
# frame is received by LLMContextAggregator and an LLM generation
# is done, we have updated context and functions
await self.set_node(transition_to_node)
async def on_context_updated() -> None:
"""
@ -245,6 +248,7 @@ class PipecatEngine:
await function_call_params.result_callback(
result, properties=properties
)
except Exception as e:
logger.error(f"Error in transition function {name}: {str(e)}")
error_result = {"status": "error", "error": str(e)}
@ -277,6 +281,7 @@ class PipecatEngine:
async def calculate_func(function_call_params: FunctionCallParams) -> None:
logger.info(f"LLM Function Call EXECUTED: safe_calculator")
logger.info(f"Arguments: {function_call_params.arguments}")
try:
expr = function_call_params.arguments.get("expression", "")
result = safe_calculator(expr)
@ -292,6 +297,7 @@ class PipecatEngine:
) -> None:
logger.info(f"LLM Function Call EXECUTED: get_current_time")
logger.info(f"Arguments: {function_call_params.arguments}")
try:
timezone = function_call_params.arguments.get("timezone", "UTC")
result = get_current_time(timezone)
@ -302,6 +308,7 @@ class PipecatEngine:
async def convert_time_func(function_call_params: FunctionCallParams) -> None:
logger.info(f"LLM Function Call EXECUTED: convert_time")
logger.info(f"Arguments: {function_call_params.arguments}")
try:
result = convert_time(
function_call_params.arguments.get("source_timezone"),
@ -332,6 +339,7 @@ class PipecatEngine:
async def retrieve_kb_func(function_call_params: FunctionCallParams) -> None:
logger.info("LLM Function Call EXECUTED: retrieve_from_knowledge_base")
logger.info(f"Arguments: {function_call_params.arguments}")
try:
query = function_call_params.arguments.get("query", "")
organization_id = await self._get_organization_id()
@ -532,7 +540,7 @@ class PipecatEngine:
self._current_node, run_in_background=False
)
frame_to_push = CancelFrame() if abort_immediately else EndFrame()
frame_to_push = CancelFrame(reason=reason) if abort_immediately else EndFrame(reason=reason)
# Apply disposition mapping - first try call_disposition if it is,
# extracted from the call conversation then fall back to reason
@ -705,6 +713,18 @@ class PipecatEngine:
f"Stasis connection set for immediate transfers: {connection.channel_id}"
)
def set_mute_pipeline(self, mute: bool) -> None:
"""Set the pipeline mute state.
This controls whether user input should be muted via the CallbackUserMuteStrategy.
When muted, the user's audio input will be blocked.
Args:
mute: True to mute user input, False to allow input
"""
logger.debug(f"Setting pipeline mute state to: {mute}")
self._mute_pipeline = mute
async def handle_llm_text_frame(self, text: str):
"""Accumulate LLM text frames to build reference text."""
self._current_llm_generation_reference_text += text

View file

@ -6,8 +6,11 @@ during workflow execution.
from __future__ import annotations
import asyncio
import re
from typing import TYPE_CHECKING, Any, Optional
import aiohttp
from loguru import logger
from api.db import db_client
@ -21,9 +24,24 @@ from api.services.workflow.tools.custom_tool import (
tool_to_function_schema,
)
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.frames.frames import FunctionCallResultProperties, TTSSpeakFrame
from pipecat.frames.frames import (
FunctionCallResultProperties,
TTSSpeakFrame,
OutputAudioRawFrame,
)
from pipecat.services.llm_service import FunctionCallParams
from pipecat.utils.enums import EndTaskReason
from pipecat.transports.websocket.fastapi import FastAPIWebsocketClient
from api.utils.hold_audio import load_hold_audio
from api.services.telephony.transfer_coordination import get_transfer_coordinator
from api.services.telephony.transfer_event_protocol import (
TransferEvent,
TransferContext,
TransferEventType,
)
from dograh.api.utils.common import get_backend_endpoints
if TYPE_CHECKING:
from api.services.workflow.pipecat_engine import PipecatEngine
@ -138,6 +156,8 @@ class CustomToolManager:
"""
if tool.category == ToolCategory.END_CALL.value:
return self._create_end_call_handler(tool, function_name)
elif tool.category == ToolCategory.TRANSFER_CALL.value:
return self._create_transfer_call_handler(tool, function_name)
return self._create_http_tool_handler(tool, function_name)
@ -230,3 +250,505 @@ class CustomToolManager:
)
return end_call_handler
def _create_transfer_call_handler(self, tool: Any, function_name: str):
"""Create a handler function for a transfer call tool.
Args:
tool: The ToolModel instance
function_name: The function name used by the LLM
Returns:
Async handler function for the transfer call tool
"""
# Don't run LLM after starting transfer - we're handling async response
properties = FunctionCallResultProperties(run_llm=False)
async def transfer_call_handler(
function_call_params: FunctionCallParams,
) -> None:
logger.info(f"Transfer Call Tool EXECUTED: {function_name}")
logger.info(f"Arguments: {function_call_params.arguments}")
try:
# Get the transfer call configuration
config = tool.definition.get("config", {})
destination = config.get("destination", "")
message_type = config.get("messageType", "none")
custom_message = config.get("customMessage", "")
timeout_seconds = config.get(
"timeout", 30
) # Default 30 seconds if not configured
# Validate destination phone number
if not destination or not destination.strip():
validation_error_result = {
"status": "failed",
"message": "I'm sorry, but I don't have a phone number configured for the transfer. Please contact support to set up call transfer.",
"action": "transfer_failed",
"reason": "no_destination",
"end_call": True,
}
await self._handle_transfer_result(
validation_error_result, function_call_params, properties
)
return
# Validate E.164 format
e164_pattern = r"^\+[1-9]\d{1,14}$"
if not re.match(e164_pattern, destination):
validation_error_result = {
"status": "failed",
"message": "I'm sorry, but the transfer phone number appears to be invalid. Please contact support to verify the transfer settings.",
"action": "transfer_failed",
"reason": "invalid_destination",
"end_call": True,
}
await self._handle_transfer_result(
validation_error_result, function_call_params, properties
)
return
# Provider validation handled by telephony endpoint
# Note: User muting and hold music are handled automatically by
# Play pre-transfer message if configured
if message_type == "custom" and custom_message:
logger.info(f"Playing pre-transfer message: {custom_message}")
await self._engine.task.queue_frame(TTSSpeakFrame(custom_message))
# Get original call information from Pipecat context
from pipecat.utils.context import get_current_call_sid
original_call_sid = get_current_call_sid()
caller_number = None # Skip caller number for now as requested
logger.info(f"Found original call context: call_id={original_call_sid}")
# Get organization ID for provider configuration
organization_id = await self.get_organization_id()
if not organization_id:
validation_error_result = {
"status": "failed",
"message": "I'm sorry, but I can't determine which organization this call belongs to. Please contact support.",
"action": "transfer_failed",
"reason": "no_organization_id",
"end_call": False,
}
await self._handle_transfer_result(
validation_error_result, function_call_params, properties
)
return
# Prepare transfer request data
transfer_data = {
"destination": destination,
"organization_id": organization_id, # Required for provider configuration
"tool_call_id": function_call_params.tool_call_id, # Use LLM's tool call ID for pipeline coordination
"tool_uuid": tool.tool_uuid, # Add tool UUID for tracing and validation
"original_call_sid": original_call_sid, # Original caller's call SID
"caller_number": caller_number, # Original caller's phone number
}
# Initialize Redis-based transfer coordination
import httpx
import time
# Get backend endpoint URL
backend_url, _ = await get_backend_endpoints()
# Get transfer coordinator for Redis-based coordination
transfer_coordinator = await get_transfer_coordinator()
# Now initiate the transfer call
transfer_url = f"{backend_url}/api/v1/telephony/call-transfer"
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
transfer_url,
json=transfer_data,
headers={"Content-Type": "application/json"},
# Authentication headers added by provider if needed
)
if response.status_code == 200:
result_data = response.json()
logger.info(f"Transfer initiated successfully: {result_data}")
# Wait for webhook completion using standard Pipecat async pattern
logger.info(
f"Transfer call initiated for {destination}, waiting for webhook completion..."
)
# Start hold music during transfer waiting period
hold_music_stop_event = asyncio.Event()
hold_music_task = None
try:
# Mute the pipeline to prevent further LLM generations during transfer
logger.info("Muting pipeline during transfer call")
self._engine.set_mute_pipeline(True)
# Determine sample rate from transport (default to 8000Hz for Twilio)
sample_rate = 8000
if hasattr(self._engine.transport, "output") and hasattr(
self._engine.transport.output(), "sample_rate"
):
sample_rate = getattr(
self._engine.transport.output(), "sample_rate", 8000
)
logger.info(
f"Starting hold music at {sample_rate}Hz while waiting for transfer"
)
# Start hold music as background task
hold_music_task = asyncio.create_task(
self.play_hold_music_loop(
hold_music_stop_event, sample_rate
)
)
# Wait for transfer completion using Redis pub/sub
logger.info(
"Waiting for transfer completion via Redis pub/sub..."
)
transfer_event = (
await transfer_coordinator.wait_for_transfer_completion(
transfer_data["tool_call_id"], timeout_seconds
)
)
# Stop hold music and unmute pipeline
logger.info(
"Transfer completed, stopping hold music and unmuting pipeline"
)
hold_music_stop_event.set()
if hold_music_task:
await hold_music_task
self._engine.set_mute_pipeline(False)
if transfer_event:
# Get result from transfer event
final_result = transfer_event.to_result_dict()
# Get transfer context for caller number
transfer_context = (
await transfer_coordinator.get_transfer_context(
transfer_data["tool_call_id"]
)
)
if transfer_context and transfer_context.caller_number:
final_result["caller_number"] = (
transfer_context.caller_number
)
# Handle the transfer result and inform user appropriately
await self._handle_transfer_result(
final_result, function_call_params, properties
)
else:
# Handle timeout case
logger.error(
f"Transfer call timed out after {timeout_seconds} seconds"
)
# Create timeout result and handle it through the same flow
timeout_result = {
"status": "failed",
"message": "I'm sorry, but the call is taking longer than expected to connect. The person might not be available right now. Please try calling back later.",
"action": "transfer_failed",
"reason": "timeout",
"end_call": True,
}
await self._handle_transfer_result(
timeout_result, function_call_params, properties
)
except Exception as e:
logger.error(f"Error during transfer wait: {e}")
# Stop hold music and unmute pipeline on error
logger.info(
"Transfer error, stopping hold music and unmuting pipeline"
)
hold_music_stop_event.set()
if hold_music_task:
await hold_music_task
self._engine.set_mute_pipeline(False)
# Handle error case
error_result = {
"status": "failed",
"message": "I'm sorry, but there was an issue processing the transfer. Please try again.",
"action": "transfer_failed",
"reason": "system_error",
"end_call": True,
}
await self._handle_transfer_result(
error_result, function_call_params, properties
)
else:
error_data = (
response.json()
if response.content
else {"error": "Unknown error"}
)
logger.error(
f"Transfer initiation failed: {response.status_code} - {error_data}"
)
# No cleanup needed for Redis-based coordination
# Handle initiation failure with user-friendly message
initiation_failure_result = {
"status": "failed",
"message": "I'm sorry, but I'm having trouble setting up the call transfer right now. There might be a technical issue. Please try again later or contact support.",
"action": "transfer_failed",
"reason": "initiation_failed",
"end_call": True,
}
await self._handle_transfer_result(
initiation_failure_result, function_call_params, properties
)
except httpx.TimeoutException:
logger.error(f"Transfer call '{function_name}' HTTP request timed out")
# No cleanup needed for Redis-based coordination
# Handle HTTP timeout with user-friendly message
http_timeout_result = {
"status": "failed",
"message": "I'm sorry, but there seems to be a network issue preventing me from setting up the call transfer. Please try again in a moment.",
"action": "transfer_failed",
"reason": "network_timeout",
"end_call": True,
}
await self._handle_transfer_result(
http_timeout_result, function_call_params, properties
)
except Exception as e:
logger.error(
f"Transfer call tool '{function_name}' execution failed: {e}"
)
# No cleanup needed for Redis-based coordination
# Handle generic exception with user-friendly message
exception_result = {
"status": "failed",
"message": "I'm sorry, but something went wrong while trying to transfer your call. Please try again later or contact support if the problem persists.",
"action": "transfer_failed",
"reason": "execution_error",
"end_call": True,
}
await self._handle_transfer_result(
exception_result, function_call_params, properties
)
return transfer_call_handler
async def _handle_transfer_result(
self, result: dict, function_call_params, properties
):
"""Handle different transfer call outcomes and take appropriate action."""
action = result.get("action", "")
status = result.get("status", "")
message = result.get("message", "")
should_end_call = result.get("end_call", False)
logger.info(f"Handling transfer result: action={action}, status={status}")
if action == "transfer_success":
# Successful transfer - add original caller to conference and end pipeline
conference_id = result.get("conference_id")
original_call_sid = result.get("original_call_sid")
transfer_call_sid = result.get("transfer_call_sid")
logger.info(
f"Transfer successful! Conference: {conference_id}, Original: {original_call_sid}, Transfer: {transfer_call_sid}"
)
# First inform LLM of success (but don't continue call)
response_properties = FunctionCallResultProperties(
run_llm=False
) # We'll handle the transfer ourselves
await function_call_params.result_callback(
{
"status": "transfer_success",
"message": "Transfer successful - connecting to conference",
"conference_id": conference_id,
},
properties=response_properties,
)
await self._engine.end_call_with_reason(
EndTaskReason.TRANSFER_CALL.value, abort_immediately=False
)
elif action == "transfer_failed":
# Transfer failed - inform user via LLM and then end the call
reason = result.get("reason", "unknown")
logger.info(f"Transfer failed ({reason}), informing user and ending call")
# Use system message pattern to direct LLM response for transfer failure
# This is more reliable than function call results
from pipecat.frames.frames import LLMMessagesAppendFrame
# Create system message with clear instructions for transfer failure
failure_instruction = {
"role": "system",
"content": f"IMPORTANT: The transfer call has FAILED. Reason: {reason}. You must inform the customer about this failure using this message: '{message}' Then immediately say goodbye and end the conversation. Do NOT ask if they need anything else or continue the conversation. Do NOT continue with transfer language.",
}
# Push the system message to LLM context
await self._engine.task.queue_frame(
LLMMessagesAppendFrame([failure_instruction], run_llm=True)
)
# Also send the function call result for consistency
response_properties = FunctionCallResultProperties(
run_llm=False
) # LLM will be triggered by system message
await function_call_params.result_callback(
{"status": "transfer_failed", "reason": reason, "message": message},
properties=response_properties,
)
# Set appropriate disposition for analytics
disposition_map = {
"no_answer": "transfer_no_answer",
"busy": "transfer_busy",
"call_failed": "transfer_failed",
"timeout": "transfer_timeout",
"no_destination": "transfer_config_error",
"invalid_destination": "transfer_config_error",
"initiation_failed": "transfer_system_error",
"network_timeout": "transfer_system_error",
"execution_error": "transfer_system_error",
}
disposition = disposition_map.get(reason, "transfer_failed")
logger.info(
f"Setting disposition: {disposition} for transfer failure reason: {reason}"
)
# Give the LLM time to speak the message, then end the call with disposition
# We'll schedule the end call after a brief delay to allow TTS
logger.info("Scheduling call end after LLM delivers failure message")
# Import here to avoid circular dependencies
import asyncio
# Schedule call end after 3 seconds to allow LLM to speak
async def delayed_end_call():
import asyncio
await asyncio.sleep(3)
await self._engine.end_call_with_reason(
f"transfer_failed_{reason}", # Include specific reason in end reason
abort_immediately=False, # Allow any queued speech to complete
)
# Create task to end call asynchronously
asyncio.create_task(delayed_end_call())
elif action == "transfer_completed":
# This should no longer happen since we ignore "completed" status in webhook
# to avoid overriding successful transfers
logger.warning(
"Received unexpected 'transfer_completed' action - this should be ignored by webhook now"
)
logger.warning(
"If you see this message, there might be an issue with the webhook status filtering"
)
# For safety, treat it as a generic result without ending the call
await function_call_params.result_callback(result, properties=properties)
else:
# Unknown action, treat as generic success
logger.warning(f"Unknown transfer action: {action}, treating as success")
await function_call_params.result_callback(result, properties=properties)
async def play_hold_music_loop(
self, stop_event: asyncio.Event, sample_rate: int = 8000
):
"""Play hold music in a loop until stop event is triggered.
Args:
stop_event: Event to stop the hold music loop
sample_rate: Sample rate for the hold music (default 8000Hz for Twilio)
"""
try:
import os
# Path to hold music file based on sample rate
assets_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "assets"
)
# Select appropriate hold music file
if sample_rate == 16000:
hold_music_file = os.path.join(
assets_dir, "transfer_hold_ring_16000.wav"
)
else: # Default to 8000Hz for Twilio
hold_music_file = os.path.join(
assets_dir, "transfer_hold_ring_8000.wav"
)
logger.info(f"Starting hold music loop with file: {hold_music_file}")
# Load hold music audio data
hold_audio_data = load_hold_audio(hold_music_file, sample_rate)
if not hold_audio_data:
logger.error("Failed to load hold music data")
return
# Convert bytes to audio frames - each frame should be about 20ms worth of audio
# For 8000Hz: 20ms = 160 samples = 320 bytes (16-bit)
# For 16000Hz: 20ms = 320 samples = 640 bytes (16-bit)
frame_size = 320 if sample_rate == 8000 else 640
audio_data = hold_audio_data
total_length = len(audio_data)
position = 0
logger.info(
f"Hold music loaded: {total_length} bytes, frame size: {frame_size}"
)
while not stop_event.is_set():
# Extract audio chunk
if position + frame_size > total_length:
# Reached end of audio, loop back to beginning
position = 0
audio_chunk = audio_data[position : position + frame_size]
position += frame_size
# Create audio frame
audio_frame = OutputAudioRawFrame(
audio=audio_chunk,
sample_rate=sample_rate,
num_channels=1,
)
# Queue the frame
await self._engine.task.queue_frame(audio_frame)
# Sleep for frame duration (20ms)
await asyncio.sleep(0.02)
logger.info("Hold music loop stopped")
except Exception as e:
logger.error(f"Error in hold music loop: {e}")

View file

@ -51,6 +51,7 @@ from api.tasks.s3_upload import (
process_workflow_completion,
upload_voicemail_audio_to_s3,
)
from api.tasks.transfer_handler import handle_transfer_redirect
class WorkerSettings:
@ -62,6 +63,7 @@ class WorkerSettings:
sync_campaign_source,
process_campaign_batch,
process_knowledge_base_document,
handle_transfer_redirect,
]
cron_jobs = []
redis_settings = REDIS_SETTINGS

View file

@ -6,3 +6,4 @@ class FunctionNames:
SYNC_CAMPAIGN_SOURCE = "sync_campaign_source"
PROCESS_CAMPAIGN_BATCH = "process_campaign_batch"
PROCESS_KNOWLEDGE_BASE_DOCUMENT = "process_knowledge_base_document"
HANDLE_TRANSFER_REDIRECT = "handle_transfer_redirect"

View file

@ -0,0 +1,137 @@
"""
ARQ task to handle call transfer redirect independently from pipeline.
This task runs in a separate worker process, ensuring the transfer logic
is completely decoupled from the real-time audio pipeline.
"""
import asyncio
import aiohttp
from loguru import logger
from api.utils.common import get_backend_endpoints
async def handle_transfer_redirect(
ctx,
original_call_sid: str,
conference_id: str,
transfer_call_sid: str
):
"""
Handle call transfer redirect in ARQ worker, independent of pipeline.
Following the test bench approach:
1. Wait for WebSocket closure to complete
2. Verify conference state (destination still connected)
3. Redirect original caller to conference using TwiML endpoint
4. Handle any failures gracefully
Args:
original_call_sid: The original caller's Twilio call SID
conference_id: The conference name to join caller to
transfer_call_sid: The destination call SID (for verification)
"""
logger.info("=" * 60)
logger.info("🚀 ARQ TRANSFER REDIRECT STARTED")
logger.info(f" Original Caller SID: {original_call_sid}")
logger.info(f" Conference ID: {conference_id}")
logger.info(f" Destination Call SID: {transfer_call_sid}")
logger.info("=" * 60)
try:
# Step 1: Wait for WebSocket closure to complete (test bench approach)
logger.info("⏱️ Step 1: Waiting for WebSocket closure to complete...")
await asyncio.sleep(2.0) # Test bench uses 1.5s, we use 2s for safety
logger.info(" WebSocket closure wait completed")
# Step 2: Verify destination is still in conference (test bench approach)
logger.info("🔍 Step 2: Verifying destination is still in conference...")
try:
# TODO: Add actual Twilio conference verification here
# For now, assume destination is still connected
logger.info(" Destination verification completed (assuming connected)")
except Exception as e:
logger.warning(f" Could not verify destination: {e}")
# Step 3: Redirect caller to conference (test bench approach)
logger.info("📞 Step 3: Redirecting caller to conference...")
success = await _redirect_caller_to_conference(original_call_sid, conference_id)
if success:
logger.info("✅ TRANSFER REDIRECT SUCCESSFUL!")
logger.info(" Caller should now be in conference with destination")
else:
logger.error("❌ TRANSFER REDIRECT FAILED!")
except Exception as e:
logger.exception(f"❌ Transfer redirect error: {e}")
logger.info("=" * 60)
logger.info("🏁 ARQ TRANSFER REDIRECT COMPLETED")
logger.info("=" * 60)
async def _redirect_caller_to_conference(call_sid: str, conference_name: str) -> bool:
"""
Redirect caller to conference using Twilio API.
Exactly following the test bench approach.
Args:
call_sid: Twilio call SID to redirect
conference_name: Name of the conference to join
Returns:
bool: True if redirect was successful, False otherwise
"""
logger.info(f"[TRANSFER-DEBUG] _redirect_caller_to_conference called with: {call_sid} and {conference_name}")
# TODO: Use provider service in production instead of hardcoded credentials
account_sid = ""
auth_token = ""
try:
# Get public backend endpoint for TwiML URL
backend_endpoint, _ = await get_backend_endpoints()
# Construct TwiML endpoint URL
transfer_url = f"{backend_endpoint}/api/v1/telephony/transfer-twiml/{conference_name}"
logger.info(f"[TRANSFER-DEBUG] Transfer URL: {transfer_url}")
# Twilio API endpoint for updating calls
api_endpoint = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Calls/{call_sid}.json"
# Redirect data - exactly like test bench
redirect_data = {
"url": transfer_url,
"method": "POST"
}
logger.info(f"[TRANSFER-DEBUG] Redirecting caller {call_sid} to conference {conference_name}")
logger.info(f"[TRANSFER-DEBUG] API endpoint: {api_endpoint}")
logger.info(f"[TRANSFER-DEBUG] Redirect data: {redirect_data}")
# Make the redirect API call
async with aiohttp.ClientSession() as session:
logger.info(f"[TRANSFER-DEBUG] Created aiohttp session")
auth = aiohttp.BasicAuth(account_sid, auth_token)
logger.info(f"[TRANSFER-DEBUG] Making POST request to Twilio API for redirect")
async with session.post(api_endpoint, data=redirect_data, auth=auth) as response:
logger.info(f"[TRANSFER-DEBUG] Received response from Twilio API")
if response.status == 200:
logger.info(f"[TRANSFER-DEBUG] API response status: 200")
logger.info(f"[TRANSFER-DEBUG] Successfully redirected caller to conference {conference_name}")
return True
else:
error_text = await response.text()
logger.error(f"[TRANSFER-DEBUG] Redirect failed - Status: {response.status}, Response: {error_text}")
return False
except Exception as e:
logger.exception(f"[TRANSFER-DEBUG] Exception during redirect: {e}")
return False

89
api/utils/hold_audio.py Normal file
View file

@ -0,0 +1,89 @@
"""
Hold audio utility for loading and caching hold music files.
This module provides functionality to load hold music audio files at specific sample rates
with caching to improve performance during multiple calls.
"""
import io
import wave
from typing import Optional, Dict, Tuple
import numpy as np
from loguru import logger
try:
import soundfile as sf
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error("In order to use hold audio, you need to `pip install soundfile`.")
raise Exception(f"Missing module: {e}")
# Global cache for loaded hold music data
_hold_audio_cache: Dict[Tuple[str, int], np.ndarray] = {}
def load_hold_audio(file_path: str, sample_rate: int) -> Optional[bytes]:
"""Load hold music audio file at the specified sample rate with caching.
Args:
file_path: Path to the hold music audio file
sample_rate: Target sample rate (8000 or 16000 Hz supported)
Returns:
Audio data as bytes (PCM16) or None if loading failed
"""
cache_key = (file_path, sample_rate)
# Check cache first
if cache_key in _hold_audio_cache:
logger.debug(f"Using cached hold audio for {file_path} at {sample_rate}Hz")
audio_data = _hold_audio_cache[cache_key]
return audio_data.tobytes()
try:
logger.info(f"Loading hold audio from {file_path} at {sample_rate}Hz")
# Load audio file
sound, file_sample_rate = sf.read(file_path, dtype="int16")
logger.info(f"Audio file loaded - file sample_rate: {file_sample_rate}, target: {sample_rate}")
# Ensure mono audio (take first channel if stereo)
if len(sound.shape) > 1:
sound = sound[:, 0]
# Resample if needed
if file_sample_rate != sample_rate:
logger.warning(f"Hold music file has sample rate {file_sample_rate}, expected {sample_rate}")
# For now, we'll use the audio as-is and let the transport handle resampling
# In a production system, you might want to use librosa or scipy for proper resampling
# Convert to int16 and cache
audio_data = sound.astype(np.int16)
_hold_audio_cache[cache_key] = audio_data
logger.info(f"Hold audio loaded successfully: {len(audio_data)} samples at {sample_rate}Hz")
return audio_data.tobytes()
except Exception as e:
logger.error(f"Failed to load hold audio file {file_path}: {e}")
return None
def clear_hold_audio_cache():
"""Clear the hold audio cache to free memory."""
global _hold_audio_cache
_hold_audio_cache.clear()
logger.info("Hold audio cache cleared")
def get_cache_info() -> Dict[str, int]:
"""Get information about the current cache state.
Returns:
Dictionary with cache statistics
"""
return {
"cached_files": len(_hold_audio_cache),
"total_cache_size": sum(len(data) for data in _hold_audio_cache.values())
}

View file

@ -74,87 +74,87 @@ services:
networks:
- app-network
api:
image: ${REGISTRY:-dograhai}/dograh-api:latest
volumes:
- shared-tmp:/tmp
environment:
# Core application config
ENVIRONMENT: "local"
LOG_LEVEL: "INFO"
# api:
# image: ${REGISTRY:-dograhai}/dograh-api:latest
# volumes:
# - shared-tmp:/tmp
# environment:
# # Core application config
# ENVIRONMENT: "local"
# LOG_LEVEL: "INFO"
# Replace this environment variable if you are using a custom
# domain to host the stack
BACKEND_API_ENDPOINT: "${BACKEND_API_ENDPOINT:-http://localhost:8000}"
# Database configuration (using containerized postgres)
DATABASE_URL: "postgresql+asyncpg://postgres:postgres@postgres:5432/postgres"
# # Database configuration (using containerized postgres)
# DATABASE_URL: "postgresql+asyncpg://postgres:postgres@postgres:5432/postgres"
# Redis configuration (using containerized redis)
REDIS_URL: "redis://:redissecret@redis:6379"
# # Redis configuration (using containerized redis)
# REDIS_URL: "redis://:redissecret@redis:6379"
# Storage configuration - using local MinIO
ENABLE_AWS_S3: "false"
# # Storage configuration - using local MinIO
# ENABLE_AWS_S3: "false"
# MinIO
MINIO_ENDPOINT: "minio:9000"
MINIO_ACCESS_KEY: "minioadmin"
MINIO_SECRET_KEY: "minioadmin"
MINIO_BUCKET: "voice-audio"
MINIO_SECURE: "false"
# # MinIO
# MINIO_ENDPOINT: "minio:9000"
# MINIO_ACCESS_KEY: "minioadmin"
# MINIO_SECRET_KEY: "minioadmin"
# MINIO_BUCKET: "voice-audio"
# MINIO_SECURE: "false"
# Langfuse
ENABLE_TRACING: "false"
# LANGFUSE_SECRET_KEY: ""
# LANGFUSE_PUBLIC_KEY: ""
# LANGFUSE_HOST: "https://langfuse.dograh.com"
# # Langfuse
# ENABLE_TRACING: "false"
# # LANGFUSE_SECRET_KEY: ""
# # LANGFUSE_PUBLIC_KEY: ""
# # LANGFUSE_HOST: "https://langfuse.dograh.com"
# Sentry
ENABLE_TELEMETRY: "${ENABLE_TELEMETRY:-true}"
SENTRY_DSN: "https://3acdb63d5f1f70430953353b82de61e0@o4509486225096704.ingest.us.sentry.io/4510152922693632"
# # Sentry
# ENABLE_TELEMETRY: "${ENABLE_TELEMETRY:-true}"
# SENTRY_DSN: "https://3acdb63d5f1f70430953353b82de61e0@o4509486225096704.ingest.us.sentry.io/4510152922693632"
# TURN server configuration (for WebRTC NAT traversal in remote server)
# Uses time-limited credentials via TURN REST API (HMAC-SHA1)
TURN_HOST: "${TURN_HOST:-}"
TURN_SECRET: "${TURN_SECRET:-}"
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
cloudflared:
condition: service_started
healthcheck:
test:
[
"CMD-SHELL",
'python -c "import urllib.request; urllib.request.urlopen(''http://localhost:8000/api/v1/health'').read()"',
]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- app-network
# ports:
# - "8000:8000"
# depends_on:
# postgres:
# condition: service_healthy
# redis:
# condition: service_healthy
# minio:
# condition: service_healthy
# cloudflared:
# condition: service_started
# healthcheck:
# test:
# [
# "CMD-SHELL",
# 'python -c "import urllib.request; urllib.request.urlopen(''http://localhost:8000/api/v1/health'').read()"',
# ]
# interval: 30s
# timeout: 10s
# retries: 3
# start_period: 60s
# networks:
# - app-network
ui:
image: ${REGISTRY:-dograhai}/dograh-ui:latest
environment:
# Server-side URL (SSR, internal Docker network)
BACKEND_URL: "http://api:8000"
NODE_ENV: "oss"
# ui:
# image: ${REGISTRY:-dograhai}/dograh-ui:latest
# environment:
# # Server-side URL (SSR, internal Docker network)
# BACKEND_URL: "http://api:8000"
# NODE_ENV: "oss"
# Flag to enable/ disable posthog and sentry
ENABLE_TELEMETRY: "${ENABLE_TELEMETRY:-true}"
# # Flag to enable/ disable posthog and sentry
# ENABLE_TELEMETRY: "${ENABLE_TELEMETRY:-true}"
# Posthog
POSTHOG_KEY: "phc_ItizB1dP6yv7ZYobbcqrpxTdbomDA8hJFSEmAMdYvIr"
POSTHOG_HOST: "https://us.posthog.com"
# # Posthog
# POSTHOG_KEY: "phc_ItizB1dP6yv7ZYobbcqrpxTdbomDA8hJFSEmAMdYvIr"
# POSTHOG_HOST: "https://us.posthog.com"
# Sentry
SENTRY_DSN: "https://d9387fed5f80e90781f1dbd9b2c0994c@o4509486225096704.ingest.us.sentry.io/4510124708200448"
@ -209,8 +209,8 @@ volumes:
redis_data:
minio-data:
driver: local
shared-tmp:
driver: local
# shared-tmp:
# driver: local
networks:
app-network:

179
ui/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "ui",
"version": "1.10.0",
"version": "1.11.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ui",
"version": "1.10.0",
"version": "1.11.2",
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@hey-api/client-fetch": "^0.10.0",
@ -777,7 +777,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz",
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
@ -970,6 +969,7 @@
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
"license": "MIT",
"peer": true,
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@ -1093,6 +1093,7 @@
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
"integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-module-imports": "^7.16.7",
"@babel/runtime": "^7.18.3",
@ -1111,13 +1112,15 @@
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@emotion/babel-plugin/node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -1127,6 +1130,7 @@
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
"integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@emotion/memoize": "^0.9.0",
"@emotion/sheet": "^1.4.0",
@ -1139,19 +1143,22 @@
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@emotion/memoize": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@emotion/react": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -1176,6 +1183,7 @@
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
"integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@emotion/hash": "^0.9.2",
"@emotion/memoize": "^0.9.0",
@ -1188,19 +1196,22 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
"integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@emotion/unitless": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
"integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@emotion/use-insertion-effect-with-fallbacks": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
"integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": ">=16.8.0"
}
@ -1209,13 +1220,15 @@
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@emotion/weak-memoize": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.2",
@ -1833,7 +1846,6 @@
"resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.66.2.tgz",
"integrity": "sha512-77nofk/zacBNDwVb86kjS2sMIrwbwoBgUNw10crhPPrhV7HUs6A4SzZxePLEGRyHbM54v0g+XL6P8DSr98BM+A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hey-api/json-schema-ref-parser": "1.0.4",
"c12": "2.0.1",
@ -2492,6 +2504,7 @@
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
"integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
@ -2749,7 +2762,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@ -2759,6 +2771,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.200.0.tgz",
"integrity": "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/api": "^1.3.0"
},
@ -4140,6 +4153,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
@ -4174,7 +4188,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.34.0.tgz",
"integrity": "sha512-aKcOkyrorBGlajjRdVoJWHTxfxO1vCNHLJVlSDaRHDIdjU+pX8IYQPvPDkYiujKLbRnWU+1TBwEt0QRgSm4SGA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=14"
}
@ -8924,7 +8937,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -11069,6 +11081,7 @@
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
@ -11079,6 +11092,7 @@
"resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
"integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint": "*",
"@types/estree": "*"
@ -11131,7 +11145,8 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/pg": {
"version": "8.6.1",
@ -11158,7 +11173,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz",
"integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -11169,7 +11183,6 @@
"integrity": "sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@ -11179,6 +11192,7 @@
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "*"
}
@ -11701,6 +11715,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
"integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/helper-numbers": "1.13.2",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2"
@ -11710,25 +11725,29 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
"integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@webassemblyjs/helper-api-error": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
"integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@webassemblyjs/helper-buffer": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
"integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@webassemblyjs/helper-numbers": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz",
"integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/floating-point-hex-parser": "1.13.2",
"@webassemblyjs/helper-api-error": "1.13.2",
@ -11739,13 +11758,15 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
"integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@webassemblyjs/helper-wasm-section": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz",
"integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
@ -11758,6 +11779,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz",
"integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@xtuc/ieee754": "^1.2.0"
}
@ -11767,6 +11789,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz",
"integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@xtuc/long": "4.2.2"
}
@ -11775,13 +11798,15 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
"integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@webassemblyjs/wasm-edit": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz",
"integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
@ -11798,6 +11823,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz",
"integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
@ -11811,6 +11837,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz",
"integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
@ -11823,6 +11850,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz",
"integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-api-error": "1.13.2",
@ -11837,6 +11865,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz",
"integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@xtuc/long": "4.2.2"
@ -11846,13 +11875,15 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
"integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/@xtuc/long": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"license": "Apache-2.0"
"license": "Apache-2.0",
"peer": true
},
"node_modules/@xyflow/react": {
"version": "12.9.2",
@ -11919,7 +11950,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -11980,6 +12010,7 @@
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ajv": "^8.0.0"
},
@ -11997,6 +12028,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -12012,7 +12044,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/ansi-regex": {
"version": "5.0.1",
@ -12330,6 +12363,7 @@
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
"integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"cosmiconfig": "^7.0.0",
@ -12436,7 +12470,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
@ -12617,6 +12650,7 @@
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
"integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.0"
}
@ -12837,6 +12871,7 @@
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
"integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/parse-json": "^4.0.0",
"import-fresh": "^3.2.1",
@ -12988,7 +13023,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -13295,6 +13329,7 @@
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
@ -13386,6 +13421,7 @@
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
"license": "MIT",
"peer": true,
"dependencies": {
"is-arrayish": "^0.2.1"
}
@ -13394,7 +13430,8 @@
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/es-abstract": {
"version": "1.23.9",
@ -13512,7 +13549,8 @@
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
@ -13661,7 +13699,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -13835,7 +13872,6 @@
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.8",
@ -14123,6 +14159,7 @@
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.8.x"
}
@ -14221,7 +14258,8 @@
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/fast-xml-parser": {
"version": "5.2.5",
@ -14286,7 +14324,8 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/find-up": {
"version": "5.0.0",
@ -14650,7 +14689,8 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"license": "BSD-2-Clause"
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.2",
@ -14915,7 +14955,6 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@ -15482,6 +15521,7 @@
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
"integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"merge-stream": "^2.0.0",
@ -15496,6 +15536,7 @@
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"has-flag": "^4.0.0"
},
@ -15583,7 +15624,8 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/json-schema": {
"version": "0.4.0",
@ -15921,13 +15963,15 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.11.5"
}
@ -16003,13 +16047,15 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/merge2": {
"version": "1.4.1",
@ -16202,7 +16248,6 @@
"resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz",
"integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "15.5.9",
"@swc/helpers": "0.5.15",
@ -16614,6 +16659,7 @@
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
@ -16672,6 +16718,7 @@
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@ -17084,6 +17131,7 @@
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "^5.1.0"
}
@ -17103,7 +17151,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -17134,7 +17181,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -17161,7 +17207,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.4.tgz",
"integrity": "sha512-Rob7Ftz2vyZ/ZGsQZPaRdIefkgOSrQSPXfqBdvOPwJfoGnjwRJUs7EM7Kc1mcoDv3NOtqBzPGbcMB8CGn9CKgw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -17186,15 +17231,13 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@ -17334,6 +17377,7 @@
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
@ -17398,8 +17442,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@ -17437,7 +17480,8 @@
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
@ -17474,6 +17518,7 @@
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -17573,7 +17618,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.35.0.tgz",
"integrity": "sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.6"
},
@ -17675,7 +17719,8 @@
"url": "https://feross.org/support"
}
],
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
@ -17732,6 +17777,7 @@
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.9.0",
@ -17768,6 +17814,7 @@
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3"
},
@ -17779,7 +17826,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/secure-json-parse": {
"version": "4.0.0",
@ -17814,6 +17862,7 @@
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"randombytes": "^2.1.0"
}
@ -18385,7 +18434,8 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/supports-color": {
"version": "7.2.0",
@ -18425,8 +18475,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.1.tgz",
"integrity": "sha512-QNbdmeS979Efzim2g/bEvfuh+fTcIdp1y7gA+sb6OYSW74rt7Cr7M78AKdf6HqWT3d5AiTb7SwTT3sLQxr4/qw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
@ -18468,6 +18517,7 @@
"resolved": "https://registry.npmjs.org/terser/-/terser-5.42.0.tgz",
"integrity": "sha512-UYCvU9YQW2f/Vwl+P0GfhxJxbUGLwd+5QrrGgLajzWAtC/23AX0vcise32kkP7Eu0Wu9VlzzHAXkLObgjQfFlQ==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.14.0",
@ -18486,6 +18536,7 @@
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"jest-worker": "^27.4.5",
@ -18519,7 +18570,8 @@
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/thread-stream": {
"version": "3.1.0",
@ -18592,7 +18644,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -18654,8 +18705,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.19.3",
@ -18793,7 +18843,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -18999,6 +19048,7 @@
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
@ -19085,6 +19135,7 @@
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
"integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==",
"license": "MIT",
"peer": true,
"dependencies": {
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.1.2"
@ -19110,6 +19161,7 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.9.tgz",
"integrity": "sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.6",
@ -19172,6 +19224,7 @@
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
@ -19185,6 +19238,7 @@
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=4.0"
}
@ -19448,6 +19502,7 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">= 6"
}
@ -19556,7 +19611,6 @@
"resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz",
"integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"property-expr": "^2.0.5",
"tiny-case": "^1.0.3",
@ -19599,7 +19653,6 @@
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.20.0"
},

View file

@ -0,0 +1,170 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { type EndCallMessageType } from "../../config";
export interface TransferCallToolConfigProps {
name: string;
onNameChange: (name: string) => void;
description: string;
onDescriptionChange: (description: string) => void;
destination: string;
onDestinationChange: (destination: string) => void;
messageType: EndCallMessageType;
onMessageTypeChange: (messageType: EndCallMessageType) => void;
customMessage: string;
onCustomMessageChange: (message: string) => void;
timeout?: number; // Make optional to match API type
onTimeoutChange: (timeout: number) => void;
}
export function TransferCallToolConfig({
name,
onNameChange,
description,
onDescriptionChange,
destination,
onDestinationChange,
messageType,
onMessageTypeChange,
customMessage,
onCustomMessageChange,
timeout,
onTimeoutChange,
}: TransferCallToolConfigProps) {
// Basic E.164 validation pattern
const isValidPhoneNumber = (phone: string): boolean => {
const e164Pattern = /^\+[1-9]\d{1,14}$/;
return e164Pattern.test(phone);
};
const phoneNumberError = destination && !isValidPhoneNumber(destination);
return (
<Card>
<CardHeader>
<CardTitle>Transfer Call Configuration</CardTitle>
<CardDescription>
Configure call transfer settings (Twilio only)
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-2">
<Label>Tool Name</Label>
<Label className="text-xs text-muted-foreground">
A descriptive name for this tool
</Label>
<Input
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="e.g., Transfer Call"
/>
</div>
<div className="grid gap-2">
<Label>Description</Label>
<Label className="text-xs text-muted-foreground">
Helps the LLM understand when to use this tool
</Label>
<Textarea
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
placeholder="When should the AI transfer the call?"
rows={3}
/>
</div>
<div className="grid gap-2 pt-4 border-t">
<Label>Destination Phone Number</Label>
<Label className="text-xs text-muted-foreground">
Phone number to transfer the call to (E.164 format with country code)
</Label>
<Input
value={destination}
onChange={(e) => onDestinationChange(e.target.value)}
placeholder="+1234567890"
className={phoneNumberError ? "border-red-500 focus:border-red-500" : ""}
/>
{phoneNumberError && (
<Label className="text-xs text-red-500">
Please enter a valid phone number in E.164 format (e.g., +1234567890)
</Label>
)}
</div>
<div className="grid gap-4 pt-4 border-t">
<Label>Pre-Transfer Message</Label>
<Label className="text-xs text-muted-foreground">
Choose whether to play a message before transferring
</Label>
<RadioGroup
value={messageType}
onValueChange={(v) => onMessageTypeChange(v as EndCallMessageType)}
className="space-y-3"
>
<label
htmlFor="none"
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/50 cursor-pointer"
>
<RadioGroupItem value="none" id="none" />
<div className="flex-1">
<span className="font-medium">No Message</span>
<p className="text-xs text-muted-foreground">
Transfer the call immediately without any message
</p>
</div>
</label>
<div className="flex items-start space-x-3 p-3 border rounded-lg hover:bg-muted/50">
<RadioGroupItem value="custom" id="custom" className="mt-1" />
<label htmlFor="custom" className="flex-1 space-y-2 cursor-pointer">
<span className="font-medium">Custom Message</span>
<p className="text-xs text-muted-foreground">
Play a custom message before transferring
</p>
</label>
</div>
{messageType === "custom" && (
<div className="pl-8">
<Textarea
value={customMessage}
onChange={(e) => onCustomMessageChange(e.target.value)}
placeholder="e.g., Please hold while I transfer your call."
rows={2}
/>
</div>
)}
</RadioGroup>
</div>
<div className="grid gap-2 pt-4 border-t">
<Label>Transfer Timeout</Label>
<Label className="text-xs text-muted-foreground">
Maximum time to wait for destination to answer (5-120 seconds)
</Label>
<Input
type="number"
value={timeout ?? 30}
onChange={(e) => {
const value = parseInt(e.target.value) || 30;
// Clamp value between 5 and 120 seconds
const clampedValue = Math.min(Math.max(value, 5), 120);
onTimeoutChange(clampedValue);
}}
placeholder="30"
min="5"
max="120"
className="w-32"
/>
<Label className="text-xs text-muted-foreground">
Default: 30 seconds
</Label>
</div>
</CardContent>
</Card>
);
}

View file

@ -1,2 +1,3 @@
export { EndCallToolConfig, type EndCallToolConfigProps } from "./EndCallToolConfig";
export { HttpApiToolConfig, type HttpApiToolConfigProps } from "./HttpApiToolConfig";
export { TransferCallToolConfig, type TransferCallToolConfigProps } from "./TransferCallToolConfig";

View file

@ -8,7 +8,7 @@ import {
getToolApiV1ToolsToolUuidGet,
updateToolApiV1ToolsToolUuidPut,
} from "@/client/sdk.gen";
import type { ToolResponse } from "@/client/types.gen";
import type { ToolResponse, TransferCallConfig as APITransferCallConfig } from "@/client/types.gen";
import { type HttpMethod, type KeyValueItem, type ToolParameter, validateUrl } from "@/components/http";
import { Button } from "@/components/ui/button";
import {
@ -29,7 +29,7 @@ import {
renderToolIcon,
type ToolCategory,
} from "../config";
import { EndCallToolConfig, HttpApiToolConfig } from "./components";
import { EndCallToolConfig, HttpApiToolConfig, TransferCallToolConfig } from "./components";
// Extended HttpApiConfig with parameters (until client types are regenerated)
interface HttpApiConfigWithParams {
@ -69,6 +69,12 @@ export default function ToolDetailPage() {
const [endCallMessageType, setEndCallMessageType] = useState<EndCallMessageType>("none");
const [endCallCustomMessage, setEndCallCustomMessage] = useState("");
// Transfer Call form state
const [transferDestination, setTransferDestination] = useState("");
const [transferMessageType, setTransferMessageType] = useState<EndCallMessageType>("none");
const [transferCustomMessage, setTransferCustomMessage] = useState("");
const [transferTimeout, setTransferTimeout] = useState(30);
// Redirect if not authenticated
useEffect(() => {
if (!loading && !user) {
@ -117,6 +123,20 @@ export default function ToolDetailPage() {
setEndCallMessageType("none");
setEndCallCustomMessage("");
}
} else if (tool.category === "transfer_call") {
// Populate transfer call specific fields
const config = tool.definition?.config as APITransferCallConfig | undefined;
if (config) {
setTransferDestination(config.destination || "");
setTransferMessageType(config.messageType || "none");
setTransferCustomMessage(config.customMessage || "");
setTransferTimeout(config.timeout ?? 30);
} else {
setTransferDestination("");
setTransferMessageType("none");
setTransferCustomMessage("");
setTransferTimeout(30);
}
} else {
// Populate HTTP API specific fields
const config = tool.definition?.config as HttpApiConfigWithParams | undefined;
@ -163,7 +183,14 @@ export default function ToolDetailPage() {
if (!tool) return;
// Validation based on tool type
if (tool.category !== "end_call") {
if (tool.category === "transfer_call") {
// Validate destination phone number for Transfer Call tools
const e164Pattern = /^\+[1-9]\d{1,14}$/;
if (!transferDestination || !e164Pattern.test(transferDestination)) {
setError("Please enter a valid phone number in E.164 format (e.g., +1234567890)");
return;
}
} else if (tool.category !== "end_call") {
// Validate URL for HTTP API tools
const urlValidation = validateUrl(url);
if (!urlValidation.valid) {
@ -201,6 +228,22 @@ export default function ToolDetailPage() {
},
},
};
} else if (tool.category === "transfer_call") {
// Build transfer call request body
requestBody = {
name,
description: description || undefined,
definition: {
schema_version: 1,
type: "transfer_call",
config: {
destination: transferDestination,
messageType: transferMessageType,
customMessage: transferMessageType === "custom" ? transferCustomMessage : undefined,
timeout: transferTimeout,
},
},
};
} else {
// Build HTTP API request body
const headersObject: Record<string, string> = {};
@ -331,6 +374,7 @@ const data = await response.json();`;
}
const isEndCallTool = tool.category === "end_call";
const isTransferCallTool = tool.category === "transfer_call";
const categoryConfig = getCategoryConfig(tool.category as ToolCategory);
return (
@ -414,6 +458,21 @@ const data = await response.json();`;
customMessage={endCallCustomMessage}
onCustomMessageChange={setEndCallCustomMessage}
/>
) : isTransferCallTool ? (
<TransferCallToolConfig
name={name}
onNameChange={setName}
description={description}
onDescriptionChange={setDescription}
destination={transferDestination}
onDestinationChange={setTransferDestination}
messageType={transferMessageType}
onMessageTypeChange={setTransferMessageType}
customMessage={transferCustomMessage}
onCustomMessageChange={setTransferCustomMessage}
timeout={transferTimeout}
onTimeoutChange={setTransferTimeout}
/>
) : (
<HttpApiToolConfig
name={name}

View file

@ -1,9 +1,9 @@
"use client";
import { Cog, Globe, type LucideIcon,PhoneOff, Puzzle } from "lucide-react";
import { Cog, Globe, type LucideIcon, PhoneOff, PhoneForwarded, Puzzle } from "lucide-react";
import { type ReactNode } from "react";
export type ToolCategory = "http_api" | "end_call" | "native" | "integration";
export type ToolCategory = "http_api" | "end_call" | "transfer_call" | "native" | "integration";
export type EndCallMessageType = "none" | "custom";
@ -42,6 +42,18 @@ export const TOOL_CATEGORIES: ToolCategoryConfig[] = [
description: "End the call when either user asks to disconnect the call, or when you believe its time to end the conversation",
},
},
{
value: "transfer_call",
label: "Transfer Call",
description: "Transfer the call to another phone number (Twilio only)",
icon: PhoneForwarded,
iconName: "phone-forwarded",
iconColor: "#10B981",
autoFill: {
name: "Transfer Call",
description: "Transfer the caller to another phone number when requested",
},
},
{
value: "native",
label: "Native (Coming Soon)",
@ -85,6 +97,8 @@ export function getToolTypeLabel(category: string): string {
switch (category) {
case "end_call":
return "End Call Tool";
case "transfer_call":
return "Transfer Call Tool";
case "http_api":
return "HTTP API Tool";
case "native":
@ -107,6 +121,21 @@ export const DEFAULT_END_CALL_CONFIG: EndCallConfig = {
customMessage: "",
};
// Transfer Call tool specific configuration
export interface TransferCallConfig {
destination: string;
messageType: EndCallMessageType; // Reuse the same type
customMessage?: string;
timeout: number;
}
export const DEFAULT_TRANSFER_CALL_CONFIG: TransferCallConfig = {
destination: "",
messageType: "none",
customMessage: "",
timeout: 30,
};
// Tool definition types for different categories
export interface HttpApiToolDefinition {
schema_version: number;
@ -132,7 +161,13 @@ export interface EndCallToolDefinition {
config: EndCallConfig;
}
export type ToolDefinition = HttpApiToolDefinition | EndCallToolDefinition;
export interface TransferCallToolDefinition {
schema_version: number;
type: "transfer_call";
config: TransferCallConfig;
}
export type ToolDefinition = HttpApiToolDefinition | EndCallToolDefinition | TransferCallToolDefinition;
export function createEndCallDefinition(config: EndCallConfig): EndCallToolDefinition {
return {
@ -142,6 +177,14 @@ export function createEndCallDefinition(config: EndCallConfig): EndCallToolDefin
};
}
export function createTransferCallDefinition(config: TransferCallConfig): TransferCallToolDefinition {
return {
schema_version: 1,
type: "transfer_call",
config,
};
}
export function createHttpApiDefinition(): HttpApiToolDefinition {
return {
schema_version: 1,
@ -157,6 +200,8 @@ export function createToolDefinition(category: ToolCategory): ToolDefinition {
switch (category) {
case "end_call":
return createEndCallDefinition(DEFAULT_END_CALL_CONFIG);
case "transfer_call":
return createTransferCallDefinition(DEFAULT_TRANSFER_CALL_CONFIG);
case "http_api":
default:
return createHttpApiDefinition();

View file

@ -1,9 +1,8 @@
// This file is auto-generated by @hey-api/openapi-ts
import { type ClientOptions as DefaultClientOptions, type Config, createClient, createConfig } from '@hey-api/client-fetch';
import { createClientConfig } from '../lib/apiClient';
import type { ClientOptions } from './types.gen';
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from '@hey-api/client-fetch';
import { createClientConfig } from '../lib/apiClient';
/**
* The `createClientConfig()` function will be called on client initialization
@ -17,4 +16,4 @@ export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> =
export const client = createClient(createClientConfig(createConfig<ClientOptions>({
baseUrl: 'http://127.0.0.1:8000'
})));
})));

View file

@ -1,3 +1,3 @@
// This file is auto-generated by @hey-api/openapi-ts
export * from './sdk.gen';
export * from './types.gen';
export * from './sdk.gen';

File diff suppressed because one or more lines are too long

View file

@ -259,7 +259,9 @@ export type CreateToolRequest = {
type?: 'http_api';
} & HttpApiToolDefinition) | ({
type?: 'end_call';
} & EndCallToolDefinition);
} & EndCallToolDefinition) | ({
type?: 'transfer_call';
} & TransferCallToolDefinition);
};
export type CreateWorkflowRequest = {
@ -673,6 +675,27 @@ export type LoadTestStatsResponse = {
}>;
};
/**
* Request schema for order status lookup.
*/
export type OrderRequest = {
order_id: string;
};
/**
* Response schema for order status.
*/
export type OrderResponse = {
order_id: string;
status: string;
refund_status: string;
amount: number;
currency: string;
created_at: string;
refunded_at: string | null;
refund_amount: number | null;
};
export type PresignedUploadUrlRequest = {
/**
* CSV filename
@ -857,6 +880,56 @@ export type ToolResponse = {
created_by?: CreatedByResponse | null;
};
/**
* Configuration for Transfer Call tools.
*/
export type TransferCallConfig = {
/**
* Phone number to transfer the call to (E.164 format, e.g., +1234567890)
*/
destination: string;
/**
* Type of message to play before transfer
*/
messageType?: 'none' | 'custom';
/**
* Custom message to play before transferring the call
*/
customMessage?: string | null;
/**
* Maximum time in seconds to wait for destination to answer (5-120 seconds)
*/
timeout?: number;
};
/**
* Request model for initiating call transfer using webhook-driven completion
*/
export type TransferCallRequest = {
destination: string;
timeout?: number | null;
tool_call_id?: string | null;
tool_uuid?: string | null;
};
/**
* Tool definition for Transfer Call tools.
*/
export type TransferCallToolDefinition = {
/**
* Schema version
*/
schema_version?: number;
/**
* Tool type
*/
type: 'transfer_call';
/**
* Transfer Call configuration
*/
config: TransferCallConfig;
};
/**
* Request model for triggering a call via API
*/
@ -945,7 +1018,9 @@ export type UpdateToolRequest = {
type?: 'http_api';
} & HttpApiToolDefinition) | ({
type?: 'end_call';
} & EndCallToolDefinition)) | null;
} & EndCallToolDefinition) | ({
type?: 'transfer_call';
} & TransferCallToolDefinition)) | null;
status?: string | null;
};
@ -4794,6 +4869,35 @@ export type SearchChunksApiV1KnowledgeBaseSearchPostResponses = {
export type SearchChunksApiV1KnowledgeBaseSearchPostResponse = SearchChunksApiV1KnowledgeBaseSearchPostResponses[keyof SearchChunksApiV1KnowledgeBaseSearchPostResponses];
export type GetOrderStatusApiV1OrderStatusPostData = {
body: OrderRequest;
path?: never;
query?: never;
url: '/api/v1/order/status';
};
export type GetOrderStatusApiV1OrderStatusPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetOrderStatusApiV1OrderStatusPostError = GetOrderStatusApiV1OrderStatusPostErrors[keyof GetOrderStatusApiV1OrderStatusPostErrors];
export type GetOrderStatusApiV1OrderStatusPostResponses = {
/**
* Successful Response
*/
200: OrderResponse;
};
export type GetOrderStatusApiV1OrderStatusPostResponse = GetOrderStatusApiV1OrderStatusPostResponses[keyof GetOrderStatusApiV1OrderStatusPostResponses];
export type HealthApiV1HealthGetData = {
body?: never;
path?: never;
@ -4819,4 +4923,4 @@ export type HealthApiV1HealthGetResponse = HealthApiV1HealthGetResponses[keyof H
export type ClientOptions = {
baseUrl: 'http://127.0.0.1:8000' | (string & {});
};
};