← back to Dear Bubbe Nextjs

lib/bubbeChat.ts

94 lines

// Shared chat + voice logic for MobileBubbe and DesktopBubbe.
// Extracted so the free-chat gate, the AI fetch, and the "never speak over
// another" voice handling live in ONE place instead of being duplicated in
// both ~600-line components (they were, and got edited twice in one session).

import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import { signIn } from 'next-auth/react'

export type ChatMessage = { id: string; role: 'user' | 'assistant'; content: string }
type SetMessages = Dispatch<SetStateAction<ChatMessage[]>>

export const FREE_MESSAGE_LIMIT = 3
const SIGN_IN_NUDGE =
  "Enough freebies, bubbeleh! Sign in with Google so I remember who you are — then we can really talk. Nu, what are you waiting for?"

/**
 * Free-chat gate: guests get FREE_MESSAGE_LIMIT messages, then Google sign-in.
 * Returns true if the message was BLOCKED (caller should return early).
 */
export function guestGateBlocks(session: unknown, setMessages: SetMessages): boolean {
  if (session) return false
  const used = parseInt(localStorage.getItem('bubbeFreeMessages') || '0', 10)
  if (used >= FREE_MESSAGE_LIMIT) {
    setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: SIGN_IN_NUDGE }])
    signIn('google')
    return true
  }
  localStorage.setItem('bubbeFreeMessages', String(used + 1))
  return false
}

/** POST the user's message to the Bubbe brain; returns her reply text (never throws on a bad body). */
export async function fetchBubbeReply(text: string): Promise<string> {
  const response = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ message: text.trim(), mode: 'bubbe' }),
  })
  const data = await response.json()
  return data.response || "Oy vey, something's wrong. Try again!"
}

/**
 * Build the voice player. Owns the "never speak over another" contract:
 * a synchronous lock (isSpeakingRef) + a hard stop-before-play on the single
 * shared <audio> element. Successful playback is unlocked by the caller's
 * 'ended' listener; early-outs unlock here (and fire onDone).
 */
export function makeVoicePlayer(opts: {
  audioRef: MutableRefObject<HTMLAudioElement | null>
  isSpeakingRef: MutableRefObject<boolean>
  setIsSpeaking: (v: boolean) => void
  stopListening?: () => void
}) {
  const { audioRef, isSpeakingRef, setIsSpeaking, stopListening } = opts
  return async function playVoice(
    text: string,
    { enabled = true, onDone }: { enabled?: boolean; onDone?: () => void } = {},
  ): Promise<void> {
    // synchronous lock — React state updates too late to block a rapid 2nd call
    if (!text || !enabled || isSpeakingRef.current) return
    isSpeakingRef.current = true
    setIsSpeaking(true)
    const unlock = () => { isSpeakingRef.current = false; setIsSpeaking(false); onDone?.() }

    stopListening?.() // don't record ourselves while speaking (safe no-op if idle)

    // never speak over another: hard-stop whatever is currently playing
    if (audioRef.current) {
      try {
        audioRef.current.pause()
        audioRef.current.currentTime = 0
        if (audioRef.current.src?.startsWith('blob:')) URL.revokeObjectURL(audioRef.current.src)
      } catch {}
    }

    try {
      const response = await fetch('/api/bubbe-voice', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text, mode: 'bubbe' }),
      })
      if (!response.ok) { unlock(); return }
      const blob = await response.blob()
      if (blob.size === 0) { unlock(); return } // quota exceeded → silent, no voice
      const url = URL.createObjectURL(blob)
      if (!audioRef.current) { unlock(); return }
      audioRef.current.pause() // stop again in case a clip slipped in while fetching
      audioRef.current.src = url
      try { await audioRef.current.play() } catch { unlock() }
    } catch { unlock() }
  }
}