Spaces:
Running
Running
| 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; | |