diff --git a/backend/local_rag.py b/backend/local_rag.py index 5fe6dad..052fcf4 100644 --- a/backend/local_rag.py +++ b/backend/local_rag.py @@ -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]]: 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, ) +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: entry["sync_status"] = "pending" 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") payload = dict(entry) - if entry.get("kind") == "text": + if entry.get("kind") in {"text", "website"}: path = Path(str(entry.get("path") or "")) 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 diff --git a/src/LibraryManager.jsx b/src/LibraryManager.jsx index e8b7990..3484f93 100644 --- a/src/LibraryManager.jsx +++ b/src/LibraryManager.jsx @@ -75,6 +75,8 @@ export default function LibraryManager({ apiBase, library, jobs, onRefresh }) { const [textContent, setTextContent] = useState('') const [websiteTitle, setWebsiteTitle] = useState('') const [websiteUrl, setWebsiteUrl] = useState('') + const [expandedItemId, setExpandedItemId] = useState(null) + const [contentPreviews, setContentPreviews] = useState({}) const toastTimeoutsRef = useRef(new Map()) const toastIdRef = useRef(0) const previousLibraryStateRef = useRef(null) @@ -122,6 +124,28 @@ export default function LibraryManager({ apiBase, library, jobs, onRefresh }) { 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) { setBusy(true) setErrorMessage('') @@ -372,8 +396,11 @@ export default function LibraryManager({ apiBase, library, jobs, onRefresh }) { const sync = itemSyncMeta(item) const label = kindLabel(item) 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 ( -
+
{label} {sync.label} @@ -389,7 +416,25 @@ export default function LibraryManager({ apiBase, library, jobs, onRefresh }) {
+ {isExpanded && ( +
+
+ {item.kind === 'website' ? 'Saved website text' : item.kind === 'text' ? 'Stored text' : 'Extracted text used by RAG'} +
+ {preview?.loading &&
Loading content…
} + {preview?.error &&
{preview.error}
} + {preview?.data && ( + <> +
{preview.data.content || 'No readable text was extracted from this item.'}
+ {preview.data.content_truncated &&
Preview shortened. The indexed source contains more text.
} + + )} +
+ )}
+ {item.kind === 'text' && } {item.kind === 'website' && } {item.kind === 'website' && } diff --git a/src/styles.css b/src/styles.css index ccc9e45..c2dcc18 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1796,6 +1796,43 @@ input:checked + .slider:before { 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 { display: inline-flex; padding: 4px 8px;