mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
Merge remote-tracking branch 'origin/main' into feat/text-chat
This commit is contained in:
commit
129a6d700c
160 changed files with 9287 additions and 3935 deletions
|
|
@ -1,237 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet, getIntegrationsApiV1IntegrationGet } from '@/client/sdk.gen';
|
||||
import type { IntegrationResponse } from '@/client/types.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
interface GoogleSheetSelectorProps {
|
||||
accessToken: string;
|
||||
onSheetSelected: (sheetUrl: string, sheetName: string) => void;
|
||||
selectedSheetUrl?: string;
|
||||
}
|
||||
|
||||
interface PickerBuilder {
|
||||
addView: (viewId: string) => PickerBuilder;
|
||||
setOAuthToken: (token: string) => PickerBuilder;
|
||||
setDeveloperKey: (key: string) => PickerBuilder;
|
||||
setCallback: (callback: (data: { action: string; docs?: Array<{ id: string; name: string; url: string }> }) => void) => PickerBuilder;
|
||||
setTitle: (title: string) => PickerBuilder;
|
||||
build: () => { setVisible: (visible: boolean) => void };
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
gapi: {
|
||||
load: (library: string, callback: () => void) => void;
|
||||
};
|
||||
google: {
|
||||
picker: {
|
||||
PickerBuilder: new () => PickerBuilder;
|
||||
ViewId: {
|
||||
SPREADSHEETS: string;
|
||||
};
|
||||
Action: {
|
||||
PICKED: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Google API configuration
|
||||
const GOOGLE_API_KEY = process.env.NEXT_PUBLIC_GOOGLE_API_KEY || '';
|
||||
|
||||
export default function GoogleSheetSelector({ accessToken, onSheetSelected, selectedSheetUrl }: GoogleSheetSelectorProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pickerApiLoaded, setPickerApiLoaded] = useState(false);
|
||||
const [googleIntegration, setGoogleIntegration] = useState<IntegrationResponse | null>(null);
|
||||
const [selectedSheetName, setSelectedSheetName] = useState<string>('');
|
||||
const [checkingIntegration, setCheckingIntegration] = useState(true);
|
||||
|
||||
// Load Google Picker API
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://apis.google.com/js/api.js';
|
||||
script.onload = () => {
|
||||
window.gapi.load('picker', () => {
|
||||
setPickerApiLoaded(true);
|
||||
logger.info('Google Picker API loaded');
|
||||
});
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (document.body.contains(script)) {
|
||||
document.body.removeChild(script);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check for Google Sheet integration
|
||||
useEffect(() => {
|
||||
const checkGoogleIntegration = async () => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getIntegrationsApiV1IntegrationGet({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
const integrations = Array.isArray(response.data) ? response.data : [response.data];
|
||||
const googleSheet = integrations.find((i: IntegrationResponse) => i.provider === 'google-sheet');
|
||||
setGoogleIntegration(googleSheet || null);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to check Google integration:', error);
|
||||
} finally {
|
||||
setCheckingIntegration(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkGoogleIntegration();
|
||||
}, [accessToken]);
|
||||
|
||||
const fetchGoogleAccessToken = async () => {
|
||||
if (!googleIntegration) return null;
|
||||
|
||||
try {
|
||||
const response = await getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet({
|
||||
path: {
|
||||
integration_id: googleIntegration.id,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data?.access_token) {
|
||||
return response.data.access_token;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch Google access token:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const openGooglePicker = async () => {
|
||||
if (!pickerApiLoaded) {
|
||||
toast.error('Google Picker is still loading. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!GOOGLE_API_KEY) {
|
||||
toast.error('Google API Key is not configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!googleIntegration) {
|
||||
toast.error('Please connect Google Sheets in the Integrations page first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const token = await fetchGoogleAccessToken();
|
||||
if (!token) {
|
||||
toast.error('Failed to get Google access token. Please re-authorize in Integrations.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const picker = new window.google.picker.PickerBuilder()
|
||||
.addView(window.google.picker.ViewId.SPREADSHEETS)
|
||||
.setOAuthToken(token)
|
||||
.setDeveloperKey(GOOGLE_API_KEY)
|
||||
.setCallback((data: { action: string; docs?: Array<{ id: string; name: string; url: string }> }) => {
|
||||
if (data.action === window.google.picker.Action.PICKED && data.docs && data.docs.length > 0) {
|
||||
const doc = data.docs[0];
|
||||
setSelectedSheetName(doc.name);
|
||||
onSheetSelected(doc.url, doc.name);
|
||||
toast.success(`Selected: ${doc.name}`);
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.setTitle('Select a Google Sheet for your campaign')
|
||||
.build();
|
||||
|
||||
picker.setVisible(true);
|
||||
} catch (error) {
|
||||
toast.error('Error opening Google Picker');
|
||||
logger.error('Error opening Google Picker:', error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (checkingIntegration) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>Google Sheet</Label>
|
||||
<div className="text-sm text-muted-foreground">Checking Google integration...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!googleIntegration) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>Google Sheet</Label>
|
||||
<div className="p-4 border border-amber-200 bg-amber-50 rounded-md">
|
||||
<p className="text-sm text-amber-800 mb-2">
|
||||
Google Sheets integration not found
|
||||
</p>
|
||||
<p className="text-sm text-amber-700">
|
||||
Please go to the{' '}
|
||||
<a href="/integrations" className="text-amber-900 underline font-medium">
|
||||
Integrations page
|
||||
</a>
|
||||
{' '}and connect your Google account first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>Google Sheet</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={openGooglePicker}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Opening...' : 'Select Google Sheet'}
|
||||
</Button>
|
||||
{selectedSheetUrl && (
|
||||
<div className="flex-1 text-sm">
|
||||
<span className="text-muted-foreground">Selected: </span>
|
||||
<a
|
||||
href={selectedSheetUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{selectedSheetName || selectedSheetUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a Google Sheet from your connected Google account
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -30,7 +30,6 @@ import { useAuth } from '@/lib/auth';
|
|||
|
||||
import CampaignAdvancedSettings, { getTimezoneValue, type TimeSlot } from '../CampaignAdvancedSettings';
|
||||
import CsvUploadSelector from '../CsvUploadSelector';
|
||||
import GoogleSheetSelector from '../GoogleSheetSelector';
|
||||
|
||||
export default function NewCampaignPage() {
|
||||
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
||||
|
|
@ -39,12 +38,11 @@ export default function NewCampaignPage() {
|
|||
// Form state
|
||||
const [campaignName, setCampaignName] = useState('');
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string>('');
|
||||
const [sourceType, setSourceType] = useState<'google-sheet' | 'csv'>('csv');
|
||||
const [sourceType, setSourceType] = useState<'csv'>('csv');
|
||||
const [sourceId, setSourceId] = useState('');
|
||||
const [selectedFileName, setSelectedFileName] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [userAccessToken, setUserAccessToken] = useState<string>('');
|
||||
|
||||
// Workflows state
|
||||
const [workflows, setWorkflows] = useState<WorkflowSummaryResponse[]>([]);
|
||||
|
|
@ -97,7 +95,6 @@ export default function NewCampaignPage() {
|
|||
if (!user) return;
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
setUserAccessToken(accessToken);
|
||||
const response = await getWorkflowsSummaryApiV1WorkflowSummaryGet({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
|
|
@ -342,12 +339,6 @@ export default function NewCampaignPage() {
|
|||
router.push('/campaigns');
|
||||
};
|
||||
|
||||
// Handle sheet selection
|
||||
const handleSheetSelected = (sheetUrl: string) => {
|
||||
setSourceId(sheetUrl);
|
||||
setCreateError(null);
|
||||
};
|
||||
|
||||
// Handle CSV file upload
|
||||
const handleFileUploaded = (fileKey: string, fileName: string) => {
|
||||
setSourceId(fileKey);
|
||||
|
|
@ -481,7 +472,7 @@ export default function NewCampaignPage() {
|
|||
<Select
|
||||
value={sourceType}
|
||||
onValueChange={(value) => {
|
||||
setSourceType(value as 'google-sheet' | 'csv');
|
||||
setSourceType(value as 'csv');
|
||||
setSourceId('');
|
||||
setSelectedFileName('');
|
||||
}}
|
||||
|
|
@ -491,7 +482,6 @@ export default function NewCampaignPage() {
|
|||
<SelectValue placeholder="Select source type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* <SelectItem value="google-sheet">Google Sheet</SelectItem> */}
|
||||
<SelectItem value="csv">CSV File</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
@ -500,18 +490,10 @@ export default function NewCampaignPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{sourceType === 'google-sheet' ? (
|
||||
<GoogleSheetSelector
|
||||
accessToken={userAccessToken}
|
||||
onSheetSelected={handleSheetSelected}
|
||||
selectedSheetUrl={sourceId}
|
||||
/>
|
||||
) : (
|
||||
<CsvUploadSelector
|
||||
onFileUploaded={handleFileUploaded}
|
||||
selectedFileName={selectedFileName}
|
||||
/>
|
||||
)}
|
||||
<CsvUploadSelector
|
||||
onFileUploaded={handleFileUploaded}
|
||||
selectedFileName={selectedFileName}
|
||||
/>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<Collapsible
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import Nango from '@nangohq/frontend';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { createSessionApiV1IntegrationSessionPost } from "@/client/sdk.gen";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
|
||||
export default function CreateIntegrationButton() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { user, getAccessToken } = useAuth();
|
||||
|
||||
const handleCreateIntegration = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (!user) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
// Fetch session details from our API
|
||||
const sessionResponse = await createSessionApiV1IntegrationSessionPost({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!sessionResponse.data?.session_token) {
|
||||
throw new Error('Failed to get session token');
|
||||
}
|
||||
|
||||
// Initialize Nango and open connect UI
|
||||
const nango = new Nango();
|
||||
const connect = nango.openConnectUI({
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'close') {
|
||||
// Handle modal closed
|
||||
setIsLoading(false);
|
||||
logger.info('Nango connect UI closed');
|
||||
} else if (event.type === 'connect') {
|
||||
// Handle auth flow successful
|
||||
setIsLoading(false);
|
||||
logger.info('Integration connected successfully');
|
||||
// Refresh the page to show new integrations
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Set the session token to initialize the connect UI
|
||||
connect.setSessionToken(sessionResponse.data.session_token);
|
||||
|
||||
} catch (err) {
|
||||
logger.error(`Error creating integration: ${err}`);
|
||||
setIsLoading(false);
|
||||
// You might want to show a toast notification here
|
||||
alert('Failed to create integration. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleCreateIntegration} disabled={isLoading}>
|
||||
{isLoading ? 'Loading...' : 'Create Integration'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,390 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet } from '@/client/sdk.gen';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
interface Email {
|
||||
id: string;
|
||||
threadId: string;
|
||||
subject: string;
|
||||
from: string;
|
||||
snippet: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface EmailDetail {
|
||||
id: string;
|
||||
threadId: string;
|
||||
subject: string;
|
||||
from: string;
|
||||
to: string;
|
||||
date: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface GmailHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface GmailPayloadPart {
|
||||
mimeType: string;
|
||||
body: {
|
||||
data?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function GmailSearchPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const integrationId = parseInt(params.id as string);
|
||||
const { getAccessToken, redirectToLogin } = useAuth();
|
||||
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [emails, setEmails] = useState<Email[]>([]);
|
||||
const [selectedEmail, setSelectedEmail] = useState<EmailDetail | null>(null);
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sendingReply, setSendingReply] = useState(false);
|
||||
|
||||
const fetchAccessToken = useCallback(async () => {
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
if (!token) {
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet({
|
||||
path: { integration_id: integrationId },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (response.data?.access_token) {
|
||||
setAccessToken(response.data.access_token);
|
||||
} else {
|
||||
setError('Failed to get access token');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error fetching access token:', err);
|
||||
setError('Failed to fetch access token. Please try again.');
|
||||
}
|
||||
}, [getAccessToken, redirectToLogin, integrationId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccessToken();
|
||||
}, [fetchAccessToken]);
|
||||
|
||||
const searchEmails = async () => {
|
||||
if (!accessToken || !searchQuery.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setEmails([]);
|
||||
setSelectedEmail(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(searchQuery)}&maxResults=20`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gmail API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.messages || data.messages.length === 0) {
|
||||
setEmails([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch details for each message
|
||||
const emailPromises = data.messages.map(async (msg: { id: string }) => {
|
||||
const msgResponse = await fetch(
|
||||
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
return msgResponse.json();
|
||||
});
|
||||
|
||||
const emailDetails = await Promise.all(emailPromises);
|
||||
|
||||
const formattedEmails: Email[] = emailDetails.map((email) => {
|
||||
const headers = email.payload.headers as GmailHeader[];
|
||||
const subject = headers.find((h) => h.name === 'Subject')?.value || 'No Subject';
|
||||
const from = headers.find((h) => h.name === 'From')?.value || 'Unknown';
|
||||
const date = headers.find((h) => h.name === 'Date')?.value || '';
|
||||
|
||||
return {
|
||||
id: email.id,
|
||||
threadId: email.threadId,
|
||||
subject,
|
||||
from,
|
||||
snippet: email.snippet || '',
|
||||
date,
|
||||
};
|
||||
});
|
||||
|
||||
setEmails(formattedEmails);
|
||||
} catch (err) {
|
||||
logger.error('Error searching emails:', err);
|
||||
setError('Failed to search emails. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadEmailDetail = async (emailId: string) => {
|
||||
if (!accessToken) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${emailId}?format=full`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gmail API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const email = await response.json();
|
||||
const headers = email.payload.headers as GmailHeader[];
|
||||
|
||||
const subject = headers.find((h) => h.name === 'Subject')?.value || 'No Subject';
|
||||
const from = headers.find((h) => h.name === 'From')?.value || 'Unknown';
|
||||
const to = headers.find((h) => h.name === 'To')?.value || 'Unknown';
|
||||
const date = headers.find((h) => h.name === 'Date')?.value || '';
|
||||
|
||||
// Extract email body
|
||||
let body = '';
|
||||
if (email.payload.body.data) {
|
||||
body = atob(email.payload.body.data.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
} else if (email.payload.parts) {
|
||||
const parts = email.payload.parts as GmailPayloadPart[];
|
||||
const textPart = parts.find((part) => part.mimeType === 'text/plain');
|
||||
if (textPart && textPart.body.data) {
|
||||
body = atob(textPart.body.data.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedEmail({
|
||||
id: email.id,
|
||||
threadId: email.threadId,
|
||||
subject,
|
||||
from,
|
||||
to,
|
||||
date,
|
||||
body,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Error loading email detail:', err);
|
||||
setError('Failed to load email details. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendReply = async () => {
|
||||
if (!accessToken || !selectedEmail || !replyText.trim()) return;
|
||||
|
||||
setSendingReply(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Create the email message
|
||||
const to = selectedEmail.from.match(/<(.+)>/)?.[1] || selectedEmail.from;
|
||||
const subject = selectedEmail.subject.startsWith('Re:')
|
||||
? selectedEmail.subject
|
||||
: `Re: ${selectedEmail.subject}`;
|
||||
|
||||
const messageParts = [
|
||||
`To: ${to}`,
|
||||
`Subject: ${subject}`,
|
||||
`In-Reply-To: ${selectedEmail.id}`,
|
||||
`References: ${selectedEmail.id}`,
|
||||
'',
|
||||
replyText,
|
||||
];
|
||||
|
||||
const message = messageParts.join('\n');
|
||||
const encodedMessage = btoa(message)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
const response = await fetch(
|
||||
`https://gmail.googleapis.com/gmail/v1/users/me/messages/send`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
raw: encodedMessage,
|
||||
threadId: selectedEmail.threadId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gmail API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
alert('Reply sent successfully!');
|
||||
setReplyText('');
|
||||
} catch (err) {
|
||||
logger.error('Error sending reply:', err);
|
||||
setError('Failed to send reply. Please try again.');
|
||||
} finally {
|
||||
setSendingReply(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-blue-600 hover:text-blue-800 mb-4"
|
||||
>
|
||||
← Back to Integrations
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold">Gmail Search</h1>
|
||||
</div>
|
||||
|
||||
{/* Search Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && searchEmails()}
|
||||
placeholder="Search emails (e.g., from:user@example.com, subject:meeting)"
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={!accessToken || loading}
|
||||
/>
|
||||
<button
|
||||
onClick={searchEmails}
|
||||
disabled={!accessToken || loading || !searchQuery.trim()}
|
||||
className="px-6 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 text-red-700 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Email List */}
|
||||
<div className="bg-card border border-border rounded-lg">
|
||||
<div className="p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">Search Results</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border max-h-[600px] overflow-y-auto">
|
||||
{emails.length === 0 && !loading && (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
{searchQuery ? 'No emails found' : 'Enter a search query to find emails'}
|
||||
</div>
|
||||
)}
|
||||
{emails.map((email) => (
|
||||
<div
|
||||
key={email.id}
|
||||
onClick={() => loadEmailDetail(email.id)}
|
||||
className={`p-4 cursor-pointer hover:bg-muted/50 ${
|
||||
selectedEmail?.id === email.id ? 'bg-accent' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm mb-1">{email.subject}</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">{email.from}</div>
|
||||
<div className="text-xs text-muted-foreground">{email.snippet}</div>
|
||||
<div className="text-xs text-muted-foreground/70 mt-1">{email.date}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Detail and Reply */}
|
||||
<div className="bg-card border border-border rounded-lg">
|
||||
<div className="p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">Email Details</h2>
|
||||
</div>
|
||||
{selectedEmail ? (
|
||||
<div className="p-4">
|
||||
<div className="mb-4 pb-4 border-b border-border">
|
||||
<div className="mb-2">
|
||||
<span className="font-medium">Subject:</span> {selectedEmail.subject}
|
||||
</div>
|
||||
<div className="mb-2 text-sm">
|
||||
<span className="font-medium">From:</span> {selectedEmail.from}
|
||||
</div>
|
||||
<div className="mb-2 text-sm">
|
||||
<span className="font-medium">To:</span> {selectedEmail.to}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Date:</span> {selectedEmail.date}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 p-4 bg-muted rounded max-h-[200px] overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap text-sm">{selectedEmail.body}</pre>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<h3 className="font-medium mb-2">Reply</h3>
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="Type your reply here..."
|
||||
className="w-full px-3 py-2 border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring mb-2 bg-background"
|
||||
rows={6}
|
||||
disabled={sendingReply}
|
||||
/>
|
||||
<button
|
||||
onClick={sendReply}
|
||||
disabled={sendingReply || !replyText.trim()}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed"
|
||||
>
|
||||
{sendingReply ? 'Sending...' : 'Send Reply'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
Select an email to view details and reply
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getIntegrationsApiV1IntegrationGet } from "@/client/sdk.gen";
|
||||
import type { IntegrationResponse } from '@/client/types.gen';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
import CreateIntegrationButton from "./CreateIntegrationButton";
|
||||
|
||||
function IntegrationsLoading() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="h-8 w-48 bg-muted rounded"></div>
|
||||
<div className="h-10 w-32 bg-muted rounded"></div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full bg-card border border-border">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Integration ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Channel
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Created At
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-card divide-y divide-border">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 w-32 bg-muted rounded"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 w-24 bg-muted rounded"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 w-24 bg-muted rounded"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 w-24 bg-muted rounded"></div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const hasFetched = useRef(false);
|
||||
const [integrations, setIntegrations] = useState<IntegrationResponse[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading || !user || hasFetched.current) return;
|
||||
hasFetched.current = true;
|
||||
|
||||
const fetchIntegrations = async () => {
|
||||
try {
|
||||
const response = await getIntegrationsApiV1IntegrationGet({});
|
||||
|
||||
const integrationData = response.data ? (Array.isArray(response.data) ? response.data : [response.data]) : [];
|
||||
const sorted = [...integrationData].sort((a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
setIntegrations(sorted);
|
||||
} catch (err) {
|
||||
logger.error(`Error fetching integrations: ${err}`);
|
||||
setError('Failed to load Integrations. Please Try Again Later.');
|
||||
}
|
||||
};
|
||||
|
||||
fetchIntegrations();
|
||||
}, [authLoading, user]);
|
||||
|
||||
if (authLoading || (integrations === null && !error)) {
|
||||
return <IntegrationsLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Your Integrations</h1>
|
||||
<CreateIntegrationButton />
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="text-red-500 text-center py-8">{error}</div>
|
||||
) : !integrations || integrations.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No integrations found. Create your first integration to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full bg-card border border-border">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Provider
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Channel
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Created At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-card divide-y divide-border">
|
||||
{integrations.map((integration) => (
|
||||
<tr key={integration.id} className="hover:bg-muted/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
{integration.provider}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{integration.provider === 'slack' && integration.provider_data ? (integration.provider_data.channel as string) || '-' : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{integration.action}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{new Date(integration.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{integration.provider === 'google-mail' && (
|
||||
<Link
|
||||
href={`/integrations/${integration.id}/gmail`}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Search
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -181,8 +181,7 @@ export default function TelephonyConfigurationsPage() {
|
|||
? "1 Telnyx configuration is"
|
||||
: `${telnyxMissingWebhookPublicKeyCount} Telnyx configurations are`}{" "}
|
||||
missing a webhook public key. Without it, Telnyx call status
|
||||
updates and inbound calls will be rejected starting{" "}
|
||||
<span className="font-medium">15 May 2026</span>. Copy your
|
||||
updates and inbound calls are being rejected. Copy your
|
||||
public key from{" "}
|
||||
<span className="whitespace-nowrap">
|
||||
Mission Control Portal → Keys & Credentials → Public Key
|
||||
|
|
|
|||
|
|
@ -9,16 +9,25 @@ import {
|
|||
listRecordingsApiV1WorkflowRecordingsGet,
|
||||
updateToolApiV1ToolsToolUuidPut,
|
||||
} from "@/client/sdk.gen";
|
||||
import type { RecordingResponseSchema, ToolResponse, TransferCallConfig as APITransferCallConfig } from "@/client/types.gen";
|
||||
import type { EndCallConfig } from "@/client/types.gen";
|
||||
import type {
|
||||
EndCallConfig,
|
||||
HttpApiToolDefinition,
|
||||
RecordingResponseSchema,
|
||||
ToolResponse,
|
||||
TransferCallConfig as APITransferCallConfig,
|
||||
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,
|
||||
|
|
@ -26,35 +35,33 @@ import {
|
|||
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 { TOOL_DOCUMENTATION_URLS } from "@/constants/documentation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
import {
|
||||
createMcpDefinition,
|
||||
DEFAULT_END_CALL_REASON_DESCRIPTION,
|
||||
type EndCallMessageType,
|
||||
getCategoryConfig,
|
||||
getToolTypeLabel,
|
||||
MCP_URL_PATTERN,
|
||||
renderToolIcon,
|
||||
type ToolCategory,
|
||||
} from "../config";
|
||||
import { BuiltinToolConfig, EndCallToolConfig, HttpApiToolConfig, TransferCallToolConfig } from "./components";
|
||||
|
||||
// Extended HttpApiConfig with parameters (until client types are regenerated)
|
||||
interface HttpApiConfigWithParams {
|
||||
method?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
credential_uuid?: string;
|
||||
parameters?: ToolParameter[];
|
||||
preset_parameters?: Array<{
|
||||
name?: string;
|
||||
type?: PresetToolParameter["type"];
|
||||
value_template?: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
timeout_ms?: number;
|
||||
customMessage?: string;
|
||||
function normalizeParameterType(value: string | null | undefined): ParameterType {
|
||||
switch (value) {
|
||||
case "number":
|
||||
case "boolean":
|
||||
return value;
|
||||
default:
|
||||
return "string";
|
||||
}
|
||||
}
|
||||
|
||||
export default function ToolDetailPage() {
|
||||
|
|
@ -108,6 +115,11 @@ export default function ToolDetailPage() {
|
|||
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[]>([]);
|
||||
|
||||
|
|
@ -155,8 +167,7 @@ export default function ToolDetailPage() {
|
|||
if (config) {
|
||||
setEndCallMessageType(config.messageType || "none");
|
||||
setCustomMessage(config.customMessage || "");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setAudioRecordingId((config as any).audioRecordingId || "");
|
||||
setAudioRecordingId(config.audioRecordingId || "");
|
||||
setEndCallReason(config.endCallReason ?? false);
|
||||
setEndCallReasonDescription(config.endCallReasonDescription || "");
|
||||
} else {
|
||||
|
|
@ -173,8 +184,7 @@ export default function ToolDetailPage() {
|
|||
setTransferDestination(config.destination || "");
|
||||
setTransferMessageType(config.messageType || "none");
|
||||
setCustomMessage(config.customMessage || "");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setTransferAudioRecordingId((config as any).audioRecordingId || "");
|
||||
setTransferAudioRecordingId(config.audioRecordingId || "");
|
||||
setTransferTimeout(config.timeout ?? 30);
|
||||
} else {
|
||||
setTransferDestination("");
|
||||
|
|
@ -183,19 +193,35 @@ export default function ToolDetailPage() {
|
|||
setTransferAudioRecordingId("");
|
||||
setTransferTimeout(30);
|
||||
}
|
||||
} 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 HttpApiConfigWithParams | undefined;
|
||||
const config = tool.definition?.config as HttpApiToolDefinition["config"] | undefined;
|
||||
if (config) {
|
||||
setHttpMethod((config.method as HttpMethod) || "POST");
|
||||
setUrl(config.url || "");
|
||||
setCredentialUuid(config.credential_uuid || "");
|
||||
setTimeoutMs(config.timeout_ms || 5000);
|
||||
setCustomMessage(config.customMessage || "");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setCustomMessageType((config as any).customMessageType || "text");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setCustomMessageRecordingId((config as any).customMessageRecordingId || "");
|
||||
setCustomMessageType(config.customMessageType || "text");
|
||||
setCustomMessageRecordingId(config.customMessageRecordingId || "");
|
||||
|
||||
// Convert headers object to array
|
||||
if (config.headers) {
|
||||
|
|
@ -212,9 +238,9 @@ export default function ToolDetailPage() {
|
|||
// Load parameters
|
||||
if (config.parameters && Array.isArray(config.parameters)) {
|
||||
setParameters(
|
||||
config.parameters.map((p: ToolParameter) => ({
|
||||
config.parameters.map((p) => ({
|
||||
name: p.name || "",
|
||||
type: p.type || "string",
|
||||
type: normalizeParameterType(p.type),
|
||||
description: p.description || "",
|
||||
required: p.required ?? true,
|
||||
}))
|
||||
|
|
@ -227,7 +253,7 @@ export default function ToolDetailPage() {
|
|||
setPresetParameters(
|
||||
config.preset_parameters.map((p) => ({
|
||||
name: p.name || "",
|
||||
type: p.type || "string",
|
||||
type: normalizeParameterType(p.type),
|
||||
valueTemplate: p.value_template || "",
|
||||
required: p.required ?? true,
|
||||
}))
|
||||
|
|
@ -275,6 +301,16 @@ export default function ToolDetailPage() {
|
|||
setError("Please enter a valid phone number (E.164 format) or SIP endpoint (e.g., PJSIP/1234)");
|
||||
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);
|
||||
|
|
@ -305,7 +341,7 @@ export default function ToolDetailPage() {
|
|||
setSaveSuccess(false);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
let requestBody;
|
||||
let requestBody: UpdateToolRequest;
|
||||
|
||||
if (tool.category === "calculator") {
|
||||
// Built-in tool - only name/description, no config
|
||||
|
|
@ -351,6 +387,12 @@ export default function ToolDetailPage() {
|
|||
},
|
||||
},
|
||||
};
|
||||
} 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> = {};
|
||||
|
|
@ -399,8 +441,7 @@ export default function ToolDetailPage() {
|
|||
|
||||
const response = await updateToolApiV1ToolsToolUuidPut({
|
||||
path: { tool_uuid: toolUuid },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
body: requestBody as any,
|
||||
body: requestBody,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
|
|
@ -510,6 +551,7 @@ const data = await response.json();`;
|
|||
const isEndCallTool = tool.category === "end_call";
|
||||
const isTransferCallTool = tool.category === "transfer_call";
|
||||
const isBuiltinTool = tool.category === "calculator";
|
||||
const isMcpTool = tool.category === "mcp";
|
||||
const categoryConfig = getCategoryConfig(tool.category as ToolCategory);
|
||||
|
||||
return (
|
||||
|
|
@ -545,7 +587,7 @@ const data = await response.json();`;
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isEndCallTool && !isTransferCallTool && !isBuiltinTool && (
|
||||
{!isEndCallTool && !isTransferCallTool && !isBuiltinTool && !isMcpTool && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCodeDialog(true)}
|
||||
|
|
@ -613,6 +655,79 @@ const data = await response.json();`;
|
|||
timeout={transferTimeout}
|
||||
onTimeoutChange={setTransferTimeout}
|
||||
/>
|
||||
) : 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}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@ import type {
|
|||
EndCallConfig,
|
||||
EndCallToolDefinition,
|
||||
HttpApiToolDefinition,
|
||||
McpToolDefinition,
|
||||
TransferCallConfig,
|
||||
TransferCallToolDefinition,
|
||||
} from "@/client/types.gen";
|
||||
|
||||
export type ToolCategory = "http_api" | "end_call" | "transfer_call" | "calculator" | "native" | "integration";
|
||||
export type ToolCategory = "http_api" | "end_call" | "transfer_call" | "calculator" | "native" | "integration" | "mcp";
|
||||
|
||||
export type EndCallMessageType = "none" | "custom" | "audio";
|
||||
|
||||
|
|
@ -75,6 +76,14 @@ export const TOOL_CATEGORIES: ToolCategoryConfig[] = [
|
|||
description: "Perform arithmetic calculations (supports +, -, *, /, **, %, and parentheses)",
|
||||
},
|
||||
},
|
||||
{
|
||||
value: "mcp",
|
||||
label: "MCP Server",
|
||||
description: "Connect a customer MCP server; its tools become available to the agent",
|
||||
icon: Puzzle,
|
||||
iconName: "puzzle",
|
||||
iconColor: "#8B5CF6",
|
||||
},
|
||||
{
|
||||
value: "native",
|
||||
label: "Native (Coming Soon)",
|
||||
|
|
@ -128,6 +137,8 @@ export function getToolTypeLabel(category: string): string {
|
|||
return "Native Tool";
|
||||
case "integration":
|
||||
return "Integration Tool";
|
||||
case "mcp":
|
||||
return "MCP Server Tool";
|
||||
default:
|
||||
return "Tool";
|
||||
}
|
||||
|
|
@ -149,7 +160,12 @@ export const DEFAULT_TRANSFER_CALL_CONFIG: TransferCallConfig = {
|
|||
timeout: 30,
|
||||
};
|
||||
|
||||
export type ToolDefinition = HttpApiToolDefinition | EndCallToolDefinition | TransferCallToolDefinition | CalculatorToolDefinition;
|
||||
export type ToolDefinition =
|
||||
| HttpApiToolDefinition
|
||||
| EndCallToolDefinition
|
||||
| TransferCallToolDefinition
|
||||
| CalculatorToolDefinition
|
||||
| McpToolDefinition;
|
||||
|
||||
export function createEndCallDefinition(config: EndCallConfig): EndCallToolDefinition {
|
||||
return {
|
||||
|
|
@ -185,6 +201,28 @@ export function createCalculatorDefinition(): CalculatorToolDefinition {
|
|||
};
|
||||
}
|
||||
|
||||
export const MCP_URL_PATTERN = /^https?:\/\//i;
|
||||
|
||||
export function createMcpDefinition(
|
||||
url: string,
|
||||
credentialUuid: string,
|
||||
toolsFilterCsv: string,
|
||||
): McpToolDefinition {
|
||||
return {
|
||||
schema_version: 1,
|
||||
type: "mcp" as const,
|
||||
config: {
|
||||
transport: "streamable_http" as const,
|
||||
url: url.trim(),
|
||||
credential_uuid: credentialUuid || null,
|
||||
tools_filter: toolsFilterCsv
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createToolDefinition(category: ToolCategory): ToolDefinition {
|
||||
switch (category) {
|
||||
case "end_call":
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import {
|
|||
listToolsApiV1ToolsGet,
|
||||
unarchiveToolApiV1ToolsToolUuidUnarchivePost,
|
||||
} from "@/client/sdk.gen";
|
||||
import type { ToolResponse } from "@/client/types.gen";
|
||||
import type { CreateToolRequest, ToolResponse } from "@/client/types.gen";
|
||||
import { CredentialSelector } from "@/components/http";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -41,8 +42,10 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
import {
|
||||
createMcpDefinition,
|
||||
createToolDefinition,
|
||||
getCategoryConfig,
|
||||
MCP_URL_PATTERN,
|
||||
renderToolIcon,
|
||||
TOOL_CATEGORIES,
|
||||
type ToolCategory,
|
||||
|
|
@ -63,6 +66,11 @@ export default function ToolsPage() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
// MCP-specific create dialog state
|
||||
const [mcpUrl, setMcpUrl] = useState("");
|
||||
const [mcpCredentialUuid, setMcpCredentialUuid] = useState("");
|
||||
const [mcpToolsFilter, setMcpToolsFilter] = useState("");
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
|
|
@ -108,21 +116,38 @@ export default function ToolsPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (newToolCategory === "mcp" && !mcpUrl.trim()) {
|
||||
setCreateError("Please enter the MCP server URL");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newToolCategory === "mcp" && !MCP_URL_PATTERN.test(mcpUrl.trim())) {
|
||||
setCreateError("MCP server URL must start with http:// or https://");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCreating(true);
|
||||
setCreateError(null);
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const categoryConfig = getCategoryConfig(newToolCategory);
|
||||
|
||||
const definition = newToolCategory === "mcp"
|
||||
? createMcpDefinition(mcpUrl, mcpCredentialUuid, mcpToolsFilter)
|
||||
: createToolDefinition(newToolCategory);
|
||||
|
||||
const requestBody: CreateToolRequest = {
|
||||
name: newToolName,
|
||||
description: newToolDescription || undefined,
|
||||
category: newToolCategory,
|
||||
icon: categoryConfig?.iconName || "globe",
|
||||
icon_color: categoryConfig?.iconColor || "#3B82F6",
|
||||
definition,
|
||||
};
|
||||
|
||||
const response = await createToolApiV1ToolsPost({
|
||||
body: {
|
||||
name: newToolName,
|
||||
description: newToolDescription || undefined,
|
||||
category: newToolCategory,
|
||||
icon: categoryConfig?.iconName || "globe",
|
||||
icon_color: categoryConfig?.iconColor || "#3B82F6",
|
||||
definition: createToolDefinition(newToolCategory),
|
||||
},
|
||||
body: requestBody,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
|
|
@ -139,6 +164,9 @@ export default function ToolsPage() {
|
|||
setNewToolName("");
|
||||
setNewToolDescription("");
|
||||
setNewToolCategory("http_api");
|
||||
setMcpUrl("");
|
||||
setMcpCredentialUuid("");
|
||||
setMcpToolsFilter("");
|
||||
// Navigate to the new tool's detail page
|
||||
router.push(`/tools/${response.data.tool_uuid}`);
|
||||
}
|
||||
|
|
@ -233,6 +261,8 @@ export default function ToolsPage() {
|
|||
return <Badge variant="secondary">Native</Badge>;
|
||||
case "integration":
|
||||
return <Badge variant="outline">Integration</Badge>;
|
||||
case "mcp":
|
||||
return <Badge variant="outline">MCP</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{category}</Badge>;
|
||||
}
|
||||
|
|
@ -465,7 +495,14 @@ export default function ToolsPage() {
|
|||
{/* Create Tool Dialog */}
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={(open) => {
|
||||
setIsCreateDialogOpen(open);
|
||||
if (open) setCreateError(null);
|
||||
if (open) {
|
||||
setCreateError(null);
|
||||
} else {
|
||||
// Reset MCP fields when dialog is closed without creating
|
||||
setMcpUrl("");
|
||||
setMcpCredentialUuid("");
|
||||
setMcpToolsFilter("");
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
|
@ -482,6 +519,7 @@ export default function ToolsPage() {
|
|||
onValueChange={(v) => {
|
||||
const category = v as ToolCategory;
|
||||
setNewToolCategory(category);
|
||||
setCreateError(null);
|
||||
const categoryConfig = getCategoryConfig(category);
|
||||
if (categoryConfig?.autoFill) {
|
||||
setNewToolName(categoryConfig.autoFill.name);
|
||||
|
|
@ -532,6 +570,46 @@ export default function ToolsPage() {
|
|||
placeholder="What does this tool do?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{newToolCategory === "mcp" && (
|
||||
<>
|
||||
<div className="grid gap-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="grid gap-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="grid gap-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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{createError && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive text-sm">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
|
||||
import { createWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPost, getWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGet, listDocumentsApiV1KnowledgeBaseDocumentsGet, listRecordingsApiV1WorkflowRecordingsGet, listToolsApiV1ToolsGet } from '@/client';
|
||||
import type { DocumentResponseSchema, RecordingResponseSchema, ToolResponse } from '@/client/types.gen';
|
||||
import { useNodeSpecs } from "@/components/flow/renderer";
|
||||
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet';
|
||||
|
|
@ -29,14 +30,6 @@ import { WorkflowProvider } from "./contexts/WorkflowContext";
|
|||
import { useWorkflowState } from "./hooks/useWorkflowState";
|
||||
import { layoutNodes } from './utils/layoutNodes';
|
||||
|
||||
// Single generic component for every node type. The spec catalog
|
||||
// (`/api/v1/node-types`) drives form rendering, canvas preview, handles,
|
||||
// and defaults. Adding a new node type means adding a Python NodeSpec —
|
||||
// no React changes required.
|
||||
const nodeTypes = Object.fromEntries(
|
||||
Object.values(NodeType).map((t) => [t, GenericNode]),
|
||||
);
|
||||
|
||||
const edgeTypes = {
|
||||
custom: CustomEdge,
|
||||
};
|
||||
|
|
@ -65,6 +58,7 @@ interface RenderWorkflowProps {
|
|||
|
||||
function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, initialVersionNumber, initialVersionStatus, user }: RenderWorkflowProps) {
|
||||
const router = useRouter();
|
||||
const { specs } = useNodeSpecs();
|
||||
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
|
||||
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
|
||||
const [isTesterRailOpen, setIsTesterRailOpen] = useState(true);
|
||||
|
|
@ -111,6 +105,22 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
|
|||
user,
|
||||
});
|
||||
|
||||
// Single generic component for every node type. Seed with core node types
|
||||
// so the initial render is stable before specs load, then merge in any
|
||||
// spec-defined or already-present node types so plugin integrations like
|
||||
// Tuner render without extra React registrations.
|
||||
const nodeTypes = useMemo(() => {
|
||||
const typeNames = new Set<string>([
|
||||
...Object.values(NodeType),
|
||||
...specs.map((spec) => spec.name),
|
||||
...nodes.map((node) => node.type),
|
||||
...(initialFlow?.nodes ?? []).map((node) => node.type),
|
||||
]);
|
||||
return Object.fromEntries(
|
||||
Array.from(typeNames).map((typeName) => [typeName, GenericNode]),
|
||||
);
|
||||
}, [initialFlow?.nodes, nodes, specs]);
|
||||
|
||||
// Derive hasDraft from the current version status
|
||||
const hasDraft = currentVersionStatus === "draft";
|
||||
|
||||
|
|
@ -350,14 +360,33 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
|
|||
await saveWorkflowConfigurations(workflowConfigurations, newName);
|
||||
}, [saveWorkflowConfigurations, workflowConfigurations]);
|
||||
|
||||
const updateTool = useCallback(
|
||||
(toolUuid: string, updater: (tool: ToolResponse) => ToolResponse) => {
|
||||
setTools((prev) =>
|
||||
prev?.map((tool) =>
|
||||
tool.tool_uuid === toolUuid ? updater(tool) : tool,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Memoize the context value to prevent unnecessary re-renders
|
||||
const workflowContextValue = useMemo(() => ({
|
||||
saveWorkflow: guardedSaveWorkflow,
|
||||
documents,
|
||||
tools,
|
||||
updateTool,
|
||||
recordings,
|
||||
readOnly: isViewingHistoricalVersion,
|
||||
}), [guardedSaveWorkflow, documents, tools, recordings, isViewingHistoricalVersion]);
|
||||
}), [
|
||||
guardedSaveWorkflow,
|
||||
documents,
|
||||
tools,
|
||||
updateTool,
|
||||
recordings,
|
||||
isViewingHistoricalVersion,
|
||||
]);
|
||||
|
||||
return (
|
||||
<WorkflowProvider value={workflowContextValue}>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ interface WorkflowContextType {
|
|||
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
|
||||
documents?: DocumentResponseSchema[];
|
||||
tools?: ToolResponse[];
|
||||
updateTool?: (
|
||||
toolUuid: string,
|
||||
updater: (tool: ToolResponse) => ToolResponse,
|
||||
) => void;
|
||||
recordings?: RecordingResponseSchema[];
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,23 @@ const NODE_HEIGHT = 120;
|
|||
const VERTICAL_SPACING = 150; // Vertical spacing between stacked nodes
|
||||
const SECTION_HORIZONTAL_GAP = 500; // Horizontal gap between sections
|
||||
|
||||
const WORKFLOW_NODE_TYPES = new Set([
|
||||
NodeType.START_CALL,
|
||||
NodeType.AGENT_NODE,
|
||||
NodeType.END_CALL,
|
||||
]);
|
||||
|
||||
function isRightRailNode(type: string): boolean {
|
||||
if (type === NodeType.WEBHOOK || type === NodeType.QA) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
!WORKFLOW_NODE_TYPES.has(type as NodeType) &&
|
||||
type !== NodeType.TRIGGER &&
|
||||
type !== NodeType.GLOBAL_NODE
|
||||
);
|
||||
}
|
||||
|
||||
export const layoutNodes = (
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[],
|
||||
|
|
@ -17,14 +34,9 @@ export const layoutNodes = (
|
|||
) => {
|
||||
// Separate nodes by type
|
||||
const triggerNodes = nodes.filter(n => n.type === NodeType.TRIGGER);
|
||||
const webhookNodes = nodes.filter(n => n.type === NodeType.WEBHOOK);
|
||||
const qaNodes = nodes.filter(n => n.type === NodeType.QA);
|
||||
const globalNodes = nodes.filter(n => n.type === NodeType.GLOBAL_NODE);
|
||||
const workflowNodes = nodes.filter(n =>
|
||||
n.type === NodeType.START_CALL ||
|
||||
n.type === NodeType.AGENT_NODE ||
|
||||
n.type === NodeType.END_CALL
|
||||
);
|
||||
const workflowNodes = nodes.filter(n => WORKFLOW_NODE_TYPES.has(n.type as NodeType));
|
||||
const rightSideNodes = nodes.filter(n => isRightRailNode(n.type));
|
||||
|
||||
// If no workflow nodes, just return original nodes
|
||||
if (workflowNodes.length === 0) {
|
||||
|
|
@ -145,30 +157,19 @@ export const layoutNodes = (
|
|||
};
|
||||
});
|
||||
|
||||
// Position webhook nodes to the right of the workflow
|
||||
const webhookNodesX = workflowMaxX + SECTION_HORIZONTAL_GAP;
|
||||
const positionedWebhookNodes = webhookNodes.map((node, index) => {
|
||||
const totalHeight = webhookNodes.length * NODE_HEIGHT + (webhookNodes.length - 1) * VERTICAL_SPACING;
|
||||
const startY = workflowCenterY - totalHeight / 2;
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: webhookNodesX,
|
||||
y: startY + index * (NODE_HEIGHT + VERTICAL_SPACING)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Position QA nodes below webhook nodes on the right side
|
||||
const qaStartY = webhookNodes.length > 0
|
||||
? workflowCenterY - (webhookNodes.length * NODE_HEIGHT + (webhookNodes.length - 1) * VERTICAL_SPACING) / 2
|
||||
+ webhookNodes.length * (NODE_HEIGHT + VERTICAL_SPACING) + VERTICAL_SPACING
|
||||
const rightSideStartY = rightSideNodes.length > 0
|
||||
? workflowCenterY - (
|
||||
rightSideNodes.length * NODE_HEIGHT +
|
||||
(rightSideNodes.length - 1) * VERTICAL_SPACING
|
||||
) / 2
|
||||
: workflowCenterY;
|
||||
const positionedQaNodes = qaNodes.map((node, index) => ({
|
||||
|
||||
const positionedRightSideNodes = rightSideNodes.map((node, index) => ({
|
||||
...node,
|
||||
position: {
|
||||
x: webhookNodesX,
|
||||
y: qaStartY + index * (NODE_HEIGHT + VERTICAL_SPACING)
|
||||
y: rightSideStartY + index * (NODE_HEIGHT + VERTICAL_SPACING)
|
||||
}
|
||||
}));
|
||||
|
||||
|
|
@ -177,8 +178,7 @@ export const layoutNodes = (
|
|||
...positionedTriggerNodes,
|
||||
...positionedGlobalNodes,
|
||||
...positionedWorkflowNodes,
|
||||
...positionedWebhookNodes,
|
||||
...positionedQaNodes
|
||||
...positionedRightSideNodes,
|
||||
];
|
||||
|
||||
// Create a map for quick lookup
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -136,28 +136,6 @@ export type AriConfigurationResponse = {
|
|||
from_numbers: Array<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* AccessTokenResponse
|
||||
*/
|
||||
export type AccessTokenResponse = {
|
||||
/**
|
||||
* Access Token
|
||||
*/
|
||||
access_token: string | null;
|
||||
/**
|
||||
* Refresh Token
|
||||
*/
|
||||
refresh_token: string | null;
|
||||
/**
|
||||
* Expires At
|
||||
*/
|
||||
expires_at: string | null;
|
||||
/**
|
||||
* Connection Id
|
||||
*/
|
||||
connection_id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* AmbientNoiseUploadRequest
|
||||
*/
|
||||
|
|
@ -985,7 +963,9 @@ export type CreateToolRequest = {
|
|||
type: 'transfer_call';
|
||||
} & TransferCallToolDefinition) | ({
|
||||
type: 'calculator';
|
||||
} & CalculatorToolDefinition);
|
||||
} & CalculatorToolDefinition) | ({
|
||||
type: 'mcp';
|
||||
} & McpToolDefinition);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1999,50 +1979,6 @@ export type InitiateCallRequest = {
|
|||
from_phone_number_id?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* IntegrationResponse
|
||||
*/
|
||||
export type IntegrationResponse = {
|
||||
/**
|
||||
* Id
|
||||
*/
|
||||
id: number;
|
||||
/**
|
||||
* Integration Id
|
||||
*/
|
||||
integration_id: string;
|
||||
/**
|
||||
* Organisation Id
|
||||
*/
|
||||
organisation_id: number;
|
||||
/**
|
||||
* Created By
|
||||
*/
|
||||
created_by: number | null;
|
||||
/**
|
||||
* Provider
|
||||
*/
|
||||
provider: string;
|
||||
/**
|
||||
* Is Active
|
||||
*/
|
||||
is_active: boolean;
|
||||
/**
|
||||
* Created At
|
||||
*/
|
||||
created_at: string;
|
||||
/**
|
||||
* Action
|
||||
*/
|
||||
action: string;
|
||||
/**
|
||||
* Provider Data
|
||||
*/
|
||||
provider_data: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* ItemKind
|
||||
*/
|
||||
|
|
@ -2133,6 +2069,102 @@ export type MpsCreditsResponse = {
|
|||
total_quota: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* McpRefreshResponse
|
||||
*
|
||||
* Result of re-discovering an MCP server's tool catalog.
|
||||
*/
|
||||
export type McpRefreshResponse = {
|
||||
/**
|
||||
* Tool Uuid
|
||||
*/
|
||||
tool_uuid: string;
|
||||
/**
|
||||
* Discovered Tools
|
||||
*/
|
||||
discovered_tools?: Array<unknown>;
|
||||
/**
|
||||
* Error
|
||||
*/
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* McpToolConfig
|
||||
*
|
||||
* Configuration for an MCP tool definition.
|
||||
*/
|
||||
export type McpToolConfig = {
|
||||
/**
|
||||
* Transport
|
||||
*
|
||||
* MCP transport protocol
|
||||
*/
|
||||
transport?: 'streamable_http';
|
||||
/**
|
||||
* Url
|
||||
*
|
||||
* MCP server URL (must be http:// or https://)
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Credential Uuid
|
||||
*
|
||||
* Reference to ExternalCredentialModel for auth
|
||||
*/
|
||||
credential_uuid?: string | null;
|
||||
/**
|
||||
* Tools Filter
|
||||
*
|
||||
* Allowlist of MCP tool names to expose (empty = all tools)
|
||||
*/
|
||||
tools_filter?: Array<string>;
|
||||
/**
|
||||
* Timeout Secs
|
||||
*
|
||||
* Connection timeout in seconds
|
||||
*/
|
||||
timeout_secs?: number;
|
||||
/**
|
||||
* Sse Read Timeout Secs
|
||||
*
|
||||
* SSE read timeout in seconds
|
||||
*/
|
||||
sse_read_timeout_secs?: number;
|
||||
/**
|
||||
* Discovered Tools
|
||||
*
|
||||
* Server-managed cache of the MCP server's tool catalog [{name, description}]. Populated best-effort by the backend.
|
||||
*/
|
||||
discovered_tools?: Array<{
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* McpToolDefinition
|
||||
*
|
||||
* Persisted MCP tool definition.
|
||||
*/
|
||||
export type McpToolDefinition = {
|
||||
/**
|
||||
* Schema Version
|
||||
*
|
||||
* Schema version
|
||||
*/
|
||||
schema_version?: number;
|
||||
/**
|
||||
* Type
|
||||
*
|
||||
* Tool type
|
||||
*/
|
||||
type: 'mcp';
|
||||
/**
|
||||
* MCP server configuration
|
||||
*/
|
||||
config: McpToolConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* NodeCategory
|
||||
*
|
||||
|
|
@ -3042,20 +3074,6 @@ export type ServiceKeyResponse = {
|
|||
created_by?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* SessionResponse
|
||||
*/
|
||||
export type SessionResponse = {
|
||||
/**
|
||||
* Session Token
|
||||
*/
|
||||
session_token: string;
|
||||
/**
|
||||
* Expires At
|
||||
*/
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* SignupRequest
|
||||
*/
|
||||
|
|
@ -3847,18 +3865,6 @@ export type UpdateCredentialRequest = {
|
|||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* UpdateIntegrationRequest
|
||||
*/
|
||||
export type UpdateIntegrationRequest = {
|
||||
/**
|
||||
* Selected Files
|
||||
*/
|
||||
selected_files: Array<{
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* UpdateToolRequest
|
||||
*
|
||||
|
|
@ -3892,7 +3898,9 @@ export type UpdateToolRequest = {
|
|||
type: 'transfer_call';
|
||||
} & TransferCallToolDefinition) | ({
|
||||
type: 'calculator';
|
||||
} & CalculatorToolDefinition) | null;
|
||||
} & CalculatorToolDefinition) | ({
|
||||
type: 'mcp';
|
||||
} & McpToolDefinition) | null;
|
||||
/**
|
||||
* Status
|
||||
*/
|
||||
|
|
@ -7962,6 +7970,50 @@ export type UpdateToolApiV1ToolsToolUuidPutResponses = {
|
|||
|
||||
export type UpdateToolApiV1ToolsToolUuidPutResponse = UpdateToolApiV1ToolsToolUuidPutResponses[keyof UpdateToolApiV1ToolsToolUuidPutResponses];
|
||||
|
||||
export type RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* X-Api-Key
|
||||
*/
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Tool Uuid
|
||||
*/
|
||||
tool_uuid: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/tools/{tool_uuid}/mcp/refresh';
|
||||
};
|
||||
|
||||
export type RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostError = RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostErrors[keyof RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostErrors];
|
||||
|
||||
export type RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: McpRefreshResponse;
|
||||
};
|
||||
|
||||
export type RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostResponse = RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostResponses[keyof RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostResponses];
|
||||
|
||||
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
@ -8006,174 +8058,6 @@ export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses = {
|
|||
|
||||
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse = UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses[keyof UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses];
|
||||
|
||||
export type GetIntegrationsApiV1IntegrationGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* X-Api-Key
|
||||
*/
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/integration/';
|
||||
};
|
||||
|
||||
export type GetIntegrationsApiV1IntegrationGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetIntegrationsApiV1IntegrationGetError = GetIntegrationsApiV1IntegrationGetErrors[keyof GetIntegrationsApiV1IntegrationGetErrors];
|
||||
|
||||
export type GetIntegrationsApiV1IntegrationGetResponses = {
|
||||
/**
|
||||
* Response Get Integrations Api V1 Integration Get
|
||||
*
|
||||
* Successful Response
|
||||
*/
|
||||
200: Array<IntegrationResponse>;
|
||||
};
|
||||
|
||||
export type GetIntegrationsApiV1IntegrationGetResponse = GetIntegrationsApiV1IntegrationGetResponses[keyof GetIntegrationsApiV1IntegrationGetResponses];
|
||||
|
||||
export type CreateSessionApiV1IntegrationSessionPostData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* X-Api-Key
|
||||
*/
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/integration/session';
|
||||
};
|
||||
|
||||
export type CreateSessionApiV1IntegrationSessionPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type CreateSessionApiV1IntegrationSessionPostError = CreateSessionApiV1IntegrationSessionPostErrors[keyof CreateSessionApiV1IntegrationSessionPostErrors];
|
||||
|
||||
export type CreateSessionApiV1IntegrationSessionPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: SessionResponse;
|
||||
};
|
||||
|
||||
export type CreateSessionApiV1IntegrationSessionPostResponse = CreateSessionApiV1IntegrationSessionPostResponses[keyof CreateSessionApiV1IntegrationSessionPostResponses];
|
||||
|
||||
export type UpdateIntegrationApiV1IntegrationIntegrationIdPutData = {
|
||||
body: UpdateIntegrationRequest;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* X-Api-Key
|
||||
*/
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Integration Id
|
||||
*/
|
||||
integration_id: number;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/integration/{integration_id}';
|
||||
};
|
||||
|
||||
export type UpdateIntegrationApiV1IntegrationIntegrationIdPutErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type UpdateIntegrationApiV1IntegrationIntegrationIdPutError = UpdateIntegrationApiV1IntegrationIntegrationIdPutErrors[keyof UpdateIntegrationApiV1IntegrationIntegrationIdPutErrors];
|
||||
|
||||
export type UpdateIntegrationApiV1IntegrationIntegrationIdPutResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: IntegrationResponse;
|
||||
};
|
||||
|
||||
export type UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse = UpdateIntegrationApiV1IntegrationIntegrationIdPutResponses[keyof UpdateIntegrationApiV1IntegrationIntegrationIdPutResponses];
|
||||
|
||||
export type GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* X-Api-Key
|
||||
*/
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Integration Id
|
||||
*/
|
||||
integration_id: number;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/integration/{integration_id}/access-token';
|
||||
};
|
||||
|
||||
export type GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError = GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetErrors[keyof GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetErrors];
|
||||
|
||||
export type GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: AccessTokenResponse;
|
||||
};
|
||||
|
||||
export type GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse = GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponses[keyof GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponses];
|
||||
|
||||
export type GetTelephonyProvidersMetadataApiV1OrganizationsTelephonyProvidersMetadataGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ import { Badge } from "@/components/ui/badge";
|
|||
interface ToolBadgesProps {
|
||||
toolUuids: string[];
|
||||
onStaleUuidsDetected?: (staleUuids: string[]) => void;
|
||||
mcpToolFilters?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export function ToolBadges({ toolUuids, onStaleUuidsDetected }: ToolBadgesProps) {
|
||||
export function ToolBadges({ toolUuids, onStaleUuidsDetected, mcpToolFilters }: ToolBadgesProps) {
|
||||
const { tools } = useWorkflow();
|
||||
const [selectedTools, setSelectedTools] = useState<ToolResponse[]>([]);
|
||||
|
||||
|
|
@ -50,15 +51,29 @@ export function ToolBadges({ toolUuids, onStaleUuidsDetected }: ToolBadgesProps)
|
|||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedTools.map((tool) => (
|
||||
<Badge
|
||||
key={tool.tool_uuid}
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
>
|
||||
{tool.name}
|
||||
</Badge>
|
||||
))}
|
||||
{selectedTools.map((tool) => {
|
||||
const isMcp = tool.category === "mcp";
|
||||
const enabledFns = isMcp ? (mcpToolFilters?.[tool.tool_uuid] ?? []) : [];
|
||||
|
||||
if (isMcp && enabledFns.length > 0) {
|
||||
return enabledFns.map((fn) => (
|
||||
<Badge
|
||||
key={`${tool.tool_uuid}-${fn}`}
|
||||
variant="outline"
|
||||
className="text-xs flex items-center gap-1.5"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500 shrink-0" />
|
||||
{fn}
|
||||
</Badge>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge key={tool.tool_uuid} variant="outline" className="text-xs">
|
||||
{tool.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { ExternalLink, RefreshCw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { renderToolIcon } from "@/app/tools/config";
|
||||
import { useWorkflowOptional } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import type { ToolResponse } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TOOLS_INTRODUCTION_DOC_URL } from "@/constants/documentation";
|
||||
|
||||
import { type McpDiscoveredTool, refreshMcpTools } from "./mcpRefresh";
|
||||
|
||||
interface ToolSelectorProps {
|
||||
value: string[];
|
||||
onChange: (uuids: string[]) => void;
|
||||
|
|
@ -18,6 +23,46 @@ interface ToolSelectorProps {
|
|||
label?: string;
|
||||
description?: string;
|
||||
showLabel?: boolean;
|
||||
mcpToolFilters?: Record<string, string[]>;
|
||||
onMcpToolFiltersChange?: (next: Record<string, string[]>) => void;
|
||||
}
|
||||
|
||||
function isMcp(tool: ToolResponse): boolean {
|
||||
return tool.category === "mcp";
|
||||
}
|
||||
|
||||
function discoveredOf(tool: ToolResponse): McpDiscoveredTool[] {
|
||||
const def = (tool.definition ?? {}) as {
|
||||
config?: { discovered_tools?: McpDiscoveredTool[] };
|
||||
};
|
||||
return def.config?.discovered_tools ?? [];
|
||||
}
|
||||
|
||||
function withDiscoveredTools(
|
||||
tool: ToolResponse,
|
||||
discoveredTools: McpDiscoveredTool[],
|
||||
): ToolResponse {
|
||||
const definition =
|
||||
tool.definition && typeof tool.definition === "object"
|
||||
? tool.definition
|
||||
: {};
|
||||
const config =
|
||||
"config" in definition &&
|
||||
definition.config &&
|
||||
typeof definition.config === "object"
|
||||
? definition.config
|
||||
: {};
|
||||
|
||||
return {
|
||||
...tool,
|
||||
definition: {
|
||||
...definition,
|
||||
config: {
|
||||
...config,
|
||||
discovered_tools: discoveredTools,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ToolSelector({
|
||||
|
|
@ -28,18 +73,64 @@ export function ToolSelector({
|
|||
label = "Tools",
|
||||
description = "Select tools that the agent can use during the conversation.",
|
||||
showLabel = true,
|
||||
mcpToolFilters = {},
|
||||
onMcpToolFiltersChange = () => {},
|
||||
}: ToolSelectorProps) {
|
||||
// Filter to only show active tools
|
||||
const activeTools = tools.filter((tool) => tool.status === "active");
|
||||
const workflow = useWorkflowOptional();
|
||||
const activeTools = tools.filter((t) => t.status === "active");
|
||||
const httpTools = activeTools.filter((t) => !isMcp(t));
|
||||
const mcpTools = activeTools.filter(isMcp);
|
||||
|
||||
const handleToggle = (toolUuid: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
onChange([...value, toolUuid]);
|
||||
} else {
|
||||
onChange(value.filter((id) => id !== toolUuid));
|
||||
}
|
||||
const [refreshing, setRefreshing] = useState<Record<string, boolean>>({});
|
||||
const [refreshError, setRefreshError] = useState<Record<string, string>>({});
|
||||
|
||||
const httpHandleToggle = (toolUuid: string, checked: boolean) => {
|
||||
if (checked) onChange([...value, toolUuid]);
|
||||
else onChange(value.filter((id) => id !== toolUuid));
|
||||
};
|
||||
|
||||
const mcpFnToggle = (toolUuid: string, fnName: string, checked: boolean) => {
|
||||
const current = mcpToolFilters[toolUuid] ?? [];
|
||||
const nextFns = checked
|
||||
? Array.from(new Set([...current, fnName]))
|
||||
: current.filter((n) => n !== fnName);
|
||||
|
||||
const nextFilters = { ...mcpToolFilters };
|
||||
if (nextFns.length > 0) nextFilters[toolUuid] = nextFns;
|
||||
else delete nextFilters[toolUuid];
|
||||
onMcpToolFiltersChange(nextFilters);
|
||||
|
||||
const hasUuid = value.includes(toolUuid);
|
||||
if (nextFns.length > 0 && !hasUuid) onChange([...value, toolUuid]);
|
||||
else if (nextFns.length === 0 && hasUuid)
|
||||
onChange(value.filter((id) => id !== toolUuid));
|
||||
};
|
||||
|
||||
const doRefresh = async (toolUuid: string) => {
|
||||
setRefreshing((r) => ({ ...r, [toolUuid]: true }));
|
||||
setRefreshError((e) => {
|
||||
const n = { ...e };
|
||||
delete n[toolUuid];
|
||||
return n;
|
||||
});
|
||||
const res = await refreshMcpTools(toolUuid);
|
||||
setRefreshing((r) => ({ ...r, [toolUuid]: false }));
|
||||
if (res.error && res.discovered_tools.length === 0) {
|
||||
setRefreshError((e) => ({ ...e, [toolUuid]: res.error as string }));
|
||||
return;
|
||||
}
|
||||
workflow?.updateTool?.(toolUuid, (tool) =>
|
||||
withDiscoveredTools(tool, res.discovered_tools),
|
||||
);
|
||||
};
|
||||
|
||||
const selectedCount =
|
||||
httpTools.filter((t) => value.includes(t.tool_uuid)).length +
|
||||
mcpTools.reduce(
|
||||
(acc, t) => acc + (mcpToolFilters[t.tool_uuid]?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{showLabel && (
|
||||
|
|
@ -48,7 +139,14 @@ export function ToolSelector({
|
|||
{description && (
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{description}{" "}
|
||||
<a href={TOOLS_INTRODUCTION_DOC_URL} target="_blank" rel="noopener noreferrer" className="underline">Learn more</a>
|
||||
<a
|
||||
href={TOOLS_INTRODUCTION_DOC_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</Label>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -67,45 +165,178 @@ export function ToolSelector({
|
|||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md divide-y">
|
||||
{activeTools.map((tool) => {
|
||||
const isSelected = value.includes(tool.tool_uuid);
|
||||
return (
|
||||
<label
|
||||
key={tool.tool_uuid}
|
||||
className={`flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/50 ${
|
||||
disabled ? "opacity-50 cursor-not-allowed" : ""
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => {
|
||||
handleToggle(tool.tool_uuid, checked === true);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="w-6 h-6 rounded flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
backgroundColor: tool.icon_color || "#3B82F6",
|
||||
}}
|
||||
>
|
||||
{renderToolIcon(tool.category, "h-3 w-3 text-white")}
|
||||
<Tabs defaultValue="http">
|
||||
<TabsList>
|
||||
<TabsTrigger value="http">
|
||||
HTTP & Tools ({httpTools.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp">
|
||||
MCP ({mcpTools.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="http">
|
||||
<div className="border rounded-md divide-y">
|
||||
{httpTools.length === 0 && (
|
||||
<div className="p-3 text-sm text-muted-foreground">
|
||||
No HTTP/native tools.
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{tool.name}
|
||||
</span>
|
||||
{tool.description && (
|
||||
<span className="text-xs text-muted-foreground break-words">
|
||||
{tool.description}
|
||||
</span>
|
||||
)}
|
||||
)}
|
||||
{httpTools.map((tool) => {
|
||||
const isSelected = value.includes(tool.tool_uuid);
|
||||
return (
|
||||
<label
|
||||
key={tool.tool_uuid}
|
||||
className={`flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/50 ${
|
||||
disabled ? "opacity-50 cursor-not-allowed" : ""
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(c) =>
|
||||
httpHandleToggle(tool.tool_uuid, c === true)
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="w-6 h-6 rounded flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
backgroundColor: tool.icon_color || "#3B82F6",
|
||||
}}
|
||||
>
|
||||
{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">
|
||||
{tool.name}
|
||||
</span>
|
||||
{tool.description && (
|
||||
<span className="text-xs text-muted-foreground break-words">
|
||||
{tool.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mcp">
|
||||
<div className="border rounded-md divide-y">
|
||||
{mcpTools.length === 0 && (
|
||||
<div className="p-3 text-sm text-muted-foreground">
|
||||
No MCP tools.
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<div className="p-2 bg-muted/30">
|
||||
)}
|
||||
{mcpTools.map((tool) => {
|
||||
const fns = discoveredOf(tool);
|
||||
const selected = mcpToolFilters[tool.tool_uuid] ?? [];
|
||||
const busy = !!refreshing[tool.tool_uuid];
|
||||
const err = refreshError[tool.tool_uuid];
|
||||
return (
|
||||
<details key={tool.tool_uuid} className="p-3">
|
||||
<summary className="flex items-center gap-3 cursor-pointer list-none">
|
||||
<div
|
||||
className="w-6 h-6 rounded flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
backgroundColor: tool.icon_color || "#8B5CF6",
|
||||
}}
|
||||
>
|
||||
{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">
|
||||
{tool.name}
|
||||
</span>
|
||||
{tool.description && (
|
||||
<span className="text-xs text-muted-foreground break-words">
|
||||
{tool.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{selected.length}/{fns.length} tools
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div className="mt-3 pl-9 grid gap-2">
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => doRefresh(tool.tool_uuid)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 mr-2 ${busy ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh tools
|
||||
</Button>
|
||||
</div>
|
||||
{err && (
|
||||
<p className="text-xs text-destructive">{err}</p>
|
||||
)}
|
||||
{fns.length === 0 && !err && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No tools discovered — Refresh.
|
||||
</p>
|
||||
)}
|
||||
{fns.map((fn) => {
|
||||
const checked = selected.includes(fn.name);
|
||||
return (
|
||||
<label
|
||||
key={fn.name}
|
||||
className="flex items-start gap-3 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(c) =>
|
||||
mcpFnToggle(tool.tool_uuid, fn.name, c === true)
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{fn.name}
|
||||
</span>
|
||||
{fn.description && (
|
||||
<span className="text-xs text-muted-foreground break-words">
|
||||
{fn.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{selected
|
||||
.filter((n) => !fns.some((f) => f.name === n))
|
||||
.map((n) => (
|
||||
<label
|
||||
key={`stale-${n}`}
|
||||
className="flex items-start gap-3 cursor-pointer opacity-60"
|
||||
>
|
||||
<Checkbox
|
||||
checked
|
||||
disabled={disabled}
|
||||
onCheckedChange={() =>
|
||||
mcpFnToggle(tool.tool_uuid, n, false)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm line-through">
|
||||
{n} (unavailable)
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<div className="mt-2 p-2 bg-muted/30 rounded-md">
|
||||
<Link
|
||||
href="/tools"
|
||||
target="_blank"
|
||||
|
|
@ -115,12 +346,12 @@ export function ToolSelector({
|
|||
Manage Tools
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{value.length > 0 && (
|
||||
{selectedCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{value.length} tool{value.length !== 1 ? "s" : ""} selected
|
||||
{selectedCount} tool{selectedCount !== 1 ? "s" : ""} selected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
68
ui/src/components/flow/mcpRefresh.ts
Normal file
68
ui/src/components/flow/mcpRefresh.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { refreshMcpToolsApiV1ToolsToolUuidMcpRefreshPost } from "@/client/sdk.gen";
|
||||
import type { McpRefreshResponse } from "@/client/types.gen";
|
||||
|
||||
export interface McpDiscoveredTool {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface McpRefreshResult {
|
||||
tool_uuid: string;
|
||||
discovered_tools: McpDiscoveredTool[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function normalizeDiscoveredTools(
|
||||
discoveredTools: McpRefreshResponse["discovered_tools"],
|
||||
): McpDiscoveredTool[] {
|
||||
if (!Array.isArray(discoveredTools)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return discoveredTools.flatMap((tool) => {
|
||||
if (!tool || typeof tool !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const name = "name" in tool ? tool.name : undefined;
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const description =
|
||||
"description" in tool && typeof tool.description === "string"
|
||||
? tool.description
|
||||
: "";
|
||||
|
||||
return [{ name, description }];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-discover an MCP tool's server catalog.
|
||||
* Uses the shared generated `client` (auth bearer is injected by interceptor).
|
||||
*/
|
||||
export async function refreshMcpTools(
|
||||
toolUuid: string,
|
||||
): Promise<McpRefreshResult> {
|
||||
const { data, error } = await refreshMcpToolsApiV1ToolsToolUuidMcpRefreshPost({
|
||||
path: {
|
||||
tool_uuid: toolUuid,
|
||||
},
|
||||
});
|
||||
if (error || !data) {
|
||||
return {
|
||||
tool_uuid: toolUuid,
|
||||
discovered_tools: [],
|
||||
error:
|
||||
typeof error === "string"
|
||||
? error
|
||||
: "Refresh request failed. Check the MCP server and try again.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
tool_uuid: data.tool_uuid,
|
||||
discovered_tools: normalizeDiscoveredTools(data.discovered_tools),
|
||||
error: data.error ?? null,
|
||||
};
|
||||
}
|
||||
|
|
@ -31,7 +31,8 @@ type NodeStyleVariant =
|
|||
| "global"
|
||||
| "trigger"
|
||||
| "webhook"
|
||||
| "qa";
|
||||
| "qa"
|
||||
| "integration";
|
||||
|
||||
const STYLE_VARIANT_BY_SPEC: Record<string, NodeStyleVariant> = {
|
||||
startCall: "start",
|
||||
|
|
@ -100,6 +101,70 @@ function buildTriggerEndpoints(
|
|||
};
|
||||
}
|
||||
|
||||
function resolveIntegrationEnabled(
|
||||
spec: NodeSpec,
|
||||
data: FlowNodeData,
|
||||
): boolean {
|
||||
for (const prop of spec.properties) {
|
||||
if (!prop.name.endsWith("enabled")) continue;
|
||||
const value = data[prop.name];
|
||||
if (typeof value === "boolean") return value;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveIntegrationSummary(
|
||||
spec: NodeSpec,
|
||||
data: FlowNodeData,
|
||||
): string {
|
||||
for (const prop of spec.properties) {
|
||||
if (
|
||||
prop.name === "name" ||
|
||||
prop.name.endsWith("enabled") ||
|
||||
/api[_-]?key|token|secret/i.test(prop.name)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = data[prop.name];
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.length > 30 ? `${value.slice(0, 30)}...` : value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return "Not configured";
|
||||
}
|
||||
|
||||
function getBadgeForSpec(
|
||||
spec: NodeSpec | undefined,
|
||||
variant: NodeStyleVariant,
|
||||
): { label: string; className: string } {
|
||||
if (!spec) {
|
||||
return { label: "Node", className: "bg-zinc-500 text-white" };
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case "start":
|
||||
return { label: "Start Node", className: "bg-emerald-500 text-white" };
|
||||
case "agent":
|
||||
return { label: "Agent Node", className: "bg-blue-500 text-white" };
|
||||
case "end":
|
||||
return { label: "End Node", className: "bg-rose-500 text-white" };
|
||||
case "global":
|
||||
return { label: "Global Node", className: "bg-amber-500 text-white" };
|
||||
case "trigger":
|
||||
return { label: "API Trigger", className: "bg-purple-500 text-white" };
|
||||
case "webhook":
|
||||
return { label: "Webhook", className: "bg-indigo-500 text-white" };
|
||||
case "qa":
|
||||
return { label: "QA Analysis", className: "bg-teal-500 text-white" };
|
||||
case "integration":
|
||||
return { label: spec.display_name, className: "bg-cyan-600 text-white" };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Canvas preview dispatch ──────────────────────────────────────────────
|
||||
|
||||
function CanvasPreview({
|
||||
|
|
@ -188,6 +253,21 @@ function CanvasPreview({
|
|||
);
|
||||
}
|
||||
|
||||
if (spec.category === "integration") {
|
||||
const enabled = resolveIntegrationEnabled(spec, data);
|
||||
const destination = resolveIntegrationSummary(spec, data);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{destination}
|
||||
</span>
|
||||
</div>
|
||||
<StatusDot enabled={enabled} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: prompt preview + tool/document badges (when spec declares them).
|
||||
const hasToolRefs = spec.properties.some((p) => p.type === "tool_refs");
|
||||
const hasDocRefs = spec.properties.some((p) => p.type === "document_refs");
|
||||
|
|
@ -205,6 +285,7 @@ function CanvasPreview({
|
|||
<ToolBadges
|
||||
toolUuids={data.tool_uuids}
|
||||
onStaleUuidsDetected={onStaleTools}
|
||||
mcpToolFilters={data.mcp_tool_filters}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -396,14 +477,22 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
|
|||
const spec = bySpecName.get(type);
|
||||
|
||||
// ── Form state ─────────────────────────────────────────────────────
|
||||
const [values, setValues] = useState<Record<string, unknown>>(() =>
|
||||
spec ? seedValues(data, spec) : {},
|
||||
// mcp_tool_filters is not a spec property, so seedValues won't carry it;
|
||||
// seed merges it back in alongside the spec-derived values.
|
||||
const seed = useCallback(
|
||||
() =>
|
||||
spec
|
||||
? { ...seedValues(data, spec), mcp_tool_filters: data.mcp_tool_filters }
|
||||
: {},
|
||||
[data, spec],
|
||||
);
|
||||
|
||||
const [values, setValues] = useState<Record<string, unknown>>(seed);
|
||||
|
||||
// Re-seed once the spec arrives (initial fetch race).
|
||||
useEffect(() => {
|
||||
if (spec && Object.keys(values).length === 0) {
|
||||
setValues(seedValues(data, spec));
|
||||
setValues(seed());
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [spec]);
|
||||
|
|
@ -464,7 +553,11 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
|
|||
const isDirty = useMemo(() => {
|
||||
if (!spec) return false;
|
||||
const baseline = seedValues(data, spec);
|
||||
return propertyNames.some((n) => values[n] !== baseline[n]);
|
||||
if (propertyNames.some((n) => values[n] !== baseline[n])) return true;
|
||||
return (
|
||||
JSON.stringify(values.mcp_tool_filters ?? {}) !==
|
||||
JSON.stringify(data.mcp_tool_filters ?? {})
|
||||
);
|
||||
}, [values, data, spec, propertyNames]);
|
||||
|
||||
const handleSave = async () => {
|
||||
|
|
@ -478,20 +571,30 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
|
|||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (newOpen && spec) setValues(seedValues(data, spec));
|
||||
if (newOpen && spec) setValues(seed());
|
||||
setOpen(newOpen);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open && spec) setValues(seedValues(data, spec));
|
||||
if (open && spec) setValues(seed());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data, open]);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────
|
||||
const styleVariant = STYLE_VARIANT_BY_SPEC[type];
|
||||
const handles = HANDLES_BY_SPEC[type] ?? { source: true, target: true };
|
||||
const styleVariant =
|
||||
STYLE_VARIANT_BY_SPEC[type] ??
|
||||
(spec?.category === "integration" ? "integration" : "agent");
|
||||
const handles =
|
||||
HANDLES_BY_SPEC[type] ??
|
||||
(spec?.category === "integration"
|
||||
? { source: false, target: false }
|
||||
: { source: true, target: true });
|
||||
const badge = getBadgeForSpec(spec, styleVariant);
|
||||
const Icon = spec ? resolveIcon(spec.icon) : Circle;
|
||||
const docUrl = DOC_URL_BY_SPEC[type];
|
||||
const contentLabel = spec?.properties.some((p) => p.name === "prompt")
|
||||
? "Prompt"
|
||||
: "Details";
|
||||
|
||||
// Edit dialog title: "Edit {display_name}". Webhook keeps the original
|
||||
// "Edit Webhook" wording — display_name is "Webhook" so it works out.
|
||||
|
|
@ -507,7 +610,9 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
|
|||
hovered_through_edge={data.hovered_through_edge}
|
||||
title={data.name || fallbackTitle}
|
||||
icon={<Icon />}
|
||||
nodeType={styleVariant}
|
||||
badgeLabel={badge.label}
|
||||
badgeClassName={badge.className}
|
||||
contentLabel={contentLabel}
|
||||
hasSourceHandle={handles.source}
|
||||
hasTargetHandle={handles.target}
|
||||
onDoubleClick={() => setOpen(true)}
|
||||
|
|
@ -562,6 +667,18 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
|
|||
tools: tools ?? [],
|
||||
documents: documents ?? [],
|
||||
recordings: recordings ?? [],
|
||||
mcpToolFilters:
|
||||
(values.mcp_tool_filters as
|
||||
| Record<string, string[]>
|
||||
| undefined) ?? {},
|
||||
onMcpToolFiltersChange: (next) =>
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
mcp_tool_filters:
|
||||
Object.keys(next).length > 0
|
||||
? next
|
||||
: undefined,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
{type === "trigger" && (
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ interface NodeContentProps {
|
|||
hovered_through_edge?: boolean;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
nodeType?: 'start' | 'agent' | 'end' | 'global' | 'trigger' | 'webhook' | 'qa';
|
||||
badgeLabel?: string;
|
||||
badgeClassName?: string;
|
||||
contentLabel?: string;
|
||||
hasSourceHandle?: boolean;
|
||||
hasTargetHandle?: boolean;
|
||||
children?: ReactNode;
|
||||
|
|
@ -22,26 +24,7 @@ interface NodeContentProps {
|
|||
}
|
||||
|
||||
// Get badge styling based on node type
|
||||
const getNodeTypeBadge = (nodeType?: string) => {
|
||||
switch (nodeType) {
|
||||
case 'start':
|
||||
return { label: 'Start Node', className: 'bg-emerald-500 text-white' };
|
||||
case 'agent':
|
||||
return { label: 'Agent Node', className: 'bg-blue-500 text-white' };
|
||||
case 'end':
|
||||
return { label: 'End Node', className: 'bg-rose-500 text-white' };
|
||||
case 'global':
|
||||
return { label: 'Global Node', className: 'bg-amber-500 text-white' };
|
||||
case 'trigger':
|
||||
return { label: 'API Trigger', className: 'bg-purple-500 text-white' };
|
||||
case 'webhook':
|
||||
return { label: 'Webhook', className: 'bg-indigo-500 text-white' };
|
||||
case 'qa':
|
||||
return { label: 'QA Analysis', className: 'bg-teal-500 text-white' };
|
||||
default:
|
||||
return { label: 'Node', className: 'bg-zinc-500 text-white' };
|
||||
}
|
||||
};
|
||||
const DEFAULT_BADGE = { label: 'Node', className: 'bg-zinc-500 text-white' };
|
||||
|
||||
export const NodeContent = ({
|
||||
selected,
|
||||
|
|
@ -50,7 +33,9 @@ export const NodeContent = ({
|
|||
hovered_through_edge,
|
||||
title,
|
||||
icon,
|
||||
nodeType,
|
||||
badgeLabel,
|
||||
badgeClassName,
|
||||
contentLabel = "Prompt",
|
||||
hasSourceHandle = false,
|
||||
hasTargetHandle = false,
|
||||
children,
|
||||
|
|
@ -58,7 +43,10 @@ export const NodeContent = ({
|
|||
onDoubleClick,
|
||||
nodeId,
|
||||
}: NodeContentProps) => {
|
||||
const badge = getNodeTypeBadge(nodeType);
|
||||
const badge = {
|
||||
label: badgeLabel ?? DEFAULT_BADGE.label,
|
||||
className: badgeClassName ?? DEFAULT_BADGE.className,
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseNode
|
||||
|
|
@ -98,7 +86,9 @@ export const NodeContent = ({
|
|||
|
||||
{/* Content area with prompt label */}
|
||||
<div className="p-4">
|
||||
<div className="text-xs text-muted-foreground mb-1.5 font-medium">Prompt:</div>
|
||||
<div className="text-xs text-muted-foreground mb-1.5 font-medium">
|
||||
{contentLabel}:
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ export interface RendererContext {
|
|||
tools: ToolResponse[];
|
||||
documents: DocumentResponseSchema[];
|
||||
recordings: RecordingResponseSchema[];
|
||||
/** Per-node MCP function allowlist (sibling of tool_uuids on node data). */
|
||||
mcpToolFilters?: Record<string, string[]>;
|
||||
/** Persist a new mcp_tool_filters object onto the node form values. */
|
||||
onMcpToolFiltersChange?: (next: Record<string, string[]>) => void;
|
||||
}
|
||||
|
||||
export interface PropertyInputProps {
|
||||
|
|
@ -83,6 +87,10 @@ export function PropertyInput({ spec, value, onChange, context }: PropertyInputP
|
|||
value={value}
|
||||
onChange={onChange}
|
||||
tools={context.tools}
|
||||
mcpToolFilters={context.mcpToolFilters ?? {}}
|
||||
onMcpToolFiltersChange={
|
||||
context.onMcpToolFiltersChange ?? (() => {})
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "document_refs":
|
||||
|
|
@ -401,7 +409,13 @@ function ToolRefsWidget({
|
|||
value,
|
||||
onChange,
|
||||
tools,
|
||||
}: WidgetProps & { tools: ToolResponse[] }) {
|
||||
mcpToolFilters,
|
||||
onMcpToolFiltersChange,
|
||||
}: WidgetProps & {
|
||||
tools: ToolResponse[];
|
||||
mcpToolFilters: Record<string, string[]>;
|
||||
onMcpToolFiltersChange: (next: Record<string, string[]>) => void;
|
||||
}) {
|
||||
return (
|
||||
<ToolSelector
|
||||
value={(value as string[] | undefined) ?? []}
|
||||
|
|
@ -409,6 +423,8 @@ function ToolRefsWidget({
|
|||
tools={tools}
|
||||
label={spec.display_name}
|
||||
description={spec.description}
|
||||
mcpToolFilters={mcpToolFilters}
|
||||
onMcpToolFiltersChange={onMcpToolFiltersChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,13 @@ export enum NodeType {
|
|||
GLOBAL_NODE = 'globalNode',
|
||||
TRIGGER = 'trigger',
|
||||
WEBHOOK = 'webhook',
|
||||
QA = 'qa'
|
||||
QA = 'qa',
|
||||
}
|
||||
|
||||
export type FlowNodeData = {
|
||||
prompt: string;
|
||||
prompt?: string;
|
||||
name: string;
|
||||
is_start?: boolean;
|
||||
is_static?: boolean;
|
||||
is_end?: boolean;
|
||||
invalid?: boolean;
|
||||
validationMessage?: string | null;
|
||||
|
|
@ -26,8 +25,6 @@ export type FlowNodeData = {
|
|||
greeting?: string;
|
||||
greeting_type?: 'text' | 'audio';
|
||||
greeting_recording_id?: string;
|
||||
wait_for_user_greeting?: boolean;
|
||||
detect_voicemail?: boolean;
|
||||
delayed_start?: boolean;
|
||||
delayed_start_duration?: number;
|
||||
// Pre-call data fetch (StartCall only)
|
||||
|
|
@ -55,8 +52,13 @@ export type FlowNodeData = {
|
|||
qa_sample_rate?: number;
|
||||
// Tools - array of tool UUIDs that can be invoked by this node
|
||||
tool_uuids?: string[];
|
||||
// Per-node MCP function allowlist: { toolUuid: [raw MCP tool name, ...] }.
|
||||
// Default-none: a toolUuid absent here (or mapped to []) exposes zero
|
||||
// functions of that MCP server on this node.
|
||||
mcp_tool_filters?: Record<string, string[]>;
|
||||
// Documents - array of knowledge base document UUIDs that can be referenced by this node
|
||||
document_uuids?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type FlowNode = {
|
||||
|
|
@ -131,4 +133,3 @@ export interface Credential {
|
|||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,8 +70,7 @@ type SidebarNavSection = {
|
|||
items: SidebarNavItem[];
|
||||
};
|
||||
|
||||
const TELEPHONY_WARNING_DEADLINE = "15 May 2026";
|
||||
const TELEPHONY_WARNING_COPY = `Action required before ${TELEPHONY_WARNING_DEADLINE}`;
|
||||
const TELEPHONY_WARNING_COPY = "Action required";
|
||||
|
||||
const NAV_SECTIONS: SidebarNavSection[] = [
|
||||
{
|
||||
|
|
@ -206,7 +205,7 @@ export function AppSidebar() {
|
|||
};
|
||||
const warningIndicator = (
|
||||
<AlertTriangle
|
||||
aria-label={`Action required on a telephony configuration before ${TELEPHONY_WARNING_DEADLINE}`}
|
||||
aria-label="Action required on a telephony configuration"
|
||||
className={cn(
|
||||
"text-amber-500",
|
||||
isCollapsed ? "absolute -right-0.5 -top-0.5 h-3 w-3" : "ml-auto h-3.5 w-3.5"
|
||||
|
|
|
|||
|
|
@ -29,10 +29,7 @@ const BLANK_WORKFLOW_DEFINITION = {
|
|||
allow_interrupt: false,
|
||||
invalid: false,
|
||||
validationMessage: null,
|
||||
is_static: false,
|
||||
add_global_prompt: false,
|
||||
wait_for_user_response: false,
|
||||
detect_voicemail: true,
|
||||
delayed_start: false,
|
||||
is_start: true,
|
||||
selected_through_edge: false,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue