Files
agent-ui/frontend/src/components/transcript-debug/VoiceMicButton.vue
josedario87 220d595568 feat: voice mic, pixel life layer, enhanced transcript-debug UX
VoiceMicButton component, PixelLife aquatic layer, improved UserMessageBubble
with voice display, AgentBadge terminal switcher, ChatContainer voice integration,
FloatingTranscriptDebug ocean life enhancements, and terminal registry support.
Remove traefik config.
2026-02-20 12:12:53 -06:00

192 lines
5.2 KiB
Vue

<script setup lang="ts">
import { ref, onBeforeUnmount } from 'vue'
const props = defineProps<{
isRecording: boolean
voiceMode: 'web' | 'whisper'
whisperStatus: 'offline' | 'loading' | 'ready'
disabled?: boolean
}>()
const emit = defineEmits<{
start: []
stop: []
}>()
// ── Interaction state machine ──
// idle → pointerdown → wait 250ms
// if pointerup < 250ms → "click" → locked (recording until next press+release)
// if 250ms passes → "hold" → ptt (recording until release + 500ms trail)
// locked → pointerdown → stopping
// stopping → pointerup → stop recording → idle
// ptt → pointerup → wait 500ms trail → stop recording → idle
type Mode = 'idle' | 'locked' | 'ptt' | 'stopping'
const mode = ref<Mode>('idle')
const HOLD_THRESHOLD = 250
const TRAIL_BUFFER = 500
let holdTimer: number | null = null
let trailTimer: number | null = null
function clearTimers() {
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
if (trailTimer) { clearTimeout(trailTimer); trailTimer = null }
}
function onPointerDown(e: PointerEvent) {
if (props.disabled) return
e.preventDefault()
// Capture pointer so we get pointerup even if cursor leaves button
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
if (mode.value === 'idle') {
// Start hold detection
holdTimer = window.setTimeout(() => {
holdTimer = null
// Still holding after 250ms → PTT mode
mode.value = 'ptt'
emit('start')
}, HOLD_THRESHOLD)
} else if (mode.value === 'locked') {
// Second press while locked → prepare to stop on release
mode.value = 'stopping'
}
}
function onPointerUp(e: PointerEvent) {
if (props.disabled) return
e.preventDefault()
if (mode.value === 'idle' && holdTimer) {
// Quick click (< 250ms) → lock mode
clearTimers()
mode.value = 'locked'
emit('start')
} else if (mode.value === 'ptt') {
// Release from PTT → 500ms trailing buffer then stop
clearTimers()
trailTimer = window.setTimeout(() => {
trailTimer = null
mode.value = 'idle'
emit('stop')
}, TRAIL_BUFFER)
} else if (mode.value === 'stopping') {
// Release from second press → stop immediately
clearTimers()
mode.value = 'idle'
emit('stop')
}
}
onBeforeUnmount(() => {
clearTimers()
})
</script>
<template>
<button
class="mic-btn"
:class="{ recording: isRecording, ptt: mode === 'ptt', disabled }"
:disabled="disabled"
@pointerdown="onPointerDown"
@pointerup="onPointerUp"
:title="isRecording ? (mode === 'ptt' ? 'Release to stop' : 'Click to stop') : 'Click or hold to record'"
>
<!-- Mic icon -->
<svg v-if="!isRecording" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
<!-- Stop icon (square) when recording -->
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>
<!-- Mode badge -->
<span class="mode-badge" :class="voiceMode">
{{ voiceMode === 'whisper' ? 'GPU' : 'WEB' }}
</span>
</button>
</template>
<style scoped>
.mic-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: var(--bg-hover, rgba(255,255,255,0.06));
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
border-radius: 6px;
color: var(--text-muted, #888);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
touch-action: none;
user-select: none;
}
.mic-btn:hover:not(:disabled) {
background: var(--bg-hover, rgba(255,255,255,0.1));
color: var(--text-primary, #ccc);
border-color: var(--text-muted, rgba(255,255,255,0.15));
}
.mic-btn.recording {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border-color: rgba(239, 68, 68, 0.5);
color: white;
animation: rec-pulse 1.5s ease-in-out infinite;
}
.mic-btn.recording.ptt {
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
border-color: rgba(249, 115, 22, 0.5);
animation: rec-pulse-ptt 0.8s ease-in-out infinite;
}
.mic-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.mode-badge {
position: absolute;
bottom: -4px;
right: -4px;
font-size: 7px;
font-weight: 700;
font-family: 'Courier New', monospace;
padding: 1px 3px;
border-radius: 3px;
line-height: 1;
letter-spacing: 0.3px;
pointer-events: none;
}
.mode-badge.whisper {
background: rgba(16, 185, 129, 0.9);
color: white;
}
.mode-badge.web {
background: rgba(59, 130, 246, 0.9);
color: white;
}
@keyframes rec-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 4px rgba(239, 68, 68, 0); }
}
@keyframes rec-pulse-ptt {
0%, 100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.5); transform: scale(1); }
50% { box-shadow: 0 0 0 5px rgba(249, 115, 22, 0); transform: scale(1.08); }
}
</style>