File size: 17,978 Bytes
0ef8bc0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aabe43b
 
 
 
 
 
 
 
0ef8bc0
 
aabe43b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ef8bc0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aabe43b
 
 
 
 
0ef8bc0
 
 
 
 
 
 
 
 
aabe43b
 
 
 
 
0ef8bc0
 
aabe43b
 
 
 
 
 
 
 
 
0ef8bc0
 
 
aabe43b
 
 
0ef8bc0
aabe43b
 
 
0ef8bc0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aabe43b
0ef8bc0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
/**
 * useCallSession β€” React hook that owns the voice_call lifecycle.
 *
 * One instance per mounted `CallOverlayInner`. Composes callApi +
 * callSocket into a single, cleanup-safe React surface:
 *
 *   const session = useCallSession({ enabled, backendUrl, authToken,
 *                                    conversationId, personaId,
 *                                    deviceInfo })
 *
 *   session.status                β†’ 'idle' | 'creating' | 'connecting'
 *                                  | 'live' | 'reconnecting'
 *                                  | 'unavailable' | 'error' | 'closed'
 *   session.sendTranscript(text)  β†’ route STT capture to the backend
 *   session.end()                 β†’ graceful user-initiated close
 *   session.onAssistantTranscript β†’ subscribe to assistant replies
 *   session.onAssistantFiller     β†’ subscribe to "hmm…" events
 *   session.onAssistantBackchannel β†’ subscribe to "mm-hm" events
 *   session.lastError             β†’ surface for the UI
 *
 * When `enabled` flips to true we:
 *   1. POST /v1/voice-call/sessions  (via callApi)
 *   2. Open the WebSocket             (via callSocket)
 *   3. Wire status + event forwarding into React state
 *
 * Critically, if the POST returns 404/501 (backend has
 * VOICE_CALL_ENABLED=false) we surface `status='unavailable'` and
 * stop β€” the caller then falls back to the chat-REST path. That
 * graceful-degradation path is what lets us ship this module
 * independent of the backend flag rollout.
 */

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
  createCallSession,
  resolveWsUrl,
  CallApiError,
  type CreateCallSessionRequest,
  type CreateCallSessionResponse,
} from './callApi'
import {
  CallSocket,
  type AssistantBackchannelPayload,
  type AssistantCancelPayload,
  type AssistantFillerPayload,
  type AssistantPartialPayload,
  type AssistantTranscriptPayload,
  type AssistantTurnEndPayload,
  type CallCloseReason,
  type CallLifecycleStatus,
} from './callSocket'

export type CallSessionStatus =
  | 'idle'
  | 'creating'
  | 'connecting'
  | 'live'
  | 'reconnecting'
  | 'draining'
  | 'closed'
  | 'unavailable'
  | 'error'

export interface UseCallSessionArgs {
  /** The hook is inert until this flips to true. Flipping back to
   *  false triggers a graceful dispose. */
  enabled: boolean
  /** Resolved HomePilot backend URL (scheme + host). */
  backendUrl: string
  /** Bearer JWT for the REST handshake + WS query param. */
  authToken: string | null
  /** Session-creation request fields. */
  request: CreateCallSessionRequest
}

export interface CallSessionHandle {
  status: CallSessionStatus
  /** Server-side canonical status last seen (live / ending / ended). */
  callState: 'live' | 'ending' | 'ended' | null
  /** Raw close reason once the session terminates. Useful for
   *  differentiating user-end vs timeout vs backend-disabled. */
  closeReason: CallCloseReason | null
  /** Any terminal error. 'unavailable' is NOT an error β€” it's a
   *  legitimate graceful-degradation signal. */
  lastError: string | null
  /** Route a final STT transcript into the call turn. No-op outside
   *  the 'live' | 'reconnecting' states. */
  sendTranscript: (text: string) => void
  /** Graceful end β€” sends call.control end, closes the socket. */
  end: () => void
  /** Bookkeeping. Server persists the *type* of the event only. */
  sendUiState: (p: { muted?: boolean; speaker_on?: boolean; backgrounded?: boolean }) => void
  /** Phase 2 β€” interim user STT (secondary barge-in signal). */
  sendTranscriptPartial: (p: { text: string; stable_prefix_len?: number }) => void
  /** Phase 3 β€” explicit user interrupt carrying the active turn_id. */
  sendBargeIn: (turn_id: string) => void
  /** Whether this session negotiated streaming. Pulled from the
   *  session-create capabilities response. False = unary mode. */
  streamingNegotiated: boolean
  /** Whether this session negotiated barge-in (implies streaming). */
  bargeInNegotiated: boolean
  /** Subscribe helpers β€” each returns an unsubscribe fn. Stable
   *  identities via useRef-backed implementation so the caller can
   *  pass them to useEffect deps without re-subscription storms. */
  onAssistantTranscript: (fn: (p: AssistantTranscriptPayload) => void) => () => void
  onAssistantFiller: (fn: (p: AssistantFillerPayload) => void) => () => void
  onAssistantBackchannel: (fn: (p: AssistantBackchannelPayload) => void) => () => void
  /** Phase 2 subscribes. */
  onAssistantPartial: (fn: (p: AssistantPartialPayload) => void) => () => void
  onAssistantTurnEnd: (fn: (p: AssistantTurnEndPayload) => void) => () => void
  onAssistantCancel: (fn: (p: AssistantCancelPayload) => void) => () => void
}

// When the backend returns 404/501 for voice_call (VOICE_CALL_ENABLED=false)
// we remember the miss per-backend and short-circuit future session creates
// for UNAVAILABLE_BACKOFF_MS. Without this, every CallOverlay mount posts a
// fresh 404 against the same flag, spamming the console and delaying the
// fallback path by a full network round-trip.
//
// Industry practice: the backend feature-flag rarely flips mid-session, so
// the backoff is long-lived (10 min) and persisted to sessionStorage so it
// survives React 18 StrictMode's double-invoke of passive effects + in-tab
// navigations that tear the CallOverlay down and re-mount it. Clearing
// sessionStorage ( or calling clearVoiceCallUnavailable ) forces a re-probe.
const UNAVAILABLE_BACKOFF_MS = 10 * 60 * 1000
const BACKOFF_STORAGE_KEY = 'homepilot_voice_call_unavailable_until'
const unavailableUntilByBackend = new Map<string, number>()

// In-flight dedupe β€” StrictMode's double-invoke of the mount effect fires
// two parallel POSTs before either resolves, so the backoff is useless in
// that window. Share a single Promise per (backendUrl, authToken) so the
// two invokes observe the same network result instead of racing two probes.
const inflightByBackend = new Map<string, Promise<CreateCallSessionResponse>>()

function _readPersistedBackoff(backendUrl: string): number {
  if (typeof window === 'undefined') return 0
  try {
    const raw = window.sessionStorage.getItem(BACKOFF_STORAGE_KEY)
    if (!raw) return 0
    const map = JSON.parse(raw) as Record<string, number>
    const until = Number(map[backendUrl] ?? 0)
    return Number.isFinite(until) ? until : 0
  } catch {
    return 0
  }
}

function _writePersistedBackoff(backendUrl: string, until: number): void {
  if (typeof window === 'undefined') return
  try {
    const raw = window.sessionStorage.getItem(BACKOFF_STORAGE_KEY)
    const map = (raw ? JSON.parse(raw) : {}) as Record<string, number>
    map[backendUrl] = until
    window.sessionStorage.setItem(BACKOFF_STORAGE_KEY, JSON.stringify(map))
  } catch {
    /* ignore quota / private-mode errors */
  }
}

function _clearPersistedBackoff(backendUrl: string): void {
  if (typeof window === 'undefined') return
  try {
    const raw = window.sessionStorage.getItem(BACKOFF_STORAGE_KEY)
    if (!raw) return
    const map = JSON.parse(raw) as Record<string, number>
    delete map[backendUrl]
    window.sessionStorage.setItem(BACKOFF_STORAGE_KEY, JSON.stringify(map))
  } catch {
    /* ignore */
  }
}

/** Resolve the active backoff deadline for a backend. Checks the in-memory
 *  cache first, falls through to sessionStorage. Expired entries return 0. */
function _backoffUntil(backendUrl: string): number {
  const mem = unavailableUntilByBackend.get(backendUrl) ?? 0
  const persisted = _readPersistedBackoff(backendUrl)
  const until = Math.max(mem, persisted)
  if (until && until <= Date.now()) {
    unavailableUntilByBackend.delete(backendUrl)
    _clearPersistedBackoff(backendUrl)
    return 0
  }
  return until
}

/** Force a re-probe of the voice_call backend on the next CallOverlay mount.
 *  Exposed for future "retry now" UI affordances. Non-destructive: no-op if
 *  no backoff was set. */
export function clearVoiceCallUnavailable(backendUrl: string): void {
  unavailableUntilByBackend.delete(backendUrl)
  _clearPersistedBackoff(backendUrl)
}

export function useCallSession(args: UseCallSessionArgs): CallSessionHandle {
  const { enabled, backendUrl, authToken, request } = args

  const [status, setStatus] = useState<CallSessionStatus>('idle')
  const [callState, setCallState] = useState<'live' | 'ending' | 'ended' | null>(null)
  const [closeReason, setCloseReason] = useState<CallCloseReason | null>(null)
  const [lastError, setLastError] = useState<string | null>(null)

  const socketRef = useRef<CallSocket | null>(null)
  const sessionRef = useRef<CreateCallSessionResponse | null>(null)

  // Stable subscribe surface. Keep listener sets here so they survive
  // socket reconnects β€” the old socket's listeners are torn down with
  // it, but user subscriptions persist across reconnects and are
  // re-attached when a new socket comes up.
  const txListeners = useRef(new Set<(p: AssistantTranscriptPayload) => void>())
  const fillerListeners = useRef(new Set<(p: AssistantFillerPayload) => void>())
  const bcListeners = useRef(new Set<(p: AssistantBackchannelPayload) => void>())
  // Phase 2/3 listener sets. Kept separate so an old subscribe set
  // doesn't churn when a new one mounts (stream wire-up rebinds often
  // during CallOverlay renders).
  const partialListeners = useRef(new Set<(p: AssistantPartialPayload) => void>())
  const turnEndListeners = useRef(new Set<(p: AssistantTurnEndPayload) => void>())
  const cancelListeners = useRef(new Set<(p: AssistantCancelPayload) => void>())
  // Negotiated modes β€” re-derived on session create, stashed here so
  // every render reads the same value without re-running the effect.
  const [streamingNegotiated, setStreamingNegotiated] = useState(false)
  const [bargeInNegotiated, setBargeInNegotiated] = useState(false)

  // Ref'd primitive so the session-create effect doesn't re-trigger
  // when the request object identity changes every render.
  const requestRef = useRef(request)
  useEffect(() => { requestRef.current = request }, [request])

  // Main lifecycle. Runs once per (enabled, backendUrl, authToken)
  // triple β€” which is what we want: flipping enabled true/false
  // drives open/close; swapping backendUrl or re-auth reconnects.
  useEffect(() => {
    if (!enabled) return

    let disposed = false
    const teardown = () => {
      disposed = true
      socketRef.current?.dispose('unmounted')
      socketRef.current = null
      sessionRef.current = null
    }

    const run = async () => {
      setStatus('creating')
      setLastError(null)
      setCloseReason(null)
      setCallState(null)

      // Skip the POST entirely if this backend has been 404/501-ing
      // recently. _backoffUntil() consults the in-memory map + the
      // sessionStorage mirror so StrictMode double-invokes + page
      // reloads share the verdict. Expired entries self-clear.
      const skipUntil = _backoffUntil(backendUrl)
      if (skipUntil) {
        // eslint-disable-next-line no-console
        console.info(
          '[useCallSession] skipping createCallSession β€” backend flagged unavailable',
          { backendUrl, resumesAt: new Date(skipUntil).toISOString() },
        )
        setStatus('unavailable')
        return
      }

      // Share the handshake Promise across concurrent callers so
      // React 18 StrictMode's two parallel mount effects can't fire
      // two POSTs (the backoff is written only after the response
      // lands, so a second probe racing the first would otherwise
      // get through before the first writes its verdict).
      let handshake: CreateCallSessionResponse
      try {
        let inflight = inflightByBackend.get(backendUrl)
        if (!inflight) {
          inflight = createCallSession(backendUrl, requestRef.current, authToken)
            .finally(() => {
              inflightByBackend.delete(backendUrl)
            })
          inflightByBackend.set(backendUrl, inflight)
        }
        handshake = await inflight
      } catch (err) {
        if (disposed) return
        if (err instanceof CallApiError && err.isUnavailable) {
          const until = Date.now() + UNAVAILABLE_BACKOFF_MS
          unavailableUntilByBackend.set(backendUrl, until)
          _writePersistedBackoff(backendUrl, until)
          // eslint-disable-next-line no-console
          console.info(
            '[useCallSession] voice_call unavailable β€” falling back to chat REST until',
            new Date(until).toISOString(),
          )
          setStatus('unavailable')
          return
        }
        // eslint-disable-next-line no-console
        console.error('[useCallSession] createCallSession failed', err)
        setStatus('error')
        setLastError(err instanceof Error ? err.message : String(err))
        return
      }
      if (disposed) return

      // Handshake worked β€” clear any prior backoff so new flag flips
      // take effect immediately instead of waiting out the window.
      unavailableUntilByBackend.delete(backendUrl)
      _clearPersistedBackoff(backendUrl)

      sessionRef.current = handshake
      const url = resolveWsUrl(
        handshake.ws_url,
        handshake.session_id,
        handshake.resume_token,
        authToken,
        backendUrl,
      )

      const sock = new CallSocket({ url })
      socketRef.current = sock

      // Translate socket lifecycle β†’ hook status.
      sock.on('statusChange', (s: CallLifecycleStatus) => {
        if (disposed) return
        setStatus(s === 'idle' ? 'idle' : s)
      })
      sock.on('callState', (p) => {
        if (disposed) return
        setCallState(p.status)
      })
      sock.on('closed', ({ reason }) => {
        if (disposed) return
        setStatus('closed')
        setCloseReason(reason)
      })
      sock.on('serverError', (p) => {
        // Non-terminal by contract; surface for the UI but keep the
        // socket open. The server will close explicitly if fatal.
        if (disposed) return
        setLastError(`${p.code}: ${p.message}`)
      })
      sock.on('assistantTranscript', (p) => {
        for (const fn of txListeners.current) fn(p)
      })
      sock.on('assistantFiller', (p) => {
        for (const fn of fillerListeners.current) fn(p)
      })
      sock.on('assistantBackchannel', (p) => {
        for (const fn of bcListeners.current) fn(p)
      })
      // Phase 2/3 event fan-outs.
      sock.on('assistantPartial', (p) => {
        for (const fn of partialListeners.current) fn(p)
      })
      sock.on('assistantTurnEnd', (p) => {
        for (const fn of turnEndListeners.current) fn(p)
      })
      sock.on('assistantCancel', (p) => {
        for (const fn of cancelListeners.current) fn(p)
      })

      // Reflect negotiated modes from the session-create response.
      const caps = handshake.capabilities as
        | { streaming?: boolean; barge_in?: boolean } | undefined
      setStreamingNegotiated(!!caps?.streaming)
      setBargeInNegotiated(!!caps?.streaming && !!caps?.barge_in)

      sock.connect()
    }

    void run()
    return teardown
  }, [enabled, backendUrl, authToken])

  // Public actions. Guarded against stale-socket calls. --------------

  const sendTranscript = useCallback((text: string) => {
    const sock = socketRef.current
    if (!sock) return
    const trimmed = text.trim()
    if (!trimmed) return
    sock.sendTranscript({ text: trimmed })
  }, [])

  const end = useCallback(() => {
    socketRef.current?.end()
  }, [])

  const sendUiState = useCallback(
    (p: { muted?: boolean; speaker_on?: boolean; backgrounded?: boolean }) => {
      socketRef.current?.sendUiState(p)
    },
    [],
  )

  const sendTranscriptPartial = useCallback(
    (p: { text: string; stable_prefix_len?: number }) => {
      socketRef.current?.sendTranscriptPartial(p)
    },
    [],
  )

  const sendBargeIn = useCallback((turn_id: string) => {
    socketRef.current?.sendBargeIn(turn_id)
  }, [])

  const onAssistantTranscript = useCallback(
    (fn: (p: AssistantTranscriptPayload) => void) => {
      txListeners.current.add(fn)
      return () => { txListeners.current.delete(fn) }
    }, [],
  )
  const onAssistantFiller = useCallback(
    (fn: (p: AssistantFillerPayload) => void) => {
      fillerListeners.current.add(fn)
      return () => { fillerListeners.current.delete(fn) }
    }, [],
  )
  const onAssistantBackchannel = useCallback(
    (fn: (p: AssistantBackchannelPayload) => void) => {
      bcListeners.current.add(fn)
      return () => { bcListeners.current.delete(fn) }
    }, [],
  )
  const onAssistantPartial = useCallback(
    (fn: (p: AssistantPartialPayload) => void) => {
      partialListeners.current.add(fn)
      return () => { partialListeners.current.delete(fn) }
    }, [],
  )
  const onAssistantTurnEnd = useCallback(
    (fn: (p: AssistantTurnEndPayload) => void) => {
      turnEndListeners.current.add(fn)
      return () => { turnEndListeners.current.delete(fn) }
    }, [],
  )
  const onAssistantCancel = useCallback(
    (fn: (p: AssistantCancelPayload) => void) => {
      cancelListeners.current.add(fn)
      return () => { cancelListeners.current.delete(fn) }
    }, [],
  )

  return useMemo<CallSessionHandle>(() => ({
    status,
    callState,
    closeReason,
    lastError,
    sendTranscript,
    end,
    sendUiState,
    sendTranscriptPartial,
    sendBargeIn,
    streamingNegotiated,
    bargeInNegotiated,
    onAssistantTranscript,
    onAssistantFiller,
    onAssistantBackchannel,
    onAssistantPartial,
    onAssistantTurnEnd,
    onAssistantCancel,
  }), [
    status, callState, closeReason, lastError,
    sendTranscript, end, sendUiState,
    sendTranscriptPartial, sendBargeIn,
    streamingNegotiated, bargeInNegotiated,
    onAssistantTranscript, onAssistantFiller, onAssistantBackchannel,
    onAssistantPartial, onAssistantTurnEnd, onAssistantCancel,
  ])
}