Compare commits
2 Commits
e067a3638c
...
e3ba9bab6b
| Author | SHA1 | Date | |
|---|---|---|---|
| e3ba9bab6b | |||
| b4e0615e77 |
+2
-3
@@ -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.
|
||||
APPWRITE_API_KEY=
|
||||
|
||||
# Appwrite chat table columns needed for encrypted coach chats:
|
||||
# userId, encryptedTitle, encryptedMessages, titleIv, messagesIv, salt, updatedAt as strings
|
||||
# version as integer. Enable row security and Users -> Create at table level.
|
||||
# Appwrite chat table columns: userId, title, messages, updatedAt.
|
||||
# Enable row security and Users -> Create at table level.
|
||||
|
||||
+4
-8
@@ -180,19 +180,15 @@ Recommended table-level permissions:
|
||||
- Update: 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:
|
||||
|
||||
| Key | Type | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `userId` | String, 64 | Yes | Current Appwrite user ID |
|
||||
| `encryptedTitle` | String, 4000 | Yes | AES-GCM ciphertext |
|
||||
| `encryptedMessages` | String, 50000+ | Yes | AES-GCM ciphertext for message JSON |
|
||||
| `titleIv` | String, 128 | Yes | Base64 IV |
|
||||
| `messagesIv` | String, 128 | Yes | Base64 IV |
|
||||
| `salt` | String, 128 | Yes | Base64 PBKDF2 salt |
|
||||
| `version` | Integer | Yes | Crypto version |
|
||||
| `title` | String, 512 | Yes | Chat title |
|
||||
| `messages` | Longtext | Yes | JSON array of coach messages |
|
||||
| `updatedAt` | DateTime | Yes | Sort key |
|
||||
|
||||
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/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/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/metrics.ts`: Prices, caffeine/sugar estimates, stats, grouping, streaks.
|
||||
- `src/lib/storage.ts`: JSON backup export/import parser.
|
||||
|
||||
@@ -44,16 +44,21 @@ await ensureTable({
|
||||
name: "Coach chats",
|
||||
columns: [
|
||||
{ kind: "string", key: "userId", size: 64, required: true },
|
||||
{ kind: "string", key: "encryptedTitle", size: 4000, required: true, encrypt: true },
|
||||
{ kind: "longtext", key: "encryptedMessages", required: true, encrypt: 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: "string", key: "title", size: 512, required: true },
|
||||
{ kind: "longtext", key: "messages", required: true },
|
||||
{ kind: "datetime", key: "updatedAt", required: true },
|
||||
],
|
||||
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.");
|
||||
|
||||
@@ -122,6 +127,19 @@ async function ensureColumn(tableId, column) {
|
||||
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) {
|
||||
const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]);
|
||||
if (existing.status === 200) {
|
||||
|
||||
+85
-582
@@ -2,7 +2,6 @@ import type { Models } from "appwrite";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Brain,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
@@ -17,21 +16,17 @@ import {
|
||||
Home,
|
||||
LineChart,
|
||||
Loader2,
|
||||
Lock,
|
||||
LogIn,
|
||||
LogOut,
|
||||
MessageCircle,
|
||||
MessageSquarePlus,
|
||||
Plus,
|
||||
PoundSterling,
|
||||
RefreshCcw,
|
||||
RotateCcw,
|
||||
Send,
|
||||
Search,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Square,
|
||||
TimerReset,
|
||||
Trash2,
|
||||
Upload,
|
||||
@@ -88,13 +83,10 @@ import {
|
||||
listEntries,
|
||||
updateEntry,
|
||||
} from "./lib/appwriteEntries";
|
||||
import {
|
||||
chatStorageErrorMessage,
|
||||
createEncryptedChat,
|
||||
deleteEncryptedChat,
|
||||
listEncryptedChats,
|
||||
updateEncryptedChat,
|
||||
} from "./lib/encryptedChats";
|
||||
import { CoachPanel } from "./components/CoachPanel";
|
||||
import { buildDynamicGreeting } from "./lib/greeting";
|
||||
import type { CoachSession } from "./lib/useCoachSession";
|
||||
import { useCoachSession } from "./lib/useCoachSession";
|
||||
import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel";
|
||||
import {
|
||||
caffeineFor,
|
||||
@@ -123,15 +115,12 @@ import {
|
||||
wholeNumber,
|
||||
} from "./lib/metrics";
|
||||
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 AuthMode = "login" | "signup";
|
||||
type AuthUser = Models.User<Models.Preferences>;
|
||||
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 = {
|
||||
flavour: "all",
|
||||
@@ -284,6 +273,7 @@ function App() {
|
||||
const flavourData = useMemo(() => groupByFlavour(filteredEntries), [filteredEntries]);
|
||||
const insights = useMemo(() => buildInsights(entries), [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) {
|
||||
setActionLoading("auth");
|
||||
@@ -596,7 +586,7 @@ function App() {
|
||||
transition={{ duration: 0.2 }}
|
||||
className="app-main"
|
||||
>
|
||||
{activeView === "overview" && (
|
||||
{activeView === "overview" && user && (
|
||||
<OverviewView
|
||||
dashboard={dashboard}
|
||||
entries={entries}
|
||||
@@ -606,9 +596,13 @@ function App() {
|
||||
chartData={chartData}
|
||||
flavourData={flavourData}
|
||||
user={user}
|
||||
coachSession={coachSession}
|
||||
onQuickAdd={(item) => void quickAdd(item)}
|
||||
onAdd={openNewEntry}
|
||||
onOpenCoach={() => setActiveView("coach")}
|
||||
onOpenCoach={(prompt) => {
|
||||
if (prompt) coachSession.queuePrompt(prompt);
|
||||
setActiveView("coach");
|
||||
}}
|
||||
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" && (
|
||||
<SettingsView
|
||||
@@ -1157,6 +1158,7 @@ function OverviewView({
|
||||
chartData,
|
||||
flavourData,
|
||||
user,
|
||||
coachSession,
|
||||
onQuickAdd,
|
||||
onAdd,
|
||||
onOpenCoach,
|
||||
@@ -1170,18 +1172,40 @@ function OverviewView({
|
||||
chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>;
|
||||
flavourData: Array<{ name: string; value: number; spend: number; accent: string }>;
|
||||
user: AuthUser;
|
||||
coachSession: CoachSession;
|
||||
onQuickAdd: (item: (typeof QUICK_ADDS)[number]) => void;
|
||||
onAdd: () => void;
|
||||
onOpenCoach: () => void;
|
||||
onOpenCoach: (prompt?: string) => void;
|
||||
onOpenLogbook: () => void;
|
||||
}) {
|
||||
return (
|
||||
<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]">
|
||||
<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 className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
@@ -1263,20 +1287,39 @@ function OverviewView({
|
||||
|
||||
function GreetingPanel({
|
||||
dashboard,
|
||||
entries,
|
||||
user,
|
||||
onOpenCoach,
|
||||
}: {
|
||||
dashboard: Dashboard;
|
||||
entries: RedBullEntry[];
|
||||
user: AuthUser;
|
||||
onOpenCoach: () => void;
|
||||
onOpenCoach: (prompt?: string) => void;
|
||||
}) {
|
||||
const todayNumber = Number.parseFloat(dashboard.todayCans) || 0;
|
||||
const progress = Math.min(100, Math.round((todayNumber / 4) * 100));
|
||||
const name = firstName(user);
|
||||
const favourite = dashboard.favouriteFlavour === "None yet" ? "still forming" : dashboard.favouriteFlavour;
|
||||
const redBullLabel = todayNumber === 1 ? "Red Bull" : "Red Bulls";
|
||||
const greeting = buildDynamicGreeting({
|
||||
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 (
|
||||
<section className="oura-hero glass-panel p-5 sm:p-6">
|
||||
@@ -1291,33 +1334,25 @@ function GreetingPanel({
|
||||
<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">
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
Daily readiness
|
||||
{greeting.badge}
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-white sm:text-4xl">
|
||||
Hey {name}, you've had {dashboard.todayCans} {redBullLabel} today and your favourite flavour is {favourite}.
|
||||
</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>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-white sm:text-4xl">{greeting.headline}</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-400">{greeting.subline}</p>
|
||||
</div>
|
||||
|
||||
<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="Sugar" value={dashboard.todaySugar} />
|
||||
<WellnessPill label="Entries" value={`${entries.length}`} />
|
||||
<WellnessPill label="Streak" value={`${dashboard.currentStreak} days`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-2 md:grid-cols-3">
|
||||
<button className="suggestion-chip" type="button" onClick={onOpenCoach}>
|
||||
Ask Coach for today's pace
|
||||
</button>
|
||||
<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
|
||||
{coachPrompts.map((item) => (
|
||||
<button key={item.label} className="suggestion-chip" type="button" onClick={() => onOpenCoach(item.prompt)}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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({
|
||||
activeTheme,
|
||||
@@ -2975,90 +2549,19 @@ function formatMetricValue(name: string, value: number) {
|
||||
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) {
|
||||
const fallback = user.email?.split("@")[0] ?? "there";
|
||||
const value = (user.name || fallback).trim();
|
||||
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) {
|
||||
if (size === 250 || size === 355 || size === 473) return size.toString();
|
||||
return "custom";
|
||||
|
||||
@@ -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
@@ -326,259 +326,240 @@ textarea:focus-visible {
|
||||
box-shadow: var(--elevation-1);
|
||||
}
|
||||
|
||||
.coach-shell {
|
||||
@apply flex flex-col;
|
||||
height: calc(100vh - 200px);
|
||||
min-height: 480px;
|
||||
.coach-panel {
|
||||
@apply flex flex-col gap-3 p-5 sm:p-6;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.coach-shell {
|
||||
height: calc(100vh - 160px);
|
||||
}
|
||||
.coach-panel-compact {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.coach-locked-shell {
|
||||
@apply flex items-center justify-center;
|
||||
.coach-panel-full {
|
||||
min-height: calc(100vh - 220px);
|
||||
}
|
||||
|
||||
.coach-layout {
|
||||
@apply relative flex flex-1 gap-4 overflow-hidden;
|
||||
.coach-panel-header {
|
||||
@apply flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between;
|
||||
}
|
||||
|
||||
.coach-sidebar {
|
||||
@apply hidden w-72 shrink-0 flex-col border p-3 xl:flex;
|
||||
background: var(--surface-container);
|
||||
border-color: var(--outline-variant);
|
||||
border-radius: 28px;
|
||||
.coach-panel-title {
|
||||
@apply flex items-start gap-3;
|
||||
}
|
||||
|
||||
.coach-sidebar-header {
|
||||
@apply flex items-center gap-3 px-1 py-2;
|
||||
}
|
||||
|
||||
.coach-sidebar-icon {
|
||||
@apply flex h-10 w-10 items-center justify-center rounded-xl;
|
||||
.coach-panel-icon {
|
||||
@apply flex h-10 w-10 shrink-0 items-center justify-center rounded-xl;
|
||||
background: var(--primary-container);
|
||||
color: var(--on-primary-container);
|
||||
}
|
||||
|
||||
.coach-sidebar-label {
|
||||
min-width: 0;
|
||||
.coach-panel-kicker {
|
||||
@apply text-xs font-semibold uppercase tracking-wide;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.coach-sidebar-label p:first-child {
|
||||
@apply truncate text-sm font-semibold;
|
||||
.coach-panel-heading {
|
||||
@apply text-lg font-semibold leading-snug;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.coach-sidebar-label p:last-child {
|
||||
@apply truncate text-xs;
|
||||
color: var(--muted);
|
||||
.coach-panel-meta {
|
||||
@apply flex flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.coach-new-chat {
|
||||
@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;
|
||||
.coach-status-pill {
|
||||
@apply inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-semibold;
|
||||
background: var(--surface-container-high);
|
||||
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);
|
||||
}
|
||||
|
||||
.coach-chat-row:hover > button:last-child,
|
||||
.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 {
|
||||
.coach-status-dot {
|
||||
@apply h-2 w-2 rounded-full;
|
||||
}
|
||||
|
||||
.coach-topbar-status-dot-ready {
|
||||
background: var(--chart-secondary);
|
||||
}
|
||||
|
||||
.coach-topbar-status-dot-busy {
|
||||
.coach-status-dot-busy {
|
||||
background: var(--chart-tertiary);
|
||||
@apply animate-pulse;
|
||||
}
|
||||
|
||||
.coach-messages {
|
||||
@apply flex-1 overflow-y-auto;
|
||||
}
|
||||
|
||||
.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;
|
||||
.coach-model-tag {
|
||||
@apply text-xs;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.coach-prompt-grid {
|
||||
@apply mt-6 grid gap-2 sm:grid-cols-1;
|
||||
max-width: 480px;
|
||||
.coach-expand-button {
|
||||
@apply inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-semibold;
|
||||
border-color: var(--outline-variant);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.coach-message {
|
||||
@apply flex gap-3;
|
||||
.coach-thread-strip {
|
||||
@apply flex flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.coach-message-user {
|
||||
@apply flex-row-reverse;
|
||||
.coach-thread-chip {
|
||||
@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 {
|
||||
@apply flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold;
|
||||
.coach-thread-chip button:first-child {
|
||||
@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);
|
||||
color: var(--on-primary-container);
|
||||
}
|
||||
|
||||
.coach-message-avatar-user {
|
||||
.coach-line-user .coach-line-avatar {
|
||||
background: var(--tertiary-container);
|
||||
color: var(--on-tertiary-container);
|
||||
}
|
||||
|
||||
.coach-message-bubble {
|
||||
@apply max-w-[85%] rounded-2xl px-4 py-3;
|
||||
}
|
||||
|
||||
.coach-message-assistant .coach-message-bubble {
|
||||
.coach-line-body {
|
||||
@apply min-w-0 rounded-2xl px-3 py-2 text-sm leading-relaxed;
|
||||
background: var(--surface-container-high);
|
||||
color: var(--text);
|
||||
border-bottom-left-radius: 6px;
|
||||
}
|
||||
|
||||
.coach-message-user .coach-message-bubble {
|
||||
.coach-line-user .coach-line-body {
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
|
||||
.coach-bubble-label {
|
||||
@apply mb-1 text-xs font-semibold;
|
||||
.coach-line-typing {
|
||||
@apply inline-block animate-pulse;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.coach-bubble-content {
|
||||
@apply whitespace-pre-wrap text-sm leading-relaxed;
|
||||
.coach-panel-error {
|
||||
@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 {
|
||||
color: var(--on-primary);
|
||||
.coach-panel-composer {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.thinking-slider {
|
||||
@apply mt-2 w-full overflow-hidden rounded-xl border px-3 py-2 text-xs font-medium transition;
|
||||
background: var(--surface-container);
|
||||
border-color: var(--outline-variant);
|
||||
.coach-panel-input {
|
||||
@apply min-h-11 flex-1;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.thinking-slider:hover {
|
||||
background: var(--primary-container);
|
||||
color: var(--on-primary-container);
|
||||
.thinking-pill-chevron {
|
||||
@apply absolute right-3 z-[1] text-xs font-bold opacity-70;
|
||||
color: var(--primary);
|
||||
animation: thinking-unlock-nudge 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.thinking-slider-active {
|
||||
border-color: color-mix(in srgb, var(--primary) 40%, var(--outline-variant));
|
||||
.thinking-pill-shimmer {
|
||||
@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 {
|
||||
@apply block overflow-hidden whitespace-nowrap;
|
||||
.thinking-pill-stopped .thinking-pill-shimmer,
|
||||
.thinking-pill-stopped .thinking-pill-chevron {
|
||||
animation: none;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.thinking-slider-track span {
|
||||
@apply inline-block;
|
||||
padding-left: 100%;
|
||||
animation: thinking-slide 3.2s linear infinite;
|
||||
.thinking-details {
|
||||
@apply mt-2;
|
||||
}
|
||||
|
||||
.thinking-details summary {
|
||||
@apply cursor-pointer text-xs font-medium;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.thinking-details summary:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.thinking-trace {
|
||||
@@ -1120,8 +1101,23 @@ textarea:focus-visible {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@keyframes thinking-slide {
|
||||
to {
|
||||
transform: translateX(-100%);
|
||||
@keyframes thinking-unlock-slide {
|
||||
0% {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
+97
-1
@@ -1,6 +1,10 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Plugin } from "vite";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
|
||||
const DEFAULT_MODEL = "deepseek-v4-pro:cloud";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const ollamaProxy = {
|
||||
@@ -17,7 +21,7 @@ export default defineConfig(({ mode }) => {
|
||||
};
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
plugins: [react(), ollamaProxyPlugin(env)],
|
||||
server: {
|
||||
proxy: {
|
||||
"/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>) : {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user