v.1.5.0: Explicit web search button, orange search styling
This commit is contained in:
113
app.py
113
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)
|
||||
# =============================================================================
|
||||
|
||||
@@ -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(
|
||||
</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 uncertainty.</p>
|
||||
<p class="desc">When enabled, JarvisChat will automatically search the web if the model indicates uncertainty. Use the 🔍 button to force a web search.</p>
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">Enable automatic web search</span>
|
||||
<div class="toggle-switch on" id="searchToggle" onclick="toggleSearch()"></div>
|
||||
@@ -277,7 +289,7 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
|
||||
<div class="chat-container" id="chatContainer">
|
||||
<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>Say "remember that..." to teach me things.</p>
|
||||
<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>
|
||||
</div>
|
||||
<div class="input-area">
|
||||
@@ -291,6 +303,7 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
|
||||
<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="search-btn" id="searchBtn" onclick="sendSearch()" title="Search the web">🔍</button>
|
||||
<button class="send-btn" id="sendBtn" onclick="sendMessage()">SEND</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -662,7 +675,74 @@ function newChat() {
|
||||
}
|
||||
|
||||
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>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>';
|
||||
}
|
||||
|
||||
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 = '<div class="search-indicator"><div class="spinner"></div>Searching the web...</div>';
|
||||
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 = '<div class="search-indicator">🔍 Found ' + data.search_results + ' results, summarizing...</div>'; }
|
||||
if (data.token) { if (firstToken) { textEl.innerHTML = ''; firstToken = false; } fullText += data.token; textEl.innerHTML = renderMarkdown(fullText); scrollToBottom(); }
|
||||
if (data.raw_results) {
|
||||
let rawHtml = '<details class="raw-results"><summary>🔍 View raw search results (' + data.raw_results.length + ')</summary><ul>';
|
||||
data.raw_results.forEach(r => {
|
||||
rawHtml += `<li><a href="${escapeHtml(r.url)}" target="_blank" rel="noopener">${escapeHtml(r.title)}</a>`;
|
||||
if (r.content) rawHtml += `<small>${escapeHtml(r.content)}</small>`;
|
||||
rawHtml += '</li>';
|
||||
});
|
||||
rawHtml += '</ul></details>';
|
||||
textEl.innerHTML += rawHtml;
|
||||
}
|
||||
if (data.done) {
|
||||
const roleLabel = assistantDiv.querySelector('.role-label');
|
||||
if (roleLabel) roleLabel.innerHTML += '<span class="search-badge-inline">🔍 web</span>';
|
||||
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 += '<br><em style="color:var(--text-muted)">[stopped]</em>';
|
||||
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 = `<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);
|
||||
|
||||
Reference in New Issue
Block a user