gmail-agentic / frontend /components /StreamingDraft.tsx
spoodermon's picture
Initial commit
c1f8e04
Raw
History Blame Contribute Delete
7.57 kB
import React, { useState, useEffect } from 'react';
import { Send, Sparkles, AlertCircle, CheckCircle } from 'lucide-react';
import { API_BASE_URL } from '@/app/config';
interface StreamingDraftProps {
emailId: string;
emailSubject?: string;
emailSender: string;
onSendEmail: (to: string, subject: string, body: string) => Promise<boolean>;
}
export default function StreamingDraft({
emailId,
emailSubject,
emailSender,
onSendEmail,
}: StreamingDraftProps) {
const [tone, setTone] = useState<'professional' | 'casual' | 'short'>('professional');
const [draftContent, setDraftContent] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [isSending, setIsSending] = useState(false);
const [sendStatus, setSendStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [errorMsg, setErrorMsg] = useState('');
// Clear draft when email selection changes
useEffect(() => {
setDraftContent('');
setSendStatus('idle');
}, [emailId]);
const generateDraft = async () => {
setIsGenerating(true);
setDraftContent('');
setSendStatus('idle');
// Connect to FastAPI SSE endpoint
const url = `${API_BASE_URL}/api/emails/${emailId}/draft?tone=${tone}`;
// Access token from localStorage
const token = localStorage.getItem('token') || '';
try {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Failed to generate draft stream.');
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
throw new Error('No readable stream body.');
}
let done = false;
let fullText = '';
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
if (value) {
const chunkStr = decoder.decode(value);
// Parse Server Sent Events format "data: text\n\n"
const lines = chunkStr.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const content = line.substring(6);
fullText += content;
setDraftContent(fullText);
}
}
}
}
} catch (err: any) {
console.error(err);
setErrorMsg(err.message || 'Connection lost during drafting.');
setSendStatus('error');
} finally {
setIsGenerating(false);
}
};
const handleSend = async () => {
if (!draftContent.trim()) return;
setIsSending(true);
setSendStatus('idle');
// Extract target email address
let toEmail = emailSender;
const match = emailSender.match(/<([^>]+)>/);
if (match) {
toEmail = match[1];
}
const subject = emailSubject ? `Re: ${emailSubject}` : 'Re: Email';
const success = await onSendEmail(toEmail, subject, draftContent);
setIsSending(false);
if (success) {
setSendStatus('success');
} else {
setErrorMsg('Failed to deliver reply.');
setSendStatus('error');
}
};
const tones = [
{ label: '💼 Professional', value: 'professional' },
{ label: '☕ Casual', value: 'casual' },
{ label: '⚡ Short / Quick', value: 'short' },
];
return (
<div className="glass-panel border-purple-500/10 rounded-2xl p-6 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-purple-400 font-semibold text-sm">
<Sparkles size={16} className="fill-purple-400/20" />
<span>AI Streaming Reply Composer</span>
</div>
<div className="text-xs text-slate-500">
Powered by GPT-4o-Mini
</div>
</div>
{/* Tone Selection */}
<div className="flex gap-2.5 bg-slate-950/40 border border-slate-900/60 p-1 rounded-xl">
{tones.map((t) => (
<button
key={t.value}
disabled={isGenerating || isSending}
onClick={() => setTone(t.value as any)}
className={`flex-1 text-center py-2 text-xs font-medium rounded-lg cursor-pointer transition ${
tone === t.value
? 'bg-purple-600/20 text-purple-300 border border-purple-500/20'
: 'text-slate-500 hover:text-slate-300 border border-transparent'
}`}
>
{t.label}
</button>
))}
</div>
{/* Generator trigger button */}
{draftContent === '' && !isGenerating && (
<button
onClick={generateDraft}
className="glow-btn text-white py-3 rounded-xl font-medium flex items-center justify-center gap-2 text-sm cursor-pointer"
>
<Sparkles size={14} />
<span>Draft Response in {tone.toUpperCase()} Tone</span>
</button>
)}
{/* Draft text editor */}
{(draftContent !== '' || isGenerating) && (
<div className="flex flex-col gap-3">
<textarea
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
disabled={isGenerating || isSending}
rows={8}
className="w-full text-sm font-sans leading-relaxed bg-slate-950/50 p-4 border border-slate-900 rounded-xl focus:outline-none focus:border-purple-600 resize-y text-slate-300"
placeholder={isGenerating ? "AI is typing reply word-by-word..." : "Edit response..."}
/>
<div className="flex items-center justify-between mt-1">
{/* Status indicators */}
<div>
{isGenerating && (
<div className="flex items-center gap-2 text-purple-400 text-xs font-medium">
<div className="h-3 w-3 animate-spin rounded-full border border-purple-400 border-t-transparent" />
<span>Streaming draft...</span>
</div>
)}
{sendStatus === 'success' && (
<div className="flex items-center gap-1.5 text-emerald-400 text-xs font-medium">
<CheckCircle size={14} />
<span>Reply sent successfully!</span>
</div>
)}
{sendStatus === 'error' && (
<div className="flex items-center gap-1.5 text-red-400 text-xs font-medium">
<AlertCircle size={14} />
<span>{errorMsg}</span>
</div>
)}
</div>
<div className="flex gap-2">
<button
onClick={generateDraft}
disabled={isGenerating || isSending}
className="bg-slate-950 border border-slate-800 text-slate-300 hover:text-white px-4 py-2 rounded-xl text-xs font-semibold cursor-pointer transition disabled:opacity-50"
>
Regenerate
</button>
<button
onClick={handleSend}
disabled={isGenerating || isSending || !draftContent.trim()}
className="glow-btn text-white px-4.5 py-2 rounded-xl text-xs font-semibold flex items-center gap-1.5 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send size={12} />
<span>{isSending ? 'Sending...' : 'Send Reply'}</span>
</button>
</div>
</div>
</div>
)}
</div>
);
}