mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-28 08:49:42 +02:00
feat: add Tuner Integration to Dograh (#311)
* Add tuner integration * bump pipecat version * chore: update pipecat submodule to match upstream and use tuner-pipecat-sdk 0.2.0 Update pipecat submodule from 0.0.109.dev23 to 13e98d0d9 (the exact commit upstream dograh-hq/dograh uses after v1.30.1). This installs pipecat-ai as 1.1.0.post277 via setuptools_scm, satisfying tuner-pipecat-sdk 0.2.0's pipecat-ai>=1.0.0 requirement. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * wire tuner * feat: refactor integrations into self contained packages * chore: simplify ensure_public_access_token * fix: remove NodeSpec and make DTOs the source of truth * feat: send relevant signal to mcp using to_mcp_dict * fix: fix tests * cleanup: remove nango integrations * feat: add agents.md for integrations --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
parent
afa78fe859
commit
5f28c1b2a9
93 changed files with 3388 additions and 3414 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
|
@ -27,14 +28,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,
|
||||
};
|
||||
|
|
@ -63,6 +56,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 [versions, setVersions] = useState<WorkflowVersion[]>([]);
|
||||
|
|
@ -107,6 +101,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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue