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: