GrantForge Bot
Deploy to Hugging Face
afd56bc
import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { MessageSquare, X, Send, BookOpen, AlertCircle, CheckCircle2, ChevronDown, ChevronUp, Pin, PinOff, Trash2 } from 'lucide-react';
import { askProjectQuestion, clearProjectQuestionsHistory } from '../../api/client';
import toast from 'react-hot-toast';
interface ProjectQAPanelProps {
projectId: string;
projectName?: string;
}
interface Message {
id: string;
role: 'user' | 'agent';
content: string;
sources?: string[];
confidence?: number;
recommendation?: string;
created_at?: string;
}
const ProjectQAPanel: React.FC<ProjectQAPanelProps> = ({ projectId, projectName }) => {
const [isOpen, setIsOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
const fetchHistory = async () => {
try {
const history = await import('../../api/client').then(m => m.getProjectQuestionsHistory(projectId));
if (history && history.length > 0) {
const loadedMessages: Message[] = [];
history.forEach((h: any) => {
loadedMessages.push({ id: h.id + '_q', role: 'user', content: h.question, created_at: h.created_at });
loadedMessages.push({
id: h.id + '_a', role: 'agent', content: h.answer,
sources: h.sources, confidence: h.confidence, recommendation: h.recommendation, created_at: h.created_at
});
});
setMessages(loadedMessages);
}
} catch (err) {
console.error("Failed to load QA history", err);
}
};
fetchHistory();
}, [projectId]);
useEffect(() => {
scrollToBottom();
}, [messages, isOpen]);
useEffect(() => {
const handleCmdK = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setIsOpen(prev => !prev);
}
};
window.addEventListener('keydown', handleCmdK);
return () => window.removeEventListener('keydown', handleCmdK);
}, []);
const handleClearHistory = async () => {
if (!window.confirm("Czy na pewno chcesz trwale usunąć historię rozmów z agentem dla tego projektu?")) {
return;
}
setIsLoading(true);
try {
await clearProjectQuestionsHistory(projectId);
setMessages([]);
toast.success("Historia została pomyślnie wyczyszczona.");
} catch (error) {
console.error(error);
toast.error("Nie udało się usunąć historii czatu.");
} finally {
setIsLoading(false);
}
};
const handleSend = async () => {
if (!input.trim() || isLoading) return;
const userMsg: Message = { id: Date.now().toString(), role: 'user', content: input };
setMessages(prev => [...prev, userMsg]);
setInput('');
setIsLoading(true);
try {
const data = await askProjectQuestion(projectId, userMsg.content);
const agentMsg: Message = {
id: (Date.now() + 1).toString(),
role: 'agent',
content: data.answer,
sources: data.sources,
confidence: data.confidence,
recommendation: data.recommendation
};
setMessages(prev => [...prev, agentMsg]);
} catch (error) {
console.error(error);
toast.error("Nie udało się pobrać odpowiedzi. Spróbuj ponownie.");
const errorMsg: Message = {
id: (Date.now() + 1).toString(),
role: 'agent',
content: "Przepraszam, napotkałem błąd podczas analizy Twojego zapytania. Sprawdź połączenie i spróbuj ponownie."
};
setMessages(prev => [...prev, errorMsg]);
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="qa-panel-container" style={{ position: 'fixed', bottom: '4rem', right: '3rem', zIndex: 1000, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', pointerEvents: 'none' }}>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ duration: 0.2 }}
style={{
width: '100%',
maxWidth: '400px',
height: '600px',
maxHeight: isPinned ? '100vh' : '80vh',
background: 'var(--bg-elevated)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: isPinned ? '0' : '16px',
boxShadow: isPinned ? 'none' : '0 10px 40px rgba(0,0,0,0.5)',
display: 'flex',
flexDirection: 'column',
marginBottom: isPinned ? '0' : '1rem',
position: isPinned ? 'fixed' : 'relative',
right: isPinned ? 0 : 'auto',
top: isPinned ? 0 : 'auto',
bottom: isPinned ? 0 : 'auto',
pointerEvents: 'auto',
overflow: 'hidden',
zIndex: 1000
}}
>
{/* HEADER */}
<div style={{ padding: '1rem', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', background: 'rgba(0,0,0,0.2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.8rem' }}>
<div style={{ width: '32px', height: '32px', borderRadius: '8px', background: 'rgba(59, 130, 246, 0.2)', color: 'var(--accent-blue)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<MessageSquare size={16} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: '1rem', fontWeight: 600 }}>Weryfikator (Ctrl+K)</h3>
{projectName && (
<div style={{ fontSize: '0.65rem', background: 'rgba(255,255,255,0.1)', padding: '2px 6px', borderRadius: '10px', marginTop: '4px', display: 'inline-block', maxWidth: '180px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{projectName}
</div>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '0.3rem' }}>
<button onClick={handleClearHistory} style={{ background: 'transparent', border: 'none', color: 'var(--text-muted)', cursor: 'pointer', padding: '0.5rem' }} className="hover-bg rounded" title="Wyczyść historię">
<Trash2 size={16} />
</button>
<button onClick={() => setIsPinned(!isPinned)} style={{ background: 'transparent', border: 'none', color: isPinned ? 'var(--accent-blue)' : 'var(--text-muted)', cursor: 'pointer', padding: '0.5rem' }} className="hover-bg rounded" title={isPinned ? "Odepnij panel" : "Przypnij jako sidebar"}>
{isPinned ? <Pin size={16} fill="currentColor" /> : <Pin size={16} />}
</button>
{!isPinned && (
<button onClick={() => setIsOpen(false)} style={{ background: 'transparent', border: 'none', color: 'var(--text-muted)', cursor: 'pointer', padding: '0.5rem' }} className="hover-bg rounded" title="Zamknij (Ctrl+K)">
<X size={18} />
</button>
)}
</div>
</div>
{/* MESSAGES */}
<div style={{ flex: 1, overflowY: 'auto', padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{messages.length === 0 ? (
<div style={{ textAlign: 'center', color: 'var(--text-muted)', marginTop: '2rem' }}>
<AlertCircle size={32} style={{ opacity: 0.5, margin: '0 auto 1rem' }} />
<p style={{ fontSize: '0.9rem', marginBottom: '0.5rem' }}>O co chcesz zapytać?</p>
<p style={{ fontSize: '0.8rem', opacity: 0.7 }}>Agent odpowie na podstawie regulaminów i Twoich wygenerowanych sekcji wniosku.</p>
</div>
) : (
messages.map((msg) => (
<div key={msg.id} style={{ display: 'flex', flexDirection: 'column', alignItems: msg.role === 'user' ? 'flex-end' : 'flex-start' }}>
<div style={{
maxWidth: '85%',
padding: '1rem',
borderRadius: '12px',
background: msg.role === 'user' ? 'rgba(59, 130, 246, 0.2)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${msg.role === 'user' ? 'rgba(59, 130, 246, 0.3)' : 'rgba(255,255,255,0.05)'}`,
color: 'var(--text-primary)',
fontSize: '0.9rem',
lineHeight: '1.5',
whiteSpace: 'pre-wrap'
}}>
{/<SUGGESTION[^>]*>/.test(msg.content) ? (
<div dangerouslySetInnerHTML={{
__html: msg.content.replace(/<SUGGESTION[^>]*>/g, '<div style="margin-top: 10px; padding: 10px; background: rgba(59, 130, 246, 0.1); border-left: 3px solid var(--accent-blue); border-radius: 4px;"><strong>Sugestia:</strong><br/>').replace(/<\/SUGGESTION>/g, '</div>').replace(/\n/g, '<br/>')
}} />
) : (
msg.content
)}
{/* AGENT EXTRA DETAILS */}
{msg.role === 'agent' && (
<div style={{ marginTop: '1rem', paddingTop: '1rem', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', flexDirection: 'column', gap: '0.8rem' }}>
{msg.sources && msg.sources.length > 0 && (
<div>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.4rem' }}>
<BookOpen size={12} /> Zidentyfikowane Źródła:
</span>
<ul style={{ margin: 0, paddingLeft: '1rem', fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
{msg.sources.map((s, i) => <li key={i}>{s}</li>)}
</ul>
</div>
)}
{msg.recommendation && (
<div style={{ padding: '0.8rem', background: 'rgba(16, 185, 129, 0.1)', borderLeft: '3px solid var(--accent-green)', borderRadius: '0 4px 4px 0', fontSize: '0.85rem' }}>
<span style={{ display: 'block', color: 'var(--accent-green)', fontWeight: 600, marginBottom: '0.2rem', fontSize: '0.75rem' }}>REKOMENDACJA</span>
{msg.recommendation}
</div>
)}
{msg.confidence !== undefined && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{msg.confidence > 0.8 ? <CheckCircle2 size={12} color="var(--accent-green)" /> : <AlertCircle size={12} color="#f59e0b" />}
Pewność: {Math.round(msg.confidence * 100)}%
</div>
)}
<div style={{ marginTop: '0.5rem', display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={() => {
const textToCopy = msg.content.replace(/<\/?SUGGESTION[^>]*>/g, '').trim();
navigator.clipboard.writeText(textToCopy);
toast.success('Skopiowano treść do schowka, możesz wkleić w edytorze.');
}}
style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.05)', color: '#fff', borderRadius: '4px', cursor: 'pointer' }}
className="hover-bg"
>
Kopiuj Odpowiedź
</button>
</div>
</div>
)}
</div>
</div>
))
)}
{isLoading && (
<div style={{ alignSelf: 'flex-start', maxWidth: '85%', padding: '1rem', borderRadius: '12px', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', display: 'flex', gap: '0.5rem' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: 'var(--text-muted)', animation: 'pulse 1.5s infinite' }} />
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: 'var(--text-muted)', animation: 'pulse 1.5s infinite 0.2s' }} />
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: 'var(--text-muted)', animation: 'pulse 1.5s infinite 0.4s' }} />
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* INPUT */}
<div style={{ padding: '1.2rem', borderTop: '1px solid rgba(255,255,255,0.05)', background: 'rgba(0,0,0,0.1)' }}>
<div style={{ position: 'relative' }}>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Zadaj pytanie w kontekście projektu..."
style={{
width: '100%',
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '8px',
padding: '0.8rem 3rem 0.8rem 1rem',
color: '#fff',
resize: 'none',
height: '60px',
fontSize: '0.9rem',
outline: 'none',
transition: 'all 0.2s'
}}
className="focus-ring"
/>
<button
onClick={handleSend}
disabled={!input.trim() || isLoading}
style={{
position: 'absolute',
right: '0.5rem',
bottom: '0.5rem',
height: '42px',
width: '42px',
borderRadius: '6px',
background: input.trim() && !isLoading ? 'var(--accent-blue)' : 'rgba(255,255,255,0.05)',
color: input.trim() && !isLoading ? '#fff' : 'var(--text-muted)',
border: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: input.trim() && !isLoading ? 'pointer' : 'default',
transition: 'all 0.2s'
}}
>
<Send size={16} />
</button>
</div>
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', textAlign: 'center', marginTop: '0.8rem' }}>
Weryfikator powołuje się na zaktualizowaną bazę regulaminów programu.
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* TOGGLE BUTTON - Hidden if pinned */}
{!isPinned && (
<motion.button
className="tour-step-qa"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setIsOpen(!isOpen)}
style={{
width: '60px',
height: '60px',
borderRadius: '30px',
background: 'var(--accent-blue)',
color: '#fff',
border: 'none',
boxShadow: '0 4px 20px rgba(59, 130, 246, 0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
pointerEvents: 'auto',
zIndex: 1001
}}
>
{isOpen ? <ChevronDown size={28} /> : <MessageSquare size={28} />}
</motion.button>
)}
<style>{`
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
`}</style>
</div>
);
};
export default ProjectQAPanel;