mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-22 11:51:04 +02:00
* ViciDial Working * feat(telephony): add FreeSWITCH provider to upstream-PBX seam Generalize the upstream-PBX capture and control paths to dispatch by provider. FreeSWITCH bridges in with X-PBX-* headers and is driven over the Event Socket Library (uuid_kill / uuid_transfer) by the channel UUID, alongside the existing VICIdial ra_call_control path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * add vici specific configs * feat(telephony): env-based VICIdial config + address3 post-call routing Move VICIdial agent/non-agent API and FreeSWITCH ESL connection settings out of hardcoded POC values into environment variables so the same image works against a PBX on another server. Add VICIdial update_lead forwarding: X-VICI-UPDATE-LEAD_* extracted variables are mapped to lead columns and pushed via the non-agent API before a transfer, with reserved API-control params dropped. Add hardcoded address3 disposition routing (Y/N -> in-group transfer, else hang up) and a synchronous final variable extraction so the transfer path sees the freshest conversation state. Skip upstream_pbx capture on non-PJSIP channels to avoid Asterisk 500s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: run formatter * feat: make vici configurable from UI * chore: clean up PR * fix: incorporate review comments * chore: incorporate review comments * chore: generate client * chore: incporporate review comments * chore: incorporate review comments --------- Co-authored-by: Dograh POC <payment@dograh.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1124 lines
51 KiB
TypeScript
1124 lines
51 KiB
TypeScript
"use client";
|
|
|
|
import { ArrowLeft, Code, ExternalLink, FlaskConical, Loader2, Save } from "lucide-react";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
import {
|
|
getToolApiV1ToolsToolUuidGet,
|
|
listRecordingsApiV1WorkflowRecordingsGet,
|
|
updateToolApiV1ToolsToolUuidPut,
|
|
} from "@/client/sdk.gen";
|
|
import type {
|
|
EndCallConfig,
|
|
HttpApiToolDefinition,
|
|
RecordingResponseSchema,
|
|
ToolResponse,
|
|
UpdateToolRequest,
|
|
} from "@/client/types.gen";
|
|
import {
|
|
CredentialSelector,
|
|
type HttpMethod,
|
|
type KeyValueItem,
|
|
type ParameterType,
|
|
type PresetToolParameter,
|
|
type ToolParameter,
|
|
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 { Textarea } from "@/components/ui/textarea";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { TOOL_DOCUMENTATION_URLS } from "@/constants/documentation";
|
|
import { useOrgConfig } from "@/context/OrgConfigContext";
|
|
import { detailFromError } from "@/lib/apiError";
|
|
import { useAuth } from "@/lib/auth";
|
|
|
|
import {
|
|
type ContextDestinationRouteRow,
|
|
createMcpDefinition,
|
|
DEFAULT_END_CALL_REASON_DESCRIPTION,
|
|
type EndCallMessageType,
|
|
type ExtendedTransferCallConfig,
|
|
getCategoryConfig,
|
|
getToolTypeLabel,
|
|
MCP_URL_PATTERN,
|
|
renderToolIcon,
|
|
type ToolCategory,
|
|
type TransferDestinationSource,
|
|
} from "../config";
|
|
import {
|
|
buildHttpToolTestSnapshot,
|
|
BuiltinToolConfig,
|
|
EndCallToolConfig,
|
|
HttpApiToolConfig,
|
|
HttpToolTestDialog,
|
|
TransferCallToolConfig,
|
|
} from "./components";
|
|
|
|
function normalizeParameterType(value: string | null | undefined): ParameterType {
|
|
switch (value) {
|
|
case "number":
|
|
case "boolean":
|
|
case "object":
|
|
case "array":
|
|
return value;
|
|
default:
|
|
return "string";
|
|
}
|
|
}
|
|
|
|
function headersToRows(headers: Record<string, string> | undefined | null): KeyValueItem[] {
|
|
if (!headers) return [];
|
|
return Object.entries(headers).map(([key, value]) => ({ key, value }));
|
|
}
|
|
|
|
export default function ToolDetailPage() {
|
|
const { toolUuid } = useParams<{ toolUuid: string }>();
|
|
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
|
const { externalPbxIntegrationsEnabled } = useOrgConfig();
|
|
const router = useRouter();
|
|
|
|
const [tool, setTool] = useState<ToolResponse | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
|
const [showCodeDialog, setShowCodeDialog] = useState(false);
|
|
const [showTestDialog, setShowTestDialog] = useState(false);
|
|
const [savedHttpTestSnapshot, setSavedHttpTestSnapshot] = useState<string | null>(null);
|
|
|
|
// Common form state
|
|
const [name, setName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
|
|
// Shared form state
|
|
const [customMessage, setCustomMessage] = useState("");
|
|
|
|
// HTTP API form state
|
|
const [httpMethod, setHttpMethod] = useState<HttpMethod>("POST");
|
|
const [url, setUrl] = useState("");
|
|
const [credentialUuid, setCredentialUuid] = useState("");
|
|
const [headers, setHeaders] = useState<KeyValueItem[]>([]);
|
|
const [parameters, setParameters] = useState<ToolParameter[]>([]);
|
|
const [presetParameters, setPresetParameters] = useState<PresetToolParameter[]>([]);
|
|
const [timeoutMs, setTimeoutMs] = useState(5000);
|
|
|
|
// End Call form state
|
|
const [endCallMessageType, setEndCallMessageType] = useState<EndCallMessageType>("none");
|
|
const [endCallReason, setEndCallReason] = useState(false);
|
|
const [endCallReasonDescription, setEndCallReasonDescription] = useState("");
|
|
const [audioRecordingId, setAudioRecordingId] = useState("");
|
|
|
|
const handleEndCallReasonChange = (enabled: boolean) => {
|
|
setEndCallReason(enabled);
|
|
if (enabled && !endCallReasonDescription) {
|
|
setEndCallReasonDescription(DEFAULT_END_CALL_REASON_DESCRIPTION);
|
|
}
|
|
};
|
|
|
|
// Transfer Call form state
|
|
const [transferDestinationSource, setTransferDestinationSource] =
|
|
useState<TransferDestinationSource>("static");
|
|
const [transferDestination, setTransferDestination] = useState("");
|
|
const [transferMessageType, setTransferMessageType] = useState<EndCallMessageType>("none");
|
|
const [transferTimeout, setTransferTimeout] = useState(30);
|
|
const [transferAudioRecordingId, setTransferAudioRecordingId] = useState("");
|
|
const [transferResolverUrl, setTransferResolverUrl] = useState("");
|
|
const [transferResolverCredentialUuid, setTransferResolverCredentialUuid] = useState("");
|
|
const [transferResolverHeaders, setTransferResolverHeaders] = useState<KeyValueItem[]>([]);
|
|
const [transferResolverTimeoutMs, setTransferResolverTimeoutMs] = useState(3000);
|
|
const [transferResolverWaitMessage, setTransferResolverWaitMessage] = useState("");
|
|
const [transferParameters, setTransferParameters] = useState<ToolParameter[]>([]);
|
|
const [transferPresetParameters, setTransferPresetParameters] = useState<PresetToolParameter[]>([]);
|
|
const [transferContextMappingPath, setTransferContextMappingPath] = useState("");
|
|
const [transferContextDestinationRoutes, setTransferContextDestinationRoutes] =
|
|
useState<ContextDestinationRouteRow[]>([]);
|
|
const [transferFallbackDestination, setTransferFallbackDestination] = useState("");
|
|
|
|
// HTTP API form state - custom message type
|
|
const [customMessageType, setCustomMessageType] = useState<'text' | 'audio'>('text');
|
|
const [customMessageRecordingId, setCustomMessageRecordingId] = useState("");
|
|
|
|
// MCP form state
|
|
const [mcpUrl, setMcpUrl] = useState("");
|
|
const [mcpCredentialUuid, setMcpCredentialUuid] = useState("");
|
|
const [mcpToolsFilter, setMcpToolsFilter] = useState("");
|
|
|
|
// Org-level recordings for audio dropdowns
|
|
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
|
|
|
|
// Redirect if not authenticated
|
|
useEffect(() => {
|
|
if (!loading && !user) {
|
|
redirectToLogin();
|
|
}
|
|
}, [loading, user, redirectToLogin]);
|
|
|
|
const fetchTool = useCallback(async () => {
|
|
if (loading || !user || !toolUuid) return;
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const accessToken = await getAccessToken();
|
|
|
|
const response = await getToolApiV1ToolsToolUuidGet({
|
|
path: { tool_uuid: toolUuid },
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
});
|
|
|
|
if (response.data) {
|
|
setTool(response.data);
|
|
populateFormFromTool(response.data);
|
|
}
|
|
} catch (err) {
|
|
setError("Failed to fetch tool");
|
|
console.error("Error fetching tool:", err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [loading, user, toolUuid, getAccessToken]);
|
|
|
|
const populateFormFromTool = (tool: ToolResponse) => {
|
|
setName(tool.name);
|
|
setDescription(tool.description || "");
|
|
|
|
if (tool.category === "end_call") {
|
|
// Populate end call specific fields
|
|
const config = tool.definition?.config as EndCallConfig | undefined;
|
|
if (config) {
|
|
setEndCallMessageType(config.messageType || "none");
|
|
setCustomMessage(config.customMessage || "");
|
|
setAudioRecordingId(config.audioRecordingId || "");
|
|
setEndCallReason(config.endCallReason ?? false);
|
|
setEndCallReasonDescription(config.endCallReasonDescription || "");
|
|
} else {
|
|
setEndCallMessageType("none");
|
|
setCustomMessage("");
|
|
setAudioRecordingId("");
|
|
setEndCallReason(false);
|
|
setEndCallReasonDescription("");
|
|
}
|
|
} else if (tool.category === "transfer_call") {
|
|
// Populate transfer call specific fields
|
|
const config = tool.definition?.config as ExtendedTransferCallConfig | undefined;
|
|
if (config) {
|
|
const resolver = config.resolver || undefined;
|
|
setTransferDestinationSource(config.destination_source || (resolver ? "dynamic" : "static"));
|
|
setTransferDestination(config.destination || "");
|
|
setTransferMessageType(config.messageType || "none");
|
|
setCustomMessage(config.customMessage || "");
|
|
setTransferAudioRecordingId(config.audioRecordingId || "");
|
|
setTransferTimeout(config.timeout ?? 30);
|
|
setTransferResolverUrl(resolver?.url || "");
|
|
setTransferResolverCredentialUuid(resolver?.credential_uuid || "");
|
|
setTransferResolverHeaders(headersToRows(resolver?.headers));
|
|
setTransferResolverTimeoutMs(resolver?.timeout_ms ?? 3000);
|
|
setTransferResolverWaitMessage(resolver?.wait_message || "");
|
|
setTransferParameters(
|
|
(resolver?.parameters || config.parameters || []).map((p) => ({
|
|
name: p.name || "",
|
|
type: normalizeParameterType(p.type),
|
|
description: p.description || "",
|
|
required: p.required ?? true,
|
|
})),
|
|
);
|
|
setTransferPresetParameters(
|
|
(resolver?.preset_parameters || []).map((p) => ({
|
|
name: p.name || "",
|
|
type: normalizeParameterType(p.type),
|
|
valueTemplate: p.value_template || "",
|
|
required: p.required ?? true,
|
|
})),
|
|
);
|
|
setTransferContextMappingPath(config.context_mapping?.context_path || "");
|
|
setTransferContextDestinationRoutes(
|
|
(config.context_mapping?.routes || []).map((route) => ({
|
|
...route,
|
|
id: crypto.randomUUID(),
|
|
}))
|
|
);
|
|
setTransferFallbackDestination(
|
|
config.context_mapping?.fallback_destination || ""
|
|
);
|
|
} else {
|
|
setTransferDestinationSource("static");
|
|
setTransferDestination("");
|
|
setTransferMessageType("none");
|
|
setCustomMessage("");
|
|
setTransferAudioRecordingId("");
|
|
setTransferTimeout(30);
|
|
setTransferResolverUrl("");
|
|
setTransferResolverCredentialUuid("");
|
|
setTransferResolverHeaders([]);
|
|
setTransferResolverTimeoutMs(3000);
|
|
setTransferResolverWaitMessage("");
|
|
setTransferParameters([]);
|
|
setTransferPresetParameters([]);
|
|
setTransferContextMappingPath("");
|
|
setTransferContextDestinationRoutes([]);
|
|
setTransferFallbackDestination("");
|
|
}
|
|
} else if (tool.category === "mcp") {
|
|
// Populate MCP specific fields
|
|
const config = tool.definition?.config as
|
|
| { url?: string; credential_uuid?: string | null; tools_filter?: string[] }
|
|
| undefined;
|
|
if (config) {
|
|
setMcpUrl(config.url || "");
|
|
setMcpCredentialUuid(config.credential_uuid || "");
|
|
setMcpToolsFilter(
|
|
Array.isArray(config.tools_filter)
|
|
? config.tools_filter.join(", ")
|
|
: ""
|
|
);
|
|
} else {
|
|
setMcpUrl("");
|
|
setMcpCredentialUuid("");
|
|
setMcpToolsFilter("");
|
|
}
|
|
} else {
|
|
// Populate HTTP API specific fields
|
|
const config = tool.definition?.config as HttpApiToolDefinition["config"] | undefined;
|
|
if (config) {
|
|
const loadedHttpMethod = (config.method as HttpMethod) || "POST";
|
|
const loadedUrl = config.url || "";
|
|
const loadedCredentialUuid = config.credential_uuid || "";
|
|
const loadedTimeoutMs = config.timeout_ms || 5000;
|
|
const loadedCustomMessage = config.customMessage || "";
|
|
const loadedCustomMessageType = config.customMessageType || "text";
|
|
const loadedCustomMessageRecordingId = config.customMessageRecordingId || "";
|
|
setHttpMethod(loadedHttpMethod);
|
|
setUrl(loadedUrl);
|
|
setCredentialUuid(loadedCredentialUuid);
|
|
setTimeoutMs(loadedTimeoutMs);
|
|
setCustomMessage(loadedCustomMessage);
|
|
setCustomMessageType(loadedCustomMessageType);
|
|
setCustomMessageRecordingId(loadedCustomMessageRecordingId);
|
|
|
|
// Convert headers object to array
|
|
const loadedHeaders = config.headers
|
|
? Object.entries(config.headers).map(([key, value]) => ({
|
|
key,
|
|
value: value as string,
|
|
}))
|
|
: [];
|
|
setHeaders(loadedHeaders);
|
|
|
|
// Load parameters
|
|
let loadedParameters: ToolParameter[] = [];
|
|
if (config.parameters && Array.isArray(config.parameters)) {
|
|
loadedParameters = config.parameters.map((p) => ({
|
|
name: p.name || "",
|
|
type: normalizeParameterType(p.type),
|
|
description: p.description || "",
|
|
required: p.required ?? true,
|
|
}));
|
|
setParameters(loadedParameters);
|
|
} else {
|
|
setParameters([]);
|
|
}
|
|
|
|
let loadedPresetParameters: PresetToolParameter[] = [];
|
|
if (config.preset_parameters && Array.isArray(config.preset_parameters)) {
|
|
loadedPresetParameters = config.preset_parameters.map((p) => ({
|
|
name: p.name || "",
|
|
type: normalizeParameterType(p.type),
|
|
valueTemplate: p.value_template || "",
|
|
required: p.required ?? true,
|
|
}));
|
|
setPresetParameters(loadedPresetParameters);
|
|
} else {
|
|
setPresetParameters([]);
|
|
}
|
|
|
|
setSavedHttpTestSnapshot(
|
|
buildHttpToolTestSnapshot({
|
|
name: tool.name,
|
|
description: tool.description || "",
|
|
httpMethod: loadedHttpMethod,
|
|
url: loadedUrl,
|
|
credentialUuid: loadedCredentialUuid,
|
|
headers: loadedHeaders,
|
|
parameters: loadedParameters,
|
|
presetParameters: loadedPresetParameters,
|
|
timeoutMs: loadedTimeoutMs,
|
|
customMessage: loadedCustomMessage,
|
|
customMessageType: loadedCustomMessageType,
|
|
customMessageRecordingId: loadedCustomMessageRecordingId,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
const fetchRecordings = useCallback(async () => {
|
|
if (loading || !user) return;
|
|
try {
|
|
const response = await listRecordingsApiV1WorkflowRecordingsGet({
|
|
query: {},
|
|
});
|
|
if (response.data) {
|
|
setRecordings(response.data.recordings);
|
|
}
|
|
} catch {
|
|
// Non-critical — dropdowns will show "No recordings available"
|
|
}
|
|
}, [loading, user]);
|
|
|
|
useEffect(() => {
|
|
fetchTool();
|
|
fetchRecordings();
|
|
}, [fetchTool, fetchRecordings]);
|
|
|
|
const handleSave = async () => {
|
|
if (!tool) return;
|
|
|
|
const normalizedTransferDestination = transferDestination.trim();
|
|
|
|
// Validation based on tool type
|
|
if (tool.category === "calculator") {
|
|
// No validation needed for built-in tools
|
|
} else if (tool.category === "transfer_call") {
|
|
if (transferDestinationSource === "static" && !normalizedTransferDestination) {
|
|
setError("Please enter a transfer destination");
|
|
return;
|
|
}
|
|
if (transferDestinationSource === "dynamic") {
|
|
const resolverUrlValidation = validateUrl(transferResolverUrl);
|
|
if (!resolverUrlValidation.valid) {
|
|
setError(resolverUrlValidation.error || "Invalid resolver URL");
|
|
return;
|
|
}
|
|
|
|
const invalidTransferParams = transferParameters.filter(
|
|
(p) => !p.name.trim() || !p.description.trim()
|
|
);
|
|
if (invalidTransferParams.length > 0) {
|
|
setError("All resolver arguments must have a name and description");
|
|
return;
|
|
}
|
|
const transferParamNames = transferParameters
|
|
.map((p) => p.name.trim())
|
|
.filter(Boolean);
|
|
if (new Set(transferParamNames).size !== transferParamNames.length) {
|
|
setError("Resolver argument names must be unique");
|
|
return;
|
|
}
|
|
const invalidPresetTransferParams = transferPresetParameters.filter(
|
|
(p) => !p.name.trim() || !p.valueTemplate.trim()
|
|
);
|
|
if (invalidPresetTransferParams.length > 0) {
|
|
setError("All resolver preset parameters must have a name and a value");
|
|
return;
|
|
}
|
|
const transferPresetParamNames = transferPresetParameters
|
|
.map((p) => p.name.trim())
|
|
.filter(Boolean);
|
|
if (new Set(transferPresetParamNames).size !== transferPresetParamNames.length) {
|
|
setError("Resolver preset parameter names must be unique");
|
|
return;
|
|
}
|
|
}
|
|
if (transferDestinationSource === "context_mapping") {
|
|
if (!transferContextMappingPath.trim()) {
|
|
setError("Please enter a gathered-context field for PBX routing");
|
|
return;
|
|
}
|
|
if (
|
|
transferContextDestinationRoutes.length === 0 ||
|
|
transferContextDestinationRoutes.some(
|
|
(route) => !route.context_value.trim() || !route.destination.trim()
|
|
)
|
|
) {
|
|
setError("Add at least one complete context value to destination mapping");
|
|
return;
|
|
}
|
|
const routeValues = transferContextDestinationRoutes.map((route) =>
|
|
route.context_value.trim().toLocaleLowerCase()
|
|
);
|
|
if (new Set(routeValues).size !== routeValues.length) {
|
|
setError("Destination mapping context values must be unique");
|
|
return;
|
|
}
|
|
}
|
|
} else if (tool.category === "mcp") {
|
|
// Validate MCP server URL (must be http(s))
|
|
if (!mcpUrl.trim()) {
|
|
setError("Please enter the MCP server URL");
|
|
return;
|
|
}
|
|
if (!MCP_URL_PATTERN.test(mcpUrl.trim())) {
|
|
setError("MCP server URL must start with http:// or https://");
|
|
return;
|
|
}
|
|
} else 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;
|
|
}
|
|
const paramNames = parameters.map((p) => p.name.trim()).filter(Boolean);
|
|
if (new Set(paramNames).size !== paramNames.length) {
|
|
setError("Parameter names must be unique");
|
|
return;
|
|
}
|
|
|
|
const invalidPresetParams = presetParameters.filter(
|
|
(p) => !p.name.trim() || !p.valueTemplate.trim()
|
|
);
|
|
if (invalidPresetParams.length > 0) {
|
|
setError("All preset parameters must have a name and a value");
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
setIsSaving(true);
|
|
setError(null);
|
|
setSaveSuccess(false);
|
|
const accessToken = await getAccessToken();
|
|
|
|
let requestBody: UpdateToolRequest;
|
|
|
|
if (tool.category === "calculator") {
|
|
// Built-in tool - only name/description, no config
|
|
requestBody = {
|
|
name,
|
|
description: description || undefined,
|
|
definition: {
|
|
schema_version: 1,
|
|
type: "calculator",
|
|
},
|
|
};
|
|
} else 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" ? customMessage : undefined,
|
|
audioRecordingId: endCallMessageType === "audio" ? audioRecordingId || undefined : undefined,
|
|
endCallReason,
|
|
endCallReasonDescription: endCallReason ? endCallReasonDescription || undefined : undefined,
|
|
},
|
|
},
|
|
};
|
|
} else if (tool.category === "transfer_call") {
|
|
const resolverHeadersObject: Record<string, string> = {};
|
|
transferResolverHeaders.filter((h) => h.key && h.value).forEach((h) => {
|
|
resolverHeadersObject[h.key] = h.value;
|
|
});
|
|
|
|
const validTransferParameters = transferParameters.filter((p) => p.name.trim());
|
|
const validTransferPresetParameters = transferPresetParameters.filter(
|
|
(p) => p.name.trim() && p.valueTemplate.trim()
|
|
);
|
|
|
|
const transferConfig: ExtendedTransferCallConfig = {
|
|
destination_source: transferDestinationSource,
|
|
destination: transferDestinationSource === "static" ? normalizedTransferDestination : "",
|
|
messageType: transferMessageType,
|
|
customMessage: transferMessageType === "custom" ? customMessage : undefined,
|
|
audioRecordingId: transferMessageType === "audio" ? transferAudioRecordingId || undefined : undefined,
|
|
timeout: transferTimeout,
|
|
resolver: transferDestinationSource === "dynamic"
|
|
? {
|
|
type: "http",
|
|
url: transferResolverUrl.trim(),
|
|
credential_uuid: transferResolverCredentialUuid || undefined,
|
|
headers:
|
|
Object.keys(resolverHeadersObject).length > 0
|
|
? resolverHeadersObject
|
|
: undefined,
|
|
timeout_ms: transferResolverTimeoutMs,
|
|
wait_message: transferResolverWaitMessage.trim() || undefined,
|
|
parameters:
|
|
validTransferParameters.length > 0
|
|
? validTransferParameters.map((p) => ({
|
|
name: p.name.trim(),
|
|
type: p.type,
|
|
description: p.description.trim(),
|
|
required: p.required,
|
|
}))
|
|
: undefined,
|
|
preset_parameters:
|
|
validTransferPresetParameters.length > 0
|
|
? validTransferPresetParameters.map((p) => ({
|
|
name: p.name.trim(),
|
|
type: p.type,
|
|
value_template: p.valueTemplate.trim(),
|
|
required: p.required,
|
|
}))
|
|
: undefined,
|
|
}
|
|
: undefined,
|
|
context_mapping: transferDestinationSource === "context_mapping"
|
|
? {
|
|
context_path: transferContextMappingPath.trim(),
|
|
routes: transferContextDestinationRoutes.map((route) => ({
|
|
context_value: route.context_value.trim(),
|
|
destination: route.destination.trim(),
|
|
})),
|
|
fallback_destination:
|
|
transferFallbackDestination.trim() || undefined,
|
|
}
|
|
: undefined,
|
|
};
|
|
// Build transfer call request body
|
|
requestBody = {
|
|
name,
|
|
description: description || undefined,
|
|
definition: {
|
|
schema_version: 1,
|
|
type: "transfer_call",
|
|
config: transferConfig,
|
|
} as UpdateToolRequest["definition"],
|
|
};
|
|
} else if (tool.category === "mcp") {
|
|
requestBody = {
|
|
name,
|
|
description: description || undefined,
|
|
definition: createMcpDefinition(mcpUrl, mcpCredentialUuid, mcpToolsFilter),
|
|
};
|
|
} 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());
|
|
const validPresetParameters = presetParameters.filter(
|
|
(p) => p.name.trim() && p.valueTemplate.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,
|
|
preset_parameters:
|
|
validPresetParameters.length > 0
|
|
? validPresetParameters.map((p) => ({
|
|
name: p.name,
|
|
type: p.type,
|
|
value_template: p.valueTemplate,
|
|
required: p.required,
|
|
}))
|
|
: undefined,
|
|
timeout_ms: timeoutMs,
|
|
customMessage: customMessageType === 'text' ? (customMessage || undefined) : undefined,
|
|
customMessageType,
|
|
customMessageRecordingId: customMessageType === 'audio' ? (customMessageRecordingId || undefined) : undefined,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
const response = await updateToolApiV1ToolsToolUuidPut({
|
|
path: { tool_uuid: toolUuid },
|
|
body: requestBody,
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
});
|
|
|
|
if (response.error) {
|
|
setError(detailFromError(response.error, "Failed to save tool"));
|
|
return;
|
|
}
|
|
|
|
if (response.data) {
|
|
setTool(response.data);
|
|
setSaveSuccess(true);
|
|
setTimeout(() => setSaveSuccess(false), 3000);
|
|
if (tool.category === "http_api") {
|
|
setSavedHttpTestSnapshot(
|
|
buildHttpToolTestSnapshot({
|
|
name,
|
|
description,
|
|
httpMethod,
|
|
url,
|
|
credentialUuid,
|
|
headers,
|
|
parameters,
|
|
presetParameters,
|
|
timeoutMs,
|
|
customMessage,
|
|
customMessageType,
|
|
customMessageRecordingId,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
setError("Failed to save tool");
|
|
console.error("Error saving tool:", err);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const getCodeSnippet = () => {
|
|
if (!tool) return "";
|
|
|
|
const headersObj: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
};
|
|
headers.filter((h) => h.key && h.value).forEach((h) => {
|
|
headersObj[h.key] = h.value;
|
|
});
|
|
|
|
// Build example body from parameters
|
|
const exampleBody: Record<string, unknown> = {};
|
|
parameters.forEach((p) => {
|
|
if (p.type === "number") {
|
|
exampleBody[p.name] = 0;
|
|
} else if (p.type === "boolean") {
|
|
exampleBody[p.name] = true;
|
|
} else {
|
|
exampleBody[p.name] = `<${p.name}>`;
|
|
}
|
|
});
|
|
presetParameters.forEach((p) => {
|
|
if (p.type === "number") {
|
|
exampleBody[p.name] = p.valueTemplate || 0;
|
|
} else if (p.type === "boolean") {
|
|
exampleBody[p.name] = p.valueTemplate || true;
|
|
} else {
|
|
exampleBody[p.name] = p.valueTemplate || `<${p.name}>`;
|
|
}
|
|
});
|
|
|
|
const hasBody =
|
|
httpMethod !== "GET" &&
|
|
httpMethod !== "DELETE" &&
|
|
(parameters.length > 0 || presetParameters.length > 0);
|
|
|
|
return `// ${tool.name}
|
|
// ${tool.description || "HTTP API Tool"}
|
|
|
|
const response = await fetch("${url}", {
|
|
method: "${httpMethod}",
|
|
headers: ${JSON.stringify(headersObj, null, 4)},${hasBody ? `
|
|
body: JSON.stringify(${JSON.stringify(exampleBody, null, 4)}),` : ""}
|
|
});
|
|
|
|
const data = await response.json();`;
|
|
};
|
|
|
|
if (loading || !user) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-12 w-64" />
|
|
<Skeleton className="h-64 w-96" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="min-h-screen">
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="max-w-4xl mx-auto space-y-6">
|
|
<Skeleton className="h-8 w-48" />
|
|
<Skeleton className="h-64 w-full" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!tool) {
|
|
return (
|
|
<div className="min-h-screen">
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="max-w-4xl mx-auto text-center">
|
|
<h1 className="text-2xl font-bold mb-4">Tool not found</h1>
|
|
<Button onClick={() => router.push("/tools")}>
|
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
Back to Tools
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isEndCallTool = tool.category === "end_call";
|
|
const isTransferCallTool = tool.category === "transfer_call";
|
|
const isBuiltinTool = tool.category === "calculator";
|
|
const isMcpTool = tool.category === "mcp";
|
|
const isHttpApiTool = tool.category === "http_api";
|
|
const hasUnsavedHttpChanges =
|
|
isHttpApiTool &&
|
|
(savedHttpTestSnapshot === null ||
|
|
buildHttpToolTestSnapshot({
|
|
name,
|
|
description,
|
|
httpMethod,
|
|
url,
|
|
credentialUuid,
|
|
headers,
|
|
parameters,
|
|
presetParameters,
|
|
timeoutMs,
|
|
customMessage,
|
|
customMessageType,
|
|
customMessageRecordingId,
|
|
}) !== savedHttpTestSnapshot);
|
|
const categoryConfig = getCategoryConfig(tool.category as ToolCategory);
|
|
|
|
return (
|
|
<div className="min-h-screen">
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="max-w-4xl mx-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-4">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => router.push("/tools")}
|
|
>
|
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
Back
|
|
</Button>
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
|
style={{
|
|
backgroundColor: tool.icon_color || categoryConfig?.iconColor || "#3B82F6",
|
|
}}
|
|
>
|
|
{renderToolIcon(tool.category)}
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-bold">{name}</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
{getToolTypeLabel(tool.category)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isHttpApiTool && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowCodeDialog(true)}
|
|
>
|
|
<Code className="w-4 h-4 mr-2" />
|
|
View Code
|
|
</Button>
|
|
)}
|
|
{TOOL_DOCUMENTATION_URLS[tool.category] && (
|
|
<a
|
|
href={TOOL_DOCUMENTATION_URLS[tool.category]}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
Docs
|
|
<ExternalLink className="h-3.5 w-3.5" />
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isBuiltinTool ? (
|
|
<BuiltinToolConfig
|
|
name={name}
|
|
onNameChange={setName}
|
|
description={description}
|
|
onDescriptionChange={setDescription}
|
|
title="Calculator Configuration"
|
|
subtitle="Built-in calculator for arithmetic operations. No additional configuration needed."
|
|
/>
|
|
) : isEndCallTool ? (
|
|
<EndCallToolConfig
|
|
name={name}
|
|
onNameChange={setName}
|
|
description={description}
|
|
onDescriptionChange={setDescription}
|
|
messageType={endCallMessageType}
|
|
onMessageTypeChange={setEndCallMessageType}
|
|
customMessage={customMessage}
|
|
onCustomMessageChange={setCustomMessage}
|
|
audioRecordingId={audioRecordingId}
|
|
onAudioRecordingIdChange={setAudioRecordingId}
|
|
recordings={recordings}
|
|
endCallReason={endCallReason}
|
|
onEndCallReasonChange={handleEndCallReasonChange}
|
|
endCallReasonDescription={endCallReasonDescription}
|
|
onEndCallReasonDescriptionChange={setEndCallReasonDescription}
|
|
/>
|
|
) : isTransferCallTool ? (
|
|
<TransferCallToolConfig
|
|
name={name}
|
|
onNameChange={setName}
|
|
description={description}
|
|
onDescriptionChange={setDescription}
|
|
destinationSource={transferDestinationSource}
|
|
onDestinationSourceChange={setTransferDestinationSource}
|
|
destination={transferDestination}
|
|
onDestinationChange={setTransferDestination}
|
|
messageType={transferMessageType}
|
|
onMessageTypeChange={setTransferMessageType}
|
|
customMessage={customMessage}
|
|
onCustomMessageChange={setCustomMessage}
|
|
audioRecordingId={transferAudioRecordingId}
|
|
onAudioRecordingIdChange={setTransferAudioRecordingId}
|
|
recordings={recordings}
|
|
timeout={transferTimeout}
|
|
onTimeoutChange={setTransferTimeout}
|
|
resolverUrl={transferResolverUrl}
|
|
onResolverUrlChange={setTransferResolverUrl}
|
|
resolverCredentialUuid={transferResolverCredentialUuid}
|
|
onResolverCredentialUuidChange={setTransferResolverCredentialUuid}
|
|
resolverHeaders={transferResolverHeaders}
|
|
onResolverHeadersChange={setTransferResolverHeaders}
|
|
resolverTimeoutMs={transferResolverTimeoutMs}
|
|
onResolverTimeoutMsChange={setTransferResolverTimeoutMs}
|
|
resolverWaitMessage={transferResolverWaitMessage}
|
|
onResolverWaitMessageChange={setTransferResolverWaitMessage}
|
|
parameters={transferParameters}
|
|
onParametersChange={setTransferParameters}
|
|
presetParameters={transferPresetParameters}
|
|
onPresetParametersChange={setTransferPresetParameters}
|
|
externalPbxRoutingEnabled={externalPbxIntegrationsEnabled}
|
|
contextMappingPath={transferContextMappingPath}
|
|
onContextMappingPathChange={setTransferContextMappingPath}
|
|
contextDestinationRoutes={transferContextDestinationRoutes}
|
|
onContextDestinationRoutesChange={setTransferContextDestinationRoutes}
|
|
fallbackDestination={transferFallbackDestination}
|
|
onFallbackDestinationChange={setTransferFallbackDestination}
|
|
/>
|
|
) : isMcpTool ? (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>MCP Server Configuration</CardTitle>
|
|
<CardDescription>
|
|
Configure the MCP server endpoint. Its tools become available to the agent.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="mcp-name">Tool Name</Label>
|
|
<Input
|
|
id="mcp-name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="e.g., Customer MCP Server"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="mcp-description">Description</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Provide a description which makes it easy for LLM to understand what this tool does
|
|
</p>
|
|
<Textarea
|
|
id="mcp-description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="What does this MCP server provide?"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="mcp-url">MCP Server URL</Label>
|
|
<Input
|
|
id="mcp-url"
|
|
value={mcpUrl}
|
|
onChange={(e) => setMcpUrl(e.target.value)}
|
|
placeholder="https://your-mcp-server.example.com/mcp"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Transport</Label>
|
|
<Input
|
|
value="Streamable HTTP"
|
|
disabled
|
|
readOnly
|
|
/>
|
|
</div>
|
|
|
|
<CredentialSelector
|
|
value={mcpCredentialUuid}
|
|
onChange={setMcpCredentialUuid}
|
|
label="Credential (Optional)"
|
|
description="Select a credential for authenticating with the MCP server, or leave empty for no auth."
|
|
/>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="mcp-tools-filter">Tools Filter (Optional)</Label>
|
|
<Input
|
|
id="mcp-tools-filter"
|
|
value={mcpToolsFilter}
|
|
onChange={(e) => setMcpToolsFilter(e.target.value)}
|
|
placeholder="e.g., tool_one, tool_two"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Comma-separated list of tool names to allow. Leave empty to expose all tools from the server.
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<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}
|
|
presetParameters={presetParameters}
|
|
onPresetParametersChange={setPresetParameters}
|
|
timeoutMs={timeoutMs}
|
|
onTimeoutMsChange={setTimeoutMs}
|
|
customMessage={customMessage}
|
|
onCustomMessageChange={setCustomMessage}
|
|
customMessageType={customMessageType}
|
|
onCustomMessageTypeChange={setCustomMessageType}
|
|
customMessageRecordingId={customMessageRecordingId}
|
|
onCustomMessageRecordingIdChange={setCustomMessageRecordingId}
|
|
recordings={recordings}
|
|
/>
|
|
)}
|
|
|
|
{isHttpApiTool && (
|
|
<HttpToolTestDialog
|
|
open={showTestDialog}
|
|
onOpenChange={setShowTestDialog}
|
|
toolUuid={toolUuid}
|
|
httpMethod={httpMethod}
|
|
url={url}
|
|
parameters={parameters}
|
|
presetParameters={presetParameters}
|
|
/>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="mt-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{saveSuccess && (
|
|
<div className="mt-4 p-4 bg-green-500/10 border border-green-500/20 rounded-lg text-green-600">
|
|
Tool saved successfully!
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-2 mt-6">
|
|
{isHttpApiTool && (
|
|
hasUnsavedHttpChanges ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="inline-flex" tabIndex={0}>
|
|
<Button type="button" variant="outline" disabled>
|
|
<FlaskConical className="w-4 h-4 mr-2" />
|
|
Test Tool
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
Save the tool before testing.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setShowTestDialog(true)}
|
|
disabled={isSaving}
|
|
>
|
|
<FlaskConical className="w-4 h-4 mr-2" />
|
|
Test Tool
|
|
</Button>
|
|
)
|
|
)}
|
|
<Button onClick={handleSave} disabled={isSaving}>
|
|
{isSaving ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="w-4 h-4 mr-2" />
|
|
Save
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Code View Dialog (only for HTTP API tools) */}
|
|
<Dialog open={showCodeDialog} onOpenChange={setShowCodeDialog}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Code Preview</DialogTitle>
|
|
<DialogDescription>
|
|
JavaScript code to make this API call
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="bg-muted rounded-lg p-4 font-mono text-sm overflow-auto max-h-96">
|
|
<pre>{getCodeSnippet()}</pre>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
</div>
|
|
);
|
|
}
|