Files
Heimgeist/src/LibraryManager.jsx

380 lines
12 KiB
JavaScript

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,
onDeleted
}) {
const [busy, setBusy] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [toasts, setToasts] = useState([])
const toastTimeoutsRef = useRef(new Map())
const toastIdRef = useRef(0)
const previousLibraryStateRef = useRef(null)
useEffect(() => {
setConfirmDelete(false)
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()
setConfirmDelete(false)
} finally {
setBusy(false)
await onRefresh()
}
}
async function addPaths() {
if (!library) return
const paths = await window.electronAPI?.pickPaths?.()
if (!Array.isArray(paths) || paths.length === 0) return
try {
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)
})
} 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 deleteLibrary() {
if (!library) return
await runAction(async () => {
const response = await fetch(`${apiBase}/libraries/${library.slug}`, { method: 'DELETE' })
await expectOk(response)
})
onDeleted?.(library.slug)
}
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 (
<div className="placeholder-view">
<p>Create a database and add files. Heimgeist will raw-index them automatically, and you can opt specific files into enrichment.</p>
</div>
)
}
return (
<div className="library-panel">
<div className="library-panel-scroll">
{confirmDelete && (
<div className="library-inline-form danger-zone">
<div className="muted-copy">Delete "{library.name}"? This removes the registered files and local retrieval data for this database.</div>
<div className="new-db-actions">
<button
className="button danger"
disabled={busy}
onClick={() => deleteLibrary().catch((error) => setErrorMessage(String(error?.message || error)))}
>
Confirm Delete
</button>
<button className="button ghost" onClick={() => setConfirmDelete(false)}>Cancel</button>
</div>
</div>
)}
{errorMessage && <div className="form-error">{errorMessage}</div>}
<div className="library-files">
<h2>Files</h2>
{library.files?.length ? (
<div className="library-file-list">
{library.files.map(file => {
const sync = fileSyncMeta(file)
return (
<div key={file.sha256 || file.rel} className="library-file-row">
<div className="library-file-meta">
<div className="library-file-name">{file.name || file.path}</div>
<div className="library-file-path">{file.path}</div>
<div className={`library-file-mode ${file.enrich_enabled ? 'enabled' : ''}`}>
{file.enrich_enabled ? 'Enrichment on' : 'Raw only'}
</div>
<div className="library-file-sync">
<div className="library-file-sync-row">
<span className={`library-file-sync-label ${sync.status}`}>{sync.label}</span>
<span className="library-file-sync-detail">{sync.detail}</span>
</div>
<div
className={`library-file-progress ${sync.status}`}
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={Math.round(sync.progress)}
aria-label={`${file.name || file.path} sync progress`}
>
<div
className="library-file-progress-bar"
style={{ width: `${sync.progress}%` }}
/>
</div>
</div>
</div>
<div className="library-file-actions">
<button
className="button ghost"
disabled={busy || isSyncing}
onClick={() => updateFileEnrichment(file.rel, !file.enrich_enabled)}
>
{file.enrich_enabled ? 'Use Raw Only' : 'Enable Enrich'}
</button>
<button className="button ghost" onClick={() => window.electronAPI?.openPath?.(file.path)}>Open</button>
<button className="button ghost" disabled={busy || isSyncing} onClick={() => removeFile(file.rel)}>Remove</button>
</div>
</div>
)
})}
</div>
) : (
<p className="muted-copy">No files registered yet.</p>
)}
</div>
</div>
<div className="library-footer-actions">
<button className="button" disabled={busy} onClick={addPaths}>Add Files</button>
{library.files?.length > 0 && !isSyncing && !isReadyForChat && (
<button className="button ghost" disabled={busy} onClick={retrySync}>Retry Sync</button>
)}
<button
className="button danger"
onClick={() => {
setConfirmDelete(true)
setErrorMessage('')
}}
>
Delete
</button>
</div>
{toasts.length > 0 && (
<div className="library-toast-stack" aria-live="polite">
{toasts.map(toast => (
<div
key={toast.id}
className={`library-toast ${toast.tone}`}
role={toast.tone === 'warning' ? 'alert' : 'status'}
>
{toast.message}
</div>
))}
</div>
)}
</div>
)
}