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