v1.9.0 -> v1.10.0: file upload UI + attachment management

- 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
This commit is contained in:
gramps
2026-07-03 12:18:06 -07:00
parent 4a891c8435
commit 81238c0d7f
8 changed files with 474 additions and 17 deletions
@@ -141,6 +141,69 @@ def test_chat_auto_search_trigger_emits_search_events(tmp_path: Path, monkeypatc
assert done_events and done_events[-1].get("searched") is True
def test_chat_with_upload_context_id_injects_document(tmp_path: Path, monkeypatch):
captured_payload = {}
def stream_stub(self, method, url, json=None, timeout=None):
nonlocal captured_payload
captured_payload = json
events = [{"message": {"content": "ok"}, "logprobs": [{"logprob": -0.01}]}, {"done": True, "eval_count": 1, "eval_duration": 1000000000}]
return _MockStreamResponse([__import__('json').dumps(e) for e in events])
monkeypatch.setattr(httpx.AsyncClient, "stream", stream_stub)
with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
db_local = db.get_db()
expires = "2099-12-31T23:59:59+00:00"
cid = db.insert_upload_context(db_local, "conv-up", "report.txt", "Confidential document content here", expires, "text/plain")
db_local.commit()
db_local.close()
resp = client.post(
"/api/chat",
json={"message": "summarize this", "upload_context_id": cid, "model": config.DEFAULT_MODEL},
headers=headers,
)
assert resp.status_code == 200
system_content = next((m["content"] for m in captured_payload.get("messages", []) if m["role"] == "system"), "")
assert "Confidential document content here" in system_content
def test_chat_with_expired_upload_context_id_silent(tmp_path: Path, monkeypatch):
captured_payload = {}
def stream_stub(self, method, url, json=None, timeout=None):
nonlocal captured_payload
captured_payload = json
events = [{"message": {"content": "ok"}, "logprobs": [{"logprob": -0.01}]}, {"done": True, "eval_count": 1, "eval_duration": 1000000000}]
return _MockStreamResponse([__import__('json').dumps(e) for e in events])
monkeypatch.setattr(httpx.AsyncClient, "stream", stream_stub)
with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
import datetime
db_local = db.get_db()
expires = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=2)).isoformat()
cid = db.insert_upload_context(db_local, "conv-exp", "old.txt", "Stale data", expires, "text/plain")
db_local.commit()
db_local.close()
resp = client.post(
"/api/chat",
json={"message": "hi", "upload_context_id": cid, "model": config.DEFAULT_MODEL},
headers=headers,
)
assert resp.status_code == 200
system_content = next((m["content"] for m in captured_payload.get("messages", []) if m["role"] == "system"), "")
assert "Stale data" not in system_content
def test_memory_command_paths_remember_and_forget(tmp_path: Path, monkeypatch):
with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[
+120
View File
@@ -152,3 +152,123 @@ def test_upload_both_mode(tmp_path: Path, monkeypatch):
assert data["mode"] == "both"
assert "context_id" in data
assert data["chunks_ingested"] > 0
def test_upload_image_type(tmp_path: Path):
with make_client(tmp_path) as client:
resp = client.post(
"/api/upload", headers=_admin_headers(client),
data={"mode": "context"},
files={"file": ("photo.png", b"fake-png", "image/png")},
)
assert resp.status_code == 200
data = resp.json()
assert data["filename"] == "photo.png"
assert "context_id" in data
def test_get_upload_by_conversation(tmp_path: Path):
with make_client(tmp_path) as client:
headers = _admin_headers(client)
resp1 = client.post("/api/upload", headers=headers,
data={"mode": "context", "conversation_id": "conv-gal"},
files={"file": ("a.txt", b"alpha")})
cid1 = resp1.json()["context_id"]
resp2 = client.post("/api/upload", headers=headers,
data={"mode": "context", "conversation_id": "conv-gal"},
files={"file": ("b.txt", b"beta")})
cid2 = resp2.json()["context_id"]
gal = client.get("/api/upload/by-conversation/conv-gal", headers=headers)
assert gal.status_code == 200
items = gal.json()
assert len(items) == 2
assert items[0]["filename"] == "a.txt"
assert items[1]["filename"] == "b.txt"
def test_link_upload_to_conversation(tmp_path: Path):
with make_client(tmp_path) as client:
headers = _admin_headers(client)
resp = client.post("/api/upload", headers=headers,
data={"mode": "context"},
files={"file": ("orphan.txt", b"lonely")})
cid = resp.json()["context_id"]
link = client.patch(f"/api/upload/{cid}/link", headers=headers,
json={"conversation_id": "new-conv"})
assert link.status_code == 200
gal = client.get("/api/upload/by-conversation/new-conv", headers=headers)
assert len(gal.json()) == 1
def test_delete_upload_removes_context(tmp_path: Path, monkeypatch):
class FakeAsyncClient:
class FakeResponse:
def __init__(self, status, json_data=None):
self.status_code = status
self._json = json_data or {}
def json(self):
return self._json
def __init__(self, *a, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kw):
if "/points/scroll" in url:
return self.FakeResponse(200, {"result": {"points": [{"id": "upload-test.txt-0"}, {"id": "upload-test.txt-1"}]}})
if "/points/delete" in url:
return self.FakeResponse(200)
return self.FakeResponse(200)
async def put(self, url, **kw):
return self.FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
with make_client(tmp_path) as client:
headers = _admin_headers(client)
resp = client.post("/api/upload", headers=headers,
data={"mode": "context", "conversation_id": "del-test"},
files={"file": ("test.txt", b"delete me")})
cid = resp.json()["context_id"]
del_resp = client.delete(f"/api/upload/{cid}", headers=headers)
assert del_resp.status_code == 200
assert del_resp.json()["status"] == "ok"
row = db.get_db().execute("SELECT id FROM upload_context WHERE id = ?", (cid,)).fetchone()
assert row is None
def test_delete_upload_not_found(tmp_path: Path):
with make_client(tmp_path) as client:
resp = client.delete("/api/upload/999", headers=_admin_headers(client))
assert resp.status_code == 404
def test_conversation_list_includes_attachment_count(tmp_path: Path):
with make_client(tmp_path) as client:
headers = _admin_headers(client)
client.post("/api/conversations", headers=headers, json={"title": "NoAttach"})
with_attach = client.post("/api/conversations", headers=headers, json={"title": "WithAttach"}).json()
conv_id = with_attach["id"]
client.post("/api/upload", headers=headers,
data={"mode": "context", "conversation_id": conv_id},
files={"file": ("f.txt", b"data")})
list_resp = client.get("/api/conversations", headers=headers)
convs = list_resp.json()
for c in convs:
if c["title"] == "NoAttach":
assert c["attachment_count"] == 0
if c["title"] == "WithAttach":
assert c["attachment_count"] == 1