From c49cda8cc6b2a4148049901e03b5699309d9192b Mon Sep 17 00:00:00 2001 From: Zen0space Date: Thu, 26 Mar 2026 19:12:01 +0800 Subject: [PATCH] feat: chat workflow agent + right-side push panel + markdown rendering - BAML: new agent functions (FormatFoodResponse, FormatRecipeResponse, FormatApiHelpResponse, StreamChat), ApiHelp intent, claude client - Backend: SSE streaming endpoint (POST /api/chat/stream), async generator workflow with tRPC caller for food search, removed sendMessage mutation and food-search.ts - Frontend: right-side push panel with activity bar (replaces FAB popup), ChatPanelContext for open/close state, responsive panel (400-500px desktop, fullscreen mobile) - Markdown: react-markdown + remark-gfm for AI replies with dark theme styling, code blocks with copy button, tables, links - UI: global seamless dark scrollbar, gradient green chat icon, hamburger menu for thread list, no focus highlight on input - Shared: ChatSSEEvent discriminated union type, ChatToolName type --- .env.example | 9 +- .../docker-compose.yml => docker-compose.yml | 26 - packages/kal-backend/src/index.ts | 17 + packages/kal-backend/src/lib/chat-workflow.ts | 536 +++++++++++ packages/kal-backend/src/lib/trpc.ts | 1 + .../kal-backend/src/middleware/timeout.ts | 6 +- packages/kal-backend/src/routers/chat.ts | 361 +------- packages/kal-backend/src/routers/halal.ts | 8 +- .../kal-backend/src/routes/chat-stream.ts | 141 +++ .../kal-baml/baml_src/clients/claude.baml | 9 + packages/kal-baml/baml_src/clients/glm.baml | 21 - .../kal-baml/baml_src/functions/agent.baml | 202 ++++ .../kal-baml/baml_src/functions/analysis.baml | 4 +- .../kal-baml/baml_src/functions/chat.baml | 6 +- .../kal-baml/baml_src/functions/recipe.baml | 10 +- .../baml_src/functions/stream-chat.baml | 27 + .../kal-baml/baml_src/functions/thinking.baml | 30 +- packages/kal-baml/baml_src/types/chat.baml | 1 + packages/kal-baml/src/chat.ts | 182 +++- packages/kal-baml/src/index.ts | 15 +- packages/kal-frontend/package.json | 4 +- packages/kal-frontend/src/app/globals.css | 85 +- packages/kal-frontend/src/app/layout.tsx | 16 +- .../src/components/chat/ChatActivityBar.tsx | 68 ++ .../src/components/chat/ChatMessage.tsx | 332 +++++++ .../src/components/chat/ChatPanel.tsx | 487 ++++++++++ .../src/components/chat/ChatWidget.tsx | 82 ++ .../src/components/chat/ToolStepIndicator.tsx | 72 ++ .../src/contexts/ChatPanelContext.tsx | 40 + packages/kal-frontend/src/lib/chat-stream.ts | 213 +++++ packages/kal-frontend/tailwind.config.ts | 5 + packages/kal-shared/src/types/index.ts | 79 +- pnpm-lock.yaml | 874 ++++++++++++++++++ 33 files changed, 3484 insertions(+), 485 deletions(-) rename docker/docker-compose.yml => docker-compose.yml (68%) create mode 100644 packages/kal-backend/src/lib/chat-workflow.ts create mode 100644 packages/kal-backend/src/routes/chat-stream.ts create mode 100644 packages/kal-baml/baml_src/clients/claude.baml delete mode 100644 packages/kal-baml/baml_src/clients/glm.baml create mode 100644 packages/kal-baml/baml_src/functions/agent.baml create mode 100644 packages/kal-baml/baml_src/functions/stream-chat.baml create mode 100644 packages/kal-frontend/src/components/chat/ChatActivityBar.tsx create mode 100644 packages/kal-frontend/src/components/chat/ChatMessage.tsx create mode 100644 packages/kal-frontend/src/components/chat/ChatPanel.tsx create mode 100644 packages/kal-frontend/src/components/chat/ChatWidget.tsx create mode 100644 packages/kal-frontend/src/components/chat/ToolStepIndicator.tsx create mode 100644 packages/kal-frontend/src/contexts/ChatPanelContext.tsx create mode 100644 packages/kal-frontend/src/lib/chat-stream.ts diff --git a/.env.example b/.env.example index 1432634..0a336c3 100644 --- a/.env.example +++ b/.env.example @@ -36,11 +36,14 @@ CHAT_FRONTEND_URL=http://localhost:3003 # Session Secret (for express-session) SESSION_SECRET= +# Internal API Key (for backend-to-backend communication) +INTERNAL_API_KEY= + # =================== -# GLM 4.6 (Zhipu AI) - BAML Chat API +# Pika AI (Anthropic Proxy) - BAML Chat API # =================== -GLM_API_BASE_URL=https://api.z.ai/api/paas/v4 -GLM_API_KEY= +PIKA_BASE_URL=https://pikaai.xyz +PIKA_API_KEY= # ============================================ # PRODUCTION (VPS 72.62.74.47) - Copy to .env.production diff --git a/docker/docker-compose.yml b/docker-compose.yml similarity index 68% rename from docker/docker-compose.yml rename to docker-compose.yml index b147caa..75bc352 100644 --- a/docker/docker-compose.yml +++ b/docker-compose.yml @@ -71,32 +71,6 @@ services: timeout: 3s retries: 5 - backend: - # Image built in GitHub Actions and pushed to GHCR on every merge to main. - # To update on VPS: docker compose pull backend && docker compose up -d backend - image: ghcr.io/zen0space/kal-monorepo/kal-backend:latest - container_name: kal-backend - ports: - - "${BACKEND_PORT:-4000}:3000" - environment: - - NODE_ENV=production - - MONGODB_URI=mongodb://${MONGODB_USER}:${MONGODB_PASSWORD}@mongodb:27017/${MONGODB_DATABASE}?authSource=admin - - REDIS_URL=redis://redis:6379 - - LOGTO_ENDPOINT=${LOGTO_ENDPOINT} - - LOGTO_APP_ID=${LOGTO_APP_ID} - - LOGTO_APP_SECRET=${LOGTO_APP_SECRET} - - SESSION_SECRET=${SESSION_SECRET} - - GLM_API_BASE_URL=${GLM_API_BASE_URL} - - GLM_API_KEY=${GLM_API_KEY} - - INTERNAL_API_KEY=${INTERNAL_API_KEY} - - FRONTEND_URL=${FRONTEND_URL} - depends_on: - - mongodb - - logto - - redis - networks: - - kal-network - volumes: mongodb_data: postgres_data: diff --git a/packages/kal-backend/src/index.ts b/packages/kal-backend/src/index.ts index 0f48617..190d287 100644 --- a/packages/kal-backend/src/index.ts +++ b/packages/kal-backend/src/index.ts @@ -7,6 +7,7 @@ import cors from "cors"; import express from "express"; import session from "express-session"; import helmet from "helmet"; +import { quickChat } from "kal-baml"; import { API_BASE_PATH } from "kal-shared"; import swaggerUi from "swagger-ui-express"; @@ -26,6 +27,7 @@ import { configureServerTimeouts, } from "./middleware/timeout.js"; import { apiRouter } from "./routers/api.js"; +import { chatStreamRouter } from "./routes/chat-stream.js"; import { appRouter } from "./routers/index.js"; const PORT = process.env.BACKEND_PORT || 3000; @@ -43,6 +45,17 @@ async function main() { console.log("⚠️ Redis not available (caching disabled)"); } + // Verify BAML/LLM connection (Anthropic via Pika AI proxy) + try { + await quickChat("Say hi in one word.", "Respond with a single word only."); + console.log("✅ BAML connected (claude-haiku-4.5 via pikaai.xyz)"); + } catch (error) { + console.warn( + "⚠️ BAML/LLM connection failed:", + error instanceof Error ? error.message : String(error) + ); + } + const app = express(); // Security headers (Helmet) @@ -95,6 +108,7 @@ async function main() { // Apply PRIVATE CORS to everything else (tRPC, health, auth routes) app.use("/trpc", cors(privateCorsOptions)); + app.use("/api/chat", cors(privateCorsOptions)); app.use("/health", cors(privateCorsOptions)); // Request timeout middleware (scalable - uses native req.setTimeout) @@ -285,6 +299,9 @@ async function main() { `); }); + // Chat SSE streaming endpoint (private — requires x-logto-id header) + app.use("/api/chat", chatStreamRouter); + // REST API routes app.use(API_BASE_PATH, apiRouter); diff --git a/packages/kal-backend/src/lib/chat-workflow.ts b/packages/kal-backend/src/lib/chat-workflow.ts new file mode 100644 index 0000000..032fe4f --- /dev/null +++ b/packages/kal-backend/src/lib/chat-workflow.ts @@ -0,0 +1,536 @@ +/** + * Chat Workflow Orchestrator + * + * Orchestrates the multi-step AI chat workflow and emits SSE events + * so the frontend can show each step in real-time. + * + * Workflow: + * 1. Save user message + * 2. Classify intent (tool step) + * 3. If food query → extract search term → search DB (tool steps) + * 4. Stream AI response using the appropriate BAML function + * 5. Save assistant message + */ + +import type { ChatSSEEvent } from "kal-shared"; +import { + classifyIntent, + extractFoodSearchTerm, + estimateNutrition, + UserIntent, + streamFormatFoodResponse, + streamFormatRecipeResponse, + streamGeneralChat, + streamFormatApiHelpResponse, + parseRecipe, + type RecipeIngredient, +} from "kal-baml"; +import { ObjectId } from "mongodb"; +import type { Db } from "mongodb"; +import type { ChatMessage, ChatThread } from "kal-shared"; + +import { appRouter } from "../routers/index.js"; +import { createCallerFactory } from "./trpc.js"; + +// ── tRPC server-side caller factory ── +const createCaller = createCallerFactory(appRouter); + +// ── Unified result type (tagged with source) ── +interface FoodSearchResult { + id: string; + name: string; + calories: number; + protein: number; + carbs: number; + fat: number; + serving: string; + category?: string; + source: "halal" | "natural" | "estimated"; + brand?: string; + halalCertifier?: string; + halalCertYear?: number; +} + +// ── Search foods via tRPC caller (hits both collections with caching) ── +async function searchFoods( + db: Db, + query: string, + limit = 10 +): Promise { + const caller = createCaller({ db, user: null, userId: undefined }); + + const [halalRaw, naturalRaw] = await Promise.all([ + caller.halal.search({ query }), + caller.food.search({ query }), + ]); + + const halalResults: FoodSearchResult[] = halalRaw.map((r) => ({ + id: r._id, + name: r.name as string, + calories: r.calories as number, + protein: r.protein as number, + carbs: r.carbs as number, + fat: r.fat as number, + serving: r.serving as string, + category: r.category as string | undefined, + source: "halal" as const, + brand: r.brand as string | undefined, + halalCertifier: r.halalCertifier as string | undefined, + halalCertYear: r.halalCertYear as number | undefined, + })); + + const naturalResults: FoodSearchResult[] = naturalRaw.map((r) => ({ + id: r._id, + name: r.name as string, + calories: r.calories as number, + protein: r.protein as number, + carbs: r.carbs as number, + fat: r.fat as number, + serving: r.serving as string, + source: "natural" as const, + })); + + // Halal first, then natural, capped at limit + return [...halalResults, ...naturalResults].slice(0, limit); +} + +// ── Format results into a string for the AI prompt ── +function formatFoodDataForPrompt(results: FoodSearchResult[]): string { + if (results.length === 0) return ""; + + return results + .map((r) => { + const parts = [ + `Name: ${r.name}`, + `Calories: ${r.calories} kcal`, + `Protein: ${r.protein}g`, + `Carbs: ${r.carbs}g`, + `Fat: ${r.fat}g`, + `Serving: ${r.serving}`, + `Source: ${r.source}`, + ]; + if (r.brand) parts.push(`Brand: ${r.brand}`); + if (r.halalCertifier) parts.push(`Halal Certifier: ${r.halalCertifier}`); + if (r.halalCertYear) parts.push(`Halal Cert Year: ${r.halalCertYear}`); + if (r.category) parts.push(`Category: ${r.category}`); + return parts.join(" | "); + }) + .join("\n"); +} + +// Store recent recipes in memory for quick reference (per thread) +const recentRecipes = new Map< + string, + { name: string; ingredients: Array<{ name: string; quantity: string }> } +>(); + +/** + * Run the full chat workflow as an async generator that yields SSE events. + * + * The caller (SSE route) iterates over these events and writes them + * to the HTTP response as Server-Sent Events. + */ +export async function* runChatWorkflow(params: { + threadId: string; + content: string; + userId: string; + db: Db; +}): AsyncGenerator { + const { threadId, content, userId, db } = params; + const now = new Date(); + + // ────────────────────────────────────────────── + // Step 0: Verify thread ownership & save user message + // ────────────────────────────────────────────── + const thread = await db.collection("chat_threads").findOne({ + _id: new ObjectId(threadId), + userId, + }); + + if (!thread) { + yield { type: "error", message: "Thread not found or access denied" }; + yield { type: "done" }; + return; + } + + // Save user message + const userMessage: Omit = { + threadId, + userId, + role: "User", + content, + createdAt: now, + }; + await db.collection("chat_messages").insertOne(userMessage); + + // Get recent conversation history + const recentMessages = await db + .collection("chat_messages") + .find({ threadId }) + .sort({ createdAt: -1 }) + .limit(10) + .toArray(); + recentMessages.reverse(); + + const conversationContext = recentMessages + .slice(-6) + .map((m) => `${m.role}: ${(m.content as string).slice(0, 150)}`) + .join("\n"); + + // ────────────────────────────────────────────── + // Step 1: Classify intent + // ────────────────────────────────────────────── + yield { + type: "tool_start", + tool: "classify_intent", + message: "Understanding your question...", + }; + + let intentResult; + try { + intentResult = await classifyIntent(content, conversationContext); + console.log( + "[Workflow] Intent:", + intentResult.intent, + "Confidence:", + intentResult.confidence + ); + } catch (error) { + console.warn("[Workflow] Intent classification failed:", error); + // Default to FoodQuery — a DB search is cheap (cached via tRPC) and + // harmless if nothing is found (falls through to general chat). + intentResult = { + intent: UserIntent.FoodQuery, + confidence: 0.3, + reasoning: "Fallback — intent classification failed, will search DB", + extracted_food_terms: [] as string[], + requires_api_lookup: true, + requires_recipe_parse: false, + }; + } + + const intentLabel = + intentResult.intent === UserIntent.FoodQuery + ? "Food nutrition query" + : intentResult.intent === UserIntent.RecipeRequest + ? "Recipe request" + : intentResult.intent === UserIntent.RecipeNutrition + ? "Recipe nutrition query" + : intentResult.intent === UserIntent.ApiHelp + ? "API documentation help" + : intentResult.intent === UserIntent.Greeting + ? "Greeting" + : "General question"; + + yield { + type: "tool_end", + tool: "classify_intent", + message: intentLabel, + data: { + intent: intentResult.intent, + confidence: intentResult.confidence, + foodTerms: intentResult.extracted_food_terms, + }, + }; + + // ────────────────────────────────────────────── + // Step 2: Route based on intent + // ────────────────────────────────────────────── + let foodData = ""; + let hasDbResults = false; + let foodResults: FoodSearchResult[] = []; + + let needsFoodSearch = + intentResult.intent === UserIntent.FoodQuery || + intentResult.requires_api_lookup; + + const isRecipeRequest = intentResult.intent === UserIntent.RecipeRequest; + const isRecipeNutrition = intentResult.intent === UserIntent.RecipeNutrition; + const isApiHelp = intentResult.intent === UserIntent.ApiHelp; + + // ApiHelp never needs food search + if (isApiHelp) { + needsFoodSearch = false; + } + + // ── Food Query Path ── + if (needsFoodSearch && !isRecipeRequest && !isRecipeNutrition) { + // Extract search term + yield { + type: "tool_start", + tool: "extract_search_term", + message: "Extracting food name...", + }; + + let searchTerm: string; + try { + searchTerm = await extractFoodSearchTerm(content); + } catch { + // Fallback: use extracted food terms from intent or the raw message + searchTerm = + intentResult.extracted_food_terms?.[0] || content.toLowerCase(); + } + + yield { + type: "tool_end", + tool: "extract_search_term", + message: `Searching for "${searchTerm}"`, + data: { searchTerm }, + }; + + // Search database + yield { + type: "tool_start", + tool: "search_database", + message: `Searching Kalori database for "${searchTerm}"...`, + }; + + foodResults = await searchFoods(db, searchTerm); + hasDbResults = foodResults.length > 0; + + if (hasDbResults) { + const halalCount = foodResults.filter((r) => r.source === "halal").length; + const naturalCount = foodResults.filter( + (r) => r.source === "natural" + ).length; + + const parts: string[] = []; + if (halalCount > 0) parts.push(`${halalCount} halal`); + if (naturalCount > 0) parts.push(`${naturalCount} natural`); + + yield { + type: "tool_end", + tool: "search_database", + message: `Found ${foodResults.length} results (${parts.join(", ")})`, + data: { + count: foodResults.length, + halalCount, + naturalCount, + foods: foodResults.map((f) => f.name), + }, + }; + + foodData = formatFoodDataForPrompt(foodResults); + } else { + yield { + type: "tool_end", + tool: "search_database", + message: `No results found for "${searchTerm}"`, + data: { count: 0 }, + }; + + // Try AI estimation + yield { + type: "tool_start", + tool: "estimate_nutrition", + message: `Estimating nutrition for "${searchTerm}"...`, + }; + + try { + const estimated = await estimateNutrition(searchTerm); + const estimatedResult: FoodSearchResult = { + id: `estimated-${Date.now()}`, + name: estimated.name, + calories: estimated.calories, + protein: estimated.protein, + carbs: estimated.carbs, + fat: estimated.fat, + serving: estimated.serving, + source: "estimated", + }; + foodResults = [estimatedResult]; + foodData = formatFoodDataForPrompt(foodResults); + + yield { + type: "tool_end", + tool: "estimate_nutrition", + message: "AI estimation ready (not from database)", + data: { estimated: true }, + }; + } catch (error) { + console.warn("[Workflow] Estimation failed:", error); + yield { + type: "tool_end", + tool: "estimate_nutrition", + message: "Not found — will respond as general chat", + }; + // No food data at all — fall through to general chat formatter + needsFoodSearch = false; + } + } + } + + // ── Recipe Nutrition Path ── + if (isRecipeNutrition) { + const cachedRecipe = recentRecipes.get(threadId); + if (cachedRecipe) { + yield { + type: "tool_start", + tool: "search_database", + message: `Looking up nutrition for ${cachedRecipe.ingredients.length} ingredients...`, + }; + + // Search ingredients in DB + const ingredientResults: FoodSearchResult[] = []; + for (const ing of cachedRecipe.ingredients) { + const results = await searchFoods(db, ing.name, 1); + if (results.length > 0) { + ingredientResults.push(results[0]); + } else { + // Estimate + try { + const est = await estimateNutrition(ing.name, ing.quantity); + ingredientResults.push({ + id: `estimated-${Date.now()}`, + name: est.name, + calories: est.calories, + protein: est.protein, + carbs: est.carbs, + fat: est.fat, + serving: est.serving, + source: "estimated", + }); + } catch { + // Skip failed estimations + } + } + } + + foodData = formatFoodDataForPrompt(ingredientResults); + hasDbResults = ingredientResults.some((r) => r.source !== "estimated"); + + yield { + type: "tool_end", + tool: "search_database", + message: `Found nutrition for ${ingredientResults.length} ingredients`, + data: { count: ingredientResults.length }, + }; + } + } + + // ────────────────────────────────────────────── + // Step 3: Stream AI response + // ────────────────────────────────────────────── + yield { + type: "tool_start", + tool: "generate_response", + message: "Generating response...", + }; + + yield { type: "stream_start" }; + + let fullResponse = ""; + + try { + let streamGen: AsyncGenerator; + + if (isApiHelp) { + // API documentation help → use FormatApiHelpResponse + streamGen = streamFormatApiHelpResponse({ + userMessage: content, + conversationContext, + }); + } else if (isRecipeRequest || isRecipeNutrition) { + // Recipe → use FormatRecipeResponse + streamGen = streamFormatRecipeResponse({ + userMessage: content, + ingredientNutrition: foodData || undefined, + conversationContext, + }); + } else if (needsFoodSearch) { + // Food query → use FormatFoodResponse with strict DB data + streamGen = streamFormatFoodResponse({ + userMessage: content, + foodData: foodData || "No data available.", + hasDbResults, + conversationContext, + }); + } else { + // General chat / greeting → use StreamChat + streamGen = streamGeneralChat({ + userMessage: content, + conversationContext, + }); + } + + for await (const chunk of streamGen) { + if (chunk && typeof chunk === "string") { + // BAML streaming yields cumulative text, so we need the delta + const delta = chunk.slice(fullResponse.length); + if (delta) { + fullResponse = chunk; + yield { type: "stream_delta", delta }; + } + } + } + } catch (error) { + console.error("[Workflow] Stream error:", error); + if (!fullResponse) { + fullResponse = + "I'm sorry, I encountered an error generating a response. Please try again."; + yield { type: "stream_delta", delta: fullResponse }; + } + } + + yield { + type: "tool_end", + tool: "generate_response", + message: "Response complete", + }; + + // ────────────────────────────────────────────── + // Step 4: Save assistant message & update thread + // ────────────────────────────────────────────── + const assistantMessage: Omit = { + threadId, + userId, + role: "Assistant", + content: fullResponse, + createdAt: new Date(), + }; + + const assistantResult = await db + .collection("chat_messages") + .insertOne(assistantMessage); + + // If recipe response, try to cache it for future nutrition queries + if (isRecipeRequest || fullResponse.toLowerCase().includes("ingredients")) { + try { + const parsed = await parseRecipe(fullResponse); + if (parsed.ingredients.length > 0) { + recentRecipes.set(threadId, { + name: parsed.name, + ingredients: parsed.ingredients.map((i: RecipeIngredient) => ({ + name: i.name, + quantity: i.quantity, + })), + }); + } + } catch { + // Not a parseable recipe — that's fine + } + } + + // Update thread metadata + const isFirstMessage = (thread.messageCount as number) === 0; + const updateData: Partial = { + updatedAt: new Date(), + messageCount: (thread.messageCount as number) + 2, + }; + + if (isFirstMessage) { + updateData.title = + content.slice(0, 50) + (content.length > 50 ? "..." : ""); + } + + await db + .collection("chat_threads") + .updateOne({ _id: new ObjectId(threadId) }, { $set: updateData }); + + yield { + type: "stream_end", + messageId: assistantResult.insertedId.toString(), + }; + + yield { type: "done" }; +} diff --git a/packages/kal-backend/src/lib/trpc.ts b/packages/kal-backend/src/lib/trpc.ts index 2e53294..abfbf79 100644 --- a/packages/kal-backend/src/lib/trpc.ts +++ b/packages/kal-backend/src/lib/trpc.ts @@ -6,6 +6,7 @@ const t = initTRPC.context().create(); export const router = t.router; export const publicProcedure = t.procedure; +export const createCallerFactory = t.createCallerFactory; // Middleware to check if user is authenticated const isAuthenticated = t.middleware(({ ctx, next }) => { diff --git a/packages/kal-backend/src/middleware/timeout.ts b/packages/kal-backend/src/middleware/timeout.ts index 0874c9a..9b889bb 100644 --- a/packages/kal-backend/src/middleware/timeout.ts +++ b/packages/kal-backend/src/middleware/timeout.ts @@ -38,8 +38,10 @@ export function requestTimeout(timeoutMs: number = DEFAULT_TIMEOUT_MS) { } // Use extended timeout for AI/chat routes that may take longer - const extendedTimeoutPaths = ["/trpc/chat.sendMessage", "/trpc/chat"]; - const isExtendedPath = extendedTimeoutPaths.some((path) => req.path.includes(path)); + const extendedTimeoutPaths = ["/trpc/chat", "/api/chat/stream"]; + const isExtendedPath = extendedTimeoutPaths.some((path) => + req.path.includes(path) + ); const effectiveTimeout = isExtendedPath ? EXTENDED_TIMEOUT_MS : timeoutMs; // Use native request timeout - no custom timers needed diff --git a/packages/kal-backend/src/routers/chat.ts b/packages/kal-backend/src/routers/chat.ts index 1c98664..0476ef7 100644 --- a/packages/kal-backend/src/routers/chat.ts +++ b/packages/kal-backend/src/routers/chat.ts @@ -1,23 +1,7 @@ -import { - smartChat, - classifyIntent, - parseRecipe, - type ParsedRecipe, - type RecipeIngredient, - UserIntent, -} from "kal-baml"; -import type { ChatMessage, ChatThread, ChatThreadPreview } from "kal-shared"; +import type { ChatThread, ChatThreadPreview } from "kal-shared"; import { ObjectId } from "mongodb"; import { z } from "zod"; -import { - isFoodQuery, - isRecipeNutritionQuery, - searchKaloriApi, - searchRecipeIngredients, - formatNutritionForChat, - calculateTotalNutrition, -} from "../lib/kalori-api-tool.js"; import { router, protectedProcedure } from "../lib/trpc.js"; // Helper to ensure user is authenticated @@ -28,9 +12,6 @@ function requireUser(user: { logtoId: string } | null): { logtoId: string } { return user; } -// Store recent recipes in memory for quick reference (per thread) -const recentRecipes = new Map(); - // =================== // Thread Management // =================== @@ -127,350 +108,14 @@ export const chatRouter = router({ .collection("chat_threads") .deleteOne({ _id: new ObjectId(threadId) }); - // Clear any cached recipes for this thread - recentRecipes.delete(threadId); - return { success: true }; }), // =================== // Message Management // =================== - - // Send a message and get AI response - sendMessage: protectedProcedure - .input( - z.object({ - threadId: z.string(), - content: z.string().min(1).max(10000), - }) - ) - .mutation(async ({ ctx, input }) => { - const user = requireUser(ctx.user); - const { threadId, content } = input; - const now = new Date(); - - console.log("[Chat] sendMessage called:", { - user: user.logtoId, - threadId, - content: content.slice(0, 50), - }); - - // Verify thread ownership - const thread = await ctx.db.collection("chat_threads").findOne({ - _id: new ObjectId(threadId), - userId: user.logtoId, - }); - - if (!thread) { - console.log("[Chat] Thread not found or access denied:", threadId); - throw new Error("Thread not found or access denied"); - } - - // Save user message - const userMessage: Omit = { - threadId, - userId: user.logtoId, - role: "User", - content, - createdAt: now, - }; - - const userMsgResult = await ctx.db - .collection("chat_messages") - .insertOne(userMessage); - - console.log( - "[Chat] User message saved:", - userMsgResult.insertedId.toString() - ); - - // Get recent conversation history for context (last 10 messages) - const recentMessages = await ctx.db - .collection("chat_messages") - .find({ threadId }) - .sort({ createdAt: -1 }) - .limit(10) - .toArray(); - - // Reverse to chronological order - recentMessages.reverse(); - - // Build conversation context for intent classification - const conversationContext = recentMessages - .slice(-5) - .map((m) => `${m.role}: ${(m.content as string).slice(0, 100)}`) - .join("\n"); - - // Classify intent to determine how to handle the message - console.log("[Thinking] Classifying user intent..."); - let intentResult; - try { - intentResult = await classifyIntent(content, conversationContext); - console.log( - "[Thinking] Intent:", - intentResult.intent, - "Confidence:", - intentResult.confidence - ); - } catch (_error) { - intentResult = { - intent: UserIntent.GeneralChat, - confidence: 0.5, - reasoning: "Fallback", - extracted_food_terms: [], - requires_api_lookup: isFoodQuery(content), - requires_recipe_parse: false, - }; - } - - // Process based on intent - let foodContext = ""; - let recipeContext = ""; - - // Check if user is asking about a recipe's nutrition - if (isRecipeNutritionQuery(content)) { - console.log("[Tool: recipe] Recipe nutrition query detected"); - - // Check if we have a recent recipe for this thread - const cachedRecipe = recentRecipes.get(threadId); - - if (cachedRecipe) { - console.log("[Tool: recipe] Using cached recipe:", cachedRecipe.name); - - // Search for all ingredients - const ingredients = cachedRecipe.ingredients.map( - (i: RecipeIngredient) => ({ - name: i.name, - quantity: i.quantity, - }) - ); - - console.log( - "[Tool: recipe] Searching nutrition for", - ingredients.length, - "ingredients" - ); - const nutritionResults = await searchRecipeIngredients(ingredients); - - // Calculate totals - const totals = calculateTotalNutrition(nutritionResults); - - // Format for context - foodContext = `Recipe: ${cachedRecipe.name}\n\nIngredient nutrition:\n${formatNutritionForChat(nutritionResults)}`; - foodContext += `\n\n**Total Nutrition:**\n- Calories: ${totals.totalCalories} cal\n- Protein: ${totals.totalProtein}g\n- Carbs: ${totals.totalCarbs}g\n- Fat: ${totals.totalFat}g`; - if (totals.hasEstimates) { - foodContext += - "\n\n_Note: Some values are AI estimates as the ingredients were not found in our database._"; - } - } else { - // Try to find a recipe in recent messages - console.log( - "[Tool: recipe] No cached recipe, looking in conversation..." - ); - - // Look for recipe in recent assistant messages - const recentAssistantMessages = recentMessages - .filter((m) => m.role === "Assistant") - .slice(-3); - - for (const msg of recentAssistantMessages) { - const msgContent = msg.content as string; - if ( - msgContent.includes("Ingredients") || - msgContent.includes("Recipe") - ) { - console.log( - "[Tool: recipe] Found recipe in conversation, parsing..." - ); - try { - const parsedRecipe = await parseRecipe(msgContent); - recentRecipes.set(threadId, parsedRecipe); - - // Search for all ingredients - const ingredients = parsedRecipe.ingredients.map( - (i: RecipeIngredient) => ({ - name: i.name, - quantity: i.quantity, - }) - ); - - console.log( - "[Tool: recipe] Searching nutrition for", - ingredients.length, - "ingredients" - ); - const nutritionResults = - await searchRecipeIngredients(ingredients); - const totals = calculateTotalNutrition(nutritionResults); - - foodContext = `Recipe: ${parsedRecipe.name}\n\nIngredient nutrition:\n${formatNutritionForChat(nutritionResults)}`; - foodContext += `\n\n**Total Nutrition:**\n- Calories: ${totals.totalCalories} cal\n- Protein: ${totals.totalProtein}g\n- Carbs: ${totals.totalCarbs}g\n- Fat: ${totals.totalFat}g`; - if (totals.hasEstimates) { - foodContext += "\n\n_Note: Some values are AI estimates._"; - } - break; - } catch (error) { - console.log("[Tool: recipe] Failed to parse recipe:", error); - } - } - } - } - } - // Handle recipe requests - save for later nutrition queries - else if ( - intentResult.intent === UserIntent.RecipeRequest || - content.toLowerCase().includes("recipe") - ) { - console.log("[Tool: recipe] Recipe request detected"); - recipeContext = - "User is asking for a recipe. After providing the recipe, they may ask about its nutrition."; - } - // Handle food queries - else if (intentResult.requires_api_lookup || isFoodQuery(content)) { - console.log( - "[Tool: api_checker] Food query detected, searching API..." - ); - const foodResults = await searchKaloriApi(content); - - if (foodResults.length > 0) { - foodContext = formatNutritionForChat(foodResults); - console.log( - "[Tool: api_checker] Added", - foodResults.length, - "results to context" - ); - } - } - - // Build messages for SmartChat - const chatMessages = recentMessages.map((m) => ({ - role: m.role as "User" | "Assistant" | "System", - content: m.content as string, - })); - - // Build enhanced system prompt - const systemPrompt = `You are Kal, a helpful AI nutrition assistant powered by Kalori. You help users with: -- Food nutrition information (calories, protein, carbs, fat) -- Recipe suggestions with Malaysian/halal food focus -- Healthy eating advice - -**IMPORTANT: Format all responses using proper Markdown:** -- Use **bold** for section headers like **Ingredients:** and **Instructions:** -- Use bullet points (- item) for lists with a blank line before the list -- Use numbered lists (1. step) for instructions -- Add blank lines between sections for readability -- Use ### for recipe titles - -Always respond in English. Be friendly, concise, and accurate. -${recipeContext}`; - - // Generate AI response using SmartChat with full context - console.log("[Streaming] Generating AI response with SmartChat..."); - let aiResponse = await smartChat({ - messages: chatMessages, - systemPrompt, - foodContext: foodContext || undefined, - }); - console.log( - "[Streaming] Response generated:", - aiResponse.slice(0, 50) + "..." - ); - - // If this was a recipe response, parse it and append nutrition summary - if ( - intentResult.intent === UserIntent.RecipeRequest || - aiResponse.toLowerCase().includes("ingredients") - ) { - try { - const parsedRecipe = await parseRecipe(aiResponse); - if (parsedRecipe.ingredients.length > 0) { - console.log("[Tool: recipe] Caching recipe:", parsedRecipe.name); - recentRecipes.set(threadId, parsedRecipe); - - // Calculate nutrition for all ingredients - console.log( - "[Tool: recipe] Calculating nutrition for", - parsedRecipe.ingredients.length, - "ingredients" - ); - const ingredients = parsedRecipe.ingredients.map( - (i: RecipeIngredient) => ({ - name: i.name, - quantity: i.quantity, - }) - ); - - const nutritionResults = await searchRecipeIngredients(ingredients); - const totals = calculateTotalNutrition(nutritionResults); - - // Build nutrition summary - let nutritionSummary = - "\n\n---\n\n**Nutrition Summary (estimated per serving):**\n"; - nutritionSummary += `- **Calories:** ${totals.totalCalories} cal\n`; - nutritionSummary += `- **Protein:** ${totals.totalProtein}g\n`; - nutritionSummary += `- **Carbs:** ${totals.totalCarbs}g\n`; - nutritionSummary += `- **Fat:** ${totals.totalFat}g\n`; - - if (totals.hasEstimates) { - nutritionSummary += - "\n_Some values are estimated as ingredients were not found in our database._"; - } - - // Append to response - aiResponse += nutritionSummary; - console.log("[Tool: recipe] Nutrition summary appended"); - } - } catch (error) { - console.log( - "[Tool: recipe] Failed to parse/calculate nutrition:", - error - ); - // Not a recipe or failed to parse - that's okay - } - } - - // Save assistant message - const assistantMessage: Omit = { - threadId, - userId: user.logtoId, - role: "Assistant", - content: aiResponse, - createdAt: new Date(), - }; - - const assistantResult = await ctx.db - .collection("chat_messages") - .insertOne(assistantMessage); - - // Update thread - const isFirstMessage = (thread.messageCount as number) === 0; - const updateData: Partial = { - updatedAt: new Date(), - messageCount: (thread.messageCount as number) + 2, // User + Assistant - }; - - // Auto-generate title from first message - if (isFirstMessage) { - updateData.title = - content.slice(0, 50) + (content.length > 50 ? "..." : ""); - } - - await ctx.db - .collection("chat_threads") - .updateOne({ _id: new ObjectId(threadId) }, { $set: updateData }); - - return { - userMessage: { - _id: userMsgResult.insertedId.toString(), - ...userMessage, - }, - assistantMessage: { - _id: assistantResult.insertedId.toString(), - ...assistantMessage, - }, - }; - }), + // NOTE: sendMessage has been replaced by the SSE endpoint at POST /api/chat/stream + // See: routes/chat-stream.ts + lib/chat-workflow.ts // Get messages for a thread getMessages: protectedProcedure diff --git a/packages/kal-backend/src/routers/halal.ts b/packages/kal-backend/src/routers/halal.ts index 416e88e..c68cd0b 100644 --- a/packages/kal-backend/src/routers/halal.ts +++ b/packages/kal-backend/src/routers/halal.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { CacheKeys, CacheTTL } from "../lib/cache-keys.js"; import { cache } from "../lib/cache.js"; +import { buildSearchQuery } from "../lib/search.js"; import { router, publicProcedure } from "../lib/trpc.js"; export const halalRouter = router({ @@ -12,9 +13,10 @@ export const halalRouter = router({ const cacheKey = CacheKeys.trpcHalalSearch(input.query); return cache.wrap(cacheKey, CacheTTL.SEARCH_RESULTS, async () => { + const searchQuery = buildSearchQuery(input.query); const foods = await ctx.db .collection("halal_foods") - .find({ name: { $regex: input.query, $options: "i" } }) + .find(searchQuery) .limit(20) .toArray(); @@ -117,9 +119,7 @@ export const halalRouter = router({ const cacheKey = CacheKeys.trpcHalalBrands(); return cache.wrap(cacheKey, CacheTTL.BRANDS, async () => { - const brands = await ctx.db - .collection("halal_foods") - .distinct("brand"); + const brands = await ctx.db.collection("halal_foods").distinct("brand"); return brands.filter(Boolean).sort(); }); }), diff --git a/packages/kal-backend/src/routes/chat-stream.ts b/packages/kal-backend/src/routes/chat-stream.ts new file mode 100644 index 0000000..07dd7b8 --- /dev/null +++ b/packages/kal-backend/src/routes/chat-stream.ts @@ -0,0 +1,141 @@ +/** + * SSE endpoint for streaming chat responses. + * + * POST /api/chat/stream + * Body: { threadId: string, content: string } + * Headers: x-logto-id (required), x-logto-email, x-logto-name + * + * Responds with Server-Sent Events following the ChatSSEEvent protocol. + */ + +import { Router, type Request, type Response } from "express"; +import type { User } from "kal-shared"; + +import { getDB } from "../lib/db.js"; +import { runChatWorkflow } from "../lib/chat-workflow.js"; + +export const chatStreamRouter: Router = Router(); + +/** + * Resolve user from x-logto-id header (same logic as context.ts). + * Returns null if not authenticated. + */ +async function resolveUser(req: Request): Promise<{ logtoId: string } | null> { + const logtoId = req.headers["x-logto-id"] as string | undefined; + if (!logtoId) return null; + + const db = getDB(); + + // Try to find user or auto-create (mirrors context.ts behaviour) + let user = await db.collection("users").findOne({ logtoId }); + + if (!user) { + const email = (req.headers["x-logto-email"] as string) || null; + const name = (req.headers["x-logto-name"] as string) || undefined; + const now = new Date(); + + const result = await db.collection("users").findOneAndUpdate( + { logtoId }, + { + $set: { + email, + ...(name ? { name } : {}), + updatedAt: now, + }, + $setOnInsert: { + logtoId, + tier: "free", + createdAt: now, + }, + }, + { upsert: true, returnDocument: "after" } + ); + user = result as unknown as User | null; + } + + return user ? { logtoId: user.logtoId } : null; +} + +// ── POST /api/chat/stream ── +chatStreamRouter.post("/stream", async (req: Request, res: Response) => { + // --- Auth --- + const user = await resolveUser(req); + if (!user) { + res.status(401).json({ error: "Authentication required" }); + return; + } + + // --- Validate body --- + const { threadId, content } = req.body as { + threadId?: string; + content?: string; + }; + + if (!threadId || typeof threadId !== "string") { + res.status(400).json({ error: "threadId is required" }); + return; + } + if (!content || typeof content !== "string" || content.trim().length === 0) { + res.status(400).json({ error: "content is required" }); + return; + } + if (content.length > 10000) { + res.status(400).json({ error: "content exceeds maximum length (10000)" }); + return; + } + + // --- Setup SSE headers --- + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", // Disable nginx buffering + }); + + // Flush headers immediately + res.flushHeaders(); + + // Handle client disconnect + let aborted = false; + req.on("close", () => { + aborted = true; + }); + + // --- Run workflow and stream events --- + const db = getDB(); + + try { + const workflow = runChatWorkflow({ + threadId, + content: content.trim(), + userId: user.logtoId, + db, + }); + + for await (const event of workflow) { + if (aborted) break; + + // Write SSE: event type + JSON data + res.write(`event: ${event.type}\n`); + res.write(`data: ${JSON.stringify(event)}\n\n`); + } + } catch (error) { + console.error("[SSE] Workflow error:", error); + + if (!aborted) { + const errorEvent = JSON.stringify({ + type: "error", + message: "Internal server error", + }); + res.write(`event: error\n`); + res.write(`data: ${errorEvent}\n\n`); + + res.write(`event: done\n`); + res.write(`data: ${JSON.stringify({ type: "done" })}\n\n`); + } + } finally { + if (!aborted) { + res.end(); + } + } +}); diff --git a/packages/kal-baml/baml_src/clients/claude.baml b/packages/kal-baml/baml_src/clients/claude.baml new file mode 100644 index 0000000..36233e3 --- /dev/null +++ b/packages/kal-baml/baml_src/clients/claude.baml @@ -0,0 +1,9 @@ +// Claude Haiku 4.5 via Pika AI proxy +client Claude { + provider anthropic + options { + base_url env.PIKA_BASE_URL + api_key env.PIKA_API_KEY + model "claude-haiku-4.5" + } +} diff --git a/packages/kal-baml/baml_src/clients/glm.baml b/packages/kal-baml/baml_src/clients/glm.baml deleted file mode 100644 index 4297b5e..0000000 --- a/packages/kal-baml/baml_src/clients/glm.baml +++ /dev/null @@ -1,21 +0,0 @@ -// GLM 4.6V FlashX client configuration for Zhipu AI (cheapest option) -client GLM46 { - provider openai-generic - options { - base_url env.GLM_API_BASE_URL - api_key env.GLM_API_KEY - model "glm-4.6v-flashx" - default_role "user" - headers { - "Content-Type" "application/json" - } - } -} - -// Fallback client for resilience -client GLM46WithFallback { - provider fallback - options { - strategy [GLM46] - } -} diff --git a/packages/kal-baml/baml_src/functions/agent.baml b/packages/kal-baml/baml_src/functions/agent.baml new file mode 100644 index 0000000..65cfe28 --- /dev/null +++ b/packages/kal-baml/baml_src/functions/agent.baml @@ -0,0 +1,202 @@ +// ============================================ +// Agent Workflow Functions +// Database-first food response generation +// ============================================ + +// Generate a response using ONLY data from the Kalori database +// This is the core function for food queries — it prevents hallucinated nutrition numbers +function FormatFoodResponse( + user_message: string, + food_data: string, + has_db_results: bool, + conversation_context: string? +) -> string { + client Claude + prompt #" + {{ _.role("system") }} + You are Kal, a Malaysian food nutrition assistant powered by the Kalori database. + + STRICT RULES: + 1. If food_data is provided with has_db_results=true, your response MUST use ONLY the numbers from that data. Do NOT invent, round, or adjust any calorie/protein/carbs/fat values. + 2. Present the data clearly with the food name, serving size, and all macros. + 3. If a food has source "halal", mention it is halal-certified. Include the certifier and year if available. + 4. If a food has source "natural", present it as from the natural foods database. + 5. If a food has source "estimated", clearly mark it: "(AI estimate — not verified in our database)" + 6. If has_db_results=false, tell the user the food was not found in the Kalori database, then provide an AI estimate clearly labeled as such. + 7. Always respond in English. Be friendly and concise. + 8. Use markdown formatting: **bold** for headers, bullet points (- item) for data lists. + 9. If multiple results are returned, show the most relevant ones (max 5). + 10. End with a brief helpful tip or offer to look up related foods. + 11. Do NOT add disclaimers about consulting nutritionists unless the user asks for medical advice. + 12. When showing nutrition, always use this format per food item: + **Food Name** (serving size) + - Calories: X kcal + - Protein: Xg + - Carbs: Xg + - Fat: Xg + + {% if conversation_context %} + Recent conversation: + {{ conversation_context }} + {% endif %} + + {% if food_data %} + DATABASE RESULTS: + {{ food_data }} + {% endif %} + + {{ _.role("user") }} + {{ user_message }} + + {{ _.role("assistant") }} + "# +} + +// Generate a response for recipe queries with nutrition from the database +function FormatRecipeResponse( + user_message: string, + recipe_data: string?, + ingredient_nutrition: string?, + conversation_context: string? +) -> string { + client Claude + prompt #" + {{ _.role("system") }} + You are Kal, a Malaysian cooking and nutrition expert. + + RULES: + 1. When providing a recipe, include clear ingredients and step-by-step instructions. + 2. Use markdown: ### for recipe title, **bold** for section headers, numbered lists for steps, bullet lists for ingredients. + 3. Focus on Malaysian and halal-friendly recipes. + 4. If ingredient_nutrition is provided, include a nutrition summary section at the end using ONLY those numbers. + 5. If some ingredients show source "estimated", note that some nutrition values are AI estimates. + 6. Always respond in English. Be friendly and practical. + 7. Keep recipes simple and achievable with common Malaysian ingredients. + + {% if conversation_context %} + Recent conversation: + {{ conversation_context }} + {% endif %} + + {% if recipe_data %} + Recipe context: + {{ recipe_data }} + {% endif %} + + {% if ingredient_nutrition %} + INGREDIENT NUTRITION FROM DATABASE: + {{ ingredient_nutrition }} + {% endif %} + + {{ _.role("user") }} + {{ user_message }} + + {{ _.role("assistant") }} + "# +} + +// Generate a response for API help/documentation questions +// The system prompt contains the full API reference so the AI gives accurate endpoint info +function FormatApiHelpResponse( + user_message: string, + conversation_context: string? +) -> string { + client Claude + prompt #" + {{ _.role("system") }} + You are Kal, the AI assistant for the Kalori API — a Malaysian food nutrition API. + You have COMPLETE knowledge of the Kalori API. Answer developer questions accurately using ONLY the information below. + + RULES: + 1. Always give specific, accurate endpoint URLs and parameters from the reference below. + 2. Include working curl examples when showing endpoints. + 3. Use markdown formatting: code blocks for URLs/curl/JSON, tables for parameters, bold for emphasis. + 4. If the user mentions a specific food (e.g. "nasi lemak"), show them the exact search endpoint with that food URL-encoded in the query. + 5. Always mention they need an API key and where to get one. + 6. Be concise and developer-friendly. No filler. + 7. Always respond in English. + 8. If asking about something NOT covered by the API (e.g. write endpoints, user auth), say so clearly. + + ═══════════════════════════════════════════ + 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. + + ═══════════════════════════════════════════ + + {% if conversation_context %} + Recent conversation: + {{ conversation_context }} + {% endif %} + + {{ _.role("user") }} + {{ user_message }} + + {{ _.role("assistant") }} + "# +} diff --git a/packages/kal-baml/baml_src/functions/analysis.baml b/packages/kal-baml/baml_src/functions/analysis.baml index f0bb56c..a8ffc7d 100644 --- a/packages/kal-baml/baml_src/functions/analysis.baml +++ b/packages/kal-baml/baml_src/functions/analysis.baml @@ -4,7 +4,7 @@ // Analyze food from text description function AnalyzeFood(description: string) -> NutritionAnalysis { - client GLM46 + client Claude prompt #" {{ _.role("system") }} You are a nutrition expert. Analyze the food described and provide @@ -23,7 +23,7 @@ function EstimateNutrition( food_name: string, quantity: string? ) -> NutritionItem { - client GLM46 + client Claude prompt #" {{ _.role("system") }} You are a nutrition expert. Estimate the nutrition for this food item. diff --git a/packages/kal-baml/baml_src/functions/chat.baml b/packages/kal-baml/baml_src/functions/chat.baml index 178126c..b6fb1fe 100644 --- a/packages/kal-baml/baml_src/functions/chat.baml +++ b/packages/kal-baml/baml_src/functions/chat.baml @@ -4,7 +4,7 @@ // Simple chat completion function Chat(messages: ChatMessage[], system_prompt: string?) -> ChatResponse { - client GLM46 + client Claude prompt #" {{ _.role("system") }} {{ system_prompt | default("You are a helpful AI assistant. Always respond in English only.") }} @@ -21,7 +21,7 @@ function Chat(messages: ChatMessage[], system_prompt: string?) -> ChatResponse { // Chat with structured response function StructuredChat(messages: ChatMessage[], system_prompt: string?, extract_sources: bool, suggest_followups: bool) -> StructuredChatResponse { - client GLM46 + client Claude prompt #" {{ _.role("system") }} {{ system_prompt | default("You are a helpful AI assistant. Always respond in English only.") }} @@ -47,7 +47,7 @@ function StructuredChat(messages: ChatMessage[], system_prompt: string?, extract // Single-turn quick chat function QuickChat(user_message: string, system_prompt: string?) -> string { - client GLM46 + client Claude prompt #" {{ _.role("system") }} {{ system_prompt | default("You are a helpful AI assistant. Be concise and direct. Always respond in English only.") }} diff --git a/packages/kal-baml/baml_src/functions/recipe.baml b/packages/kal-baml/baml_src/functions/recipe.baml index a0df118..c6312a0 100644 --- a/packages/kal-baml/baml_src/functions/recipe.baml +++ b/packages/kal-baml/baml_src/functions/recipe.baml @@ -5,7 +5,7 @@ // Parse a recipe text and extract ingredients function ParseRecipe(recipe_text: string) -> ParsedRecipe { - client GLM46 + client Claude prompt #" {{ _.role("system") }} You are a recipe parser. Extract the recipe name and ingredients from the text. @@ -33,7 +33,7 @@ function GenerateRecipe( preferences: string?, dietary_restrictions: string? ) -> ParsedRecipe { - client GLM46 + client Claude prompt #" {{ _.role("system") }} You are a Malaysian cooking expert. Generate a simple, practical recipe. @@ -62,7 +62,7 @@ function GenerateRecipe( // Extract food search terms from any text (improved version) function ExtractFoodSearchTerm(user_message: string) -> string { - client GLM46 + client Claude prompt #" {{ _.role("system") }} You are a food search term extractor for a Malaysian nutrition database. @@ -94,7 +94,7 @@ function ExtractFoodSearchTerm(user_message: string) -> string { // Normalize ingredient name for database search // This function cleans up recipe ingredient names to match database entries function NormalizeIngredientName(ingredient_name: string) -> string { - client GLM46 + client Claude prompt #" {{ _.role("system") }} You are a food database search optimizer. Convert recipe ingredient names to simple, searchable food names that would match a nutrition database. @@ -133,7 +133,7 @@ function SummarizeNutrition( ingredients_data: string, servings: int? ) -> NutritionSummary { - client GLM46 + client Claude prompt #" {{ _.role("system") }} You are a nutrition calculator. Given ingredient nutrition data, calculate totals. diff --git a/packages/kal-baml/baml_src/functions/stream-chat.baml b/packages/kal-baml/baml_src/functions/stream-chat.baml new file mode 100644 index 0000000..97b4cb7 --- /dev/null +++ b/packages/kal-baml/baml_src/functions/stream-chat.baml @@ -0,0 +1,27 @@ +// ============================================ +// Streaming-optimized chat functions +// Used for non-food general conversation queries +// ============================================ + +// General streaming chat for greetings, general questions, etc. +function StreamChat( + user_message: string, + system_prompt: string?, + conversation_context: string? +) -> string { + client Claude + prompt #" + {{ _.role("system") }} + {{ system_prompt | default("You are Kal, a friendly Malaysian food nutrition AI assistant. Always respond in English. Be concise and helpful. If the user asks about food nutrition, let them know you can look up accurate data from the Kalori database.") }} + + {% if conversation_context %} + Recent conversation: + {{ conversation_context }} + {% endif %} + + {{ _.role("user") }} + {{ user_message }} + + {{ _.role("assistant") }} + "# +} diff --git a/packages/kal-baml/baml_src/functions/thinking.baml b/packages/kal-baml/baml_src/functions/thinking.baml index 577ea94..529f6bb 100644 --- a/packages/kal-baml/baml_src/functions/thinking.baml +++ b/packages/kal-baml/baml_src/functions/thinking.baml @@ -8,20 +8,32 @@ function ClassifyIntent( user_message: string, conversation_context: string? ) -> IntentClassification { - client GLM46 + client Claude prompt #" {{ _.role("system") }} You are an intent classifier for a nutrition and food assistant app called Kalori. Analyze the user's message and classify their intent. Available intents: - - FoodQuery: User is asking about nutrition/calories of a specific food - - RecipeRequest: User wants a recipe or cooking instructions - - RecipeNutrition: User wants to know nutrition of a recipe they provided or we suggested - - GeneralChat: General conversation not about food - - Greeting: Simple hello/hi/thanks + - FoodQuery: User is asking about nutrition/calories of a specific food. This includes ANY mention of a food item even without explicit nutrition keywords (e.g. "nasi lemak", "tell me about roti canai", "what about ayam goreng"). + - RecipeRequest: User wants a recipe or cooking instructions (e.g. "how to cook", "recipe for", "cara masak") + - RecipeNutrition: User wants to know nutrition of a recipe they provided or we suggested (e.g. "nutrition for this recipe", "calories of those ingredients") + - ApiHelp: User is asking about API endpoints, how to integrate Kalori data into their own app, API documentation, API keys, or developer-related questions (e.g. "give me the API endpoint", "how do I use the API", "I want to fetch nasi lemak data in my app", "API for food data", "how to get API key", "show me the docs") + - GeneralChat: General conversation not about food (e.g. "what can you do?", "tell me a joke") + - Greeting: Simple hello/hi/thanks/bye - Unknown: Cannot determine intent + IMPORTANT classification rules: + - If the message mentions ANY specific food name (Malaysian or international), classify as FoodQuery even without words like "calories" or "nutrition" + - EXCEPTION: If the message mentions food in the context of API/endpoint/integration (e.g. "API endpoint for nasi lemak", "fetch nasi lemak data"), classify as ApiHelp NOT FoodQuery + - Malaysian food names to watch for: nasi, mee, roti, ayam, ikan, daging, sayur, kuih, teh, kopi, milo, sambal, rendang, laksa, satay, char kuey teow, etc. + - Brand/restaurant names count as FoodQuery: Ramly, McD, KFC, Starbucks, Tealive, etc. + - "How healthy is X" or "is X good for diet" = FoodQuery + - "API endpoint for X" or "how to get X data in my app" = ApiHelp + - Set requires_api_lookup=true for ALL FoodQuery and RecipeNutrition intents + - Set requires_recipe_parse=true for RecipeRequest and RecipeNutrition intents + - Extract ALL food terms mentioned, even partial matches + {% if conversation_context %} Recent conversation context: {{ conversation_context }} @@ -41,7 +53,7 @@ function Think( context: string?, available_data: string? ) -> ThinkingResult { - client GLM46 + client Claude prompt #" {{ _.role("system") }} You are a thoughtful AI assistant. Think through the problem step by step. @@ -64,14 +76,14 @@ function Think( "# } -// Smart chat that uses conversation history +// Smart chat that uses conversation history (kept for backward compatibility) function SmartChat( messages: ChatMessage[], system_prompt: string?, food_context: string?, thread_summary: string? ) -> string { - client GLM46 + client Claude prompt #" {{ _.role("system") }} {{ system_prompt | default("You are Kal, a helpful AI nutrition assistant. Always respond in English only. Be friendly and helpful.") }} diff --git a/packages/kal-baml/baml_src/types/chat.baml b/packages/kal-baml/baml_src/types/chat.baml index f55b624..685a217 100644 --- a/packages/kal-baml/baml_src/types/chat.baml +++ b/packages/kal-baml/baml_src/types/chat.baml @@ -91,6 +91,7 @@ enum UserIntent { FoodQuery // Asking about food nutrition RecipeRequest // Asking for a recipe RecipeNutrition // Asking about nutrition of a recipe + ApiHelp // Asking about API endpoints, integration, or docs GeneralChat // General conversation Greeting // Simple greeting Unknown // Unable to classify diff --git a/packages/kal-baml/src/chat.ts b/packages/kal-baml/src/chat.ts index cb0e7f3..060a2aa 100644 --- a/packages/kal-baml/src/chat.ts +++ b/packages/kal-baml/src/chat.ts @@ -1,4 +1,4 @@ -import { b } from '../baml_client/baml_client/index.js'; +import { b } from "../baml_client/baml_client/index.js"; import { Role, UserIntent, @@ -11,14 +11,14 @@ import { type ParsedRecipe, type IntentClassification, type ThinkingResult, -} from '../baml_client/baml_client/types.js'; +} from "../baml_client/baml_client/types.js"; // ============================================ // Type Exports // ============================================ export interface ChatInput { - messages: Array<{ role: 'User' | 'Assistant' | 'System'; content: string }>; + messages: Array<{ role: "User" | "Assistant" | "System"; content: string }>; systemPrompt?: string; } @@ -29,7 +29,7 @@ export interface ChatResult { } export interface SmartChatInput { - messages: Array<{ role: 'User' | 'Assistant' | 'System'; content: string }>; + messages: Array<{ role: "User" | "Assistant" | "System"; content: string }>; systemPrompt?: string; foodContext?: string; threadSummary?: string; @@ -39,10 +39,8 @@ export interface SmartChatInput { // Helper Functions // ============================================ - - function toMessages( - messages: Array<{ role: 'User' | 'Assistant' | 'System'; content: string }> + messages: Array<{ role: "User" | "Assistant" | "System"; content: string }> ): ChatMessage[] { return messages.map((m) => ({ role: m.role.toLowerCase(), @@ -63,10 +61,10 @@ export async function chat(input: ChatInput): Promise { const response = await b.Chat(messages, input.systemPrompt ?? null); return { success: true, data: response }; } catch (error) { - console.error('[kal-baml] Chat error:', error); + console.error("[kal-baml] Chat error:", error); return { success: false, - error: error instanceof Error ? error.message : 'Unknown error', + error: error instanceof Error ? error.message : "Unknown error", }; } } @@ -121,12 +119,12 @@ export async function* streamQuickChat( const stream = b.stream.QuickChat(message, systemPrompt ?? null); for await (const chunk of stream) { - if (chunk && typeof chunk === 'string') { + if (chunk && typeof chunk === "string") { yield chunk; } } } catch (error) { - console.error('[kal-baml] Stream error:', error); + console.error("[kal-baml] Stream error:", error); throw error; } } @@ -190,7 +188,7 @@ export async function extractFoodSearchTerm(message: string): Promise { const term = await b.ExtractFoodSearchTerm(message); return term.trim().toLowerCase(); } catch (error) { - console.error('[kal-baml] Extract food term error:', error); + console.error("[kal-baml] Extract food term error:", error); return message; } } @@ -200,12 +198,14 @@ export async function extractFoodSearchTerm(message: string): Promise { * Removes preparation words (patty, slice), cooking methods (fried, grilled), * and state words (fresh, frozen) while keeping brand names and core food identity */ -export async function normalizeIngredientName(ingredientName: string): Promise { +export async function normalizeIngredientName( + ingredientName: string +): Promise { try { const normalized = await b.NormalizeIngredientName(ingredientName); return normalized.trim().toLowerCase(); } catch (error) { - console.error('[kal-baml] Normalize ingredient error:', error); + console.error("[kal-baml] Normalize ingredient error:", error); return ingredientName.toLowerCase(); } } @@ -248,6 +248,160 @@ export async function summarizeNutrition( ); } +// ============================================ +// Agent Workflow Functions (NEW) +// ============================================ + +/** + * Format a food response using ONLY database data. + * The prompt is strict: it will not hallucinate nutrition numbers. + */ +export async function formatFoodResponse(params: { + userMessage: string; + foodData: string; + hasDbResults: boolean; + conversationContext?: string; +}): Promise { + return await b.FormatFoodResponse( + params.userMessage, + params.foodData, + params.hasDbResults, + params.conversationContext ?? null + ); +} + +/** + * Stream a food response using ONLY database data. + * Yields partial strings as the AI generates them. + */ +export async function* streamFormatFoodResponse(params: { + userMessage: string; + foodData: string; + hasDbResults: boolean; + conversationContext?: string; +}): AsyncGenerator { + const stream = b.stream.FormatFoodResponse( + params.userMessage, + params.foodData, + params.hasDbResults, + params.conversationContext ?? null + ); + + for await (const chunk of stream) { + if (chunk && typeof chunk === "string") { + yield chunk; + } + } +} + +/** + * Format a recipe response with optional ingredient nutrition from DB. + */ +export async function formatRecipeResponse(params: { + userMessage: string; + recipeData?: string; + ingredientNutrition?: string; + conversationContext?: string; +}): Promise { + return await b.FormatRecipeResponse( + params.userMessage, + params.recipeData ?? null, + params.ingredientNutrition ?? null, + params.conversationContext ?? null + ); +} + +/** + * Stream a recipe response with optional ingredient nutrition from DB. + */ +export async function* streamFormatRecipeResponse(params: { + userMessage: string; + recipeData?: string; + ingredientNutrition?: string; + conversationContext?: string; +}): AsyncGenerator { + const stream = b.stream.FormatRecipeResponse( + params.userMessage, + params.recipeData ?? null, + params.ingredientNutrition ?? null, + params.conversationContext ?? null + ); + + for await (const chunk of stream) { + if (chunk && typeof chunk === "string") { + yield chunk; + } + } +} + +/** + * Stream a general chat response (non-food queries). + */ +export async function* streamGeneralChat(params: { + userMessage: string; + systemPrompt?: string; + conversationContext?: string; +}): AsyncGenerator { + const stream = b.stream.StreamChat( + params.userMessage, + params.systemPrompt ?? null, + params.conversationContext ?? null + ); + + for await (const chunk of stream) { + if (chunk && typeof chunk === "string") { + yield chunk; + } + } +} + +/** + * Non-streaming general chat response (non-food queries). + */ +export async function generalChat(params: { + userMessage: string; + systemPrompt?: string; + conversationContext?: string; +}): Promise { + return await b.StreamChat( + params.userMessage, + params.systemPrompt ?? null, + params.conversationContext ?? null + ); +} + +/** + * Format an API help response with full Kalori API reference. + */ +export async function formatApiHelpResponse(params: { + userMessage: string; + conversationContext?: string; +}): Promise { + return await b.FormatApiHelpResponse( + params.userMessage, + params.conversationContext ?? null + ); +} + +/** + * Stream an API help response with full Kalori API reference. + */ +export async function* streamFormatApiHelpResponse(params: { + userMessage: string; + conversationContext?: string; +}): AsyncGenerator { + const stream = b.stream.FormatApiHelpResponse( + params.userMessage, + params.conversationContext ?? null + ); + + for await (const chunk of stream) { + if (chunk && typeof chunk === "string") { + yield chunk; + } + } +} + // ============================================ // Re-export types and enums // ============================================ diff --git a/packages/kal-baml/src/index.ts b/packages/kal-baml/src/index.ts index b1c59d8..36aa64c 100644 --- a/packages/kal-baml/src/index.ts +++ b/packages/kal-baml/src/index.ts @@ -1,5 +1,5 @@ // Re-export all BAML generated types and functions -export * from '../baml_client/baml_client/index.js'; +export * from "../baml_client/baml_client/index.js"; // Export convenience wrapper functions export { @@ -9,6 +9,15 @@ export { smartChat, streamQuickChat, structuredChat, + // Agent workflow (NEW) + formatFoodResponse, + streamFormatFoodResponse, + formatRecipeResponse, + streamFormatRecipeResponse, + streamGeneralChat, + generalChat, + formatApiHelpResponse, + streamFormatApiHelpResponse, // Thinking & Intent classifyIntent, think, @@ -24,7 +33,7 @@ export { // Enums Role, UserIntent, -} from './chat.js'; +} from "./chat.js"; // Export types export type { @@ -40,4 +49,4 @@ export type { ParsedRecipe, IntentClassification, ThinkingResult, -} from './chat.js'; +} from "./chat.js"; diff --git a/packages/kal-frontend/package.json b/packages/kal-frontend/package.json index f1cb9dd..62c9655 100644 --- a/packages/kal-frontend/package.json +++ b/packages/kal-frontend/package.json @@ -21,7 +21,9 @@ "react": "^19.0.0", "react-chartjs-2": "^5.3.1", "react-dom": "^19.0.0", - "react-feather": "^2.0.10" + "react-feather": "^2.0.10", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@types/node": "^22.10.2", diff --git a/packages/kal-frontend/src/app/globals.css b/packages/kal-frontend/src/app/globals.css index 852f4a3..5fde71c 100644 --- a/packages/kal-frontend/src/app/globals.css +++ b/packages/kal-frontend/src/app/globals.css @@ -16,6 +16,27 @@ * { box-sizing: border-box; + /* Seamless dark scrollbar everywhere */ + scrollbar-width: thin; + scrollbar-color: var(--border-color) transparent; +} + +*::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 9999px; +} + +*::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); } body { @@ -669,8 +690,60 @@ html { display: none; } .scrollbar-hide { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + +/* Thin seamless scrollbar for chat widget */ +.chat-scrollbar { + scrollbar-width: thin; /* Firefox */ + scrollbar-color: var(--border-color) transparent; +} +.chat-scrollbar::-webkit-scrollbar { + width: 4px; +} +.chat-scrollbar::-webkit-scrollbar-track { + background: transparent; +} +.chat-scrollbar::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 9999px; +} +.chat-scrollbar::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* Gradient green chat icon */ +.chat-gradient-icon svg { + stroke: url(#chat-icon-gradient); +} + +/* Chat markdown — list styling */ +.chat-md-ul { + list-style: none; +} +.chat-md-ul > .chat-md-li::before { + content: "•"; + color: var(--accent-green); + font-weight: bold; + display: inline-block; + width: 1em; + margin-left: -0.25em; +} +.chat-md-ol { + list-style: decimal; + list-style-position: inside; +} +.chat-md-ol > .chat-md-li::marker { + color: var(--text-muted); +} + +/* Chat markdown — remove spacing from last child in nested contexts */ +.chat-markdown > :first-child { + margin-top: 0; +} +.chat-markdown > :last-child { + margin-bottom: 0; } /* ============================================ @@ -764,7 +837,9 @@ html { padding: 0.25rem; margin: -0.25rem; border-radius: 0.25rem; - transition: color 0.2s, background-color 0.2s; + transition: + color 0.2s, + background-color 0.2s; } .toast-close:hover { @@ -875,7 +950,7 @@ html { right: 1rem; width: auto; } - + .toast { padding: 0.875rem; } @@ -894,7 +969,7 @@ html { --grid-min-md: 280px; --grid-min-lg: 320px; --grid-min-xl: 400px; - + /* Responsive max-widths */ --container-lg: 1024px; --container-xl: 1280px; diff --git a/packages/kal-frontend/src/app/layout.tsx b/packages/kal-frontend/src/app/layout.tsx index 0cb1aca..6f41bc6 100644 --- a/packages/kal-frontend/src/app/layout.tsx +++ b/packages/kal-frontend/src/app/layout.tsx @@ -2,7 +2,9 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; +import { ChatWidget } from "@/components/chat/ChatWidget"; import { ToastContainer } from "@/components/ui/Toast"; +import { ChatPanelProvider } from "@/contexts/ChatPanelContext"; import { ToastProvider } from "@/contexts/ToastContext"; import { AuthProvider } from "@/lib/auth-context"; import { TRPCProvider } from "@/lib/trpc-provider"; @@ -82,7 +84,18 @@ export default function RootLayout({ - {children} + + +
+ {/* Main content — scrollable, takes remaining space */} +
+ {children} +
+ {/* Right side: activity bar + chat panel (auth-gated internally) */} + +
+
+
@@ -90,4 +103,3 @@ export default function RootLayout({ ); } - diff --git a/packages/kal-frontend/src/components/chat/ChatActivityBar.tsx b/packages/kal-frontend/src/components/chat/ChatActivityBar.tsx new file mode 100644 index 0000000..93b9e32 --- /dev/null +++ b/packages/kal-frontend/src/components/chat/ChatActivityBar.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { MessageCircle } from "react-feather"; + +import { useChatPanel } from "@/contexts/ChatPanelContext"; +import { useAuth } from "@/lib/auth-context"; + +/** + * Thin right-side activity bar with a chat icon. + * Always visible on desktop for authenticated users. + * Hidden on mobile (mobile uses a FAB instead). + */ +export function ChatActivityBar() { + const { logtoId } = useAuth(); + const { isOpen, toggle } = useChatPanel(); + + // Only show for authenticated users + if (!logtoId) return null; + + return ( +
+ +
+ ); +} diff --git a/packages/kal-frontend/src/components/chat/ChatMessage.tsx b/packages/kal-frontend/src/components/chat/ChatMessage.tsx new file mode 100644 index 0000000..1639861 --- /dev/null +++ b/packages/kal-frontend/src/components/chat/ChatMessage.tsx @@ -0,0 +1,332 @@ +"use client"; + +import { + memo, + useState, + useCallback, + type ComponentPropsWithoutRef, +} from "react"; +import { User, Copy, Check } from "react-feather"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +interface ChatMessageProps { + role: "User" | "Assistant"; + content: string; + createdAt?: Date; + /** When true, shows a blinking cursor after the content (streaming in progress) */ + streaming?: boolean; +} + +function formatTime(date: Date): string { + return new Date(date).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); +} + +/** Small copy button for code blocks */ +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }, [text]); + + return ( + + ); +} + +/** Custom component overrides for ReactMarkdown — dark theme styling */ +const markdownComponents = { + // Headings + h1: ({ children, ...props }: ComponentPropsWithoutRef<"h1">) => ( +

+ {children} +

+ ), + h2: ({ children, ...props }: ComponentPropsWithoutRef<"h2">) => ( +

+ {children} +

+ ), + h3: ({ children, ...props }: ComponentPropsWithoutRef<"h3">) => ( +

+ {children} +

+ ), + h4: ({ children, ...props }: ComponentPropsWithoutRef<"h4">) => ( +

+ {children} +

+ ), + + // Paragraphs + p: ({ children, ...props }: ComponentPropsWithoutRef<"p">) => ( +

+ {children} +

+ ), + + // Strong / emphasis + strong: ({ children, ...props }: ComponentPropsWithoutRef<"strong">) => ( + + {children} + + ), + em: ({ children, ...props }: ComponentPropsWithoutRef<"em">) => ( + + {children} + + ), + + // Links + a: ({ children, href, ...props }: ComponentPropsWithoutRef<"a">) => ( + + {children} + + ), + + // Lists + ul: ({ children, ...props }: ComponentPropsWithoutRef<"ul">) => ( +
    + {children} +
+ ), + ol: ({ children, ...props }: ComponentPropsWithoutRef<"ol">) => ( +
    + {children} +
+ ), + li: ({ children, ...props }: ComponentPropsWithoutRef<"li">) => ( +
  • + {children} +
  • + ), + + // Horizontal rule + hr: (props: ComponentPropsWithoutRef<"hr">) => ( +
    + ), + + // Code — inline and block + code: ({ + children, + className, + ...props + }: ComponentPropsWithoutRef<"code">) => { + const isBlock = className?.includes("language-"); + + // Inline code + if (!isBlock) { + return ( + + {children} + + ); + } + + // Block code — rendered inside
     by ReactMarkdown
    +    return (
    +      
    +        {children}
    +      
    +    );
    +  },
    +  pre: ({ children, ...props }: ComponentPropsWithoutRef<"pre">) => {
    +    // Extract text content for the copy button
    +    const text = extractText(children);
    +
    +    return (
    +      
    +
    +          {children}
    +        
    + +
    + ); + }, + + // Block quotes + blockquote: ({ + children, + ...props + }: ComponentPropsWithoutRef<"blockquote">) => ( +
    + {children} +
    + ), + + // Tables + table: ({ children, ...props }: ComponentPropsWithoutRef<"table">) => ( +
    + + {children} +
    +
    + ), + thead: ({ children, ...props }: ComponentPropsWithoutRef<"thead">) => ( + + {children} + + ), + tbody: ({ children, ...props }: ComponentPropsWithoutRef<"tbody">) => ( + + {children} + + ), + tr: ({ children, ...props }: ComponentPropsWithoutRef<"tr">) => ( + + {children} + + ), + th: ({ children, ...props }: ComponentPropsWithoutRef<"th">) => ( + + {children} + + ), + td: ({ children, ...props }: ComponentPropsWithoutRef<"td">) => ( + + {children} + + ), +}; + +/** Recursively extract text content from React children (for copy button) */ +function extractText(node: React.ReactNode): string { + if (typeof node === "string") return node; + if (typeof node === "number") return String(node); + if (!node) return ""; + if (Array.isArray(node)) return node.map(extractText).join(""); + if (typeof node === "object" && "props" in node) { + return extractText( + (node as { props: { children?: React.ReactNode } }).props.children + ); + } + return ""; +} + +export const ChatMessage = memo(function ChatMessage({ + role, + content, + createdAt, + streaming, +}: ChatMessageProps) { + const isUser = role === "User"; + + return ( +
    + {/* Avatar */} +
    + {isUser ? ( + + ) : ( + K + )} +
    + + {/* Bubble */} +
    + {isUser ? ( + content + ) : ( +
    + + {content} + +
    + )} + {streaming && ( + + )} + + {createdAt && !streaming && ( +
    + {formatTime(createdAt)} +
    + )} +
    +
    + ); +}); + +/** Animated typing indicator shown while waiting for AI response. */ +export function TypingIndicator() { + return ( +
    +
    + K +
    +
    +
    + + + +
    +
    +
    + ); +} diff --git a/packages/kal-frontend/src/components/chat/ChatPanel.tsx b/packages/kal-frontend/src/components/chat/ChatPanel.tsx new file mode 100644 index 0000000..201aff1 --- /dev/null +++ b/packages/kal-frontend/src/components/chat/ChatPanel.tsx @@ -0,0 +1,487 @@ +"use client"; + +import { + useState, + useMemo, + useRef, + useCallback, + useLayoutEffect, + type FormEvent, + type KeyboardEvent, +} from "react"; +import { + Send, + ChevronRight, + Plus, + Trash2, + ChevronLeft, + Menu, + MessageCircle, +} from "react-feather"; + +import { ChatMessage } from "./ChatMessage"; +import { ToolStepIndicator, type ToolStep } from "./ToolStepIndicator"; + +import { useToast } from "@/contexts/ToastContext"; +import { useAuth } from "@/lib/auth-context"; +import { sendChatStream } from "@/lib/chat-stream"; +import { trpc } from "@/lib/trpc"; + +interface Message { + _id: string; + role: "User" | "Assistant"; + content: string; + createdAt: Date; + streaming?: boolean; +} + +interface ChatPanelProps { + onClose: () => void; +} + +export function ChatPanel({ onClose }: ChatPanelProps) { + const [activeThreadId, setActiveThreadId] = useState(null); + const [optimisticMessages, setOptimisticMessages] = useState([]); + const [streamingContent, setStreamingContent] = useState(""); + const [toolSteps, setToolSteps] = useState([]); + const [isSending, setIsSending] = useState(false); + const [input, setInput] = useState(""); + const [showThreadList, setShowThreadList] = useState(false); + + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const abortRef = useRef(null); + const toast = useToast(); + const auth = useAuth(); + + // ---- tRPC hooks (threads + messages CRUD, no more sendMessage) ---- + const threadsQuery = trpc.chat.getThreads.useQuery( + { limit: 20 }, + { enabled: true } + ); + + const messagesQuery = trpc.chat.getMessages.useQuery( + { threadId: activeThreadId ?? "", limit: 50 }, + { + enabled: !!activeThreadId, + refetchOnWindowFocus: false, + } + ); + + const createThread = trpc.chat.createThread.useMutation({ + onSuccess: (thread) => { + setActiveThreadId(thread._id); + setShowThreadList(false); + threadsQuery.refetch(); + }, + onError: () => { + toast.error("Failed to create conversation"); + }, + }); + + const deleteThread = trpc.chat.deleteThread.useMutation({ + onSuccess: () => { + if (activeThreadId) { + setActiveThreadId(null); + } + threadsQuery.refetch(); + }, + onError: () => { + toast.error("Failed to delete conversation"); + }, + }); + + // Derive displayed messages: server data + optimistic overlay + streaming + const displayMessages = useMemo(() => { + const serverMessages: Message[] = (messagesQuery.data ?? []).map((m) => ({ + _id: m._id, + role: m.role, + content: m.content, + createdAt: new Date(m.createdAt), + })); + + const messages = [...serverMessages, ...optimisticMessages]; + + // If we're streaming, append the in-progress assistant message + if (streamingContent) { + messages.push({ + _id: "streaming", + role: "Assistant", + content: streamingContent, + createdAt: new Date(), + streaming: true, + }); + } + + return messages; + }, [messagesQuery.data, optimisticMessages, streamingContent]); + + // Auto-scroll to bottom when messages change or streaming updates + useLayoutEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [displayMessages, toolSteps]); + + // Auto-load the most recent thread on mount + const hasAutoLoaded = useRef(false); + if (threadsQuery.data && !hasAutoLoaded.current && !activeThreadId) { + hasAutoLoaded.current = true; + if (threadsQuery.data.length > 0) { + setActiveThreadId(threadsQuery.data[0]._id); + } + } + + // ---- SSE send handler ---- + const startStream = useCallback( + (threadId: string, content: string) => { + if (!auth.logtoId) { + toast.error("Not authenticated"); + return; + } + + setIsSending(true); + setToolSteps([]); + setStreamingContent(""); + + // Show optimistic user message + setOptimisticMessages([ + { + _id: `optimistic-${Date.now()}`, + role: "User", + content, + createdAt: new Date(), + }, + ]); + + const controller = sendChatStream( + { + threadId, + content, + logtoId: auth.logtoId, + email: auth.email, + name: auth.name, + }, + { + onToolStart: (tool, message) => { + setToolSteps((prev) => [ + ...prev, + { tool, message, status: "running" }, + ]); + }, + onToolEnd: (tool, message) => { + setToolSteps((prev) => + prev.map((s) => + s.tool === tool && s.status === "running" + ? { ...s, message, status: "done" } + : s + ) + ); + }, + onStreamStart: () => { + // Clear tool steps for generate_response since we're now streaming + setToolSteps((prev) => + prev.filter((s) => s.tool !== "generate_response") + ); + }, + onStreamDelta: (delta) => { + setStreamingContent((prev) => prev + delta); + }, + onStreamEnd: () => { + // Streaming complete — refetch real messages from server + setStreamingContent(""); + setOptimisticMessages([]); + setToolSteps([]); + setIsSending(false); + messagesQuery.refetch(); + threadsQuery.refetch(); + }, + onError: (message) => { + toast.error(message || "Failed to send message"); + setStreamingContent(""); + setOptimisticMessages([]); + setToolSteps([]); + setIsSending(false); + }, + onDone: () => { + // Final cleanup in case onStreamEnd didn't fire + setIsSending(false); + }, + } + ); + + abortRef.current = controller; + }, + [auth.logtoId, auth.email, auth.name, toast, messagesQuery, threadsQuery] + ); + + const handleSend = useCallback( + (e?: FormEvent) => { + e?.preventDefault(); + const trimmed = input.trim(); + if (!trimmed || isSending) return; + + setInput(""); + + if (activeThreadId) { + startStream(activeThreadId, trimmed); + } else { + // Auto-create thread on first message + createThread.mutate(undefined, { + onSuccess: (thread) => { + startStream(thread._id, trimmed); + }, + }); + } + }, + [input, activeThreadId, isSending, startStream, createThread] + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend] + ); + + const handleNewChat = useCallback(() => { + // Abort any in-progress stream + abortRef.current?.abort(); + setStreamingContent(""); + setOptimisticMessages([]); + setToolSteps([]); + setIsSending(false); + createThread.mutate(); + }, [createThread]); + + const handleSelectThread = useCallback((threadId: string) => { + abortRef.current?.abort(); + setActiveThreadId(threadId); + setOptimisticMessages([]); + setStreamingContent(""); + setToolSteps([]); + setIsSending(false); + setShowThreadList(false); + }, []); + + const handleDeleteThread = useCallback( + (threadId: string) => { + deleteThread.mutate({ threadId }); + }, + [deleteThread] + ); + + // ---- Thread list view ---- + if (showThreadList) { + return ( +
    + {/* Header */} +
    + +

    + Conversations +

    + +
    + + {/* Thread list */} +
    + {threadsQuery.isLoading && ( +
    + Loading... +
    + )} + {threadsQuery.data?.length === 0 && ( +
    + No conversations yet. +
    + Start a new chat! +
    + )} + {threadsQuery.data?.map((thread) => ( +
    + + +
    + ))} +
    +
    + ); + } + + // ---- Main chat view ---- + return ( +
    + {/* Header */} +
    +
    + +
    +

    + Kal Assistant +

    +

    + Malaysian food nutrition AI +

    +
    +
    +
    + + +
    +
    + + {/* Messages */} +
    + {/* Loading state */} + {messagesQuery.isLoading && activeThreadId && ( +
    +
    +
    + )} + + {/* Empty state */} + {displayMessages.length === 0 && + !messagesQuery.isLoading && + !isSending && ( +
    +
    + +
    +

    + Ask me anything! +

    +

    + I can help with Malaysian food nutrition, calories, recipes, and + healthy eating tips. +

    +
    + {[ + "Calories in nasi lemak?", + "Healthy roti canai recipe", + "Protein in ayam goreng", + ].map((suggestion) => ( + + ))} +
    +
    + )} + + {/* Message bubbles */} + {displayMessages.map((msg) => ( + + ))} + + {/* Tool step indicators (shown while workflow is running) */} + {toolSteps.length > 0 && !streamingContent && ( + + )} + +
    +
    + + {/* Input */} +
    +
    +