From 4c7d719e026d4e4defa97c3a4975ccc4396b6670 Mon Sep 17 00:00:00 2001 From: Ned Halksworth Date: Wed, 27 May 2026 17:30:35 +0100 Subject: [PATCH] rm coach chat and ollama proxy drop coach panel, session, chat store, and the limitsSummaryForCoach helper --- api/ollama-chat.js | 77 ------- src/components/CoachPanel.tsx | 195 ---------------- src/lib/coachChats.ts | 107 --------- src/lib/useCoachSession.ts | 417 ---------------------------------- src/lib/userLimits.ts | 19 -- 5 files changed, 815 deletions(-) delete mode 100644 api/ollama-chat.js delete mode 100644 src/components/CoachPanel.tsx delete mode 100644 src/lib/coachChats.ts delete mode 100644 src/lib/useCoachSession.ts diff --git a/api/ollama-chat.js b/api/ollama-chat.js deleted file mode 100644 index 5ed710b..0000000 --- a/api/ollama-chat.js +++ /dev/null @@ -1,77 +0,0 @@ -/* global Buffer, fetch, process */ - -const DEFAULT_MODEL = "deepseek-v4-pro:cloud"; - -export default async function handler(req, res) { - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); - - if (req.method === "OPTIONS") { - res.statusCode = 204; - res.end(); - return; - } - - if (req.method !== "POST") { - res.statusCode = 405; - res.end("Method not allowed"); - return; - } - - const apiKey = process.env.OLLAMA_API_KEY; - if (!apiKey) { - res.statusCode = 500; - res.end("OLLAMA_API_KEY is not configured on the server."); - return; - } - - try { - const payload = await readJson(req); - const upstream = await fetch("https://ollama.com/api/chat", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - ...payload, - model: payload.model || process.env.OLLAMA_MODEL || DEFAULT_MODEL, - stream: payload.stream !== false, - }), - }); - - res.statusCode = upstream.status; - res.setHeader("Content-Type", upstream.headers.get("content-type") || "application/x-ndjson"); - - if (!upstream.ok) { - res.end(await upstream.text()); - return; - } - - if (!upstream.body) { - res.end(); - return; - } - - const reader = upstream.body.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - res.write(Buffer.from(value)); - } - res.end(); - } catch (error) { - res.statusCode = 500; - res.end(error instanceof Error ? error.message : "Ollama proxy failed."); - } -} - -async function readJson(req) { - if (req.body && typeof req.body === "object") return req.body; - if (typeof req.body === "string") return JSON.parse(req.body || "{}"); - - let raw = ""; - for await (const chunk of req) raw += chunk; - return raw ? JSON.parse(raw) : {}; -} diff --git a/src/components/CoachPanel.tsx b/src/components/CoachPanel.tsx deleted file mode 100644 index 314ab09..0000000 --- a/src/components/CoachPanel.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { Brain, ChevronRight, Loader2, Plus, Send, Sparkles, Square, Trash2 } from "lucide-react"; -import type { FormEvent } from "react"; -import { getBstHour } from "../lib/greeting"; -import type { CoachSession } from "../lib/useCoachSession"; -import { OLLAMA_MODEL } from "../lib/useCoachSession"; -import type { CoachMessage } from "../types"; - -type CoachPanelProps = { - session: CoachSession; - mode: "compact" | "full"; - dashboard: { - todayCans: string; - todayCaffeine: string; - favouriteFlavour: string; - }; - userInitials: string; - onExpand?: () => void; -}; - -const QUICK_PROMPTS = [ - "what's my favourite flavour historically?", - "how should i pace caffeine for the rest of the day?", - "suggest a lower-sugar swap", -]; - -export function CoachPanel({ session, mode, dashboard, userInitials, onExpand }: CoachPanelProps) { - const { - busy, - chats, - error, - input, - activeChatId, - removeChat, - sendPrompt, - setActiveChatId, - setInput, - startNewChat, - stopThinking, - storageReady, - storageStatus, - visibleMessages, - } = session; - - const displayMessages = mode === "compact" ? visibleMessages.slice(-4) : visibleMessages; - const compact = mode === "compact"; - - async function submit(event: FormEvent) { - event.preventDefault(); - await sendPrompt(input); - } - - if (!storageReady) { - return ( -
-
-
-
- ); - } - - return ( -
-
-
-
-
-
-

coach

-

- {dashboard.todayCans} cans today · {dashboard.favouriteFlavour} -

-
-
-
- - - {busy ? "thinking" : storageStatus} - - {!compact && {OLLAMA_MODEL}} - {compact && onExpand && ( - - )} -
-
- - {!compact && chats.length > 1 && ( -
- {chats.map((chat) => ( -
- - -
- ))} - -
- )} - -
- {dashboard.todayCaffeine} caffeine - bst {getBstHour()}:00 -
- -
- {!displayMessages.length ? ( -
-
- ) : ( - displayMessages.map((message) => ( - - )) - )} -
- - {error &&

{error}

} - -
- {!compact && ( - - )} - setInput(event.target.value)} - placeholder="ask coach anything..." - disabled={busy} - /> - {busy ? ( - - ) : ( - - )} -
-
- ); -} - -function CoachLine({ message, userInitials }: { message: CoachMessage; userInitials: string }) { - const isAssistant = message.role === "assistant"; - const isThinking = isAssistant && message.pending && !message.content.trim(); - - return ( -
- {isAssistant ? : userInitials} -
- {isThinking && } - {message.content ?

{message.content}

: !isThinking ? ... : null} - {isAssistant && !message.pending && message.thinking?.trim() ? ( -
- reasoning -
{message.thinking}
-
- ) : null} -
-
- ); -} - -function ThinkingPill({ stopped }: { stopped?: boolean }) { - return ( -
-
-
-
- ); -} diff --git a/src/lib/coachChats.ts b/src/lib/coachChats.ts deleted file mode 100644 index 3896ba6..0000000 --- a/src/lib/coachChats.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { Models } from "appwrite"; -import type { CoachChat, CoachMessage } from "../types"; -import { appwriteConfig, ID, Permission, Query, Role, tablesDB } from "./appwrite"; - -type CoachChatRow = Models.Row & { - userId: string; - title: string; - messages: string; - updatedAt: string; -}; - -export async function listCoachChats(userId: string) { - const response = await tablesDB.listRows({ - databaseId: appwriteConfig.databaseId, - tableId: appwriteConfig.chatCollectionId, - queries: [Query.equal("userId", userId), Query.orderDesc("updatedAt"), Query.limit(50)], - }); - - return response.rows.filter(isPlainChatRow).map(fromRow); -} - -export async function createCoachChat(userId: string, chat: CoachChat) { - const row = await tablesDB.createRow({ - databaseId: appwriteConfig.databaseId, - tableId: appwriteConfig.chatCollectionId, - rowId: ID.custom(chat.id), - data: toRowData(userId, chat), - permissions: userRowPermissions(userId), - }); - - return fromRow(row); -} - -export async function updateCoachChat(userId: string, chat: CoachChat) { - const row = await tablesDB.updateRow({ - databaseId: appwriteConfig.databaseId, - tableId: appwriteConfig.chatCollectionId, - rowId: chat.id, - data: toRowData(userId, chat), - permissions: userRowPermissions(userId), - }); - - return fromRow(row); -} - -export async function deleteCoachChat(id: string) { - await tablesDB.deleteRow({ - databaseId: appwriteConfig.databaseId, - tableId: appwriteConfig.chatCollectionId, - rowId: id, - }); -} - -export function chatStorageErrorMessage(error: unknown) { - if (error instanceof Error) { - if (/not found|404/i.test(error.message)) { - return `Appwrite chat table '${appwriteConfig.chatCollectionId}' was not found. Run npm run setup:appwrite.`; - } - if (/permissions?.*create|action 'create'|not authorized|401|unauthorized/i.test(error.message)) { - return `Appwrite chat table needs Users -> Create and row security on '${appwriteConfig.chatCollectionId}'.`; - } - if (/unknown attribute|invalid document structure|missing required attribute/i.test(error.message)) { - if (/encrypted/i.test(error.message)) { - return "Coach chat table still requires legacy encrypted columns. Run npm run setup:appwrite or remove encryptedTitle, encryptedMessages, titleIv, messagesIv, salt, and version as required in Appwrite Console."; - } - return "Coach chat schema needs title and messages columns. Run npm run setup:appwrite."; - } - return error.message; - } - return "Coach chat storage failed."; -} - -function toRowData(userId: string, chat: CoachChat) { - return { - userId, - title: chat.title.slice(0, 512) || "today", - messages: JSON.stringify(chat.messages), - updatedAt: chat.updatedAt, - }; -} - -function isPlainChatRow(row: CoachChatRow) { - return typeof row.title === "string" && row.title.length > 0 && typeof row.messages === "string" && row.messages.length > 0; -} - -function fromRow(row: CoachChatRow): CoachChat { - let messages: CoachMessage[] = []; - try { - messages = JSON.parse(row.messages) as CoachMessage[]; - } catch { - messages = []; - } - - return { - id: row.$id, - userId: row.userId, - title: row.title, - messages, - createdAt: row.$createdAt, - updatedAt: row.updatedAt || row.$updatedAt, - }; -} - -function userRowPermissions(userId: string) { - const role = Role.user(userId); - return [Permission.read(role), Permission.update(role), Permission.delete(role)]; -} diff --git a/src/lib/useCoachSession.ts b/src/lib/useCoachSession.ts deleted file mode 100644 index eb87584..0000000 --- a/src/lib/useCoachSession.ts +++ /dev/null @@ -1,417 +0,0 @@ -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; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [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, limitCheck, patchAssistantMessage, persistChat, storageReady, upsertChatState, user, userLimits, 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 }; diff --git a/src/lib/userLimits.ts b/src/lib/userLimits.ts index 4c38bbe..dcb611f 100644 --- a/src/lib/userLimits.ts +++ b/src/lib/userLimits.ts @@ -178,25 +178,6 @@ export function limitStatusMessage( return lines.join(" "); } -export function limitsSummaryForCoach(limits: UserLimits, check: LimitCheckResult): string { - const parts: string[] = []; - - if (limits.dailyCanLimit != null) { - parts.push(`daily can limit: ${limits.dailyCanLimit} (${check.todayCans} logged today)`); - } - if (limits.dailySpendLimit != null) { - parts.push(`daily spend limit: ${currency.format(limits.dailySpendLimit)} (${currency.format(check.todaySpend)} today)`); - } - if (limits.stopTime) { - parts.push( - `stop drinking by: ${formatStopTimeLabel(limits.stopTime)} bst (${check.pastStopTime ? "past stop time now" : "before stop time"})`, - ); - } - - if (!parts.length) return "no personal daily limits configured yet."; - return parts.join(". "); -} - export function hasAnyLimit(limits: UserLimits) { return Boolean(limits.dailyCanLimit != null || limits.dailySpendLimit != null || limits.stopTime); }