intial commit
This commit is contained in:
+5
-13
@@ -1,17 +1,9 @@
|
||||
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_COLLECTION_ID=intake_entries
|
||||
VITE_APPWRITE_CHAT_COLLECTION_ID=coach_chats
|
||||
|
||||
|
||||
# Server-only. Do not prefix with VITE_ or it will be exposed to the browser.
|
||||
OLLAMA_API_KEY=
|
||||
OLLAMA_MODEL=deepseek-v4-pro:cloud
|
||||
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.
|
||||
# Optional. Leave blank in local dev so the app uses the current Vite origin,
|
||||
# including fallback ports like http://127.0.0.1:5174.
|
||||
VITE_APPWRITE_OAUTH_SUCCESS_URL=
|
||||
VITE_APPWRITE_OAUTH_FAILURE_URL=
|
||||
|
||||
+1
-46
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
- Endpoint: `https://fra.cloud.appwrite.io/v1`
|
||||
@@ -36,8 +28,6 @@ Configured defaults:
|
||||
- Project name: `Red Bull Tracker App`
|
||||
- Database ID: `redbull_tracker`
|
||||
- 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`.
|
||||
|
||||
@@ -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 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:
|
||||
|
||||
```text
|
||||
@@ -166,44 +154,11 @@ Recommended indexes:
|
||||
- `user_import_key`: key index on `userId`, `importKey`
|
||||
- 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
|
||||
|
||||
- `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/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/metrics.ts`: Prices, caffeine/sugar estimates, stats, grouping, streaks.
|
||||
- `src/lib/storage.ts`: JSON backup export/import parser.
|
||||
|
||||
Generated
-46
@@ -9,7 +9,6 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@zxing/browser": "^0.2.0",
|
||||
"appwrite": "^25.0.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"framer-motion": "^11.18.2",
|
||||
@@ -1899,41 +1898,6 @@
|
||||
"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": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -5115,16 +5079,6 @@
|
||||
"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": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
|
||||
+1
-3
@@ -7,12 +7,10 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"setup:appwrite": "node scripts/setup-appwrite.mjs"
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@zxing/browser": "^0.2.0",
|
||||
"appwrite": "^25.0.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"framer-motion": "^11.18.2",
|
||||
|
||||
+378
-1086
File diff suppressed because it is too large
Load Diff
+12
-17
@@ -1,25 +1,20 @@
|
||||
import type { Flavour } from "../types";
|
||||
|
||||
export const BUILT_IN_FLAVOURS: Flavour[] = [
|
||||
{ name: "Original", accent: "#282874" },
|
||||
{ name: "Zero", accent: "#B1D0EE", sugarFree: true },
|
||||
{ name: "Sugar Free", accent: "#009EDF", sugarFree: true },
|
||||
{ name: "Ruby", accent: "#B50045" },
|
||||
{ name: "Iced Vanilla", accent: "#53B2C2" },
|
||||
{ name: "Tropical", accent: "#FFCB04" },
|
||||
{ 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: "Original", accent: "#00A7FF" },
|
||||
{ name: "Sugar Free", accent: "#E7EEF8", sugarFree: true },
|
||||
{ name: "Ruby", accent: "#C3093B" },
|
||||
{ name: "Iced Vanilla", accent: "#49adbe" },
|
||||
{ name: "Tropical", accent: "#FFC247" },
|
||||
{ name: "Watermelon", accent: "#FF355E" },
|
||||
{ name: "Blueberry", accent: "#496DFF" },
|
||||
{ name: "Coconut Berry", accent: "#0070B8" },
|
||||
{ name: "Peach", accent: "#E24585" },
|
||||
{ name: "Juneberry", accent: "#0085C8" },
|
||||
{ name: "Coconut Berry", accent: "#D8F9FF" },
|
||||
{ name: "Peach", accent: "#FF9B63" },
|
||||
{ name: "Juneberry", accent: "#9C73FF" },
|
||||
{ name: "Dragon Fruit", accent: "#FF3DBD" },
|
||||
{ name: "Curuba Elderflower", accent: "#78B941" },
|
||||
{ name: "Winter Edition", accent: "#BF1431" },
|
||||
{ name: "Summer Edition", accent: "#F2E853" },
|
||||
{ name: "Curuba Elderflower", accent: "#B7FF4A" },
|
||||
{ name: "Winter Edition", accent: "#7CE7FF" },
|
||||
{ name: "Summer Edition", accent: "#f0e53b" },
|
||||
{ name: "Other", accent: "#AEB9C7" },
|
||||
];
|
||||
|
||||
|
||||
+128
-1002
File diff suppressed because it is too large
Load Diff
+17
-6
@@ -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 currentOrigin = window.location.origin;
|
||||
|
||||
export const appwriteConfig = {
|
||||
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",
|
||||
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
|
||||
chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats",
|
||||
barcodeCollectionId: env.VITE_APPWRITE_BARCODE_COLLECTION_ID || "barcode_products",
|
||||
|
||||
oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL),
|
||||
oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL),
|
||||
};
|
||||
|
||||
const client = new Client()
|
||||
@@ -24,6 +23,18 @@ export async function pingAppwrite() {
|
||||
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(/\/$/, "");
|
||||
}
|
||||
|
||||
@@ -34,57 +34,6 @@ export type EntryDraft = Omit<
|
||||
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 = {
|
||||
flavour: string;
|
||||
dateRange: DateFilter;
|
||||
@@ -105,40 +54,3 @@ export type ImportPreview = {
|
||||
fileName: string;
|
||||
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;
|
||||
};
|
||||
|
||||
Vendored
-3
@@ -5,11 +5,8 @@ interface ImportMetaEnv {
|
||||
readonly VITE_APPWRITE_PROJECT_ID?: string;
|
||||
readonly VITE_APPWRITE_DATABASE_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_FAILURE_URL?: string;
|
||||
readonly VITE_OLLAMA_PROXY_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
+12
-13
@@ -6,21 +6,20 @@ export default {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
display: [
|
||||
"Google Sans",
|
||||
"Google Sans Text",
|
||||
"Product Sans",
|
||||
"Roboto",
|
||||
"SF Pro Display",
|
||||
"SF Pro Text",
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Avenir Next",
|
||||
"Helvetica Neue",
|
||||
"sans-serif",
|
||||
],
|
||||
body: [
|
||||
"Google Sans",
|
||||
"Google Sans Text",
|
||||
"Product Sans",
|
||||
"Roboto",
|
||||
"SF Pro Text",
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Avenir Next",
|
||||
"Helvetica Neue",
|
||||
"sans-serif",
|
||||
],
|
||||
},
|
||||
@@ -39,11 +38,11 @@ export default {
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
apple: "0 1px 2px rgba(69, 54, 62, 0.14), 0 2px 6px rgba(69, 54, 62, 0.08)",
|
||||
fridge: "0 2px 6px rgba(69, 54, 62, 0.12), 0 8px 18px rgba(69, 54, 62, 0.08)",
|
||||
can: "0 1px 2px rgba(156, 65, 104, 0.18), 0 3px 8px rgba(156, 65, 104, 0.10)",
|
||||
redline: "0 2px 8px rgba(186, 26, 26, 0.20)",
|
||||
cyan: "0 1px 2px rgba(156, 65, 104, 0.16), 0 4px 12px rgba(156, 65, 104, 0.10)",
|
||||
apple: "0 18px 55px rgba(0, 0, 0, 0.22), 0 1px 2px rgba(0, 0, 0, 0.18)",
|
||||
fridge: "0 18px 70px rgba(0, 0, 0, 0.34), 0 1px 2px rgba(255, 255, 255, 0.06)",
|
||||
can: "0 10px 24px rgba(57, 213, 255, 0.12)",
|
||||
redline: "0 12px 28px rgba(255, 52, 72, 0.26)",
|
||||
cyan: "0 14px 32px rgba(57, 213, 255, 0.18)",
|
||||
},
|
||||
backgroundImage: {
|
||||
"carbon-grid":
|
||||
|
||||
+12
-134
@@ -1,140 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Plugin } from "vite";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
|
||||
const DEFAULT_MODEL = "deepseek-v4-pro:cloud";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const ollamaProxy = {
|
||||
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: {
|
||||
chunkSizeWarningLimit: 700,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
charts: ["recharts"],
|
||||
motion: ["framer-motion"],
|
||||
icons: ["lucide-react"],
|
||||
},
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
chunkSizeWarningLimit: 700,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
charts: ["recharts"],
|
||||
motion: ["framer-motion"],
|
||||
icons: ["lucide-react"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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