diff --git a/app.py b/app.py index 500c7e2..b359a38 100644 --- a/app.py +++ b/app.py @@ -13,6 +13,7 @@ Features: - Copy-to-clipboard on code blocks - Token count estimates - SearXNG integration for web search when model is uncertain + - Explicit web search via search button """ import json @@ -44,7 +45,7 @@ syslog_handler.setFormatter(logging.Formatter('jarvischat[%(process)d]: %(leveln log.addHandler(syslog_handler) # --- Configuration --- -VERSION = "1.4.0" +VERSION = "1.5.0" OLLAMA_BASE = "http://localhost:11434" SEARXNG_BASE = "http://localhost:8888" BASE_DIR = Path(__file__).parent @@ -67,6 +68,9 @@ REFUSAL_PATTERNS = re.compile(r"|".join([ r"as of my (?:knowledge|training) cutoff", r"i'?m not able to (?:access|provide|browse)", r"(?:check|visit|use) a (?:website|financial|news)", + r"as an ai model", + r"based on my training data", + r"i don'?t have the capability", ]), re.IGNORECASE) # --- Hedging patterns --- @@ -738,6 +742,113 @@ async def delete_all_conversations(): return {"status": "ok"} +# ============================================================================= +# EXPLICIT WEB SEARCH +# ============================================================================= + +@app.post("/api/search") +async def explicit_search(request: Request): + """Explicit web search - bypasses model uncertainty, queries SearXNG directly.""" + body = await request.json() + query = body.get("query", "").strip() + conv_id = body.get("conversation_id") + model = body.get("model", DEFAULT_MODEL) + + if not query: + raise HTTPException(status_code=400, detail="Empty query") + + db = get_db() + now = datetime.now(timezone.utc).isoformat() + + if not conv_id: + conv_id = str(uuid.uuid4()) + title = f"🔍 {query[:70]}..." if len(query) > 70 else f"🔍 {query}" + 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)) + + db.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", + (conv_id, "user", f"🔍 {query}", now)) + db.commit() + db.close() + + async def stream_search(): + yield f"data: {json.dumps({'conversation_id': conv_id, 'searching': True})}\n\n" + + results = await query_searxng(query, max_results=5) + + if not results: + error_msg = "No search results found." + yield f"data: {json.dumps({'token': error_msg, 'conversation_id': conv_id})}\n\n" + + # Save to DB + db2 = get_db() + db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", + (conv_id, "assistant", error_msg, datetime.now(timezone.utc).isoformat())) + db2.commit() + db2.close() + + yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id})}\n\n" + return + + yield f"data: {json.dumps({'search_results': len(results), 'conversation_id': conv_id})}\n\n" + + # Ask Ollama to summarize + search_context = format_search_results(results) + messages = [ + {"role": "system", "content": f"You have access to current web data. Answer directly using ONLY the data below. Be concise. No apologies. No disclaimers.\n\n{search_context}"}, + {"role": "user", "content": query} + ] + + full_response = [] + async with httpx.AsyncClient() as client: + try: + async with client.stream("POST", f"{OLLAMA_BASE}/api/chat", + json={"model": model, "messages": messages, "stream": True}, + 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" + if chunk.get("done"): + break + except json.JSONDecodeError: + pass + except Exception as e: + log.error(f"Ollama error during search summarization: {e}") + yield f"data: {json.dumps({'error': str(e)})}\n\n" + return + + summary = "".join(full_response) + + # Build raw results markdown + raw_lines = [] + for i, r in enumerate(results, 1): + raw_lines.append(f"{i}. [{r['title']}]({r['url']})") + if r['content']: + raw_lines.append(f" {r['content']}") + raw_results_md = "\n".join(raw_lines) + + saved_msg = f"{summary}\n\n---\n*🔍 Web search results*" + + 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() + + # Send raw results for frontend expandable div + yield f"data: {json.dumps({'raw_results': results, 'conversation_id': conv_id})}\n\n" + yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id, 'searched': True})}\n\n" + + return StreamingResponse(stream_search(), media_type="text/event-stream") + + # ============================================================================= # CHAT (STREAMING) # ============================================================================= diff --git a/templates/index.html b/templates/index.html index 0e90cff..a9a22d7 100644 --- a/templates/index.html +++ b/templates/index.html @@ -142,7 +142,7 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var( .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; } +.search-badge-inline { display:inline-block; padding:2px 8px; background:rgba(243,156,18,0.15); border:1px solid rgba(243,156,18,0.3); border-radius:10px; color:var(--warning); font-family:var(--font-mono); font-size:10px; margin-left:8px; } .memory-badge-inline { display:inline-block; padding:2px 8px; background:rgba(155,89,182,0.15); border:1px solid rgba(155,89,182,0.3); border-radius:10px; color:#9b59b6; 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); } @@ -162,6 +162,9 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var( .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); } +.search-btn { padding:12px 14px; background:var(--warning); border:none; border-radius:var(--radius); color:#fff; font-size:16px; cursor:pointer; transition:background 0.2s; } +.search-btn:hover { background:#e67e22; } +.search-btn:disabled { background:var(--text-muted); cursor:not-allowed; } .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; } @@ -170,6 +173,15 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var( .token-info.warning { color:var(--warning); } .token-info.danger { color:var(--danger); } +.message.assistant.search-result .content { background:rgba(243,156,18,0.08); border:1px solid rgba(243,156,18,0.2); border-radius:var(--radius); padding:12px; } +.raw-results { margin-top:12px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:var(--radius); padding:8px 12px; font-size:12px; } +.raw-results summary { cursor:pointer; color:var(--accent); font-family:var(--font-mono); } +.raw-results ul { margin:8px 0 0 0; padding-left:20px; list-style:none; } +.raw-results li { margin-bottom:8px; } +.raw-results a { color:var(--accent); text-decoration:none; } +.raw-results a:hover { text-decoration:underline; } +.raw-results small { color:var(--text-muted); display:block; margin-top:2px; } + @media (max-width:768px) { .sidebar { display:none; } .topbar { padding:10px 14px; } @@ -238,7 +250,7 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
When enabled, JarvisChat will automatically search the web if the model indicates uncertainty.
+When enabled, JarvisChat will automatically search the web if the model indicates uncertainty. Use the 🔍 button to force a web search.
JarvisChat — your local coding companion.
Profile + Memory context injected automatically.
Web search kicks in when the model is uncertain.
Say "remember that..." to teach me things.
JarvisChat — your local coding companion.
Profile + Memory context injected automatically.
Web search kicks in when the model is uncertain.
Use 🔍 to force a web search.
Say "remember that..." to teach me things.
JarvisChat — your local coding companion.
Profile + Memory context injected automatically.
Web search kicks in when the model is uncertain.
Say "remember that..." to teach me things.
JarvisChat — your local coding companion.
Profile + Memory context injected automatically.
Web search kicks in when the model is uncertain.
Use 🔍 to force a web search.
Say "remember that..." to teach me things.