Feat: Implement detailed content previews for library items
Adds functionality to display rich content previews (including truncated or extracted RAG text) when viewing a library item, updating backend indexing and frontend rendering accordingly.
This commit is contained in:
@@ -468,11 +468,62 @@ def _ensure_stage_link(slug: str, source_path: Path, rel: str) -> None:
|
|||||||
|
|
||||||
def _find_item(data: Dict[str, Any], item_id: str) -> Optional[Dict[str, Any]]:
|
def _find_item(data: Dict[str, Any], item_id: str) -> Optional[Dict[str, Any]]:
|
||||||
return next(
|
return next(
|
||||||
(entry for entry in data.get("files", []) if str(entry.get("item_id") or "") == item_id),
|
(
|
||||||
|
entry for entry in data.get("files", [])
|
||||||
|
if str(entry.get("item_id") or entry.get("sha256") or entry.get("rel") or "") == item_id
|
||||||
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_indexed_file_text(slug: str, entry: Dict[str, Any], max_chars: int = 200_000) -> tuple[str, bool]:
|
||||||
|
corpus_path = _collect_library_paths(slug)["corpus"]
|
||||||
|
if not corpus_path.exists():
|
||||||
|
return "", False
|
||||||
|
|
||||||
|
raw_path = str(entry.get("path") or "")
|
||||||
|
try:
|
||||||
|
source_key = str(Path(raw_path).expanduser().resolve())
|
||||||
|
except Exception:
|
||||||
|
source_key = raw_path
|
||||||
|
item_id = str(entry.get("item_id") or entry.get("sha256") or entry.get("rel") or "")
|
||||||
|
parts: List[str] = []
|
||||||
|
total = 0
|
||||||
|
truncated = False
|
||||||
|
|
||||||
|
with corpus_path.open("r", encoding="utf-8", errors="ignore") as handle:
|
||||||
|
for line in handle:
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
record = json.loads(line)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
record_source = str(record.get("source_path") or "")
|
||||||
|
try:
|
||||||
|
record_source_key = str(Path(record_source).expanduser().resolve())
|
||||||
|
except Exception:
|
||||||
|
record_source_key = record_source
|
||||||
|
record_item_id = str((record.get("meta") or {}).get("item_id") or record.get("id") or "")
|
||||||
|
if record_source_key != source_key and (not item_id or record_item_id != item_id):
|
||||||
|
continue
|
||||||
|
text = str(record.get("text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
remaining = max_chars - total
|
||||||
|
if remaining <= 0:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
if len(text) > remaining:
|
||||||
|
parts.append(text[:remaining].rstrip())
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
parts.append(text)
|
||||||
|
total += len(text) + 2
|
||||||
|
|
||||||
|
return "\n\n".join(parts), truncated
|
||||||
|
|
||||||
|
|
||||||
def _mark_entry_pending(entry: Dict[str, Any]) -> None:
|
def _mark_entry_pending(entry: Dict[str, Any]) -> None:
|
||||||
entry["sync_status"] = "pending"
|
entry["sync_status"] = "pending"
|
||||||
entry.pop("sync_error", None)
|
entry.pop("sync_error", None)
|
||||||
@@ -1266,9 +1317,16 @@ def get_library_item(slug: str, item_id: str):
|
|||||||
raise HTTPException(status_code=404, detail="Content item not found")
|
raise HTTPException(status_code=404, detail="Content item not found")
|
||||||
|
|
||||||
payload = dict(entry)
|
payload = dict(entry)
|
||||||
if entry.get("kind") == "text":
|
if entry.get("kind") in {"text", "website"}:
|
||||||
path = Path(str(entry.get("path") or ""))
|
path = Path(str(entry.get("path") or ""))
|
||||||
payload["content"] = path.read_text(encoding="utf-8") if path.exists() else ""
|
payload["content"] = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||||
|
payload["content_origin"] = "stored_snapshot" if entry.get("kind") == "website" else "stored_text"
|
||||||
|
payload["content_truncated"] = False
|
||||||
|
else:
|
||||||
|
content, truncated = _read_indexed_file_text(slug, entry)
|
||||||
|
payload["content"] = content
|
||||||
|
payload["content_origin"] = "extracted_text"
|
||||||
|
payload["content_truncated"] = truncated
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export default function LibraryManager({ apiBase, library, jobs, onRefresh }) {
|
|||||||
const [textContent, setTextContent] = useState('')
|
const [textContent, setTextContent] = useState('')
|
||||||
const [websiteTitle, setWebsiteTitle] = useState('')
|
const [websiteTitle, setWebsiteTitle] = useState('')
|
||||||
const [websiteUrl, setWebsiteUrl] = useState('')
|
const [websiteUrl, setWebsiteUrl] = useState('')
|
||||||
|
const [expandedItemId, setExpandedItemId] = useState(null)
|
||||||
|
const [contentPreviews, setContentPreviews] = useState({})
|
||||||
const toastTimeoutsRef = useRef(new Map())
|
const toastTimeoutsRef = useRef(new Map())
|
||||||
const toastIdRef = useRef(0)
|
const toastIdRef = useRef(0)
|
||||||
const previousLibraryStateRef = useRef(null)
|
const previousLibraryStateRef = useRef(null)
|
||||||
@@ -122,6 +124,28 @@ export default function LibraryManager({ apiBase, library, jobs, onRefresh }) {
|
|||||||
setWebsiteUrl('')
|
setWebsiteUrl('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleContentPreview(item) {
|
||||||
|
const itemId = item?.item_id
|
||||||
|
if (!library || !itemId) return
|
||||||
|
if (expandedItemId === itemId) {
|
||||||
|
setExpandedItemId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExpandedItemId(itemId)
|
||||||
|
if (contentPreviews[itemId]) return
|
||||||
|
setContentPreviews(current => ({ ...current, [itemId]: { loading: true } }))
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBase}/libraries/${library.slug}/items/${itemId}`)
|
||||||
|
const data = await expectJson(response)
|
||||||
|
setContentPreviews(current => ({ ...current, [itemId]: { data } }))
|
||||||
|
} catch (error) {
|
||||||
|
setContentPreviews(current => ({
|
||||||
|
...current,
|
||||||
|
[itemId]: { error: String(error?.message || error) }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runAction(fn, successMessage) {
|
async function runAction(fn, successMessage) {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setErrorMessage('')
|
setErrorMessage('')
|
||||||
@@ -372,8 +396,11 @@ export default function LibraryManager({ apiBase, library, jobs, onRefresh }) {
|
|||||||
const sync = itemSyncMeta(item)
|
const sync = itemSyncMeta(item)
|
||||||
const label = kindLabel(item)
|
const label = kindLabel(item)
|
||||||
const source = item.kind === 'website' ? item.url : item.kind === 'text' ? 'Written in Heimgeist' : item.path
|
const source = item.kind === 'website' ? item.url : item.kind === 'text' ? 'Written in Heimgeist' : item.path
|
||||||
|
const itemId = item.item_id || item.sha256 || item.rel
|
||||||
|
const isExpanded = expandedItemId === itemId
|
||||||
|
const preview = contentPreviews[itemId]
|
||||||
return (
|
return (
|
||||||
<article key={item.item_id || item.sha256 || item.rel} className={`library-content-card kind-${item.kind || 'file'}`}>
|
<article key={itemId} className={`library-content-card kind-${item.kind || 'file'} ${isExpanded ? 'expanded' : ''}`}>
|
||||||
<div className="library-content-card-header">
|
<div className="library-content-card-header">
|
||||||
<span className="library-kind-badge">{label}</span>
|
<span className="library-kind-badge">{label}</span>
|
||||||
<span className={`library-file-sync-label ${sync.status}`}>{sync.label}</span>
|
<span className={`library-file-sync-label ${sync.status}`}>{sync.label}</span>
|
||||||
@@ -389,7 +416,25 @@ export default function LibraryManager({ apiBase, library, jobs, onRefresh }) {
|
|||||||
<div className="library-file-progress-bar" style={{ width: `${sync.progress}%` }} />
|
<div className="library-file-progress-bar" style={{ width: `${sync.progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="library-content-preview">
|
||||||
|
<div className="library-content-preview-label">
|
||||||
|
{item.kind === 'website' ? 'Saved website text' : item.kind === 'text' ? 'Stored text' : 'Extracted text used by RAG'}
|
||||||
|
</div>
|
||||||
|
{preview?.loading && <div className="muted-copy">Loading content…</div>}
|
||||||
|
{preview?.error && <div className="form-error">{preview.error}</div>}
|
||||||
|
{preview?.data && (
|
||||||
|
<>
|
||||||
|
<pre>{preview.data.content || 'No readable text was extracted from this item.'}</pre>
|
||||||
|
{preview.data.content_truncated && <div className="muted-copy">Preview shortened. The indexed source contains more text.</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="library-file-actions">
|
<div className="library-file-actions">
|
||||||
|
<button className="button ghost" disabled={busy} onClick={() => toggleContentPreview(item)}>
|
||||||
|
{isExpanded ? 'Hide Content' : 'View Content'}
|
||||||
|
</button>
|
||||||
{item.kind === 'text' && <button className="button ghost" disabled={busy} onClick={() => editText(item)}>Edit</button>}
|
{item.kind === 'text' && <button className="button ghost" disabled={busy} onClick={() => editText(item)}>Edit</button>}
|
||||||
{item.kind === 'website' && <button className="button ghost" onClick={() => desktopApi.openExternalLink(item.url)}>Open</button>}
|
{item.kind === 'website' && <button className="button ghost" onClick={() => desktopApi.openExternalLink(item.url)}>Open</button>}
|
||||||
{item.kind === 'website' && <button className="button ghost" disabled={busy} onClick={() => refreshWebsite(item)}>Refresh</button>}
|
{item.kind === 'website' && <button className="button ghost" disabled={busy} onClick={() => refreshWebsite(item)}>Refresh</button>}
|
||||||
|
|||||||
@@ -1796,6 +1796,43 @@ input:checked + .slider:before {
|
|||||||
border-color: color-mix(in srgb, #69c4a1 34%, var(--border));
|
border-color: color-mix(in srgb, #69c4a1 34%, var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.library-content-card.expanded {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-content-preview {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--bg) 82%, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-content-preview-label {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-content-preview pre {
|
||||||
|
max-height: 420px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-content-preview .muted-copy {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.library-kind-badge {
|
.library-kind-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user