diff --git a/frontend/src/views/KnowledgeView.vue b/frontend/src/views/KnowledgeView.vue
index 0463746..bb3a2cf 100644
--- a/frontend/src/views/KnowledgeView.vue
+++ b/frontend/src/views/KnowledgeView.vue
@@ -1,5 +1,5 @@
@@ -375,7 +435,7 @@ onUnmounted(() => {
-
+
-
-
Loading…
+
+
+ Loading…
+
@@ -869,11 +931,15 @@ onUnmounted(() => {
}
.empty-hint { font-size: 0.85rem; opacity: 0.7; }
-/* ── Load more indicator ─────────────────────────────────── */
-.load-more-indicator {
+/* ── Sentinel ────────────────────────────────────────────── */
+.scroll-sentinel {
grid-column: 1 / -1;
- padding: 16px;
- text-align: center;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.sentinel-loading {
font-size: 0.8rem;
color: var(--color-muted);
}
diff --git a/src/fabledassistant/routes/knowledge.py b/src/fabledassistant/routes/knowledge.py
index aaa5331..9625436 100644
--- a/src/fabledassistant/routes/knowledge.py
+++ b/src/fabledassistant/routes/knowledge.py
@@ -62,6 +62,63 @@ async def list_knowledge():
})
+@knowledge_bp.route("/ids", methods=["GET"])
+@login_required
+async def list_knowledge_ids():
+ """Return note IDs only (cheap) for the two-tier pagination feed.
+
+ Same filter params as GET /api/knowledge.
+ Additional params: limit (default 100, max 200), offset (default 0).
+ Returns {ids, total, has_more}.
+ """
+ uid = get_current_user_id()
+ note_type = request.args.get("type", "").strip().lower() or None
+ tags_raw = request.args.get("tags", "").strip()
+ tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else []
+ sort = request.args.get("sort", "modified").strip().lower()
+ q = request.args.get("q", "").strip() or None
+ if sort not in _VALID_SORTS:
+ sort = "modified"
+ try:
+ limit = min(int(request.args.get("limit", 100)), 200)
+ offset = max(0, int(request.args.get("offset", 0)))
+ except ValueError:
+ return jsonify({"error": "Invalid limit or offset"}), 400
+
+ if note_type and note_type not in _VALID_TYPES:
+ return jsonify({"error": "Invalid type"}), 400
+
+ from fabledassistant.services.knowledge import query_knowledge_ids
+ ids, total = await query_knowledge_ids(
+ user_id=uid, note_type=note_type, tags=tags,
+ sort=sort, q=q, limit=limit, offset=offset,
+ )
+ return jsonify({"ids": ids, "total": total, "has_more": (offset + len(ids)) < total})
+
+
+@knowledge_bp.route("/batch", methods=["GET"])
+@login_required
+async def get_knowledge_batch():
+ """Fetch full items for a comma-separated list of IDs (max 100).
+
+ Returns {items: [...]} in the order of the requested IDs.
+ """
+ uid = get_current_user_id()
+ ids_raw = request.args.get("ids", "").strip()
+ if not ids_raw:
+ return jsonify({"items": []})
+ try:
+ ids = [int(x) for x in ids_raw.split(",") if x.strip()]
+ except ValueError:
+ return jsonify({"error": "Invalid IDs"}), 400
+ if len(ids) > 100:
+ return jsonify({"error": "Too many IDs (max 100)"}), 400
+
+ from fabledassistant.services.knowledge import get_knowledge_by_ids
+ items = await get_knowledge_by_ids(uid, ids)
+ return jsonify({"items": items})
+
+
@knowledge_bp.route("/tags", methods=["GET"])
@login_required
async def list_knowledge_tags():
diff --git a/src/fabledassistant/services/knowledge.py b/src/fabledassistant/services/knowledge.py
index ab79455..02f6249 100644
--- a/src/fabledassistant/services/knowledge.py
+++ b/src/fabledassistant/services/knowledge.py
@@ -167,6 +167,69 @@ async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list
return [r for r in rows if r]
+async def query_knowledge_ids(
+ user_id: int,
+ note_type: str | None,
+ tags: list[str],
+ sort: str,
+ q: str | None,
+ limit: int = 100,
+ offset: int = 0,
+) -> tuple[list[int], int]:
+ """Return note IDs only — cheap query for the two-tier pagination feed."""
+ if q:
+ # Re-use semantic search, extract IDs in rank order
+ items, total = await _semantic_knowledge_search(
+ user_id, q, note_type=note_type, tags=tags,
+ limit=limit, offset=offset,
+ )
+ return [item["id"] for item in items], total
+
+ async with async_session() as session:
+ base = (
+ select(Note.id)
+ .where(Note.user_id == user_id)
+ .where(Note.status.is_(None))
+ )
+ if note_type:
+ base = base.where(Note.note_type == note_type)
+ else:
+ base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
+ for tag in tags:
+ base = base.where(Note.tags.contains([tag]))
+
+ count_stmt = select(func.count()).select_from(base.subquery())
+ total: int = (await session.execute(count_stmt)).scalar_one()
+
+ if sort == "created":
+ base = base.order_by(Note.created_at.desc())
+ elif sort == "alpha":
+ base = base.order_by(Note.title.asc())
+ elif sort == "type":
+ base = base.order_by(Note.note_type.asc(), Note.updated_at.desc())
+ else:
+ base = base.order_by(Note.updated_at.desc())
+
+ ids = list((await session.execute(base.limit(limit).offset(offset))).scalars().all())
+
+ return ids, total
+
+
+async def get_knowledge_by_ids(user_id: int, ids: list[int]) -> list[dict]:
+ """Fetch full items for the given IDs, preserving the requested order."""
+ if not ids:
+ return []
+ async with async_session() as session:
+ stmt = (
+ select(Note)
+ .where(Note.user_id == user_id)
+ .where(Note.id.in_(ids))
+ )
+ rows = list((await session.execute(stmt)).scalars().all())
+ by_id = {n.id: n for n in rows}
+ return [_note_to_item(by_id[i]) for i in ids if i in by_id]
+
+
async def get_people_and_places_context(user_id: int) -> str:
"""Return a compact summary of known people and places for LLM system prompt injection."""
async with async_session() as session: