11"""
2- ThinkMCP — Streamlit UI
2+ ThinkMCP — Streamlit UI (local-first: Ollama + Qwen 3).
3+
4+ Runs the thinking agent against your local Ollama server and streams the
5+ thinking trace and tool calls into the UI live, as they happen.
36"""
47from __future__ import annotations
58
69import json
710import os
8- import sys
9- import time
11+ import queue
1012import threading
13+ import time
1114from datetime import datetime
1215from typing import Any
1316
1417import streamlit as st
1518
16- sys .path .insert (0 , os .path .dirname (__file__ ))
1719from agent .thinking_agent import run_thinking_agent
1820
1921# ── Page config ───────────────────────────────────────────────────────────────
6466 margin: 6px 0 4px 0;
6567}
6668
69+ /* Status pills */
70+ .pill-ok { color: #34d399; font-size: 12px; }
71+ .pill-bad { color: #f87171; font-size: 12px; }
72+
6773/* Keyboard shortcut hint */
6874.kb-hint { font-size: 12.5px; color: #555; padding: 9px 0 0 4px; display: inline-block; }
6975
193199st .session_state .setdefault ("result" , None )
194200st .session_state .setdefault ("history" , [])
195201
202+ # ── Ollama helpers ────────────────────────────────────────────────────────────
203+
204+
205+ @st .cache_data (ttl = 15 , show_spinner = False )
206+ def installed_models (host : str ) -> list [str ]:
207+ """List models available on the local Ollama server (empty if unreachable)."""
208+ try :
209+ import ollama
210+
211+ resp = ollama .Client (host = host ).list ()
212+ return [m .model for m in resp .models ]
213+ except Exception :
214+ return []
215+
216+
217+ def pick_default_model (models : list [str ]) -> int :
218+ preferred = os .environ .get ("THINKMCP_MODEL" , "" )
219+ if preferred in models :
220+ return models .index (preferred )
221+ for i , m in enumerate (models ):
222+ if m .lower ().startswith ("qwen3" ):
223+ return i
224+ return 0
225+
226+
196227# ── Sidebar ───────────────────────────────────────────────────────────────────
197228
198229with st .sidebar :
201232 <div class="sb-icon">🧠</div>
202233 <div>
203234 <div class="sb-title">ThinkMCP</div>
204- <div class="sb-sub">Research Agent</div>
235+ <div class="sb-sub">Local Research Agent · Ollama </div>
205236 </div>
206237 </div>
207238 """ , unsafe_allow_html = True )
208239
209- with st .expander ("SETTINGS" , expanded = True ):
240+ with st .expander ("MODEL" , expanded = True ):
241+ st .markdown ('<div class="sec-label">Ollama Host</div>' , unsafe_allow_html = True )
242+ ollama_host = st .text_input (
243+ "oh" , label_visibility = "collapsed" ,
244+ value = os .environ .get ("OLLAMA_HOST" , "http://localhost:11434" ),
245+ )
246+ models = installed_models (ollama_host )
247+ st .markdown ('<div class="sec-label">Model</div>' , unsafe_allow_html = True )
248+ if models :
249+ model = st .selectbox (
250+ "om" , models , index = pick_default_model (models ),
251+ label_visibility = "collapsed" ,
252+ )
253+ st .markdown ('<span class="pill-ok">● Ollama connected</span>' ,
254+ unsafe_allow_html = True )
255+ else :
256+ model = st .text_input (
257+ "om" , label_visibility = "collapsed" ,
258+ value = os .environ .get ("THINKMCP_MODEL" , "qwen3:8b" ),
259+ )
260+ st .markdown ('<span class="pill-bad">● Ollama not reachable — is it running?</span>' ,
261+ unsafe_allow_html = True )
262+
263+ with st .expander ("SETTINGS" , expanded = False ):
210264 st .markdown ('<div class="sec-label">System Prompt</div>' , unsafe_allow_html = True )
211265 system_prompt = st .text_area (
212266 "sp" , label_visibility = "collapsed" , height = 155 ,
213267 value = (
214268 "You are ThinkMCP — a thinking-augmented research agent. "
215- "Before each action, use think_tool to reason about your approach. "
216- "After gathering evidence, use critique_tool to verify quality. "
217- "Use remember_tool to persist key findings across steps. "
269+ "Before each action, reason carefully about your approach. "
270+ "Use web_search_tool, fetch_url_tool, search_papers_tool and "
271+ "search_code_tool to gather evidence. Use critique_tool on your "
272+ "draft before answering. Use remember_tool to persist key findings. "
218273 "When done, present a thorough, well-sourced answer."
219274 ),
220275 )
221276 st .markdown ('<div class="sec-label">Max Iterations</div>' , unsafe_allow_html = True )
222277 max_iter = st .slider ("mi" , 5 , 30 , 20 , label_visibility = "collapsed" )
223278 show_raw = st .toggle ("Show raw trace JSON" , value = False )
224279
225- with st .expander ("API KEYS" , expanded = True ):
226- st .markdown ('<div class="sec-label">Tavily</div>' , unsafe_allow_html = True )
280+ with st .expander ("API KEYS" , expanded = False ):
281+ st .caption ("Only needed for web search / GitHub code search. "
282+ "Everything else runs fully local." )
283+ st .markdown ('<div class="sec-label">Tavily (web search)</div>' , unsafe_allow_html = True )
227284 tavily_key = st .text_input (
228285 "tv" , type = "password" , label_visibility = "collapsed" ,
229286 value = os .environ .get ("TAVILY_API_KEY" , "" ),
230287 )
231- st .markdown ('<div class="sec-label">Anthropic</div>' , unsafe_allow_html = True )
232- anthropic_key = st .text_input (
233- "ak" , type = "password" , label_visibility = "collapsed" ,
234- value = os .environ .get ("ANTHROPIC_API_KEY" , "" ),
235- )
236- st .markdown ('<div class="sec-label">GitHub</div>' , unsafe_allow_html = True )
288+ st .markdown ('<div class="sec-label">GitHub (optional)</div>' , unsafe_allow_html = True )
237289 github_token = st .text_input (
238290 "gh" , type = "password" , label_visibility = "collapsed" ,
239291 placeholder = "(optional)" ,
240292 value = os .environ .get ("GITHUB_TOKEN" , "" ),
241293 )
242294
243- # Propagate keys to env
295+ # Propagate keys to env (inherited by the MCP server subprocess)
244296 if tavily_key :
245297 os .environ ["TAVILY_API_KEY" ] = tavily_key
246- if anthropic_key :
247- os .environ ["ANTHROPIC_API_KEY" ] = anthropic_key
248298 if github_token :
249299 os .environ ["GITHUB_TOKEN" ] = github_token
250300
@@ -324,33 +374,21 @@ def render_trace_entry(entry: dict, container: Any) -> None:
324374 )
325375
326376
327- def render_result (result : dict ) -> None :
328- """Populate all output slots from a result dict."""
377+ def render_answer_and_metrics (result : dict ) -> None :
378+ """Fill the answer, metrics and export slots from a result dict."""
329379 trace = result .get ("trace" , [])
330380 answer = result .get ("answer" , "" )
331381 elapsed = result .get ("elapsed" , 0.0 )
332382 q = result .get ("query" , "" )
333383
334- # Trace tab
335- trace_empty_slot .empty ()
336- rendered : set = set ()
337- for entry in trace :
338- key = (entry .get ("step" ), entry .get ("tool_name" ) or "think" )
339- if key not in rendered :
340- render_trace_entry (entry , trace_container )
341- rendered .add (key )
342-
343- # Raw JSON tab
344384 if show_raw :
345385 raw_json_slot .json ({"query" : q , "answer" : answer , "trace" : trace })
346386 else :
347387 raw_json_slot .markdown ('<div class="empty-ans">Enable "Show raw trace JSON" in settings.</div>' ,
348388 unsafe_allow_html = True )
349389
350- # Answer
351390 answer_slot .markdown (answer or "*No text response generated.*" )
352391
353- # Metrics
354392 n_think = sum (1 for e in trace if e .get ("thought" ))
355393 n_tools = sum (1 for e in trace if e .get ("tool_name" ))
356394 tool_names = list (dict .fromkeys (e ["tool_name" ] for e in trace if e .get ("tool_name" )))
@@ -363,7 +401,6 @@ def render_result(result: dict) -> None:
363401 if tool_names :
364402 st .caption ("· " .join (f"`{ t } `" for t in tool_names ))
365403
366- # Export
367404 trace_md = "\n \n " .join (
368405 f"**Step { e ['step' ]} ** — "
369406 + (f"🧠 { e .get ('thought_summary' ,'' )} " if e .get ("thought" ) else f"🔧 { e .get ('tool_name' ,'' )} " )
@@ -388,6 +425,18 @@ def render_result(result: dict) -> None:
388425 mime = "application/json" , use_container_width = True ,
389426 )
390427
428+
429+ def render_result (result : dict ) -> None :
430+ """Full re-render (stored / history results)."""
431+ trace_empty_slot .empty ()
432+ rendered : set = set ()
433+ for entry in result .get ("trace" , []):
434+ key = (entry .get ("step" ), entry .get ("tool_name" ) or "think" )
435+ if key not in rendered :
436+ render_trace_entry (entry , trace_container )
437+ rendered .add (key )
438+ render_answer_and_metrics (result )
439+
391440# ── Show stored result or empty states ────────────────────────────────────────
392441
393442if st .session_state .result and not run_btn :
@@ -404,11 +453,12 @@ def render_result(result: dict) -> None:
404453 unsafe_allow_html = True )
405454 metrics_slot .markdown ('<div class="empty-ans">No data yet.</div>' , unsafe_allow_html = True )
406455
407- # ── Run agent ───────────────── ────────────────────────────────────────────────
456+ # ── Run agent (live streaming) ────────────────────────────────────────────────
408457
409458if run_btn and query .strip ():
410- if not os .environ .get ("ANTHROPIC_API_KEY" ):
411- st .error ("Set your Anthropic API key in the sidebar first." )
459+ if not models :
460+ st .error (f"Cannot reach Ollama at { ollama_host } . Start it with `ollama serve` "
461+ f"and make sure a Qwen 3 model is pulled." )
412462 st .stop ()
413463
414464 # Clear previous output
@@ -418,17 +468,23 @@ def render_result(result: dict) -> None:
418468 metrics_slot .empty ()
419469 export_slot .empty ()
420470
421- done_event = threading .Event ()
471+ done_event = threading .Event ()
472+ events : queue .Queue = queue .Queue ()
422473 result_holder : dict = {"answer" : "" , "trace" : [], "error" : None }
423- start_time = time .time ()
474+ start_time = time .time ()
475+
476+ def _on_event (event_type : str , data : Any ) -> None :
477+ events .put ((event_type , data ))
424478
425479 def _run () -> None :
426480 try :
427481 answer , trace = run_thinking_agent (
428482 query = query ,
429483 system_prompt = system_prompt ,
430484 max_iterations = max_iter ,
431- on_event = lambda * _ : None ,
485+ on_event = _on_event ,
486+ model = model ,
487+ ollama_host = ollama_host ,
432488 )
433489 result_holder ["answer" ] = answer
434490 result_holder ["trace" ] = trace
@@ -440,16 +496,34 @@ def _run() -> None:
440496 threading .Thread (target = _run , daemon = True ).start ()
441497
442498 frames = ["⠋" , "⠙" , "⠹" , "⠸" , "⠼" , "⠴" , "⠦" , "⠧" , "⠇" , "⠏" ]
443- frame = 0
444- while not done_event .is_set ():
499+ frame = 0
500+ activity = "warming up"
501+
502+ # Drain events from the agent thread and render them as they arrive.
503+ while not done_event .is_set () or not events .empty ():
504+ while not events .empty ():
505+ event_type , data = events .get_nowait ()
506+ if event_type == "thinking" :
507+ trace_empty_slot .empty ()
508+ render_trace_entry (data , trace_container )
509+ activity = "thinking"
510+ elif event_type == "tool_call" :
511+ activity = f"calling { data .get ('name' , 'tool' )} "
512+ elif event_type == "tool_result" :
513+ trace_empty_slot .empty ()
514+ render_trace_entry (data , trace_container )
515+ elif event_type == "text" :
516+ answer_slot .markdown (data .get ("text" , "" ))
517+ activity = "writing answer"
518+
445519 elapsed = time .time () - start_time
446520 status_slot .markdown (
447521 f"<div style='color:#555;font-size:13px;padding:4px 0'>"
448- f"{ frames [frame % len (frames )]} Running … { elapsed :.0f} s</div>" ,
522+ f"{ frames [frame % len (frames )]} { activity } … { elapsed :.0f} s</div>" ,
449523 unsafe_allow_html = True ,
450524 )
451525 frame += 1
452- time .sleep (0.15 )
526+ time .sleep (0.12 )
453527
454528 status_slot .empty ()
455529 elapsed = time .time () - start_time
@@ -464,9 +538,10 @@ def _run() -> None:
464538 "trace" : result_holder ["trace" ],
465539 "elapsed" : elapsed ,
466540 "timestamp" : datetime .now ().isoformat (),
541+ "model" : model ,
467542 }
468543 st .session_state .result = result
469-
470- render_result (result )
471-
472544 st .session_state .history .append (result )
545+
546+ # Trace already rendered live — just fill in answer, metrics and export.
547+ render_answer_and_metrics (result )
0 commit comments