grantforge-api / frontend-react /src /components /project /ProjectChatPanel.tsx
GrantForge Bot
Deploy to Hugging Face
afd56bc
import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Bot, X, Send, AlertCircle, ChevronDown, Pin, Sparkles, Trash2 } from 'lucide-react';
import { getProjectChatHistory, sendProjectChatMessage, getProjectSections, updateProjectSection, clearProjectChatHistory } from '../../api/client';
import toast from 'react-hot-toast';
interface ProjectChatPanelProps {
projectId: string;
projectName?: string;
activeSectionId?: string;
activeSectionTitle?: string;
}
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
created_at?: string;
isError?: boolean;
originalInput?: string;
}
const ProjectChatPanel: React.FC<ProjectChatPanelProps> = ({ projectId, projectName, activeSectionId, activeSectionTitle }) => {
const [isOpen, setIsOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
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 getProjectChatHistory(projectId);
if (history && history.length > 0) {
setMessages(history);
}
} catch (err) {
console.error("Failed to load chat history", err);
}
};
if (isOpen || isPinned) {
fetchHistory();
}
}, [projectId, isOpen, isPinned]);
useEffect(() => {
scrollToBottom();
}, [messages, isOpen, isLoading]);
useEffect(() => {
const handleCmdJ = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'j') {
e.preventDefault();
setIsOpen(prev => !prev);
}
};
window.addEventListener('keydown', handleCmdJ);
return () => window.removeEventListener('keydown', handleCmdJ);
}, []);
const handleClearHistory = async () => {
if (!window.confirm("Czy na pewno chcesz trwale usunąć historię rozmów z asystentem dla tego projektu?")) {
return;
}
setIsLoading(true);
try {
await clearProjectChatHistory(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 handleSendRef = useRef<any>(null);
useEffect(() => {
const handleOpenChat = (e: any) => {
setIsOpen(true);
const { prefillMessage, autoSend } = e.detail || {};
if (prefillMessage) {
if (autoSend) {
setTimeout(() => {
if (handleSendRef.current) handleSendRef.current(prefillMessage);
}, 50);
} else {
setInput(prefillMessage);
}
}
};
window.addEventListener('open-project-chat', handleOpenChat);
return () => window.removeEventListener('open-project-chat', handleOpenChat);
}, []);
const handleApplySuggestion = async (text: string, mode: 'insert' | 'replace', sectionTarget?: string) => {
if (mode === 'insert') {
if (sectionTarget && sectionTarget !== 'Aktywna sekcja') {
window.dispatchEvent(new CustomEvent('navigate-to-section', {
detail: { sectionType: sectionTarget }
}));
} else {
window.dispatchEvent(new CustomEvent('switch-to-sections-tab'));
}
// Wait a bit for the SectionEditor to mount and the tab to switch
setTimeout(() => {
window.dispatchEvent(new CustomEvent('insert-suggestion', { detail: { text, mode } }));
toast.success('Sugestia została przesłana do edytora.');
}, 400);
return;
}
setIsLoading(true);
try {
let targetId = null;
let foundSectionType = null;
const sections = await getProjectSections(projectId);
const findSection = (query: string) => {
if (!query) return null;
const q = query.trim().toLowerCase();
if (!q) return null;
let bestMatch = null;
let highestScore = 0;
sections.forEach((s: any) => {
const st = s.section_type?.toLowerCase() || "";
const stClean = s.section_type?.replace(/_/g, ' ').toLowerCase() || "";
const title = s.title?.trim().toLowerCase() || "";
let score = 0;
if (s.id === query) {
score = 200;
} else if (st === q || title === q) {
score = 100;
} else if (stClean === q) {
score = 90;
} else if ((st && q.includes(st)) || (stClean && q.includes(stClean))) {
score = 80;
} else if (title && q && title.includes(q)) {
score = 70;
} else if (q && title && q.includes(title)) {
score = title.length >= 4 ? 60 : 0;
} else if (stClean && q && stClean.includes(q)) {
score = 50;
} else if (q && stClean && q.includes(stClean)) {
score = stClean.length >= 4 ? 40 : 0;
} else if (q && title) {
const targetWords = q.split(/\s+/).filter((w: string) => w.length >= 4);
for (const w of targetWords) {
if (title.includes(w) || (stClean && stClean.includes(w))) {
score = 30;
break;
}
}
}
if (score > highestScore) {
highestScore = score;
bestMatch = s;
}
});
return bestMatch;
};
let matchingSection: any = null;
if (sectionTarget && sectionTarget !== 'Aktywna sekcja' && sectionTarget !== activeSectionTitle) {
matchingSection = findSection(sectionTarget);
}
if (!matchingSection && activeSectionId) {
matchingSection = findSection(activeSectionId);
}
if (matchingSection) {
targetId = matchingSection.id;
foundSectionType = matchingSection.section_type;
}
if (targetId) {
await updateProjectSection(projectId, targetId, text);
window.dispatchEvent(new CustomEvent('external-section-update', {
detail: { sectionType: foundSectionType, content: text }
}));
window.dispatchEvent(new CustomEvent('refresh-sections'));
toast.success(`Zawartość sekcji została pomyślnie nadpisana w bazie.`);
window.dispatchEvent(new CustomEvent('navigate-to-section', {
detail: { sectionType: foundSectionType }
}));
} else {
toast.error("Nie można ustalić sekcji docelowej upewnij się, jaką sekcję edytujesz.");
}
} catch (e) {
console.error(e);
toast.error("Wystąpił błąd podczas nadpisywania sekcji w bazie.");
} finally {
setIsLoading(false);
}
};
const renderMessageContent = (content: string) => {
const parts = content.split(/(<SUGGESTION[^>]*>[\s\S]*?<\/SUGGESTION>)/);
return (
<>
{parts.map((part, i) => {
const match = part.match(/<SUGGESTION(?: section="([^"]*)")?>([\s\S]*?)<\/SUGGESTION>/);
if (match) {
const sectionName = match[1] || activeSectionTitle || "Aktywna sekcja";
const suggestionText = match[2].trim();
return (
<div key={i} style={{ marginTop: '1rem', background: 'rgba(16, 185, 129, 0.05)', border: '1px solid rgba(16, 185, 129, 0.3)', borderRadius: '8px', overflow: 'hidden' }}>
<div style={{ padding: '0.6rem 1rem', background: 'rgba(16, 185, 129, 0.15)', borderBottom: '1px solid rgba(16, 185, 129, 0.2)', fontSize: '0.8rem', fontWeight: 600, color: '#34d399', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Sparkles size={16} />
<div style={{ padding: '2px 6px', background: 'rgba(16, 185, 129, 0.2)', borderRadius: '4px', fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>SUGESTIA</div>
Dla sekcji: {sectionName}
</span>
</div>
<div style={{ padding: '1rem', fontSize: '0.85rem', whiteSpace: 'pre-wrap', color: 'var(--text-secondary)' }}>
{suggestionText}
</div>
<div style={{ padding: '0.5rem 1rem', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', flexWrap: 'wrap', gap: '0.5rem', background: 'rgba(0,0,0,0.1)' }}>
<button onClick={() => handleApplySuggestion(suggestionText, 'insert')} className="btn hover-lift" style={{ flex: 1, padding: '0.4rem', fontSize: '0.8rem', background: '#34d399', color: '#000', fontWeight: 600, border: 'none', borderRadius: '4px', cursor: 'pointer', minWidth: '140px' }} title="Wkleja tekst w miejscu kursora w aktualnie otwartej sekcji">Wklej w miejscu kursora</button>
<button onClick={() => {
if (window.confirm(`Czy na pewno chcesz nadpisać całą treść sekcji "${sectionName}" w bazie danych? Obecna treść tej sekcji zostanie utracona!`)) {
handleApplySuggestion(suggestionText, 'replace', sectionName);
}
}} className="btn hover-lift" style={{ flex: 1, padding: '0.4rem', fontSize: '0.8rem', background: 'rgba(255,255,255,0.1)', color: '#fff', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', cursor: 'pointer', minWidth: '140px' }} title={`Zastępuje treść sekcji: ${sectionName}`}>Nadpisz sekcję: {sectionName}</button>
</div>
</div>
);
}
return <span key={i} style={{ whiteSpace: 'pre-wrap' }}>{part}</span>;
})}
</>
);
};
const handleSend = async (overrideInput?: string) => {
const textToUse = typeof overrideInput === 'string' ? overrideInput : input;
if (!textToUse.trim() || isLoading) return;
const userMsg: ChatMessage = { id: Date.now().toString(), role: 'user', content: textToUse };
setMessages(prev => [...prev, userMsg]);
if (typeof overrideInput !== 'string') {
setInput('');
}
setIsLoading(true);
try {
const agentMsg = await sendProjectChatMessage(projectId, userMsg.content, activeSectionId, activeSectionTitle);
setMessages(prev => [...prev, agentMsg]);
} catch (error: any) {
console.error(error);
toast.error("Nie udało się pobrać odpowiedzi po kilku próbach.");
const errorMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: "Przepraszam, napotkałem problem techniczny (np. przeciążenie serwerów LLM). System podjął próby automatycznej naprawy, ale niestety nie powiodły się one w tej chwili.\n\nProszę, spróbuj ponownie za chwilę.",
isError: true,
originalInput: textToUse
};
setMessages(prev => [...prev, errorMsg]);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
handleSendRef.current = handleSend;
}, [handleSend]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="chat-panel-container" style={{ position: 'fixed', bottom: '9rem', 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: '430px',
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(99, 102, 241, 0.2)', color: '#818cf8', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Bot size={16} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: '1rem', fontWeight: 600 }}>Asystent Projektu (Ctrl+J)</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 ? '#818cf8' : '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+J)">
<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={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<div style={{
maxWidth: '85%',
padding: '1rem',
borderRadius: '12px',
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.05)',
color: 'var(--text-primary)',
fontSize: '0.9rem',
lineHeight: '1.5'
}}>
Cześć! Jestem Twoim Asystentem Projektu.<br/><br/>
Mogę pomóc w pisaniu sekcji, wyjaśnianiu regulaminów lub sugerowaniu poprawek.<br/><br/>
W czym mogę Ci pomóc?
</div>
</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(99, 102, 241, 0.2)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${msg.role === 'user' ? 'rgba(99, 102, 241, 0.3)' : 'rgba(255,255,255,0.05)'}`,
color: 'var(--text-primary)',
fontSize: '0.9rem',
lineHeight: '1.5'
}}>
{msg.role === 'assistant' ? renderMessageContent(msg.content) : msg.content}
{msg.role === 'assistant' && !msg.isError && (
<div style={{ marginTop: '0.5rem', display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={() => {
navigator.clipboard.writeText(msg.content);
toast.success('Skopiowano odpowiedź do schowka.');
}}
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', marginTop: '0.5rem' }}
className="hover-bg"
>
Kopiuj Odpowiedź
</button>
</div>
)}
{msg.isError && msg.originalInput && (
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={() => {
// Remove the error message from the UI and retry sending the prompt
setMessages(prev => prev.filter(m => m.id !== msg.id));
handleSend(msg.originalInput);
}}
style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem', background: '#ef4444', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem' }}
className="hover-lift"
>
Ponów zapytanie
</button>
</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', alignItems: 'center', gap: '0.8rem' }}>
<div style={{ display: 'flex', gap: '0.3rem' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#818cf8', animation: 'pulse 1.5s infinite' }} />
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#818cf8', animation: 'pulse 1.5s infinite 0.2s' }} />
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#818cf8', animation: 'pulse 1.5s infinite 0.4s' }} />
</div>
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Przetwarzanie zapytania... Prosimy o chwilę cierpliwości.</span>
</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="Napisz wiadomość do Asystenta..."
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 ? '#6366f1' : '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>
</motion.div>
)}
</AnimatePresence>
{/* TOGGLE BUTTON - Hidden if pinned */}
{!isPinned && (
<motion.button
className="tour-step-chat"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setIsOpen(!isOpen)}
style={{
width: '60px',
height: '60px',
borderRadius: '30px',
background: '#6366f1',
color: '#fff',
border: 'none',
boxShadow: '0 4px 20px rgba(99, 102, 241, 0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
pointerEvents: 'auto',
zIndex: 1001,
marginTop: '1rem'
}}
>
{isOpen ? <ChevronDown size={28} /> : <Bot size={28} />}
</motion.button>
)}
<style>{`
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
`}</style>
</div>
);
};
export default ProjectChatPanel;