chore: voice debug component

This commit is contained in:
Sojan
2025-05-07 03:04:51 -07:00
parent d6dd8efe46
commit 5b77618a43
3 changed files with 664 additions and 1 deletions

View File

@@ -12,6 +12,7 @@ import Message from './Message.vue';
import NextMessageList from 'next/message/MessageList.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import VoiceTimelineView from './VoiceTimelineView.vue';
// stores and apis
import { mapGetters } from 'vuex';
@@ -48,6 +49,7 @@ export default {
Banner,
ConversationLabelSuggestion,
NextButton,
VoiceTimelineView,
},
mixins: [inboxMixin],
props: {
@@ -146,6 +148,9 @@ export default {
inbox() {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
isAVoiceChannel() {
return this.inbox?.channel_type === INBOX_TYPES.VOICE;
},
typingUsersList() {
const userList = this.$store.getters[
'conversationTypingStatus/getUserList'
@@ -647,9 +652,15 @@ export default {
/>
</div>
</div>
<VoiceTimelineView
v-if="isAVoiceChannel"
:conversation-id="currentChat.id"
class="mb-4"
/>
<ReplyBox
v-model:popout-reply-box="isPopOutReplyBox"
@toggle-popout="showPopOutReplyBox"
:is-private-note-only="isAVoiceChannel"
/>
</div>
</div>

View File

@@ -66,6 +66,12 @@ export default {
WootMessageEditor,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
isPrivateNoteOnly: {
type: Boolean,
default: false,
},
},
emits: ['update:popoutReplyBox', 'togglePopout'],
setup() {
const {
@@ -102,7 +108,7 @@ export default {
recordingAudioState: '',
recordingAudioDurationText: '',
isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY,
replyType: REPLY_EDITOR_MODES.NOTE,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '',

View File

@@ -0,0 +1,646 @@
<script>
import { defineComponent, ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { mapGetters } from 'vuex';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default defineComponent({
components: {
NextButton,
},
name: 'VoiceTimelineView',
props: {
conversationId: {
type: Number,
required: true,
},
isCallEnded: {
type: Boolean,
default: false,
},
},
data() {
// Use 01:34 (94 seconds) as our fallback/demo duration throughout the component
const defaultDuration = '01:34';
const defaultSeconds = 94;
return {
currentTime: '00:00',
duration: defaultDuration, // Default duration that will be shown even if API returns nothing
isPlaying: false,
// Generate more realistic waveform data with varied heights
waveformData: this.generateRealisticWaveform(180),
// Define different waveform segments for caller and agent with speaking patterns
waveformSegments: [
{ start: '00:00', end: '00:20', type: 'caller', data: this.generateRealisticWaveform(40) },
{ start: '00:21', end: '00:30', type: 'agent', data: this.generateRealisticWaveform(20) },
{ start: '00:31', end: '00:45', type: 'caller', data: this.generateRealisticWaveform(30) },
{ start: '00:46', end: '00:58', type: 'agent', data: this.generateRealisticWaveform(26) },
{ start: '00:59', end: '01:10', type: 'caller', data: this.generateRealisticWaveform(24) },
{ start: '01:11', end: '01:23', type: 'agent', data: this.generateRealisticWaveform(24) },
{ start: '01:24', end: '01:34', type: 'caller', data: this.generateRealisticWaveform(20) },
],
timeLabels: ['00:00', '00:12', '00:24', '00:36', '00:48', '01:00', '01:12', '01:24'],
markers: [
{ time: '00:15', label: 'systemEvent', text: 'Tool call: Customer identity verified (method: Voice)', icon: 'i-ph-check-circle-fill', ms: '4598ms' },
{ time: '00:32', label: 'systemEvent', text: 'Tool call: Order details retrieved (order_id: #38291)', icon: 'i-ph-database-fill', ms: '7436ms' },
{ time: '00:48', label: 'systemEvent', text: 'Tool call: Customer account verified (id: 57482)', icon: 'i-ph-user-circle-fill', ms: '22060ms' },
{ time: '01:05', label: 'systemEvent', text: 'Tool call: Address updated (1234 Main St, NY)', icon: 'i-ph-database-fill', ms: '84521ms' },
{ time: '01:20', label: 'systemEvent', text: 'Tool call: Email sent (template: address_update)', icon: 'i-ph-envelope-fill', ms: '3250ms' },
],
timer: null,
currentSeconds: 0,
totalSeconds: defaultSeconds, // Default duration in seconds
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
}),
playButtonIcon() {
return this.isPlaying ? 'pause' : 'play_arrow';
},
playButtonLabel() {
return this.isPlaying ? 'Pause' : 'Play';
},
progressPercentage() {
return this.totalSeconds > 0 ? (this.currentSeconds / this.totalSeconds) * 100 : 0;
},
callData() {
return this.currentChat?.additional_attributes || {};
},
callDuration() {
// Get call duration in seconds from call data
const duration = this.callData?.duration || 94; // Default to 94 seconds (01:34) if not available
return parseInt(duration, 10);
},
formattedCallDuration() {
// Always return a formatted duration, never 00:00
const formattedTime = this.formatTime(this.callDuration);
return formattedTime === '00:00' ? '01:34' : formattedTime;
},
shouldShowTimeline() {
if (this.isCallEnded) return true;
// If prop not provided, check call status from conversation data
const callStatus = this.callData?.call_status;
return callStatus === 'completed' || callStatus === 'ended';
},
},
watch: {
conversationId: {
immediate: true,
handler() {
this.loadCallData();
},
},
currentSeconds(newVal) {
this.currentTime = this.formatTime(newVal);
},
callDuration: {
handler(newVal) {
if (newVal && newVal !== this.totalSeconds) {
this.totalSeconds = newVal;
this.duration = this.formattedCallDuration;
// Ensure duration is never empty
if (!this.duration || this.duration === '00:00') {
this.duration = '01:34'; // Fallback default
}
console.log('Call duration updated to:', this.duration);
}
},
immediate: true
}
},
methods: {
generateRealisticWaveform(length) {
// Create a waveform pattern that matches the marked style in the reference image
// with vertical bars grouped together in sections
const baseAmplitude = 40;
const minAmplitude = 10;
const waveform = [];
// Generate segments of active speech with groups of bars
let i = 0;
while (i < length) {
// Create a speech segment (cluster of vertical bars)
const segmentSize = Math.floor(Math.random() * 8) + 5; // 5-12 bars per segment
// For each bar in the segment
for (let j = 0; j < segmentSize && i < length; j++, i++) {
// Create bars of varying heights in a pattern
const barHeight = j % 2 === 0
? Math.random() * baseAmplitude + minAmplitude // Taller bars
: Math.random() * (baseAmplitude / 2) + minAmplitude; // Shorter bars
waveform.push(barHeight);
}
// Add a gap between segments (spaces between bar clusters)
const gapSize = Math.floor(Math.random() * 4) + 2; // 2-5 bars of space
for (let j = 0; j < gapSize && i < length; j++, i++) {
waveform.push(Math.random() * (minAmplitude / 5)); // Very low height for gaps
}
}
return waveform;
},
togglePlayPause() {
this.isPlaying = !this.isPlaying;
if (this.isPlaying) {
this.startTimer();
} else {
this.stopTimer();
}
},
startTimer() {
if (this.timer) return;
this.timer = setInterval(() => {
this.currentSeconds += 0.1;
if (this.currentSeconds >= this.totalSeconds) {
this.currentSeconds = this.totalSeconds;
this.stopTimer();
this.isPlaying = false;
}
}, 100);
},
stopTimer() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
},
formatTime(seconds) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
},
getMarkerPosition(marker) {
return this.getTimePercentage(marker.time);
},
getTimePercentage(timeString) {
const minutes = parseInt(timeString.split(':')[0], 10);
const seconds = parseInt(timeString.split(':')[1], 10);
const totalSeconds = minutes * 60 + seconds;
return (totalSeconds / this.totalSeconds) * 100;
},
timeToSeconds(timeString) {
const minutes = parseInt(timeString.split(':')[0], 10);
const seconds = parseInt(timeString.split(':')[1], 10);
return minutes * 60 + seconds;
},
getMarkerClass(marker) {
switch (marker.label) {
case 'assistantSentSMS':
return 'marker--sms';
case 'userResponse':
return 'marker--user';
case 'agentMessage':
return 'marker--agent';
case 'systemEvent':
return 'marker--system';
case 'callEnded':
return 'marker--end';
default:
return '';
}
},
setProgress(event) {
const timeline = event.currentTarget;
const rect = timeline.getBoundingClientRect();
const offsetX = event.clientX - rect.left;
const percentage = (offsetX / rect.width) * 100;
this.currentSeconds = (percentage / 100) * this.totalSeconds;
},
loadCallData() {
// Always set a default value first
this.duration = '01:34'; // Default fallback
this.totalSeconds = 94; // Default 01:34 in seconds
// If we have actual call duration, use it
if (this.callDuration && this.callDuration > 0) {
this.totalSeconds = this.callDuration;
this.duration = this.formattedCallDuration;
}
console.log('Call data loaded for conversation:', this.conversationId);
console.log('Call duration:', this.duration);
console.log('Sample conversation events:', this.markers.map(m => m.text).join(', '));
},
extractCallEvents() {
// Simulate real implementation - would parse messages to find call events
const messages = this.currentChat?.messages || [];
// In a real implementation, we would find call events in the messages
console.log('Analyzing conversation messages for call events and tool calls...');
console.log('Looking through', messages.length, 'messages for activities');
// This is just for demonstration - actual implementation would process real events
const eventTypes = [
'call_initiated',
'authentication_successful',
'customer_verified',
'tool_call:database_lookup',
'tool_call:payment_processed',
'tool_call:email_notification_sent',
'call_ended'
];
console.log('Found call events and tool calls:', eventTypes.join(', '));
// The actual markers we're showing in the UI simulate these events
console.log('Processing tool calls:');
console.log('- Tool call: Customer account verified (customer_id: 57482)');
console.log('- Tool call: Address updated (new_address: 1234 Main St, Apt 5B)');
console.log('- Tool call: Confirmation email sent (template: address_update)');
},
// Determine the active speaker at the current time
getActiveSpeaker() {
// In a real implementation, this would check the actual call data
// For now, we'll use the predefined segments to determine who's speaking
const currentTimeStr = this.formatTime(this.currentSeconds);
// Find the segment that contains the current time
const activeSegment = this.waveformSegments.find(segment => {
const startSeconds = this.timeToSeconds(segment.start);
const endSeconds = this.timeToSeconds(segment.end);
return this.currentSeconds >= startSeconds && this.currentSeconds <= endSeconds;
});
// Return the speaker type, or null if not found
return activeSegment ? activeSegment.type : null;
},
// Check if a specific speaker is active
isSpeakerActive(speakerType) {
return this.getActiveSpeaker() === speakerType;
},
},
mounted() {
this.loadCallData();
},
beforeUnmount() {
this.stopTimer();
},
});
</script>
<template>
<div v-if="shouldShowTimeline" class="voice-timeline-view">
<div class="timeline-header">
<div class="time-display">
<span class="current-time">{{ currentTime }}</span> / <span class="total-time">{{ duration || formattedCallDuration }}</span>
</div>
<div class="control-buttons">
<NextButton
@click="togglePlayPause"
v-tooltip.top-start="playButtonLabel"
:icon="isPlaying ? 'i-ph-pause-fill' : 'i-ph-play-fill'"
slate
faded
sm
round
/>
</div>
</div>
<div
class="timeline-container"
@click="setProgress"
>
<!-- Progress Indicator -->
<div
class="progress-indicator"
:style="{ left: `${progressPercentage}%` }"
></div>
<!-- Agent Timeline (Top) -->
<div class="agent-timeline">
<div class="waveform-bars">
<div
v-for="(value, index) in waveformData.slice(waveformData.length / 2)"
:key="'agent-' + index"
class="waveform-bar"
:class="{
'active-agent-bar': isSpeakerActive('agent'),
'inactive-agent-bar': !isSpeakerActive('agent'),
'progress-passed': index / (waveformData.length / 2) < progressPercentage / 100
}"
:style="{
height: `${value}%`
}"
></div>
</div>
</div>
<!-- Contact Timeline (Middle) -->
<div class="contact-timeline">
<div class="waveform-bars">
<div
v-for="(value, index) in waveformData.slice(0, waveformData.length / 2)"
:key="'contact-' + index"
class="waveform-bar"
:class="{
'active-contact-bar': isSpeakerActive('caller'),
'inactive-contact-bar': !isSpeakerActive('caller'),
'progress-passed': index / (waveformData.length / 2) < progressPercentage / 100
}"
:style="{
height: `${value}%`
}"
></div>
</div>
</div>
<!-- Events Timeline overlaid at bottom -->
<div class="events-timeline">
<!-- Event Markers -->
<div
v-for="(marker, index) in markers"
:key="index"
class="event-marker"
:class="getMarkerClass(marker)"
:style="{ left: `${getMarkerPosition(marker)}%` }"
>
<div class="marker-dot">
<span v-if="marker.icon" :class="[marker.icon, 'marker-icon']"></span>
</div>
<!-- Tooltips for all events -->
<div
class="marker-tooltip"
:class="[
`marker-tooltip-${marker.label}`,
{ 'marker-tooltip-visible': isPlaying && currentSeconds >= timeToSeconds(marker.time) && currentSeconds <= timeToSeconds(marker.time) + 2 }
]"
>
<div class="font-semibold mb-1">Tool Call</div>
<div class="marker-text">
<div class="whitespace-normal">
{{ marker.text.replace('Tool call:', '').trim() }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.voice-timeline-view {
@apply relative flex flex-col px-5 pt-3 pb-5 mb-4 mx-4 h-[220px] border border-n-slate-3 dark:border-n-slate-7 rounded-lg bg-n-solid-2 shadow-sm;
font-family: Inter, system-ui, -apple-system, sans-serif;
}
.timeline-header {
@apply flex justify-between items-center mb-2 px-1;
.control-buttons {
@apply flex items-center justify-center;
}
.time-display {
@apply text-sm font-medium text-n-slate-11;
.current-time {
@apply font-medium text-n-slate-11;
}
.total-time {
@apply font-normal text-n-slate-11;
}
}
}
.timeline-container {
@apply relative flex-1 bg-n-solid-2 rounded-lg overflow-hidden cursor-pointer shadow-sm;
display: flex;
flex-direction: column;
height: 190px;
}
.time-labels {
@apply absolute top-0 left-0 right-0 h-7 z-10;
.time-label {
@apply absolute text-sm transform -translate-x-1/2 font-medium text-n-blue-8 dark:text-n-blue-5;
top: 4px;
.h-3 {
@apply bg-n-slate-3 dark:bg-n-slate-6;
}
}
}
.progress-indicator {
@apply absolute top-0 bottom-0 w-[1.5px] z-30 bg-n-blue-7 dark:bg-n-blue-5;
box-shadow: 0 0 3px rgba(var(--blue-7), 0.5);
}
/* Agent Timeline (Top) */
.agent-timeline {
@apply relative h-[50px] px-2 mt-1 bg-n-blue-2 dark:bg-n-blue-7/20 rounded-xl;
.waveform-bars {
@apply flex items-end h-full relative z-10;
}
.waveform-bar {
@apply flex-1 mx-[0.5px] transition-all duration-300 rounded-sm;
min-width: 2px;
}
}
/* Contact Timeline (Middle) */
.contact-timeline {
@apply relative h-[55px] px-2 mt-3 bg-n-slate-2 dark:bg-n-slate-7/20 rounded-xl; /* Added background */
.waveform-bars {
@apply flex items-end h-full relative z-10;
}
.waveform-bar {
@apply flex-1 mx-[0.5px] transition-all duration-300 rounded-sm;
min-width: 2px;
}
}
/* Waveform bar states */
.waveform-bar {
@apply rounded-sm mx-[0.5px] transition-all duration-300 min-w-[2.5px];
}
.active-agent-bar {
@apply bg-n-blue-7 dark:bg-n-blue-6;
}
.inactive-agent-bar {
@apply bg-n-blue-5 dark:bg-n-blue-5;
}
.active-contact-bar {
@apply bg-n-slate-7 dark:bg-n-slate-6;
}
.inactive-contact-bar {
@apply bg-n-slate-4 dark:bg-n-slate-5;
}
.progress-passed {
@apply opacity-100;
}
.waveform-bar:not(.progress-passed) {
@apply opacity-40;
}
/* Events Timeline (Bottom) */
.events-timeline {
@apply absolute bottom-0 left-0 right-0 h-[60px] px-2 z-20;
}
.event-marker {
@apply absolute w-0 z-30;
/* All event markers are positioned at the bottom in the events timeline */
bottom: 15px;
.marker-dot {
@apply absolute -translate-x-1/2 size-8 rounded-full flex items-center justify-center shadow-lg;
top: -16px;
}
.marker-icon {
@apply text-[16px] font-bold;
}
/* Apply bright Tailwind color classes to each marker icon type */
.marker--systemEvent .marker-icon {
@apply text-n-slate-1;
}
.marker--agentMessage .marker-icon {
@apply text-n-slate-1;
}
.marker--userResponse .marker-icon {
@apply text-n-slate-12;
}
.marker--callEnded .marker-icon {
@apply text-n-slate-1;
}
.marker-tooltip {
@apply absolute -translate-x-1/2 p-3 rounded-md shadow-xl text-n-slate-1 text-sm min-w-[150px] max-w-[250px] transition-all duration-200 z-50 opacity-0 invisible bg-n-slate-12;
bottom: 40px;
&.marker-tooltip-visible {
@apply opacity-100 visible;
}
.marker-text {
@apply text-sm whitespace-normal font-normal text-n-slate-1;
}
.marker-time {
@apply text-xs text-n-slate-2 mt-1 font-medium;
}
&:after {
content: '';
@apply absolute w-0 h-0 border-l-[6px] border-l-transparent border-t-[6px] left-1/2 -translate-x-1/2;
border-top-color: #1E293B; /* Consistent color for both light/dark modes */
bottom: -6px;
}
/* Improve positioning when tooltip is near the edge */
&.marker-tooltip-left {
@apply -left-[50px];
&:after {
@apply left-[calc(50%+50px)];
}
}
&.marker-tooltip-right {
@apply -right-[50px];
&:after {
@apply right-[calc(50%+50px)];
}
}
}
&:hover {
.marker-tooltip {
@apply z-50 opacity-100 visible;
}
.marker-dot {
@apply transform scale-125;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
}
}
/* Marker type styling with bright Tailwind color classes */
&.marker--systemEvent .marker-dot {
@apply bg-n-iris-11;
}
&.marker--agentMessage .marker-dot {
@apply bg-n-blue-11;
}
&.marker--userResponse .marker-dot {
@apply bg-n-amber-11;
}
&.marker--callEnded .marker-dot {
@apply bg-n-ruby-11;
}
/* Tooltip type styling with bright Tailwind color classes */
&.marker-tooltip-systemEvent {
@apply bg-n-iris-11;
&:after {
@apply border-t-n-iris-11;
}
}
&.marker-tooltip-agentMessage {
@apply bg-n-blue-11;
&:after {
@apply border-t-n-blue-11;
}
}
&.marker-tooltip-userResponse {
@apply bg-n-amber-11;
&:after {
@apply border-t-n-amber-11;
}
}
&.marker-tooltip-callEnded {
@apply bg-n-ruby-11;
&:after {
@apply border-t-n-ruby-11;
}
}
}
</style>