81238c0d7f
- Paperclip icon left of text input, file preview pill (image thumb or file icon)
- Conversation list shows attachment icon right of trash, opens gallery overlay
- Gallery overlay: scrollable, close (X), delete attachment per item
- DELETE /api/upload/{id} removes from SQLite + Qdrant
- PATCH /api/upload/{id}/link ties upload to conversation
- GET /api/upload/by-conversation/{id} lists attachments
- Chat accepts upload_context_id, injects [ATTACHED DOCUMENT] into system prompt
- Conversation list includes attachment_count field
- 8 new tests: upload context injection, delete, link, by-conversation, image type, attachment count
Still missing (P3): drag-and-drop upload, global attachments page, file download, batch upload
169 lines
6.6 KiB
Python
169 lines
6.6 KiB
Python
"""JarvisChat routers - /api/upload file/document attachment endpoint."""
|
|
import json
|
|
import logging
|
|
import os
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from config import UPLOAD_DIR, MAX_UPLOAD_BYTES, SUPPORTED_UPLOAD_TYPES, UPLOAD_CONTEXT_EXPIRY_HOURS
|
|
from db import get_db, insert_upload_context, list_upload_context_by_conversation, delete_upload_context_by_id
|
|
from rag import chunk_text, QDRANT_URL, EMBED_URL, EMBED_MODEL, RAG_COLLECTION
|
|
|
|
log = logging.getLogger("jarvischat")
|
|
router = APIRouter()
|
|
|
|
|
|
def _point_id(filename: str, chunk_idx: int) -> str:
|
|
return f"upload-{filename}-{chunk_idx}"
|
|
|
|
|
|
@router.post("/api/upload")
|
|
async def upload_file(
|
|
request: Request,
|
|
file: UploadFile = File(...),
|
|
mode: str = Form("both"),
|
|
conversation_id: str = Form(""),
|
|
):
|
|
if mode not in ("context", "ingest", "both"):
|
|
raise HTTPException(status_code=422, detail="mode must be context, ingest, or both")
|
|
|
|
if file.size and file.size > MAX_UPLOAD_BYTES:
|
|
return JSONResponse(status_code=413, content={"detail": f"File exceeds {MAX_UPLOAD_BYTES} byte limit"})
|
|
|
|
content_type = file.content_type or "application/octet-stream"
|
|
if content_type not in SUPPORTED_UPLOAD_TYPES:
|
|
return JSONResponse(status_code=415, content={"detail": f"Unsupported file type: {content_type}"})
|
|
|
|
raw_bytes = await file.read()
|
|
if not raw_bytes:
|
|
raise HTTPException(status_code=422, detail="Empty file")
|
|
|
|
if content_type == "application/pdf":
|
|
try:
|
|
from pypdf import PdfReader
|
|
import io
|
|
reader = PdfReader(io.BytesIO(raw_bytes))
|
|
extracted = "\n".join(page.extract_text() or "" for page in reader.pages)
|
|
except Exception as e:
|
|
log.warning(f"PDF extraction error: {e}")
|
|
raise HTTPException(status_code=422, detail="Failed to extract text from PDF")
|
|
else:
|
|
extracted = raw_bytes.decode("utf-8", errors="replace")
|
|
|
|
result = {"filename": file.filename, "size_bytes": len(raw_bytes), "mode": mode}
|
|
|
|
if mode in ("ingest", "both"):
|
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
|
chunks = chunk_text(extracted)
|
|
ingested = 0
|
|
async with httpx.AsyncClient() as client:
|
|
for i, chunk in enumerate(chunks):
|
|
embed_resp = await client.post(
|
|
f"{EMBED_URL}/api/embeddings",
|
|
json={"model": EMBED_MODEL, "prompt": chunk},
|
|
timeout=30.0,
|
|
)
|
|
if embed_resp.status_code != 200:
|
|
log.warning(f"Embedding failed for chunk {i}: {embed_resp.status_code}")
|
|
continue
|
|
vector = embed_resp.json()["embedding"]
|
|
pid = _point_id(file.filename or "unnamed", i)
|
|
upsert_resp = await client.put(
|
|
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points?wait=true",
|
|
json={
|
|
"points": [{
|
|
"id": pid,
|
|
"vector": vector,
|
|
"payload": {"text": chunk, "source": file.filename, "upload_date": datetime.now(timezone.utc).isoformat(), "type": "upload"},
|
|
}]
|
|
},
|
|
timeout=30.0,
|
|
)
|
|
if upsert_resp.status_code in (200, 201):
|
|
ingested += 1
|
|
else:
|
|
log.warning(f"Qdrant upsert failed for chunk {i}: {upsert_resp.status_code}")
|
|
result["chunks_ingested"] = ingested
|
|
|
|
if mode in ("context", "both"):
|
|
expires = (datetime.now(timezone.utc) + timedelta(hours=UPLOAD_CONTEXT_EXPIRY_HOURS)).isoformat()
|
|
db = get_db()
|
|
try:
|
|
cid = insert_upload_context(db, conversation_id or "", file.filename or "unnamed", extracted, expires, content_type)
|
|
db.commit()
|
|
result["context_id"] = cid
|
|
finally:
|
|
db.close()
|
|
|
|
result["message"] = f"Uploaded {file.filename}"
|
|
return result
|
|
|
|
|
|
@router.patch("/api/upload/{context_id}/link")
|
|
async def link_upload_to_conversation(context_id: int, request: Request):
|
|
body = await request.json()
|
|
conv_id = body.get("conversation_id", "").strip()
|
|
if not conv_id:
|
|
raise HTTPException(status_code=422, detail="conversation_id required")
|
|
db = get_db()
|
|
try:
|
|
row = db.execute("SELECT id FROM upload_context WHERE id = ?", (context_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Upload context not found")
|
|
db.execute("UPDATE upload_context SET conversation_id = ? WHERE id = ?", (conv_id, context_id))
|
|
db.commit()
|
|
return {"status": "ok"}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/api/upload/by-conversation/{conv_id}")
|
|
async def get_upload_by_conversation(conv_id: str):
|
|
db = get_db()
|
|
try:
|
|
items = list_upload_context_by_conversation(db, conv_id)
|
|
return items
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.delete("/api/upload/{context_id}")
|
|
async def delete_upload(context_id: int):
|
|
db = get_db()
|
|
try:
|
|
row = db.execute("SELECT filename FROM upload_context WHERE id = ?", (context_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Attachment not found")
|
|
filename = row["filename"]
|
|
if not filename:
|
|
filename = "unnamed"
|
|
delete_upload_context_by_id(db, context_id)
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
try:
|
|
async with httpx.AsyncClient() as client:
|
|
scroll_resp = await client.post(
|
|
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/scroll",
|
|
json={"filter": {"must": [{"key": "source", "match": {"value": filename}}]}, "limit": 100, "with_payload": False},
|
|
timeout=10.0,
|
|
)
|
|
if scroll_resp.status_code == 200:
|
|
points = scroll_resp.json().get("result", {}).get("points", [])
|
|
point_ids = [p["id"] for p in points]
|
|
if point_ids:
|
|
await client.post(
|
|
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/delete",
|
|
json={"points": point_ids},
|
|
timeout=10.0,
|
|
)
|
|
log.info(f"Deleted {len(point_ids)} Qdrant points for source '{filename}'")
|
|
except Exception as e:
|
|
log.warning(f"Qdrant cleanup for '{filename}' failed: {e}")
|
|
|
|
return {"status": "ok", "filename": filename}
|