Compare commits

...

2 Commits

Author SHA1 Message Date
gramps 81238c0d7f 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
2026-07-03 12:18:06 -07:00
gramps 4a891c8435 v1.8.9 -> v1.9.0: file upload backend (PDF/text, Qdrant ingest, SQLite context) 2026-07-01 18:15:23 -07:00
11 changed files with 784 additions and 11 deletions
+5 -2
View File
@@ -5,6 +5,7 @@ Creates the FastAPI app, registers middleware, mounts all routers.
""" """
import logging import logging
import logging.handlers import logging.handlers
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -13,7 +14,7 @@ from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from config import VERSION, RATE_WINDOW_SECONDS from config import VERSION, RATE_WINDOW_SECONDS, UPLOAD_DIR
from db import init_db from db import init_db
from memory import get_memory_count from memory import get_memory_count
from security import ( from security import (
@@ -32,6 +33,7 @@ import routers.skills as skills
import routers.chat as chat import routers.chat as chat
import routers.search_route as search_route import routers.search_route as search_route
import routers.completions as completions import routers.completions as completions
import routers.upload as upload
# --- Logging --- # --- Logging ---
log = logging.getLogger("jarvischat") log = logging.getLogger("jarvischat")
@@ -47,6 +49,7 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
log.info(f"JarvisChat {VERSION} starting up") log.info(f"JarvisChat {VERSION} starting up")
os.makedirs(UPLOAD_DIR, exist_ok=True)
init_db() init_db()
log.info(f"Memory system: {get_memory_count()} memories loaded") log.info(f"Memory system: {get_memory_count()} memories loaded")
yield yield
@@ -138,7 +141,7 @@ async def index(request: Request):
for router_module in [ for router_module in [
auth_router, conversations.router, memories.router, models.router, auth_router, conversations.router, memories.router, models.router,
presets.router, profile.router, settings.router, skills.router, presets.router, profile.router, settings.router, skills.router,
chat.router, search_route.router, completions.router, chat.router, search_route.router, completions.router, upload.router,
]: ]:
app.include_router(router_module) app.include_router(router_module)
+8 -1
View File
@@ -9,7 +9,7 @@ import logging
log = logging.getLogger("jarvischat") log = logging.getLogger("jarvischat")
VERSION = "v1.8.9" VERSION = "v1.10.0"
OLLAMA_BASE = os.environ.get("OLLAMA_BASE", "http://localhost:11434") 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") LLAMA_SERVER_BASE = os.environ.get("LLAMA_SERVER_BASE", "http://192.168.50.108:8081")
SEARXNG_BASE = "http://localhost:8888" SEARXNG_BASE = "http://localhost:8888"
@@ -46,6 +46,13 @@ BODY_LIMIT_DEFAULT_BYTES = 64 * 1024
BODY_LIMIT_CHAT_BYTES = 128 * 1024 BODY_LIMIT_CHAT_BYTES = 128 * 1024
BODY_LIMIT_PROFILE_BYTES = 256 * 1024 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", "image/png", "image/jpeg", "image/gif", "image/svg+xml", "image/webp"}
UPLOAD_CONTEXT_EXPIRY_HOURS = 1
BODY_LIMIT_UPLOAD_BYTES = MAX_UPLOAD_BYTES
MAX_CHAT_MESSAGE_CHARS = 8000 MAX_CHAT_MESSAGE_CHARS = 8000
MAX_SEARCH_QUERY_CHARS = 500 MAX_SEARCH_QUERY_CHARS = 500
MAX_PROFILE_CHARS = 32000 MAX_PROFILE_CHARS = 32000
+52
View File
@@ -68,6 +68,43 @@ def format_active_skills_prompt(skills: list) -> str:
return text return text
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, 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, content_type, expires_at FROM upload_context WHERE id = ?",
(context_id,),
).fetchone()
if not row:
return None
expires = datetime.fromisoformat(row["expires_at"])
if expires < datetime.now(timezone.utc):
db.execute("DELETE FROM upload_context WHERE id = ?", (context_id,))
db.commit()
return None
return dict(row)
def init_db(): def init_db():
from security import hash_pin from security import hash_pin
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
@@ -108,6 +145,21 @@ def init_db():
fact, topic, source, created_at UNINDEXED fact, topic, source, created_at UNINDEXED
) )
""") """)
conn.execute("""
CREATE TABLE IF NOT EXISTS upload_context (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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(): if not conn.execute("SELECT id FROM profile WHERE id = 1").fetchone():
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
+18
View File
@@ -18,6 +18,24 @@ RAG_COLLECTION = "jarvis_rag"
RAG_SCORE_THRESHOLD = 0.25 RAG_SCORE_THRESHOLD = 0.25
def chunk_text(text: str, chunk_size: int = 512, overlap: int = 128) -> list:
words = text.split()
# Approximate token count as len(words) * 1.3
target_words = int(chunk_size / 1.3)
overlap_words = int(overlap / 1.3)
if not words:
return []
chunks = []
start = 0
while start < len(words):
end = min(start + target_words, len(words))
chunks.append(" ".join(words[start:end]))
if end == len(words):
break
start += target_words - overlap_words
return chunks
async def query_rag(query: str, limit: int = 3) -> list: async def query_rag(query: str, limit: int = 3) -> list:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
+14 -2
View File
@@ -9,7 +9,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from config import DEFAULT_MODEL, LLAMA_SERVER_BASE 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 memory import process_remember_command
from rag import build_system_prompt from rag import build_system_prompt
from search import (calculate_perplexity, is_uncertain, is_refusal, 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") raise HTTPException(status_code=413, detail="Chat message is too long")
model = body.get("model", DEFAULT_MODEL) model = body.get("model", DEFAULT_MODEL)
preset_prompt = body.get("system_prompt", "") preset_prompt = body.get("system_prompt", "")
upload_context_id = body.get("upload_context_id")
if not user_message: if not user_message:
raise HTTPException(status_code=400, detail="Empty 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()} settings = {row["key"]: row["value"] for row in db.execute("SELECT key, value FROM settings").fetchall()}
search_enabled = settings.get("search_enabled", "true") == "true" 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) remember_response = process_remember_command(user_message)
if not conv_id: if not conv_id:
@@ -95,7 +104,10 @@ async def chat(request: Request):
history_rows = db.execute( history_rows = db.execute(
"SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY id ASC", (conv_id,) "SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY id ASC", (conv_id,)
).fetchall() ).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() db.close()
messages = [] messages = []
+9 -1
View File
@@ -15,8 +15,16 @@ router = APIRouter()
async def list_conversations(): async def list_conversations():
db = get_db() db = get_db()
rows = db.execute("SELECT * FROM conversations ORDER BY updated_at DESC").fetchall() 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() db.close()
return [dict(r) for r in rows] return result
@router.post("/api/conversations") @router.post("/api/conversations")
+168
View File
@@ -0,0 +1,168 @@
"""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}
+3 -1
View File
@@ -22,7 +22,7 @@ from fastapi import HTTPException, Request
from config import ( from config import (
ALLOWED_NETWORKS, TRUST_X_FORWARDED_FOR, TRUSTED_ORIGINS, ALLOWED_NETWORKS, TRUST_X_FORWARDED_FOR, TRUSTED_ORIGINS,
BODY_LIMIT_DEFAULT_BYTES, BODY_LIMIT_CHAT_BYTES, BODY_LIMIT_PROFILE_BYTES, BODY_LIMIT_DEFAULT_BYTES, BODY_LIMIT_CHAT_BYTES, BODY_LIMIT_PROFILE_BYTES, BODY_LIMIT_UPLOAD_BYTES,
RATE_WINDOW_SECONDS, RL_LOGIN_PER_WINDOW, RL_CHAT_PER_WINDOW, RATE_WINDOW_SECONDS, RL_LOGIN_PER_WINDOW, RL_CHAT_PER_WINDOW,
RL_SEARCH_PER_WINDOW, RL_STATS_PER_WINDOW, RL_WRITE_PER_WINDOW, RL_SEARCH_PER_WINDOW, RL_STATS_PER_WINDOW, RL_WRITE_PER_WINDOW,
RL_DEFAULT_PER_WINDOW, VERSION, RL_DEFAULT_PER_WINDOW, VERSION,
@@ -114,6 +114,8 @@ def request_body_limit(path: str) -> int:
return BODY_LIMIT_CHAT_BYTES return BODY_LIMIT_CHAT_BYTES
if path == "/api/profile": if path == "/api/profile":
return BODY_LIMIT_PROFILE_BYTES return BODY_LIMIT_PROFILE_BYTES
if path == "/api/upload":
return BODY_LIMIT_UPLOAD_BYTES
return BODY_LIMIT_DEFAULT_BYTES return BODY_LIMIT_DEFAULT_BYTES
+170 -4
View File
@@ -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 { 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:hover { background:#e67e22; }
.search-btn:disabled { background:var(--text-muted); cursor:not-allowed; } .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; } .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; } .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(
<button class="logout-btn" id="authActionBtn" onclick="handleAuthAction()" title="Unlock admin">ADMIN</button> <button class="logout-btn" id="authActionBtn" onclick="handleAuthAction()" title="Unlock admin">ADMIN</button>
</div> </div>
</div> </div>
<div class="gallery-overlay" id="galleryOverlay" onclick="closeGallery(event)">
<div class="gallery-panel" onclick="event.stopPropagation()">
<div class="gallery-header">
<h3>📎 Attachments</h3>
<button class="gallery-close" onclick="closeGallery()"></button>
</div>
<div class="gallery-body" id="galleryBody"></div>
</div>
</div>
<div class="chat-container" id="chatContainer"> <div class="chat-container" id="chatContainer">
<div class="welcome-screen" id="welcomeScreen"> <div class="welcome-screen" id="welcomeScreen">
<div class="logo"></div> <div class="logo"></div>
@@ -346,7 +388,19 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
<span class="preset-label">PRESET</span> <span class="preset-label">PRESET</span>
<select id="presetSelect"><option value="">None (profile only)</option></select> <select id="presetSelect"><option value="">None (profile only)</option></select>
</div> </div>
<div class="file-preview" id="filePreview">
<img class="file-preview-thumb" id="filePreviewThumb" src="" alt="" style="display:none">
<span class="file-preview-icon" id="filePreviewIcon">📄</span>
<div class="file-preview-info">
<div class="file-preview-name" id="filePreviewName"></div>
<div class="file-preview-size" id="filePreviewSize"></div>
<div class="file-preview-notice" id="filePreviewNotice"></div>
</div>
<button class="file-preview-clear" onclick="clearFileSelection()" title="Remove file"></button>
</div>
<div class="input-wrapper"> <div class="input-wrapper">
<input type="file" id="fileInput" style="display:none" accept=".txt,.md,.pdf,.json,.py,.html,.png,.jpg,.jpeg,.gif,.svg,.webp" onchange="onFileSelected(event)">
<button class="paperclip-btn" id="paperclipBtn" onclick="document.getElementById('fileInput').click()" title="Attach file">📎</button>
<textarea id="userInput" placeholder="Type a message... (Shift+Enter for new line)" rows="1" autofocus></textarea> <textarea id="userInput" placeholder="Type a message... (Shift+Enter for new line)" rows="1" autofocus></textarea>
<div class="token-thermometer" title="Context usage"> <div class="token-thermometer" title="Context usage">
<div class="thermometer-bar"><div class="thermometer-fill" id="thermometerFill" style="height:0%"></div></div> <div class="thermometer-bar"><div class="thermometer-fill" id="thermometerFill" style="height:0%"></div></div>
@@ -364,6 +418,7 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
let currentConvId = null; let currentConvId = null;
let isStreaming = false; let isStreaming = false;
let abortController = null; let abortController = null;
let selectedFile = null;
let profileEnabled = true; let profileEnabled = true;
let searchEnabled = true; let searchEnabled = true;
let memoryEnabled = true; let memoryEnabled = true;
@@ -993,7 +1048,8 @@ async function loadConversations() {
convs.forEach(c => { convs.forEach(c => {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'conv-item' + (c.id === currentConvId ? ' active' : ''); div.className = 'conv-item' + (c.id === currentConvId ? ' active' : '');
div.innerHTML = `<span class="conv-trash" onclick="event.stopPropagation();deleteConversation('${c.id}')" title="Delete conversation">🗑</span><span class="conv-title" onclick="loadConversation('${c.id}')">${c.title}</span>`; const attachIcon = c.attachment_count > 0 ? `<span class="conv-attach has-attachments" onclick="event.stopPropagation();showGallery('${c.id}')" title="${c.attachment_count} attachment(s)">📎</span>` : '';
div.innerHTML = `<span class="conv-trash" onclick="event.stopPropagation();deleteConversation('${c.id}')" title="Delete conversation">🗑</span>${attachIcon}<span class="conv-title" onclick="loadConversation('${c.id}')">${c.title}</span>`;
list.appendChild(div); list.appendChild(div);
}); });
} catch(e) {} } catch(e) {}
@@ -1043,6 +1099,7 @@ function newChat() {
updateTokenThermometer(); updateTokenThermometer();
} }
function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }
function showWelcome() { function showWelcome() {
document.getElementById('chatContainer').innerHTML = '<div class="welcome-screen" id="welcomeScreen"><div class="logo">⚡</div><p>JarvisChat — your local coding companion.<br>Profile + Memory context injected automatically.<br>Web search kicks in when the model is uncertain.<br>Use 🔍 to force a web search.<br>Say "remember that..." to teach me things.</p></div>'; document.getElementById('chatContainer').innerHTML = '<div class="welcome-screen" id="welcomeScreen"><div class="logo">⚡</div><p>JarvisChat — your local coding companion.<br>Profile + Memory context injected automatically.<br>Web search kicks in when the model is uncertain.<br>Use 🔍 to force a web search.<br>Say "remember that..." to teach me things.</p></div>';
} }
@@ -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 = '<div class="gallery-empty">Loading...</div>';
try {
const resp = await authFetch('/api/upload/by-conversation/' + encodeURIComponent(convId));
const items = await resp.json();
if (!items || items.length === 0) {
body.innerHTML = '<div class="gallery-empty">No attachments in this conversation.</div>';
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
? '<span class="gallery-item-icon">🖼</span>'
: '<span class="gallery-item-icon">📄</span>';
const expires = new Date(item.expires_at);
const expiresStr = expires.toLocaleString();
div.innerHTML = thumbHtml + '<div class="gallery-item-info"><div class="gallery-item-name">' + escapeHtml(item.filename) + '</div><div class="gallery-item-meta">Expires: ' + expiresStr + '</div></div><button class="gallery-item-delete" onclick="deleteGalleryItem(' + item.id + ')">Delete</button>';
body.appendChild(div);
}
} catch(e) {
body.innerHTML = '<div class="gallery-empty">Error loading attachments.</div>';
}
}
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() { async function sendMessage() {
const input = document.getElementById('userInput'); const input = document.getElementById('userInput');
const message = input.value.trim(); const message = input.value.trim();
@@ -1150,7 +1285,32 @@ async function sendMessage() {
let ttr = 0; let ttr = 0;
try { try {
abortController = new AbortController(); 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 reader = resp.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let fullText = ''; let fullText = '';
@@ -1167,7 +1327,13 @@ async function sendMessage() {
try { try {
const data = JSON.parse(line.slice(6)); const data = JSON.parse(line.slice(6));
if (data.error) { textEl.textContent = 'Error: ' + data.error; setStreamingState(false); return; } 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) + '<div class="search-indicator"><div class="spinner"></div>Searching...</div>' : '<div class="search-indicator"><div class="spinner"></div>Searching...</div>'; searchTriggered = true; } if (data.searching) { textEl.innerHTML = fullText ? renderMarkdown(fullText) + '<div class="search-indicator"><div class="spinner"></div>Searching...</div>' : '<div class="search-indicator"><div class="spinner"></div>Searching...</div>'; searchTriggered = true; }
if (data.search_results) { textEl.innerHTML = '<div class="search-indicator">🔍 Found ' + data.search_results + ' results...</div>'; fullText = ''; firstToken = true; } if (data.search_results) { textEl.innerHTML = '<div class="search-indicator">🔍 Found ' + data.search_results + ' results...</div>'; fullText = ''; firstToken = true; }
if (data.token) { if (firstToken) { textEl.innerHTML = ''; firstToken = false; ttr = performance.now() - ttrStart; } fullText += data.token; textEl.innerHTML = renderMarkdown(fullText); scrollToBottom(); } 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(); e.preventDefault();
const toast = document.createElement('div'); const toast = document.createElement('div');
toast.className = 'toast-error'; 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); document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000); setTimeout(() => toast.remove(), 3000);
} }
@@ -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 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): def test_memory_command_paths_remember_and_forget(tmp_path: Path, monkeypatch):
with make_client(tmp_path) as client: with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[ sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[
+274
View File
@@ -0,0 +1,274 @@
import os
from pathlib import Path
import httpx
import pytest
from fastapi.testclient import TestClient
import app
import db
import routers.upload as upload_route
from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "jarvischat-upload.db"
SESSIONS.clear()
PIN_ATTEMPTS.clear()
RATE_EVENTS.clear()
db.init_db()
return TestClient(app.app, raise_server_exceptions=False)
def _admin_headers(client: TestClient) -> dict:
login = client.post("/api/auth/login", json={"pin": "1234"}, headers={"Origin": "http://testserver"})
sid = login.json()["session_id"]
return {"X-Session-ID": sid, "Origin": "http://testserver"}
def _guest_headers(client: TestClient) -> dict:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
return {"X-Session-ID": sid, "Origin": "http://testserver"}
def test_upload_requires_admin(tmp_path: Path):
with make_client(tmp_path) as client:
resp = client.post("/api/upload", headers=_guest_headers(client), files={"file": ("test.txt", b"hello")})
assert resp.status_code == 403
def test_upload_unsupported_mime(tmp_path: Path):
with make_client(tmp_path) as client:
resp = client.post(
"/api/upload", headers=_admin_headers(client),
files={"file": ("test.exe", b"fake", "application/x-msdownload")},
)
assert resp.status_code == 415
def test_upload_context_mode(tmp_path: Path):
with make_client(tmp_path) as client:
resp = client.post(
"/api/upload", headers=_admin_headers(client),
data={"mode": "context", "conversation_id": "conv-1"},
files={"file": ("notes.txt", b"Hello world notes")},
)
assert resp.status_code == 200
data = resp.json()
assert data["filename"] == "notes.txt"
assert data["mode"] == "context"
assert "context_id" in data
assert "chunks_ingested" not in data
row = db.get_db().execute("SELECT content FROM upload_context WHERE id = ?", (data["context_id"],)).fetchone()
assert row["content"] == "Hello world notes"
def test_upload_ingest_mode(tmp_path: Path, monkeypatch):
embed_count = 0
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):
nonlocal embed_count
if "/api/embeddings" in url:
embed_count += 1
return self.FakeResponse(200, {"embedding": [0.1] * 768})
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:
resp = client.post(
"/api/upload", headers=_admin_headers(client),
data={"mode": "ingest"},
files={"file": ("data.txt", b"word " * 1000)},
)
assert resp.status_code == 200
data = resp.json()
assert data["mode"] == "ingest"
assert data["chunks_ingested"] > 0
assert "context_id" not in data
assert embed_count == data["chunks_ingested"]
def test_upload_both_mode(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 "/api/embeddings" in url:
return self.FakeResponse(200, {"embedding": [0.1] * 768})
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:
resp = client.post(
"/api/upload", headers=_admin_headers(client),
data={"mode": "both", "conversation_id": "conv-2"},
files={"file": ("both.txt", b"test " * 500)},
)
assert resp.status_code == 200
data = resp.json()
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