From 81238c0d7f0f13ea3f9ef31679725b528bb8351c Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 3 Jul 2026 12:18:06 -0700 Subject: [PATCH] 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 --- config.py | 4 +- db.py | 26 ++- routers/chat.py | 16 +- routers/conversations.py | 10 +- routers/upload.py | 78 +++++++- templates/index.html | 174 +++++++++++++++++- tests/test_chat_streaming_and_memory_paths.py | 63 +++++++ tests/test_upload.py | 120 ++++++++++++ 8 files changed, 474 insertions(+), 17 deletions(-) diff --git a/config.py b/config.py index a552c61..f9a4bec 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,7 @@ import logging log = logging.getLogger("jarvischat") -VERSION = "v1.9.0" +VERSION = "v1.10.0" OLLAMA_BASE = os.environ.get("OLLAMA_BASE", "http://localhost:11434") LLAMA_SERVER_BASE = os.environ.get("LLAMA_SERVER_BASE", "http://192.168.50.108:8081") SEARXNG_BASE = "http://localhost:8888" @@ -49,7 +49,7 @@ BODY_LIMIT_PROFILE_BYTES = 256 * 1024 # --- Upload --- UPLOAD_DIR = "/tmp/jarvischat_uploads" MAX_UPLOAD_BYTES = 20 * 1024 * 1024 -SUPPORTED_UPLOAD_TYPES = {"text/plain", "text/markdown", "application/pdf", "application/json", "text/x-python", "text/html"} +SUPPORTED_UPLOAD_TYPES = {"text/plain", "text/markdown", "application/pdf", "application/json", "text/x-python", "text/html", "image/png", "image/jpeg", "image/gif", "image/svg+xml", "image/webp"} UPLOAD_CONTEXT_EXPIRY_HOURS = 1 BODY_LIMIT_UPLOAD_BYTES = MAX_UPLOAD_BYTES diff --git a/db.py b/db.py index 5fa0750..60d0422 100644 --- a/db.py +++ b/db.py @@ -68,18 +68,31 @@ def format_active_skills_prompt(skills: list) -> str: return text -def insert_upload_context(db, conversation_id: str, filename: str, content: str, expires_at: str) -> int: +def insert_upload_context(db, conversation_id: str, filename: str, content: str, expires_at: str, content_type: str = "text/plain") -> int: now = datetime.now(timezone.utc).isoformat() cur = db.execute( - "INSERT INTO upload_context (conversation_id, filename, content, created_at, expires_at) VALUES (?, ?, ?, ?, ?)", - (conversation_id, filename, content, now, expires_at), + "INSERT INTO upload_context (conversation_id, filename, content, content_type, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)", + (conversation_id, filename, content, content_type, now, expires_at), ) return cur.lastrowid +def list_upload_context_by_conversation(db, conversation_id: str): + rows = db.execute( + "SELECT id, conversation_id, filename, content_type, created_at, expires_at FROM upload_context WHERE conversation_id = ? ORDER BY id ASC", + (conversation_id,), + ).fetchall() + return [dict(r) for r in rows] + + +def delete_upload_context_by_id(db, context_id: int) -> bool: + cur = db.execute("DELETE FROM upload_context WHERE id = ?", (context_id,)) + return cur.rowcount > 0 + + def get_upload_context(db, context_id: int): row = db.execute( - "SELECT id, conversation_id, filename, content, expires_at FROM upload_context WHERE id = ?", + "SELECT id, conversation_id, filename, content, content_type, expires_at FROM upload_context WHERE id = ?", (context_id,), ).fetchone() if not row: @@ -138,10 +151,15 @@ def init_db(): conversation_id TEXT, filename TEXT NOT NULL, content TEXT NOT NULL, + content_type TEXT DEFAULT 'text/plain', created_at TEXT NOT NULL, expires_at TEXT NOT NULL ) """) + try: + conn.execute("ALTER TABLE upload_context ADD COLUMN content_type TEXT DEFAULT 'text/plain'") + except Exception: + pass if not conn.execute("SELECT id FROM profile WHERE id = 1").fetchone(): now = datetime.now(timezone.utc).isoformat() diff --git a/routers/chat.py b/routers/chat.py index c7a41d2..662622f 100644 --- a/routers/chat.py +++ b/routers/chat.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, HTTPException, Request from fastapi.responses import StreamingResponse from config import DEFAULT_MODEL, LLAMA_SERVER_BASE -from db import get_db +from db import get_db, get_upload_context from memory import process_remember_command from rag import build_system_prompt from search import (calculate_perplexity, is_uncertain, is_refusal, @@ -69,6 +69,7 @@ async def chat(request: Request): raise HTTPException(status_code=413, detail="Chat message is too long") model = body.get("model", DEFAULT_MODEL) preset_prompt = body.get("system_prompt", "") + upload_context_id = body.get("upload_context_id") if not user_message: raise HTTPException(status_code=400, detail="Empty message") @@ -78,6 +79,14 @@ async def chat(request: Request): settings = {row["key"]: row["value"] for row in db.execute("SELECT key, value FROM settings").fetchall()} search_enabled = settings.get("search_enabled", "true") == "true" + upload_doc = None + if upload_context_id: + ctx = get_upload_context(db, upload_context_id) + if ctx: + upload_doc = f"[ATTACHED DOCUMENT: {ctx['filename']}]\n{ctx['content']}\n[END DOCUMENT]" + else: + log.warning(f"upload_context_id {upload_context_id} not found or expired, continuing without it") + remember_response = process_remember_command(user_message) if not conv_id: @@ -95,7 +104,10 @@ async def chat(request: Request): history_rows = db.execute( "SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY id ASC", (conv_id,) ).fetchall() - system_prompt = await build_system_prompt(db, preset_prompt, user_message) + extra_prompt = preset_prompt + if upload_doc: + extra_prompt = (extra_prompt + "\n\n" + upload_doc) if extra_prompt else upload_doc + system_prompt = await build_system_prompt(db, extra_prompt, user_message) db.close() messages = [] diff --git a/routers/conversations.py b/routers/conversations.py index 0e693cd..2babf2a 100644 --- a/routers/conversations.py +++ b/routers/conversations.py @@ -15,8 +15,16 @@ router = APIRouter() async def list_conversations(): db = get_db() rows = db.execute("SELECT * FROM conversations ORDER BY updated_at DESC").fetchall() + result = [] + for r in rows: + c = dict(r) + attach_count = db.execute( + "SELECT COUNT(*) FROM upload_context WHERE conversation_id = ?", (c["id"],) + ).fetchone()[0] + c["attachment_count"] = attach_count + result.append(c) db.close() - return [dict(r) for r in rows] + return result @router.post("/api/conversations") diff --git a/routers/upload.py b/routers/upload.py index 7d06013..818a546 100644 --- a/routers/upload.py +++ b/routers/upload.py @@ -9,13 +9,17 @@ 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 +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, @@ -66,12 +70,12 @@ async def upload_file( log.warning(f"Embedding failed for chunk {i}: {embed_resp.status_code}") continue vector = embed_resp.json()["embedding"] - point_id = f"{file.filename}-{i}" + 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": point_id, + "id": pid, "vector": vector, "payload": {"text": chunk, "source": file.filename, "upload_date": datetime.now(timezone.utc).isoformat(), "type": "upload"}, }] @@ -88,7 +92,7 @@ async def upload_file( 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) + cid = insert_upload_context(db, conversation_id or "", file.filename or "unnamed", extracted, expires, content_type) db.commit() result["context_id"] = cid finally: @@ -96,3 +100,69 @@ async def upload_file( 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} diff --git a/templates/index.html b/templates/index.html index d4736a1..69d588c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -180,6 +180,39 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var( .search-btn { padding:12px 14px; background:var(--warning); border:none; border-radius:var(--radius); color:#fff; font-size:16px; cursor:pointer; transition:background 0.2s; } .search-btn:hover { background:#e67e22; } .search-btn:disabled { background:var(--text-muted); cursor:not-allowed; } +.paperclip-btn { padding:12px 12px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:var(--radius); color:var(--text-secondary); font-size:18px; cursor:pointer; transition:all 0.2s; line-height:1; } +.paperclip-btn:hover { background:var(--bg-hover); color:var(--accent); border-color:var(--accent-dim); } +.file-preview { max-width:900px; margin:0 auto 8px; display:none; align-items:center; gap:10px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:var(--radius); padding:8px 12px; } +.file-preview.visible { display:flex; } +.file-preview-thumb { width:48px; height:48px; object-fit:cover; border-radius:4px; border:1px solid var(--border); } +.file-preview-icon { font-size:28px; flex-shrink:0; color:var(--text-muted); } +.file-preview-info { flex:1; min-width:0; } +.file-preview-name { font-size:13px; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.file-preview-size { font-size:11px; color:var(--text-muted); } +.file-preview-clear { background:none; border:none; color:var(--text-muted); font-size:18px; cursor:pointer; padding:4px; line-height:1; transition:color 0.15s; } +.file-preview-clear:hover { color:var(--danger); } +.file-preview-notice { font-size:11px; color:var(--warning); } +.conv-attach { color:var(--text-muted); cursor:pointer; padding:2px 2px; font-size:14px; flex-shrink:0; transition:color 0.15s; margin-left:2px; } +.conv-attach:hover { color:var(--accent); } +.conv-attach.has-attachments { color:var(--accent-dim); } +.gallery-overlay { position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.85); z-index:10000; display:none; justify-content:center; align-items:center; } +.gallery-overlay.visible { display:flex; } +.gallery-panel { background:var(--bg-secondary); border:1px solid var(--border); border-radius:var(--radius); width:90%; max-width:700px; max-height:85vh; display:flex; flex-direction:column; } +.gallery-header { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); } +.gallery-header h3 { font-size:16px; color:var(--text-primary); font-family:var(--font-mono); } +.gallery-close { background:none; border:none; color:var(--text-muted); font-size:24px; cursor:pointer; padding:4px; line-height:1; transition:color 0.15s; } +.gallery-close:hover { color:var(--text-primary); } +.gallery-body { flex:1; overflow-y:auto; padding:12px 20px; } +.gallery-empty { text-align:center; padding:40px 20px; color:var(--text-muted); font-size:14px; } +.gallery-item { display:flex; align-items:center; gap:12px; padding:12px; border-radius:var(--radius); margin-bottom:8px; background:var(--bg-tertiary); transition:background 0.15s; } +.gallery-item:hover { background:var(--bg-hover); } +.gallery-item-thumb { width:60px; height:60px; object-fit:cover; border-radius:4px; border:1px solid var(--border); flex-shrink:0; } +.gallery-item-icon { font-size:32px; flex-shrink:0; color:var(--text-muted); text-align:center; width:60px; } +.gallery-item-info { flex:1; min-width:0; } +.gallery-item-name { font-size:14px; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.gallery-item-meta { font-size:11px; color:var(--text-muted); margin-top:2px; } +.gallery-item-delete { background:none; border:1px solid var(--danger); border-radius:var(--radius); color:var(--danger); font-size:12px; cursor:pointer; padding:6px 12px; transition:all 0.15s; flex-shrink:0; } +.gallery-item-delete:hover { background:var(--danger); color:#fff; } .token-thermometer { display:flex; flex-direction:column; align-items:center; gap:4px; } .thermometer-bar { width:12px; height:80px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; position:relative; overflow:hidden; } @@ -335,6 +368,15 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var( +
@@ -346,7 +388,19 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var( PRESET
+
+ + 📄 +
+
+
+
+
+ +
+ +
@@ -364,6 +418,7 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var( let currentConvId = null; let isStreaming = false; let abortController = null; +let selectedFile = null; let profileEnabled = true; let searchEnabled = true; let memoryEnabled = true; @@ -993,7 +1048,8 @@ async function loadConversations() { convs.forEach(c => { const div = document.createElement('div'); div.className = 'conv-item' + (c.id === currentConvId ? ' active' : ''); - div.innerHTML = `🗑${c.title}`; + const attachIcon = c.attachment_count > 0 ? `📎` : ''; + div.innerHTML = `🗑${attachIcon}${c.title}`; list.appendChild(div); }); } catch(e) {} @@ -1043,6 +1099,7 @@ function newChat() { updateTokenThermometer(); } +function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function showWelcome() { document.getElementById('chatContainer').innerHTML = '

JarvisChat — your local coding companion.
Profile + Memory context injected automatically.
Web search kicks in when the model is uncertain.
Use 🔍 to force a web search.
Say "remember that..." to teach me things.

'; } @@ -1128,6 +1185,84 @@ async function sendSearch() { } } +function onFileSelected(event) { + const file = event.target.files[0]; + if (!file) return; + selectedFile = file; + const preview = document.getElementById('filePreview'); + const thumb = document.getElementById('filePreviewThumb'); + const icon = document.getElementById('filePreviewIcon'); + const nameEl = document.getElementById('filePreviewName'); + const sizeEl = document.getElementById('filePreviewSize'); + const notice = document.getElementById('filePreviewNotice'); + nameEl.textContent = file.name; + sizeEl.textContent = (file.size / 1024).toFixed(1) + ' KB'; + if (file.type.startsWith('image/')) { + thumb.style.display = 'block'; + thumb.src = URL.createObjectURL(file); + icon.style.display = 'none'; + notice.textContent = 'Model does not support image input — filename only will be used as context.'; + } else { + thumb.style.display = 'none'; + icon.style.display = 'inline'; + icon.textContent = '📄'; + notice.textContent = ''; + } + preview.classList.add('visible'); + event.target.value = ''; +} +function clearFileSelection() { + selectedFile = null; + const preview = document.getElementById('filePreview'); + preview.classList.remove('visible'); + const thumb = document.getElementById('filePreviewThumb'); + thumb.src = ''; +} +function showGallery(convId) { + document.getElementById('galleryOverlay').classList.add('visible'); + loadGallery(convId); +} +function closeGallery(event) { + if (event && event.target !== event.currentTarget) return; + document.getElementById('galleryOverlay').classList.remove('visible'); +} +async function loadGallery(convId) { + const body = document.getElementById('galleryBody'); + body.innerHTML = ''; + try { + const resp = await authFetch('/api/upload/by-conversation/' + encodeURIComponent(convId)); + const items = await resp.json(); + if (!items || items.length === 0) { + body.innerHTML = ''; + return; + } + body.innerHTML = ''; + for (const item of items) { + const div = document.createElement('div'); + div.className = 'gallery-item'; + const isImage = item.content_type && item.content_type.startsWith('image/'); + const thumbHtml = isImage + ? '🖼' + : '📄'; + const expires = new Date(item.expires_at); + const expiresStr = expires.toLocaleString(); + div.innerHTML = thumbHtml + ''; + body.appendChild(div); + } + } catch(e) { + body.innerHTML = ''; + } +} +async function deleteGalleryItem(contextId) { + if (!confirm('Delete this attachment? It will also be removed from the RAG corpus.')) return; + try { + const resp = await authFetch('/api/upload/' + contextId, { method: 'DELETE' }); + if (resp.ok) { + await loadGallery(currentConvId); + await loadConversations(); + } + } catch(e) {} +} async function sendMessage() { const input = document.getElementById('userInput'); const message = input.value.trim(); @@ -1150,7 +1285,32 @@ async function sendMessage() { let ttr = 0; try { abortController = new AbortController(); - const resp = await authFetch('/api/chat', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ conversation_id: currentConvId, message, model, system_prompt: presetPrompt }), signal: abortController.signal }); + let uploadContextId = null; + if (selectedFile) { + const formData = new FormData(); + formData.append('file', selectedFile, selectedFile.name); + formData.append('mode', 'both'); + if (currentConvId) formData.append('conversation_id', currentConvId); + try { + const uploadResp = await authFetch('/api/upload', { method: 'POST', body: formData, signal: abortController.signal }); + const uploadData = await uploadResp.json(); + if (uploadResp.ok && uploadData.context_id) { + uploadContextId = uploadData.context_id; + if (!currentConvId && uploadData.conversation_id) currentConvId = uploadData.conversation_id; + } else { + throw new Error(uploadData.detail || 'Upload failed'); + } + } catch(e) { + if (e.name !== 'AbortError') textEl.textContent = 'Error uploading file: ' + e.message; + setStreamingState(false); + return; + } + clearFileSelection(); + await loadConversations(); + } + const chatBody = { conversation_id: currentConvId, message, model, system_prompt: presetPrompt }; + if (uploadContextId) chatBody.upload_context_id = uploadContextId; + const resp = await authFetch('/api/chat', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(chatBody), signal: abortController.signal }); const reader = resp.body.getReader(); const decoder = new TextDecoder(); let fullText = ''; @@ -1167,7 +1327,13 @@ async function sendMessage() { try { const data = JSON.parse(line.slice(6)); if (data.error) { textEl.textContent = 'Error: ' + data.error; setStreamingState(false); return; } - if (data.conversation_id && !currentConvId) { currentConvId = data.conversation_id; await loadConversations(); } + if (data.conversation_id && !currentConvId) { + currentConvId = data.conversation_id; + if (uploadContextId) { + authFetch('/api/upload/' + uploadContextId + '/link', { method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ conversation_id: currentConvId }) }).catch(()=>{}); + } + await loadConversations(); + } if (data.searching) { textEl.innerHTML = fullText ? renderMarkdown(fullText) + '
Searching...
' : '
Searching...
'; searchTriggered = true; } if (data.search_results) { textEl.innerHTML = '
🔍 Found ' + data.search_results + ' results...
'; fullText = ''; firstToken = true; } if (data.token) { if (firstToken) { textEl.innerHTML = ''; firstToken = false; ttr = performance.now() - ttrStart; } fullText += data.token; textEl.innerHTML = renderMarkdown(fullText); scrollToBottom(); } @@ -1296,7 +1462,7 @@ userInput.addEventListener('paste', e => { e.preventDefault(); const toast = document.createElement('div'); toast.className = 'toast-error'; - toast.textContent = 'Image pasting is not supported — text only.'; + toast.textContent = 'Use the 📎 button to attach images — paste is text only.'; document.body.appendChild(toast); setTimeout(() => toast.remove(), 3000); } diff --git a/tests/test_chat_streaming_and_memory_paths.py b/tests/test_chat_streaming_and_memory_paths.py index 53c52ea..439f8c2 100644 --- a/tests/test_chat_streaming_and_memory_paths.py +++ b/tests/test_chat_streaming_and_memory_paths.py @@ -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()[ diff --git a/tests/test_upload.py b/tests/test_upload.py index 9257ee3..15e83ad 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -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