rm coach chat and ollama proxy

drop coach panel, session, chat store, and the limitsSummaryForCoach helper
This commit is contained in:
Ned Halksworth
2026-05-27 17:30:35 +01:00
committed by Ned
parent 08372febfe
commit 4c7d719e02
5 changed files with 0 additions and 815 deletions
-107
View File
@@ -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<CoachChatRow>({
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<CoachChatRow>({
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<CoachChatRow>({
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)];
}
-417
View File
@@ -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<Models.Preferences>;
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<typeof useCoachSession>;
export function useCoachSession(
user: AuthUser,
dashboard: Dashboard,
entries: RedBullEntry[],
userLimits: UserLimits = {},
limitCheck?: LimitCheckResult,
) {
const [chats, setChats] = useState<CoachChat[]>([]);
const [activeChatId, setActiveChatId] = useState<string | null>(null);
const [savedChatIds, setSavedChatIds] = useState<Set<string>>(() => 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<AbortController | null>(null);
const queuedPromptRef = useRef<string | null>(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<CoachMessage>) => {
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<CoachMessage>): 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<Uint8Array>, 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 };
-19
View File
@@ -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);
}