Skip to content
Merged
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
35 changes: 27 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,35 @@
![TailwindCSS](https://img.shields.io/badge/tailwindcss-%2338B2AC.svg?style=for-the-badge&logo=tailwind-css&logoColor=white)
![Supabase](https://img.shields.io/badge/Supabase-3ECF8E?style=for-the-badge&logo=supabase&logoColor=white)

A modern, responsive, and dynamic Event Scheduling application built with React, Vite, TypeScript, and Supabase. The application allows users to manage their events seamlessly, featuring an intuitive calendar interface, categorical filtering, search functionality, and automated Telegram notifications.
A modern, responsive, and dynamic Event Scheduling application built with React, Vite, TypeScript, and Supabase. The application allows users to manage their events seamlessly using AI-powered natural language processing, voice commands, and image recognition.

## Live Demo

- **Web Application:** [https://smart-scheduling.vercel.app/](https://smart-scheduling.vercel.app/)
- **Telegram Bot:** [@Maantisbot](https://t.me/Maantisbot) (Link: [https://t.me/Maantisbot](https://t.me/Maantisbot))


## Features

- **Conversational AI Agent:** Interact with an intelligent chatbot interface (`ChatPanel`) that manages context, processes intents, and handles multi-turn scheduling entirely with natural language.
- **AI Event Parsing:** Create structured events from raw text, voice, or images using LLMs.
- **Voice-to-Event:** Record your voice and let the AI transcribe and schedule the event automatically.
- **Image/OCR Scheduling:** Take a picture of a physical note or schedule and extract event details instantly.
- **User Authentication:** Secure signup and login using Supabase Auth.
- **Guest Mode:** Experience the app without creating an account (events are stored in-memory).
- **Interactive Calendar:** Visual event calendar for easy date selection and event viewing.
- **Event Management:** Add, edit, and delete events with details like title, description, category, and date.
- **Telegram Notifications:** Get notified via Telegram bot when an event starts.
- **Advanced Filtering & Search:** Search events by keyword and filter them by custom categories.
- **Responsive Design:** Fully responsive UI built with Tailwind CSS and Shadcn UI components.
- **Real-time Toasts:** Instant feedback on user actions using Sonner.

## AI Capabilities

The "Smart" in Smart Scheduling is powered by a robust AI pipeline:

- **Natural Language Processing:** Powered by Groq (Llama 3.1 8B) to extract titles, dates, times, and categories from unstructured text.
- **Voice Transcription:** Utilizes Groq Whisper (whisper-large-v3-turbo) for high-accuracy voice-to-text conversion.
- **Date Intelligence:** Combines LLM analysis with Chrono-node for precise, deterministic date and recurrence calculations.
- **OCR (Optical Character Recognition):** Integrated with Tesseract.js to process images and extract textual event data.


## Tech Stack

Expand All @@ -50,6 +61,8 @@ erDiagram
bigint telegram_chat_id "Linked Telegram Chat ID"
text link_code "Unique linking code for Telegram"
uuid last_event_id FK "Reference to the last interacted event"
text last_bot_response "Text of the most recent agent response"
text conversation_history "JSON structure storing sequential user-agent chat history"
}

events {
Expand All @@ -72,6 +85,8 @@ erDiagram

The application leverages Supabase Edge Functions and `pg_cron` for background tasks:

- **agent:** A state-aware conversational routing function that interprets advanced temporal logic, handles complex intents, and drives the chatbot UI.
- **analyze-event:** An Edge Function that utilizes Groq Llama and Whisper models to parse events from text and audio inputs.
- **send-notifications:** An Edge Function that scans for upcoming events and sends Telegram messages to users with linked accounts.
- **pg_cron:** Managed via the `process-notifications-every-minute` job, which triggers the notification engine every minute.

Expand Down Expand Up @@ -109,16 +124,20 @@ Ensure you have the following installed on your local machine:
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
```

### Telegram Bot Setup
## Setup Secrets

To enable notifications, you must configure a Telegram Bot:
To enable full functionality, you must configure the following Supabase Secrets:

1. Create a bot using [@BotFather](https://t.me/botfather) and obtain the `TELEGRAM_BOT_TOKEN`.
2. Set the token in your Supabase project secrets:
1. **Telegram Notifications:** Create a bot via [@BotFather](https://t.me/botfather) and set the token:
```bash
supabase secrets set TELEGRAM_BOT_TOKEN=your_token
```

2. **Groq AI Capabilities:** Obtain an API key from Groq Console and set it:
```bash
supabase secrets set GROQ_API_KEY=your_key
```

4. **Start the Development Server:**
```bash
npm run dev
Expand Down
283 changes: 283 additions & 0 deletions src/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
import { useState, useRef, useEffect } from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { supabase } from "@/integrations/supabase/client";
import { toast } from "sonner";
import { Send, Mic, Image, Square, Loader2, Bot, User, Wrench } from "lucide-react";

interface Message {
role: "user" | "assistant" | "status";
content: string;
toolCalls?: { tool: string; args: any }[];
}

interface ChatPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onEventChanged?: () => void;
}

const ChatPanel = ({ open, onOpenChange, onEventChanged }: ChatPanelProps) => {
const [messages, setMessages] = useState<Message[]>([
{ role: "assistant", content: "Hey! I'm Maantis, your AI scheduling assistant. I can create events, check your schedule, resolve conflicts, and more. What can I help with?" }
]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const mediaRecorder = useRef<MediaRecorder | null>(null);
const audioChunks = useRef<Blob[]>([]);
const conversationHistory = useRef<any[]>([]);

useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);

const sendToAgent = async (userMessage: string, inputType: string = "text", fileData?: string) => {
setIsLoading(true);
setMessages(prev => [...prev, { role: "status", content: "Thinking..." }]);

try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
toast.error("Please log in first");
setIsLoading(false);
return;
}

const { data, error } = await supabase.functions.invoke('agent', {
body: {
user_id: user.id,
message: userMessage,
input_type: inputType,
file_data: fileData,
conversation_history: conversationHistory.current.slice(-10),
},
});

if (error) throw new Error(error.message);

setMessages(prev => prev.filter(m => m.role !== "status"));

if (data.transcription && inputType === "voice") {
setMessages(prev => [...prev, { role: "status", content: `Heard: "${data.transcription}"` }]);
}

const assistantMsg: Message = {
role: "assistant",
content: data.response || "I processed your request.",
toolCalls: data.tool_calls_made,
};
setMessages(prev => [...prev, assistantMsg]);

conversationHistory.current.push({ role: "user", content: userMessage });
conversationHistory.current.push({ role: "assistant", content: data.response });

if (data.tool_calls_made?.some((t: any) => ["create_event", "update_event", "delete_event"].includes(t.tool))) {
onEventChanged?.();
}

} catch (err: any) {
setMessages(prev => prev.filter(m => m.role !== "status"));
setMessages(prev => [...prev, { role: "assistant", content: `Error: ${err.message}` }]);
} finally {
setIsLoading(false);
}
};

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
const msg = input.trim();
setInput("");
setMessages(prev => [...prev, { role: "user", content: msg }]);
await sendToAgent(msg);
};

const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder.current = new MediaRecorder(stream);
audioChunks.current = [];

mediaRecorder.current.ondataavailable = (e) => {
if (e.data.size > 0) audioChunks.current.push(e.data);
};

mediaRecorder.current.onstop = async () => {
const audioBlob = new Blob(audioChunks.current, { type: 'audio/webm' });
stream.getTracks().forEach(track => track.stop());

const reader = new FileReader();
reader.onloadend = async () => {
const base64 = (reader.result as string).split(',')[1];
setMessages(prev => [...prev, { role: "user", content: "Voice message" }]);
await sendToAgent("", "voice", base64);
};
reader.readAsDataURL(audioBlob);
};

mediaRecorder.current.start();
setIsRecording(true);
} catch (err) {
toast.error("Could not access microphone.");
}
};

const stopRecording = () => {
if (mediaRecorder.current && isRecording) {
mediaRecorder.current.stop();
setIsRecording(false);
}
};

const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;

const reader = new FileReader();
reader.onloadend = async () => {
const base64 = (reader.result as string).split(',')[1];
setMessages(prev => [...prev, { role: "user", content: `Image: ${file.name}` }]);
await sendToAgent(input || "", "image", base64);
setInput("");
};
reader.readAsDataURL(file);
e.target.value = "";
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] h-[600px] flex flex-col p-0 gap-0">
<div className="flex items-center gap-3 p-4 border-b bg-primary/5">
<div className="w-9 h-9 rounded-full bg-primary flex items-center justify-center">
<Bot className="h-5 w-5 text-white" />
</div>
<div>
<h3 className="font-semibold text-sm">Maantis Agent</h3>
<p className="text-xs text-muted-foreground">AI Scheduling Assistant</p>
</div>
</div>

<ScrollArea className="flex-1 p-4" ref={scrollRef}>
<div className="space-y-4">
{messages.map((msg, i) => (
<div key={i} className={`flex gap-2 ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
{msg.role === "assistant" && (
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<Bot className="h-4 w-4 text-primary" />
</div>
)}
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
msg.role === "user"
? "bg-primary text-primary-foreground rounded-br-md"
: msg.role === "status"
? "bg-muted text-muted-foreground italic text-xs py-1.5"
: "bg-muted rounded-bl-md"
}`}>
<p className="whitespace-pre-wrap">{msg.content}</p>
{msg.toolCalls && msg.toolCalls.length > 0 && (
<div className="mt-2 pt-2 border-t border-border/50">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 flex items-center gap-1">
<Wrench className="h-3 w-3" /> Tools used
</p>
{msg.toolCalls.map((tc, j) => (
<span key={j} className="inline-block text-[11px] bg-background rounded px-1.5 py-0.5 mr-1 mb-0.5 font-mono">
{tc.tool}
</span>
))}
</div>
)}
</div>
{msg.role === "user" && (
<div className="w-7 h-7 rounded-full bg-primary flex items-center justify-center shrink-0 mt-0.5">
<User className="h-4 w-4 text-white" />
</div>
)}
</div>
))}
{isLoading && (
<div className="flex gap-2">
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<Bot className="h-4 w-4 text-primary animate-pulse" />
</div>
<div className="bg-muted rounded-2xl rounded-bl-md px-4 py-2.5">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
</div>
)}
</div>
</ScrollArea>

<div className="p-3 border-t bg-background">
<form onSubmit={handleSubmit} className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
accept="image/*"
className="hidden"
onChange={handleImageUpload}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 h-9 w-9"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
>
<Image className="h-4 w-4 text-muted-foreground" />
</Button>

{isRecording ? (
<Button
type="button"
variant="destructive"
size="icon"
className="shrink-0 h-9 w-9 animate-pulse"
onClick={stopRecording}
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 h-9 w-9"
onClick={startRecording}
disabled={isLoading}
>
<Mic className="h-4 w-4 text-muted-foreground" />
</Button>
)}

<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask me anything..."
disabled={isLoading || isRecording}
className="h-9 text-sm"
/>

<Button
type="submit"
size="icon"
className="shrink-0 h-9 w-9"
disabled={isLoading || !input.trim()}
>
<Send className="h-4 w-4" />
</Button>
</form>
</div>
</DialogContent>
</Dialog>
);
};

export default ChatPanel;
1 change: 1 addition & 0 deletions src/components/EventManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,5 +111,6 @@ export const useEventManager = () => {
addEvent,
updateEvent,
deleteEvent,
fetchEvents,
};
};
Loading
Loading