Add twilio softphone with integrated AI assistant

This commit is contained in:
Francisco Gaona
2026-01-04 08:48:43 +01:00
parent 16907aadf8
commit 5f3fcef1ec
42 changed files with 5689 additions and 923 deletions

View File

@@ -17,10 +17,12 @@ import {
SidebarRail,
} from '@/components/ui/sidebar'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut, Users, Globe, Building } from 'lucide-vue-next'
import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut, Users, Globe, Building, Phone } from 'lucide-vue-next'
import { useSoftphone } from '~/composables/useSoftphone'
const { logout } = useAuth()
const { api } = useApi()
const softphone = useSoftphone()
const handleLogout = async () => {
await logout()
@@ -115,6 +117,11 @@ const staticMenuItems = [
url: '/setup/roles',
icon: Layers,
},
{
title: 'Integrations',
url: '/settings/integrations',
icon: Settings,
},
],
},
]
@@ -328,6 +335,13 @@ const centralAdminMenuItems: Array<{
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem v-if="!isCentralAdmin">
<SidebarMenuButton @click="softphone.open" class="cursor-pointer hover:bg-accent">
<Phone class="h-4 w-4" />
<span>Softphone</span>
<span v-if="softphone.hasIncomingCall.value" class="ml-auto h-2 w-2 rounded-full bg-red-500 animate-pulse"></span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton @click="handleLogout" class="cursor-pointer hover:bg-accent">
<LogOut class="h-4 w-4" />

View File

@@ -178,7 +178,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~
import { Input } from '~/components/ui/input';
import { Label } from '~/components/ui/label';
import { Badge } from '~/components/ui/badge';
import Checkbox from '~/components/ui/checkbox.vue';
import { Checkbox } from '~/components/ui/checkbox';
import DatePicker from '~/components/ui/date-picker/DatePicker.vue';
import { UserPlus, Trash2, Users } from 'lucide-vue-next';

View File

@@ -0,0 +1,300 @@
<template>
<Dialog v-model:open="softphone.isOpen.value">
<DialogContent class="sm:max-w-[500px] max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Softphone</DialogTitle>
</DialogHeader>
<div class="flex-1 overflow-y-auto space-y-4">
<!-- Connection Status -->
<div class="flex items-center justify-between p-3 rounded-lg border" :class="{
'bg-green-50 border-green-200': softphone.isConnected.value,
'bg-red-50 border-red-200': !softphone.isConnected.value
}">
<span class="text-sm font-medium">
{{ softphone.isConnected.value ? 'Connected' : 'Disconnected' }}
</span>
<div class="h-2 w-2 rounded-full" :class="{
'bg-green-500': softphone.isConnected.value,
'bg-red-500': !softphone.isConnected.value
}"></div>
</div>
<!-- Incoming Call -->
<div v-if="softphone.incomingCall.value" class="p-4 rounded-lg border border-blue-200 bg-blue-50 animate-pulse">
<div class="text-center space-y-4">
<div>
<p class="text-sm text-gray-600">Incoming call from</p>
<p class="text-2xl font-bold">{{ formatPhoneNumber(softphone.incomingCall.value.fromNumber) }}</p>
</div>
<div class="flex gap-2 justify-center">
<Button @click="handleAccept" class="bg-green-500 hover:bg-green-600">
<PhoneIcon class="w-4 h-4 mr-2" />
Accept
</Button>
<Button @click="handleReject" variant="destructive">
<PhoneOffIcon class="w-4 h-4 mr-2" />
Reject
</Button>
</div>
</div>
</div>
<!-- Active Call -->
<div v-if="softphone.currentCall.value" class="space-y-4">
<div class="p-4 rounded-lg border bg-gray-50">
<div class="text-center space-y-2">
<p class="text-sm text-gray-600">
{{ softphone.currentCall.value.direction === 'outbound' ? 'Calling' : 'Connected with' }}
</p>
<p class="text-2xl font-bold">
{{ formatPhoneNumber(
softphone.currentCall.value.direction === 'outbound'
? softphone.currentCall.value.toNumber
: softphone.currentCall.value.fromNumber
) }}
</p>
<p class="text-sm text-gray-500 capitalize">{{ softphone.callStatus.value }}</p>
</div>
</div>
<!-- Call Controls -->
<div class="grid grid-cols-3 gap-2">
<Button variant="outline" size="sm" @click="toggleMute">
<MicIcon v-if="!isMuted" class="w-4 h-4" />
<MicOffIcon v-else class="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" @click="showDialpad = !showDialpad">
<Hash class="w-4 h-4" />
</Button>
<Button variant="destructive" size="sm" @click="handleEndCall">
<PhoneOffIcon class="w-4 h-4" />
</Button>
</div>
<!-- Dialpad -->
<div v-if="showDialpad" class="grid grid-cols-3 gap-2">
<Button
v-for="digit in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']"
:key="digit"
variant="outline"
size="sm"
@click="handleDtmf(digit)"
class="h-12 text-lg font-semibold"
>
{{ digit }}
</Button>
</div>
</div>
<!-- AI Suggestions - Show whenever there are suggestions, not just during active call -->
<div v-if="softphone.aiSuggestions.value.length > 0" class="space-y-2">
<h3 class="text-sm font-semibold flex items-center gap-2">
<span>AI Assistant</span>
<span class="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded-full">
{{ softphone.aiSuggestions.value.length }}
</span>
</h3>
<div class="space-y-2 max-h-40 overflow-y-auto">
<div
v-for="(suggestion, index) in softphone.aiSuggestions.value.slice(0, 5)"
:key="index"
class="p-3 rounded-lg border text-sm transition-all"
:class="{
'bg-blue-50 border-blue-200 animate-pulse': suggestion.type === 'response' && index === 0,
'bg-blue-50 border-blue-200': suggestion.type === 'response' && index !== 0,
'bg-green-50 border-green-200 animate-pulse': suggestion.type === 'action' && index === 0,
'bg-green-50 border-green-200': suggestion.type === 'action' && index !== 0,
'bg-purple-50 border-purple-200 animate-pulse': suggestion.type === 'insight' && index === 0,
'bg-purple-50 border-purple-200': suggestion.type === 'insight' && index !== 0
}"
>
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-semibold uppercase" :class="{
'text-blue-700': suggestion.type === 'response',
'text-green-700': suggestion.type === 'action',
'text-purple-700': suggestion.type === 'insight'
}">{{ suggestion.type }}</span>
<span class="text-xs text-gray-400">just now</span>
</div>
<p class="leading-relaxed">{{ suggestion.text }}</p>
</div>
</div>
</div>
<!-- Dialer (when no active call) -->
<div v-if="!softphone.currentCall.value && !softphone.incomingCall.value" class="space-y-4">
<div>
<label class="text-sm font-medium">Phone Number</label>
<Input
v-model="phoneNumber"
placeholder="+1234567890"
class="mt-1"
@keyup.enter="handleCall"
/>
</div>
<div class="grid grid-cols-3 gap-2">
<Button
v-for="digit in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']"
:key="digit"
variant="outline"
@click="phoneNumber += digit"
class="h-12 text-lg font-semibold"
>
{{ digit }}
</Button>
</div>
<div class="flex gap-2">
<Button @click="handleCall" class="flex-1" :disabled="!phoneNumber">
<PhoneIcon class="w-4 h-4 mr-2" />
Call
</Button>
<Button @click="phoneNumber = ''" variant="outline">
<XIcon class="w-4 h-4" />
</Button>
</div>
<!-- Debug: Test AI Suggestions -->
<Button @click="testAiSuggestion" variant="outline" size="sm" class="w-full">
🧪 Test AI Suggestion
</Button>
<!-- Recent Calls -->
<div v-if="softphone.callHistory.value.length > 0" class="space-y-2">
<h3 class="text-sm font-semibold">Recent Calls</h3>
<div class="space-y-1 max-h-40 overflow-y-auto">
<div
v-for="call in softphone.callHistory.value.slice(0, 5)"
:key="call.callSid"
class="flex items-center justify-between p-2 rounded hover:bg-gray-100 cursor-pointer"
@click="phoneNumber = call.direction === 'outbound' ? call.toNumber : call.fromNumber"
>
<div class="flex items-center gap-2">
<PhoneIcon v-if="call.direction === 'outbound'" class="w-3 h-3 text-green-500" />
<PhoneIncomingIcon v-else class="w-3 h-3 text-blue-500" />
<span class="text-sm">
{{ formatPhoneNumber(call.direction === 'outbound' ? call.toNumber : call.fromNumber) }}
</span>
</div>
<span class="text-xs text-gray-500">{{ formatDuration(call.duration) }}</span>
</div>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useSoftphone } from '~/composables/useSoftphone';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog';
import { Button } from '~/components/ui/button';
import { Input } from '~/components/ui/input';
import { PhoneIcon, PhoneOffIcon, PhoneIncomingIcon, MicIcon, MicOffIcon, Hash, XIcon } from 'lucide-vue-next';
import { toast } from 'vue-sonner';
const softphone = useSoftphone();
const phoneNumber = ref('');
const showDialpad = ref(false);
const isMuted = ref(false);
const handleCall = async () => {
if (!phoneNumber.value) {
toast.error('Please enter a phone number');
return;
}
try {
await softphone.initiateCall(phoneNumber.value);
phoneNumber.value = '';
toast.success('Call initiated');
} catch (error: any) {
toast.error(error.message || 'Failed to initiate call');
}
};
const handleAccept = async () => {
if (!softphone.incomingCall.value) return;
try {
await softphone.acceptCall(softphone.incomingCall.value.callSid);
} catch (error: any) {
toast.error(error.message || 'Failed to accept call');
}
};
const handleReject = async () => {
if (!softphone.incomingCall.value) return;
try {
await softphone.rejectCall(softphone.incomingCall.value.callSid);
} catch (error: any) {
toast.error(error.message || 'Failed to reject call');
}
};
const handleEndCall = async () => {
if (!softphone.currentCall.value) return;
try {
await softphone.endCall(softphone.currentCall.value.callSid);
} catch (error: any) {
toast.error(error.message || 'Failed to end call');
}
};
// Debug: Test AI suggestions display
const testAiSuggestion = () => {
console.log('🧪 Testing AI suggestion display');
console.log('Current suggestions:', softphone.aiSuggestions.value);
// Add a test suggestion
softphone.aiSuggestions.value.unshift({
type: 'response',
text: '💡 Test suggestion: This is a test AI suggestion to verify UI display'
});
console.log('After test:', softphone.aiSuggestions.value);
toast.success('Test suggestion added');
};
const handleDtmf = async (digit: string) => {
if (!softphone.currentCall.value) return;
try {
await softphone.sendDtmf(softphone.currentCall.value.callSid, digit);
} catch (error: any) {
console.error('Failed to send DTMF:', error);
}
};
const toggleMute = () => {
isMuted.value = !isMuted.value;
// TODO: Implement actual audio muting
toast.info(isMuted.value ? 'Muted' : 'Unmuted');
};
const formatPhoneNumber = (number: string): string => {
if (!number) return '';
// Simple US format
const cleaned = number.replace(/\D/g, '');
if (cleaned.length === 11 && cleaned[0] === '1') {
return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`;
} else if (cleaned.length === 10) {
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
}
return number;
};
const formatDuration = (seconds?: number): string => {
if (!seconds) return '--:--';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
</script>

View File

@@ -1,33 +0,0 @@
<script setup lang="ts">
import { Check } from 'lucide-vue-next'
import { CheckboxIndicator, CheckboxRoot, type CheckboxRootEmits, type CheckboxRootProps, useForwardPropsEmits } from 'radix-vue'
import { computed, type HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<CheckboxRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CheckboxRoot
v-bind="forwarded"
:class="
cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
props.class,
)
"
>
<CheckboxIndicator class="flex h-full w-full items-center justify-center text-current">
<Check class="h-4 w-4" />
</CheckboxIndicator>
</CheckboxRoot>
</template>

View File

@@ -10,7 +10,8 @@ export const useApi = () => {
// In browser, use current hostname but with port 3000 for API
const currentHost = window.location.hostname
const protocol = window.location.protocol
return `${protocol}//${currentHost}:3000`
//return `${protocol}//${currentHost}:3000`
return `${protocol}//${currentHost}`
}
// Fallback for SSR
return config.public.apiBaseUrl

View File

@@ -0,0 +1,629 @@
import { ref, computed, onMounted, onUnmounted, shallowRef } from 'vue';
import { io, Socket } from 'socket.io-client';
import { Device, Call as TwilioCall } from '@twilio/voice-sdk';
import { useAuth } from './useAuth';
import { toast } from 'vue-sonner';
interface Call {
callSid: string;
direction: 'inbound' | 'outbound';
fromNumber: string;
toNumber: string;
status: string;
startedAt?: string;
duration?: number;
}
interface CallTranscript {
text: string;
isFinal: boolean;
timestamp: number;
}
interface AiSuggestion {
type: 'response' | 'action' | 'insight';
text: string;
data?: any;
}
// Module-level shared state for global access
const socket = ref<Socket | null>(null);
const twilioDevice = shallowRef<Device | null>(null);
const twilioCall = shallowRef<TwilioCall | null>(null);
const isConnected = ref(false);
const isOpen = ref(false);
const currentCall = ref<Call | null>(null);
const incomingCall = ref<Call | null>(null);
const transcript = ref<CallTranscript[]>([]);
const aiSuggestions = ref<AiSuggestion[]>([]);
const callHistory = ref<Call[]>([]);
const isInitialized = ref(false);
const isMuted = ref(false);
const volume = ref(100);
export function useSoftphone() {
const auth = useAuth();
// Get token and tenantId from localStorage
const getToken = () => {
if (typeof window === 'undefined') return null;
return localStorage.getItem('token');
};
const getTenantId = () => {
if (typeof window === 'undefined') return null;
return localStorage.getItem('tenantId');
};
// Computed properties
const isInCall = computed(() => currentCall.value !== null);
const hasIncomingCall = computed(() => incomingCall.value !== null);
const callStatus = computed(() => currentCall.value?.status || 'idle');
/**
* Request microphone permission explicitly
*/
const requestMicrophonePermission = async () => {
try {
// Check if mediaDevices is supported
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
toast.error('Microphone access requires HTTPS. Please access the app via https:// or use localhost for testing.');
console.error('navigator.mediaDevices not available. This typically means the page is not served over HTTPS.');
return false;
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Stop the stream immediately, we just wanted the permission
stream.getTracks().forEach(track => track.stop());
return true;
} catch (error: any) {
console.error('Microphone permission denied:', error);
if (error.name === 'NotAllowedError') {
toast.error('Microphone access denied. Please allow microphone access in your browser settings.');
} else if (error.name === 'NotFoundError') {
toast.error('No microphone found. Please connect a microphone and try again.');
} else {
toast.error('Microphone access is required for calls. Please ensure you are using HTTPS or localhost.');
}
return false;
}
};
/**
* Initialize Twilio Device
*/
const initializeTwilioDevice = async () => {
try {
// First, explicitly request microphone permission
const hasPermission = await requestMicrophonePermission();
if (!hasPermission) {
return;
}
const { api } = useApi();
console.log('Requesting Twilio token from /api/voice/token...');
const response = await api.get('/voice/token');
const token = response.data.token;
console.log('Token received, creating Device...');
// Log the token payload to see what identity is being used
try {
const tokenPayload = JSON.parse(atob(token.split('.')[1]));
console.log('Token identity:', tokenPayload.sub);
console.log('Token grants:', tokenPayload.grants);
} catch (e) {
console.log('Could not parse token payload');
}
twilioDevice.value = new Device(token, {
logLevel: 1,
codecPreferences: ['opus', 'pcmu'],
enableImprovedSignalingErrorPrecision: true,
edge: 'ashburn',
});
// Device events
twilioDevice.value.on('registered', () => {
console.log('✓ Twilio Device registered - ready to receive calls');
toast.success('Softphone ready');
});
twilioDevice.value.on('unregistered', () => {
console.log('⚠ Twilio Device unregistered');
});
twilioDevice.value.on('error', (error) => {
console.error('❌ Twilio Device error:', error);
toast.error('Device error: ' + error.message);
});
twilioDevice.value.on('incoming', (call: TwilioCall) => {
console.log('🔔 Twilio Device INCOMING event received:', call.parameters);
console.log('Call parameters:', {
CallSid: call.parameters.CallSid,
From: call.parameters.From,
To: call.parameters.To,
});
twilioCall.value = call;
// Update state
incomingCall.value = {
callSid: call.parameters.CallSid || '',
direction: 'inbound',
fromNumber: call.parameters.From || '',
toNumber: call.parameters.To || '',
status: 'ringing',
};
// Open softphone dialog
isOpen.value = true;
// Show notification
toast.info(`Incoming call from ${incomingCall.value.fromNumber}`, {
duration: 30000,
});
// Setup call handlers
setupCallHandlers(call);
// Play ringtone
playRingtone();
});
// Register the device
console.log('Registering Twilio Device...');
await twilioDevice.value.register();
console.log('✓ Twilio Device register() completed');
console.log('Device identity:', twilioDevice.value.identity);
console.log('Device state:', twilioDevice.value.state);
} catch (error: any) {
console.error('Failed to initialize Twilio Device:', error);
toast.error('Failed to initialize voice device: ' + error.message);
}
};
/**
* Setup handlers for a Twilio call
*/
const setupCallHandlers = (call: TwilioCall) => {
call.on('accept', () => {
console.log('Call accepted');
currentCall.value = {
callSid: call.parameters.CallSid || '',
direction: twilioCall.value === call ? 'inbound' : 'outbound',
fromNumber: call.parameters.From || '',
toNumber: call.parameters.To || '',
status: 'in-progress',
startedAt: new Date().toISOString(),
};
incomingCall.value = null;
});
call.on('disconnect', () => {
console.log('Call disconnected');
currentCall.value = null;
twilioCall.value = null;
});
call.on('cancel', () => {
console.log('Call cancelled');
incomingCall.value = null;
twilioCall.value = null;
});
call.on('reject', () => {
console.log('Call rejected');
incomingCall.value = null;
twilioCall.value = null;
});
call.on('error', (error) => {
console.error('Call error:', error);
toast.error('Call error: ' + error.message);
});
};
/**
* Initialize WebSocket connection
*/
const connect = () => {
const token = getToken();
if (socket.value?.connected || !token) {
return;
}
// Use same pattern as useApi to preserve subdomain for multi-tenant
const getBackendUrl = () => {
if (typeof window !== 'undefined') {
const currentHost = window.location.hostname;
const protocol = window.location.protocol;
return `${protocol}//${currentHost}`;
}
return 'http://localhost:3000';
};
// Connect to /voice namespace
socket.value = io(`${getBackendUrl()}/voice`, {
auth: {
token: token,
},
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: 5,
});
// Connection events
socket.value.on('connect', () => {
console.log('🔌 Softphone WebSocket connected');
console.log('📋 Token payload (check userId):', parseJwt(token));
isConnected.value = true;
// Initialize Twilio Device after WebSocket connects
initializeTwilioDevice();
});
socket.value.on('disconnect', () => {
console.log('Softphone WebSocket disconnected');
isConnected.value = false;
});
socket.value.on('connect_error', (error) => {
console.error('Softphone connection error:', error);
toast.error('Failed to connect to voice service');
});
// Call events
socket.value.on('call:incoming', handleIncomingCall);
socket.value.on('call:initiated', handleCallInitiated);
socket.value.on('call:accepted', handleCallAccepted);
socket.value.on('call:rejected', handleCallRejected);
socket.value.on('call:ended', handleCallEnded);
socket.value.on('call:update', handleCallUpdate);
socket.value.on('call:error', handleCallError);
socket.value.on('call:state', handleCallState);
// AI events
socket.value.on('ai:transcript', handleAiTranscript);
socket.value.on('ai:suggestion', (data: any) => {
console.log('🎯 AI Suggestion received:', data.text);
handleAiSuggestion(data);
});
socket.value.on('ai:action', handleAiAction);
isInitialized.value = true;
};
/**
* Disconnect WebSocket
*/
const disconnect = () => {
if (socket.value) {
socket.value.disconnect();
socket.value = null;
isConnected.value = false;
isInitialized.value = false;
}
};
/**
* Open softphone dialog
*/
const open = () => {
if (!isInitialized.value) {
connect();
}
isOpen.value = true;
};
/**
* Close softphone dialog
*/
const close = () => {
isOpen.value = false;
};
/**
* Initiate outbound call using Twilio Device
*/
const initiateCall = async (toNumber: string) => {
if (!twilioDevice.value) {
toast.error('Voice device not initialized');
return;
}
try {
// Make call using Twilio Device
const call = await twilioDevice.value.connect({
params: {
To: toNumber,
}
});
twilioCall.value = call;
setupCallHandlers(call);
toast.success('Calling ' + toNumber);
} catch (error: any) {
console.error('Failed to initiate call:', error);
toast.error('Failed to initiate call: ' + error.message);
throw error;
}
};
/**
* Accept incoming call
*/
const acceptCall = async (callSid: string) => {
console.log('📞 Accepting call - callSid:', callSid);
console.log('twilioCall.value:', twilioCall.value);
if (!twilioCall.value) {
console.error('❌ No incoming call to accept - twilioCall.value is null');
toast.error('No incoming call');
return;
}
try {
console.log('Calling twilioCall.value.accept()...');
await twilioCall.value.accept();
console.log('✓ Call accepted successfully');
toast.success('Call accepted');
} catch (error: any) {
console.error('❌ Failed to accept call:', error);
toast.error('Failed to accept call: ' + error.message);
}
};
/**
* Reject incoming call
*/
const rejectCall = async (callSid: string) => {
if (!twilioCall.value) {
toast.error('No incoming call');
return;
}
try {
twilioCall.value.reject();
incomingCall.value = null;
twilioCall.value = null;
toast.info('Call rejected');
} catch (error: any) {
console.error('Failed to reject call:', error);
toast.error('Failed to reject call: ' + error.message);
}
};
/**
* End active call
*/
const endCall = async (callSid: string) => {
if (!twilioCall.value) {
toast.error('No active call');
return;
}
try {
twilioCall.value.disconnect();
currentCall.value = null;
twilioCall.value = null;
toast.info('Call ended');
} catch (error: any) {
console.error('Failed to end call:', error);
toast.error('Failed to end call: ' + error.message);
}
};
/**
* Toggle mute
*/
const toggleMute = () => {
if (!twilioCall.value) return;
isMuted.value = !isMuted.value;
twilioCall.value.mute(isMuted.value);
};
/**
* Send DTMF tone
*/
const sendDtmf = async (callSid: string, digit: string) => {
if (!twilioCall.value) {
return;
}
twilioCall.value.sendDigits(digit);
};
// Event handlers
const handleIncomingCall = (data: Call) => {
// Socket.IO notification that a call is coming
// The actual call object will come from Twilio Device SDK's 'incoming' event
console.log('Socket.IO call notification:', data);
// Don't set incomingCall here - wait for the Device SDK incoming event
};
const handleCallInitiated = (data: any) => {
console.log('Call initiated:', data);
currentCall.value = {
callSid: data.callSid,
direction: 'outbound',
fromNumber: '',
toNumber: data.toNumber,
status: data.status,
};
transcript.value = [];
aiSuggestions.value = [];
};
const handleCallAccepted = (data: any) => {
console.log('Call accepted:', data);
if (incomingCall.value?.callSid === data.callSid) {
currentCall.value = incomingCall.value;
if (currentCall.value) {
currentCall.value.status = 'in-progress';
}
incomingCall.value = null;
}
stopRingtone();
};
const handleCallRejected = (data: any) => {
console.log('Call rejected:', data);
if (incomingCall.value?.callSid === data.callSid) {
incomingCall.value = null;
}
stopRingtone();
};
const handleCallEnded = (data: any) => {
console.log('Call ended:', data);
if (currentCall.value?.callSid === data.callSid) {
currentCall.value = null;
}
if (incomingCall.value?.callSid === data.callSid) {
incomingCall.value = null;
}
stopRingtone();
toast.info('Call ended');
};
const handleCallUpdate = (data: any) => {
console.log('Call update:', data);
if (currentCall.value?.callSid === data.callSid) {
currentCall.value = { ...currentCall.value, ...data };
}
};
const handleCallError = (data: any) => {
console.error('Call error:', data);
toast.error(data.message || 'Call error occurred');
};
const handleCallState = (data: Call) => {
console.log('Call state:', data);
if (data.status === 'in-progress') {
currentCall.value = data;
}
};
const handleAiTranscript = (data: { transcript: string; isFinal: boolean }) => {
transcript.value.push({
text: data.transcript,
isFinal: data.isFinal,
timestamp: Date.now(),
});
// Keep only last 50 transcript items
if (transcript.value.length > 50) {
transcript.value = transcript.value.slice(-50);
}
};
const handleAiSuggestion = (data: AiSuggestion) => {
aiSuggestions.value.unshift(data);
// Keep only last 10 suggestions
if (aiSuggestions.value.length > 10) {
aiSuggestions.value = aiSuggestions.value.slice(0, 10);
}
};
// Helper to parse JWT (for debugging)
const parseJwt = (token: string) => {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch (e) {
return null;
}
};
const handleAiAction = (data: any) => {
console.log('AI action:', data);
toast.info(`AI: ${data.action}`);
};
// Ringtone management
let ringtoneAudio: HTMLAudioElement | null = null;
const playRingtone = () => {
// Optional: Play a simple beep tone using Web Audio API
// This is a nice-to-have enhancement but not required for incoming calls to work
try {
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
// Phone ringtone frequency (440 Hz)
oscillator.frequency.value = 440;
oscillator.type = 'sine';
const now = audioContext.currentTime;
gainNode.gain.setValueAtTime(0.15, now);
gainNode.gain.setValueAtTime(0, now + 0.5);
gainNode.gain.setValueAtTime(0.15, now + 1.0);
gainNode.gain.setValueAtTime(0, now + 1.5);
oscillator.start(now);
oscillator.stop(now + 2);
} catch (error) {
// Silent fail - incoming call still works without audio
console.debug('Audio notification skipped:', error);
}
};
const stopRingtone = () => {
if (ringtoneAudio) {
ringtoneAudio.pause();
ringtoneAudio = null;
}
};
// Auto-connect on mount if token is available
onMounted(() => {
if (getToken() && !isInitialized.value) {
connect();
}
});
// Cleanup on unmount
onUnmounted(() => {
stopRingtone();
});
return {
// State
isOpen,
isConnected,
isInCall,
hasIncomingCall,
currentCall,
incomingCall,
transcript,
aiSuggestions,
callStatus,
callHistory,
isMuted,
volume,
// Actions
open,
close,
initiateCall,
acceptCall,
rejectCall,
endCall,
sendDtmf,
toggleMute,
connect,
disconnect,
};
}

View File

@@ -2,6 +2,7 @@
import { ref } from 'vue'
import AppSidebar from '@/components/AppSidebar.vue'
import AIChatBar from '@/components/AIChatBar.vue'
import SoftphoneDialog from '@/components/SoftphoneDialog.vue'
import {
Breadcrumb,
BreadcrumbItem,
@@ -75,6 +76,9 @@ const breadcrumbs = computed(() => {
<!-- AI Chat Bar Component -->
<AIChatBar />
<!-- Softphone Dialog (Global) -->
<SoftphoneDialog />
</SidebarInset>
</SidebarProvider>
</template>

View File

@@ -67,4 +67,12 @@ export default defineNuxtConfig({
compatibilityDate: '2024-01-01',
css: ['~/assets/css/main.css'],
components: [
{
path: '~/components',
pathPrefix: false,
extensions: ['.vue'],
},
],
})

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@
"dependencies": {
"@internationalized/date": "^3.10.1",
"@nuxtjs/tailwindcss": "^6.11.4",
"@twilio/voice-sdk": "^2.11.2",
"@vueuse/core": "^10.11.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
@@ -26,6 +27,7 @@
"radix-vue": "^1.4.1",
"reka-ui": "^2.6.1",
"shadcn-nuxt": "^2.3.3",
"socket.io-client": "^4.8.3",
"tailwind-merge": "^2.2.1",
"vue": "^3.4.15",
"vue-router": "^4.2.5",

View File

@@ -0,0 +1,201 @@
<template>
<NuxtLayout name="default">
<main class="container mx-auto px-4 py-8">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold">Integrations</h1>
<p class="text-muted-foreground mt-2">
Configure third-party service integrations for your tenant
</p>
</div>
<Button @click="saveConfig" :disabled="saving">
<Save class="mr-2 h-4 w-4" />
{{ saving ? 'Saving...' : 'Save Configuration' }}
</Button>
</div>
<!-- Services Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Twilio Configuration -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Phone class="w-5 h-5" />
Twilio Voice
</CardTitle>
<CardDescription>
Configure Twilio for voice calling
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="twilio-account-sid">Account SID</Label>
<Input
id="twilio-account-sid"
v-model="twilioConfig.accountSid"
placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
/>
</div>
<div class="space-y-2">
<Label for="twilio-auth-token">Auth Token</Label>
<Input
id="twilio-auth-token"
v-model="twilioConfig.authToken"
type="password"
placeholder="Enter your Twilio auth token"
/>
</div>
<div class="space-y-2">
<Label for="twilio-phone-number">Phone Number</Label>
<Input
id="twilio-phone-number"
v-model="twilioConfig.phoneNumber"
placeholder="+1234567890"
/>
</div>
<div class="space-y-2">
<Label for="twilio-api-key">API Key SID (for browser calls)</Label>
<Input
id="twilio-api-key"
v-model="twilioConfig.apiKey"
placeholder="SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
/>
</div>
<div class="space-y-2">
<Label for="twilio-api-secret">API Secret</Label>
<Input
id="twilio-api-secret"
v-model="twilioConfig.apiSecret"
type="password"
placeholder="Enter your API Key Secret"
/>
</div>
<div class="space-y-2">
<Label for="twilio-twiml-app">TwiML App SID</Label>
<Input
id="twilio-twiml-app"
v-model="twilioConfig.twimlAppSid"
placeholder="APxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
/>
</div>
</CardContent>
</Card>
<!-- OpenAI Configuration -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Bot class="w-5 h-5" />
OpenAI Realtime
</CardTitle>
<CardDescription>
Configure OpenAI for AI features
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="openai-api-key">API Key</Label>
<Input
id="openai-api-key"
v-model="openaiConfig.apiKey"
type="password"
placeholder="sk-..."
/>
</div>
<div class="space-y-2">
<Label for="openai-model">Model</Label>
<Input
id="openai-model"
v-model="openaiConfig.model"
placeholder="gpt-4o-realtime-preview"
/>
</div>
<div class="space-y-2">
<Label for="openai-voice">Voice</Label>
<select
id="openai-voice"
v-model="openaiConfig.voice"
class="w-full px-3 py-2 border rounded-md bg-background"
>
<option value="alloy">Alloy</option>
<option value="echo">Echo</option>
<option value="fable">Fable</option>
<option value="onyx">Onyx</option>
<option value="nova">Nova</option>
<option value="shimmer">Shimmer</option>
</select>
</div>
</CardContent>
</Card>
</div>
</main>
</NuxtLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card';
import { Input } from '~/components/ui/input';
import { Label } from '~/components/ui/label';
import { Button } from '~/components/ui/button';
import { Phone, Bot, Save } from 'lucide-vue-next';
import { useApi } from '~/composables/useApi';
import { toast } from 'vue-sonner';
const { api } = useApi();
const twilioConfig = ref({
accountSid: '',
authToken: '',
phoneNumber: '',
apiKey: '',
apiSecret: '',
twimlAppSid: '',
});
const openaiConfig = ref({
apiKey: '',
model: 'gpt-4o-realtime-preview',
voice: 'alloy',
});
const saving = ref(false);
const loading = ref(true);
onMounted(async () => {
try {
const response = await api.get('/tenant/integrations');
if (response.data) {
if (response.data.twilio) {
twilioConfig.value = { ...twilioConfig.value, ...response.data.twilio };
}
if (response.data.openai) {
openaiConfig.value = { ...openaiConfig.value, ...response.data.openai };
}
}
} catch (error: any) {
console.error('Failed to load configuration:', error);
} finally {
loading.value = false;
}
});
const saveConfig = async () => {
saving.value = true;
try {
const integrationsConfig = {
twilio: twilioConfig.value,
openai: openaiConfig.value,
};
await api.put('/tenant/integrations', { integrationsConfig });
toast.success('Configuration saved successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to save configuration');
} finally {
saving.value = false;
}
};
</script>