Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions backend/README.md
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
```
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -63,6 +64,7 @@ const App = () => (
<Route path="/risk-intelligence" element={<ProtectedRoute><RiskIntelligence /></ProtectedRoute>} />
<Route path="/journey/:batchId" element={<BatchJourney />} />
</Routes>
<ChatbotWidget />
</Suspense>
</WalletProvider>
</AuthProvider>
Expand Down
177 changes: 177 additions & 0 deletions src/components/chat/ChatbotWidget.tsx
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()}>

Copy link
Copy Markdown
Owner

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

Screenshot 2026-06-19 201702.png

<Send className="h-4 w-4" />
</Button>
</form>
</CardContent>
</Card>
)}
</>
);
}
113 changes: 113 additions & 0 deletions supabase/functions/ai-chat/index.ts
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 })
}
})
30 changes: 30 additions & 0 deletions supabase/migrations/20260618_create_chat_usage.sql
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);
Loading