mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
feat: add end_call tool (#118)
* feat: add end_call tool * chore: remove run_llm=True from properties
This commit is contained in:
parent
e7712474c1
commit
a172db8022
26 changed files with 1274 additions and 716 deletions
|
|
@ -0,0 +1,50 @@
|
|||
"""add end_call tool category
|
||||
|
||||
Revision ID: 493ca2bb001f
|
||||
Revises: b79f19f68157
|
||||
Create Date: 2026-01-14 15:04:48.899778
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from alembic_postgresql_enum import TableReference
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "493ca2bb001f"
|
||||
down_revision: Union[str, None] = "b79f19f68157"
|
||||
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", "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", "native", "integration"],
|
||||
affected_columns=[
|
||||
TableReference(
|
||||
table_schema="public", table_name="tools", column_name="category"
|
||||
)
|
||||
],
|
||||
enum_values_to_rename=[],
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
"""remove unique tool name constraint
|
||||
|
||||
Revision ID: dcb0a27d98c6
|
||||
Revises: 493ca2bb001f
|
||||
Create Date: 2026-01-14 16:07:01.940879
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "dcb0a27d98c6"
|
||||
down_revision: Union[str, None] = "493ca2bb001f"
|
||||
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.drop_constraint(op.f("unique_org_tool_name"), "tools", type_="unique")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_unique_constraint(
|
||||
op.f("unique_org_tool_name"),
|
||||
"tools",
|
||||
["organization_id", "name"],
|
||||
postgresql_nulls_not_distinct=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -889,5 +889,4 @@ class ToolModel(Base):
|
|||
Index("ix_tools_uuid", "tool_uuid"),
|
||||
Index("ix_tools_status", "status"),
|
||||
Index("ix_tools_category", "category"),
|
||||
UniqueConstraint("organization_id", "name", name="unique_org_tool_name"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -86,7 +86,12 @@ class ToolClient(BaseDBClient):
|
|||
)
|
||||
|
||||
if status:
|
||||
query = query.where(ToolModel.status == status)
|
||||
# Support comma-separated status values (e.g., "active,archived")
|
||||
status_list = [s.strip() for s in status.split(",")]
|
||||
if len(status_list) > 1:
|
||||
query = query.where(ToolModel.status.in_(status_list))
|
||||
else:
|
||||
query = query.where(ToolModel.status == status)
|
||||
else:
|
||||
# By default, exclude archived tools
|
||||
query = query.where(ToolModel.status != ToolStatus.ARCHIVED.value)
|
||||
|
|
@ -233,6 +238,44 @@ class ToolClient(BaseDBClient):
|
|||
return True
|
||||
return False
|
||||
|
||||
async def unarchive_tool(
|
||||
self, tool_uuid: str, organization_id: int
|
||||
) -> Optional[ToolModel]:
|
||||
"""Restore an archived tool by setting its status to active.
|
||||
|
||||
Args:
|
||||
tool_uuid: The unique tool UUID
|
||||
organization_id: ID of the organization (for authorization)
|
||||
|
||||
Returns:
|
||||
The unarchived ToolModel if found, None otherwise
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
update(ToolModel)
|
||||
.where(
|
||||
ToolModel.tool_uuid == tool_uuid,
|
||||
ToolModel.organization_id == organization_id,
|
||||
ToolModel.status == ToolStatus.ARCHIVED.value,
|
||||
)
|
||||
.values(
|
||||
status=ToolStatus.ACTIVE.value,
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
logger.info(
|
||||
f"Unarchived tool {tool_uuid} for organization {organization_id}"
|
||||
)
|
||||
# Fetch and return the updated tool
|
||||
result = await session.execute(
|
||||
select(ToolModel).where(ToolModel.tool_uuid == tool_uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
return None
|
||||
|
||||
async def validate_tool_uuid(self, tool_uuid: str, organization_id: int) -> bool:
|
||||
"""Check if a tool UUID exists and belongs to the organization.
|
||||
|
||||
|
|
|
|||
|
|
@ -121,9 +121,8 @@ class ToolCategory(Enum):
|
|||
"""Tool category types"""
|
||||
|
||||
HTTP_API = "http_api" # Custom HTTP API calls (implemented)
|
||||
NATIVE = (
|
||||
"native" # Built-in integrations (future: call_transfer, dtmf_input, end_call)
|
||||
)
|
||||
END_CALL = "end_call" # End call tool
|
||||
NATIVE = "native" # Built-in integrations (future: call_transfer, dtmf_input)
|
||||
INTEGRATION = "integration" # Third-party integrations (future: Google Calendar, Salesforce, etc.)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""API routes for managing tools."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
|
@ -45,14 +45,38 @@ class HttpApiConfig(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class ToolDefinition(BaseModel):
|
||||
"""Tool definition schema."""
|
||||
class EndCallConfig(BaseModel):
|
||||
"""Configuration for End Call tools."""
|
||||
|
||||
schema_version: int = Field(
|
||||
default=1, description="Schema version for compatibility"
|
||||
messageType: Literal["none", "custom"] = Field(
|
||||
default="none", description="Type of goodbye message"
|
||||
)
|
||||
type: str = Field(description="Tool type (http_api)")
|
||||
config: HttpApiConfig = Field(description="Tool configuration")
|
||||
customMessage: Optional[str] = Field(
|
||||
default=None, description="Custom message to play before ending the call"
|
||||
)
|
||||
|
||||
|
||||
class HttpApiToolDefinition(BaseModel):
|
||||
"""Tool definition for HTTP API tools."""
|
||||
|
||||
schema_version: int = Field(default=1, description="Schema version")
|
||||
type: Literal["http_api"] = Field(description="Tool type")
|
||||
config: HttpApiConfig = Field(description="HTTP API configuration")
|
||||
|
||||
|
||||
class EndCallToolDefinition(BaseModel):
|
||||
"""Tool definition for End Call tools."""
|
||||
|
||||
schema_version: int = Field(default=1, description="Schema version")
|
||||
type: Literal["end_call"] = Field(description="Tool type")
|
||||
config: EndCallConfig = Field(description="End Call configuration")
|
||||
|
||||
|
||||
# Union type for tool definitions - Pydantic will discriminate based on 'type' field
|
||||
ToolDefinition = Annotated[
|
||||
Union[HttpApiToolDefinition, EndCallToolDefinition],
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
||||
class CreateToolRequest(BaseModel):
|
||||
|
|
@ -140,13 +164,15 @@ def validate_category(category: str) -> None:
|
|||
|
||||
|
||||
def validate_status(status: str) -> None:
|
||||
"""Validate that the status is valid."""
|
||||
"""Validate that the status is valid. Supports comma-separated values."""
|
||||
valid_statuses = [s.value for s in ToolStatus]
|
||||
if status not in valid_statuses:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid status '{status}'. Must be one of: {', '.join(valid_statuses)}",
|
||||
)
|
||||
status_list = [s.strip() for s in status.split(",")]
|
||||
for s in status_list:
|
||||
if s not in valid_statuses:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid status '{s}'. Must be one of: {', '.join(valid_statuses)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
|
|
@ -205,27 +231,18 @@ async def create_tool(
|
|||
|
||||
validate_category(request.category)
|
||||
|
||||
try:
|
||||
tool = await db_client.create_tool(
|
||||
organization_id=user.selected_organization_id,
|
||||
user_id=user.id,
|
||||
name=request.name,
|
||||
definition=request.definition.model_dump(),
|
||||
category=request.category,
|
||||
description=request.description,
|
||||
icon=request.icon,
|
||||
icon_color=request.icon_color,
|
||||
)
|
||||
tool = await db_client.create_tool(
|
||||
organization_id=user.selected_organization_id,
|
||||
user_id=user.id,
|
||||
name=request.name,
|
||||
definition=request.definition.model_dump(),
|
||||
category=request.category,
|
||||
description=request.description,
|
||||
icon=request.icon,
|
||||
icon_color=request.icon_color,
|
||||
)
|
||||
|
||||
return build_tool_response(tool)
|
||||
|
||||
except Exception as e:
|
||||
if "unique_org_tool_name" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"A tool with the name '{request.name}' already exists",
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return build_tool_response(tool)
|
||||
|
||||
|
||||
@router.get("/{tool_uuid}")
|
||||
|
|
@ -281,32 +298,21 @@ async def update_tool(
|
|||
if request.status:
|
||||
validate_status(request.status)
|
||||
|
||||
try:
|
||||
tool = await db_client.update_tool(
|
||||
tool_uuid=tool_uuid,
|
||||
organization_id=user.selected_organization_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
definition=request.definition.model_dump() if request.definition else None,
|
||||
icon=request.icon,
|
||||
icon_color=request.icon_color,
|
||||
status=request.status,
|
||||
)
|
||||
tool = await db_client.update_tool(
|
||||
tool_uuid=tool_uuid,
|
||||
organization_id=user.selected_organization_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
definition=request.definition.model_dump() if request.definition else None,
|
||||
icon=request.icon,
|
||||
icon_color=request.icon_color,
|
||||
status=request.status,
|
||||
)
|
||||
|
||||
if not tool:
|
||||
raise HTTPException(status_code=404, detail="Tool not found")
|
||||
if not tool:
|
||||
raise HTTPException(status_code=404, detail="Tool not found")
|
||||
|
||||
return build_tool_response(tool, include_created_by=True)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
if "unique_org_tool_name" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"A tool with the name '{request.name}' already exists",
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return build_tool_response(tool, include_created_by=True)
|
||||
|
||||
|
||||
@router.delete("/{tool_uuid}")
|
||||
|
|
@ -334,3 +340,30 @@ async def delete_tool(
|
|||
raise HTTPException(status_code=404, detail="Tool not found")
|
||||
|
||||
return {"status": "archived", "tool_uuid": tool_uuid}
|
||||
|
||||
|
||||
@router.post("/{tool_uuid}/unarchive")
|
||||
async def unarchive_tool(
|
||||
tool_uuid: str,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> ToolResponse:
|
||||
"""
|
||||
Unarchive a tool (restore from archived state).
|
||||
|
||||
Args:
|
||||
tool_uuid: The UUID of the tool to unarchive
|
||||
|
||||
Returns:
|
||||
The unarchived tool
|
||||
"""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No organization selected for the user"
|
||||
)
|
||||
|
||||
tool = await db_client.unarchive_tool(tool_uuid, user.selected_organization_id)
|
||||
|
||||
if not tool:
|
||||
raise HTTPException(status_code=404, detail="Tool not found")
|
||||
|
||||
return build_tool_response(tool)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Optional
|
|||
from loguru import logger
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import ToolCategory
|
||||
from api.services.workflow.disposition_mapper import (
|
||||
get_organization_id_from_workflow_run,
|
||||
)
|
||||
|
|
@ -20,13 +21,17 @@ 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
|
||||
from pipecat.frames.frames import FunctionCallResultProperties, TTSSpeakFrame
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
|
||||
|
||||
# End task reason for end call tool
|
||||
END_CALL_TOOL_REASON = "end_call_tool"
|
||||
|
||||
|
||||
class CustomToolManager:
|
||||
"""Manager for custom tool registration and execution.
|
||||
|
||||
|
|
@ -34,14 +39,12 @@ class CustomToolManager:
|
|||
1. Fetching tools from the database based on tool UUIDs
|
||||
2. Converting tools to LLM function schemas
|
||||
3. Registering tool execution handlers with the LLM
|
||||
4. Executing HTTP API tools when invoked by the LLM
|
||||
4. Executing tools when invoked by the LLM
|
||||
"""
|
||||
|
||||
def __init__(self, engine: "PipecatEngine") -> None:
|
||||
self._engine = engine
|
||||
self._organization_id: Optional[int] = None
|
||||
# Cache: maps function_name -> (tool, schema)
|
||||
self._tools_cache: dict[str, tuple[Any, dict]] = {}
|
||||
|
||||
async def get_organization_id(self) -> Optional[int]:
|
||||
"""Get and cache the organization ID from workflow run."""
|
||||
|
|
@ -73,9 +76,6 @@ class CustomToolManager:
|
|||
raw_schema = tool_to_function_schema(tool)
|
||||
function_name = raw_schema["function"]["name"]
|
||||
|
||||
# Cache the tool for later execution
|
||||
self._tools_cache[function_name] = (tool, raw_schema)
|
||||
|
||||
# Convert to FunctionSchema object for compatibility with update_llm_context
|
||||
func_schema = get_function_schema(
|
||||
function_name,
|
||||
|
|
@ -117,9 +117,6 @@ class CustomToolManager:
|
|||
schema = tool_to_function_schema(tool)
|
||||
function_name = schema["function"]["name"]
|
||||
|
||||
# Cache the tool for potential later use
|
||||
self._tools_cache[function_name] = (tool, schema)
|
||||
|
||||
# Create and register the handler
|
||||
handler = self._create_handler(tool, function_name)
|
||||
self._engine.llm.register_function(function_name, handler)
|
||||
|
|
@ -133,7 +130,7 @@ class CustomToolManager:
|
|||
logger.error(f"Failed to register custom tool handlers: {e}")
|
||||
|
||||
def _create_handler(self, tool: Any, function_name: str):
|
||||
"""Create a handler function for a custom tool.
|
||||
"""Create a handler function for a tool based on its category.
|
||||
|
||||
Args:
|
||||
tool: The ToolModel instance
|
||||
|
|
@ -142,17 +139,29 @@ class CustomToolManager:
|
|||
Returns:
|
||||
Async handler function for the tool
|
||||
"""
|
||||
# Run LLM after tool execution to continue conversation
|
||||
properties = FunctionCallResultProperties(run_llm=True)
|
||||
if tool.category == ToolCategory.END_CALL.value:
|
||||
return self._create_end_call_handler(tool, function_name)
|
||||
|
||||
async def custom_tool_handler(
|
||||
return self._create_http_tool_handler(tool, function_name)
|
||||
|
||||
def _create_http_tool_handler(self, tool: Any, function_name: str):
|
||||
"""Create a handler function for an HTTP API tool.
|
||||
|
||||
Args:
|
||||
tool: The ToolModel instance
|
||||
function_name: The function name used by the LLM
|
||||
|
||||
Returns:
|
||||
Async handler function for the HTTP API tool
|
||||
"""
|
||||
|
||||
async def http_tool_handler(
|
||||
function_call_params: FunctionCallParams,
|
||||
) -> None:
|
||||
logger.info(f"LLM Function Call EXECUTED: {function_name}")
|
||||
logger.info(f"HTTP Tool EXECUTED: {function_name}")
|
||||
logger.info(f"Arguments: {function_call_params.arguments}")
|
||||
|
||||
try:
|
||||
# Execute the HTTP API tool
|
||||
result = await execute_http_tool(
|
||||
tool=tool,
|
||||
arguments=function_call_params.arguments,
|
||||
|
|
@ -160,30 +169,66 @@ class CustomToolManager:
|
|||
organization_id=self._organization_id,
|
||||
)
|
||||
|
||||
await function_call_params.result_callback(
|
||||
result, properties=properties
|
||||
)
|
||||
await function_call_params.result_callback(result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Custom tool '{function_name}' execution failed: {e}")
|
||||
logger.error(f"HTTP tool '{function_name}' execution failed: {e}")
|
||||
await function_call_params.result_callback(
|
||||
{"status": "error", "error": str(e)},
|
||||
properties=properties,
|
||||
{"status": "error", "error": str(e)}
|
||||
)
|
||||
|
||||
return custom_tool_handler
|
||||
return http_tool_handler
|
||||
|
||||
def get_cached_tool(self, function_name: str) -> Optional[tuple[Any, dict]]:
|
||||
"""Get a cached tool by its function name.
|
||||
def _create_end_call_handler(self, tool: Any, function_name: str):
|
||||
"""Create a handler function for an end call tool.
|
||||
|
||||
Args:
|
||||
tool: The ToolModel instance
|
||||
function_name: The function name used by the LLM
|
||||
|
||||
Returns:
|
||||
Tuple of (tool, schema) if found, None otherwise
|
||||
Async handler function for the end call tool
|
||||
"""
|
||||
return self._tools_cache.get(function_name)
|
||||
# Don't run LLM after end call - we're terminating
|
||||
properties = FunctionCallResultProperties(run_llm=False)
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the tools cache."""
|
||||
self._tools_cache.clear()
|
||||
async def end_call_handler(
|
||||
function_call_params: FunctionCallParams,
|
||||
) -> None:
|
||||
logger.info(f"End Call Tool EXECUTED: {function_name}")
|
||||
|
||||
try:
|
||||
# Get the end call configuration
|
||||
config = tool.definition.get("config", {})
|
||||
message_type = config.get("messageType", "none")
|
||||
custom_message = config.get("customMessage", "")
|
||||
|
||||
# Send result callback first
|
||||
await function_call_params.result_callback(
|
||||
{"status": "success", "action": "ending_call"},
|
||||
properties=properties,
|
||||
)
|
||||
|
||||
if message_type == "custom" and custom_message:
|
||||
# Queue the custom message to be spoken
|
||||
logger.info(f"Playing custom goodbye message: {custom_message}")
|
||||
await self._engine.task.queue_frame(TTSSpeakFrame(custom_message))
|
||||
# End the call after the message (not immediately)
|
||||
await self._engine.send_end_task_frame(
|
||||
END_CALL_TOOL_REASON, abort_immediately=False
|
||||
)
|
||||
else:
|
||||
# No message - end call immediately
|
||||
logger.info("Ending call immediately (no goodbye message)")
|
||||
await self._engine.send_end_task_frame(
|
||||
END_CALL_TOOL_REASON, abort_immediately=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"End call tool '{function_name}' execution failed: {e}")
|
||||
# Still try to end the call even if there's an error
|
||||
await self._engine.send_end_task_frame(
|
||||
END_CALL_TOOL_REASON, abort_immediately=True
|
||||
)
|
||||
|
||||
return end_call_handler
|
||||
|
|
|
|||
|
|
@ -113,7 +113,6 @@ class WorkflowGraph:
|
|||
# )
|
||||
|
||||
errors.extend(self._assert_start_node())
|
||||
errors.extend(self._assert_end_node())
|
||||
errors.extend(self._assert_connection_counts())
|
||||
errors.extend(self._assert_global_node())
|
||||
errors.extend(self._assert_node_configs())
|
||||
|
|
@ -174,30 +173,6 @@ class WorkflowGraph:
|
|||
)
|
||||
return errors
|
||||
|
||||
def _assert_end_node(self):
|
||||
errors: list[WorkflowError] = []
|
||||
end_node = [n for n in self.nodes.values() if n.data.is_end]
|
||||
if not end_node:
|
||||
errors.append(
|
||||
WorkflowError(
|
||||
kind=ItemKind.workflow,
|
||||
id=None,
|
||||
field=None,
|
||||
message="Workflow must have exactly one end node",
|
||||
)
|
||||
)
|
||||
|
||||
elif len(end_node) > 1:
|
||||
errors.append(
|
||||
WorkflowError(
|
||||
kind=ItemKind.workflow,
|
||||
id=None,
|
||||
field=None,
|
||||
message="Workflow must have exactly one end node",
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
def _assert_connection_counts(self):
|
||||
errors: list[WorkflowError] = []
|
||||
|
||||
|
|
@ -235,13 +210,13 @@ class WorkflowGraph:
|
|||
)
|
||||
)
|
||||
case NodeType.agentNode:
|
||||
if in_d < 1 or out_d < 1:
|
||||
if in_d < 1:
|
||||
errors.append(
|
||||
WorkflowError(
|
||||
kind=ItemKind.node,
|
||||
id=n.id,
|
||||
field=None,
|
||||
message=f"Worker must have at least 1 incoming and 1 outgoing edge",
|
||||
message=f"Worker must have at least 1 incoming edge",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class MockToolModel:
|
|||
tool_uuid: str
|
||||
name: str
|
||||
description: str
|
||||
category: str
|
||||
definition: Dict[str, Any]
|
||||
|
||||
|
||||
|
|
@ -55,6 +56,7 @@ class TestToolToFunctionSchema:
|
|||
tool_uuid="test-uuid-1",
|
||||
name="Get Weather",
|
||||
description="Get current weather for a location",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
|
|
@ -97,6 +99,7 @@ class TestToolToFunctionSchema:
|
|||
tool_uuid="test-uuid-2",
|
||||
name="Book Appointment",
|
||||
description="Book an appointment with the service",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
|
|
@ -145,6 +148,7 @@ class TestToolToFunctionSchema:
|
|||
tool_uuid="test-uuid-3",
|
||||
name="Get User's Account Info!!!",
|
||||
description="Get account information",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
|
|
@ -167,6 +171,7 @@ class TestToolToFunctionSchema:
|
|||
tool_uuid="test-uuid-4",
|
||||
name="Ping Server",
|
||||
description="Check if server is alive",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
|
|
@ -188,6 +193,7 @@ class TestToolToFunctionSchema:
|
|||
tool_uuid="test-uuid-5",
|
||||
name="My Tool",
|
||||
description=None,
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
|
|
@ -213,6 +219,7 @@ class TestExecuteHttpTool:
|
|||
tool_uuid="test-uuid",
|
||||
name="Create User",
|
||||
description="Create a new user",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
|
|
@ -257,6 +264,7 @@ class TestExecuteHttpTool:
|
|||
tool_uuid="test-uuid",
|
||||
name="Search Users",
|
||||
description="Search for users",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
|
|
@ -297,6 +305,7 @@ class TestExecuteHttpTool:
|
|||
tool_uuid="test-uuid",
|
||||
name="Delete User",
|
||||
description="Delete a user",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
|
|
@ -336,6 +345,7 @@ class TestExecuteHttpTool:
|
|||
tool_uuid="test-uuid",
|
||||
name="Slow API",
|
||||
description="A slow API call",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
|
|
@ -368,6 +378,7 @@ class TestExecuteHttpTool:
|
|||
tool_uuid="test-uuid",
|
||||
name="API with Headers",
|
||||
description="API that requires headers",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
|
|
@ -406,6 +417,7 @@ class TestExecuteHttpTool:
|
|||
tool_uuid="test-uuid",
|
||||
name="Authenticated API",
|
||||
description="API that requires authentication",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
|
|
@ -457,6 +469,7 @@ class TestExecuteHttpTool:
|
|||
tool_uuid="test-uuid",
|
||||
name="API with Credential",
|
||||
description="API with credential configured",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
|
|
@ -724,6 +737,7 @@ class TestCustomToolManagerUnit:
|
|||
tool_uuid="uuid-1",
|
||||
name="Test Tool",
|
||||
description="A test tool",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
|
|
@ -791,6 +805,7 @@ class TestCustomToolManagerUnit:
|
|||
tool_uuid="uuid-1",
|
||||
name="API Call",
|
||||
description="Make an API call",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
|
|
@ -846,53 +861,6 @@ class TestCustomToolManagerUnit:
|
|||
# Verify result was returned
|
||||
assert result_received["status"] == "success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_cache_prevents_duplicate_fetches(self):
|
||||
"""Test that tools are cached after first fetch."""
|
||||
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
||||
|
||||
mock_engine = Mock()
|
||||
mock_engine._workflow_run_id = 1
|
||||
mock_engine._call_context_vars = {}
|
||||
mock_engine.llm = Mock()
|
||||
mock_engine.llm.register_function = Mock()
|
||||
|
||||
manager = CustomToolManager(mock_engine)
|
||||
|
||||
mock_tool = MockToolModel(
|
||||
tool_uuid="uuid-1",
|
||||
name="Cached Tool",
|
||||
description="A tool that should be cached",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
"config": {"method": "GET", "url": "https://api.example.com"},
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine_custom_tools.get_organization_id_from_workflow_run"
|
||||
) as mock_get_org:
|
||||
mock_get_org.return_value = 1
|
||||
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine_custom_tools.db_client"
|
||||
) as mock_db:
|
||||
mock_db.get_tools_by_uuids = AsyncMock(return_value=[mock_tool])
|
||||
|
||||
# First call should fetch from DB
|
||||
await manager.get_tool_schemas(["uuid-1"])
|
||||
|
||||
# Verify tool is now in cache
|
||||
cached = manager.get_cached_tool("cached_tool")
|
||||
assert cached is not None
|
||||
assert cached[0].tool_uuid == "uuid-1"
|
||||
|
||||
# Clear cache and verify it's empty
|
||||
manager.clear_cache()
|
||||
cached = manager.get_cached_tool("cached_tool")
|
||||
assert cached is None
|
||||
|
||||
|
||||
class TestUpdateLLMContext:
|
||||
"""Tests for update_llm_context function."""
|
||||
|
|
|
|||
|
|
@ -206,31 +206,6 @@ class TestCustomToolManagerContextIntegration:
|
|||
assert "get_current_time" in tool_names
|
||||
assert "get_weather" in tool_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_cached_after_first_fetch(self, mock_engine, sample_tools):
|
||||
"""Test that CustomToolManager caches tools after first fetch."""
|
||||
manager = CustomToolManager(mock_engine)
|
||||
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine_custom_tools.get_organization_id_from_workflow_run"
|
||||
) as mock_get_org:
|
||||
mock_get_org.return_value = 1
|
||||
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine_custom_tools.db_client"
|
||||
) as mock_db:
|
||||
mock_db.get_tools_by_uuids = AsyncMock(return_value=[sample_tools[0]])
|
||||
|
||||
# First fetch
|
||||
await manager.get_tool_schemas(["weather-uuid-123"])
|
||||
|
||||
# Verify tool is cached (cache stores raw schema dict, not FunctionSchema)
|
||||
cached = manager.get_cached_tool("get_weather")
|
||||
assert cached is not None
|
||||
tool, raw_schema = cached
|
||||
assert tool.tool_uuid == "weather-uuid-123"
|
||||
assert raw_schema["function"]["name"] == "get_weather"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_preserves_function_call_history(
|
||||
self, mock_engine, sample_tools
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import pytest
|
|||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from api.tests.conftest import END_CALL_SYSTEM_PROMPT, MockTransportProcessor
|
||||
from pipecat.frames.frames import LLMContextFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
|
|
@ -123,6 +124,7 @@ async def run_pipeline_with_tool_calls(
|
|||
# Small delay to let runner start
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
# Run both concurrently
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import pytest
|
|||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from api.tests.conftest import MockTransportProcessor
|
||||
from pipecat.frames.frames import LLMContextFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
|
|
@ -128,6 +129,7 @@ async def run_pipeline_with_user_idle(
|
|||
# Small delay to let runner start
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
# Calculate total wait time:
|
||||
# - Initial bot speech
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ ui/
|
|||
|
||||
## API Client
|
||||
|
||||
The `src/client/` directory is auto-generated from the backend OpenAPI spec:
|
||||
The `src/client/` directory is auto-generated from the backend OpenAPI spec. Whenever you add a
|
||||
new api route in backend, and wish to use it in the UI, generate the client using below command.
|
||||
|
||||
```bash
|
||||
npm run generate-client
|
||||
|
|
|
|||
188
ui/package-lock.json
generated
188
ui/package-lock.json
generated
|
|
@ -1,16 +1,15 @@
|
|||
{
|
||||
"name": "ui",
|
||||
"version": "1.8.0",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ui",
|
||||
"version": "1.8.0",
|
||||
"version": "1.10.0",
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@hey-api/client-fetch": "^0.10.0",
|
||||
"@livekit/components-react": "^2.9.0",
|
||||
"@nangohq/frontend": "^0.60.3",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
|
|
@ -33,7 +32,6 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"livekit-client": "^2.9.9",
|
||||
"lucide-react": "^0.505.0",
|
||||
"next": "^15.3.3",
|
||||
"next-themes": "^0.4.6",
|
||||
|
|
@ -1033,12 +1031,6 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz",
|
||||
"integrity": "sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@dagrejs/dagre": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz",
|
||||
|
|
@ -2527,78 +2519,6 @@
|
|||
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@livekit/components-core": {
|
||||
"version": "0.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.2.tgz",
|
||||
"integrity": "sha512-lSNEyWuJ94PNVR7uycpqxV5XG+GmPpRCRPFEiVnAKG8xbTKlBrlu6ruPgg3/dZG4oHCPOpTN7VPV4jbcUjFAoA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "1.6.13",
|
||||
"loglevel": "1.9.1",
|
||||
"rxjs": "7.8.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"livekit-client": "^2.9.5",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/components-core/node_modules/loglevel": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz",
|
||||
"integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/components-react": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.0.tgz",
|
||||
"integrity": "sha512-HAY1d1N2n5e8KP3ogTGgvHpO4afDSKAt1pqDsHPumTYcNTGKuMtIfMc4CfUMrtVMQAggzouOTYfb/gC4gnaYiQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/components-core": "0.12.2",
|
||||
"clsx": "2.1.1",
|
||||
"usehooks-ts": "3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@livekit/krisp-noise-filter": "^0.2.12",
|
||||
"livekit-client": "^2.9.5",
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@livekit/krisp-noise-filter": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/mutex": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
|
||||
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@livekit/protocol": {
|
||||
"version": "1.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.34.0.tgz",
|
||||
"integrity": "sha512-bU7pCLAMRVTVZb1KSxA46q55bhOc4iATrY/gccy2/oX1D57tiZEI+8wGRWHeDwBb0UwnABu6JXzC4tTFkdsaOg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nangohq/frontend": {
|
||||
"version": "0.60.5",
|
||||
"resolved": "https://registry.npmjs.org/@nangohq/frontend/-/frontend-0.60.5.tgz",
|
||||
|
|
@ -16003,24 +15923,6 @@
|
|||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.9.9",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.9.9.tgz",
|
||||
"integrity": "sha512-Mrm9Z/kPmJP5r4EMbzXPfB27gRP+tZtU7Zzen2o8u6w5N6FxaqXerRFqtXddGXaVzZouJOAg51/5A9u3saC/2A==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
"@livekit/protocol": "1.34.0",
|
||||
"events": "^3.3.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"sdp-transform": "^2.15.0",
|
||||
"ts-debounce": "^4.0.0",
|
||||
"tslib": "2.8.1",
|
||||
"typed-emitter": "^2.1.0",
|
||||
"webrtc-adapter": "^9.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/loader-runner": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
|
||||
|
|
@ -16045,12 +15947,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
|
|
@ -16058,19 +15954,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loglevel": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
|
|
@ -17754,15 +17637,6 @@
|
|||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||
|
|
@ -17907,21 +17781,6 @@
|
|||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz",
|
||||
"integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp-transform": {
|
||||
"version": "2.15.0",
|
||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
||||
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"sdp-verify": "checker.js"
|
||||
}
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.0.0.tgz",
|
||||
|
|
@ -18778,12 +18637,6 @@
|
|||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-debounce": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz",
|
||||
"integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tsconfig-paths": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||
|
|
@ -18935,15 +18788,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"rxjs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
|
||||
|
|
@ -19195,21 +19039,6 @@
|
|||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/usehooks-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.debounce": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.15.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
|
|
@ -19360,19 +19189,6 @@
|
|||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.1.tgz",
|
||||
"integrity": "sha512-1AQO+d4ElfVSXyzNVTOewgGT/tAomwwztX/6e3totvyyzXPvXIIuUUjAmyZGbKBKbZOXauuJooZm3g6IuFuiNQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"sdp": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0",
|
||||
"npm": ">=3.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@
|
|||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@hey-api/client-fetch": "^0.10.0",
|
||||
"@livekit/components-react": "^2.9.0",
|
||||
"@nangohq/frontend": "^0.60.3",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
|
|
@ -36,7 +35,6 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"livekit-client": "^2.9.9",
|
||||
"lucide-react": "^0.505.0",
|
||||
"next": "^15.3.3",
|
||||
"next-themes": "^0.4.6",
|
||||
|
|
|
|||
112
ui/src/app/tools/[toolUuid]/components/EndCallToolConfig.tsx
Normal file
112
ui/src/app/tools/[toolUuid]/components/EndCallToolConfig.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"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 EndCallToolConfigProps {
|
||||
name: string;
|
||||
onNameChange: (name: string) => void;
|
||||
description: string;
|
||||
onDescriptionChange: (description: string) => void;
|
||||
messageType: EndCallMessageType;
|
||||
onMessageTypeChange: (messageType: EndCallMessageType) => void;
|
||||
customMessage: string;
|
||||
onCustomMessageChange: (message: string) => void;
|
||||
}
|
||||
|
||||
export function EndCallToolConfig({
|
||||
name,
|
||||
onNameChange,
|
||||
description,
|
||||
onDescriptionChange,
|
||||
messageType,
|
||||
onMessageTypeChange,
|
||||
customMessage,
|
||||
onCustomMessageChange,
|
||||
}: EndCallToolConfigProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>End Call Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the behavior when the call ends
|
||||
</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., End 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 end the call?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 pt-4 border-t">
|
||||
<Label>Goodbye Message</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Choose whether to play a message before disconnecting
|
||||
</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">
|
||||
End 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 disconnecting
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
{messageType === "custom" && (
|
||||
<div className="pl-8">
|
||||
<Textarea
|
||||
value={customMessage}
|
||||
onChange={(e) => onCustomMessageChange(e.target.value)}
|
||||
placeholder="e.g., Thank you for calling. Goodbye!"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
169
ui/src/app/tools/[toolUuid]/components/HttpApiToolConfig.tsx
Normal file
169
ui/src/app/tools/[toolUuid]/components/HttpApiToolConfig.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
CredentialSelector,
|
||||
type HttpMethod,
|
||||
HttpMethodSelector,
|
||||
KeyValueEditor,
|
||||
type KeyValueItem,
|
||||
ParameterEditor,
|
||||
type ToolParameter,
|
||||
UrlInput,
|
||||
} from "@/components/http";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export interface HttpApiToolConfigProps {
|
||||
name: string;
|
||||
onNameChange: (name: string) => void;
|
||||
description: string;
|
||||
onDescriptionChange: (description: string) => void;
|
||||
httpMethod: HttpMethod;
|
||||
onHttpMethodChange: (method: HttpMethod) => void;
|
||||
url: string;
|
||||
onUrlChange: (url: string) => void;
|
||||
credentialUuid: string;
|
||||
onCredentialUuidChange: (uuid: string) => void;
|
||||
headers: KeyValueItem[];
|
||||
onHeadersChange: (headers: KeyValueItem[]) => void;
|
||||
parameters: ToolParameter[];
|
||||
onParametersChange: (parameters: ToolParameter[]) => void;
|
||||
timeoutMs: number;
|
||||
onTimeoutMsChange: (timeout: number) => void;
|
||||
}
|
||||
|
||||
export function HttpApiToolConfig({
|
||||
name,
|
||||
onNameChange,
|
||||
description,
|
||||
onDescriptionChange,
|
||||
httpMethod,
|
||||
onHttpMethodChange,
|
||||
url,
|
||||
onUrlChange,
|
||||
credentialUuid,
|
||||
onCredentialUuidChange,
|
||||
headers,
|
||||
onHeadersChange,
|
||||
parameters,
|
||||
onParametersChange,
|
||||
timeoutMs,
|
||||
onTimeoutMsChange,
|
||||
}: HttpApiToolConfigProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tool Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the HTTP API endpoint and request settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="settings" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
<TabsTrigger value="auth">Authentication</TabsTrigger>
|
||||
<TabsTrigger value="parameters">Parameters</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="settings" className="space-y-4 mt-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Tool Name</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Use a descriptive name, like "Get Weather using API" for a tool that fetches weather
|
||||
</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="e.g., Book Appointment"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Description</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Provide a description which makes it easy for LLM to understand what this tool does
|
||||
</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||
placeholder="What does this tool do?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>HTTP Method</Label>
|
||||
<HttpMethodSelector
|
||||
value={httpMethod}
|
||||
onChange={onHttpMethodChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Timeout (ms)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={timeoutMs}
|
||||
onChange={(e) =>
|
||||
onTimeoutMsChange(parseInt(e.target.value) || 5000)
|
||||
}
|
||||
min={1000}
|
||||
max={30000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Endpoint URL</Label>
|
||||
<UrlInput
|
||||
value={url}
|
||||
onChange={onUrlChange}
|
||||
placeholder="https://api.example.com/appointments"
|
||||
showValidation
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="auth" className="space-y-4 mt-4">
|
||||
<CredentialSelector
|
||||
value={credentialUuid}
|
||||
onChange={onCredentialUuidChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="parameters" className="space-y-4 mt-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Tool Parameters</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Define the parameters that the LLM will provide when calling this tool.
|
||||
These will be sent as JSON body for POST/PUT/PATCH or as URL query params for GET/DELETE.
|
||||
</Label>
|
||||
<ParameterEditor
|
||||
parameters={parameters}
|
||||
onChange={onParametersChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 pt-4 border-t">
|
||||
<Label>Custom Headers</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Add custom headers to include in the request (optional)
|
||||
</Label>
|
||||
<KeyValueEditor
|
||||
items={headers}
|
||||
onChange={onHeadersChange}
|
||||
keyPlaceholder="Header name"
|
||||
valuePlaceholder="Header value"
|
||||
addButtonText="Add Header"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
2
ui/src/app/tools/[toolUuid]/components/index.ts
Normal file
2
ui/src/app/tools/[toolUuid]/components/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { EndCallToolConfig, type EndCallToolConfigProps } from "./EndCallToolConfig";
|
||||
export { HttpApiToolConfig, type HttpApiToolConfigProps } from "./HttpApiToolConfig";
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Code, Globe, Loader2, Save } from "lucide-react";
|
||||
import { ArrowLeft, Code, Loader2, Save } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
|
|
@ -9,6 +9,27 @@ import {
|
|||
updateToolApiV1ToolsToolUuidPut,
|
||||
} from "@/client/sdk.gen";
|
||||
import type { ToolResponse } from "@/client/types.gen";
|
||||
import { type HttpMethod, type KeyValueItem, type ToolParameter, validateUrl } from "@/components/http";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
import {
|
||||
type EndCallConfig,
|
||||
type EndCallMessageType,
|
||||
getCategoryConfig,
|
||||
getToolTypeLabel,
|
||||
renderToolIcon,
|
||||
type ToolCategory,
|
||||
} from "../config";
|
||||
import { EndCallToolConfig, HttpApiToolConfig } from "./components";
|
||||
|
||||
// Extended HttpApiConfig with parameters (until client types are regenerated)
|
||||
interface HttpApiConfigWithParams {
|
||||
|
|
@ -19,38 +40,6 @@ interface HttpApiConfigWithParams {
|
|||
parameters?: ToolParameter[];
|
||||
timeout_ms?: number;
|
||||
}
|
||||
import {
|
||||
CredentialSelector,
|
||||
type HttpMethod,
|
||||
HttpMethodSelector,
|
||||
KeyValueEditor,
|
||||
type KeyValueItem,
|
||||
ParameterEditor,
|
||||
type ToolParameter,
|
||||
UrlInput,
|
||||
validateUrl,
|
||||
} from "@/components/http";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
export default function ToolDetailPage() {
|
||||
const { toolUuid } = useParams<{ toolUuid: string }>();
|
||||
|
|
@ -64,9 +53,11 @@ export default function ToolDetailPage() {
|
|||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [showCodeDialog, setShowCodeDialog] = useState(false);
|
||||
|
||||
// Form state
|
||||
// Common form state
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// HTTP API form state
|
||||
const [httpMethod, setHttpMethod] = useState<HttpMethod>("POST");
|
||||
const [url, setUrl] = useState("");
|
||||
const [credentialUuid, setCredentialUuid] = useState("");
|
||||
|
|
@ -74,6 +65,10 @@ export default function ToolDetailPage() {
|
|||
const [parameters, setParameters] = useState<ToolParameter[]>([]);
|
||||
const [timeoutMs, setTimeoutMs] = useState(5000);
|
||||
|
||||
// End Call form state
|
||||
const [endCallMessageType, setEndCallMessageType] = useState<EndCallMessageType>("none");
|
||||
const [endCallCustomMessage, setEndCallCustomMessage] = useState("");
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
|
|
@ -112,37 +107,50 @@ export default function ToolDetailPage() {
|
|||
setName(tool.name);
|
||||
setDescription(tool.description || "");
|
||||
|
||||
const config = tool.definition?.config as HttpApiConfigWithParams | undefined;
|
||||
if (config) {
|
||||
setHttpMethod((config.method as HttpMethod) || "POST");
|
||||
setUrl(config.url || "");
|
||||
setCredentialUuid(config.credential_uuid || "");
|
||||
setTimeoutMs(config.timeout_ms || 5000);
|
||||
|
||||
// Convert headers object to array
|
||||
if (config.headers) {
|
||||
setHeaders(
|
||||
Object.entries(config.headers).map(([key, value]) => ({
|
||||
key,
|
||||
value: value as string,
|
||||
}))
|
||||
);
|
||||
if (tool.category === "end_call") {
|
||||
// Populate end call specific fields
|
||||
const config = tool.definition?.config as EndCallConfig | undefined;
|
||||
if (config) {
|
||||
setEndCallMessageType(config.messageType || "none");
|
||||
setEndCallCustomMessage(config.customMessage || "");
|
||||
} else {
|
||||
setHeaders([]);
|
||||
setEndCallMessageType("none");
|
||||
setEndCallCustomMessage("");
|
||||
}
|
||||
} else {
|
||||
// Populate HTTP API specific fields
|
||||
const config = tool.definition?.config as HttpApiConfigWithParams | undefined;
|
||||
if (config) {
|
||||
setHttpMethod((config.method as HttpMethod) || "POST");
|
||||
setUrl(config.url || "");
|
||||
setCredentialUuid(config.credential_uuid || "");
|
||||
setTimeoutMs(config.timeout_ms || 5000);
|
||||
|
||||
// Load parameters
|
||||
if (config.parameters && Array.isArray(config.parameters)) {
|
||||
setParameters(
|
||||
config.parameters.map((p: ToolParameter) => ({
|
||||
name: p.name || "",
|
||||
type: p.type || "string",
|
||||
description: p.description || "",
|
||||
required: p.required ?? true,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setParameters([]);
|
||||
// Convert headers object to array
|
||||
if (config.headers) {
|
||||
setHeaders(
|
||||
Object.entries(config.headers).map(([key, value]) => ({
|
||||
key,
|
||||
value: value as string,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setHeaders([]);
|
||||
}
|
||||
|
||||
// Load parameters
|
||||
if (config.parameters && Array.isArray(config.parameters)) {
|
||||
setParameters(
|
||||
config.parameters.map((p: ToolParameter) => ({
|
||||
name: p.name || "",
|
||||
type: p.type || "string",
|
||||
description: p.description || "",
|
||||
required: p.required ?? true,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setParameters([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -152,18 +160,23 @@ export default function ToolDetailPage() {
|
|||
}, [fetchTool]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validate URL
|
||||
const urlValidation = validateUrl(url);
|
||||
if (!urlValidation.valid) {
|
||||
setError(urlValidation.error || "Invalid URL");
|
||||
return;
|
||||
}
|
||||
if (!tool) return;
|
||||
|
||||
// Validate parameters have names
|
||||
const invalidParams = parameters.filter((p) => !p.name.trim());
|
||||
if (invalidParams.length > 0) {
|
||||
setError("All parameters must have a name");
|
||||
return;
|
||||
// Validation based on tool type
|
||||
if (tool.category !== "end_call") {
|
||||
// Validate URL for HTTP API tools
|
||||
const urlValidation = validateUrl(url);
|
||||
if (!urlValidation.valid) {
|
||||
setError(urlValidation.error || "Invalid URL");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate parameters have names
|
||||
const invalidParams = parameters.filter((p) => !p.name.trim());
|
||||
if (invalidParams.length > 0) {
|
||||
setError("All parameters must have a name");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -172,36 +185,52 @@ export default function ToolDetailPage() {
|
|||
setSaveSuccess(false);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
// Convert headers array to object
|
||||
const headersObject: Record<string, string> = {};
|
||||
headers.filter((h) => h.key && h.value).forEach((h) => {
|
||||
headersObject[h.key] = h.value;
|
||||
});
|
||||
let requestBody;
|
||||
|
||||
// Filter out empty parameters
|
||||
const validParameters = parameters.filter((p) => p.name.trim());
|
||||
|
||||
// Build the request body (cast needed until client types are regenerated)
|
||||
const requestBody = {
|
||||
name,
|
||||
description: description || undefined,
|
||||
definition: {
|
||||
schema_version: 1,
|
||||
type: "http_api",
|
||||
config: {
|
||||
method: httpMethod,
|
||||
url,
|
||||
credential_uuid: credentialUuid || undefined,
|
||||
headers:
|
||||
Object.keys(headersObject).length > 0
|
||||
? headersObject
|
||||
: undefined,
|
||||
parameters:
|
||||
validParameters.length > 0 ? validParameters : undefined,
|
||||
timeout_ms: timeoutMs,
|
||||
if (tool.category === "end_call") {
|
||||
// Build end call request body
|
||||
requestBody = {
|
||||
name,
|
||||
description: description || undefined,
|
||||
definition: {
|
||||
schema_version: 1,
|
||||
type: "end_call",
|
||||
config: {
|
||||
messageType: endCallMessageType,
|
||||
customMessage: endCallMessageType === "custom" ? endCallCustomMessage : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
} else {
|
||||
// Build HTTP API request body
|
||||
const headersObject: Record<string, string> = {};
|
||||
headers.filter((h) => h.key && h.value).forEach((h) => {
|
||||
headersObject[h.key] = h.value;
|
||||
});
|
||||
|
||||
const validParameters = parameters.filter((p) => p.name.trim());
|
||||
|
||||
requestBody = {
|
||||
name,
|
||||
description: description || undefined,
|
||||
definition: {
|
||||
schema_version: 1,
|
||||
type: "http_api",
|
||||
config: {
|
||||
method: httpMethod,
|
||||
url,
|
||||
credential_uuid: credentialUuid || undefined,
|
||||
headers:
|
||||
Object.keys(headersObject).length > 0
|
||||
? headersObject
|
||||
: undefined,
|
||||
parameters:
|
||||
validParameters.length > 0 ? validParameters : undefined,
|
||||
timeout_ms: timeoutMs,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const response = await updateToolApiV1ToolsToolUuidPut({
|
||||
path: { tool_uuid: toolUuid },
|
||||
|
|
@ -301,6 +330,9 @@ const data = await response.json();`;
|
|||
);
|
||||
}
|
||||
|
||||
const isEndCallTool = tool.category === "end_call";
|
||||
const categoryConfig = getCategoryConfig(tool.category as ToolCategory);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
|
|
@ -320,27 +352,29 @@ const data = await response.json();`;
|
|||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: tool.icon_color || "#3B82F6",
|
||||
backgroundColor: tool.icon_color || categoryConfig?.iconColor || "#3B82F6",
|
||||
}}
|
||||
>
|
||||
<Globe className="w-5 h-5 text-white" />
|
||||
{renderToolIcon(tool.category)}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{name}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
HTTP API Tool
|
||||
{getToolTypeLabel(tool.category)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCodeDialog(true)}
|
||||
>
|
||||
<Code className="w-4 h-4 mr-2" />
|
||||
View Code
|
||||
</Button>
|
||||
{!isEndCallTool && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCodeDialog(true)}
|
||||
>
|
||||
<Code className="w-4 h-4 mr-2" />
|
||||
View Code
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
|
|
@ -369,121 +403,41 @@ const data = await response.json();`;
|
|||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tool Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the HTTP API endpoint and request settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="settings" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
<TabsTrigger value="auth">Authentication</TabsTrigger>
|
||||
<TabsTrigger value="parameters">Parameters</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="settings" className="space-y-4 mt-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Tool Name</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Use a descriptive name, like "Get Weather using API" for a tool that fetches weather
|
||||
</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Book Appointment"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Description</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Provide a description which makes it easy for LLM to understand what this tool does
|
||||
</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What does this tool do?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>HTTP Method</Label>
|
||||
<HttpMethodSelector
|
||||
value={httpMethod}
|
||||
onChange={setHttpMethod}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Timeout (ms)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={timeoutMs}
|
||||
onChange={(e) =>
|
||||
setTimeoutMs(parseInt(e.target.value) || 5000)
|
||||
}
|
||||
min={1000}
|
||||
max={30000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Endpoint URL</Label>
|
||||
<UrlInput
|
||||
value={url}
|
||||
onChange={setUrl}
|
||||
placeholder="https://api.example.com/appointments"
|
||||
showValidation
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="auth" className="space-y-4 mt-4">
|
||||
<CredentialSelector
|
||||
value={credentialUuid}
|
||||
onChange={setCredentialUuid}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="parameters" className="space-y-4 mt-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Tool Parameters</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Define the parameters that the LLM will provide when calling this tool.
|
||||
These will be sent as JSON body for POST/PUT/PATCH or as URL query params for GET/DELETE.
|
||||
</Label>
|
||||
<ParameterEditor
|
||||
parameters={parameters}
|
||||
onChange={setParameters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 pt-4 border-t">
|
||||
<Label>Custom Headers</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Add custom headers to include in the request (optional)
|
||||
</Label>
|
||||
<KeyValueEditor
|
||||
items={headers}
|
||||
onChange={setHeaders}
|
||||
keyPlaceholder="Header name"
|
||||
valuePlaceholder="Header value"
|
||||
addButtonText="Add Header"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{isEndCallTool ? (
|
||||
<EndCallToolConfig
|
||||
name={name}
|
||||
onNameChange={setName}
|
||||
description={description}
|
||||
onDescriptionChange={setDescription}
|
||||
messageType={endCallMessageType}
|
||||
onMessageTypeChange={setEndCallMessageType}
|
||||
customMessage={endCallCustomMessage}
|
||||
onCustomMessageChange={setEndCallCustomMessage}
|
||||
/>
|
||||
) : (
|
||||
<HttpApiToolConfig
|
||||
name={name}
|
||||
onNameChange={setName}
|
||||
description={description}
|
||||
onDescriptionChange={setDescription}
|
||||
httpMethod={httpMethod}
|
||||
onHttpMethodChange={setHttpMethod}
|
||||
url={url}
|
||||
onUrlChange={setUrl}
|
||||
credentialUuid={credentialUuid}
|
||||
onCredentialUuidChange={setCredentialUuid}
|
||||
headers={headers}
|
||||
onHeadersChange={setHeaders}
|
||||
parameters={parameters}
|
||||
onParametersChange={setParameters}
|
||||
timeoutMs={timeoutMs}
|
||||
onTimeoutMsChange={setTimeoutMs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code View Dialog */}
|
||||
{/* Code View Dialog (only for HTTP API tools) */}
|
||||
<Dialog open={showCodeDialog} onOpenChange={setShowCodeDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
|
|
|
|||
164
ui/src/app/tools/config.tsx
Normal file
164
ui/src/app/tools/config.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"use client";
|
||||
|
||||
import { Cog, Globe, type LucideIcon,PhoneOff, Puzzle } from "lucide-react";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export type ToolCategory = "http_api" | "end_call" | "native" | "integration";
|
||||
|
||||
export type EndCallMessageType = "none" | "custom";
|
||||
|
||||
export interface ToolCategoryConfig {
|
||||
value: ToolCategory;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
iconName: string; // String name for storing in database
|
||||
iconColor: string;
|
||||
disabled?: boolean;
|
||||
autoFill?: {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const TOOL_CATEGORIES: ToolCategoryConfig[] = [
|
||||
{
|
||||
value: "http_api",
|
||||
label: "External HTTP API",
|
||||
description: "Make HTTP requests to external APIs",
|
||||
icon: Globe,
|
||||
iconName: "globe",
|
||||
iconColor: "#3B82F6",
|
||||
},
|
||||
{
|
||||
value: "end_call",
|
||||
label: "End Call",
|
||||
description: "End the call when conditions are met",
|
||||
icon: PhoneOff,
|
||||
iconName: "phone-off",
|
||||
iconColor: "#EF4444",
|
||||
autoFill: {
|
||||
name: "End Call",
|
||||
description: "End the call when either user asks to disconnect the call, or when you believe its time to end the conversation",
|
||||
},
|
||||
},
|
||||
{
|
||||
value: "native",
|
||||
label: "Native (Coming Soon)",
|
||||
description: "Built-in tools like call transfer, DTMF input",
|
||||
icon: Cog,
|
||||
iconName: "cog",
|
||||
iconColor: "#6B7280",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
value: "integration",
|
||||
label: "Integration (Coming Soon)",
|
||||
description: "Third-party integrations like Google Calendar",
|
||||
icon: Puzzle,
|
||||
iconName: "puzzle",
|
||||
iconColor: "#8B5CF6",
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function getCategoryConfig(category: ToolCategory): ToolCategoryConfig | undefined {
|
||||
return TOOL_CATEGORIES.find(c => c.value === category);
|
||||
}
|
||||
|
||||
export function getToolIcon(category: string): LucideIcon {
|
||||
const config = TOOL_CATEGORIES.find(c => c.value === category);
|
||||
return config?.icon ?? Globe;
|
||||
}
|
||||
|
||||
export function getToolIconColor(category: string, fallbackColor?: string): string {
|
||||
const config = TOOL_CATEGORIES.find(c => c.value === category);
|
||||
return config?.iconColor ?? fallbackColor ?? "#3B82F6";
|
||||
}
|
||||
|
||||
export function renderToolIcon(category: string, className: string = "w-5 h-5 text-white"): ReactNode {
|
||||
const Icon = getToolIcon(category);
|
||||
return <Icon className={className} />;
|
||||
}
|
||||
|
||||
export function getToolTypeLabel(category: string): string {
|
||||
switch (category) {
|
||||
case "end_call":
|
||||
return "End Call Tool";
|
||||
case "http_api":
|
||||
return "HTTP API Tool";
|
||||
case "native":
|
||||
return "Native Tool";
|
||||
case "integration":
|
||||
return "Integration Tool";
|
||||
default:
|
||||
return "Tool";
|
||||
}
|
||||
}
|
||||
|
||||
// End Call tool specific configuration
|
||||
export interface EndCallConfig {
|
||||
messageType: EndCallMessageType;
|
||||
customMessage?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_END_CALL_CONFIG: EndCallConfig = {
|
||||
messageType: "none",
|
||||
customMessage: "",
|
||||
};
|
||||
|
||||
// Tool definition types for different categories
|
||||
export interface HttpApiToolDefinition {
|
||||
schema_version: number;
|
||||
type: "http_api";
|
||||
config: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
credential_uuid?: string;
|
||||
parameters?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
}>;
|
||||
timeout_ms?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EndCallToolDefinition {
|
||||
schema_version: number;
|
||||
type: "end_call";
|
||||
config: EndCallConfig;
|
||||
}
|
||||
|
||||
export type ToolDefinition = HttpApiToolDefinition | EndCallToolDefinition;
|
||||
|
||||
export function createEndCallDefinition(config: EndCallConfig): EndCallToolDefinition {
|
||||
return {
|
||||
schema_version: 1,
|
||||
type: "end_call",
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
export function createHttpApiDefinition(): HttpApiToolDefinition {
|
||||
return {
|
||||
schema_version: 1,
|
||||
type: "http_api",
|
||||
config: {
|
||||
method: "POST",
|
||||
url: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createToolDefinition(category: ToolCategory): ToolDefinition {
|
||||
switch (category) {
|
||||
case "end_call":
|
||||
return createEndCallDefinition(DEFAULT_END_CALL_CONFIG);
|
||||
case "http_api":
|
||||
default:
|
||||
return createHttpApiDefinition();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Globe, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { Plus, RotateCcw, Search, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
createToolApiV1ToolsPost,
|
||||
deleteToolApiV1ToolsToolUuidDelete,
|
||||
listToolsApiV1ToolsGet,
|
||||
unarchiveToolApiV1ToolsToolUuidUnarchivePost,
|
||||
} from "@/client/sdk.gen";
|
||||
import type { ToolResponse } from "@/client/types.gen";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -39,27 +40,13 @@ import {
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
type ToolCategory = "http_api" | "native" | "integration";
|
||||
|
||||
const TOOL_CATEGORIES: { value: ToolCategory; label: string; description: string; disabled?: boolean }[] = [
|
||||
{
|
||||
value: "http_api",
|
||||
label: "External HTTP API",
|
||||
description: "Make HTTP requests to external APIs",
|
||||
},
|
||||
{
|
||||
value: "native",
|
||||
label: "Native (Coming Soon)",
|
||||
description: "Built-in tools like call transfer, DTMF input",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
value: "integration",
|
||||
label: "Integration (Coming Soon)",
|
||||
description: "Third-party integrations like Google Calendar",
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
import {
|
||||
createToolDefinition,
|
||||
getCategoryConfig,
|
||||
renderToolIcon,
|
||||
TOOL_CATEGORIES,
|
||||
type ToolCategory,
|
||||
} from "./config";
|
||||
|
||||
export default function ToolsPage() {
|
||||
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
||||
|
|
@ -74,6 +61,7 @@ export default function ToolsPage() {
|
|||
const [newToolCategory, setNewToolCategory] = useState<ToolCategory>("http_api");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
|
|
@ -94,6 +82,9 @@ export default function ToolsPage() {
|
|||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
query: {
|
||||
status: "active,archived",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
|
|
@ -113,36 +104,36 @@ export default function ToolsPage() {
|
|||
|
||||
const handleCreateTool = async () => {
|
||||
if (!newToolName.trim()) {
|
||||
setError("Please enter a name for the tool");
|
||||
setCreateError("Please enter a name for the tool");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
setCreateError(null);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const categoryConfig = getCategoryConfig(newToolCategory);
|
||||
const response = await createToolApiV1ToolsPost({
|
||||
body: {
|
||||
name: newToolName,
|
||||
description: newToolDescription || undefined,
|
||||
category: newToolCategory,
|
||||
icon: "globe",
|
||||
icon_color: "#3B82F6",
|
||||
definition: {
|
||||
schema_version: 1,
|
||||
type: newToolCategory,
|
||||
config: {
|
||||
method: "POST",
|
||||
url: "",
|
||||
},
|
||||
},
|
||||
icon: categoryConfig?.iconName || "globe",
|
||||
icon_color: categoryConfig?.iconColor || "#3B82F6",
|
||||
definition: createToolDefinition(newToolCategory),
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
const errorDetail = (response.error as { detail?: string })?.detail;
|
||||
setCreateError(errorDetail || "Failed to create tool");
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
setIsCreateDialogOpen(false);
|
||||
setNewToolName("");
|
||||
|
|
@ -151,8 +142,23 @@ export default function ToolsPage() {
|
|||
// Navigate to the new tool's detail page
|
||||
router.push(`/tools/${response.data.tool_uuid}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to create tool");
|
||||
} catch (err: unknown) {
|
||||
let errorMessage = "Failed to create tool";
|
||||
if (err && typeof err === "object") {
|
||||
const errObj = err as Record<string, unknown>;
|
||||
// Handle API client error response
|
||||
if (errObj.error && typeof errObj.error === "object") {
|
||||
const errorData = errObj.error as Record<string, unknown>;
|
||||
if (typeof errorData.detail === "string") {
|
||||
errorMessage = errorData.detail;
|
||||
}
|
||||
}
|
||||
// Handle standard Error objects
|
||||
else if (errObj.message && typeof errObj.message === "string") {
|
||||
errorMessage = errObj.message;
|
||||
}
|
||||
}
|
||||
setCreateError(errorMessage);
|
||||
console.error("Error creating tool:", err);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
|
|
@ -178,8 +184,31 @@ export default function ToolsPage() {
|
|||
|
||||
fetchTools();
|
||||
} catch (err) {
|
||||
setError("Failed to delete tool");
|
||||
console.error("Error deleting tool:", err);
|
||||
setError("Failed to archive tool");
|
||||
console.error("Error archiving tool:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnarchiveTool = async (toolUuid: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
await unarchiveToolApiV1ToolsToolUuidUnarchivePost({
|
||||
path: {
|
||||
tool_uuid: toolUuid,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchTools();
|
||||
} catch (err) {
|
||||
setError("Failed to unarchive tool");
|
||||
console.error("Error unarchiving tool:", err);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -189,10 +218,15 @@ export default function ToolsPage() {
|
|||
tool.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const activeTools = filteredTools.filter((tool) => tool.status === "active");
|
||||
const archivedTools = filteredTools.filter((tool) => tool.status === "archived");
|
||||
|
||||
const getCategoryBadge = (category: string) => {
|
||||
switch (category) {
|
||||
case "http_api":
|
||||
return <Badge variant="default">HTTP API</Badge>;
|
||||
case "end_call":
|
||||
return <Badge variant="destructive">End Call</Badge>;
|
||||
case "native":
|
||||
return <Badge variant="secondary">Native</Badge>;
|
||||
case "integration":
|
||||
|
|
@ -233,7 +267,7 @@ export default function ToolsPage() {
|
|||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Tools</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage reusable HTTP API tools that can be used across your workflows
|
||||
Manage reusable tools that can be used across your workflows
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -249,7 +283,7 @@ export default function ToolsPage() {
|
|||
<div>
|
||||
<CardTitle>Your Tools</CardTitle>
|
||||
<CardDescription>
|
||||
Create and manage HTTP API tools for your organization
|
||||
Create and manage tools for your organization
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
|
|
@ -285,9 +319,9 @@ export default function ToolsPage() {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredTools.length === 0 ? (
|
||||
) : activeTools.length === 0 && archivedTools.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Globe className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
{renderToolIcon("http_api", "w-12 h-12 text-muted-foreground mx-auto mb-4")}
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{searchQuery
|
||||
? "No tools match your search"
|
||||
|
|
@ -300,53 +334,123 @@ export default function ToolsPage() {
|
|||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredTools.map((tool) => (
|
||||
<div
|
||||
key={tool.tool_uuid}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
onClick={() =>
|
||||
router.push(`/tools/${tool.tool_uuid}`)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<>
|
||||
{/* Active Tools */}
|
||||
{activeTools.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{activeTools.map((tool) => (
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor:
|
||||
tool.icon_color || "#3B82F6",
|
||||
}}
|
||||
key={tool.tool_uuid}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
onClick={() =>
|
||||
router.push(`/tools/${tool.tool_uuid}`)
|
||||
}
|
||||
>
|
||||
<Globe className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{tool.name}
|
||||
</span>
|
||||
{getCategoryBadge(tool.category)}
|
||||
{getStatusBadge(tool.status)}
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor:
|
||||
tool.icon_color || getCategoryConfig(tool.category as ToolCategory)?.iconColor || "#3B82F6",
|
||||
}}
|
||||
>
|
||||
{renderToolIcon(tool.category)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{tool.name}
|
||||
</span>
|
||||
{getCategoryBadge(tool.category)}
|
||||
</div>
|
||||
{tool.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{tool.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) =>
|
||||
handleDeleteTool(tool.tool_uuid, e)
|
||||
}
|
||||
className="text-destructive hover:text-destructive/90"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) =>
|
||||
handleDeleteTool(tool.tool_uuid, e)
|
||||
}
|
||||
className="text-destructive hover:text-destructive/90"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
))}
|
||||
</div>
|
||||
) : !searchQuery ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No active tools
|
||||
</p>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
Create Your First Tool
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Archived Tools */}
|
||||
{archivedTools.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-semibold text-muted-foreground mb-4">
|
||||
Archived Tools
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{archivedTools.map((tool) => (
|
||||
<div
|
||||
key={tool.tool_uuid}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 cursor-pointer transition-colors opacity-60"
|
||||
onClick={() =>
|
||||
router.push(`/tools/${tool.tool_uuid}`)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor:
|
||||
tool.icon_color || getCategoryConfig(tool.category as ToolCategory)?.iconColor || "#3B82F6",
|
||||
}}
|
||||
>
|
||||
{renderToolIcon(tool.category)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{tool.name}
|
||||
</span>
|
||||
{getCategoryBadge(tool.category)}
|
||||
{getStatusBadge(tool.status)}
|
||||
</div>
|
||||
{tool.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) =>
|
||||
handleUnarchiveTool(tool.tool_uuid, e)
|
||||
}
|
||||
className="text-primary hover:text-primary/90"
|
||||
title="Restore tool"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -354,7 +458,10 @@ export default function ToolsPage() {
|
|||
</div>
|
||||
|
||||
{/* Create Tool Dialog */}
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={(open) => {
|
||||
setIsCreateDialogOpen(open);
|
||||
if (open) setCreateError(null);
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Tool</DialogTitle>
|
||||
|
|
@ -367,7 +474,15 @@ export default function ToolsPage() {
|
|||
<Label>Tool Type</Label>
|
||||
<Select
|
||||
value={newToolCategory}
|
||||
onValueChange={(v) => setNewToolCategory(v as ToolCategory)}
|
||||
onValueChange={(v) => {
|
||||
const category = v as ToolCategory;
|
||||
setNewToolCategory(category);
|
||||
const categoryConfig = getCategoryConfig(category);
|
||||
if (categoryConfig?.autoFill) {
|
||||
setNewToolName(categoryConfig.autoFill.name);
|
||||
setNewToolDescription(categoryConfig.autoFill.description);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
|
|
@ -385,7 +500,7 @@ export default function ToolsPage() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{TOOL_CATEGORIES.find(c => c.value === newToolCategory)?.description}
|
||||
{getCategoryConfig(newToolCategory)?.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
|
|
@ -413,6 +528,11 @@ export default function ToolsPage() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
{createError && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive text-sm">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -184,7 +184,11 @@ export type CreateToolRequest = {
|
|||
category?: string;
|
||||
icon?: string | null;
|
||||
icon_color?: string | null;
|
||||
definition: ToolDefinition;
|
||||
definition: ({
|
||||
type?: 'http_api';
|
||||
} & HttpApiToolDefinition) | ({
|
||||
type?: 'end_call';
|
||||
} & EndCallToolDefinition);
|
||||
};
|
||||
|
||||
export type CreateWorkflowRequest = {
|
||||
|
|
@ -349,6 +353,38 @@ export type EmbedTokenResponse = {
|
|||
embed_script: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for End Call tools.
|
||||
*/
|
||||
export type EndCallConfig = {
|
||||
/**
|
||||
* Type of goodbye message
|
||||
*/
|
||||
messageType?: 'none' | 'custom';
|
||||
/**
|
||||
* Custom message to play before ending the call
|
||||
*/
|
||||
customMessage?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool definition for End Call tools.
|
||||
*/
|
||||
export type EndCallToolDefinition = {
|
||||
/**
|
||||
* Schema version
|
||||
*/
|
||||
schema_version?: number;
|
||||
/**
|
||||
* Tool type
|
||||
*/
|
||||
type: 'end_call';
|
||||
/**
|
||||
* End Call configuration
|
||||
*/
|
||||
config: EndCallConfig;
|
||||
};
|
||||
|
||||
export type FileMetadataResponse = {
|
||||
key: string;
|
||||
metadata: {
|
||||
|
|
@ -392,6 +428,24 @@ export type HttpApiConfig = {
|
|||
timeout_ms?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool definition for HTTP API tools.
|
||||
*/
|
||||
export type HttpApiToolDefinition = {
|
||||
/**
|
||||
* Schema version
|
||||
*/
|
||||
schema_version?: number;
|
||||
/**
|
||||
* Tool type
|
||||
*/
|
||||
type: 'http_api';
|
||||
/**
|
||||
* HTTP API configuration
|
||||
*/
|
||||
config: HttpApiConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request payload for superadmin impersonation.
|
||||
*
|
||||
|
|
@ -572,24 +626,6 @@ export type TestSessionResponse = {
|
|||
completed_at: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool definition schema.
|
||||
*/
|
||||
export type ToolDefinition = {
|
||||
/**
|
||||
* Schema version for compatibility
|
||||
*/
|
||||
schema_version?: number;
|
||||
/**
|
||||
* Tool type (http_api)
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* Tool configuration
|
||||
*/
|
||||
config: HttpApiConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* A parameter that the tool accepts.
|
||||
*/
|
||||
|
|
@ -706,7 +742,11 @@ export type UpdateToolRequest = {
|
|||
description?: string | null;
|
||||
icon?: string | null;
|
||||
icon_color?: string | null;
|
||||
definition?: ToolDefinition | null;
|
||||
definition?: (({
|
||||
type?: 'http_api';
|
||||
} & HttpApiToolDefinition) | ({
|
||||
type?: 'end_call';
|
||||
} & EndCallToolDefinition)) | null;
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
|
|
@ -2802,6 +2842,41 @@ export type UpdateToolApiV1ToolsToolUuidPutResponses = {
|
|||
|
||||
export type UpdateToolApiV1ToolsToolUuidPutResponse = UpdateToolApiV1ToolsToolUuidPutResponses[keyof UpdateToolApiV1ToolsToolUuidPutResponses];
|
||||
|
||||
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
tool_uuid: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/tools/{tool_uuid}/unarchive';
|
||||
};
|
||||
|
||||
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostError = UnarchiveToolApiV1ToolsToolUuidUnarchivePostErrors[keyof UnarchiveToolApiV1ToolsToolUuidUnarchivePostErrors];
|
||||
|
||||
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: ToolResponse;
|
||||
};
|
||||
|
||||
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse = UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses[keyof UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses];
|
||||
|
||||
export type GetIntegrationsApiV1IntegrationGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink, Globe, Loader2 } from "lucide-react";
|
||||
import { ExternalLink, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { renderToolIcon } from "@/app/tools/config";
|
||||
import { listToolsApiV1ToolsGet } from "@/client/sdk.gen";
|
||||
import type { ToolResponse } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -123,7 +124,7 @@ export function ToolSelector({
|
|||
backgroundColor: tool.icon_color || "#3B82F6",
|
||||
}}
|
||||
>
|
||||
<Globe className="h-3 w-3 text-white" />
|
||||
{renderToolIcon(tool.category, "h-3 w-3 text-white")}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm font-medium truncate">
|
||||
|
|
|
|||
|
|
@ -250,9 +250,13 @@ export default function CustomEdge(props: CustomEdgeProps) {
|
|||
{/* Show full EdgeLabel when selected or hovered, otherwise show simple label */}
|
||||
{(selected || isHovered) ? (
|
||||
<div className={cn(
|
||||
"flex flex-col gap-2 bg-card rounded-lg border shadow-xl min-w-[220px]",
|
||||
"flex flex-col gap-2 bg-card rounded-lg border min-w-[220px]",
|
||||
"animate-in fade-in zoom-in duration-200",
|
||||
data?.invalid ? "border-destructive/50 shadow-[0_0_15px_rgba(239,68,68,0.3)]" : "border-border"
|
||||
data?.invalid
|
||||
? "border-destructive/50 shadow-[0_0_15px_rgba(239,68,68,0.3)]"
|
||||
: selected
|
||||
? "border-primary ring-2 ring-primary/40 shadow-[0_0_20px_rgba(59,130,246,0.5)]"
|
||||
: "border-border shadow-xl"
|
||||
)}>
|
||||
{/* Header with label */}
|
||||
<div className={cn(
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ export const BaseNode = forwardRef<
|
|||
// Border styling
|
||||
"border-border",
|
||||
className,
|
||||
// Selected state
|
||||
selected ? "border-muted-foreground shadow-lg" : "",
|
||||
// Selected state - prominent halo effect
|
||||
selected ? "border-primary ring-2 ring-primary/40 shadow-[0_0_20px_rgba(59,130,246,0.5)]" : "",
|
||||
// Invalid state
|
||||
invalid ? "border-destructive shadow-[0_0_10px_rgba(239,68,68,0.3)]" : "",
|
||||
// Hovered through edge takes precedence over selected through edge
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue