grantforge-api / frontend-react /src /components /project /AIGeneratorPanel.tsx
GrantForge Bot
Deploy to Hugging Face
afd56bc
import React, { useState, useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import { Play, Square, FileText, CheckCircle2, AlertCircle, Loader2, Sparkles, ChevronDown, ChevronRight } from 'lucide-react';
import toast from 'react-hot-toast';
interface CompletedSection {
title: string;
content: string;
index: number;
}
interface AIGeneratorPanelProps {
projectId: string;
onCompleted: () => void;
}
const BACKEND_URL = import.meta.env.VITE_API_URL || 'http://localhost:8001';
const AIGeneratorPanel: React.FC<AIGeneratorPanelProps> = ({ projectId, onCompleted }) => {
const [isGenerating, setIsGenerating] = useState(false);
const [currentStatus, setCurrentStatus] = useState<string>('Gotowy do rozpoczęcia automatycznego generowania wniosku.');
const [currentSection, setCurrentSection] = useState<string | null>(null);
const [completedSections, setCompletedSections] = useState<CompletedSection[]>([]);
const [expandedSection, setExpandedSection] = useState<number | null>(null);
const [fullDocument, setFullDocument] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [missingDataQuestion, setMissingDataQuestion] = useState<string | null>(null);
const [userResponse, setUserResponse] = useState<string>('');
const eventSourceRef = useRef<EventSource | null>(null);
const endRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (endRef.current && isGenerating) {
endRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [completedSections, isGenerating]);
useEffect(() => {
return () => { eventSourceRef.current?.close(); };
}, []);
const handleStartGeneration = () => {
if (eventSourceRef.current) eventSourceRef.current.close();
// Użyj toast zamiast window.confirm dla spójnego UX
toast((t) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.8rem', minWidth: '280px' }}>
<div style={{ fontWeight: 700, color: '#fff', fontSize: '0.95rem' }}>
🚀 Uruchomić Autopilota AI?
</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.85rem', lineHeight: 1.4 }}>
Agent wygeneruje wszystkie sekcje wniosku. Proces zajmie kilka minut — nie zamykaj karty.
</div>
<div style={{ display: 'flex', gap: '0.6rem' }}>
<button
onClick={() => { toast.dismiss(t.id); startGeneration(); }}
style={{ flex: 1, padding: '0.5rem', background: '#8b5cf6', color: '#fff', border: 'none', borderRadius: '6px', fontWeight: 600, cursor: 'pointer', fontSize: '0.85rem' }}
>
Tak, generuj
</button>
<button
onClick={() => toast.dismiss(t.id)}
style={{ flex: 1, padding: '0.5rem', background: 'rgba(255,255,255,0.1)', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.85rem' }}
>
Anuluj
</button>
</div>
</div>
), { duration: Infinity, style: { background: '#1e1b4b', border: '1px solid rgba(139,92,246,0.3)' } });
};
const startGeneration = (resume: boolean = false) => {
setIsGenerating(true);
if (!resume) {
setError(null);
setCompletedSections([]);
setFullDocument(null);
setCurrentSection(null);
setProgress(0);
}
setCurrentStatus(resume ? 'Wznawianie połączenia z agentem LangGraph...' : 'Nawiązywanie połączenia z agentem LangGraph...');
const token = localStorage.getItem('token') || '';
const url = `${BACKEND_URL}/api/generator/stream?project_id=${projectId}&resume=${resume}&token=${encodeURIComponent(token)}`;
const source = new EventSource(url);
eventSourceRef.current = source;
// Agent zaczął pisać konkretną sekcję
source.addEventListener('section_started', (event) => {
const title = event.data;
setCurrentSection(title);
setCurrentStatus(`Agent pisze: „${title}"...`);
});
// Sekcja ukończona — dodaj do listy
source.addEventListener('section_completed', (event) => {
try {
const data: CompletedSection = JSON.parse(event.data);
setCompletedSections(prev => {
const updated = [...prev, data];
// Szacunkowy postęp (SMART ma 16 sekcji)
setProgress(Math.min(95, Math.round((updated.length / 16) * 95)));
return updated;
});
setCurrentSection(null);
toast.success(`✅ Ukończono: ${data.title}`, { duration: 3000 });
} catch {
console.error('SSE parse error: section_completed', event.data);
}
});
// Wszystko gotowe
source.addEventListener('document_done', (event) => {
try {
const data = JSON.parse(event.data);
setFullDocument(data.full_content);
setProgress(100);
setCurrentStatus('Wniosek gotowy! Sekcje zostały zapisane w projekcie.');
setIsGenerating(false);
setCurrentSection(null);
source.close();
toast.success('🎉 Autopilot AI zakończył generowanie wniosku!', { duration: 6000 });
// Odśwież dane projektu (sekcje zapisane przez backend)
setTimeout(() => onCompleted(), 500);
} catch {
console.error('SSE parse error: document_done', event.data);
}
});
// Utrata połączenia
source.addEventListener('waiting_for_user_input', (event) => {
try {
const data = JSON.parse(event.data);
if (data.status === 'WAITING_FOR_USER_INPUT') {
setMissingDataQuestion(data.missing_data_question);
setCurrentStatus('Wstrzymano — oczekiwanie na Twoją odpowiedź');
source.close();
}
} catch {
console.error('SSE parse error: waiting_for_user_input', event.data);
}
});
// Błąd z backendu
source.addEventListener('error', (event: any) => {
let errMsg = 'Nieoczekiwany błąd agenta.';
if (event.data) {
try {
const parsed = JSON.parse(event.data);
errMsg = typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail || parsed);
} catch {
errMsg = typeof event.data === 'string' ? event.data : JSON.stringify(event.data);
}
}
if (typeof errMsg !== 'string') {
errMsg = String(errMsg);
}
setError(errMsg);
toast.error(`Zatrzymano: ${errMsg}`);
setIsGenerating(false);
setCurrentSection(null);
source.close();
setCurrentStatus('Generowanie zatrzymane z powodu błędu.');
});
// Utrata połączenia
source.onerror = () => {
if (isGenerating && !fullDocument) {
// EventSource automatycznie próbuje się ponownie — nie przeszkadzaj
setCurrentStatus('Łączenie ponowne ze strumieniem...');
}
};
};
const handleStop = () => {
eventSourceRef.current?.close();
setIsGenerating(false);
setCurrentSection(null);
setCurrentStatus('Przerwano przez użytkownika.');
toast('Zatrzymano Agenta AI.', { icon: '🛑' });
};
const submitUserResponse = async () => {
try {
const token = localStorage.getItem('token') || '';
const res = await fetch(`${BACKEND_URL}/api/generator/resume`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
project_id: projectId,
user_response: userResponse
})
});
if (!res.ok) throw new Error('Błąd wysyłania odpowiedzi');
setMissingDataQuestion(null);
setUserResponse('');
// Wznów strumień
startGeneration(true);
} catch (err: any) {
toast.error(err.message || 'Wystąpił błąd');
}
};
const statusColor = error ? 'var(--accent-red)' : isGenerating ? 'var(--accent-purple)' : fullDocument ? 'var(--accent-green)' : 'var(--text-secondary)';
return (
<div style={{ maxWidth: '960px', margin: '0 auto', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{/* === HEADER CARD === */}
<div className="glass-card" style={{ padding: '2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div style={{ width: '48px', height: '48px', background: 'rgba(139,92,246,0.12)', borderRadius: '12px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent-purple)', flexShrink: 0 }}>
<Sparkles size={24} />
</div>
<div>
<h2 style={{ fontSize: '1.35rem', fontWeight: 800, margin: 0, color: 'var(--text-primary)' }}>
Autopilot AI — Generator Wniosku
</h2>
<p style={{ color: 'var(--text-secondary)', margin: '0.2rem 0 0', fontSize: '0.875rem' }}>
Agent LangGraph napisze wszystkie sekcje wniosku jednocześnie, korzystając z bazy wiedzy RAG.
</p>
</div>
</div>
<div style={{ flexShrink: 0 }}>
{!isGenerating ? (
<button
onClick={handleStartGeneration}
className="btn hover-lift"
style={{ background: 'var(--accent-purple)', color: 'white', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.65rem 1.4rem' }}
>
<Play size={17} /> Uruchom Agenta
</button>
) : (
<button
onClick={handleStop}
className="btn"
style={{ background: 'rgba(239,68,68,0.1)', color: 'var(--accent-red)', border: '1px solid rgba(239,68,68,0.25)', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.65rem 1.4rem' }}
>
<Square size={17} /> Przerwij
</button>
)}
</div>
</div>
{/* Status bar */}
<div style={{ background: 'rgba(0,0,0,0.2)', borderRadius: '10px', padding: '0.9rem 1.2rem', border: '1px solid rgba(255,255,255,0.06)', display: 'flex', alignItems: 'center', gap: '0.9rem' }}>
{isGenerating
? <Loader2 size={19} style={{ color: 'var(--accent-purple)', animation: 'spin 1s linear infinite', flexShrink: 0 }} />
: error
? <AlertCircle size={19} color="var(--accent-red)" style={{ flexShrink: 0 }} />
: <CheckCircle2 size={19} color={fullDocument ? 'var(--accent-green)' : 'var(--text-muted)'} style={{ flexShrink: 0 }} />
}
<span style={{ color: statusColor, fontSize: '0.92rem', fontWeight: 500 }}>{currentStatus}</span>
</div>
{/* Progress bar */}
{(isGenerating || progress > 0) && (
<div style={{ marginTop: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.4rem', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
<span>{currentSection ? `Pisze: ${currentSection}` : completedSections.length > 0 ? `${completedSections.length} sekcji gotowych` : 'Inicjalizacja...'}</span>
<span>{progress}%</span>
</div>
<div style={{ height: '6px', background: 'rgba(255,255,255,0.06)', borderRadius: '3px', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${progress}%`, background: error ? 'var(--accent-red)' : progress === 100 ? 'var(--accent-green)' : 'var(--accent-purple)', borderRadius: '3px', transition: 'width 0.5s ease' }} />
</div>
</div>
)}
{/* Error box */}
{error && (
<div style={{ marginTop: '1rem', padding: '0.9rem 1.1rem', background: 'rgba(239,68,68,0.08)', borderLeft: '3px solid var(--accent-red)', borderRadius: '6px', display: 'flex', alignItems: 'flex-start', gap: '0.75rem', color: '#FECACA', fontSize: '0.875rem' }}>
<AlertCircle size={17} style={{ flexShrink: 0, marginTop: '1px' }} />
<div><strong>Błąd agenta:</strong> {error}</div>
</div>
)}
</div>
{/* === COMPLETED SECTIONS === */}
{completedSections.length > 0 && (
<div className="glass-card" style={{ overflow: 'hidden' }}>
<div style={{ padding: '1.2rem 1.5rem', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<FileText size={17} color="var(--accent-blue)" />
<h3 style={{ margin: 0, fontSize: '1rem', fontWeight: 700 }}>
Wygenerowane sekcje ({completedSections.length})
</h3>
{isGenerating && (
<span style={{ marginLeft: 'auto', fontSize: '0.8rem', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<Loader2 size={13} style={{ animation: 'spin 1s linear infinite' }} />
Agent pracuje...
</span>
)}
</div>
<div style={{ maxHeight: '520px', overflowY: 'auto', padding: '0.5rem 0' }}>
{completedSections.map((sec, idx) => (
<div key={idx} style={{ borderBottom: idx < completedSections.length - 1 ? '1px solid rgba(255,255,255,0.04)' : 'none' }}>
{/* Accordion header */}
<button
onClick={() => setExpandedSection(expandedSection === idx ? null : idx)}
style={{ width: '100%', padding: '1rem 1.5rem', background: 'transparent', border: 'none', display: 'flex', alignItems: 'center', gap: '0.8rem', cursor: 'pointer', textAlign: 'left' }}
>
<div style={{ width: '24px', height: '24px', borderRadius: '50%', background: 'rgba(16,185,129,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<CheckCircle2 size={14} color="var(--accent-green)" />
</div>
<span style={{ flex: 1, fontWeight: 600, fontSize: '0.95rem', color: 'var(--text-primary)' }}>{sec.title}</span>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginRight: '0.5rem' }}>
{sec.content.length > 0 ? `${Math.round(sec.content.length / 5)} słów` : ''}
</span>
{expandedSection === idx
? <ChevronDown size={16} color="var(--text-muted)" />
: <ChevronRight size={16} color="var(--text-muted)" />
}
</button>
{/* Accordion content */}
{expandedSection === idx && (
<div style={{ padding: '0 1.5rem 1.5rem 3.5rem' }}>
<div className="markdown-body" style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', lineHeight: 1.75 }}>
<ReactMarkdown>{sec.content}</ReactMarkdown>
</div>
</div>
)}
</div>
))}
{/* Placeholder sekcji w trakcie pisania */}
{isGenerating && currentSection && (
<div style={{ padding: '1rem 1.5rem', display: 'flex', alignItems: 'center', gap: '0.8rem' }}>
<div style={{ width: '24px', height: '24px', borderRadius: '50%', background: 'rgba(139,92,246,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Loader2 size={14} color="var(--accent-purple)" style={{ animation: 'spin 1s linear infinite' }} />
</div>
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem', fontStyle: 'italic' }}>
Pisze: {currentSection}...
</span>
</div>
)}
<div ref={endRef} />
</div>
</div>
)}
{/* === SUCCESS BANNER === */}
{fullDocument && (
<div className="glass-card" style={{ padding: '1.5rem 2rem', border: '1px solid rgba(16,185,129,0.25)', background: 'rgba(16,185,129,0.04)', display: 'flex', alignItems: 'center', gap: '1rem' }}>
<CheckCircle2 size={28} color="var(--accent-green)" style={{ flexShrink: 0 }} />
<div>
<p style={{ margin: 0, fontWeight: 700, color: 'var(--accent-green)', fontSize: '1.05rem' }}>
Wniosek wygenerowany pomyślnie!
</p>
<p style={{ margin: '0.2rem 0 0', color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
Sekcje zostały zapisane w projekcie. Przejdź do zakładki <strong>Sekcje wniosku</strong>, aby je przejrzeć i edytować, lub do <strong>Wniosku Końcowego</strong>, by skompilować i wyeksportować.
</p>
</div>
</div>
)}
{/* === INFO CARD (gdy nic nie uruchomiono) === */}
{!isGenerating && !fullDocument && completedSections.length === 0 && !error && (
<div className="glass-card" style={{ padding: '2rem', border: '1px solid rgba(255,255,255,0.05)' }}>
<h3 style={{ margin: '0 0 1rem', fontSize: '1rem', fontWeight: 700, color: 'var(--text-primary)' }}>
Jak działa Autopilot AI?
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{[
{ n: '1', t: 'Planowanie', d: 'Agent dobiera odpowiednie sekcje dla Twojego programu dotacyjnego.' },
{ n: '2', t: 'Pobieranie kontekstu', d: 'Dla każdej sekcji system przeszukuje bazę wiedzy RAG z regulaminami i wytycznymi.' },
{ n: '3', t: 'Generowanie', d: 'LLM pisze treść każdej sekcji w formacie gotowym do edycji i eksportu.' },
{ n: '4', t: 'Zapis do projektu', d: 'Wygenerowane sekcje są automatycznie zapisywane — możesz je od razu edytować.' },
].map(step => (
<div key={step.n} style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem' }}>
<div style={{ width: '28px', height: '28px', borderRadius: '50%', background: 'rgba(139,92,246,0.12)', border: '1px solid rgba(139,92,246,0.3)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent-purple)', fontSize: '0.8rem', fontWeight: 700, flexShrink: 0 }}>
{step.n}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: '0.9rem', color: 'var(--text-primary)', marginBottom: '0.1rem' }}>{step.t}</div>
<div style={{ fontSize: '0.825rem', color: 'var(--text-muted)' }}>{step.d}</div>
</div>
</div>
))}
</div>
<div style={{ marginTop: '1.5rem', padding: '0.9rem 1.1rem', background: 'rgba(234,179,8,0.06)', borderLeft: '3px solid rgba(234,179,8,0.5)', borderRadius: '6px', fontSize: '0.825rem', color: 'rgba(253,224,71,0.85)' }}>
⚠️ Treść wygenerowana przez AI na podstawie bazy wiedzy. Zalecana weryfikacja przez doradcę lub prawnika przed wysłaniem wniosku.
</div>
</div>
)}
{/* === MISSING DATA MODAL (HIL) === */}
{missingDataQuestion && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.7)', zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
<div className="glass-card" style={{ width: '550px', maxWidth: '90vw', padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', border: '1px solid var(--accent-purple)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Sparkles size={24} color="var(--accent-purple)" />
<h3 style={{ margin: 0, color: 'var(--text-primary)', fontSize: '1.2rem', fontWeight: 700 }}>
Agent potrzebuje informacji
</h3>
</div>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', lineHeight: 1.5, background: 'rgba(0,0,0,0.2)', padding: '1rem', borderRadius: '8px', borderLeft: '3px solid var(--accent-purple)' }}>
{missingDataQuestion}
</p>
<textarea
value={userResponse}
onChange={e => setUserResponse(e.target.value)}
placeholder="Wpisz odpowiedź tutaj, aby agent mógł kontynuować pisanie..."
style={{
width: '100%', minHeight: '120px', padding: '0.85rem',
background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.15)',
color: 'white', borderRadius: '8px', fontSize: '0.9rem',
resize: 'vertical'
}}
/>
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'flex-end', marginTop: '0.5rem' }}>
<button
onClick={() => {
setMissingDataQuestion(null);
handleStop();
}}
className="btn" style={{ background: 'transparent', color: 'var(--text-muted)', border: '1px solid rgba(255,255,255,0.1)', padding: '0.6rem 1.2rem' }}
>
Anuluj generowanie
</button>
<button
onClick={submitUserResponse}
disabled={!userResponse.trim()}
className="btn" style={{ background: 'var(--accent-purple)', color: 'white', padding: '0.6rem 1.2rem', opacity: userResponse.trim() ? 1 : 0.5 }}
>
Wyślij i wznów pracę
</button>
</div>
</div>
</div>
)}
<style>{`
@keyframes spin { 100% { transform: rotate(360deg); } }
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 {
color: var(--text-primary); margin-top: 1.25em; margin-bottom: 0.4em; font-weight: 700;
}
.markdown-body p { margin-bottom: 0.9em; }
.markdown-body ul { padding-left: 1.4em; margin-bottom: 0.9em; list-style-type: disc; }
.markdown-body li { margin-bottom: 0.2em; }
.markdown-body strong { color: var(--text-primary); font-weight: 600; }
`}</style>
</div>
);
};
export default AIGeneratorPanel;