Files
jarvisChat/app.py
2026-03-09 20:06:01 -07:00

1703 lines
97 KiB
Python

#!/usr/bin/env python3
"""
JarvisChat - Lightweight Ollama Coding Companion
A minimal replacement for Open-WebUI that actually runs on Python 3.13
Talks to Ollama API on localhost:11434
Features:
- Persistent profile/memory injected into every conversation
- Saved system prompt presets (coding assistant, sysadmin, general, custom)
- Streaming chat with conversation history
- Model switching between all installed Ollama models
- Copy-to-clipboard on code blocks
- Token count estimates
- SearXNG integration for web search when model is uncertain
"""
import json
import logging
import math
import sqlite3
import uuid
import re
from datetime import datetime, timezone
from pathlib import Path
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
# --- Logging Setup ---
import logging.handlers
log = logging.getLogger("jarvischat")
log.setLevel(logging.DEBUG)
syslog_handler = logging.handlers.SysLogHandler(address='/dev/log')
syslog_handler.setFormatter(logging.Formatter('jarvischat[%(process)d]: %(levelname)s %(message)s'))
log.addHandler(syslog_handler)
# --- Configuration ---
VERSION = "1.3.0"
OLLAMA_BASE = "http://localhost:11434"
SEARXNG_BASE = "http://localhost:8888"
DB_PATH = Path(__file__).parent / "jarvischat.db"
DEFAULT_MODEL = "deepseek-coder:6.7b"
# --- Perplexity Threshold ---
# Higher perplexity = model is less confident / more uncertain
# Tune this based on your models. Start conservative (higher threshold).
PERPLEXITY_THRESHOLD = 15.0
# --- Refusal Patterns (fallback for confident refusals) ---
REFUSAL_PATTERNS = re.compile(r"|".join([
r"i don'?t have (?:real-?time|current|live)",
r"i (?:can'?t|cannot) provide (?:current|real-?time|live)",
r"i don'?t have access to (?:current|real-?time|live)",
r"(?:current|live|real-?time) (?:data|information|prices?|weather)",
r"my (?:knowledge|training) (?:cutoff|only goes|ends)",
r"as of my (?:knowledge|training) cutoff",
r"i'?m not able to (?:access|provide|browse)",
r"(?:check|visit|use) a (?:website|financial|news)",
]), re.IGNORECASE)
# --- Hedging patterns to strip from search-augmented responses ---
HEDGE_PATTERNS = [
r"^I'?m sorry,?\s*but\s*I\s*(?:can'?t|cannot)\s*assist\s*with\s*that[^.]*\.\s*",
r"^I'?m sorry,?\s*but[^.]*(?:previous|incorrect)[^.]*\.\s*",
r"(?:But\s+)?[Pp]lease\s+(?:make\s+sure\s+to\s+)?verify\s+(?:the\s+)?(?:data|information|this)\s+(?:from\s+)?(?:reliable\s+)?sources[^.]*\.\s*",
r"[Pp]lease\s+verify[^.]*(?:accurate|reliability)[^.]*\.\s*",
r"[Bb]ut\s+please\s+(?:make\s+sure|verify|check)[^.]*\.\s*",
]
def clean_hedging(text: str) -> str:
"""Remove hedging sentences from model response."""
cleaned = text
for pattern in HEDGE_PATTERNS:
cleaned = re.sub(pattern, "", cleaned, flags=re.IGNORECASE)
return cleaned.strip()
def format_direct_answer(question: str, results: list[dict]) -> str:
"""Format search results directly when model refuses to help."""
if not results:
return "No search results found."
lines = [f"Here's what I found:\n"]
for r in results[:3]: # Top 3 results
lines.append(f"**{r['title']}**")
if r['content']:
lines.append(f"{r['content']}")
lines.append("")
return "\n".join(lines).strip()
# --- Default Profile ---
DEFAULT_PROFILE = """You are a coding companion running locally on a machine called "jarvis".
## Environment
- jarvis: Debian 13 (trixie) x86_64, AMD Ryzen 5 5600X, 16GB RAM, AMD RX 6600 XT (8GB VRAM), IP varies
- llamadev: Windows 11, primary development machine, IP 192.168.50.108, user "alphaalpaca"
- Corsair: Windows 11, gaming/streaming rig
- pivault: RPi 5, 8GB RAM, Debian 13, 11TB RAID5 NAS at /mnt/pivault, IP 192.168.50.159
- Router: ASUS ROG Rapture GT-BE98 Pro "BigBlinkyRouter" at 192.168.50.1
- Ollama runs on jarvis with GPU acceleration (ROCm), serving models on port 11434
## About the User
- Experienced developer, BS in Computer Science (Oklahoma State), coding since 1981 (TRS-80)
- Deep Unix/Linux background — wrote device drivers at SCO during Xenix era (1990s)
- Currently learning Rust, transitioning from decades of PHP
- Building a WW2 mobile game in Godot Engine for Android
- Runs a YouTube series: "Building a Professional Dev Environment with Local AI"
- Working on "Sysadmin's Wizard's Notebook" app concept in Rust
- Veteran on fixed income — prefers free/open-source solutions
- Home lab enthusiast with Z-Wave and Tapo smart home devices
- Streams Fortnite on a regular schedule
## How to Respond
- Be direct and concise — no hand-holding, this user knows what they're doing
- When showing code, prefer complete working examples over snippets
- Default to command-line solutions over GUI when possible
- Consider resource constraints (fixed income, specific hardware limits)
- Use Rust, Python, or bash unless another language is specifically needed
- Explain trade-offs when multiple approaches exist
- Don't repeat information the user clearly already knows"""
# --- Default System Prompt Presets ---
DEFAULT_PRESETS = [
{
"name": "Coding Companion",
"prompt": "You are a senior software engineer and coding companion. Focus on writing clean, efficient, well-documented code. Provide complete working examples. Explain architectural decisions and trade-offs. Prefer Rust, Python, and bash."
},
{
"name": "Linux Sysadmin",
"prompt": "You are an experienced Linux systems administrator. Focus on command-line solutions, systemd services, networking, storage, and security. Prefer Debian/Ubuntu conventions. Be concise and direct."
},
{
"name": "General Assistant",
"prompt": "You are a helpful general-purpose assistant. Be clear and concise."
}
]
# --- Database Setup ---
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("""
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT 'New Chat',
model TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS system_presets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
prompt TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS profile (
id INTEGER PRIMARY KEY CHECK (id = 1),
content TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
# Seed default profile if empty
existing = conn.execute("SELECT id FROM profile WHERE id = 1").fetchone()
if not existing:
now = datetime.now(timezone.utc).isoformat()
conn.execute("INSERT INTO profile (id, content, updated_at) VALUES (1, ?, ?)",
(DEFAULT_PROFILE, now))
# Seed default presets if empty
existing_presets = conn.execute("SELECT COUNT(*) as c FROM system_presets").fetchone()
if existing_presets["c"] == 0:
now = datetime.now(timezone.utc).isoformat()
for preset in DEFAULT_PRESETS:
conn.execute(
"INSERT INTO system_presets (id, name, prompt, is_default, created_at) VALUES (?, ?, ?, 1, ?)",
(str(uuid.uuid4()), preset["name"], preset["prompt"], now)
)
# Default settings
defaults = {
"profile_enabled": "true",
"default_model": DEFAULT_MODEL,
"search_enabled": "true",
}
for key, value in defaults.items():
existing = conn.execute("SELECT key FROM settings WHERE key = ?", (key,)).fetchone()
if not existing:
conn.execute("INSERT INTO settings (key, value) VALUES (?, ?)", (key, value))
conn.commit()
conn.close()
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
# --- SearXNG Integration ---
async def query_searxng(query: str, max_results: int = 5) -> list[dict]:
"""Query SearXNG and return search results."""
log.info(f"Querying SearXNG: '{query}'")
async with httpx.AsyncClient() as client:
# For weather queries, hit wttr.in directly
weather_match = re.search(r"(?:weather|temperature|forecast)\s+(?:in\s+)?(.+?)(?:\s+right now|\s+today|\s+degrees)?$", query, re.IGNORECASE)
if weather_match or "weather" in query.lower() or "temperature" in query.lower():
location = weather_match.group(1) if weather_match else re.sub(r"(weather|temperature|forecast|right now|today|degrees)", "", query, flags=re.IGNORECASE).strip()
if location:
try:
log.info(f"Fetching weather for: {location}")
resp = await client.get(
f"https://wttr.in/{location}?format=3",
timeout=10.0,
headers={"User-Agent": "curl/7.68.0"}
)
if resp.status_code == 200:
weather_text = resp.text.strip()
log.info(f"wttr.in returned: {weather_text}")
return [{
"title": "Current Weather",
"url": f"https://wttr.in/{location}",
"content": weather_text,
}]
except Exception as e:
log.warning(f"wttr.in error: {e}, falling back to SearXNG")
try:
resp = await client.get(
f"{SEARXNG_BASE}/search",
params={
"q": query,
"format": "json",
"categories": "general",
},
timeout=10.0
)
if resp.status_code == 200:
data = resp.json()
results = []
# Check for direct answers/infoboxes first
if data.get("answers"):
for answer in data["answers"]:
results.append({
"title": "Direct Answer",
"url": "",
"content": answer,
})
log.info(f"Got direct answer: {answer[:100]}")
if data.get("infoboxes"):
for box in data["infoboxes"]:
content = box.get("content", "")
if not content and box.get("attributes"):
content = " | ".join([f"{a.get('label','')}: {a.get('value','')}" for a in box["attributes"]])
results.append({
"title": box.get("infobox", "Info"),
"url": box.get("urls", [{}])[0].get("url", "") if box.get("urls") else "",
"content": content,
})
log.info(f"Got infobox: {box.get('infobox', '')}")
# Then regular results
for r in data.get("results", [])[:max_results]:
results.append({
"title": r.get("title", ""),
"url": r.get("url", ""),
"content": r.get("content", ""),
})
log.info(f"SearXNG returned {len(results)} total results")
for i, r in enumerate(results[:5]):
log.debug(f" Result {i+1}: {r['title'][:60]}")
return results
else:
log.warning(f"SearXNG returned status {resp.status_code}")
except Exception as e:
log.error(f"SearXNG error: {e}")
return []
def calculate_perplexity(logprobs: list) -> float:
"""Calculate perplexity from logprobs. Higher = less confident."""
if not logprobs:
return 0.0
avg_logprob = sum(lp["logprob"] for lp in logprobs) / len(logprobs)
perplexity = math.exp(-avg_logprob)
return perplexity
def is_uncertain(logprobs: list, threshold: float = PERPLEXITY_THRESHOLD) -> bool:
"""Check if model output indicates uncertainty based on perplexity."""
if not logprobs:
log.debug("No logprobs returned, skipping uncertainty check")
return False
perplexity = calculate_perplexity(logprobs)
log.info(f"Perplexity: {perplexity:.2f} (threshold: {threshold})")
return perplexity > threshold
def is_refusal(text: str) -> bool:
"""Check if model is refusing/admitting it can't help."""
match = REFUSAL_PATTERNS.search(text)
if match:
log.info(f"Refusal detected: '{match.group()}'")
return True
return False
def format_search_results(results: list[dict]) -> str:
"""Format search results as context for the model."""
if not results:
return ""
lines = ["[LIVE WEB DATA]\n"]
for i, r in enumerate(results, 1):
lines.append(f"{i}. {r['title']}")
if r['content']:
lines.append(f" {r['content']}")
lines.append("")
lines.append("\nAnswer directly using the data above. No apologies. No disclaimers. No \"please verify elsewhere.\" Just answer.")
return "\n".join(lines)
def extract_search_query(user_message: str) -> str:
"""Extract a good search query from the user's message."""
query = user_message.strip()
# For temperature/weather queries, be more specific
if re.search(r"temperature|weather", query, re.IGNORECASE):
query = re.sub(r"^what('?s| is) the ", "", query, flags=re.IGNORECASE)
query = query + " right now degrees"
# For price queries, be more specific
if re.search(r"price|spot price", query, re.IGNORECASE):
query = re.sub(r"^(what('?s| is)|can you tell me) the ", "", query, flags=re.IGNORECASE)
query = query + " today USD"
# Remove common question words
query = re.sub(r"^(what|who|where|when|why|how|is|are|can|could|would|should|do|does|did)\s+", "", query, flags=re.IGNORECASE)
# Remove trailing punctuation
query = re.sub(r"[?!.]+$", "", query)
# Limit length
if len(query) > 100:
query = query[:100]
return query.strip() or user_message[:100]
# --- App Lifecycle ---
@asynccontextmanager
async def lifespan(app: FastAPI):
log.info(f"JarvisChat v{VERSION} starting up")
log.info(f"Ollama: {OLLAMA_BASE}")
log.info(f"SearXNG: {SEARXNG_BASE}")
init_db()
yield
log.info("JarvisChat shutting down")
app = FastAPI(title="JarvisChat", lifespan=lifespan)
# --- API Routes ---
@app.get("/", response_class=HTMLResponse)
async def index():
return HTML_PAGE.replace("{{VERSION}}", VERSION)
@app.get("/api/models")
async def list_models():
async with httpx.AsyncClient() as client:
try:
resp = await client.get(f"{OLLAMA_BASE}/api/tags", timeout=10)
return resp.json()
except httpx.ConnectError:
raise HTTPException(status_code=502, detail="Cannot connect to Ollama. Is it running?")
@app.get("/api/ps")
async def running_models():
async with httpx.AsyncClient() as client:
try:
resp = await client.get(f"{OLLAMA_BASE}/api/ps", timeout=10)
return resp.json()
except httpx.ConnectError:
raise HTTPException(status_code=502, detail="Cannot connect to Ollama.")
@app.post("/api/show")
async def show_model(request: Request):
"""Get model information including context size."""
body = await request.json()
async with httpx.AsyncClient() as client:
try:
resp = await client.post(f"{OLLAMA_BASE}/api/show", json=body, timeout=10)
return resp.json()
except httpx.ConnectError:
raise HTTPException(status_code=502, detail="Cannot connect to Ollama.")
# --- Search Status ---
@app.get("/api/search/status")
async def search_status():
"""Check if SearXNG is available."""
async with httpx.AsyncClient() as client:
try:
resp = await client.get(f"{SEARXNG_BASE}/healthz", timeout=5)
return {"available": resp.status_code == 200}
except:
# Try a simple search as fallback health check
try:
resp = await client.get(f"{SEARXNG_BASE}/search", params={"q": "test", "format": "json"}, timeout=5)
return {"available": resp.status_code == 200}
except:
return {"available": False}
# --- Profile ---
@app.get("/api/profile")
async def get_profile():
db = get_db()
row = db.execute("SELECT content, updated_at FROM profile WHERE id = 1").fetchone()
db.close()
if row:
return {"content": row["content"], "updated_at": row["updated_at"]}
return {"content": "", "updated_at": ""}
@app.put("/api/profile")
async def update_profile(request: Request):
body = await request.json()
now = datetime.now(timezone.utc).isoformat()
db = get_db()
db.execute("UPDATE profile SET content = ?, updated_at = ? WHERE id = 1",
(body["content"], now))
db.commit()
db.close()
return {"status": "ok", "updated_at": now}
@app.get("/api/profile/default")
async def get_default_profile():
return {"content": DEFAULT_PROFILE}
# --- Settings ---
@app.get("/api/settings")
async def get_settings():
db = get_db()
rows = db.execute("SELECT key, value FROM settings").fetchall()
db.close()
return {row["key"]: row["value"] for row in rows}
@app.put("/api/settings")
async def update_settings(request: Request):
body = await request.json()
db = get_db()
for key, value in body.items():
db.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, str(value)))
db.commit()
db.close()
return {"status": "ok"}
# --- System Presets ---
@app.get("/api/presets")
async def list_presets():
db = get_db()
rows = db.execute("SELECT * FROM system_presets ORDER BY is_default DESC, name ASC").fetchall()
db.close()
return [dict(r) for r in rows]
@app.post("/api/presets")
async def create_preset(request: Request):
body = await request.json()
preset_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).isoformat()
db = get_db()
db.execute(
"INSERT INTO system_presets (id, name, prompt, is_default, created_at) VALUES (?, ?, ?, 0, ?)",
(preset_id, body["name"], body["prompt"], now)
)
db.commit()
db.close()
return {"id": preset_id, "name": body["name"], "prompt": body["prompt"]}
@app.put("/api/presets/{preset_id}")
async def update_preset(preset_id: str, request: Request):
body = await request.json()
db = get_db()
db.execute("UPDATE system_presets SET name = ?, prompt = ? WHERE id = ?",
(body["name"], body["prompt"], preset_id))
db.commit()
db.close()
return {"status": "ok"}
@app.delete("/api/presets/{preset_id}")
async def delete_preset(preset_id: str):
db = get_db()
db.execute("DELETE FROM system_presets WHERE id = ? AND is_default = 0", (preset_id,))
db.commit()
db.close()
return {"status": "ok"}
# --- Conversation CRUD ---
@app.get("/api/conversations")
async def list_conversations():
db = get_db()
rows = db.execute("SELECT * FROM conversations ORDER BY updated_at DESC").fetchall()
db.close()
return [dict(r) for r in rows]
@app.post("/api/conversations")
async def create_conversation(request: Request):
body = await request.json()
conv_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).isoformat()
model = body.get("model", DEFAULT_MODEL)
title = body.get("title", "New Chat")
db = get_db()
db.execute(
"INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
(conv_id, title, model, now, now)
)
db.commit()
db.close()
return {"id": conv_id, "title": title, "model": model, "created_at": now, "updated_at": now}
@app.get("/api/conversations/{conv_id}")
async def get_conversation(conv_id: str):
db = get_db()
conv = db.execute("SELECT * FROM conversations WHERE id = ?", (conv_id,)).fetchone()
if not conv:
db.close()
raise HTTPException(status_code=404, detail="Conversation not found")
messages = db.execute(
"SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC", (conv_id,)
).fetchall()
db.close()
return {"conversation": dict(conv), "messages": [dict(m) for m in messages]}
@app.put("/api/conversations/{conv_id}")
async def update_conversation(conv_id: str, request: Request):
body = await request.json()
db = get_db()
now = datetime.now(timezone.utc).isoformat()
if "title" in body:
db.execute("UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?",
(body["title"], now, conv_id))
if "model" in body:
db.execute("UPDATE conversations SET model = ?, updated_at = ? WHERE id = ?",
(body["model"], now, conv_id))
db.commit()
db.close()
return {"status": "ok"}
@app.delete("/api/conversations/{conv_id}")
async def delete_conversation(conv_id: str):
db = get_db()
db.execute("DELETE FROM messages WHERE conversation_id = ?", (conv_id,))
db.execute("DELETE FROM conversations WHERE id = ?", (conv_id,))
db.commit()
db.close()
return {"status": "ok"}
@app.delete("/api/conversations")
async def delete_all_conversations():
db = get_db()
db.execute("DELETE FROM messages")
db.execute("DELETE FROM conversations")
db.commit()
db.close()
log.info("Deleted all conversations")
return {"status": "ok"}
# --- Chat (streaming) ---
def build_system_prompt(db, extra_prompt=""):
"""Build the full system prompt: profile + preset/custom prompt"""
parts = []
# Check if profile is enabled
settings = {row["key"]: row["value"] for row in db.execute("SELECT key, value FROM settings").fetchall()}
if settings.get("profile_enabled", "true") == "true":
profile = db.execute("SELECT content FROM profile WHERE id = 1").fetchone()
if profile and profile["content"].strip():
parts.append(profile["content"].strip())
if extra_prompt and extra_prompt.strip():
parts.append(extra_prompt.strip())
return "\n\n---\n\n".join(parts) if parts else ""
@app.post("/api/chat")
async def chat(request: Request):
body = await request.json()
conv_id = body.get("conversation_id")
user_message = body.get("message", "").strip()
model = body.get("model", DEFAULT_MODEL)
preset_prompt = body.get("system_prompt", "")
if not user_message:
raise HTTPException(status_code=400, detail="Empty message")
db = get_db()
now = datetime.now(timezone.utc).isoformat()
# Check if search is enabled
settings = {row["key"]: row["value"] for row in db.execute("SELECT key, value FROM settings").fetchall()}
search_enabled = settings.get("search_enabled", "true") == "true"
log.debug(f"Chat request: model={model}, search_enabled={search_enabled}")
# Auto-create conversation if needed
if not conv_id:
conv_id = str(uuid.uuid4())
title = user_message[:80] + ("..." if len(user_message) > 80 else "")
db.execute(
"INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
(conv_id, title, model, now, now)
)
else:
db.execute("UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id))
# Save user message
db.execute(
"INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
(conv_id, "user", user_message, now)
)
db.commit()
# Build message history
history_rows = db.execute(
"SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY id ASC",
(conv_id,)
).fetchall()
# Build system prompt (profile + preset)
system_prompt = build_system_prompt(db, preset_prompt)
db.close()
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
for row in history_rows:
messages.append({"role": row["role"], "content": row["content"]})
ollama_payload = {
"model": model,
"messages": messages,
"stream": True,
"logprobs": True,
}
async def stream_response():
full_response = []
all_logprobs = []
tokens_per_sec = 0.0
async with httpx.AsyncClient() as client:
try:
async with client.stream(
"POST",
f"{OLLAMA_BASE}/api/chat",
json=ollama_payload,
timeout=httpx.Timeout(300.0, connect=10.0)
) as resp:
async for line in resp.aiter_lines():
if line.strip():
try:
chunk = json.loads(line)
if "message" in chunk and "content" in chunk["message"]:
token = chunk["message"]["content"]
full_response.append(token)
yield f"data: {json.dumps({'token': token, 'conversation_id': conv_id})}\n\n"
# Collect logprobs
if "logprobs" in chunk and chunk["logprobs"]:
all_logprobs.extend(chunk["logprobs"])
if chunk.get("done"):
# Capture timing info from final chunk
eval_count = chunk.get("eval_count", 0)
eval_duration = chunk.get("eval_duration", 0)
tokens_per_sec = (eval_count / (eval_duration / 1e9)) if eval_duration > 0 else 0
break
except json.JSONDecodeError:
pass
# Check for uncertainty and search if needed
assistant_msg = "".join(full_response)
perplexity = calculate_perplexity(all_logprobs) if all_logprobs else 0.0
should_search = is_uncertain(all_logprobs) or is_refusal(assistant_msg)
if search_enabled and should_search:
# Signal that we're searching
yield f"data: {json.dumps({'searching': True, 'conversation_id': conv_id})}\n\n"
# Query SearXNG
search_query = extract_search_query(user_message)
log.info(f"Extracted search query: '{search_query}'")
search_results = await query_searxng(search_query)
if search_results:
# Build augmented messages - inject search context, DON'T include the refusal
search_context = format_search_results(search_results)
# Rebuild: system prompt + search context + original user question
augmented_messages = []
if system_prompt:
augmented_messages.append({"role": "system", "content": system_prompt + "\n\n" + search_context})
else:
augmented_messages.append({"role": "system", "content": search_context})
# Add conversation history except the last user message (we'll re-add it)
for row in history_rows[:-1]:
augmented_messages.append({"role": row["role"], "content": row["content"]})
# Re-add the user question
augmented_messages.append({"role": "user", "content": user_message})
augmented_payload = {
"model": model,
"messages": augmented_messages,
"stream": True,
}
# Signal search results found - include actual results for debug
yield f"data: {json.dumps({'search_results': len(search_results), 'results_preview': [r['title'] for r in search_results], 'conversation_id': conv_id})}\n\n"
# Stream the augmented response
yield f"data: {json.dumps({'debug': 'Starting augmented response...', 'conversation_id': conv_id})}\n\n"
augmented_response = []
async with client.stream(
"POST",
f"{OLLAMA_BASE}/api/chat",
json=augmented_payload,
timeout=httpx.Timeout(300.0, connect=10.0)
) as resp2:
async for line in resp2.aiter_lines():
if line.strip():
try:
chunk = json.loads(line)
if "message" in chunk and "content" in chunk["message"]:
token = chunk["message"]["content"]
augmented_response.append(token)
if chunk.get("done"):
break
except json.JSONDecodeError:
pass
# Clean hedging from the response
raw_response = "".join(augmented_response)
if not raw_response.strip():
log.warning("Augmented response empty, falling back to original")
raw_response = assistant_msg
cleaned_response = clean_hedging(raw_response)
log.debug(f"Cleaned hedging: {len(raw_response)} -> {len(cleaned_response)} chars")
# If model STILL refuses after getting search data, format answer ourselves
if is_refusal(cleaned_response) or len(cleaned_response) < 20:
log.warning("Model refused even with search context, formatting direct answer")
cleaned_response = format_direct_answer(user_message, search_results)
# Send cleaned response as single chunk
yield f"data: {json.dumps({'token': cleaned_response, 'conversation_id': conv_id, 'augmented': True})}\n\n"
# Save the cleaned response
search_note = "\n\n---\n*🔍 Enhanced with web search results*"
saved_msg = cleaned_response + search_note
db2 = get_db()
db2.execute(
"INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
(conv_id, "assistant", saved_msg, datetime.now(timezone.utc).isoformat())
)
db2.commit()
db2.close()
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id, 'searched': True, 'perplexity': round(perplexity, 2), 'tokens_per_sec': round(tokens_per_sec, 1)})}\n\n"
return
# No search needed - save original response
db2 = get_db()
db2.execute(
"INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
(conv_id, "assistant", assistant_msg, datetime.now(timezone.utc).isoformat())
)
db2.commit()
db2.close()
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id, 'perplexity': round(perplexity, 2), 'tokens_per_sec': round(tokens_per_sec, 1)})}\n\n"
except httpx.ConnectError:
yield f"data: {json.dumps({'error': 'Cannot connect to Ollama. Is it running?'})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return StreamingResponse(stream_response(), media_type="text/event-stream")
# =====================================================================
# FRONTEND
# =====================================================================
HTML_PAGE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JarvisChat</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0a0e14;
--bg-secondary: #111820;
--bg-tertiary: #1a2230;
--bg-hover: #1e2a3a;
--text-primary: #c8d6e5;
--text-secondary: #7f8fa6;
--text-muted: #4a5568;
--accent: #48b5e0;
--accent-dim: #2a6f8a;
--accent-glow: rgba(72, 181, 224, 0.15);
--danger: #e74c3c;
--danger-hover: #c0392b;
--success: #2ecc71;
--warning: #f39c12;
--border: #1e2a3a;
--scrollbar: #2a3a4a;
--radius: 8px;
--font-body: 'IBM Plex Sans', -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', 'Consolas', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--font-body); background: var(--bg-primary); color: var(--text-primary); height: 100vh; overflow: hidden; display: flex; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 3px; }
/* Sidebar */
.sidebar { width: 280px; min-width: 280px; background: var(--bg-secondary); border-right: 1px solid var(--border); display: flex; flex-direction: column; height: 100vh; }
.sidebar-header { padding: 20px 16px 12px; border-bottom: 1px solid var(--border); text-align: center; }
.sidebar-header .logo { width: 100%; max-width: 180px; height: auto; margin-bottom: 12px; border-radius: 8px; }
.sidebar-header h1 { font-family: var(--font-mono); font-size: 18px; font-weight: 600; color: var(--accent); letter-spacing: 1px; margin-bottom: 4px; }
.sidebar-header .subtitle { font-size: 11px; color: var(--text-muted); font-family: var(--font-mono); margin-bottom: 12px; }
.btn-row { display: flex; gap: 6px; }
.new-chat-btn, .settings-btn { padding: 10px 14px; background: var(--accent-glow); border: 1px solid var(--accent-dim); border-radius: var(--radius); color: var(--accent); font-family: var(--font-body); font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
.new-chat-btn { flex: 1; }
.settings-btn { padding: 10px 12px; }
.new-chat-btn:hover, .settings-btn:hover { background: var(--accent-dim); color: #fff; }
.delete-all-btn { padding: 10px 12px; background: transparent; border: 1px solid var(--danger); border-radius: var(--radius); color: var(--danger); font-size: 14px; cursor: pointer; transition: all 0.2s; }
.delete-all-btn:hover { background: var(--danger); color: #fff; }
.conversation-list { flex: 1; overflow-y: auto; padding: 8px; }
.conv-item { padding: 10px 12px; border-radius: var(--radius); cursor: pointer; margin-bottom: 2px; display: flex; justify-content: space-between; align-items: center; transition: background 0.15s; font-size: 13px; color: var(--text-secondary); }
.conv-item:hover { background: var(--bg-hover); color: var(--text-primary); }
.conv-item.active { background: var(--bg-tertiary); color: var(--text-primary); }
.conv-item .conv-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.conv-item .conv-delete { opacity: 0; color: var(--danger); cursor: pointer; padding: 2px 6px; font-size: 16px; }
.conv-item:hover .conv-delete { opacity: 0.7; }
.conv-item .conv-delete:hover { opacity: 1; }
.sidebar-footer { padding: 12px 16px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-muted); font-family: var(--font-mono); }
.sidebar-footer .status-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
/* Main */
.main { flex: 1; display: flex; flex-direction: column; height: 100vh; min-width: 0; }
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); gap: 12px; }
.topbar-left { display: flex; align-items: center; gap: 12px; }
.topbar-right { display: flex; align-items: center; gap: 8px; }
.topbar select { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text-primary); font-family: var(--font-mono); font-size: 13px; padding: 6px 10px; border-radius: var(--radius); cursor: pointer; }
.topbar-label { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 1px; }
.profile-badge, .search-badge { font-size: 11px; padding: 4px 10px; border-radius: 12px; font-family: var(--font-mono); cursor: pointer; border: none; transition: all 0.2s; }
.profile-badge.on, .search-badge.on { background: rgba(46,204,113,0.15); color: var(--success); border: 1px solid rgba(46,204,113,0.3); }
.profile-badge.off, .search-badge.off { background: rgba(231,76,60,0.15); color: var(--danger); border: 1px solid rgba(231,76,60,0.3); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--success); display: inline-block; animation: pulse 2s infinite; }
.status-dot.offline { background: var(--danger); animation: none; }
.status-dot.warning { background: var(--warning); animation: none; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
/* Modal */
.modal-overlay { display:none; position:fixed; top:0;left:0;right:0;bottom:0; background:rgba(0,0,0,0.7); z-index:1000; align-items:center; justify-content:center; }
.modal-overlay.visible { display:flex; }
.modal { background:var(--bg-secondary); border:1px solid var(--border); border-radius:12px; width:90%; max-width:700px; max-height:85vh; overflow-y:auto; }
.modal-header { display:flex; justify-content:space-between; align-items:center; padding:20px 24px 16px; border-bottom:1px solid var(--border); position:sticky; top:0; background:var(--bg-secondary); z-index:1; }
.modal-header h2 { font-family:var(--font-mono); font-size:16px; color:var(--accent); }
.modal-close { background:none; border:none; color:var(--text-muted); font-size:24px; cursor:pointer; }
.modal-close:hover { color:var(--text-primary); }
.modal-body { padding: 20px 24px; }
.modal-section { margin-bottom: 24px; }
.modal-section h3 { font-family:var(--font-mono); font-size:13px; color:var(--text-secondary); text-transform:uppercase; letter-spacing:1px; margin-bottom:8px; }
.modal-section p.desc { font-size:12px; color:var(--text-muted); margin-bottom:10px; line-height:1.5; }
.modal-section textarea { width:100%; background:var(--bg-tertiary); border:1px solid var(--border); color:var(--text-primary); font-family:var(--font-mono); font-size:12px; padding:12px; border-radius:var(--radius); resize:vertical; line-height:1.6; }
.modal-section textarea:focus { outline:none; border-color:var(--accent-dim); }
.token-count { font-size:11px; color:var(--text-muted); font-family:var(--font-mono); margin-top:4px; text-align:right; }
.toggle-row { display:flex; align-items:center; justify-content:space-between; padding:8px 0; }
.toggle-label { font-size:13px; }
.toggle-switch { position:relative; width:44px; height:24px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:12px; cursor:pointer; transition:background 0.2s; }
.toggle-switch.on { background:var(--accent-dim); border-color:var(--accent-dim); }
.toggle-switch::after { content:''; position:absolute; top:2px; left:2px; width:18px; height:18px; background:var(--text-primary); border-radius:50%; transition:transform 0.2s; }
.toggle-switch.on::after { transform:translateX(20px); }
.btn-small { padding:6px 14px; border-radius:var(--radius); font-family:var(--font-mono); font-size:12px; cursor:pointer; border:1px solid var(--border); transition:all 0.2s; }
.btn-save { background:var(--accent-dim); color:#fff; border-color:var(--accent-dim); }
.btn-save:hover { background:var(--accent); }
.btn-reset { background:transparent; color:var(--text-muted); }
.btn-reset:hover { color:var(--danger); border-color:var(--danger); }
.btn-bar { display:flex; gap:8px; margin-top:10px; }
.preset-item { display:flex; align-items:center; gap:8px; padding:8px 10px; background:var(--bg-tertiary); border-radius:var(--radius); margin-bottom:6px; font-size:13px; }
.preset-item .preset-name { flex:1; color:var(--text-primary); }
.preset-item button { background:none; border:none; color:var(--text-muted); cursor:pointer; font-size:13px; padding:2px 4px; }
.preset-item button:hover { color:var(--text-primary); }
/* Chat */
.chat-container { flex:1; overflow-y:auto; padding:20px; display:flex; flex-direction:column; gap:16px; }
.welcome-screen { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--text-muted); text-align:center; gap:12px; }
.welcome-screen .logo { font-family:var(--font-mono); font-size:48px; color:var(--accent-dim); opacity:0.5; }
.welcome-screen p { font-size:14px; max-width:420px; line-height:1.6; }
.message { display:flex; gap:12px; max-width:900px; width:100%; margin:0 auto; animation:fadeIn 0.2s ease; }
@keyframes fadeIn { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }
.message .avatar { width:32px; height:32px; min-width:32px; border-radius:6px; display:flex; align-items:center; justify-content:center; font-family:var(--font-mono); font-size:13px; font-weight:600; margin-top:2px; }
.message.user .avatar { background:#1a3a5c; color:var(--accent); }
.message.assistant .avatar { background:var(--accent-dim); color:#fff; }
.message .content { flex:1; min-width:0; }
.message .content .role-label { font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px; color:var(--text-muted); font-family:var(--font-mono); }
.message .content .text { font-size:14px; line-height:1.65; word-wrap:break-word; overflow-wrap:break-word; }
.message .content .text pre { background:var(--bg-primary); border:1px solid var(--border); border-radius:var(--radius); padding:12px; margin:8px 0; overflow-x:auto; font-family:var(--font-mono); font-size:13px; line-height:1.5; position:relative; }
.message .content .text code { font-family:var(--font-mono); background:var(--bg-primary); padding:2px 5px; border-radius:3px; font-size:13px; }
.message .content .text pre code { background:none; padding:0; }
.copy-btn { position:absolute; top:6px; right:6px; background:var(--bg-tertiary); border:1px solid var(--border); color:var(--text-muted); font-family:var(--font-mono); font-size:11px; padding:3px 8px; border-radius:4px; cursor:pointer; }
.copy-btn:hover { color:var(--text-primary); }
.typing-indicator { display:inline-flex; gap:4px; padding:4px 0; }
.typing-indicator span { width:6px; height:6px; background:var(--accent-dim); border-radius:50%; animation:blink 1.4s infinite; }
.typing-indicator span:nth-child(2) { animation-delay:0.2s; }
.typing-indicator span:nth-child(3) { animation-delay:0.4s; }
@keyframes blink { 0%,80%,100%{opacity:0.3} 40%{opacity:1} }
.search-indicator { display:inline-flex; align-items:center; gap:8px; padding:8px 12px; background:rgba(243,156,18,0.15); border:1px solid rgba(243,156,18,0.3); border-radius:var(--radius); color:var(--warning); font-family:var(--font-mono); font-size:12px; margin:8px 0; }
.search-indicator .spinner { width:14px; height:14px; border:2px solid rgba(243,156,18,0.3); border-top-color:var(--warning); border-radius:50%; animation:spin 1s linear infinite; }
@keyframes spin { to{transform:rotate(360deg)} }
.search-badge-inline { display:inline-block; padding:2px 8px; background:rgba(46,204,113,0.15); border:1px solid rgba(46,204,113,0.3); border-radius:10px; color:var(--success); font-family:var(--font-mono); font-size:10px; margin-left:8px; }
.perplexity-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-family:var(--font-mono); font-size:10px; margin-left:8px; }
.perplexity-badge.low { background:rgba(46,204,113,0.15); border:1px solid rgba(46,204,113,0.3); color:var(--success); }
.perplexity-badge.medium { background:rgba(243,156,18,0.15); border:1px solid rgba(243,156,18,0.3); color:var(--warning); }
.perplexity-badge.high { background:rgba(231,76,60,0.15); border:1px solid rgba(231,76,60,0.3); color:var(--danger); }
.tps-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-family:var(--font-mono); font-size:10px; margin-left:8px; background:rgba(72,181,224,0.15); border:1px solid rgba(72,181,224,0.3); color:var(--accent); }
/* Input */
.input-area { padding:16px 20px; border-top:1px solid var(--border); background:var(--bg-secondary); }
.input-row-top { max-width:900px; margin:0 auto 8px; display:flex; gap:8px; align-items:center; }
.input-row-top select { background:var(--bg-tertiary); border:1px solid var(--border); color:var(--text-secondary); font-family:var(--font-mono); font-size:11px; padding:4px 8px; border-radius:var(--radius); cursor:pointer; }
.input-row-top .preset-label { font-size:11px; color:var(--text-muted); font-family:var(--font-mono); }
.input-wrapper { max-width:900px; margin:0 auto; display:flex; gap:10px; align-items:flex-end; }
.input-wrapper textarea { flex:1; background:var(--bg-tertiary); border:1px solid var(--border); color:var(--text-primary); font-family:var(--font-body); font-size:14px; padding:12px 14px; border-radius:var(--radius); resize:none; min-height:44px; max-height:200px; line-height:1.5; }
.input-wrapper textarea:focus { outline:none; border-color:var(--accent-dim); }
.input-wrapper textarea::placeholder { color:var(--text-muted); }
.send-btn { padding:12px 20px; background:var(--accent-dim); border:none; border-radius:var(--radius); color:#fff; font-family:var(--font-mono); font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap; }
.send-btn:hover { background:var(--accent); }
.stop-btn { padding:12px 20px; background:var(--danger); border:none; border-radius:var(--radius); color:#fff; font-family:var(--font-mono); font-size:13px; font-weight:600; cursor:pointer; }
.stop-btn:hover { background:var(--danger-hover); }
/* Token Thermometer */
.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-fill { position:absolute; bottom:0; left:0; right:0; background:linear-gradient(to top, var(--success), var(--warning), var(--danger)); transition:height 0.3s ease; border-radius:0 0 5px 5px; }
.thermometer-label { font-family:var(--font-mono); font-size:9px; color:var(--text-muted); writing-mode:vertical-rl; text-orientation:mixed; transform:rotate(180deg); white-space:nowrap; }
.token-info { font-family:var(--font-mono); font-size:10px; color:var(--text-muted); text-align:center; cursor:help; }
.token-info.warning { color:var(--warning); }
.token-info.danger { color:var(--danger); }
@media (max-width:768px) {
.sidebar { display:none; }
.topbar { padding:10px 14px; }
.chat-container { padding:12px; }
.input-area { padding:10px 12px; }
}
</style>
</head>
<body>
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<img class="logo" src="data:image/jpeg;base64,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" alt="JarvisChat Logo" />
<h1>&#9889; JarvisChat {{VERSION}}</h1>
<div class="subtitle">&#129433; local coding companion</div>
<div class="btn-row">
<button class="new-chat-btn" onclick="newChat()">+ New Chat</button>
<button class="settings-btn" onclick="openSettings()">&#9881;</button>
<button class="delete-all-btn" onclick="deleteAllConversations()" title="Delete all conversations">&#128465;</button>
</div>
</div>
<div class="conversation-list" id="convList"></div>
<div class="sidebar-footer">
<div class="status-row" id="ollamaStatus"><span class="status-dot offline"></span> checking...</div>
<div class="status-row" id="searchStatus"><span class="status-dot offline"></span> search: checking...</div>
</div>
</aside>
<!-- Settings Modal -->
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<div class="modal-header">
<h2>Settings</h2>
<button class="modal-close" onclick="closeSettings()">&times;</button>
</div>
<div class="modal-body">
<div class="modal-section">
<h3>Profile / Memory</h3>
<p class="desc">This context is injected as a system prompt into every conversation. It tells the model who you are, your environment, and how you want responses. Edit freely.</p>
<div class="toggle-row">
<span class="toggle-label">Inject profile into all chats</span>
<div class="toggle-switch on" id="profileToggle" onclick="toggleProfile()"></div>
</div>
<textarea id="profileEditor" rows="18" spellcheck="false"></textarea>
<div class="token-count" id="profileTokenCount"></div>
<div class="btn-bar">
<button class="btn-small btn-save" id="saveProfileBtn" onclick="saveProfile()">Save Profile</button>
<button class="btn-small btn-reset" onclick="resetProfile()">Reset to Default</button>
</div>
</div>
<div class="modal-section">
<h3>Web Search (SearXNG)</h3>
<p class="desc">When enabled, JarvisChat will automatically search the web if the model indicates it doesn't know the answer. Results are injected as context for a better response.</p>
<div class="toggle-row">
<span class="toggle-label">Enable automatic web search</span>
<div class="toggle-switch on" id="searchToggle" onclick="toggleSearch()"></div>
</div>
</div>
<div class="modal-section">
<h3>System Prompt Presets</h3>
<p class="desc">Presets add extra instructions on top of your profile. Select one in the chat to specialize behavior.</p>
<div id="presetList"></div>
<div class="btn-bar" style="margin-top:12px;">
<button class="btn-small btn-save" onclick="addPreset()">+ Add Preset</button>
</div>
</div>
<div class="modal-section">
<h3>General</h3>
<div class="toggle-row">
<span class="toggle-label">Default model</span>
<select id="defaultModelSetting" onchange="saveDefaultModel()"></select>
</div>
</div>
</div>
</div>
</div>
<main class="main">
<div class="topbar">
<div class="topbar-left">
<span class="topbar-label">Model</span>
<select id="modelSelect"></select>
</div>
<div class="topbar-right">
<button class="search-badge on" id="searchBadge" onclick="toggleSearch()" title="Toggle auto web search">🔍 SEARCH ON</button>
<button class="profile-badge on" id="profileBadge" onclick="toggleProfile()" title="Toggle profile injection">PROFILE ON</button>
</div>
</div>
<div class="chat-container" id="chatContainer">
<div class="welcome-screen" id="welcomeScreen">
<div class="logo">&#9889;</div>
<p>JarvisChat &mdash; your local coding companion.<br>Profile context is injected automatically.<br>Web search kicks in when the model is uncertain.<br>Pick a model and start building.</p>
</div>
</div>
<div class="input-area">
<div class="input-row-top">
<span class="preset-label">PRESET</span>
<select id="presetSelect">
<option value="">None (profile only)</option>
</select>
</div>
<div class="input-wrapper">
<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="thermometer-bar"><div class="thermometer-fill" id="thermometerFill" style="height:0%"></div></div>
<div class="token-info" id="tokenInfo">-- / --</div>
</div>
<button class="send-btn" id="sendBtn" onclick="sendMessage()">SEND</button>
</div>
</div>
</main>
<script>
let currentConvId = null;
let isStreaming = false;
let abortController = null;
let profileEnabled = true;
let searchEnabled = true;
let presets = [];
let modelContextSize = 8192; // default, updated on model change
let cachedProfile = '';
let conversationHistory = []; // track messages for token counting
document.addEventListener('DOMContentLoaded', async () => {
await loadModels();
await loadSettings();
await loadProfile();
await loadPresets();
await loadConversations();
checkOllamaStatus();
checkSearchStatus();
setInterval(checkOllamaStatus, 30000);
setInterval(checkSearchStatus, 60000);
document.getElementById('userInput').addEventListener('input', updateTokenThermometer);
updateTokenThermometer();
});
async function checkOllamaStatus() {
try {
const resp = await fetch('/api/ps');
const data = await resp.json();
const el = document.getElementById('ollamaStatus');
const models = data.models || [];
el.innerHTML = models.length > 0
? '<span class="status-dot"></span> ' + models.map(m => m.name).join(', ')
: '<span class="status-dot"></span> Ollama ready';
} catch(e) {
document.getElementById('ollamaStatus').innerHTML = '<span class="status-dot offline"></span> Ollama offline';
}
}
async function checkSearchStatus() {
try {
const resp = await fetch('/api/search/status');
const data = await resp.json();
const el = document.getElementById('searchStatus');
if (data.available) {
el.innerHTML = '<span class="status-dot"></span> search: ready';
} else {
el.innerHTML = '<span class="status-dot warning"></span> search: unavailable';
}
} catch(e) {
document.getElementById('searchStatus').innerHTML = '<span class="status-dot offline"></span> search: error';
}
}
async function loadModels() {
try {
const resp = await fetch('/api/models');
const data = await resp.json();
const select = document.getElementById('modelSelect');
const settingSelect = document.getElementById('defaultModelSetting');
select.innerHTML = '';
settingSelect.innerHTML = '';
(data.models || []).forEach(m => {
const gb = (m.size / (1024*1024*1024)).toFixed(1);
select.add(new Option(m.name + ' (' + gb + 'GB)', m.name));
settingSelect.add(new Option(m.name, m.name));
});
select.addEventListener('change', fetchModelContextSize);
} catch(e) {}
}
async function fetchModelContextSize() {
const model = document.getElementById('modelSelect').value;
if (!model) return;
try {
const resp = await fetch('/api/show', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name: model })
});
const data = await resp.json();
// num_ctx is in model_info or parameters
if (data.model_info && data.model_info['context_length']) {
modelContextSize = data.model_info['context_length'];
} else if (data.parameters) {
const match = data.parameters.match(/num_ctx\s+(\d+)/);
if (match) modelContextSize = parseInt(match[1]);
}
updateTokenThermometer();
} catch(e) {
console.log('Could not fetch model context size:', e);
}
}
async function loadSettings() {
try {
const resp = await fetch('/api/settings');
const s = await resp.json();
profileEnabled = s.profile_enabled !== 'false';
searchEnabled = s.search_enabled !== 'false';
updateProfileUI();
updateSearchUI();
if (s.default_model) {
document.getElementById('modelSelect').value = s.default_model;
document.getElementById('defaultModelSetting').value = s.default_model;
}
} catch(e) {}
}
async function saveSettings() {
await fetch('/api/settings', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
profile_enabled: profileEnabled ? 'true' : 'false',
search_enabled: searchEnabled ? 'true' : 'false'
})
});
}
async function saveDefaultModel() {
const model = document.getElementById('defaultModelSetting').value;
await fetch('/api/settings', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ default_model: model })
});
}
async function loadProfile() {
try {
const resp = await fetch('/api/profile');
const data = await resp.json();
cachedProfile = data.content || '';
document.getElementById('profileEditor').value = cachedProfile;
updateTokenCount();
updateTokenThermometer();
} catch(e) {}
}
async function saveProfile() {
const content = document.getElementById('profileEditor').value;
await fetch('/api/profile', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ content: content })
});
updateTokenCount();
var btn = document.getElementById('saveProfileBtn');
btn.textContent = 'Saved!';
setTimeout(function() { btn.textContent = 'Save Profile'; }, 1500);
}
async function resetProfile() {
if (!confirm('Reset profile to default? This overwrites your current profile.')) return;
try {
const resp = await fetch('/api/profile/default');
const data = await resp.json();
document.getElementById('profileEditor').value = data.content;
await saveProfile();
} catch(e) {}
}
function toggleProfile() {
profileEnabled = !profileEnabled;
updateProfileUI();
saveSettings();
}
function toggleSearch() {
searchEnabled = !searchEnabled;
updateSearchUI();
saveSettings();
}
function updateProfileUI() {
const badge = document.getElementById('profileBadge');
const toggle = document.getElementById('profileToggle');
badge.className = 'profile-badge ' + (profileEnabled ? 'on' : 'off');
badge.textContent = profileEnabled ? 'PROFILE ON' : 'PROFILE OFF';
if (toggle) toggle.className = 'toggle-switch' + (profileEnabled ? ' on' : '');
}
function updateSearchUI() {
const badge = document.getElementById('searchBadge');
const toggle = document.getElementById('searchToggle');
badge.className = 'search-badge ' + (searchEnabled ? 'on' : 'off');
badge.innerHTML = searchEnabled ? '🔍 SEARCH ON' : '🔍 SEARCH OFF';
if (toggle) toggle.className = 'toggle-switch' + (searchEnabled ? ' on' : '');
}
function updateTokenCount() {
const text = document.getElementById('profileEditor').value;
cachedProfile = text;
const tokens = Math.round(text.length / 4);
document.getElementById('profileTokenCount').textContent = '~' + tokens + ' tokens';
updateTokenThermometer();
}
function estimateTokens(text) {
// Rough estimate: ~4 characters per token for English
return Math.round((text || '').length / 4);
}
function updateTokenThermometer() {
const userInput = document.getElementById('userInput').value || '';
const presetId = document.getElementById('presetSelect').value;
const preset = presets.find(p => p.id === presetId);
const presetText = preset ? preset.prompt : '';
// Calculate total tokens: profile + preset + history + current input
let totalTokens = 0;
if (profileEnabled && cachedProfile) {
totalTokens += estimateTokens(cachedProfile);
}
totalTokens += estimateTokens(presetText);
conversationHistory.forEach(msg => {
totalTokens += estimateTokens(msg.content);
});
totalTokens += estimateTokens(userInput);
// Update thermometer fill
const fill = document.getElementById('thermometerFill');
const info = document.getElementById('tokenInfo');
const percent = Math.min((totalTokens / modelContextSize) * 100, 100);
fill.style.height = percent + '%';
// Format numbers: use K for thousands
const formatNum = n => n >= 1000 ? (n/1000).toFixed(1) + 'K' : n;
info.textContent = formatNum(totalTokens) + ' / ' + formatNum(modelContextSize);
info.title = totalTokens + ' / ' + modelContextSize + ' tokens';
// Color coding
info.className = 'token-info';
if (percent >= 90) {
info.classList.add('danger');
} else if (percent >= 70) {
info.classList.add('warning');
}
}
document.getElementById('profileEditor').addEventListener('input', updateTokenCount);
document.getElementById('presetSelect').addEventListener('change', updateTokenThermometer);
async function loadPresets() {
try {
const resp = await fetch('/api/presets');
presets = await resp.json();
renderPresetList();
renderPresetSelect();
} catch(e) {}
}
function renderPresetList() {
const container = document.getElementById('presetList');
container.innerHTML = '';
presets.forEach(function(p) {
const div = document.createElement('div');
div.className = 'preset-item';
const nameSpan = document.createElement('span');
nameSpan.className = 'preset-name';
nameSpan.textContent = p.name;
div.appendChild(nameSpan);
const actions = document.createElement('div');
actions.className = 'preset-actions';
const editBtn = document.createElement('button');
editBtn.innerHTML = '&#9998;';
editBtn.title = 'Edit';
editBtn.setAttribute('data-id', p.id);
editBtn.addEventListener('click', function() { editPreset(this.getAttribute('data-id')); });
actions.appendChild(editBtn);
if (!p.is_default) {
const delBtn = document.createElement('button');
delBtn.innerHTML = '&times;';
delBtn.title = 'Delete';
delBtn.setAttribute('data-id', p.id);
delBtn.addEventListener('click', function() { deletePreset(this.getAttribute('data-id')); });
actions.appendChild(delBtn);
}
div.appendChild(actions);
container.appendChild(div);
});
}
function renderPresetSelect() {
const select = document.getElementById('presetSelect');
const current = select.value;
select.innerHTML = '<option value="">None (profile only)</option>';
presets.forEach(function(p) { select.add(new Option(p.name, p.id)); });
select.value = current;
}
async function addPreset() {
const name = prompt('Preset name:');
if (!name) return;
const p = prompt('System prompt text:');
if (!p) return;
await fetch('/api/presets', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name: name, prompt: p}) });
await loadPresets();
}
async function editPreset(id) {
const preset = presets.find(function(p) { return p.id === id; });
if (!preset) return;
const name = prompt('Preset name:', preset.name);
if (!name) return;
const p = prompt('System prompt:', preset.prompt);
if (p === null) return;
await fetch('/api/presets/' + id, { method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name: name, prompt: p}) });
await loadPresets();
}
async function deletePreset(id) {
if (!confirm('Delete this preset?')) return;
await fetch('/api/presets/' + id, { method:'DELETE' });
await loadPresets();
}
function getSelectedPresetPrompt() {
const id = document.getElementById('presetSelect').value;
if (!id) return '';
const p = presets.find(function(x) { return x.id === id; });
return p ? p.prompt : '';
}
function openSettings() { document.getElementById('settingsModal').classList.add('visible'); loadProfile(); }
function closeSettings() { document.getElementById('settingsModal').classList.remove('visible'); }
document.getElementById('settingsModal').addEventListener('click', function(e) { if (e.target.id === 'settingsModal') closeSettings(); });
async function loadConversations() {
try {
const resp = await fetch('/api/conversations');
const convs = await resp.json();
const list = document.getElementById('convList');
list.innerHTML = '';
convs.forEach(function(c) {
const div = document.createElement('div');
div.className = 'conv-item' + (c.id === currentConvId ? ' active' : '');
const titleSpan = document.createElement('span');
titleSpan.className = 'conv-title';
titleSpan.textContent = c.title;
titleSpan.setAttribute('data-id', c.id);
titleSpan.addEventListener('click', function() { loadConversation(this.getAttribute('data-id')); });
div.appendChild(titleSpan);
const delSpan = document.createElement('span');
delSpan.className = 'conv-delete';
delSpan.innerHTML = '&times;';
delSpan.setAttribute('data-id', c.id);
delSpan.addEventListener('click', function(ev) { ev.stopPropagation(); deleteConversation(this.getAttribute('data-id')); });
div.appendChild(delSpan);
list.appendChild(div);
});
} catch(e) {}
}
async function loadConversation(convId) {
try {
const resp = await fetch('/api/conversations/' + convId);
const data = await resp.json();
currentConvId = convId;
document.getElementById('modelSelect').value = data.conversation.model;
fetchModelContextSize();
const container = document.getElementById('chatContainer');
container.innerHTML = '';
conversationHistory = [];
data.messages.forEach(function(msg) {
appendMessage(msg.role, msg.content, false);
conversationHistory.push({ role: msg.role, content: msg.content });
});
scrollToBottom();
updateTokenThermometer();
await loadConversations();
} catch(e) {}
}
async function deleteConversation(convId) {
if (!confirm('Delete this conversation?')) return;
await fetch('/api/conversations/' + convId, { method:'DELETE' });
if (currentConvId === convId) { currentConvId = null; showWelcome(); }
await loadConversations();
}
async function deleteAllConversations() {
if (!confirm('Delete ALL conversations? This cannot be undone.')) return;
await fetch('/api/conversations', { method:'DELETE' });
currentConvId = null;
conversationHistory = [];
showWelcome();
updateTokenThermometer();
await loadConversations();
}
function newChat() {
currentConvId = null;
conversationHistory = [];
showWelcome();
document.querySelectorAll('.conv-item').forEach(function(el) { el.classList.remove('active'); });
updateTokenThermometer();
}
function showWelcome() {
document.getElementById('chatContainer').innerHTML =
'<div class="welcome-screen" id="welcomeScreen">' +
'<div class="logo">&#9889;</div>' +
'<p>JarvisChat &mdash; your local coding companion.<br>Profile context is injected automatically.<br>Web search kicks in when the model is uncertain.<br>Pick a model and start building.</p>' +
'</div>';
}
async function sendMessage() {
const input = document.getElementById('userInput');
const message = input.value.trim();
if (!message || isStreaming) return;
const model = document.getElementById('modelSelect').value;
const presetPrompt = getSelectedPresetPrompt();
const welcome = document.getElementById('welcomeScreen');
if (welcome) welcome.remove();
appendMessage('user', message, true);
conversationHistory.push({ role: 'user', content: message });
input.value = '';
input.style.height = 'auto';
updateTokenThermometer();
const assistantDiv = appendMessage('assistant', '', true);
const textEl = assistantDiv.querySelector('.text');
textEl.innerHTML = '<div class="typing-indicator"><span></span><span></span><span></span></div>';
setStreamingState(true);
let searchTriggered = false;
let originalResponse = '';
try {
abortController = new AbortController();
const resp = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ conversation_id: currentConvId, message: message, model: model, system_prompt: presetPrompt }),
signal: abortController.signal
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let fullText = '';
let firstToken = true;
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.startsWith('data: ')) continue;
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.searching) {
// Model expressed uncertainty, searching...
originalResponse = fullText;
textEl.innerHTML = renderMarkdown(fullText) + '<div class="search-indicator"><div class="spinner"></div>Searching the web...</div>';
searchTriggered = true;
}
if (data.debug) {
console.log('[DEBUG]', data.debug);
// Show debug in UI temporarily
textEl.innerHTML += '<div class="search-indicator" style="font-size:10px">' + data.debug + '</div>';
}
if (data.search_results) {
// Got search results, about to stream augmented response
let preview = data.results_preview ? data.results_preview.slice(0,3).join(', ') : '';
textEl.innerHTML = '<div class="search-indicator">🔍 Found ' + data.search_results + ' results: ' + preview + '...</div>';
fullText = ''; // Reset for augmented response
firstToken = true;
}
if (data.token) {
if (firstToken) {
if (searchTriggered) {
// Clear the search indicator and start fresh with augmented response
textEl.innerHTML = '';
} else {
textEl.innerHTML = '';
}
firstToken = false;
}
fullText += data.token;
textEl.innerHTML = renderMarkdown(fullText);
scrollToBottom();
}
if (data.done) {
const roleLabel = assistantDiv.querySelector('.role-label');
if (data.searched) {
// Add search badge to the message
if (roleLabel && !roleLabel.querySelector('.search-badge-inline')) {
roleLabel.innerHTML += '<span class="search-badge-inline">🔍 web search</span>';
}
}
// Add perplexity badge
if (typeof data.perplexity === 'number' && roleLabel) {
const ppl = data.perplexity;
let pplClass = 'low';
if (ppl >= 15) pplClass = 'high';
else if (ppl >= 8) pplClass = 'medium';
roleLabel.innerHTML += '<span class="perplexity-badge ' + pplClass + '" title="Perplexity (lower=confident, higher=uncertain)">ppl: ' + ppl.toFixed(1) + '</span>';
}
// Add tokens per second badge
if (typeof data.tokens_per_sec === 'number' && data.tokens_per_sec > 0 && roleLabel) {
roleLabel.innerHTML += '<span class="tps-badge" title="Tokens per second">' + data.tokens_per_sec.toFixed(1) + ' t/s</span>';
}
// Track assistant response for token counting
conversationHistory.push({ role: 'assistant', content: fullText });
updateTokenThermometer();
addCopyButtons(assistantDiv);
setStreamingState(false);
await loadConversations();
checkOllamaStatus();
}
} catch(e) {}
}
}
} catch (e) {
if (e.name === 'AbortError') textEl.innerHTML += '<br><em style="color:var(--text-muted)">[stopped]</em>';
else textEl.textContent = 'Error: ' + e.message;
setStreamingState(false);
}
}
function setStreamingState(streaming) {
isStreaming = streaming;
const btn = document.getElementById('sendBtn');
if (streaming) {
btn.textContent = 'STOP'; btn.className = 'stop-btn';
btn.onclick = function() { if (abortController) abortController.abort(); setStreamingState(false); };
} else {
btn.textContent = 'SEND'; btn.className = 'send-btn'; btn.onclick = sendMessage;
}
}
function appendMessage(role, content, animate) {
const container = document.getElementById('chatContainer');
const div = document.createElement('div');
div.className = 'message ' + role;
if (!animate) div.style.animation = 'none';
div.innerHTML = '<div class="avatar">' + (role==='user'?'YOU':'AI') + '</div>' +
'<div class="content"><div class="role-label">' + role + '</div>' +
'<div class="text">' + (content ? renderMarkdown(content) : '') + '</div></div>';
container.appendChild(div);
if (content && role === 'assistant') addCopyButtons(div);
scrollToBottom();
return div;
}
function renderMarkdown(text) {
var blocks = [];
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, function(match, lang, code) {
blocks.push('<pre data-lang="' + lang + '"><code>' + escapeHtml(code) + '</code></pre>');
return '\x00BLOCK' + (blocks.length - 1) + '\x00';
});
text = text.replace(/```([\s\S]*?)```/g, function(match, code) {
blocks.push('<pre><code>' + escapeHtml(code) + '</code></pre>');
return '\x00BLOCK' + (blocks.length - 1) + '\x00';
});
var h = escapeHtml(text);
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
h = h.replace(/\*(.+?)\*/g, '<em>$1</em>');
h = h.replace(/\n/g, '<br>');
h = h.replace(/\x00BLOCK(\d+)\x00/g, function(match, idx) { return blocks[parseInt(idx)]; });
return h;
}
function addCopyButtons(msgDiv) {
msgDiv.querySelectorAll('pre').forEach(function(pre) {
if (pre.querySelector('.copy-btn')) return;
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.textContent = 'copy';
btn.onclick = function() {
navigator.clipboard.writeText(pre.querySelector('code') ? pre.querySelector('code').textContent : pre.textContent)
.then(function() { btn.textContent = 'copied!'; setTimeout(function() { btn.textContent = 'copy'; }, 1500); });
};
pre.style.position = 'relative';
pre.appendChild(btn);
});
}
function escapeHtml(t) { var d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
function scrollToBottom() { var c = document.getElementById('chatContainer'); c.scrollTop = c.scrollHeight; }
var userInput = document.getElementById('userInput');
userInput.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 200) + 'px'; });
userInput.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } });
</script>
</body>
</html>
"""
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080)