Files
agent-ui/frontend/src/services/whisperSocket.ts
josedario87 e1aa8b1bdb feat: integrate Tauri v2 with Android widget and voice assistant
- Add Tauri v2 shell (Cargo, tauri.conf.json, capabilities, plugins)
- Migrate all fetch() calls to apiFetch() for Tauri-aware HTTP
- Migrate WebSocket endpoints to resolveEndpoints() for dynamic URLs
- Add ServerConfigDialog for remote server URL configuration
- Add tauri.ts lib with isTauri detection, apiFetch wrapper, plugin helpers
- Add server-config Pinia store with persistence via plugin-store
- Conditional PWA (disabled in Tauri builds)
- Android: home screen transcript widget (last 5 messages, 30s refresh)
- Android: voice command / share activity (SpeechRecognizer + WebSocket)
- Android: signed release APK with auto-copy to installers/
- Remove stale frontend/src-tauri directory
2026-02-23 15:33:43 -06:00

179 lines
4.4 KiB
TypeScript

/**
* Singleton Whisper WebSocket Service
* One shared connection used by all voice components (FloatingVoice, useVoiceInput, etc.)
*/
import { ref } from 'vue'
import { resolveEndpoints } from '../config/endpoints'
import { apiFetch } from '@/lib/tauri'
export type WhisperStatus = 'offline' | 'loading' | 'ready'
type TranscriptionCallback = (msg: {
success?: boolean
text?: string
error?: string
partial?: boolean
model?: string
device?: string
}) => void
// ====== Singleton state ======
const status = ref<WhisperStatus>('loading')
let socket: WebSocket | null = null
let reconnectTimer: number | null = null
const listeners = new Set<TranscriptionCallback>()
// ====== Connection management ======
function connect() {
if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) return
console.log('[WhisperSocket] Connecting to', resolveEndpoints().whisper)
socket = new WebSocket(resolveEndpoints().whisper)
const timeout = setTimeout(() => {
if (socket && socket.readyState !== WebSocket.OPEN) {
console.error('[WhisperSocket] Connection timeout (10s)')
socket.close()
status.value = 'loading'
}
}, 10000)
socket.onopen = () => {
clearTimeout(timeout)
console.log('[WhisperSocket] Connected')
status.value = 'ready'
}
socket.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'ready') {
console.log('[WhisperSocket] Server ready:', msg.model, msg.device)
status.value = 'ready'
} else if (msg.type === 'transcription') {
// Broadcast to all listeners
for (const cb of listeners) {
cb(msg)
}
}
} catch (e) {
console.error('[WhisperSocket] Message parse error:', e)
}
}
socket.onclose = () => {
console.log('[WhisperSocket] Closed, will reconnect...')
socket = null
status.value = 'loading'
scheduleReconnect()
}
socket.onerror = (e) => {
console.error('[WhisperSocket] Error:', e)
status.value = 'loading'
}
}
function scheduleReconnect() {
if (reconnectTimer) return
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null
checkStatusAndConnect()
}, 2000)
}
async function checkStatusAndConnect() {
try {
const res = await apiFetch('/api/whisper/status')
const data = await res.json()
if (data.running) {
connect()
} else {
status.value = 'loading'
scheduleReconnect()
}
} catch {
status.value = 'loading'
scheduleReconnect()
}
}
// ====== Public API ======
/** Initialize the singleton connection (call once at app startup) */
export function initWhisperSocket() {
checkStatusAndConnect()
}
/** Send audio for transcription */
export function sendAudio(base64: string, language: string, partial: boolean) {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
type: 'transcribe',
audio: base64,
language,
partial
}))
} else {
console.warn('[WhisperSocket] Not connected, dropping audio')
}
}
/** Subscribe to transcription results. Returns unsubscribe function. */
export function onTranscription(callback: TranscriptionCallback): () => void {
listeners.add(callback)
return () => listeners.delete(callback)
}
/** Get reactive status */
export function getWhisperStatus() {
return status
}
/** Check if socket is connected */
export function isConnected(): boolean {
return socket?.readyState === WebSocket.OPEN
}
/** Force reconnect (e.g. when user toggles Whisper) */
export async function reconnect() {
if (status.value === 'loading' && socket?.readyState === WebSocket.CONNECTING) return
status.value = 'loading'
if (socket) {
socket.close()
socket = null
}
try {
const res = await apiFetch('/api/whisper/toggle', { method: 'POST' })
const data = await res.json()
if (data.running) {
connect()
} else {
// Poll until ready
const poll = async () => {
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 2000))
try {
const s = await apiFetch('/api/whisper/status')
const d = await s.json()
if (d.running) {
connect()
return
}
} catch { /* retry */ }
}
status.value = 'offline'
}
poll()
}
} catch {
status.value = 'loading'
scheduleReconnect()
}
}