import React, { useEffect, useRef, useState } from 'react' const TOAST_DURATION_MS = { info: 3600, success: 4800, warning: 5600 } function fileSyncMeta(file) { const sync = file?.sync || {} const status = String(sync.status || 'pending') const progress = Math.max(0, Math.min(100, Number(sync.progress) || 0)) const detail = String(sync.detail || '').trim() const error = String(sync.error || '').trim() const enrichEnabled = !!file?.enrich_enabled if (status === 'ready') { return { status, progress: 100, label: 'Available', detail: detail || (enrichEnabled ? 'Ready in chat with enrichment enabled.' : 'Ready in chat with raw indexing only.') } } if (status === 'failed') { return { status, progress: 100, label: 'Sync failed', detail: error || detail || 'Heimgeist could not finish syncing this file.' } } if (status === 'syncing') { return { status, progress, label: progress > 0 ? `Syncing ${Math.round(progress)}%` : 'Syncing', detail: detail || 'Rebuilding the corpus and indexes. Selected files may also be enriched.' } } return { status: 'pending', progress: 6, label: 'Queued', detail: 'Waiting to rebuild the retrieval pipeline.' } } export default function LibraryManager({ apiBase, library, jobs, onRefresh }) { const [busy, setBusy] = useState(false) const [errorMessage, setErrorMessage] = useState('') const [toasts, setToasts] = useState([]) const toastTimeoutsRef = useRef(new Map()) const toastIdRef = useRef(0) const previousLibraryStateRef = useRef(null) useEffect(() => { setErrorMessage('') }, [library?.slug, library?.name]) function dismissToast(id) { const timeoutId = toastTimeoutsRef.current.get(id) if (timeoutId) { clearTimeout(timeoutId) toastTimeoutsRef.current.delete(id) } setToasts(current => current.filter(toast => toast.id !== id)) } function clearToasts() { toastTimeoutsRef.current.forEach(timeoutId => clearTimeout(timeoutId)) toastTimeoutsRef.current.clear() setToasts([]) } function queueToast(message, tone = 'info') { setToasts(current => { if (current.some(toast => toast.message === message && toast.tone === tone)) { return current } const id = `library-toast-${toastIdRef.current++}` const next = [...current, { id, message, tone }].slice(-3) const timeoutId = window.setTimeout(() => dismissToast(id), TOAST_DURATION_MS[tone] || TOAST_DURATION_MS.info) toastTimeoutsRef.current.set(id, timeoutId) return next }) } useEffect(() => () => { toastTimeoutsRef.current.forEach(timeoutId => clearTimeout(timeoutId)) toastTimeoutsRef.current.clear() }, []) async function expectOk(response) { if (response.ok) return response const detail = await response.text() throw new Error(detail || `HTTP ${response.status}`) } async function runAction(fn) { setBusy(true) try { setErrorMessage('') await fn() } finally { setBusy(false) await onRefresh() } } async function registerPaths(paths) { await runAction(async () => { const response = await fetch(`${apiBase}/libraries/${library.slug}/files/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths }) }) await expectOk(response) }) } async function addPaths() { if (!library) return const paths = await desktopApi?.pickPaths?.() if (!Array.isArray(paths) || paths.length === 0) return try { await registerPaths(paths) } catch (error) { setErrorMessage(String(error?.message || error)) } } async function removeFile(rel) { if (!library) return try { await runAction(async () => { const response = await fetch(`${apiBase}/libraries/${library.slug}/files`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rel }) }) await expectOk(response) }) } catch (error) { setErrorMessage(String(error?.message || error)) } } async function updateFileEnrichment(rel, enabled) { if (!library) return try { await runAction(async () => { const response = await fetch(`${apiBase}/libraries/${library.slug}/files/enrichment`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rel, enabled }) }) await expectOk(response) }) } catch (error) { setErrorMessage(String(error?.message || error)) } } async function retrySync() { if (!library) return try { await runAction(async () => { const response = await fetch(`${apiBase}/libraries/${library.slug}/jobs/prepare`, { method: 'POST' }) await expectOk(response) }) } catch (error) { setErrorMessage(String(error?.message || error)) } } const librarySlug = library?.slug || null const isSyncing = !!librarySlug && (jobs || []).some( job => job.slug === librarySlug && (job.status === 'queued' || job.status === 'running') ) const isReadyForChat = !!library?.states?.is_indexed const hasFailedFiles = (library?.files || []).some(file => file?.sync?.status === 'failed') useEffect(() => { if (!library?.slug) { previousLibraryStateRef.current = null clearToasts() return } const nextState = { slug: library.slug, hasFiles: !!library.files?.length, isSyncing, isReadyForChat, hasFailedFiles } const previousState = previousLibraryStateRef.current if (!previousState || previousState.slug !== nextState.slug) { previousLibraryStateRef.current = nextState clearToasts() return } if (!previousState.isSyncing && nextState.isSyncing) { queueToast( 'Syncing this database. Heimgeist is rebuilding the corpus and indexes automatically, and only selected files will run through enrichment.' ) } if (previousState.isSyncing && !nextState.isSyncing) { if (nextState.hasFailedFiles) { queueToast( 'Some files did not finish syncing. Their tiles show the failure state and error details.', 'warning' ) } else if (nextState.isReadyForChat) { queueToast( 'Sync complete. This database is ready in chat. Raw indexing stays on by default; enable enrichment only for files that need deeper recall.', 'success' ) } else if (!nextState.hasFiles) { queueToast('Add files to make this database available in chat.') } } else if (previousState.hasFiles && !nextState.hasFiles && !nextState.isSyncing) { queueToast('All files were removed. Add files to make this database available in chat.') } else if (!previousState.hasFailedFiles && nextState.hasFailedFiles && !nextState.isSyncing) { queueToast( 'Some files did not finish syncing. Their tiles show the failure state and error details.', 'warning' ) } previousLibraryStateRef.current = nextState }, [ library?.slug, library?.files?.length, hasFailedFiles, isReadyForChat, isSyncing ]) if (!library) { return (

Create a database and add files. Heimgeist will raw-index them automatically, and you can opt specific files into enrichment.

) } return (
{errorMessage &&
{errorMessage}
}

Files

{library.files?.length ? (
{library.files.map(file => { const sync = fileSyncMeta(file) return (
{file.name || file.path}
{file.path}
{file.enrich_enabled ? 'Enrichment on' : 'Raw only'}
{sync.label} {sync.detail}
) })}
) : (

No files registered yet.

)}
{library.files?.length > 0 && !isSyncing && !isReadyForChat && ( )}
{toasts.length > 0 && (
{toasts.map(toast => (
{toast.message}
))}
)}
) }