import { useCallback, useMemo, useRef, useState } from 'react'; import type { Message, UseChatHelpers, UseChatOptions } from './types.js'; import { streamText } from './useStreamingText.js'; let __id = 0; const defaultGenerateId = (): string => `m-${Date.now().toString(36)}-${++__id}`; /** * `useChat` — primary hook for chat UIs. * * Adopts the Vercel AI SDK `useChat` return shape (per * `learning_ai_common_plat/docs/ROADMAP_2026_DECISIONS.md` §10) so any * React engineer familiar with the AI SDK can drop straight in. The * transport is **pluggable** — products supply `endpoint`, * `streamProtocol`, `fetcher`, or `headers` to talk to OpenAI directly, * Anthropic, an LysnrAI service, or any backend that streams text. * * @example * ```tsx * const { messages, input, handleInputChange, handleSubmit, isLoading } = * useChat({ endpoint: '/api/chat' }); * * return ( * <> * {messages.map(m => )} * * * ); * ``` */ export function useChat(options: UseChatOptions = {}): UseChatHelpers { const { initialMessages = [], initialInput = '', endpoint = '/api/chat', streamProtocol = 'text', fetcher, headers, onFinish, onError, generateId = defaultGenerateId, } = options; const [messages, setMessages] = useState(initialMessages); const [input, setInput] = useState(initialInput); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(undefined); const abortRef = useRef(null); const handleInputChange = useCallback( (e: { target: { value: string } }) => setInput(e.target.value), [], ); const stop = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; // Drop the trailing isStreaming flag so the bubble settles into // its final state even though we stopped mid-stream. setMessages(prev => prev.map(m => (m.isStreaming ? { ...m, isStreaming: false } : m)), ); setIsLoading(false); }, []); /** * Core send loop — appends `userMessage`, POSTs the conversation, * streams the response into a new assistant message, and updates * loading/error state along the way. */ const sendMessages = useCallback( async (next: Message[]) => { // Cancel any in-flight request before starting a new one. abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; const assistantId = generateId(); setMessages([ ...next, { id: assistantId, role: 'assistant', content: '', createdAt: new Date(), isStreaming: true, }, ]); setIsLoading(true); setError(undefined); const doFetch = fetcher ?? globalThis.fetch.bind(globalThis); let finalContent = ''; try { const response = await doFetch(endpoint, { method: 'POST', signal: controller.signal, headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify({ messages: next }), }); if (!response.ok) { throw new Error(`Chat endpoint returned ${response.status}`); } for await (const delta of streamText( response, streamProtocol, controller.signal, )) { finalContent += delta; // Functional update so concurrent setMessages calls don't lose work. setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: m.content + delta } : m, ), ); } // Settle isStreaming flag. setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, isStreaming: false } : m, ), ); if (onFinish && !controller.signal.aborted) { onFinish({ id: assistantId, role: 'assistant', content: finalContent, isStreaming: false, }); } } catch (err) { if ((err as Error).name === 'AbortError') { // User-initiated stop — already handled in `stop()`. return; } const e = err instanceof Error ? err : new Error(String(err)); setError(e); // Mark the partial assistant message as no longer streaming so // the bubble's "thinking" indicator clears. setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, isStreaming: false } : m, ), ); onError?.(e); } finally { if (abortRef.current === controller) abortRef.current = null; setIsLoading(false); } }, [endpoint, fetcher, generateId, headers, onError, onFinish, streamProtocol], ); const append = useCallback( async incoming => { const message: Message = { id: incoming.id ?? generateId(), role: incoming.role, content: incoming.content, createdAt: new Date(), }; const next = [...messages, message]; setMessages(next); await sendMessages(next); }, [generateId, messages, sendMessages], ); const handleSubmit = useCallback( async e => { e?.preventDefault?.(); const trimmed = input.trim(); if (!trimmed || isLoading) return; setInput(''); await append({ role: 'user', content: trimmed }); }, [append, input, isLoading], ); const reload = useCallback(async () => { // Find the last user message; drop everything after it. const lastUserIndex = [...messages] .reverse() .findIndex(m => m.role === 'user'); if (lastUserIndex === -1) return; const cutoff = messages.length - lastUserIndex; // exclusive const truncated = messages.slice(0, cutoff); setMessages(truncated); await sendMessages(truncated); }, [messages, sendMessages]); return useMemo( () => ({ messages, input, setInput, handleInputChange, handleSubmit, append, isLoading, error, stop, reload, setMessages, }), [ messages, input, handleInputChange, handleSubmit, append, isLoading, error, stop, reload, ], ); }