intial commit

This commit is contained in:
Ned Halksworth
2026-05-15 21:36:13 +01:00
committed by Ned
parent 012b900716
commit bd3e970286
12 changed files with 566 additions and 2457 deletions
+5 -13
View File
@@ -1,17 +1,9 @@
VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1 VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=your-project-id VITE_APPWRITE_PROJECT_ID=6a0752ee001fb2ef7138
VITE_APPWRITE_DATABASE_ID=redbull_tracker VITE_APPWRITE_DATABASE_ID=redbull_tracker
VITE_APPWRITE_COLLECTION_ID=intake_entries VITE_APPWRITE_COLLECTION_ID=intake_entries
VITE_APPWRITE_CHAT_COLLECTION_ID=coach_chats
# Optional. Leave blank in local dev so the app uses the current Vite origin,
# Server-only. Do not prefix with VITE_ or it will be exposed to the browser. # including fallback ports like http://127.0.0.1:5174.
OLLAMA_API_KEY= VITE_APPWRITE_OAUTH_SUCCESS_URL=
OLLAMA_MODEL=deepseek-v4-pro:cloud VITE_APPWRITE_OAUTH_FAILURE_URL=
VITE_OLLAMA_PROXY_URL=/api/ollama-chat
# Server/admin only. Never prefix with VITE_. Needed only for npm run setup:appwrite.
APPWRITE_API_KEY=
# Appwrite chat table columns: userId, title, messages, updatedAt.
# Enable row security and Users -> Create at table level.
+1 -46
View File
@@ -21,14 +21,6 @@ cp .env.example .env.local
This app uses only the Appwrite browser SDK. Do not add an API key to the frontend. This app uses only the Appwrite browser SDK. Do not add an API key to the frontend.
To create/update the database tables from this repo, set a server/admin key as `APPWRITE_API_KEY` in `.env.local` and run:
```bash
npm run setup:appwrite
```
The setup script reads `APPWRITE_API_KEY` only from Node, never from browser code.
Configured defaults: Configured defaults:
- Endpoint: `https://fra.cloud.appwrite.io/v1` - Endpoint: `https://fra.cloud.appwrite.io/v1`
@@ -36,8 +28,6 @@ Configured defaults:
- Project name: `Red Bull Tracker App` - Project name: `Red Bull Tracker App`
- Database ID: `redbull_tracker` - Database ID: `redbull_tracker`
- Collection ID: `intake_entries` - Collection ID: `intake_entries`
- Chat collection ID: `coach_chats`
- Barcode collection ID: `barcode_products`
`client.ping()` is called automatically during app boot in `src/App.tsx` through `pingAppwrite()` from `src/lib/appwrite.ts`. `client.ping()` is called automatically during app boot in `src/App.tsx` through `pingAppwrite()` from `src/lib/appwrite.ts`.
@@ -87,8 +77,6 @@ So if the Console asks you to create a **table**, that is the same resource as t
The app uses Appwrite's current `TablesDB` SDK methods (`listRows`, `createRow`, `updateRow`, `deleteRow`). The env var remains named `VITE_APPWRITE_COLLECTION_ID` for compatibility with the first setup pass, but its value should be your table ID. The app uses Appwrite's current `TablesDB` SDK methods (`listRows`, `createRow`, `updateRow`, `deleteRow`). The env var remains named `VITE_APPWRITE_COLLECTION_ID` for compatibility with the first setup pass, but its value should be your table ID.
The barcode scanner uses a separate `barcode_products` table by default. Verified Red Bull barcode rows are seeded by `scripts/setup-appwrite.mjs` using `APPWRITE_API_KEY`; browser code can only read verified rows and create/update the current user's own mappings with row-level permissions.
Create a database with ID: Create a database with ID:
```text ```text
@@ -166,44 +154,11 @@ Recommended indexes:
- `user_import_key`: key index on `userId`, `importKey` - `user_import_key`: key index on `userId`, `importKey`
- Optional unique index on `userId`, `importKey` if your Appwrite plan/schema supports it - Optional unique index on `userId`, `importKey` if your Appwrite plan/schema supports it
## Encrypted Coach Chats
Create a second table with ID:
```text
coach_chats
```
Enable row security on `coach_chats`.
Recommended table-level permissions:
- Create: `users`
- Read: none
- Update: none
- Delete: none
The app stores coach chat titles and messages as plain JSON in Appwrite with row-level user permissions.
Create these chat columns:
| Key | Type | Required | Notes |
| --- | --- | --- | --- |
| `userId` | String, 64 | Yes | Current Appwrite user ID |
| `title` | String, 512 | Yes | Chat title |
| `messages` | Longtext | Yes | JSON array of coach messages |
| `updatedAt` | DateTime | Yes | Sort key |
Recommended chat index:
- `user_chat_updated`: key index on `userId`, `updatedAt`
## Component Structure ## Component Structure
- `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/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/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.
-46
View File
@@ -9,7 +9,6 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@zxing/browser": "^0.2.0",
"appwrite": "^25.0.0", "appwrite": "^25.0.0",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"framer-motion": "^11.18.2", "framer-motion": "^11.18.2",
@@ -1899,41 +1898,6 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@zxing/browser": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.0.tgz",
"integrity": "sha512-+ORhrLva0vm6ck74NDCmvYNW3XLoAG81Mu90qfcssN1PBKJjQadxZGeMCcIk+BdJbD/zEAjjHDXOwEK1QCmRtw==",
"license": "MIT",
"optionalDependencies": {
"@zxing/text-encoding": "^0.9.0"
},
"peerDependencies": {
"@zxing/library": "^0.22.0"
}
},
"node_modules/@zxing/library": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.22.0.tgz",
"integrity": "sha512-BmInervZV7NwaZWX1LW64sZ4Lh4wxXYFZwGmj98ArPOkRXCtO9b8Gog0Xyh82dsYYGOeRxX+aAhLSq+hQ2XLZQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"ts-custom-error": "^3.3.1"
},
"engines": {
"node": ">= 24.0.0"
},
"optionalDependencies": {
"@zxing/text-encoding": "~0.9.0"
}
},
"node_modules/@zxing/text-encoding": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
"license": "(Unlicense OR Apache-2.0)",
"optional": true
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -5115,16 +5079,6 @@
"typescript": ">=4.8.4" "typescript": ">=4.8.4"
} }
}, },
"node_modules/ts-custom-error": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/ts-interface-checker": { "node_modules/ts-interface-checker": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+1 -3
View File
@@ -7,12 +7,10 @@
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
"setup:appwrite": "node scripts/setup-appwrite.mjs"
}, },
"dependencies": { "dependencies": {
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@zxing/browser": "^0.2.0",
"appwrite": "^25.0.0", "appwrite": "^25.0.0",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"framer-motion": "^11.18.2", "framer-motion": "^11.18.2",
+295 -1003
View File
File diff suppressed because it is too large Load Diff
+12 -17
View File
@@ -1,25 +1,20 @@
import type { Flavour } from "../types"; import type { Flavour } from "../types";
export const BUILT_IN_FLAVOURS: Flavour[] = [ export const BUILT_IN_FLAVOURS: Flavour[] = [
{ name: "Original", accent: "#282874" }, { name: "Original", accent: "#00A7FF" },
{ name: "Zero", accent: "#B1D0EE", sugarFree: true }, { name: "Sugar Free", accent: "#E7EEF8", sugarFree: true },
{ name: "Sugar Free", accent: "#009EDF", sugarFree: true }, { name: "Ruby", accent: "#C3093B" },
{ name: "Ruby", accent: "#B50045" }, { name: "Iced Vanilla", accent: "#49adbe" },
{ name: "Iced Vanilla", accent: "#53B2C2" }, { name: "Tropical", accent: "#FFC247" },
{ name: "Tropical", accent: "#FFCB04" }, { name: "Watermelon", accent: "#FF355E" },
{ name: "Cherry Edition", accent: "#D81B60" },
{ name: "Apricot Edition", accent: "#F3911B" },
{ name: "Lilac Sugarfree", accent: "#7D62CE", sugarFree: true },
{ name: "Pink Sugarfree", accent: "#E77BAB", sugarFree: true },
{ name: "Watermelon", accent: "#E6301F" },
{ name: "Blueberry", accent: "#496DFF" }, { name: "Blueberry", accent: "#496DFF" },
{ name: "Coconut Berry", accent: "#0070B8" }, { name: "Coconut Berry", accent: "#D8F9FF" },
{ name: "Peach", accent: "#E24585" }, { name: "Peach", accent: "#FF9B63" },
{ name: "Juneberry", accent: "#0085C8" }, { name: "Juneberry", accent: "#9C73FF" },
{ name: "Dragon Fruit", accent: "#FF3DBD" }, { name: "Dragon Fruit", accent: "#FF3DBD" },
{ name: "Curuba Elderflower", accent: "#78B941" }, { name: "Curuba Elderflower", accent: "#B7FF4A" },
{ name: "Winter Edition", accent: "#BF1431" }, { name: "Winter Edition", accent: "#7CE7FF" },
{ name: "Summer Edition", accent: "#F2E853" }, { name: "Summer Edition", accent: "#f0e53b" },
{ name: "Other", accent: "#AEB9C7" }, { name: "Other", accent: "#AEB9C7" },
]; ];
+128 -1002
View File
File diff suppressed because it is too large Load Diff
+17 -6
View File
@@ -1,16 +1,15 @@
import { Account, Channel, Client, ID, Permission, Query, Role, TablesDB } from "appwrite"; import { Account, Channel, Client, ID, OAuthProvider, Permission, Query, Role, TablesDB } from "appwrite";
const env = import.meta.env; const env = import.meta.env;
const currentOrigin = window.location.origin; const currentOrigin = window.location.origin;
export const appwriteConfig = { export const appwriteConfig = {
endpoint: env.VITE_APPWRITE_ENDPOINT || "https://fra.cloud.appwrite.io/v1", endpoint: env.VITE_APPWRITE_ENDPOINT || "https://fra.cloud.appwrite.io/v1",
projectId: env.VITE_APPWRITE_PROJECT_ID!, projectId: env.VITE_APPWRITE_PROJECT_ID || "6a0752ee001fb2ef7138",
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker", databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries", collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats", oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL),
barcodeCollectionId: env.VITE_APPWRITE_BARCODE_COLLECTION_ID || "barcode_products", oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL),
}; };
const client = new Client() const client = new Client()
@@ -24,6 +23,18 @@ export async function pingAppwrite() {
return client.ping(); return client.ping();
} }
export { account, Channel, client, ID, Permission, Query, Role, tablesDB }; export { account, Channel, client, ID, OAuthProvider, Permission, Query, Role, tablesDB };
function resolveOAuthUrl(value?: string) {
if (!value) return currentOrigin;
const configured = new URL(value, currentOrigin);
const current = new URL(currentOrigin);
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
if (env.DEV && localHosts.has(configured.hostname) && localHosts.has(current.hostname)) {
return currentOrigin;
}
return configured.toString().replace(/\/$/, "");
}
-88
View File
@@ -34,57 +34,6 @@ export type EntryDraft = Omit<
source?: RedBullEntry["source"]; source?: RedBullEntry["source"];
}; };
export type BarcodeFormatName = "ean-13" | "ean-8" | "upc-a" | "upc-e" | "unknown";
export type BarcodeProductDraft = {
flavourName: string;
sizeMl: number;
pricePerCan: number;
sugarFree?: boolean;
caffeineMgPerCan?: number;
};
export type ResolvedBarcodeProduct = BarcodeProductDraft & {
flavourAccent: string;
source: "built-in" | "user";
};
export type BarcodeSeedProduct = BarcodeProductDraft & {
verifiedBy: string;
sourceName?: string;
sourceUrl?: string;
notes?: string;
variant?: string;
};
export type UserBarcodeMapping = BarcodeProductDraft & {
barcode: string;
createdAt: string;
updatedAt: string;
};
export type BarcodeLookupCatalog = {
verifiedProducts?: Record<string, BarcodeSeedProduct>;
userMappings?: UserBarcodeMapping[];
};
export type BarcodeLookupResult =
| {
status: "known" | "user";
barcode: string;
product: ResolvedBarcodeProduct;
}
| {
status: "partial";
barcode: string;
product: BarcodeProductDraft;
reason: string;
}
| {
status: "unknown";
barcode: string;
};
export type Filters = { export type Filters = {
flavour: string; flavour: string;
dateRange: DateFilter; dateRange: DateFilter;
@@ -105,40 +54,3 @@ export type ImportPreview = {
fileName: string; fileName: string;
rows: ImportPreviewRow[]; rows: ImportPreviewRow[];
}; };
export type ChatRole = "user" | "assistant";
export type CoachMessage = {
id: string;
role: ChatRole;
content: string;
thinking?: string;
pending?: boolean;
stopped?: boolean;
};
export type CoachChat = {
id: string;
userId: string;
title: string;
messages: CoachMessage[];
createdAt: string;
updatedAt: string;
};
export type UserLimits = {
dailyCanLimit?: number;
dailySpendLimit?: number;
stopTime?: string;
};
export type LimitViolation = "cans" | "spend" | "stopTime";
export type LimitCheckResult = {
violations: LimitViolation[];
projectedCans: number;
projectedSpend: number;
todayCans: number;
todaySpend: number;
pastStopTime: boolean;
};
-3
View File
@@ -5,11 +5,8 @@ interface ImportMetaEnv {
readonly VITE_APPWRITE_PROJECT_ID?: string; readonly VITE_APPWRITE_PROJECT_ID?: string;
readonly VITE_APPWRITE_DATABASE_ID?: string; readonly VITE_APPWRITE_DATABASE_ID?: string;
readonly VITE_APPWRITE_COLLECTION_ID?: string; readonly VITE_APPWRITE_COLLECTION_ID?: string;
readonly VITE_APPWRITE_CHAT_COLLECTION_ID?: string;
readonly VITE_APPWRITE_BARCODE_COLLECTION_ID?: string;
readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string; readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string;
readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string; readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string;
readonly VITE_OLLAMA_PROXY_URL?: string;
} }
interface ImportMeta { interface ImportMeta {
+12 -13
View File
@@ -6,21 +6,20 @@ export default {
extend: { extend: {
fontFamily: { fontFamily: {
display: [ display: [
"Google Sans", "SF Pro Display",
"Google Sans Text", "SF Pro Text",
"Product Sans",
"Roboto",
"-apple-system", "-apple-system",
"BlinkMacSystemFont", "BlinkMacSystemFont",
"Avenir Next",
"Helvetica Neue",
"sans-serif", "sans-serif",
], ],
body: [ body: [
"Google Sans", "SF Pro Text",
"Google Sans Text",
"Product Sans",
"Roboto",
"-apple-system", "-apple-system",
"BlinkMacSystemFont", "BlinkMacSystemFont",
"Avenir Next",
"Helvetica Neue",
"sans-serif", "sans-serif",
], ],
}, },
@@ -39,11 +38,11 @@ export default {
}, },
}, },
boxShadow: { boxShadow: {
apple: "0 1px 2px rgba(69, 54, 62, 0.14), 0 2px 6px rgba(69, 54, 62, 0.08)", apple: "0 18px 55px rgba(0, 0, 0, 0.22), 0 1px 2px rgba(0, 0, 0, 0.18)",
fridge: "0 2px 6px rgba(69, 54, 62, 0.12), 0 8px 18px rgba(69, 54, 62, 0.08)", fridge: "0 18px 70px rgba(0, 0, 0, 0.34), 0 1px 2px rgba(255, 255, 255, 0.06)",
can: "0 1px 2px rgba(156, 65, 104, 0.18), 0 3px 8px rgba(156, 65, 104, 0.10)", can: "0 10px 24px rgba(57, 213, 255, 0.12)",
redline: "0 2px 8px rgba(186, 26, 26, 0.20)", redline: "0 12px 28px rgba(255, 52, 72, 0.26)",
cyan: "0 1px 2px rgba(156, 65, 104, 0.16), 0 4px 12px rgba(156, 65, 104, 0.10)", cyan: "0 14px 32px rgba(57, 213, 255, 0.18)",
}, },
backgroundImage: { backgroundImage: {
"carbon-grid": "carbon-grid":
+3 -125
View File
@@ -1,37 +1,8 @@
import { defineConfig } from "vite";
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";
const DEFAULT_MODEL = "deepseek-v4-pro:cloud"; export default defineConfig({
plugins: [react()],
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const ollamaProxy = {
target: "https://ollama.com",
changeOrigin: true,
rewrite: () => "/api/chat",
configure(proxy: { on: (event: "proxyReq", handler: (proxyReq: { setHeader: (name: string, value: string) => void }) => void) => void }) {
proxy.on("proxyReq", (proxyReq) => {
if (env.OLLAMA_API_KEY) {
proxyReq.setHeader("Authorization", `Bearer ${env.OLLAMA_API_KEY}`);
}
});
},
};
return {
plugins: [react(), ollamaProxyPlugin(env)],
server: {
proxy: {
"/api/ollama-chat": ollamaProxy,
},
},
preview: {
proxy: {
"/api/ollama-chat": ollamaProxy,
},
},
build: { build: {
chunkSizeWarningLimit: 700, chunkSizeWarningLimit: 700,
rollupOptions: { rollupOptions: {
@@ -44,97 +15,4 @@ 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>) : {};
}