'use client'; import { useCallback, useMemo, useRef, useState } from 'react'; export type Role = 'user' | 'assistant'; export type ChatMessage = { id: string; role: Role; content: string }; export type UseChatStreamingConfig = { api: string; // e.g. /api/chat badgeId: string; // Optional login path for redirect when 401 occurs. Defaults to '/auth/login' for portability across apps. loginPath?: string; }; export function useChatStreaming(cfg: UseChatStreamingConfig) { const { api, badgeId, loginPath = '/auth/login' } = cfg; const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); const [sessionId, setSessionId] = useState(null); const abortRef = useRef(null); const sendMessage = useCallback(async (text?: string) => { const content = (text ?? input).trim(); if (!content || sending) return; setInput(''); setSending(true); const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', content }; const assistantId = crypto.randomUUID(); setMessages(m => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]); const redirectToLogin = (promptText: string) => { // Inform the user then redirect to login carrying callbackUrl and prompt setMessages(m => m.map(msg => msg.id === assistantId ? { ...msg, content: 'You need to log in to use chat. Redirecting to login…' } : msg)); const currentPath = typeof window !== 'undefined' ? (window.location.pathname + window.location.search + window.location.hash) : '/'; const base = loginPath || '/auth/login'; const loginUrl = `${base}?callbackUrl=${encodeURIComponent(currentPath)}&chatPrompt=${encodeURIComponent(promptText)}`; setTimeout(() => { if (typeof window !== 'undefined') window.location.href = loginUrl; }, 700); }; try { abortRef.current?.abort(); abortRef.current = new AbortController(); // Ensure a session exists let sid = sessionId; if (!sid) { const sres = await fetch(`${api}/session`, { method: 'POST' }); if (sres.status === 401) { redirectToLogin(content); return; } if (!sres.ok) throw new Error('session'); const sj = await sres.json(); sid = sj.sessionId as string; setSessionId(sid); } const res = await fetch(api, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, sessionId: sid, badgeId }), signal: abortRef.current.signal, }); if (res.status === 401) { redirectToLogin(content); return; } if (!res.ok || !res.body) throw new Error(`send: ${res.status}`); const reader = res.body.getReader(); const decoder = new TextDecoder(); let done = false; while (!done) { const chunk = await reader.read(); done = chunk.done || false; if (chunk.value) { const textPart = decoder.decode(chunk.value, { stream: true }); setMessages(m => m.map(msg => msg.id === assistantId ? { ...msg, content: msg.content + textPart } : msg)); } } } catch (e) { setMessages(m => m.map(msg => msg.id === assistantId ? { ...msg, content: 'Streaming failed. Please try again.' } : msg)); } finally { setSending(false); } }, [api, input, sending, badgeId]); const cancel = useCallback(() => { abortRef.current?.abort(); setSending(false); // If the last assistant message is empty, set a friendly stopped message setMessages(m => { const last = m[m.length - 1]; if (last && last.role === 'assistant' && (last.content || '').trim().length === 0) { return m.map((msg, idx) => idx === m.length - 1 ? { ...msg, content: 'Generation stopped.' } : msg); } return m; }); }, []); return useMemo(() => ({ messages, input, setInput, sending, sendMessage, cancel }), [messages, input, sending, sendMessage, cancel]); }