diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..c6db59c --- /dev/null +++ b/backend/README.md @@ -0,0 +1,37 @@ +# AgroDex Backend Services + +This directory contains the Node.js Express backend and documentation for the Supabase Edge Functions. + +## AI Chatbot Configuration + +The AI Chatbot relies on an Anthropic API Key to function correctly. The chatbot endpoint runs as a **Supabase Edge Function** (`supabase/functions/ai-chat/index.ts`). + +### How to configure the AI Chatbot + +1. **Get an API Key**: Obtain an API key from the [Anthropic Console](https://console.anthropic.com/). +2. **Set the Supabase Secret**: + Open your terminal and use the Supabase CLI to configure the secret for your local or production project: + ```bash + # Set the secret for the Edge Function + npx supabase secrets set ANTHROPIC_API_KEY=sk-ant-api03-... + ``` +3. **Restart the Edge Functions (Local)**: + If you are running the project locally, restart your edge functions to pick up the new secret: + ```bash + npx supabase functions serve + ``` +4. **Deploy (Production)**: + If deploying to production, make sure the secret is pushed and then deploy the function: + ```bash + npx supabase functions deploy ai-chat + ``` + +If the API key is missing, the chatbot widget in the frontend will gracefully display an error indicating that configuration is required. + +## Additional Variables + +If you are running local development, you should also copy `.env.example` to `.env` in the `backend/` directory: + +```bash +cp .env.example .env +``` diff --git a/src/App.tsx b/src/App.tsx index cf0d3b8..3a0b2c3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import ProtectedRoute from '@/components/ProtectedRoute'; import { HelmetProvider } from 'react-helmet-async'; import { DEMO_VERIFY_URL } from '@/lib/demo'; import { lazy, Suspense } from 'react'; +import { ChatbotWidget } from '@/components/chat/ChatbotWidget'; const Landing = lazy(() => import('./pages/Landing')); const Index = lazy(() => import('./pages/Index')); @@ -63,6 +64,7 @@ const App = () => ( } /> } /> + diff --git a/src/components/chat/ChatbotWidget.tsx b/src/components/chat/ChatbotWidget.tsx new file mode 100644 index 0000000..d7eafd5 --- /dev/null +++ b/src/components/chat/ChatbotWidget.tsx @@ -0,0 +1,177 @@ +import { useState } from 'react'; +import { MessageCircle, X, Send, Bot } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useAuth } from '@/contexts/AuthContext'; + +interface ChatMessage { + id: string; + role: 'user' | 'assistant'; + content: string; +} + +export function ChatbotWidget() { + const [isOpen, setIsOpen] = useState(false); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const { session } = useAuth(); + + const functionUrl = import.meta.env.VITE_SUPABASE_URL + ? `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/ai-chat` + : ''; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || isLoading) return; + + const userMessage: ChatMessage = { + id: Date.now().toString(), + role: 'user', + content: input.trim(), + }; + + setMessages(prev => [...prev, userMessage]); + setInput(''); + setIsLoading(true); + setError(null); + + try { + if (!functionUrl) { + throw new Error('Chat service is not configured. Please set VITE_SUPABASE_URL.'); + } + + const response = await fetch(functionUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}), + }, + body: JSON.stringify({ + messages: [...messages, userMessage].map(m => ({ + role: m.role, + content: m.content, + })), + }), + }); + + if (!response.ok) { + const errData = await response.json().catch(() => null); + let errorMsg = errData?.error || `Request failed with status ${response.status}`; + + if (errorMsg === 'ANTHROPIC_API_KEY not configured') { + errorMsg = 'AI is not configured. Maintainer must run: supabase secrets set ANTHROPIC_API_KEY=your_key'; + } + + throw new Error(errorMsg); + } + + const textResponse = await response.text(); + let assistantContent = ''; + + // Parse Vercel AI SDK stream format (e.g., '0:"Hello"\n') + const lines = textResponse.split('\n'); + for (const line of lines) { + if (line.startsWith('0:')) { + try { + const chunk = JSON.parse(line.substring(2)); + assistantContent += chunk; + } catch { + // Ignore malformed chunks + } + } + } + + if (!assistantContent) { + assistantContent = textResponse || 'Sorry, I could not generate a response.'; + } + + const assistantMessage: ChatMessage = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: assistantContent, + }; + + setMessages(prev => [...prev, assistantMessage]); + } catch (err) { + console.error('Chat error:', err); + setError(err instanceof Error ? err.message : 'An error occurred. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + if (!session) return null; // Only show chat to logged-in users + + return ( + <> + {!isOpen && ( + + )} + + {isOpen && ( + + +
+ + AgroDex AI +
+ +
+ + + + {messages.length === 0 && ( +
+ Hi! I'm your AgroDex assistant. Ask me anything about food auditing or the platform! +
+ )} + {messages.map(m => ( +
+
+ {m.content} +
+
+ ))} + {isLoading && ( +
+
+ Typing... +
+
+ )} + {error && ( +
+ {error} +
+ )} +
+ +
+ setInput(e.target.value)} + placeholder="Ask something..." + className="flex-1" + /> + +
+
+
+ )} + + ); +} diff --git a/supabase/functions/ai-chat/index.ts b/supabase/functions/ai-chat/index.ts new file mode 100644 index 0000000..87c1470 --- /dev/null +++ b/supabase/functions/ai-chat/index.ts @@ -0,0 +1,113 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0' +import Anthropic from 'npm:@anthropic-ai/sdk@^0.32.1' + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +} + +const MAX_DAILY_MESSAGES = 20000; + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }) + } + + try { + const authHeader = req.headers.get('Authorization') + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing Authorization header' }), { status: 401, headers: corsHeaders }) + } + + const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? '' + const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' + const supabase = createClient(supabaseUrl, supabaseServiceKey) + + // Verify User JWT + const token = authHeader.replace('Bearer ', '') + const { data: { user }, error: authError } = await supabase.auth.getUser(token) + + if (authError || !user) { + return new Response(JSON.stringify({ error: 'Invalid token' }), { status: 401, headers: corsHeaders }) + } + + const { messages } = await req.json() + if (!messages || !Array.isArray(messages)) { + return new Response(JSON.stringify({ error: 'Invalid messages format' }), { status: 400, headers: corsHeaders }) + } + + const today = new Date().toISOString().split('T')[0] + + // Check rate limit + const { data: usageData, error: usageError } = await supabase + .from('chat_usage') + .select('message_count') + .eq('user_id', user.id) + .eq('date', today) + .single() + + const currentCount = usageData?.message_count || 0 + + if (currentCount >= MAX_DAILY_MESSAGES) { + return new Response(JSON.stringify({ error: 'Daily limit reached' }), { status: 429, headers: corsHeaders }) + } + + // Increment usage + await supabase + .from('chat_usage') + .upsert({ user_id: user.id, date: today, message_count: currentCount + 1 }, { onConflict: 'user_id,date' }) + + const apiKey = Deno.env.get('ANTHROPIC_API_KEY') + if (!apiKey) { + return new Response(JSON.stringify({ error: 'ANTHROPIC_API_KEY not configured' }), { status: 500, headers: corsHeaders }) + } + + const anthropic = new Anthropic({ apiKey }) + + const systemPrompt = "You are a helpful assistant for AgroDex, an application that fights food fraud in Indonesia by pairing Hedera's immutable ledger with AI for real-time food auditing. Provide concise and clear answers." + + // Strip out unnecessary fields and convert to Anthropic format + const anthropicMessages = messages.map(m => ({ + role: m.role === 'user' ? 'user' : 'assistant', + content: m.content + })).filter(m => m.role === 'user' || m.role === 'assistant') + + const stream = await anthropic.messages.create({ + model: 'claude-3-haiku-20240307', + max_tokens: 1024, + system: systemPrompt, + messages: anthropicMessages, + stream: true, + }) + + // Create a readable stream for SSE (Server-Sent Events) to work with Vercel AI SDK + const encoder = new TextEncoder() + const readableStream = new ReadableStream({ + async start(controller) { + try { + for await (const chunk of stream) { + if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') { + const text = chunk.delta.text + // Vercel AI SDK expects format: 0:"text" + controller.enqueue(encoder.encode(`0:${JSON.stringify(text)}\n`)) + } + } + controller.close() + } catch (e) { + controller.error(e) + } + } + }) + + return new Response(readableStream, { + headers: { + ...corsHeaders, + 'Content-Type': 'text/plain; charset=utf-8', + 'X-Content-Type-Options': 'nosniff' + } + }) + } catch (error: any) { + console.error('Error in ai-chat:', error) + return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: corsHeaders }) + } +}) diff --git a/supabase/migrations/20260618_create_chat_usage.sql b/supabase/migrations/20260618_create_chat_usage.sql new file mode 100644 index 0000000..3a8a49c --- /dev/null +++ b/supabase/migrations/20260618_create_chat_usage.sql @@ -0,0 +1,30 @@ +-- Migration: Create chat_usage table for tracking Anthropic API rate limits + +CREATE TABLE IF NOT EXISTS public.chat_usage ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + date DATE NOT NULL DEFAULT CURRENT_DATE, + message_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, date) +); + +-- Enable RLS (Row Level Security) +ALTER TABLE public.chat_usage ENABLE ROW LEVEL SECURITY; + +-- Create policies +-- Service role (Edge Functions) can do everything +CREATE POLICY "Service role has full access to chat_usage" ON public.chat_usage + FOR ALL USING (auth.role() = 'service_role'); + +-- Users can only read their own usage (if we ever want to show them their limit) +CREATE POLICY "Users can view their own chat usage" ON public.chat_usage + FOR SELECT USING (auth.uid() = user_id); + +-- Create a function to increment the usage securely via RPC, +-- or we can just let the edge function update it directly since it uses service_role key. +-- Since the edge function will use the service_role key, it can just run a normal upsert. + +-- Create an index for faster lookups by user and date +CREATE INDEX IF NOT EXISTS idx_chat_usage_user_date ON public.chat_usage(user_id, date);