Files
vip-coordinator/frontend/src/components/AICopilot.tsx
kyle 3b0b1205df 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>
2026-02-01 19:30:41 +01:00

376 lines
12 KiB
TypeScript

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>
</>
);
}