| import { useState, useEffect } from 'react'; |
| import { useTranslation } from 'react-i18next'; |
| import { Database, Trash2, RefreshCw, Search, ChevronLeft, ChevronRight, Loader2, FileText, Wand2, CheckCircle2 } from 'lucide-react'; |
| import { api } from '../lib/api'; |
| import { useAuth } from '../lib/auth'; |
| import { useTenant } from '../lib/tenant'; |
| import { useToast } from '../hooks/useToast'; |
| import { logError } from '../lib/logger'; |
|
|
| interface KbEntry { |
| id: string; |
| content: string; |
| metadata?: Record<string, unknown>; |
| createdAt: string; |
| } |
|
|
| interface KbResponse { |
| entries: KbEntry[]; |
| total: number; |
| page: number; |
| limit: number; |
| } |
|
|
| const PAGE_SIZE = 20; |
|
|
| export default function KnowledgeBasePage() { |
| const { t } = useTranslation(); |
| const toast = useToast(); |
| const { token } = useAuth(); |
| const { selectedOrgId } = useTenant(); |
| const [data, setData] = useState<KbResponse | null>(null); |
| const [loading, setLoading] = useState(true); |
| const [page, setPage] = useState(1); |
| const [search, setSearch] = useState(''); |
| const [deletingId, setDeletingId] = useState<string | null>(null); |
| const [reindexing, setReindexing] = useState(false); |
| const [genDescription, setGenDescription] = useState(''); |
| const [generating, setGenerating] = useState(false); |
| const [genResult, setGenResult] = useState<{ faqCount: number; chunksIndexed: number; preview: Array<{ question: string; answer: string }> } | null>(null); |
|
|
| const fetchEntries = async (p = page) => { |
| if (!token || !selectedOrgId) return; |
| setLoading(true); |
| try { |
| const res = await api.get( |
| `/v1/organizations/${selectedOrgId}/kb?page=${p}&limit=${PAGE_SIZE}`, |
| token |
| ); |
| setData(res); |
| } catch (err) { |
| logError('[KB] Failed to fetch entries:', err); |
| } finally { |
| setLoading(false); |
| } |
| }; |
|
|
| useEffect(() => { |
| fetchEntries(1); |
| setPage(1); |
| }, [token, selectedOrgId]); |
|
|
| const handleDelete = async (id: string) => { |
| if (!token || !selectedOrgId) return; |
| if (!confirm(t('knowledge.confirm_delete'))) return; |
| setDeletingId(id); |
| try { |
| await api.delete(`/v1/organizations/${selectedOrgId}/kb/${id}`, token); |
| await fetchEntries(page); |
| } catch (err: any) { |
| logError('[KB] Delete failed:', err); |
| toast.error(err?.message ?? t('knowledge.delete_error')); |
| } finally { |
| setDeletingId(null); |
| } |
| }; |
|
|
| const handleReindex = async () => { |
| if (!token || !selectedOrgId) return; |
| setReindexing(true); |
| try { |
| await api.post(`/v1/organizations/${selectedOrgId}/index-kb`, {}, token); |
| toast.success(t('knowledge.reindex_success')); |
| } catch (err: any) { |
| const msg: string = err?.message ?? ''; |
| if (msg.toLowerCase().includes('no kb url') || msg.toLowerCase().includes('not configured')) { |
| toast.error(t('knowledge.no_kb_url')); |
| } else { |
| logError('[KB] Re-index failed:', err); |
| toast.error(t('knowledge.reindex_error')); |
| } |
| } finally { |
| setReindexing(false); |
| } |
| }; |
|
|
| const handleGenerate = async () => { |
| if (!token || !selectedOrgId || !genDescription.trim()) return; |
| setGenerating(true); |
| setGenResult(null); |
| try { |
| const res = await api.post( |
| `/v1/organizations/${selectedOrgId}/kb/generate`, |
| { description: genDescription }, |
| token |
| ); |
| setGenResult(res); |
| toast.success(t('knowledge.generate_success_count', { count: res.faqCount })); |
| await fetchEntries(1); |
| setPage(1); |
| } catch (err: any) { |
| logError('[KB] Generate failed:', err); |
| toast.error(err?.message ?? t('knowledge.generate_error')); |
| } finally { |
| setGenerating(false); |
| } |
| }; |
|
|
| const handlePageChange = (newPage: number) => { |
| setPage(newPage); |
| fetchEntries(newPage); |
| }; |
|
|
| const filteredEntries = (data?.entries ?? []).filter(e => |
| !search || e.content.toLowerCase().includes(search.toLowerCase()) |
| ); |
|
|
| const totalPages = data ? Math.ceil(data.total / PAGE_SIZE) : 1; |
|
|
| return ( |
| <div className="p-8 max-w-5xl mx-auto"> |
| <div className="flex items-center justify-between mb-8"> |
| <div className="flex items-center gap-3"> |
| <div className="w-10 h-10 bg-violet-100 rounded-2xl flex items-center justify-center"> |
| <Database className="w-5 h-5 text-violet-600" /> |
| </div> |
| <div> |
| <h1 className="text-2xl font-bold text-slate-900">{t('knowledge.title')}</h1> |
| <p className="text-sm text-slate-500"> |
| {data ? `${data.total} ${t('knowledge.chunks')}` : t('common.loading')} |
| </p> |
| </div> |
| </div> |
| <button |
| onClick={handleReindex} |
| disabled={reindexing} |
| className="flex items-center gap-2 px-4 py-2 bg-violet-600 text-white rounded-xl text-sm font-medium hover:bg-violet-700 transition disabled:opacity-50" |
| > |
| <RefreshCw className={`w-4 h-4 ${reindexing ? 'animate-spin' : ''}`} /> |
| {reindexing ? t('knowledge.reindexing') : t('knowledge.reindex')} |
| </button> |
| </div> |
| |
| {/* KB Auto-Generation Panel */} |
| <div className="bg-white rounded-2xl border border-slate-100 p-5 mb-6"> |
| <div className="flex items-center gap-2 mb-3"> |
| <Wand2 className="w-4 h-4 text-violet-500" /> |
| <h2 className="text-sm font-semibold text-slate-700">{t('knowledge.generate_from_desc')}</h2> |
| </div> |
| <textarea |
| value={genDescription} |
| onChange={e => setGenDescription(e.target.value)} |
| placeholder={t('knowledge.generate_placeholder')} |
| rows={3} |
| className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-violet-400 resize-none mb-3" |
| /> |
| <button |
| onClick={handleGenerate} |
| disabled={generating || !genDescription.trim()} |
| className="flex items-center gap-2 px-4 py-2 bg-violet-600 text-white rounded-xl text-sm font-medium hover:bg-violet-700 transition disabled:opacity-50" |
| > |
| {generating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wand2 className="w-4 h-4" />} |
| {generating ? t('knowledge.generating_btn') : t('knowledge.generate_btn')} |
| </button> |
| |
| {genResult && ( |
| <div className="mt-4 pt-4 border-t border-slate-100"> |
| <p className="text-sm font-medium text-slate-700 mb-3"> |
| <CheckCircle2 className="inline w-4 h-4 text-green-500 mr-1" /> |
| {t('knowledge.generate_result_summary', { count: genResult.faqCount, chunks: genResult.chunksIndexed })} |
| </p> |
| <div className="space-y-2"> |
| {genResult.preview.slice(0, 3).map((qa, i) => ( |
| <div key={i} className="bg-slate-50 rounded-xl p-3 text-sm"> |
| <p className="font-medium text-slate-700 mb-1">Q: {qa.question}</p> |
| <p className="text-slate-500">R: {qa.answer}</p> |
| </div> |
| ))} |
| </div> |
| </div> |
| )} |
| </div> |
| |
| <div className="relative mb-4"> |
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> |
| <input |
| type="text" |
| placeholder={t('knowledge.search_placeholder')} |
| value={search} |
| onChange={e => setSearch(e.target.value)} |
| className="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-violet-400" |
| /> |
| </div> |
| |
| {loading ? ( |
| <div className="flex items-center justify-center py-20"> |
| <Loader2 className="w-8 h-8 animate-spin text-slate-400" /> |
| </div> |
| ) : filteredEntries.length === 0 ? ( |
| <div className="text-center py-20 text-slate-400"> |
| <FileText className="w-12 h-12 mx-auto mb-3 opacity-30" /> |
| <p className="font-medium">{t('knowledge.no_documents')}</p> |
| <p className="text-sm mt-1">{t('knowledge.import_hint')}</p> |
| </div> |
| ) : ( |
| <div className="space-y-3"> |
| {filteredEntries.map((entry, i) => ( |
| <div key={entry.id} className="bg-white rounded-2xl border border-slate-100 p-5 hover:shadow-sm transition group"> |
| <div className="flex items-start justify-between gap-4"> |
| <div className="flex-1 min-w-0"> |
| <div className="flex items-center gap-2 mb-2"> |
| <span className="text-xs font-mono bg-slate-100 text-slate-500 px-2 py-0.5 rounded-lg"> |
| #{(page - 1) * PAGE_SIZE + i + 1} |
| </span> |
| {entry.metadata && ( |
| <span className="text-xs text-slate-400"> |
| {Object.entries(entry.metadata as Record<string, unknown>) |
| .slice(0, 2) |
| .map(([k, v]) => `${k}: ${v}`) |
| .join(' · ')} |
| </span> |
| )} |
| <span className="text-xs text-slate-300 ml-auto"> |
| {new Date(entry.createdAt).toLocaleDateString()} |
| </span> |
| </div> |
| <p className="text-sm text-slate-700 leading-relaxed line-clamp-4"> |
| {entry.content} |
| </p> |
| </div> |
| <button |
| onClick={() => handleDelete(entry.id)} |
| disabled={deletingId === entry.id} |
| className="opacity-0 group-hover:opacity-100 p-2 text-red-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition" |
| title="Supprimer ce chunk" |
| > |
| {deletingId === entry.id |
| ? <Loader2 className="w-4 h-4 animate-spin" /> |
| : <Trash2 className="w-4 h-4" />} |
| </button> |
| </div> |
| </div> |
| ))} |
| </div> |
| )} |
| |
| {!search && totalPages > 1 && ( |
| <div className="flex items-center justify-between mt-6"> |
| <p className="text-sm text-slate-500">Page {page} sur {totalPages}</p> |
| <div className="flex gap-2"> |
| <button |
| onClick={() => handlePageChange(page - 1)} |
| disabled={page === 1} |
| className="p-2 rounded-xl border border-slate-200 hover:bg-slate-50 disabled:opacity-40 transition" |
| > |
| <ChevronLeft className="w-4 h-4" /> |
| </button> |
| <button |
| onClick={() => handlePageChange(page + 1)} |
| disabled={page === totalPages} |
| className="p-2 rounded-xl border border-slate-200 hover:bg-slate-50 disabled:opacity-40 transition" |
| > |
| <ChevronRight className="w-4 h-4" /> |
| </button> |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| } |
| |