Download apps/admin/src/pages/KnowledgeBasePage.tsx from safetrack/edtech: direct link, hf CLI and curl.
- Browser
- Download file 13.1 kB
-
https://huggingface.co/spaces/safetrack/edtech/resolve/main/apps/admin/src/pages/KnowledgeBasePage.tsx
- Command line
-
hf download hf://spaces/safetrack/edtech/apps/admin/src/pages/KnowledgeBasePage.tsx
-
curl -L -o KnowledgeBasePage.tsx https://huggingface.co/spaces/safetrack/edtech/resolve/main/apps/admin/src/pages/KnowledgeBasePage.tsx
13.1 kB
| 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> | |
| ); | |
| } | |