rm coach chat and ollama proxy
drop coach panel, session, chat store, and the limitsSummaryForCoach helper
This commit is contained in:
@@ -1,77 +0,0 @@
|
|||||||
/* global Buffer, fetch, process */
|
|
||||||
|
|
||||||
const DEFAULT_MODEL = "deepseek-v4-pro:cloud";
|
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
||||||
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
|
||||||
res.statusCode = 204;
|
|
||||||
res.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
res.statusCode = 405;
|
|
||||||
res.end("Method not allowed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = process.env.OLLAMA_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.end("OLLAMA_API_KEY is not configured on the server.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = await readJson(req);
|
|
||||||
const upstream = await fetch("https://ollama.com/api/chat", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
...payload,
|
|
||||||
model: payload.model || process.env.OLLAMA_MODEL || DEFAULT_MODEL,
|
|
||||||
stream: payload.stream !== false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
res.statusCode = upstream.status;
|
|
||||||
res.setHeader("Content-Type", upstream.headers.get("content-type") || "application/x-ndjson");
|
|
||||||
|
|
||||||
if (!upstream.ok) {
|
|
||||||
res.end(await upstream.text());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!upstream.body) {
|
|
||||||
res.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = upstream.body.getReader();
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
res.write(Buffer.from(value));
|
|
||||||
}
|
|
||||||
res.end();
|
|
||||||
} catch (error) {
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.end(error instanceof Error ? error.message : "Ollama proxy failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJson(req) {
|
|
||||||
if (req.body && typeof req.body === "object") return req.body;
|
|
||||||
if (typeof req.body === "string") return JSON.parse(req.body || "{}");
|
|
||||||
|
|
||||||
let raw = "";
|
|
||||||
for await (const chunk of req) raw += chunk;
|
|
||||||
return raw ? JSON.parse(raw) : {};
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import { Brain, ChevronRight, Loader2, Plus, Send, Sparkles, Square, Trash2 } from "lucide-react";
|
|
||||||
import type { FormEvent } from "react";
|
|
||||||
import { getBstHour } from "../lib/greeting";
|
|
||||||
import type { CoachSession } from "../lib/useCoachSession";
|
|
||||||
import { OLLAMA_MODEL } from "../lib/useCoachSession";
|
|
||||||
import type { CoachMessage } from "../types";
|
|
||||||
|
|
||||||
type CoachPanelProps = {
|
|
||||||
session: CoachSession;
|
|
||||||
mode: "compact" | "full";
|
|
||||||
dashboard: {
|
|
||||||
todayCans: string;
|
|
||||||
todayCaffeine: string;
|
|
||||||
favouriteFlavour: string;
|
|
||||||
};
|
|
||||||
userInitials: string;
|
|
||||||
onExpand?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const QUICK_PROMPTS = [
|
|
||||||
"what's my favourite flavour historically?",
|
|
||||||
"how should i pace caffeine for the rest of the day?",
|
|
||||||
"suggest a lower-sugar swap",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function CoachPanel({ session, mode, dashboard, userInitials, onExpand }: CoachPanelProps) {
|
|
||||||
const {
|
|
||||||
busy,
|
|
||||||
chats,
|
|
||||||
error,
|
|
||||||
input,
|
|
||||||
activeChatId,
|
|
||||||
removeChat,
|
|
||||||
sendPrompt,
|
|
||||||
setActiveChatId,
|
|
||||||
setInput,
|
|
||||||
startNewChat,
|
|
||||||
stopThinking,
|
|
||||||
storageReady,
|
|
||||||
storageStatus,
|
|
||||||
visibleMessages,
|
|
||||||
} = session;
|
|
||||||
|
|
||||||
const displayMessages = mode === "compact" ? visibleMessages.slice(-4) : visibleMessages;
|
|
||||||
const compact = mode === "compact";
|
|
||||||
|
|
||||||
async function submit(event: FormEvent<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import type { Models } from "appwrite";
|
|
||||||
import type { CoachChat, CoachMessage } from "../types";
|
|
||||||
import { appwriteConfig, ID, Permission, Query, Role, tablesDB } from "./appwrite";
|
|
||||||
|
|
||||||
type CoachChatRow = Models.Row & {
|
|
||||||
userId: string;
|
|
||||||
title: string;
|
|
||||||
messages: string;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function listCoachChats(userId: string) {
|
|
||||||
const response = await tablesDB.listRows<CoachChatRow>({
|
|
||||||
databaseId: appwriteConfig.databaseId,
|
|
||||||
tableId: appwriteConfig.chatCollectionId,
|
|
||||||
queries: [Query.equal("userId", userId), Query.orderDesc("updatedAt"), Query.limit(50)],
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.rows.filter(isPlainChatRow).map(fromRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createCoachChat(userId: string, chat: CoachChat) {
|
|
||||||
const row = await tablesDB.createRow<CoachChatRow>({
|
|
||||||
databaseId: appwriteConfig.databaseId,
|
|
||||||
tableId: appwriteConfig.chatCollectionId,
|
|
||||||
rowId: ID.custom(chat.id),
|
|
||||||
data: toRowData(userId, chat),
|
|
||||||
permissions: userRowPermissions(userId),
|
|
||||||
});
|
|
||||||
|
|
||||||
return fromRow(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateCoachChat(userId: string, chat: CoachChat) {
|
|
||||||
const row = await tablesDB.updateRow<CoachChatRow>({
|
|
||||||
databaseId: appwriteConfig.databaseId,
|
|
||||||
tableId: appwriteConfig.chatCollectionId,
|
|
||||||
rowId: chat.id,
|
|
||||||
data: toRowData(userId, chat),
|
|
||||||
permissions: userRowPermissions(userId),
|
|
||||||
});
|
|
||||||
|
|
||||||
return fromRow(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteCoachChat(id: string) {
|
|
||||||
await tablesDB.deleteRow({
|
|
||||||
databaseId: appwriteConfig.databaseId,
|
|
||||||
tableId: appwriteConfig.chatCollectionId,
|
|
||||||
rowId: id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function chatStorageErrorMessage(error: unknown) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (/not found|404/i.test(error.message)) {
|
|
||||||
return `Appwrite chat table '${appwriteConfig.chatCollectionId}' was not found. Run npm run setup:appwrite.`;
|
|
||||||
}
|
|
||||||
if (/permissions?.*create|action 'create'|not authorized|401|unauthorized/i.test(error.message)) {
|
|
||||||
return `Appwrite chat table needs Users -> Create and row security on '${appwriteConfig.chatCollectionId}'.`;
|
|
||||||
}
|
|
||||||
if (/unknown attribute|invalid document structure|missing required attribute/i.test(error.message)) {
|
|
||||||
if (/encrypted/i.test(error.message)) {
|
|
||||||
return "Coach chat table still requires legacy encrypted columns. Run npm run setup:appwrite or remove encryptedTitle, encryptedMessages, titleIv, messagesIv, salt, and version as required in Appwrite Console.";
|
|
||||||
}
|
|
||||||
return "Coach chat schema needs title and messages columns. Run npm run setup:appwrite.";
|
|
||||||
}
|
|
||||||
return error.message;
|
|
||||||
}
|
|
||||||
return "Coach chat storage failed.";
|
|
||||||
}
|
|
||||||
|
|
||||||
function toRowData(userId: string, chat: CoachChat) {
|
|
||||||
return {
|
|
||||||
userId,
|
|
||||||
title: chat.title.slice(0, 512) || "today",
|
|
||||||
messages: JSON.stringify(chat.messages),
|
|
||||||
updatedAt: chat.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlainChatRow(row: CoachChatRow) {
|
|
||||||
return typeof row.title === "string" && row.title.length > 0 && typeof row.messages === "string" && row.messages.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromRow(row: CoachChatRow): CoachChat {
|
|
||||||
let messages: CoachMessage[] = [];
|
|
||||||
try {
|
|
||||||
messages = JSON.parse(row.messages) as CoachMessage[];
|
|
||||||
} catch {
|
|
||||||
messages = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: row.$id,
|
|
||||||
userId: row.userId,
|
|
||||||
title: row.title,
|
|
||||||
messages,
|
|
||||||
createdAt: row.$createdAt,
|
|
||||||
updatedAt: row.updatedAt || row.$updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function userRowPermissions(userId: string) {
|
|
||||||
const role = Role.user(userId);
|
|
||||||
return [Permission.read(role), Permission.update(role), Permission.delete(role)];
|
|
||||||
}
|
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import type { Models } from "appwrite";
|
|
||||||
import {
|
|
||||||
chatStorageErrorMessage,
|
|
||||||
createCoachChat,
|
|
||||||
deleteCoachChat,
|
|
||||||
listCoachChats,
|
|
||||||
updateCoachChat,
|
|
||||||
} from "./coachChats";
|
|
||||||
import { buildFlavourHistorySummary, getBstHour } from "./greeting";
|
|
||||||
import {
|
|
||||||
caffeineFor,
|
|
||||||
currency,
|
|
||||||
humanDateTime,
|
|
||||||
makeId,
|
|
||||||
oneDecimal,
|
|
||||||
spendFor,
|
|
||||||
sugarFor,
|
|
||||||
wholeNumber,
|
|
||||||
} from "./metrics";
|
|
||||||
import type { CoachChat, CoachMessage, LimitCheckResult, RedBullEntry, UserLimits } from "../types";
|
|
||||||
import { limitsSummaryForCoach } from "./userLimits";
|
|
||||||
|
|
||||||
type AuthUser = Models.User<Models.Preferences>;
|
|
||||||
|
|
||||||
type Dashboard = {
|
|
||||||
todayCans: string;
|
|
||||||
todayCaffeine: string;
|
|
||||||
todaySugar: string;
|
|
||||||
favouriteFlavour: string;
|
|
||||||
currentStreak: string;
|
|
||||||
totalSpend: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const OLLAMA_MODEL = "deepseek-v4-pro:cloud";
|
|
||||||
const OLLAMA_PROXY_URL = import.meta.env.VITE_OLLAMA_PROXY_URL?.trim() || "/api/ollama-chat";
|
|
||||||
|
|
||||||
type OllamaStreamChunk = { error?: string; message?: { content?: string; thinking?: string } };
|
|
||||||
|
|
||||||
export type CoachSession = ReturnType<typeof useCoachSession>;
|
|
||||||
|
|
||||||
export function useCoachSession(
|
|
||||||
user: AuthUser,
|
|
||||||
dashboard: Dashboard,
|
|
||||||
entries: RedBullEntry[],
|
|
||||||
userLimits: UserLimits = {},
|
|
||||||
limitCheck?: LimitCheckResult,
|
|
||||||
) {
|
|
||||||
const [chats, setChats] = useState<CoachChat[]>([]);
|
|
||||||
const [activeChatId, setActiveChatId] = useState<string | null>(null);
|
|
||||||
const [savedChatIds, setSavedChatIds] = useState<Set<string>>(() => new Set());
|
|
||||||
const [storageStatus, setStorageStatus] = useState("loading");
|
|
||||||
const [storageReady, setStorageReady] = useState(false);
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
|
||||||
const queuedPromptRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const activeChat = useMemo(() => chats.find((chat) => chat.id === activeChatId) ?? null, [chats, activeChatId]);
|
|
||||||
const messages = useMemo(() => activeChat?.messages ?? [], [activeChat]);
|
|
||||||
const visibleMessages = useMemo(() => messages.filter((message) => message.id !== "coach-welcome"), [messages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function loadChats() {
|
|
||||||
if (!user.$id) return;
|
|
||||||
setStorageStatus("loading");
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const savedChats = await listCoachChats(user.$id);
|
|
||||||
if (cancelled) return;
|
|
||||||
const initialChats = savedChats.length ? savedChats : [buildNewCoachChat(user, dashboard)];
|
|
||||||
setChats(initialChats);
|
|
||||||
setSavedChatIds(new Set(savedChats.map((chat) => chat.id)));
|
|
||||||
setActiveChatId(initialChats[0].id);
|
|
||||||
setStorageStatus(savedChats.length ? `${savedChats.length} synced` : "ready");
|
|
||||||
setStorageReady(true);
|
|
||||||
} catch (caught) {
|
|
||||||
if (cancelled) return;
|
|
||||||
setError(chatStorageErrorMessage(caught));
|
|
||||||
const fallback = buildNewCoachChat(user, dashboard);
|
|
||||||
setChats([fallback]);
|
|
||||||
setActiveChatId(fallback.id);
|
|
||||||
setStorageStatus("local only");
|
|
||||||
setStorageReady(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadChats();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [user.$id]);
|
|
||||||
|
|
||||||
const upsertChatState = useCallback((chat: CoachChat) => {
|
|
||||||
setChats((current) => {
|
|
||||||
const exists = current.some((item) => item.id === chat.id);
|
|
||||||
return exists ? current.map((item) => (item.id === chat.id ? chat : item)) : [chat, ...current];
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const patchAssistantMessage = useCallback((chatId: string, messageId: string, patch: Partial<CoachMessage>) => {
|
|
||||||
setChats((current) =>
|
|
||||||
current.map((chat) =>
|
|
||||||
chat.id === chatId
|
|
||||||
? {
|
|
||||||
...chat,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
messages: chat.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)),
|
|
||||||
}
|
|
||||||
: chat,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const withAssistantMessage = useCallback((chat: CoachChat, messageId: string, patch: Partial<CoachMessage>): CoachChat => {
|
|
||||||
return {
|
|
||||||
...chat,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
messages: chat.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)),
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const persistChat = useCallback(
|
|
||||||
async (chat: CoachChat) => {
|
|
||||||
try {
|
|
||||||
const saved = savedChatIds.has(chat.id)
|
|
||||||
? await updateCoachChat(user.$id, chat)
|
|
||||||
: await createCoachChat(user.$id, chat);
|
|
||||||
setSavedChatIds((current) => new Set(current).add(saved.id));
|
|
||||||
upsertChatState(saved);
|
|
||||||
setStorageStatus("synced");
|
|
||||||
return true;
|
|
||||||
} catch (caught) {
|
|
||||||
setStorageStatus("save pending");
|
|
||||||
setError(chatStorageErrorMessage(caught));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[savedChatIds, upsertChatState, user.$id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendPrompt = useCallback(
|
|
||||||
async (prompt: string, chatOverride?: CoachChat | null) => {
|
|
||||||
const trimmed = prompt.trim();
|
|
||||||
if (!trimmed || busy || !storageReady || !user.$id) return false;
|
|
||||||
|
|
||||||
const currentChat = chatOverride ?? activeChat ?? buildNewCoachChat(user, dashboard);
|
|
||||||
const userMessage: CoachMessage = { id: makeId(), role: "user", content: trimmed };
|
|
||||||
const assistantId = makeId();
|
|
||||||
const assistantMessage: CoachMessage = { id: assistantId, role: "assistant", content: "", thinking: "", pending: true };
|
|
||||||
const conversation = [...currentChat.messages, userMessage];
|
|
||||||
const draftChat: CoachChat = {
|
|
||||||
...currentChat,
|
|
||||||
title: titleForChat(currentChat.title, trimmed),
|
|
||||||
messages: [...conversation, assistantMessage],
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
upsertChatState(draftChat);
|
|
||||||
setActiveChatId(draftChat.id);
|
|
||||||
setInput("");
|
|
||||||
setBusy(true);
|
|
||||||
setError("");
|
|
||||||
|
|
||||||
let streamedContent = "";
|
|
||||||
let streamedThinking = "";
|
|
||||||
const abortController = new AbortController();
|
|
||||||
abortRef.current = abortController;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const requestMessages: Array<{ role: string; content: string; thinking?: string }> = [
|
|
||||||
{ role: "system", content: buildCoachSystemPrompt(user, dashboard, entries, userLimits, limitCheck) },
|
|
||||||
...conversation
|
|
||||||
.filter((message) => message.content.trim().length > 0)
|
|
||||||
.map((message) => ({
|
|
||||||
role: message.role,
|
|
||||||
content: message.content,
|
|
||||||
...(message.thinking ? { thinking: message.thinking } : {}),
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
const response = await fetch(OLLAMA_PROXY_URL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: OLLAMA_MODEL,
|
|
||||||
messages: requestMessages,
|
|
||||||
stream: true,
|
|
||||||
think: true,
|
|
||||||
}),
|
|
||||||
signal: abortController.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const detail = await response.text();
|
|
||||||
throw new Error(parseCoachError(detail, response.status));
|
|
||||||
}
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error("streaming response was empty.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await readOllamaStream(response.body, (chunk) => {
|
|
||||||
if (chunk.error) throw new Error(chunk.error);
|
|
||||||
if (chunk.message?.thinking) streamedThinking += chunk.message.thinking;
|
|
||||||
if (chunk.message?.content) streamedContent += chunk.message.content.toLocaleLowerCase();
|
|
||||||
|
|
||||||
patchAssistantMessage(draftChat.id, assistantId, {
|
|
||||||
content: streamedContent,
|
|
||||||
thinking: streamedThinking,
|
|
||||||
pending: !streamedContent,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const finalChat = withAssistantMessage(draftChat, assistantId, {
|
|
||||||
content: streamedContent || "no answer returned.",
|
|
||||||
thinking: streamedThinking,
|
|
||||||
pending: false,
|
|
||||||
});
|
|
||||||
upsertChatState(finalChat);
|
|
||||||
void persistChat(finalChat);
|
|
||||||
return true;
|
|
||||||
} catch (caught) {
|
|
||||||
const aborted = abortController.signal.aborted;
|
|
||||||
const message = caught instanceof Error ? caught.message : "coach request failed.";
|
|
||||||
const finalChat = withAssistantMessage(draftChat, assistantId, {
|
|
||||||
content: aborted ? streamedContent || "stopped thinking." : `coach unavailable: ${message}`.toLocaleLowerCase(),
|
|
||||||
thinking: streamedThinking,
|
|
||||||
pending: false,
|
|
||||||
stopped: aborted,
|
|
||||||
});
|
|
||||||
upsertChatState(finalChat);
|
|
||||||
void persistChat(finalChat);
|
|
||||||
if (!aborted) setError(message);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
abortRef.current = null;
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[activeChat, busy, dashboard, entries, limitCheck, patchAssistantMessage, persistChat, storageReady, upsertChatState, user, userLimits, withAssistantMessage],
|
|
||||||
);
|
|
||||||
|
|
||||||
const queuePrompt = useCallback((prompt: string) => {
|
|
||||||
queuedPromptRef.current = prompt;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const prompt = queuedPromptRef.current;
|
|
||||||
if (!storageReady || !prompt || busy) return;
|
|
||||||
queuedPromptRef.current = null;
|
|
||||||
void sendPrompt(prompt);
|
|
||||||
}, [storageReady, busy, sendPrompt]);
|
|
||||||
|
|
||||||
const startNewChat = useCallback(() => {
|
|
||||||
const chat = buildNewCoachChat(user, dashboard);
|
|
||||||
setChats((current) => [chat, ...current]);
|
|
||||||
setActiveChatId(chat.id);
|
|
||||||
setInput("");
|
|
||||||
setError("");
|
|
||||||
}, [dashboard, user]);
|
|
||||||
|
|
||||||
const removeChat = useCallback(
|
|
||||||
async (chatId: string) => {
|
|
||||||
if (busy) return;
|
|
||||||
try {
|
|
||||||
if (savedChatIds.has(chatId)) await deleteCoachChat(chatId);
|
|
||||||
setSavedChatIds((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
next.delete(chatId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setChats((current) => {
|
|
||||||
const next = current.filter((chat) => chat.id !== chatId);
|
|
||||||
const fallback = buildNewCoachChat(user, dashboard);
|
|
||||||
setActiveChatId(next[0]?.id ?? fallback.id);
|
|
||||||
return next.length ? next : [fallback];
|
|
||||||
});
|
|
||||||
} catch (caught) {
|
|
||||||
setError(chatStorageErrorMessage(caught));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[busy, dashboard, savedChatIds, user],
|
|
||||||
);
|
|
||||||
|
|
||||||
const stopThinking = useCallback(() => {
|
|
||||||
abortRef.current?.abort();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
activeChatId,
|
|
||||||
busy,
|
|
||||||
chats,
|
|
||||||
error,
|
|
||||||
input,
|
|
||||||
queuePrompt,
|
|
||||||
removeChat,
|
|
||||||
sendPrompt,
|
|
||||||
setActiveChatId,
|
|
||||||
setError,
|
|
||||||
setInput,
|
|
||||||
startNewChat,
|
|
||||||
stopThinking,
|
|
||||||
storageReady,
|
|
||||||
storageStatus,
|
|
||||||
visibleMessages,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstName(user: AuthUser) {
|
|
||||||
const fallback = user.email?.split("@")[0] ?? "there";
|
|
||||||
const value = (user.name || fallback).trim();
|
|
||||||
return value.split(/\s+/)[0] || "there";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNewCoachChat(user: AuthUser, dashboard: Dashboard): CoachChat {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const favourite = dashboard.favouriteFlavour === "None yet" ? "your patterns" : dashboard.favouriteFlavour;
|
|
||||||
return {
|
|
||||||
id: makeId(),
|
|
||||||
userId: user.$id,
|
|
||||||
title: "today",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: "coach-welcome",
|
|
||||||
role: "assistant",
|
|
||||||
content: `hey ${firstName(user).toLocaleLowerCase()}, ${dashboard.todayCans} cans logged today. ask about ${favourite}, caffeine pace, or spend.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleForChat(currentTitle: string, prompt: string) {
|
|
||||||
if (currentTitle !== "today" && currentTitle !== "new chat") return currentTitle;
|
|
||||||
const cleaned = prompt.trim().replace(/\s+/g, " ").toLocaleLowerCase();
|
|
||||||
return cleaned.length > 48 ? `${cleaned.slice(0, 45)}...` : cleaned || "today";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCoachSystemPrompt(
|
|
||||||
user: AuthUser,
|
|
||||||
dashboard: Dashboard,
|
|
||||||
entries: RedBullEntry[],
|
|
||||||
userLimits: UserLimits,
|
|
||||||
limitCheck?: LimitCheckResult,
|
|
||||||
) {
|
|
||||||
const recent = entries
|
|
||||||
.slice(0, 12)
|
|
||||||
.map(
|
|
||||||
(entry) =>
|
|
||||||
`- ${humanDateTime(entry.dateTime)}: ${entry.cans} can(s), ${entry.flavour}, ${entry.sizeMl}ml, ${currency.format(spendFor(entry))}, ${wholeNumber.format(caffeineFor(entry))}mg caffeine, ${oneDecimal.format(sugarFor(entry))}g sugar`,
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
return [
|
|
||||||
"You are an upbeat Red Bull intake coach inside a tracking app.",
|
|
||||||
"Respond entirely in lower case.",
|
|
||||||
"Give concise, practical suggestions based only on the logged data provided.",
|
|
||||||
"When asked about favourite flavour historically, use the flavour history breakdown below.",
|
|
||||||
"Do not give medical advice.",
|
|
||||||
`User: ${user.name || user.email || "Appwrite user"}`,
|
|
||||||
`Current time (BST): ${getBstHour()}:00.`,
|
|
||||||
`Today: ${dashboard.todayCans} cans, ${dashboard.todayCaffeine} caffeine, ${dashboard.todaySugar} sugar.`,
|
|
||||||
`Personal limits: ${limitsSummaryForCoach(userLimits, limitCheck ?? { violations: [], projectedCans: 0, projectedSpend: 0, todayCans: 0, todaySpend: 0, pastStopTime: false })}`,
|
|
||||||
`All-time favourite: ${dashboard.favouriteFlavour}. Streak: ${dashboard.currentStreak} day(s). Spend: ${dashboard.totalSpend}.`,
|
|
||||||
`Flavour history:\n${buildFlavourHistorySummary(entries)}`,
|
|
||||||
`Recent entries:\n${recent || "No entries logged yet."}`,
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCoachError(detail: string, status: number) {
|
|
||||||
const trimmed = detail.trim();
|
|
||||||
if (trimmed.startsWith("<") || /nginx|405 not allowed/i.test(trimmed)) {
|
|
||||||
return `coach api unavailable (${status}). run npm run dev with OLLAMA_API_KEY set, or proxy POST /api/ollama-chat on your host.`;
|
|
||||||
}
|
|
||||||
return trimmed || `request failed (${status}).`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readOllamaStream(body: ReadableStream<Uint8Array>, onChunk: (chunk: OllamaStreamChunk) => void) {
|
|
||||||
const reader = body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split("\n");
|
|
||||||
buffer = lines.pop() ?? "";
|
|
||||||
for (const line of lines) {
|
|
||||||
const chunk = parseOllamaLine(line);
|
|
||||||
if (chunk) onChunk(chunk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode();
|
|
||||||
if (buffer.trim()) {
|
|
||||||
const chunk = parseOllamaLine(buffer);
|
|
||||||
if (chunk) onChunk(chunk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOllamaLine(line: string): OllamaStreamChunk | null {
|
|
||||||
const trimmed = line.trim().replace(/^data:\s*/, "");
|
|
||||||
if (!trimmed || trimmed === "[DONE]") return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(trimmed) as OllamaStreamChunk;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { OLLAMA_MODEL };
|
|
||||||
@@ -178,25 +178,6 @@ export function limitStatusMessage(
|
|||||||
return lines.join(" ");
|
return lines.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function limitsSummaryForCoach(limits: UserLimits, check: LimitCheckResult): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (limits.dailyCanLimit != null) {
|
|
||||||
parts.push(`daily can limit: ${limits.dailyCanLimit} (${check.todayCans} logged today)`);
|
|
||||||
}
|
|
||||||
if (limits.dailySpendLimit != null) {
|
|
||||||
parts.push(`daily spend limit: ${currency.format(limits.dailySpendLimit)} (${currency.format(check.todaySpend)} today)`);
|
|
||||||
}
|
|
||||||
if (limits.stopTime) {
|
|
||||||
parts.push(
|
|
||||||
`stop drinking by: ${formatStopTimeLabel(limits.stopTime)} bst (${check.pastStopTime ? "past stop time now" : "before stop time"})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parts.length) return "no personal daily limits configured yet.";
|
|
||||||
return parts.join(". ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasAnyLimit(limits: UserLimits) {
|
export function hasAnyLimit(limits: UserLimits) {
|
||||||
return Boolean(limits.dailyCanLimit != null || limits.dailySpendLimit != null || limits.stopTime);
|
return Boolean(limits.dailyCanLimit != null || limits.dailySpendLimit != null || limits.stopTime);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user