Skip to content
Merged

Dev #136

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 19 additions & 16 deletions .github/workflows/admin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,15 @@ jobs:
- name: Type check
run: pnpm --filter kal-admin typecheck

build:
name: Build
deploy:
name: Build & Deploy to Vercel
runs-on: ubuntu-latest
needs: lint-and-typecheck
if: github.event_name == 'push'

env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID_ADMIN }}

steps:
- name: Checkout repository
Expand All @@ -84,19 +89,17 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Vercel CLI
run: pnpm add -g vercel@latest

- name: Build shared dependencies
run: |
pnpm --filter kal-shared build
pnpm --filter kal-baml build
pnpm --filter kal-backend build
- name: Pull Vercel Environment Information
working-directory: packages/kal-admin
run: vercel pull --yes --environment=${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} --token=${{ secrets.VERCEL_TOKEN }}

- name: Build with Vercel
working-directory: packages/kal-admin
run: vercel build ${{ github.ref == 'refs/heads/main' && '--prod' || '' }} --token=${{ secrets.VERCEL_TOKEN }}

- name: Build admin
run: pnpm --filter kal-admin build
env:
NEXT_PUBLIC_API_URL: https://kalori-api.my/api/v1
ADMIN_SECRET: placeholder
ADMIN_USERNAME: placeholder
ADMIN_PASSWORD: placeholder
- name: Deploy to Vercel
working-directory: packages/kal-admin
run: vercel deploy --prebuilt ${{ github.ref == 'refs/heads/main' && '--prod' || '' }} --token=${{ secrets.VERCEL_TOKEN }}
28 changes: 18 additions & 10 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,15 @@ jobs:
- name: Type check
run: pnpm --filter kal-frontend typecheck

build:
name: Build
deploy:
name: Build & Deploy to Vercel
runs-on: ubuntu-latest
needs: lint-and-typecheck
if: github.event_name == 'push'

env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID_FRONTEND }}

steps:
- name: Checkout repository
Expand All @@ -93,11 +98,14 @@ jobs:
pnpm --filter kal-baml build
pnpm --filter kal-backend build

- name: Build frontend
run: pnpm --filter kal-frontend build
env:
NEXT_PUBLIC_BACKEND_URL: https://kalori-api.my
NEXT_PUBLIC_APP_URL: https://kalori-api.my
NEXT_PUBLIC_API_URL: https://kalori-api.my/api/v1/v1
NEXT_PUBLIC_LOGTO_ENDPOINT: https://auth.kalori-api.my
NEXT_PUBLIC_LOGTO_APP_ID: placeholder
- name: Install Vercel CLI
run: pnpm add -g vercel@latest

- name: Pull Vercel Environment Information
run: vercel pull --yes --environment=${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} --token=${{ secrets.VERCEL_TOKEN }}

- name: Build with Vercel
run: vercel build ${{ github.ref == 'refs/heads/main' && '--prod' || '' }} --token=${{ secrets.VERCEL_TOKEN }}

- name: Deploy to Vercel
run: vercel deploy --prebuilt ${{ github.ref == 'refs/heads/main' && '--prod' || '' }} --token=${{ secrets.VERCEL_TOKEN }}
5 changes: 5 additions & 0 deletions packages/kal-frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ NEXT_PUBLIC_DEMO_API_KEY=kal_660e99df8cb78a60d8cc90dcb1228b1a812283d1393f0e49918
# Stripe
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

# GLM fallback (server-side only β€” never use NEXT_PUBLIC_ prefix)
GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
GLM_API_KEY=your-glm-api-key
GLM_MODEL=glm-4-flash

# Web Push (VAPID public key only β€” private key stays on backend)
# Must match VAPID_PUBLIC_KEY in kal-backend .env
NEXT_PUBLIC_VAPID_PUBLIC_KEY=your-vapid-public-key
216 changes: 216 additions & 0 deletions packages/kal-frontend/src/app/api/chat/glm-fallback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import type { NextRequest } from "next/server";

function getGlmEndpoint(): string {
const base =
process.env.GLM_BASE_URL ?? "https://open.bigmodel.cn/api/paas/v4";
return `${base}/chat/completions`;
}

const SYSTEM_PROMPT = `You are Kal Assistant, the official AI for Kalori (kalori-api.my), a Malaysian food nutrition platform.

Your ONLY topics are:
- Malaysian food nutrition: calories, macros, vitamins, serving sizes
- Malaysian recipes and cooking tips
- Healthy eating advice related to Malaysian cuisine
- The Kalori product and its API (kalori-api.my) β€” you have COMPLETE knowledge of it below

STRICT rules:
- Never recommend, mention, or compare other APIs, products, or services (e.g. Edamam, FatSecret, Spoonacular, USDA, OpenAI, etc.)
- If asked about anything outside Malaysian food/nutrition or the Kalori product, politely decline and redirect the user to ask about Malaysian food or Kalori
- Always give specific, accurate endpoint URLs and parameters from the reference below
- Include working curl examples when showing endpoints
- Use markdown formatting: code blocks for URLs/curl/JSON, tables for parameters
- Always mention they need an API key and where to get one
- Keep responses concise and developer-friendly

═══════════════════════════════════════════
KALORI API REFERENCE
═══════════════════════════════════════════

Base URL: https://api.kalori-api.my/api/v1
Authentication: x-api-key header (required for all requests)
Get an API key: Sign in at https://kalori-api.my β†’ Dashboard β†’ Generate API Key

Documentation:
- Interactive Swagger UI: https://api.kalori-api.my/api-docs
- Custom docs page: https://api.kalori-api.my/docs
- OpenAPI 3.0 spec: https://api.kalori-api.my/openapi.json

── NATURAL FOODS ──

GET /api/v1/foods/search?q={query}
Search natural foods by name. Returns up to 20 results.
Response: { success: true, data: [{ id, name, calories, protein, carbs, fat, serving, category }], count }

GET /api/v1/foods?category={cat}&limit={n}&offset={n}
List all natural foods. Optional filters: category, limit (default 50, max 200), offset.
Response: { success: true, data: [...], pagination: { total, limit, offset, hasMore } }

GET /api/v1/foods/:id
Get a single food by MongoDB ObjectId.
Response: { success: true, data: { id, name, calories, protein, carbs, fat, serving, category } }

GET /api/v1/categories
List all food categories.
Response: { success: true, data: ["Basics", "Desserts", "Drinks", "Meat", "Noodles", "Rice", ...] }

── HALAL FOODS ──

GET /api/v1/halal/search?q={query}
Search halal-certified foods by name. Returns up to 20 results.
Response: { success: true, data: [{ id, name, calories, protein, carbs, fat, serving, category, brand, halalCertifier, halalCertYear }], count }

GET /api/v1/halal?brand={brand}&category={cat}&limit={n}&offset={n}
List halal foods with optional filters.
Response: { success: true, data: [...], pagination: { total, limit, offset, hasMore } }

GET /api/v1/halal/:id
Get a single halal food by ID.

GET /api/v1/halal/brands?q={filter}&withCount=true
List all halal brands. Optional: q (filter brand names), withCount (include product counts).

── STATS ──

GET /api/v1/stats
Database statistics.
Response: { success: true, data: { naturalFoods: { total, categories }, halalFoods: { total, brands } } }

── DATA TYPES ──

Food: { id: string, name: string, calories: number, protein: number, carbs: number, fat: number, serving: string, category: string }
HalalFood extends Food: { brand: string, halalCertifier: string, halalCertYear: number }

── ERROR RESPONSES ──

All errors: { success: false, error: "message" }
Status codes: 400 (bad request), 401 (invalid API key), 404 (not found), 429 (rate limit), 500 (server error)

── RATE LIMITS ──

Free tier: 65/min, 3,300/day, 95,000/month
Tier 1: 130/min, 6,600/day, 195,000/month
Tier 2: 145/min, 7,500/day, 215,000/month
Rate limit headers included in all responses: X-RateLimit-Limit-Minute, X-RateLimit-Remaining-Minute, etc.

═══════════════════════════════════════════`;

function sse(data: Record<string, unknown>): string {
return `data: ${JSON.stringify(data)}\n\n`;
}

export async function POST(request: NextRequest) {
const apiKey = process.env.GLM_API_KEY;

const sseHeaders = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
};

if (!apiKey) {
return new Response(
sse({ type: "error", message: "GLM fallback not configured" }) +
sse({ type: "done" }),
{ headers: sseHeaders }
);
}

const { content } = (await request.json()) as { content: string };
const encoder = new TextEncoder();

const stream = new ReadableStream({
async start(controller) {
try {
controller.enqueue(encoder.encode(sse({ type: "stream_start" })));

const glmRes = await fetch(getGlmEndpoint(), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: process.env.GLM_MODEL ?? "glm-4-flash",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content },
],
stream: true,
}),
});

if (!glmRes.ok || !glmRes.body) {
const errText = await glmRes.text().catch(() => "");
controller.enqueue(
encoder.encode(
sse({ type: "error", message: `GLM error (${glmRes.status}): ${errText}` })
)
);
controller.enqueue(encoder.encode(sse({ type: "done" })));
return;
}

const reader = glmRes.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) {
if (!line.startsWith("data: ")) continue;
const raw = line.slice(6).trim();
if (raw === "[DONE]") continue;

try {
const chunk = JSON.parse(raw);
const delta: string | undefined =
chunk.choices?.[0]?.delta?.content;
if (delta) {
controller.enqueue(
encoder.encode(sse({ type: "stream_delta", delta }))
);
}
} catch {
// skip malformed chunk
}
}
}

controller.enqueue(
encoder.encode(
sse({
type: "stream_delta",
delta:
"\n\n---\n> ⚠️ **Heads up:** This response was generated by a backup AI and **wasn't saved** to your chat history. Our main AI service is temporarily unavailable β€” please try again later.",
})
)
);
controller.enqueue(
encoder.encode(sse({ type: "stream_end", messageId: "glm-fallback" }))
);
controller.enqueue(encoder.encode(sse({ type: "done" })));
} catch (error) {
controller.enqueue(
encoder.encode(
sse({
type: "error",
message: (error as Error).message || "GLM request failed",
})
)
);
controller.enqueue(encoder.encode(sse({ type: "done" })));
} finally {
try { controller.close(); } catch { /* already closed */ }
}
},
});

return new Response(stream, { headers: sseHeaders });
}
Loading
Loading