feat(ui): clickable document viewer — open any cited document in the browser from chat chips and the sources table

This commit is contained in:
2026-08-22 02:08:49 -04:00
parent 7e8d14702e
commit 6ec6181c7b
15 changed files with 1218 additions and 58 deletions
+48 -3
View File
@@ -1,17 +1,32 @@
"""GET /api/docs — the indexed document list (feeds the Sources page)."""
"""GET /api/docs — the indexed document list (feeds the Sources page).
GET /api/documents/content — one indexed document's full content (feeds the
clickable document viewer, phase 10). DB-only by design: the (source, path)
pair is looked up as a row, so there is no filesystem access and no
path-traversal surface — ``../``-style values simply aren't rows (→ 404).
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.db import get_db
from app.models import Chunk, Document
from app.schemas import DocList, DocSummary
from app.schemas import DocContent, DocList, DocSummary
router = APIRouter(tags=["kb"])
def doc_format(path: str) -> str:
"""Lowercased path suffix without its dot (``kubernetes.md`` → ``md``,
``notes/deep.Markdown`` → ``markdown``); ``text`` when the path has no
suffix — the value shown in the viewer's format badge."""
return Path(path).suffix.lower().lstrip(".") or "text"
@router.get("/docs", response_model=DocList)
def list_documents(db: Session = Depends(get_db)) -> DocList: # noqa: B008
"""All indexed documents with per-document chunk counts.
@@ -45,3 +60,33 @@ def list_documents(db: Session = Depends(get_db)) -> DocList: # noqa: B008
for row in rows
]
)
@router.get("/documents/content", response_model=DocContent)
def get_document_content(
source: str, path: str, db: Session = Depends(get_db) # noqa: B008
) -> DocContent:
"""Full content of one indexed document, looked up by ``(source, path)``.
Stateless (A10) and database-only: unknown pairs — including traversal
strings such as ``../../etc/passwd`` — are just non-existent rows and
map to 404 ``{detail: "document not found"}``.
"""
row = db.execute(
select(Document, func.count(Chunk.id).label("chunks"))
.outerjoin(Chunk, Chunk.document_id == Document.id)
.where(Document.source == source, Document.path == path)
.group_by(Document.id)
).first()
if row is None:
raise HTTPException(status_code=404, detail="document not found")
doc, chunks = row
return DocContent(
source=doc.source,
path=doc.path,
title=doc.title,
format=doc_format(doc.path),
content=doc.content,
indexed_at=doc.indexed_at.isoformat(),
chunks=chunks,
)
+12
View File
@@ -61,3 +61,15 @@ class DocList(BaseModel):
"""Response of ``GET /api/docs`` (empty list → designed empty state)."""
documents: list[DocSummary]
class DocContent(BaseModel):
"""One indexed document's full content (feeds the viewer page, phase 10)."""
source: str
path: str
title: str
format: str
content: str
indexed_at: str
chunks: int