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:
@@ -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()[
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user