← 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() }
}
}