diff --git a/.github/workflows/admin.yml b/.github/workflows/admin.yml index 05c655a..d0285d1 100644 --- a/.github/workflows/admin.yml +++ b/.github/workflows/admin.yml @@ -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 @@ -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 }} diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 1c8a54a..b125d46 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -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 @@ -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 }} diff --git a/packages/kal-frontend/.env.example b/packages/kal-frontend/.env.example index b012ab2..de8c4c7 100644 --- a/packages/kal-frontend/.env.example +++ b/packages/kal-frontend/.env.example @@ -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 diff --git a/packages/kal-frontend/src/app/api/chat/glm-fallback/route.ts b/packages/kal-frontend/src/app/api/chat/glm-fallback/route.ts new file mode 100644 index 0000000..a946e4f --- /dev/null +++ b/packages/kal-frontend/src/app/api/chat/glm-fallback/route.ts @@ -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 { + 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 }); +} diff --git a/packages/kal-frontend/src/lib/chat-stream.ts b/packages/kal-frontend/src/lib/chat-stream.ts index 2d506e4..51d8791 100644 --- a/packages/kal-frontend/src/lib/chat-stream.ts +++ b/packages/kal-frontend/src/lib/chat-stream.ts @@ -3,6 +3,7 @@ * * Uses the browser Fetch API with ReadableStream to consume * Server-Sent Events from POST /api/chat/stream. + * Falls back to /api/chat/glm-fallback if the backend is unreachable. */ import type { ChatSSEEvent, ChatToolName } from "kal-shared"; @@ -43,7 +44,6 @@ function getApiUrl(): string { */ function parseSSEEvents(raw: string): ChatSSEEvent[] { const events: ChatSSEEvent[] = []; - // Split on double newline (event boundary) const blocks = raw.split("\n\n").filter((b) => b.trim()); for (const block of blocks) { @@ -68,10 +68,126 @@ function parseSSEEvents(raw: string): ChatSSEEvent[] { return events; } +function dispatchEvent(event: ChatSSEEvent, callbacks: ChatStreamCallbacks) { + switch (event.type) { + case "tool_start": + callbacks.onToolStart?.(event.tool, event.message); + break; + case "tool_end": + callbacks.onToolEnd?.(event.tool, event.message, event.data); + break; + case "stream_start": + callbacks.onStreamStart?.(); + break; + case "stream_delta": + callbacks.onStreamDelta?.(event.delta); + break; + case "stream_end": + callbacks.onStreamEnd?.(event.messageId); + break; + case "error": + callbacks.onError?.(event.message); + break; + case "done": + callbacks.onDone?.(); + break; + } +} + +async function readSSEStream( + body: ReadableStream, + callbacks: ChatStreamCallbacks, + interceptErrors = false +): Promise<{ fallbackNeeded: boolean }> { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let fallbackNeeded = false; + + const process = (events: ChatSSEEvent[]) => { + for (const event of events) { + if ( + interceptErrors && + event.type === "stream_delta" && + event.delta === BACKEND_ERROR_DELTA + ) { + // Backend AI failed — intercept the error delta, fall back to GLM + fallbackNeeded = true; + } else if ( + fallbackNeeded && + (event.type === "tool_end" || + event.type === "stream_end" || + event.type === "done") + ) { + // Suppress post-error events — GLM will emit its own to finish the flow + } else { + dispatchEvent(event, callbacks); + } + } + }; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + const lastDoubleNewline = buffer.lastIndexOf("\n\n"); + if (lastDoubleNewline === -1) continue; + + const complete = buffer.slice(0, lastDoubleNewline + 2); + buffer = buffer.slice(lastDoubleNewline + 2); + + process(parseSSEEvents(complete)); + } + + if (buffer.trim()) { + process(parseSSEEvents(buffer)); + } + + return { fallbackNeeded }; +} + +// Exact error text the backend workflow yields when every BAML call fails. +// We match on this to detect a failed backend response since the backend +// sends it as a stream_delta (not an SSE error event). +const BACKEND_ERROR_DELTA = + "I'm sorry, I encountered an error generating a response. Please try again."; + +// ---- GLM fallback ---- + +async function sendGlmFallback( + content: string, + callbacks: ChatStreamCallbacks, + signal: AbortSignal +) { + const response = await fetch("/api/chat/glm-fallback", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + signal, + }); + + if (!response.ok || !response.body) { + const body = await response.text().catch(() => ""); + callbacks.onError?.( + `Fallback error (${response.status}): ${body || "Unknown error"}` + ); + callbacks.onDone?.(); + return; + } + + // Skip onStreamEnd so messagesQuery.refetch() never fires — the backend + // already saved its error message to DB, and a refetch would overwrite + // the GLM response in the UI with that error text. + await readSSEStream(response.body, { ...callbacks, onStreamEnd: undefined }); +} + // ---- Main function ---- /** * Send a chat message via SSE and process the stream. + * Falls back to the GLM API route if the backend is unreachable or errors. * * @returns An AbortController that can be used to cancel the request. */ @@ -87,7 +203,6 @@ export function sendChatStream( ): AbortController { const controller = new AbortController(); - // Fire-and-forget async IIFE (async () => { try { const headers: Record = { @@ -97,23 +212,29 @@ export function sendChatStream( if (params.email) headers["x-logto-email"] = params.email; if (params.name) headers["x-logto-name"] = params.name; - const response = await fetch(`${getApiUrl()}/api/chat/stream`, { - method: "POST", - headers, - body: JSON.stringify({ - threadId: params.threadId, - content: params.content, - }), - signal: controller.signal, - credentials: "include", - }); + let response: Response; + + try { + response = await fetch(`${getApiUrl()}/api/chat/stream`, { + method: "POST", + headers, + body: JSON.stringify({ + threadId: params.threadId, + content: params.content, + }), + signal: controller.signal, + credentials: "include", + }); + } catch (fetchError) { + if ((fetchError as Error).name === "AbortError") return; + // Backend unreachable — try GLM fallback + await sendGlmFallback(params.content, callbacks, controller.signal); + return; + } if (!response.ok) { - const body = await response.text(); - callbacks.onError?.( - `Server error (${response.status}): ${body || "Unknown error"}` - ); - callbacks.onDone?.(); + // Backend returned an error — try GLM fallback + await sendGlmFallback(params.content, callbacks, controller.signal); return; } @@ -123,87 +244,17 @@ export function sendChatStream( return; } - // Read the stream - const reader = response.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 }); - - // Process complete events (delimited by double newline) - const lastDoubleNewline = buffer.lastIndexOf("\n\n"); - if (lastDoubleNewline === -1) continue; - - const complete = buffer.slice(0, lastDoubleNewline + 2); - buffer = buffer.slice(lastDoubleNewline + 2); - - const events = parseSSEEvents(complete); - - for (const event of events) { - switch (event.type) { - case "tool_start": - callbacks.onToolStart?.(event.tool, event.message); - break; - case "tool_end": - callbacks.onToolEnd?.(event.tool, event.message, event.data); - break; - case "stream_start": - callbacks.onStreamStart?.(); - break; - case "stream_delta": - callbacks.onStreamDelta?.(event.delta); - break; - case "stream_end": - callbacks.onStreamEnd?.(event.messageId); - break; - case "error": - callbacks.onError?.(event.message); - break; - case "done": - callbacks.onDone?.(); - break; - } - } - } + const { fallbackNeeded } = await readSSEStream( + response.body, + callbacks, + true // intercept SSE error events and fall back to GLM + ); - // Process any remaining buffer - if (buffer.trim()) { - const events = parseSSEEvents(buffer); - for (const event of events) { - switch (event.type) { - case "tool_start": - callbacks.onToolStart?.(event.tool, event.message); - break; - case "tool_end": - callbacks.onToolEnd?.(event.tool, event.message, event.data); - break; - case "stream_start": - callbacks.onStreamStart?.(); - break; - case "stream_delta": - callbacks.onStreamDelta?.(event.delta); - break; - case "stream_end": - callbacks.onStreamEnd?.(event.messageId); - break; - case "error": - callbacks.onError?.(event.message); - break; - case "done": - callbacks.onDone?.(); - break; - } - } + if (fallbackNeeded) { + await sendGlmFallback(params.content, callbacks, controller.signal); } } catch (error) { - if ((error as Error).name === "AbortError") { - // User cancelled — not an error - return; - } + if ((error as Error).name === "AbortError") return; callbacks.onError?.((error as Error).message || "Network error"); callbacks.onDone?.(); }