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( +
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.