feat: comprehensive update with Signal, Copilot, themes, and PDF features
## Signal Messaging Integration - Added SignalService for sending messages to drivers via Signal - SignalMessage model for tracking message history - Driver chat modal for real-time messaging - Send schedule via Signal (ICS + PDF attachments) ## AI Copilot - Natural language interface for VIP Coordinator - Capabilities: create VIPs, schedule events, assign drivers - Help and guidance for users - Floating copilot button in UI ## Theme System - Dark/light/system theme support - Color scheme selection (blue, green, purple, orange, red) - ThemeContext for global state - AppearanceMenu in header ## PDF Schedule Export - VIPSchedulePDF component for schedule generation - PDF settings (header, footer, branding) - Preview PDF in browser - Settings stored in database ## Database Migrations - add_signal_messages: SignalMessage model - add_pdf_settings: Settings model for PDF config - add_reminder_tracking: lastReminderSent for events - make_driver_phone_optional: phone field nullable ## Event Management - Event status service for automated updates - IN_PROGRESS/COMPLETED status tracking - Reminder tracking for notifications ## UI/UX Improvements - Driver schedule modal - Improved My Schedule page - Better error handling and loading states - Responsive design improvements ## Other Changes - AGENT_TEAM.md documentation - Seed data improvements - Ability factory updates - Driver profile page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
375
frontend/src/components/AICopilot.tsx
Normal file
375
frontend/src/components/AICopilot.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { copilotApi } from '@/lib/api';
|
||||
import {
|
||||
X,
|
||||
Send,
|
||||
Bot,
|
||||
User,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
interface ContentBlock {
|
||||
type: 'text' | 'image';
|
||||
text?: string;
|
||||
source?: {
|
||||
type: 'base64';
|
||||
media_type: string;
|
||||
data: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string | ContentBlock[];
|
||||
timestamp: Date;
|
||||
toolCalls?: any[];
|
||||
}
|
||||
|
||||
export function AICopilot() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [pendingImage, setPendingImage] = useState<{ data: string; type: string } | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const chatMutation = useMutation({
|
||||
mutationFn: async (chatMessages: { role: string; content: string | ContentBlock[] }[]) => {
|
||||
const { data } = await copilotApi.post('/copilot/chat', { messages: chatMessages });
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const assistantMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: data.response,
|
||||
timestamp: new Date(),
|
||||
toolCalls: data.toolCalls,
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const errorMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: `Sorry, I encountered an error: ${error.message || 'Unknown error'}. Please try again.`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() && !pendingImage) return;
|
||||
|
||||
// Build content
|
||||
let content: string | ContentBlock[];
|
||||
if (pendingImage) {
|
||||
content = [];
|
||||
if (input.trim()) {
|
||||
content.push({ type: 'text', text: input.trim() });
|
||||
}
|
||||
content.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: pendingImage.type,
|
||||
data: pendingImage.data,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
content = input.trim();
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const newMessages = [...messages, userMessage];
|
||||
setMessages(newMessages);
|
||||
setInput('');
|
||||
setPendingImage(null);
|
||||
|
||||
// Prepare messages for API
|
||||
const apiMessages = newMessages.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
}));
|
||||
|
||||
chatMutation.mutate(apiMessages);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = (reader.result as string).split(',')[1];
|
||||
setPendingImage({
|
||||
data: base64,
|
||||
type: file.type,
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const clearChat = () => {
|
||||
setMessages([]);
|
||||
setPendingImage(null);
|
||||
};
|
||||
|
||||
const renderContent = (content: string | ContentBlock[]) => {
|
||||
if (typeof content === 'string') {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||||
ul: ({ children }) => <ul className="list-disc list-inside mb-2">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside mb-2">{children}</ol>,
|
||||
li: ({ children }) => <li className="mb-1">{children}</li>,
|
||||
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
||||
code: ({ children }) => (
|
||||
<code className="bg-muted px-1 py-0.5 rounded text-sm">{children}</code>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-muted p-2 rounded text-sm overflow-x-auto my-2">{children}</pre>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{content.map((block, i) => {
|
||||
if (block.type === 'text') {
|
||||
return <p key={i}>{block.text}</p>;
|
||||
}
|
||||
if (block.type === 'image' && block.source) {
|
||||
return (
|
||||
<img
|
||||
key={i}
|
||||
src={`data:${block.source.media_type};base64,${block.source.data}`}
|
||||
alt="Uploaded"
|
||||
className="max-w-full max-h-48 rounded-lg"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-full shadow-elevated hover:bg-primary/90 transition-all ${
|
||||
isOpen ? 'scale-0 opacity-0' : 'scale-100 opacity-100'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
<span className="font-medium">AI Assistant</span>
|
||||
</button>
|
||||
|
||||
{/* Chat panel */}
|
||||
<div
|
||||
className={`fixed bottom-6 right-6 z-50 w-[400px] h-[600px] max-h-[80vh] bg-card border border-border rounded-2xl shadow-elevated flex flex-col overflow-hidden transition-all duration-300 ${
|
||||
isOpen
|
||||
? 'scale-100 opacity-100'
|
||||
: 'scale-95 opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-primary text-primary-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5" />
|
||||
<span className="font-semibold">VIP Coordinator AI</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={clearChat}
|
||||
className="p-2 hover:bg-white/20 rounded-lg transition-colors"
|
||||
title="Clear chat"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 hover:bg-white/20 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Bot className="h-12 w-12 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<p className="font-medium mb-2">Hi! I'm your AI assistant.</p>
|
||||
<p className="text-sm">
|
||||
I can help you with VIPs, drivers, events, and more.
|
||||
<br />
|
||||
You can also upload screenshots of emails!
|
||||
</p>
|
||||
<div className="mt-4 space-y-2 text-sm text-left max-w-xs mx-auto">
|
||||
<p className="text-muted-foreground">Try asking:</p>
|
||||
<button
|
||||
onClick={() => setInput("What's happening today?")}
|
||||
className="block w-full text-left px-3 py-2 bg-muted rounded-lg hover:bg-accent transition-colors"
|
||||
>
|
||||
"What's happening today?"
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInput('Who are the VIPs arriving by flight?')}
|
||||
className="block w-full text-left px-3 py-2 bg-muted rounded-lg hover:bg-accent transition-colors"
|
||||
>
|
||||
"Who are the VIPs arriving by flight?"
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInput('Which drivers are available?')}
|
||||
className="block w-full text-left px-3 py-2 bg-muted rounded-lg hover:bg-accent transition-colors"
|
||||
>
|
||||
"Which drivers are available?"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex gap-3 ${
|
||||
message.role === 'user' ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
{message.role === 'assistant' && (
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-primary flex items-center justify-center">
|
||||
<Bot className="h-4 w-4 text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[80%] rounded-2xl px-4 py-2 ${
|
||||
message.role === 'user'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm">{renderContent(message.content)}</div>
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border/50 text-xs text-muted-foreground">
|
||||
<span className="font-medium">Actions taken: </span>
|
||||
{message.toolCalls.map((tc) => tc.tool).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{message.role === 'user' && (
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<User className="h-4 w-4 text-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{chatMutation.isPending && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-primary flex items-center justify-center">
|
||||
<Bot className="h-4 w-4 text-primary-foreground" />
|
||||
</div>
|
||||
<div className="bg-muted rounded-2xl px-4 py-3">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Pending image preview */}
|
||||
{pendingImage && (
|
||||
<div className="px-4 py-2 border-t border-border bg-muted/50">
|
||||
<div className="relative inline-block">
|
||||
<img
|
||||
src={`data:${pendingImage.type};base64,${pendingImage.data}`}
|
||||
alt="To upload"
|
||||
className="h-16 rounded-lg"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setPendingImage(null)}
|
||||
className="absolute -top-2 -right-2 p-1 bg-destructive text-white rounded-full"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-4 border-t border-border">
|
||||
<div className="flex items-end gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImageUpload}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-2 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
|
||||
title="Upload image"
|
||||
>
|
||||
<ImagePlus className="h-5 w-5" />
|
||||
</button>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask about VIPs, drivers, events..."
|
||||
className="flex-1 resize-none bg-muted border-0 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary min-h-[44px] max-h-32"
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={chatMutation.isPending || (!input.trim() && !pendingImage)}
|
||||
className="p-3 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user