Add twilio softphone with integrated AI assistant
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
300
frontend/components/SoftphoneDialog.vue
Normal file
300
frontend/components/SoftphoneDialog.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
629
frontend/composables/useSoftphone.ts
Normal file
629
frontend/composables/useSoftphone.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -67,4 +67,12 @@ export default defineNuxtConfig({
|
||||
compatibilityDate: '2024-01-01',
|
||||
|
||||
css: ['~/assets/css/main.css'],
|
||||
|
||||
components: [
|
||||
{
|
||||
path: '~/components',
|
||||
pathPrefix: false,
|
||||
extensions: ['.vue'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
993
frontend/package-lock.json
generated
993
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
201
frontend/pages/settings/integrations.vue
Normal file
201
frontend/pages/settings/integrations.vue
Normal 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>
|
||||
Reference in New Issue
Block a user