From 3d1ede26ca1c81d570c9c74f729769469f410aa2 Mon Sep 17 00:00:00 2001 From: gramps Date: Sun, 15 Mar 2026 17:12:20 -0700 Subject: [PATCH] v.1.5.0: Explicit web search button, orange search styling --- app.py | 113 ++++++++++++++++++++++++++++++++++++++++++- templates/index.html | 110 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 212 insertions(+), 11 deletions(-) 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( @@ -331,7 +344,7 @@ async function loadMemoryStats() { const data = await resp.json(); document.getElementById('memoryStats').textContent = `Total: ${data.total} memories`; document.getElementById('memoryStatus').innerHTML = ` memory: ${data.total} entries`; - + const listResp = await fetch('/api/memories?limit=20'); const listData = await listResp.json(); const container = document.getElementById('memoryList'); @@ -662,7 +675,74 @@ function newChat() { } function showWelcome() { - document.getElementById('chatContainer').innerHTML = '

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

'; + document.getElementById('chatContainer').innerHTML = '

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

'; +} + +async function sendSearch() { + const input = document.getElementById('userInput'); + const query = input.value.trim(); + if (!query || isStreaming) return; + const model = document.getElementById('modelSelect').value; + const welcome = document.getElementById('welcomeScreen'); + if (welcome) welcome.remove(); + appendMessage('user', '🔍 ' + query, true); + conversationHistory.push({ role: 'user', content: '🔍 ' + query }); + input.value = ''; + input.style.height = 'auto'; + updateTokenThermometer(); + const assistantDiv = appendMessage('assistant', '', true, true); + const textEl = assistantDiv.querySelector('.text'); + textEl.innerHTML = '
Searching the web...
'; + setStreamingState(true); + try { + abortController = new AbortController(); + const resp = await fetch('/api/search', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ conversation_id: currentConvId, query, model }), signal: abortController.signal }); + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let fullText = ''; + let buffer = ''; + let firstToken = true; + 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 (const line of lines) { + 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.search_results) { textEl.innerHTML = '
🔍 Found ' + data.search_results + ' results, summarizing...
'; } + if (data.token) { if (firstToken) { textEl.innerHTML = ''; firstToken = false; } fullText += data.token; textEl.innerHTML = renderMarkdown(fullText); scrollToBottom(); } + if (data.raw_results) { + let rawHtml = '
🔍 View raw search results (' + data.raw_results.length + ')
'; + textEl.innerHTML += rawHtml; + } + if (data.done) { + const roleLabel = assistantDiv.querySelector('.role-label'); + if (roleLabel) roleLabel.innerHTML += '🔍 web'; + conversationHistory.push({ role: 'assistant', content: fullText }); + updateTokenThermometer(); + addCopyButtons(assistantDiv); + setStreamingState(false); + await loadConversations(); + } + } catch(e) { console.log('Parse error:', e); } + } + } + } catch (e) { + if (e.name === 'AbortError') textEl.innerHTML += '
[stopped]'; + else textEl.textContent = 'Error: ' + e.message; + setStreamingState(false); + } } async function sendMessage() { @@ -731,15 +811,25 @@ async function sendMessage() { function setStreamingState(streaming) { isStreaming = streaming; - const btn = document.getElementById('sendBtn'); - if (streaming) { btn.textContent = 'STOP'; btn.className = 'stop-btn'; btn.onclick = () => { if (abortController) abortController.abort(); setStreamingState(false); }; } - else { btn.textContent = 'SEND'; btn.className = 'send-btn'; btn.onclick = sendMessage; } + const sendBtn = document.getElementById('sendBtn'); + const searchBtn = document.getElementById('searchBtn'); + if (streaming) { + sendBtn.textContent = 'STOP'; + sendBtn.className = 'stop-btn'; + sendBtn.onclick = () => { if (abortController) abortController.abort(); setStreamingState(false); }; + searchBtn.disabled = true; + } else { + sendBtn.textContent = 'SEND'; + sendBtn.className = 'send-btn'; + sendBtn.onclick = sendMessage; + searchBtn.disabled = false; + } } -function appendMessage(role, content, animate) { +function appendMessage(role, content, animate, isSearch = false) { const container = document.getElementById('chatContainer'); const div = document.createElement('div'); - div.className = 'message ' + role; + div.className = 'message ' + role + (isSearch && role === 'assistant' ? ' search-result' : ''); if (!animate) div.style.animation = 'none'; div.innerHTML = `
${role === 'user' ? 'YOU' : 'AI'}
${role}
${content ? renderMarkdown(content) : ''}
`; container.appendChild(div);