Refactor coach to plain Appwrite storage with integrated overview UI.
Remove client-side encryption, migrate coach_chats schema, fix the Ollama proxy, and embed coach on overview alongside the dedicated tab. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
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)];
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import type { Models } from "appwrite";
|
||||
import type { CoachChat } from "../types";
|
||||
import { appwriteConfig, ID, Permission, Query, Role, tablesDB } from "./appwrite";
|
||||
|
||||
const CHAT_CRYPTO_VERSION = 1;
|
||||
const KEY_ITERATIONS = 210_000;
|
||||
|
||||
type EncryptedChatRow = Models.Row & {
|
||||
userId: string;
|
||||
encryptedTitle: string;
|
||||
encryptedMessages: string;
|
||||
titleIv: string;
|
||||
messagesIv: string;
|
||||
salt: string;
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type EncryptedValue = {
|
||||
ciphertext: string;
|
||||
iv: string;
|
||||
};
|
||||
|
||||
export async function listEncryptedChats(userId: string, passphrase: string) {
|
||||
const response = await tablesDB.listRows<EncryptedChatRow>({
|
||||
databaseId: appwriteConfig.databaseId,
|
||||
tableId: appwriteConfig.chatCollectionId,
|
||||
queries: [Query.equal("userId", userId), Query.orderDesc("updatedAt"), Query.limit(50)],
|
||||
});
|
||||
|
||||
const chats: CoachChat[] = [];
|
||||
for (const row of response.rows) {
|
||||
chats.push(await decryptChatRow(row, passphrase));
|
||||
}
|
||||
|
||||
return chats;
|
||||
}
|
||||
|
||||
export async function createEncryptedChat(userId: string, passphrase: string, chat: CoachChat) {
|
||||
const row = await tablesDB.createRow<EncryptedChatRow>({
|
||||
databaseId: appwriteConfig.databaseId,
|
||||
tableId: appwriteConfig.chatCollectionId,
|
||||
rowId: ID.custom(chat.id),
|
||||
data: await toEncryptedRowData(userId, passphrase, chat),
|
||||
permissions: userRowPermissions(userId),
|
||||
});
|
||||
|
||||
return decryptChatRow(row, passphrase);
|
||||
}
|
||||
|
||||
export async function updateEncryptedChat(userId: string, passphrase: string, chat: CoachChat) {
|
||||
const row = await tablesDB.updateRow<EncryptedChatRow>({
|
||||
databaseId: appwriteConfig.databaseId,
|
||||
tableId: appwriteConfig.chatCollectionId,
|
||||
rowId: chat.id,
|
||||
data: await toEncryptedRowData(userId, passphrase, chat),
|
||||
permissions: userRowPermissions(userId),
|
||||
});
|
||||
|
||||
return decryptChatRow(row, passphrase);
|
||||
}
|
||||
|
||||
export async function deleteEncryptedChat(id: string) {
|
||||
await tablesDB.deleteRow({
|
||||
databaseId: appwriteConfig.databaseId,
|
||||
tableId: appwriteConfig.chatCollectionId,
|
||||
rowId: id,
|
||||
});
|
||||
}
|
||||
|
||||
export function chatStorageErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (/decrypt|operation failed|unable to decrypt/i.test(error.message)) {
|
||||
return "Encrypted chat key could not unlock saved chats.";
|
||||
}
|
||||
if (/not found|404/i.test(error.message)) {
|
||||
return `Appwrite chat table '${appwriteConfig.chatCollectionId}' was not found.`;
|
||||
}
|
||||
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}'.`;
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
return "Encrypted chat storage failed.";
|
||||
}
|
||||
|
||||
async function toEncryptedRowData(userId: string, passphrase: string, chat: CoachChat) {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const key = await deriveKey(passphrase, userId, salt);
|
||||
const title = await encryptText(chat.title, key);
|
||||
const messages = await encryptText(JSON.stringify(chat.messages), key);
|
||||
|
||||
return {
|
||||
userId,
|
||||
encryptedTitle: title.ciphertext,
|
||||
encryptedMessages: messages.ciphertext,
|
||||
titleIv: title.iv,
|
||||
messagesIv: messages.iv,
|
||||
salt: bytesToBase64(salt),
|
||||
version: CHAT_CRYPTO_VERSION,
|
||||
updatedAt: chat.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function decryptChatRow(row: EncryptedChatRow, passphrase: string): Promise<CoachChat> {
|
||||
const salt = base64ToBytes(row.salt);
|
||||
const key = await deriveKey(passphrase, row.userId, salt);
|
||||
const title = await decryptText({ ciphertext: row.encryptedTitle, iv: row.titleIv }, key);
|
||||
const messages = JSON.parse(await decryptText({ ciphertext: row.encryptedMessages, iv: row.messagesIv }, key));
|
||||
|
||||
return {
|
||||
id: row.$id,
|
||||
userId: row.userId,
|
||||
title,
|
||||
messages,
|
||||
createdAt: row.$createdAt,
|
||||
updatedAt: row.updatedAt || row.$updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function deriveKey(passphrase: string, userId: string, salt: Uint8Array) {
|
||||
const material = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(`${userId}:${passphrase}`),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveKey"],
|
||||
);
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt: bytesToArrayBuffer(salt), iterations: KEY_ITERATIONS, hash: "SHA-256" },
|
||||
material,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
function bytesToArrayBuffer(bytes: Uint8Array) {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
async function encryptText(value: string, key: CryptoKey): Promise<EncryptedValue> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(value));
|
||||
return { ciphertext: bytesToBase64(new Uint8Array(encrypted)), iv: bytesToBase64(iv) };
|
||||
}
|
||||
|
||||
async function decryptText(value: EncryptedValue, key: CryptoKey) {
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: base64ToBytes(value.iv) },
|
||||
key,
|
||||
base64ToBytes(value.ciphertext),
|
||||
);
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string) {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function userRowPermissions(userId: string) {
|
||||
const role = Role.user(userId);
|
||||
return [Permission.read(role), Permission.update(role), Permission.delete(role)];
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { groupByFlavour } from "./metrics";
|
||||
|
||||
type GreetingInput = {
|
||||
name: string;
|
||||
todayCans: number;
|
||||
favouriteFlavour: string;
|
||||
currentStreak: number;
|
||||
todayCaffeineMg: number;
|
||||
allTimeCans: number;
|
||||
};
|
||||
|
||||
type GreetingResult = {
|
||||
badge: string;
|
||||
headline: string;
|
||||
subline: string;
|
||||
};
|
||||
|
||||
export function getBstHour(date = new Date()) {
|
||||
const hour = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: "Europe/London",
|
||||
hour: "numeric",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
return Number.parseInt(hour, 10);
|
||||
}
|
||||
|
||||
export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
|
||||
const hour = getBstHour();
|
||||
const timeLabel = timeOfDayLabel(hour);
|
||||
const cans = input.todayCans;
|
||||
const favourite =
|
||||
input.favouriteFlavour === "None yet" ? null : input.favouriteFlavour;
|
||||
const streak = input.currentStreak;
|
||||
|
||||
const badge = cans === 0 ? `${timeLabel} · clear slate` : `${timeLabel} · ${cans} today`;
|
||||
|
||||
let headline: string;
|
||||
if (cans === 0) {
|
||||
headline =
|
||||
streak > 0
|
||||
? `${input.name}, nothing logged yet today — ${streak}-day streak still alive.`
|
||||
: `${input.name}, no Red Bulls logged yet this ${hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening"}.`;
|
||||
} else if (cans === 1) {
|
||||
headline = `${input.name}, one Red Bull in so far today.`;
|
||||
} else if (cans <= 3) {
|
||||
headline = `${input.name}, ${cans} Red Bulls today — steady pace.`;
|
||||
} else {
|
||||
headline = `${input.name}, ${cans} Red Bulls today — worth watching the caffeine curve.`;
|
||||
}
|
||||
|
||||
const flavourLine = favourite
|
||||
? cans > 0
|
||||
? `Today's top pick looks like ${favourite}.`
|
||||
: `All-time favourite: ${favourite} (${input.allTimeCans} cans logged).`
|
||||
: "Your flavour story is just getting started.";
|
||||
|
||||
const caffeineLine =
|
||||
cans > 0 && input.todayCaffeineMg > 0
|
||||
? `~${Math.round(input.todayCaffeineMg)}mg caffeine so far.`
|
||||
: hour >= 17 && cans === 0
|
||||
? "Evening reset — clean slate if you want it."
|
||||
: hour >= 22
|
||||
? "Late night — pace yourself if you're still going."
|
||||
: "Log an intake to unlock today's signals.";
|
||||
|
||||
return {
|
||||
badge,
|
||||
headline,
|
||||
subline: [flavourLine, caffeineLine].join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFlavourHistorySummary(entries: Parameters<typeof groupByFlavour>[0]) {
|
||||
const breakdown = groupByFlavour(entries);
|
||||
if (!breakdown.length) return "No flavour history yet.";
|
||||
|
||||
return breakdown
|
||||
.map((item, index) => {
|
||||
const rank = index === 0 ? " (all-time favourite)" : "";
|
||||
return `- ${item.name}: ${item.value} cans${rank}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function timeOfDayLabel(hour: number) {
|
||||
if (hour >= 5 && hour < 12) return "morning";
|
||||
if (hour >= 12 && hour < 17) return "afternoon";
|
||||
if (hour >= 17 && hour < 22) return "evening";
|
||||
return "night";
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
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, RedBullEntry } from "../types";
|
||||
|
||||
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[]) {
|
||||
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;
|
||||
};
|
||||
}, [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) },
|
||||
...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[]) {
|
||||
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.`,
|
||||
`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 };
|
||||
Reference in New Issue
Block a user