Merge pull request 'Refactor coach to plain Appwrite storage with integrated overview UI.' (#1) from cursor/coach-integration-plain-storage into main

Reviewed-on: #1
This commit is contained in:
2026-05-23 19:25:53 +00:00
11 changed files with 1182 additions and 958 deletions
+2 -3
View File
@@ -17,6 +17,5 @@ VITE_OLLAMA_PROXY_URL=/api/ollama-chat
# Server/admin only. Never prefix with VITE_. Needed only for npm run setup:appwrite. # Server/admin only. Never prefix with VITE_. Needed only for npm run setup:appwrite.
APPWRITE_API_KEY= APPWRITE_API_KEY=
# Appwrite chat table columns needed for encrypted coach chats: # Appwrite chat table columns: userId, title, messages, updatedAt.
# userId, encryptedTitle, encryptedMessages, titleIv, messagesIv, salt, updatedAt as strings # Enable row security and Users -> Create at table level.
# version as integer. Enable row security and Users -> Create at table level.
+4 -8
View File
@@ -180,19 +180,15 @@ Recommended table-level permissions:
- Update: none - Update: none
- Delete: none - Delete: none
The app encrypts chat titles and messages in the browser before writing rows. The encryption passphrase is not stored, and Appwrite only receives ciphertext. The app stores coach chat titles and messages as plain JSON in Appwrite with row-level user permissions.
Create these chat columns: Create these chat columns:
| Key | Type | Required | Notes | | Key | Type | Required | Notes |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `userId` | String, 64 | Yes | Current Appwrite user ID | | `userId` | String, 64 | Yes | Current Appwrite user ID |
| `encryptedTitle` | String, 4000 | Yes | AES-GCM ciphertext | | `title` | String, 512 | Yes | Chat title |
| `encryptedMessages` | String, 50000+ | Yes | AES-GCM ciphertext for message JSON | | `messages` | Longtext | Yes | JSON array of coach messages |
| `titleIv` | String, 128 | Yes | Base64 IV |
| `messagesIv` | String, 128 | Yes | Base64 IV |
| `salt` | String, 128 | Yes | Base64 PBKDF2 salt |
| `version` | Integer | Yes | Crypto version |
| `updatedAt` | DateTime | Yes | Sort key | | `updatedAt` | DateTime | Yes | Sort key |
Recommended chat index: Recommended chat index:
@@ -204,7 +200,7 @@ Recommended chat index:
- `src/App.tsx`: UI shell, auth gate, dashboard/logbook/trends/coach/data views, modals, and action state. - `src/App.tsx`: UI shell, auth gate, dashboard/logbook/trends/coach/data views, modals, and action state.
- `src/lib/appwrite.ts`: Appwrite SDK client, account/database services, env config, and ping helper. - `src/lib/appwrite.ts`: Appwrite SDK client, account/database services, env config, and ping helper.
- `src/lib/appwriteEntries.ts`: User-scoped Appwrite CRUD, document permissions, duplicate signatures. - `src/lib/appwriteEntries.ts`: User-scoped Appwrite CRUD, document permissions, duplicate signatures.
- `src/lib/encryptedChats.ts`: Client-side encrypted chat storage for Appwrite. - `src/lib/coachChats.ts`: Appwrite-backed coach chat storage.
- `src/lib/excel.ts`: Styled `.xlsx` export, summary sheet, row validation, duplicate-aware import preview. - `src/lib/excel.ts`: Styled `.xlsx` export, summary sheet, row validation, duplicate-aware import preview.
- `src/lib/metrics.ts`: Prices, caffeine/sugar estimates, stats, grouping, streaks. - `src/lib/metrics.ts`: Prices, caffeine/sugar estimates, stats, grouping, streaks.
- `src/lib/storage.ts`: JSON backup export/import parser. - `src/lib/storage.ts`: JSON backup export/import parser.
+24 -6
View File
@@ -44,16 +44,21 @@ await ensureTable({
name: "Coach chats", name: "Coach chats",
columns: [ columns: [
{ kind: "string", key: "userId", size: 64, required: true }, { kind: "string", key: "userId", size: 64, required: true },
{ kind: "string", key: "encryptedTitle", size: 4000, required: true, encrypt: true }, { kind: "string", key: "title", size: 512, required: true },
{ kind: "longtext", key: "encryptedMessages", required: true, encrypt: true }, { kind: "longtext", key: "messages", required: true },
{ kind: "string", key: "titleIv", size: 128, required: true },
{ kind: "string", key: "messagesIv", size: 128, required: true },
{ kind: "string", key: "salt", size: 128, required: true },
{ kind: "integer", key: "version", required: true },
{ kind: "datetime", key: "updatedAt", required: true }, { kind: "datetime", key: "updatedAt", required: true },
], ],
indexes: [{ key: "user_chat_updated", type: "key", columns: ["userId", "updatedAt"], orders: ["ASC", "DESC"], lengths: [32] }], indexes: [{ key: "user_chat_updated", type: "key", columns: ["userId", "updatedAt"], orders: ["ASC", "DESC"], lengths: [32] }],
}); });
await retireLegacyChatColumns(chatTableId, [
"encryptedTitle",
"encryptedMessages",
"titleIv",
"messagesIv",
"salt",
"version",
]);
await waitForColumns(chatTableId, ["userId", "title", "messages", "updatedAt"]);
console.log("Appwrite database and tables ready."); console.log("Appwrite database and tables ready.");
@@ -122,6 +127,19 @@ async function ensureColumn(tableId, column) {
console.log(`Column ${tableId}.${column.key} created.`); console.log(`Column ${tableId}.${column.key} created.`);
} }
async function retireLegacyChatColumns(tableId, keys) {
for (const key of keys) {
const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [200, 404]);
if (existing.status === 404) {
console.log(`Legacy column ${tableId}.${key} already removed.`);
continue;
}
await request("DELETE", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [204, 404]);
console.log(`Legacy column ${tableId}.${key} removed.`);
}
}
async function ensureIndex(tableId, index) { async function ensureIndex(tableId, index) {
const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]); const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]);
if (existing.status === 200) { if (existing.status === 200) {
+85 -582
View File
@@ -2,7 +2,6 @@ import type { Models } from "appwrite";
import { import {
Activity, Activity,
AlertTriangle, AlertTriangle,
Brain,
CalendarDays, CalendarDays,
CheckCircle2, CheckCircle2,
ChevronRight, ChevronRight,
@@ -17,21 +16,17 @@ import {
Home, Home,
LineChart, LineChart,
Loader2, Loader2,
Lock,
LogIn, LogIn,
LogOut, LogOut,
MessageCircle, MessageCircle,
MessageSquarePlus,
Plus, Plus,
PoundSterling, PoundSterling,
RefreshCcw, RefreshCcw,
RotateCcw, RotateCcw,
Send,
Search, Search,
Settings2, Settings2,
ShieldCheck, ShieldCheck,
Sparkles, Sparkles,
Square,
TimerReset, TimerReset,
Trash2, Trash2,
Upload, Upload,
@@ -88,13 +83,10 @@ import {
listEntries, listEntries,
updateEntry, updateEntry,
} from "./lib/appwriteEntries"; } from "./lib/appwriteEntries";
import { import { CoachPanel } from "./components/CoachPanel";
chatStorageErrorMessage, import { buildDynamicGreeting } from "./lib/greeting";
createEncryptedChat, import type { CoachSession } from "./lib/useCoachSession";
deleteEncryptedChat, import { useCoachSession } from "./lib/useCoachSession";
listEncryptedChats,
updateEncryptedChat,
} from "./lib/encryptedChats";
import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel"; import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel";
import { import {
caffeineFor, caffeineFor,
@@ -123,15 +115,12 @@ import {
wholeNumber, wholeNumber,
} from "./lib/metrics"; } from "./lib/metrics";
import { exportPayload, parseImport } from "./lib/storage"; import { exportPayload, parseImport } from "./lib/storage";
import type { CoachChat, CoachMessage, DateFilter, EntryDraft, Filters, Flavour, ImportPreview, RedBullEntry } from "./types"; import type { DateFilter, EntryDraft, Filters, Flavour, ImportPreview, RedBullEntry } from "./types";
type AppView = "overview" | "logbook" | "trends" | "coach" | "settings"; type AppView = "overview" | "logbook" | "trends" | "coach" | "settings";
type AuthMode = "login" | "signup"; type AuthMode = "login" | "signup";
type AuthUser = Models.User<Models.Preferences>; type AuthUser = Models.User<Models.Preferences>;
type SetupStatus = { state: "checking" | "ok" | "error"; message: string }; type SetupStatus = { state: "checking" | "ok" | "error"; message: string };
type OllamaStreamChunk = { error?: string; message?: { content?: string; thinking?: string } };
const OLLAMA_MODEL = "deepseek-v4-pro:cloud";
const OLLAMA_PROXY_URL = import.meta.env.VITE_OLLAMA_PROXY_URL?.trim() || "/api/ollama-chat";
const DEFAULT_FILTERS: Filters = { const DEFAULT_FILTERS: Filters = {
flavour: "all", flavour: "all",
@@ -284,6 +273,7 @@ function App() {
const flavourData = useMemo(() => groupByFlavour(filteredEntries), [filteredEntries]); const flavourData = useMemo(() => groupByFlavour(filteredEntries), [filteredEntries]);
const insights = useMemo(() => buildInsights(entries), [entries]); const insights = useMemo(() => buildInsights(entries), [entries]);
const recentEntries = useMemo(() => entries.slice(0, 5), [entries]); const recentEntries = useMemo(() => entries.slice(0, 5), [entries]);
const coachSession = useCoachSession(user ?? { $id: "", email: "", name: "" } as AuthUser, dashboard, entries);
async function login(email: string, password: string) { async function login(email: string, password: string) {
setActionLoading("auth"); setActionLoading("auth");
@@ -596,7 +586,7 @@ function App() {
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="app-main" className="app-main"
> >
{activeView === "overview" && ( {activeView === "overview" && user && (
<OverviewView <OverviewView
dashboard={dashboard} dashboard={dashboard}
entries={entries} entries={entries}
@@ -606,9 +596,13 @@ function App() {
chartData={chartData} chartData={chartData}
flavourData={flavourData} flavourData={flavourData}
user={user} user={user}
coachSession={coachSession}
onQuickAdd={(item) => void quickAdd(item)} onQuickAdd={(item) => void quickAdd(item)}
onAdd={openNewEntry} onAdd={openNewEntry}
onOpenCoach={() => setActiveView("coach")} onOpenCoach={(prompt) => {
if (prompt) coachSession.queuePrompt(prompt);
setActiveView("coach");
}}
onOpenLogbook={() => setActiveView("logbook")} onOpenLogbook={() => setActiveView("logbook")}
/> />
)} )}
@@ -642,7 +636,14 @@ function App() {
/> />
)} )}
{activeView === "coach" && <CoachView dashboard={dashboard} entries={entries} user={user} />} {activeView === "coach" && user && (
<CoachPanel
mode="full"
session={coachSession}
dashboard={dashboard}
userInitials={userInitials(user)}
/>
)}
{activeView === "settings" && ( {activeView === "settings" && (
<SettingsView <SettingsView
@@ -1157,6 +1158,7 @@ function OverviewView({
chartData, chartData,
flavourData, flavourData,
user, user,
coachSession,
onQuickAdd, onQuickAdd,
onAdd, onAdd,
onOpenCoach, onOpenCoach,
@@ -1170,18 +1172,40 @@ function OverviewView({
chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>; chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>;
flavourData: Array<{ name: string; value: number; spend: number; accent: string }>; flavourData: Array<{ name: string; value: number; spend: number; accent: string }>;
user: AuthUser; user: AuthUser;
coachSession: CoachSession;
onQuickAdd: (item: (typeof QUICK_ADDS)[number]) => void; onQuickAdd: (item: (typeof QUICK_ADDS)[number]) => void;
onAdd: () => void; onAdd: () => void;
onOpenCoach: () => void; onOpenCoach: (prompt?: string) => void;
onOpenLogbook: () => void; onOpenLogbook: () => void;
}) { }) {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<GreetingPanel dashboard={dashboard} entries={entries} user={user} onOpenCoach={onOpenCoach} /> <GreetingPanel dashboard={dashboard} user={user} onOpenCoach={onOpenCoach} />
<section className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<CoachPanel
mode="compact"
session={coachSession}
dashboard={dashboard}
userInitials={userInitials(user)}
onExpand={() => onOpenCoach()}
/>
<QuickAddPanel items={quickAdds} onQuickAdd={onQuickAdd} />
</section>
<section className="grid gap-4 xl:grid-cols-[1.25fr_0.75fr]"> <section className="grid gap-4 xl:grid-cols-[1.25fr_0.75fr]">
<TodayPanel dashboard={dashboard} entries={entries} onAdd={onAdd} /> <TodayPanel dashboard={dashboard} entries={entries} onAdd={onAdd} />
<QuickAddPanel items={quickAdds} onQuickAdd={onQuickAdd} /> <AppCard title="Coach signals" subtitle="Live from your log">
<div className="grid gap-2">
<WellnessPill label="Today" value={`${dashboard.todayCans} cans`} />
<WellnessPill label="Caffeine" value={dashboard.todayCaffeine} />
<WellnessPill label="Favourite" value={dashboard.favouriteFlavour} />
<button className="list-button" type="button" onClick={() => onOpenCoach()}>
Open full coach
<ChevronRight size={16} aria-hidden="true" />
</button>
</div>
</AppCard>
</section> </section>
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
@@ -1263,20 +1287,39 @@ function OverviewView({
function GreetingPanel({ function GreetingPanel({
dashboard, dashboard,
entries,
user, user,
onOpenCoach, onOpenCoach,
}: { }: {
dashboard: Dashboard; dashboard: Dashboard;
entries: RedBullEntry[];
user: AuthUser; user: AuthUser;
onOpenCoach: () => void; onOpenCoach: (prompt?: string) => void;
}) { }) {
const todayNumber = Number.parseFloat(dashboard.todayCans) || 0; const todayNumber = Number.parseFloat(dashboard.todayCans) || 0;
const progress = Math.min(100, Math.round((todayNumber / 4) * 100)); const progress = Math.min(100, Math.round((todayNumber / 4) * 100));
const name = firstName(user); const name = firstName(user);
const favourite = dashboard.favouriteFlavour === "None yet" ? "still forming" : dashboard.favouriteFlavour; const greeting = buildDynamicGreeting({
const redBullLabel = todayNumber === 1 ? "Red Bull" : "Red Bulls"; name,
todayCans: todayNumber,
favouriteFlavour: dashboard.favouriteFlavour,
currentStreak: Number.parseInt(dashboard.currentStreak, 10) || 0,
todayCaffeineMg: Number.parseFloat(dashboard.todayCaffeine.replace(/[^\d.]/g, "")) || 0,
allTimeCans: Number.parseFloat(dashboard.allTimeCans) || 0,
});
const coachPrompts = [
{
label: "Pace today's caffeine",
prompt: "what does my red bull pattern say about today?",
},
{
label: "Sugar-free swap",
prompt: "give me one lower-sugar swap based on my favourite flavour.",
},
{
label: "Weekly spend trend",
prompt: "review my weekly spend trend and suggest one saving.",
},
];
return ( return (
<section className="oura-hero glass-panel p-5 sm:p-6"> <section className="oura-hero glass-panel p-5 sm:p-6">
@@ -1291,33 +1334,25 @@ function GreetingPanel({
<div className="min-w-0"> <div className="min-w-0">
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs font-semibold text-slate-400"> <div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs font-semibold text-slate-400">
<Sparkles size={14} aria-hidden="true" /> <Sparkles size={14} aria-hidden="true" />
Daily readiness {greeting.badge}
</div> </div>
<h2 className="text-3xl font-semibold tracking-tight text-white sm:text-4xl"> <h2 className="text-3xl font-semibold tracking-tight text-white sm:text-4xl">{greeting.headline}</h2>
Hey {name}, you've had {dashboard.todayCans} {redBullLabel} today and your favourite flavour is {favourite}. <p className="mt-3 max-w-2xl text-sm leading-6 text-slate-400">{greeting.subline}</p>
</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-400">
Clean caffeine, sugar, spend, and streak signals in one glance.
</p>
</div> </div>
<div className="grid gap-2 sm:grid-cols-3 xl:min-w-[390px] xl:grid-cols-1"> <div className="grid gap-2 sm:grid-cols-3 xl:min-w-[390px] xl:grid-cols-1">
<WellnessPill label="Caffeine" value={dashboard.todayCaffeine} /> <WellnessPill label="Caffeine" value={dashboard.todayCaffeine} />
<WellnessPill label="Sugar" value={dashboard.todaySugar} /> <WellnessPill label="Sugar" value={dashboard.todaySugar} />
<WellnessPill label="Entries" value={`${entries.length}`} /> <WellnessPill label="Streak" value={`${dashboard.currentStreak} days`} />
</div> </div>
</div> </div>
<div className="mt-5 grid gap-2 md:grid-cols-3"> <div className="mt-5 grid gap-2 md:grid-cols-3">
<button className="suggestion-chip" type="button" onClick={onOpenCoach}> {coachPrompts.map((item) => (
Ask Coach for today's pace <button key={item.label} className="suggestion-chip" type="button" onClick={() => onOpenCoach(item.prompt)}>
</button> {item.label}
<button className="suggestion-chip" type="button" onClick={onOpenCoach}>
Get a sugar-free swap idea
</button>
<button className="suggestion-chip" type="button" onClick={onOpenCoach}>
Review weekly spend trend
</button> </button>
))}
</div> </div>
</section> </section>
); );
@@ -1542,467 +1577,6 @@ function TrendsView({
); );
} }
function CoachView({ dashboard, entries, user }: { dashboard: Dashboard; entries: RedBullEntry[]; user: AuthUser }) {
const [chats, setChats] = useState<CoachChat[]>([]);
const [activeChatId, setActiveChatId] = useState<string | null>(null);
const [savedChatIds, setSavedChatIds] = useState<Set<string>>(() => new Set());
const [chatKey, setChatKey] = useState("");
const [chatKeyInput, setChatKeyInput] = useState("");
const [chatStorageStatus, setChatStorageStatus] = useState("unlock encrypted chat storage");
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [openThinkingIds, setOpenThinkingIds] = useState<string[]>([]);
const abortRef = useRef<AbortController | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const activeChat = chats.find((chat) => chat.id === activeChatId) ?? null;
const messages = useMemo(() => activeChat?.messages ?? [], [activeChat]);
const visibleMessages = useMemo(() => messages.filter((message) => message.id !== "coach-welcome"), [messages]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
}, [activeChatId, messages]);
const quickPrompts = [
"what does my red bull pattern say about today?",
"give me one lower-sugar swap based on my favourite flavour.",
"how should i pace caffeine for the rest of the day?",
];
async function unlockChats(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const passphrase = chatKeyInput.trim();
if (!passphrase) return;
setBusy(true);
setError("");
setChatStorageStatus("opening encrypted appwrite chats...");
try {
const savedChats = await listEncryptedChats(user.$id, passphrase);
const initialChats = savedChats.length ? savedChats : [buildNewCoachChat(user)];
setChatKey(passphrase);
setChats(initialChats);
setSavedChatIds(new Set(savedChats.map((chat) => chat.id)));
setActiveChatId(initialChats[0].id);
setChatStorageStatus(savedChats.length ? `${savedChats.length} encrypted chat${savedChats.length === 1 ? "" : "s"} loaded` : "new encrypted chat ready");
} catch (caught) {
const message = chatStorageErrorMessage(caught);
setError(message);
setChatKey("");
setChatStorageStatus("encrypted chat unlock failed");
} finally {
setBusy(false);
}
}
function startNewChat() {
if (!chatKey) return;
const chat = buildNewCoachChat(user);
setChats((current) => [chat, ...current]);
setActiveChatId(chat.id);
setInput("");
setError("");
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await sendPrompt(input);
}
async function sendPrompt(prompt: string) {
const trimmed = prompt.trim();
if (!trimmed || busy) return;
if (!chatKey) {
setError("unlock encrypted chat storage first.");
return;
}
const currentChat = activeChat ?? buildNewCoachChat(user);
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 now = new Date().toISOString();
const draftChat: CoachChat = {
...currentChat,
title: titleForChat(currentChat.title, trimmed),
messages: [...conversation, assistantMessage],
updatedAt: now,
};
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(detail || `Ollama request failed with status ${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: true,
});
});
const finalChat = withAssistantMessage(draftChat, assistantId, {
content: streamedContent || "no answer returned.",
thinking: streamedThinking,
pending: false,
});
upsertChatState(finalChat);
await persistChat(finalChat);
} 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);
await persistChat(finalChat);
if (!aborted) setError(message);
} finally {
abortRef.current = null;
setBusy(false);
}
}
function stopThinking() {
abortRef.current?.abort();
}
function toggleThinking(id: string) {
setOpenThinkingIds((current) => (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]));
}
function upsertChatState(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];
});
}
function patchAssistantMessage(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,
),
);
}
function withAssistantMessage(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)),
};
}
async function persistChat(chat: CoachChat) {
if (!chatKey) return;
try {
const saved = savedChatIds.has(chat.id)
? await updateEncryptedChat(user.$id, chatKey, chat)
: await createEncryptedChat(user.$id, chatKey, chat);
setSavedChatIds((current) => new Set(current).add(saved.id));
upsertChatState(saved);
setChatStorageStatus("encrypted chat saved to appwrite");
} catch (caught) {
setChatStorageStatus("encrypted chat save failed");
setError(chatStorageErrorMessage(caught));
}
}
async function removeChat(chatId: string) {
if (busy) return;
try {
if (savedChatIds.has(chatId)) await deleteEncryptedChat(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);
setActiveChatId(next[0]?.id ?? fallback.id);
return next.length ? next : [fallback];
});
setChatStorageStatus("encrypted chat deleted");
} catch (caught) {
setError(chatStorageErrorMessage(caught));
}
}
if (!chatKey) {
return (
<section className="coach-shell coach-locked-shell">
<div className="coach-empty-state">
<div className="coach-empty-icon">
<Lock size={28} aria-hidden="true" />
</div>
<h2>unlock coach</h2>
<p>
messages are encrypted before appwrite stores them. your passphrase is never saved use the same one on every device.
</p>
<form className="coach-unlock-card" onSubmit={unlockChats}>
<input
className="coach-input"
type="password"
value={chatKeyInput}
onChange={(event) => setChatKeyInput(event.target.value)}
placeholder="encryption passphrase"
autoComplete="current-password"
/>
<button className="primary-button" type="submit" disabled={busy || !chatKeyInput.trim()}>
{busy ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <Lock size={17} aria-hidden="true" />}
unlock
</button>
</form>
{error && <p className="mt-4 max-w-md text-sm" style={{ color: "var(--error)" }}>{error}</p>}
</div>
</section>
);
}
const userInitials = user.name
? user.name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2)
: (user.email?.[0] ?? "U").toUpperCase();
return (
<section className="coach-shell">
<div className="coach-layout">
<aside className="coach-sidebar">
<div className="coach-sidebar-header">
<div className="coach-sidebar-icon">
<Brain size={18} aria-hidden="true" />
</div>
<div className="coach-sidebar-label">
<p>coach</p>
<p>{chatStorageStatus}</p>
</div>
</div>
<button className="coach-new-chat" type="button" onClick={startNewChat} disabled={busy}>
<Plus size={16} aria-hidden="true" />
new chat
</button>
<div className="coach-chat-list">
{chats.map((chat) => (
<div key={chat.id} className={`coach-chat-row ${chat.id === activeChatId ? "coach-chat-row-active" : ""}`}>
<button type="button" onClick={() => setActiveChatId(chat.id)}>
<span>{chat.title}</span>
<small>{new Intl.DateTimeFormat("en-GB", { day: "2-digit", month: "short" }).format(new Date(chat.updatedAt))}</small>
</button>
<button type="button" aria-label={`delete ${chat.title}`} onClick={() => void removeChat(chat.id)} disabled={busy}>
<Trash2 size={14} aria-hidden="true" />
</button>
</div>
))}
</div>
<div className="coach-context-card">
<p className="text-xs font-semibold uppercase" style={{ color: "var(--muted)" }}>today</p>
<div className="mt-2 grid gap-2">
<WellnessPill label="cans" value={dashboard.todayCans} />
<WellnessPill label="caffeine" value={dashboard.todayCaffeine} />
<WellnessPill label="favourite" value={dashboard.favouriteFlavour} />
</div>
</div>
</aside>
<section className="coach-main">
<div className="coach-topbar">
<span className="coach-topbar-status">
<span className={`coach-topbar-status-dot ${busy ? "coach-topbar-status-dot-busy" : "coach-topbar-status-dot-ready"}`} />
{busy ? "thinking" : "ready"}
</span>
<span className="coach-topbar-status" style={{ color: "var(--muted)" }}>{OLLAMA_MODEL}</span>
</div>
<div className="coach-messages" aria-live="polite">
<div className="coach-messages-inner">
{!visibleMessages.length ? (
<div className="coach-empty-state">
<div className="coach-empty-icon">
<Sparkles size={28} aria-hidden="true" />
</div>
<h2>how can I help?</h2>
<p>ask about caffeine, sugar, spending, or your flavour patterns.</p>
<div className="coach-prompt-grid">
{quickPrompts.map((prompt) => (
<button key={prompt} className="chat-suggestion-chip" type="button" disabled={busy} onClick={() => void sendPrompt(prompt)}>
{prompt}
</button>
))}
</div>
</div>
) : (
visibleMessages.map((message) => (
<CoachMessageBubble
key={message.id}
message={message}
userInitials={userInitials}
thinkingOpen={openThinkingIds.includes(message.id)}
onToggleThinking={() => toggleThinking(message.id)}
/>
))
)}
<div ref={messagesEndRef} />
</div>
</div>
{error && (
<div className="coach-error">
<div className="coach-error-inner">
{error}
</div>
</div>
)}
<form className="coach-composer" onSubmit={submit}>
<div className="coach-composer-inner">
<button className="composer-icon-button" type="button" onClick={startNewChat} disabled={busy} aria-label="new chat">
<Plus size={18} aria-hidden="true" />
</button>
<textarea
className="coach-input"
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void sendPrompt(input);
}
}}
placeholder="ask coach"
disabled={busy}
rows={1}
/>
{busy ? (
<button className="composer-send-button composer-stop-button" type="button" onClick={stopThinking} aria-label="stop thinking">
<Square size={16} aria-hidden="true" />
</button>
) : (
<button className="composer-send-button" type="submit" disabled={!input.trim()} aria-label="send message">
<Send size={16} aria-hidden="true" />
</button>
)}
</div>
<p className="coach-hint">coach can make mistakes. check important info.</p>
</form>
</section>
</div>
</section>
);
}
function CoachMessageBubble({
message,
userInitials,
thinkingOpen,
onToggleThinking,
}: {
message: CoachMessage;
userInitials: string;
thinkingOpen: boolean;
onToggleThinking: () => void;
}) {
const isAssistant = message.role === "assistant";
const canShowThinking = isAssistant && (message.pending || Boolean(message.thinking));
const thinkingLabel = message.stopped ? "stopped thinking" : message.pending ? "thinking" : "view reasoning";
return (
<article className={`coach-message ${isAssistant ? "coach-message-assistant" : "coach-message-user"}`}>
{isAssistant ? (
<div className="coach-message-avatar coach-message-avatar-assistant">
<Brain size={16} aria-hidden="true" />
</div>
) : (
<div className="coach-message-avatar coach-message-avatar-user">
{userInitials}
</div>
)}
<div className="coach-message-bubble">
<div className="coach-bubble-content">
{message.content || (message.pending ? (
<div className="coach-typing-dots"><span /><span /><span /></div>
) : "")}
</div>
{canShowThinking && (
<div className="mt-2">
<button className={`thinking-slider ${message.pending ? "thinking-slider-active" : ""}`} type="button" onClick={onToggleThinking}>
{thinkingLabel}
</button>
<AnimatePresence>
{thinkingOpen && (
<motion.pre
className="thinking-trace"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
>
{message.thinking || "waiting for reasoning trace..."}
</motion.pre>
)}
</AnimatePresence>
</div>
)}
</div>
</article>
);
}
function SettingsView({ function SettingsView({
activeTheme, activeTheme,
@@ -2975,90 +2549,19 @@ function formatMetricValue(name: string, value: number) {
return oneDecimal.format(value); return oneDecimal.format(value);
} }
async function readOllamaStream(stream: ReadableStream<Uint8Array>, onChunk: (chunk: OllamaStreamChunk) => void) {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
function processLine(line: string) {
const chunk = parseOllamaLine(line);
if (chunk) onChunk(chunk);
}
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() ?? "";
lines.forEach(processLine);
}
buffer += decoder.decode();
if (buffer.trim()) processLine(buffer);
}
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;
}
}
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, including headings and short labels.",
"Give concise, practical suggestions based only on the logged data provided.",
"Do not give medical advice; suggest checking labels and using personal judgement for caffeine tolerance.",
`User: ${user.name || user.email || "Appwrite user"}`,
`Today: ${dashboard.todayCans} cans, ${dashboard.todayCaffeine} caffeine, ${dashboard.todaySugar} sugar.`,
`Favourite flavour: ${dashboard.favouriteFlavour}. Current streak: ${dashboard.currentStreak} day(s). Total spend: ${dashboard.totalSpend}.`,
`Recent entries:\n${recent || "No entries logged yet."}`,
].join("\n");
}
function buildNewCoachChat(user: AuthUser): CoachChat {
const now = new Date().toISOString();
return {
id: makeId(),
userId: user.$id,
title: "new chat",
createdAt: now,
updatedAt: now,
messages: [
{
id: "coach-welcome",
role: "assistant",
content: `hey ${firstName(user).toLocaleLowerCase()}, i can help with caffeine pace, sugar swaps, spend trends, and smarter quick-add choices.`,
},
],
};
}
function titleForChat(currentTitle: string, prompt: string) {
if (currentTitle !== "new chat") return currentTitle;
const cleaned = prompt.trim().replace(/\s+/g, " ").toLocaleLowerCase();
return cleaned.length > 48 ? `${cleaned.slice(0, 45)}...` : cleaned || "new chat";
}
function firstName(user: AuthUser) { function firstName(user: AuthUser) {
const fallback = user.email?.split("@")[0] ?? "there"; const fallback = user.email?.split("@")[0] ?? "there";
const value = (user.name || fallback).trim(); const value = (user.name || fallback).trim();
return value.split(/\s+/)[0] || "there"; return value.split(/\s+/)[0] || "there";
} }
function userInitials(user: AuthUser) {
if (user.name) {
return user.name.split(" ").map((part) => part[0]).join("").toUpperCase().slice(0, 2);
}
return (user.email?.[0] ?? "U").toUpperCase();
}
function sizeToPreset(size: number) { function sizeToPreset(size: number) {
if (size === 250 || size === 355 || size === 473) return size.toString(); if (size === 250 || size === 355 || size === 473) return size.toString();
return "custom"; return "custom";
+195
View File
@@ -0,0 +1,195 @@
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<HTMLFormElement>) {
event.preventDefault();
await sendPrompt(input);
}
if (!storageReady) {
return (
<section className="coach-panel glass-panel p-5">
<div className="flex items-center gap-3 text-sm" style={{ color: "var(--muted)" }}>
<Loader2 className="animate-spin" size={18} aria-hidden="true" />
loading coach...
</div>
</section>
);
}
return (
<section className={`coach-panel glass-panel ${compact ? "coach-panel-compact" : "coach-panel-full"}`}>
<header className="coach-panel-header">
<div className="coach-panel-title">
<div className="coach-panel-icon">
<Brain size={18} aria-hidden="true" />
</div>
<div>
<p className="coach-panel-kicker">coach</p>
<h3 className="coach-panel-heading">
{dashboard.todayCans} cans today · {dashboard.favouriteFlavour}
</h3>
</div>
</div>
<div className="coach-panel-meta">
<span className="coach-status-pill">
<span className={`coach-status-dot ${busy ? "coach-status-dot-busy" : ""}`} />
{busy ? "thinking" : storageStatus}
</span>
{!compact && <span className="coach-model-tag">{OLLAMA_MODEL}</span>}
{compact && onExpand && (
<button className="coach-expand-button" type="button" onClick={onExpand}>
open
<ChevronRight size={14} aria-hidden="true" />
</button>
)}
</div>
</header>
{!compact && chats.length > 1 && (
<div className="coach-thread-strip">
{chats.map((chat) => (
<div key={chat.id} className={`coach-thread-chip ${chat.id === activeChatId ? "coach-thread-chip-active" : ""}`}>
<button type="button" onClick={() => setActiveChatId(chat.id)}>
{chat.title}
</button>
<button type="button" aria-label={`delete ${chat.title}`} onClick={() => void removeChat(chat.id)} disabled={busy}>
<Trash2 size={12} aria-hidden="true" />
</button>
</div>
))}
<button className="coach-thread-new" type="button" onClick={startNewChat} disabled={busy}>
<Plus size={14} aria-hidden="true" />
</button>
</div>
)}
<div className="coach-panel-context">
<span>{dashboard.todayCaffeine} caffeine</span>
<span>bst {getBstHour()}:00</span>
</div>
<div className={`coach-panel-feed ${compact ? "coach-panel-feed-compact" : ""}`} aria-live="polite">
{!displayMessages.length ? (
<div className="coach-panel-empty">
<Sparkles size={20} aria-hidden="true" />
<p>ask about pace, flavours, or spend coach reads your live log.</p>
<div className="coach-quick-grid">
{QUICK_PROMPTS.map((prompt) => (
<button key={prompt} className="suggestion-chip" type="button" disabled={busy} onClick={() => void sendPrompt(prompt)}>
{prompt}
</button>
))}
</div>
</div>
) : (
displayMessages.map((message) => (
<CoachLine key={message.id} message={message} userInitials={userInitials} />
))
)}
</div>
{error && <p className="coach-panel-error">{error}</p>}
<form className="coach-panel-composer" onSubmit={submit}>
{!compact && (
<button className="icon-button" type="button" onClick={startNewChat} disabled={busy} aria-label="new chat">
<Plus size={16} aria-hidden="true" />
</button>
)}
<input
className="field-control coach-panel-input"
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="ask coach anything..."
disabled={busy}
/>
{busy ? (
<button className="icon-button" type="button" onClick={stopThinking} aria-label="stop">
<Square size={16} aria-hidden="true" />
</button>
) : (
<button className="primary-button coach-panel-send" type="submit" disabled={!input.trim()} aria-label="send">
<Send size={16} aria-hidden="true" />
</button>
)}
</form>
</section>
);
}
function CoachLine({ message, userInitials }: { message: CoachMessage; userInitials: string }) {
const isAssistant = message.role === "assistant";
const isThinking = isAssistant && message.pending && !message.content.trim();
return (
<article className={`coach-line ${isAssistant ? "coach-line-assistant" : "coach-line-user"}`}>
<span className="coach-line-avatar">{isAssistant ? <Brain size={14} /> : userInitials}</span>
<div className="coach-line-body">
{isThinking && <ThinkingPill stopped={message.stopped} />}
{message.content ? <p>{message.content}</p> : !isThinking ? <span className="coach-line-typing">...</span> : null}
{isAssistant && !message.pending && message.thinking?.trim() ? (
<details className="thinking-details">
<summary>reasoning</summary>
<pre className="thinking-trace">{message.thinking}</pre>
</details>
) : null}
</div>
</article>
);
}
function ThinkingPill({ stopped }: { stopped?: boolean }) {
return (
<div className={`thinking-pill ${stopped ? "thinking-pill-stopped" : ""}`} aria-live="polite">
<div className="thinking-pill-track">
<span className="thinking-pill-shimmer" aria-hidden="true" />
<span className="thinking-pill-label">{stopped ? "stopped" : "Thinking..."}</span>
<span className="thinking-pill-chevron" aria-hidden="true"></span>
</div>
</div>
);
}
+175 -179
View File
@@ -326,259 +326,240 @@ textarea:focus-visible {
box-shadow: var(--elevation-1); box-shadow: var(--elevation-1);
} }
.coach-shell { .coach-panel {
@apply flex flex-col; @apply flex flex-col gap-3 p-5 sm:p-6;
height: calc(100vh - 200px);
min-height: 480px;
} }
@media (min-width: 1024px) { .coach-panel-compact {
.coach-shell { min-height: 360px;
height: calc(100vh - 160px);
}
} }
.coach-locked-shell { .coach-panel-full {
@apply flex items-center justify-center; min-height: calc(100vh - 220px);
} }
.coach-layout { .coach-panel-header {
@apply relative flex flex-1 gap-4 overflow-hidden; @apply flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between;
} }
.coach-sidebar { .coach-panel-title {
@apply hidden w-72 shrink-0 flex-col border p-3 xl:flex; @apply flex items-start gap-3;
background: var(--surface-container);
border-color: var(--outline-variant);
border-radius: 28px;
} }
.coach-sidebar-header { .coach-panel-icon {
@apply flex items-center gap-3 px-1 py-2; @apply flex h-10 w-10 shrink-0 items-center justify-center rounded-xl;
}
.coach-sidebar-icon {
@apply flex h-10 w-10 items-center justify-center rounded-xl;
background: var(--primary-container); background: var(--primary-container);
color: var(--on-primary-container); color: var(--on-primary-container);
} }
.coach-sidebar-label { .coach-panel-kicker {
min-width: 0; @apply text-xs font-semibold uppercase tracking-wide;
color: var(--primary);
} }
.coach-sidebar-label p:first-child { .coach-panel-heading {
@apply truncate text-sm font-semibold; @apply text-lg font-semibold leading-snug;
color: var(--text); color: var(--text);
} }
.coach-sidebar-label p:last-child { .coach-panel-meta {
@apply truncate text-xs; @apply flex flex-wrap items-center gap-2;
color: var(--muted);
} }
.coach-new-chat { .coach-status-pill {
@apply mt-3 inline-flex min-h-11 items-center gap-2 rounded-xl border px-4 text-sm font-semibold transition disabled:cursor-not-allowed; @apply inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-semibold;
background: var(--surface-container-high); background: var(--surface-container-high);
border-color: var(--outline-variant); border-color: var(--outline-variant);
color: var(--text);
}
.coach-new-chat:hover:not(:disabled) {
background: var(--primary-container);
color: var(--on-primary-container);
}
.coach-chat-list {
@apply mt-3 grid flex-1 content-start gap-1 overflow-y-auto;
}
.coach-chat-row {
@apply grid grid-cols-[1fr_auto] items-center rounded-2xl transition;
color: var(--text);
}
.coach-chat-row > button:first-child {
@apply grid min-w-0 gap-0.5 px-3 py-2.5 text-left;
}
.coach-chat-row > button:last-child {
@apply mr-1 grid h-7 w-7 place-items-center rounded-lg opacity-0 transition;
color: var(--muted); color: var(--muted);
} }
.coach-chat-row:hover > button:last-child, .coach-status-dot {
.coach-chat-row-active > button:last-child {
opacity: 1;
}
.coach-chat-row span {
@apply truncate text-sm font-medium;
}
.coach-chat-row small {
@apply text-xs;
color: var(--muted);
}
.coach-chat-row-active,
.coach-chat-row:hover {
background: var(--primary-container);
}
.coach-chat-row-active {
color: var(--on-primary-container);
}
.coach-chat-row-active small {
color: var(--on-primary-container);
}
.coach-context-card {
@apply mt-auto rounded-2xl border p-3;
background: var(--surface-container-high);
border-color: var(--outline-variant);
}
.coach-main {
@apply relative flex flex-1 flex-col overflow-hidden border;
background: var(--surface-container-lowest);
border-color: var(--outline-variant);
border-radius: 28px;
}
.coach-topbar {
@apply flex items-center justify-between border-b px-4 py-2;
border-color: var(--outline-variant);
background: var(--surface-container-low);
}
.coach-topbar-status {
@apply inline-flex items-center gap-1.5 text-xs font-medium;
color: var(--muted);
}
.coach-topbar-status-dot {
@apply h-2 w-2 rounded-full; @apply h-2 w-2 rounded-full;
}
.coach-topbar-status-dot-ready {
background: var(--chart-secondary); background: var(--chart-secondary);
} }
.coach-topbar-status-dot-busy { .coach-status-dot-busy {
background: var(--chart-tertiary); background: var(--chart-tertiary);
@apply animate-pulse; @apply animate-pulse;
} }
.coach-messages { .coach-model-tag {
@apply flex-1 overflow-y-auto; @apply text-xs;
}
.coach-messages-inner {
@apply mx-auto max-w-3xl space-y-4 px-4 pb-32 pt-6;
}
.coach-empty-state {
@apply flex min-h-full flex-col items-center justify-center px-6 text-center;
}
.coach-empty-icon {
@apply mb-5 flex h-16 w-16 items-center justify-center rounded-2xl;
background: var(--primary-container);
color: var(--on-primary-container);
}
.coach-empty-state h2 {
@apply text-3xl font-semibold tracking-tight;
color: var(--text);
}
.coach-empty-state p {
@apply mt-2 max-w-md text-sm leading-6;
color: var(--muted); color: var(--muted);
} }
.coach-prompt-grid { .coach-expand-button {
@apply mt-6 grid gap-2 sm:grid-cols-1; @apply inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-semibold;
max-width: 480px; border-color: var(--outline-variant);
color: var(--text);
} }
.coach-message { .coach-thread-strip {
@apply flex gap-3; @apply flex flex-wrap items-center gap-2;
} }
.coach-message-user { .coach-thread-chip {
@apply flex-row-reverse; @apply inline-flex items-center overflow-hidden rounded-full border text-xs font-semibold;
border-color: var(--outline-variant);
background: var(--surface-container-high);
} }
.coach-message-avatar { .coach-thread-chip button:first-child {
@apply flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold; @apply px-3 py-1.5;
color: var(--text);
} }
.coach-message-avatar-assistant { .coach-thread-chip button:last-child {
@apply px-2 py-1.5 opacity-60;
color: var(--muted);
}
.coach-thread-chip-active {
background: var(--primary-container);
border-color: color-mix(in srgb, var(--primary) 30%, var(--outline-variant));
}
.coach-thread-new {
@apply inline-flex h-8 w-8 items-center justify-center rounded-full border;
border-color: var(--outline-variant);
color: var(--text);
}
.coach-panel-context {
@apply flex flex-wrap gap-3 text-xs font-semibold;
color: var(--muted);
}
.coach-panel-feed {
@apply grid flex-1 content-start gap-3 overflow-y-auto rounded-2xl border p-3;
background: var(--surface-container-low);
border-color: var(--outline-variant);
min-height: 200px;
max-height: min(56vh, 640px);
}
.coach-panel-feed-compact {
min-height: 160px;
max-height: 280px;
}
.coach-panel-empty {
@apply flex flex-col items-center justify-center gap-3 px-4 py-8 text-center;
color: var(--muted);
}
.coach-panel-empty p {
@apply max-w-sm text-sm leading-6;
}
.coach-quick-grid {
@apply grid w-full gap-2;
}
.coach-line {
@apply grid grid-cols-[auto_1fr] gap-2;
}
.coach-line-avatar {
@apply flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold;
background: var(--surface-container-high);
color: var(--text);
}
.coach-line-assistant .coach-line-avatar {
background: var(--primary-container); background: var(--primary-container);
color: var(--on-primary-container); color: var(--on-primary-container);
} }
.coach-message-avatar-user { .coach-line-user .coach-line-avatar {
background: var(--tertiary-container); background: var(--tertiary-container);
color: var(--on-tertiary-container); color: var(--on-tertiary-container);
} }
.coach-message-bubble { .coach-line-body {
@apply max-w-[85%] rounded-2xl px-4 py-3; @apply min-w-0 rounded-2xl px-3 py-2 text-sm leading-relaxed;
}
.coach-message-assistant .coach-message-bubble {
background: var(--surface-container-high); background: var(--surface-container-high);
color: var(--text); color: var(--text);
border-bottom-left-radius: 6px;
} }
.coach-message-user .coach-message-bubble { .coach-line-user .coach-line-body {
background: var(--primary); background: var(--primary);
color: var(--on-primary); color: var(--on-primary);
border-bottom-right-radius: 6px;
} }
.coach-bubble-label { .coach-line-typing {
@apply mb-1 text-xs font-semibold; @apply inline-block animate-pulse;
color: var(--muted); color: var(--muted);
} }
.coach-bubble-content { .coach-panel-error {
@apply whitespace-pre-wrap text-sm leading-relaxed; @apply rounded-xl border px-3 py-2 text-sm;
border-color: var(--error-container);
background: var(--error-container);
color: var(--on-error-container);
} }
.coach-message-user .coach-bubble-content { .coach-panel-composer {
color: var(--on-primary); @apply flex items-center gap-2;
} }
.thinking-slider { .coach-panel-input {
@apply mt-2 w-full overflow-hidden rounded-xl border px-3 py-2 text-xs font-medium transition; @apply min-h-11 flex-1;
background: var(--surface-container); }
border-color: var(--outline-variant);
.coach-panel-send {
@apply min-h-11 min-w-11 px-0;
}
.thinking-pill {
@apply mb-2 overflow-hidden rounded-full border;
background: color-mix(in srgb, var(--surface-container) 88%, black 4%);
border-color: color-mix(in srgb, var(--outline-variant) 80%, var(--primary) 20%);
}
.thinking-pill-track {
@apply relative flex min-h-10 items-center justify-center overflow-hidden px-4;
}
.thinking-pill-label {
@apply relative z-[1] text-xs font-semibold tracking-wide;
color: var(--muted); color: var(--muted);
} }
.thinking-slider:hover { .thinking-pill-chevron {
background: var(--primary-container); @apply absolute right-3 z-[1] text-xs font-bold opacity-70;
color: var(--on-primary-container); color: var(--primary);
animation: thinking-unlock-nudge 1.8s ease-in-out infinite;
} }
.thinking-slider-active { .thinking-pill-shimmer {
border-color: color-mix(in srgb, var(--primary) 40%, var(--outline-variant)); @apply absolute inset-y-0 left-0 w-16 rounded-full opacity-70;
background: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--primary-container) 70%, white),
transparent
);
animation: thinking-unlock-slide 1.8s ease-in-out infinite;
} }
.thinking-slider-track { .thinking-pill-stopped .thinking-pill-shimmer,
@apply block overflow-hidden whitespace-nowrap; .thinking-pill-stopped .thinking-pill-chevron {
animation: none;
opacity: 0.35;
} }
.thinking-slider-track span { .thinking-details {
@apply inline-block; @apply mt-2;
padding-left: 100%; }
animation: thinking-slide 3.2s linear infinite;
.thinking-details summary {
@apply cursor-pointer text-xs font-medium;
color: var(--muted);
}
.thinking-details summary:hover {
color: var(--text);
} }
.thinking-trace { .thinking-trace {
@@ -1120,8 +1101,23 @@ textarea:focus-visible {
color: var(--text); color: var(--text);
} }
@keyframes thinking-slide { @keyframes thinking-unlock-slide {
to { 0% {
transform: translateX(-100%); transform: translateX(-120%);
}
100% {
transform: translateX(calc(100vw + 120%));
}
}
@keyframes thinking-unlock-nudge {
0%,
100% {
transform: translateX(0);
opacity: 0.45;
}
50% {
transform: translateX(-4px);
opacity: 1;
} }
} }
+107
View File
@@ -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)];
}
-178
View File
@@ -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)];
}
+90
View File
@@ -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";
}
+402
View File
@@ -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 };
+97 -1
View File
@@ -1,6 +1,10 @@
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Plugin } from "vite";
import { defineConfig, loadEnv } from "vite"; import { defineConfig, loadEnv } from "vite";
const DEFAULT_MODEL = "deepseek-v4-pro:cloud";
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), ""); const env = loadEnv(mode, process.cwd(), "");
const ollamaProxy = { const ollamaProxy = {
@@ -17,7 +21,7 @@ export default defineConfig(({ mode }) => {
}; };
return { return {
plugins: [react()], plugins: [react(), ollamaProxyPlugin(env)],
server: { server: {
proxy: { proxy: {
"/api/ollama-chat": ollamaProxy, "/api/ollama-chat": ollamaProxy,
@@ -42,3 +46,95 @@ export default defineConfig(({ mode }) => {
}, },
}; };
}); });
function ollamaProxyPlugin(env: Record<string, string>): Plugin {
return {
name: "ollama-proxy",
configureServer(server) {
server.middlewares.use("/api/ollama-chat", createOllamaHandler(env));
},
configurePreviewServer(server) {
server.middlewares.use("/api/ollama-chat", createOllamaHandler(env));
},
};
}
function createOllamaHandler(env: Record<string, string>) {
return (req: IncomingMessage, res: ServerResponse) => {
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.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Method not allowed");
return;
}
void handleOllamaProxy(req, res, env);
};
}
async function handleOllamaProxy(req: IncomingMessage, res: ServerResponse, env: Record<string, string>) {
const apiKey = env.OLLAMA_API_KEY;
if (!apiKey) {
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("OLLAMA_API_KEY is not configured on the server.");
return;
}
try {
const payload = await readJsonBody(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 || 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.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(error instanceof Error ? error.message : "Ollama proxy failed.");
}
}
async function readJsonBody(req: IncomingMessage) {
let raw = "";
for await (const chunk of req) raw += chunk;
return raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
}