import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Models } from "appwrite"; import { chatStorageErrorMessage, createCoachChat, deleteCoachChat, listCoachChats, updateCoachChat, } from "./coachChats"; import { buildFlavourHistorySummary, getBstHour } from "./greeting"; import { caffeineFor, currency, humanDateTime, makeId, oneDecimal, spendFor, sugarFor, wholeNumber, } from "./metrics"; import type { CoachChat, CoachMessage, LimitCheckResult, RedBullEntry, UserLimits } from "../types"; import { limitsSummaryForCoach } from "./userLimits"; type AuthUser = Models.User; type Dashboard = { todayCans: string; todayCaffeine: string; todaySugar: string; favouriteFlavour: string; currentStreak: string; totalSpend: string; }; const OLLAMA_MODEL = "deepseek-v4-pro:cloud"; const OLLAMA_PROXY_URL = import.meta.env.VITE_OLLAMA_PROXY_URL?.trim() || "/api/ollama-chat"; type OllamaStreamChunk = { error?: string; message?: { content?: string; thinking?: string } }; export type CoachSession = ReturnType; export function useCoachSession( user: AuthUser, dashboard: Dashboard, entries: RedBullEntry[], userLimits: UserLimits = {}, limitCheck?: LimitCheckResult, ) { const [chats, setChats] = useState([]); const [activeChatId, setActiveChatId] = useState(null); const [savedChatIds, setSavedChatIds] = useState>(() => new Set()); const [storageStatus, setStorageStatus] = useState("loading"); const [storageReady, setStorageReady] = useState(false); const [input, setInput] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const abortRef = useRef(null); const queuedPromptRef = useRef(null); const activeChat = useMemo(() => chats.find((chat) => chat.id === activeChatId) ?? null, [chats, activeChatId]); const messages = useMemo(() => activeChat?.messages ?? [], [activeChat]); const visibleMessages = useMemo(() => messages.filter((message) => message.id !== "coach-welcome"), [messages]); useEffect(() => { let cancelled = false; async function loadChats() { if (!user.$id) return; setStorageStatus("loading"); setError(""); try { const savedChats = await listCoachChats(user.$id); if (cancelled) return; const initialChats = savedChats.length ? savedChats : [buildNewCoachChat(user, dashboard)]; setChats(initialChats); setSavedChatIds(new Set(savedChats.map((chat) => chat.id))); setActiveChatId(initialChats[0].id); setStorageStatus(savedChats.length ? `${savedChats.length} synced` : "ready"); setStorageReady(true); } catch (caught) { if (cancelled) return; setError(chatStorageErrorMessage(caught)); const fallback = buildNewCoachChat(user, dashboard); setChats([fallback]); setActiveChatId(fallback.id); setStorageStatus("local only"); setStorageReady(true); } } void loadChats(); return () => { cancelled = true; }; }, [user.$id]); const upsertChatState = useCallback((chat: CoachChat) => { setChats((current) => { const exists = current.some((item) => item.id === chat.id); return exists ? current.map((item) => (item.id === chat.id ? chat : item)) : [chat, ...current]; }); }, []); const patchAssistantMessage = useCallback((chatId: string, messageId: string, patch: Partial) => { setChats((current) => current.map((chat) => chat.id === chatId ? { ...chat, updatedAt: new Date().toISOString(), messages: chat.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)), } : chat, ), ); }, []); const withAssistantMessage = useCallback((chat: CoachChat, messageId: string, patch: Partial): CoachChat => { return { ...chat, updatedAt: new Date().toISOString(), messages: chat.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)), }; }, []); const persistChat = useCallback( async (chat: CoachChat) => { try { const saved = savedChatIds.has(chat.id) ? await updateCoachChat(user.$id, chat) : await createCoachChat(user.$id, chat); setSavedChatIds((current) => new Set(current).add(saved.id)); upsertChatState(saved); setStorageStatus("synced"); return true; } catch (caught) { setStorageStatus("save pending"); setError(chatStorageErrorMessage(caught)); return false; } }, [savedChatIds, upsertChatState, user.$id], ); const sendPrompt = useCallback( async (prompt: string, chatOverride?: CoachChat | null) => { const trimmed = prompt.trim(); if (!trimmed || busy || !storageReady || !user.$id) return false; const currentChat = chatOverride ?? activeChat ?? buildNewCoachChat(user, dashboard); const userMessage: CoachMessage = { id: makeId(), role: "user", content: trimmed }; const assistantId = makeId(); const assistantMessage: CoachMessage = { id: assistantId, role: "assistant", content: "", thinking: "", pending: true }; const conversation = [...currentChat.messages, userMessage]; const draftChat: CoachChat = { ...currentChat, title: titleForChat(currentChat.title, trimmed), messages: [...conversation, assistantMessage], updatedAt: new Date().toISOString(), }; upsertChatState(draftChat); setActiveChatId(draftChat.id); setInput(""); setBusy(true); setError(""); let streamedContent = ""; let streamedThinking = ""; const abortController = new AbortController(); abortRef.current = abortController; try { const requestMessages: Array<{ role: string; content: string; thinking?: string }> = [ { role: "system", content: buildCoachSystemPrompt(user, dashboard, entries, userLimits, limitCheck) }, ...conversation .filter((message) => message.content.trim().length > 0) .map((message) => ({ role: message.role, content: message.content, ...(message.thinking ? { thinking: message.thinking } : {}), })), ]; const response = await fetch(OLLAMA_PROXY_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: OLLAMA_MODEL, messages: requestMessages, stream: true, think: true, }), signal: abortController.signal, }); if (!response.ok) { const detail = await response.text(); throw new Error(parseCoachError(detail, response.status)); } if (!response.body) { throw new Error("streaming response was empty."); } await readOllamaStream(response.body, (chunk) => { if (chunk.error) throw new Error(chunk.error); if (chunk.message?.thinking) streamedThinking += chunk.message.thinking; if (chunk.message?.content) streamedContent += chunk.message.content.toLocaleLowerCase(); patchAssistantMessage(draftChat.id, assistantId, { content: streamedContent, thinking: streamedThinking, pending: !streamedContent, }); }); const finalChat = withAssistantMessage(draftChat, assistantId, { content: streamedContent || "no answer returned.", thinking: streamedThinking, pending: false, }); upsertChatState(finalChat); void persistChat(finalChat); return true; } catch (caught) { const aborted = abortController.signal.aborted; const message = caught instanceof Error ? caught.message : "coach request failed."; const finalChat = withAssistantMessage(draftChat, assistantId, { content: aborted ? streamedContent || "stopped thinking." : `coach unavailable: ${message}`.toLocaleLowerCase(), thinking: streamedThinking, pending: false, stopped: aborted, }); upsertChatState(finalChat); void persistChat(finalChat); if (!aborted) setError(message); return false; } finally { abortRef.current = null; setBusy(false); } }, [activeChat, busy, dashboard, entries, patchAssistantMessage, persistChat, storageReady, upsertChatState, user, withAssistantMessage], ); const queuePrompt = useCallback((prompt: string) => { queuedPromptRef.current = prompt; }, []); useEffect(() => { const prompt = queuedPromptRef.current; if (!storageReady || !prompt || busy) return; queuedPromptRef.current = null; void sendPrompt(prompt); }, [storageReady, busy, sendPrompt]); const startNewChat = useCallback(() => { const chat = buildNewCoachChat(user, dashboard); setChats((current) => [chat, ...current]); setActiveChatId(chat.id); setInput(""); setError(""); }, [dashboard, user]); const removeChat = useCallback( async (chatId: string) => { if (busy) return; try { if (savedChatIds.has(chatId)) await deleteCoachChat(chatId); setSavedChatIds((current) => { const next = new Set(current); next.delete(chatId); return next; }); setChats((current) => { const next = current.filter((chat) => chat.id !== chatId); const fallback = buildNewCoachChat(user, dashboard); setActiveChatId(next[0]?.id ?? fallback.id); return next.length ? next : [fallback]; }); } catch (caught) { setError(chatStorageErrorMessage(caught)); } }, [busy, dashboard, savedChatIds, user], ); const stopThinking = useCallback(() => { abortRef.current?.abort(); }, []); return { activeChatId, busy, chats, error, input, queuePrompt, removeChat, sendPrompt, setActiveChatId, setError, setInput, startNewChat, stopThinking, storageReady, storageStatus, visibleMessages, }; } function firstName(user: AuthUser) { const fallback = user.email?.split("@")[0] ?? "there"; const value = (user.name || fallback).trim(); return value.split(/\s+/)[0] || "there"; } function buildNewCoachChat(user: AuthUser, dashboard: Dashboard): CoachChat { const now = new Date().toISOString(); const favourite = dashboard.favouriteFlavour === "None yet" ? "your patterns" : dashboard.favouriteFlavour; return { id: makeId(), userId: user.$id, title: "today", createdAt: now, updatedAt: now, messages: [ { id: "coach-welcome", role: "assistant", content: `hey ${firstName(user).toLocaleLowerCase()}, ${dashboard.todayCans} cans logged today. ask about ${favourite}, caffeine pace, or spend.`, }, ], }; } function titleForChat(currentTitle: string, prompt: string) { if (currentTitle !== "today" && currentTitle !== "new chat") return currentTitle; const cleaned = prompt.trim().replace(/\s+/g, " ").toLocaleLowerCase(); return cleaned.length > 48 ? `${cleaned.slice(0, 45)}...` : cleaned || "today"; } function buildCoachSystemPrompt( user: AuthUser, dashboard: Dashboard, entries: RedBullEntry[], userLimits: UserLimits, limitCheck?: LimitCheckResult, ) { const recent = entries .slice(0, 12) .map( (entry) => `- ${humanDateTime(entry.dateTime)}: ${entry.cans} can(s), ${entry.flavour}, ${entry.sizeMl}ml, ${currency.format(spendFor(entry))}, ${wholeNumber.format(caffeineFor(entry))}mg caffeine, ${oneDecimal.format(sugarFor(entry))}g sugar`, ) .join("\n"); return [ "You are an upbeat Red Bull intake coach inside a tracking app.", "Respond entirely in lower case.", "Give concise, practical suggestions based only on the logged data provided.", "When asked about favourite flavour historically, use the flavour history breakdown below.", "Do not give medical advice.", `User: ${user.name || user.email || "Appwrite user"}`, `Current time (BST): ${getBstHour()}:00.`, `Today: ${dashboard.todayCans} cans, ${dashboard.todayCaffeine} caffeine, ${dashboard.todaySugar} sugar.`, `Personal limits: ${limitsSummaryForCoach(userLimits, limitCheck ?? { violations: [], projectedCans: 0, projectedSpend: 0, todayCans: 0, todaySpend: 0, pastStopTime: false })}`, `All-time favourite: ${dashboard.favouriteFlavour}. Streak: ${dashboard.currentStreak} day(s). Spend: ${dashboard.totalSpend}.`, `Flavour history:\n${buildFlavourHistorySummary(entries)}`, `Recent entries:\n${recent || "No entries logged yet."}`, ].join("\n"); } function parseCoachError(detail: string, status: number) { const trimmed = detail.trim(); if (trimmed.startsWith("<") || /nginx|405 not allowed/i.test(trimmed)) { return `coach api unavailable (${status}). run npm run dev with OLLAMA_API_KEY set, or proxy POST /api/ollama-chat on your host.`; } return trimmed || `request failed (${status}).`; } async function readOllamaStream(body: ReadableStream, onChunk: (chunk: OllamaStreamChunk) => void) { const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { const chunk = parseOllamaLine(line); if (chunk) onChunk(chunk); } } buffer += decoder.decode(); if (buffer.trim()) { const chunk = parseOllamaLine(buffer); if (chunk) onChunk(chunk); } } function parseOllamaLine(line: string): OllamaStreamChunk | null { const trimmed = line.trim().replace(/^data:\s*/, ""); if (!trimmed || trimmed === "[DONE]") return null; try { return JSON.parse(trimmed) as OllamaStreamChunk; } catch { return null; } } export { OLLAMA_MODEL };