-
Notifications
You must be signed in to change notification settings - Fork 40
feat: Implement Anthropic AI Chatbot with Daily Rate Limiting #114
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kalyan-1845
wants to merge
16
commits into
daviddprtma:main
Choose a base branch
from
kalyan-1845:add-ai-chat
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
3e274c2
feat: Add Anthropic AI Chatbot with rate limiting (Fixes #15)
kalyan-1845 9bdcc9a
Merge branch 'main' into add-ai-chat
daviddprtma f90c1b6
Merge branch 'main' into add-ai-chat
daviddprtma f9aab5f
fix: resolve review comments (remove supabase, use npm)
kalyan-1845 992b7e3
Merge branch 'main' into add-ai-chat
daviddprtma 6f666ad
fix: resolve ai-sdk import and remove pnpm-lock
kalyan-1845 e684bd4
Merge branch 'main' into add-ai-chat
daviddprtma 316d249
fix: resolve chat icon error by replacing useChat with manual fetch
kalyan-1845 2e4e8a9
Merge branch 'main' into add-ai-chat
daviddprtma befcbb1
Merge branch 'main' into add-ai-chat
daviddprtma 52c9d00
Merge branch 'main' into add-ai-chat
daviddprtma 9b864c2
Merge branch 'main' into add-ai-chat
daviddprtma 28e6ad7
fix: resolve JSON parsing error for chatbot response stream
kalyan-1845 9e76e3f
fix: remove unused variable in catch block
kalyan-1845 799071c
Merge branch 'main' into add-ai-chat
daviddprtma 1c86984
docs: add instructions for configuring ANTHROPIC_API_KEY in backend R…
kalyan-1845 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ChatMessage[]>([]); | ||
| const [input, setInput] = useState(''); | ||
| const [isLoading, setIsLoading] = useState(false); | ||
| const [error, setError] = useState<string | null>(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 && ( | ||
| <Button | ||
| onClick={() => setIsOpen(true)} | ||
| className="fixed bottom-6 right-6 h-14 w-14 rounded-full shadow-lg p-0" | ||
| size="icon" | ||
| > | ||
| <MessageCircle className="h-6 w-6" /> | ||
| </Button> | ||
| )} | ||
|
|
||
| {isOpen && ( | ||
| <Card className="fixed bottom-6 right-6 w-80 sm:w-96 h-[500px] shadow-xl flex flex-col z-50 animate-in slide-in-from-bottom-5"> | ||
| <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3 border-b"> | ||
| <div className="flex items-center gap-2"> | ||
| <Bot className="h-5 w-5 text-emerald-500" /> | ||
| <CardTitle className="text-lg font-bold">AgroDex AI</CardTitle> | ||
| </div> | ||
| <Button variant="ghost" size="icon" onClick={() => setIsOpen(false)} className="h-8 w-8"> | ||
| <X className="h-4 w-4" /> | ||
| </Button> | ||
| </CardHeader> | ||
|
|
||
| <CardContent className="flex-1 flex flex-col p-4 overflow-hidden"> | ||
| <ScrollArea className="flex-1 pr-4 mb-4"> | ||
| {messages.length === 0 && ( | ||
| <div className="text-center text-muted-foreground mt-4 text-sm"> | ||
| Hi! I'm your AgroDex assistant. Ask me anything about food auditing or the platform! | ||
| </div> | ||
| )} | ||
| {messages.map(m => ( | ||
| <div key={m.id} className={`mb-4 flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}> | ||
| <div className={`rounded-lg px-3 py-2 max-w-[80%] text-sm ${m.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}> | ||
| {m.content} | ||
| </div> | ||
| </div> | ||
| ))} | ||
| {isLoading && ( | ||
| <div className="mb-4 flex justify-start"> | ||
| <div className="rounded-lg px-3 py-2 bg-muted text-sm animate-pulse"> | ||
| Typing... | ||
| </div> | ||
| </div> | ||
| )} | ||
| {error && ( | ||
| <div className="text-destructive text-sm text-center my-2"> | ||
| {error} | ||
| </div> | ||
| )} | ||
| </ScrollArea> | ||
|
|
||
| <form onSubmit={handleSubmit} className="flex gap-2 items-center"> | ||
| <Input | ||
| value={input} | ||
| onChange={(e) => setInput(e.target.value)} | ||
| placeholder="Ask something..." | ||
| className="flex-1" | ||
| /> | ||
| <Button type="submit" size="icon" disabled={isLoading || !input.trim()}> | ||
| <Send className="h-4 w-4" /> | ||
| </Button> | ||
| </form> | ||
| </CardContent> | ||
| </Card> | ||
| )} | ||
| </> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }) | ||
| } | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the error is shown in here. please fix it while I click to the icons