import { useCallback, useRef, useState } from "react"; import { CHAT_URL, UPLOAD_URL } from "../lib/constants"; import type { Message } from "../lib/types"; export function useChat( sessionId: string, selectedLL: [number, number] | null ) { const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(""); const [isStreaming, setIsStreaming] = useState(false); const [hasFirstToken, setHasFirstToken] = useState(false); const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null); const [isUploading, setIsUploading] = useState(false); const chatBodyRef = useRef(null); const scrollToBottom = useCallback(() => { const el = chatBodyRef.current; if (!el) return; el.scrollTop = el.scrollHeight; }, []); const typeOut = useCallback( async (fullText: string) => { const step = fullText.length > 1200 ? 6 : fullText.length > 400 ? 3 : 1; const delayMs = fullText.length > 1200 ? 4 : fullText.length > 400 ? 8 : 15; let firstTokenSet = false; for (let i = 0; i < fullText.length; i += step) { const acc = fullText.slice(0, i + step); setMessages((m) => { const out = [...m]; for (let j = out.length - 1; j >= 0; j--) { if (out[j].role === "assistant") { out[j] = { ...out[j], text: acc }; break; } } return out; }); if (!firstTokenSet && acc.length > 0) { setHasFirstToken(true); firstTokenSet = true; } scrollToBottom(); await new Promise((r) => setTimeout(r, delayMs)); } setIsStreaming(false); setHasFirstToken(true); scrollToBottom(); }, [scrollToBottom] ); const onFileChosen = useCallback(async (file: File) => { setIsUploading(true); try { const fd = new FormData(); fd.append("file", file); const res = await fetch(UPLOAD_URL, { method: "POST", body: fd }).then( (r) => r.json() ); const url = res?.url || (res?.path ? (import.meta.env.VITE_API_BASE || "http://localhost:8000") + res.path : ""); if (url) setPendingPhotoUrl(url); } finally { setIsUploading(false); } }, []); const send = useCallback(async () => { const text = draft.trim(); if (!text) return; const attached = pendingPhotoUrl; // capture now setPendingPhotoUrl(null); // clear immediately setMessages((m) => [ ...m, { role: "user", text, image: attached || undefined }, ]); setDraft(""); setTimeout(scrollToBottom, 0); setIsStreaming(true); setHasFirstToken(false); setMessages((m) => [...m, { role: "assistant", text: "" }]); setTimeout(scrollToBottom, 0); let finalText = text; if (selectedLL) finalText += `\n\n[COORDS lat=${selectedLL[0]} lon=${selectedLL[1]}]`; const payload: any = { message: finalText, session_id: sessionId }; if (selectedLL) payload.user_location = { lat: selectedLL[0], lon: selectedLL[1] }; if (attached) payload.photo_url = attached; const res = await fetch(CHAT_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) .then((r) => r.json()) .catch(() => ({ reply: "Something went wrong." })); await typeOut(res.reply || "(no reply)"); return res; // caller can react to tool_used (e.g., reload reports) }, [draft, pendingPhotoUrl, selectedLL, sessionId, scrollToBottom, typeOut]); return { messages, draft, setDraft, isStreaming, hasFirstToken, chatBodyRef, send, pendingPhotoUrl, setPendingPhotoUrl, isUploading, onFileChosen, }; }