Skip to content

Commit 1446f29

Browse files
Gh-Novelclaude
andcommitted
feat(ui): live-streaming thinking trace and Ollama model picker
- agent events (thinking, tool_call, tool_result) flow through a thread-safe queue and render in the UI as they happen - sidebar auto-detects installed Ollama models with a connectivity pill - remove Anthropic key handling; only optional Tavily/GitHub keys remain Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0d02eb8 commit 1446f29

1 file changed

Lines changed: 123 additions & 48 deletions

File tree

‎app.py‎

Lines changed: 123 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
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
"""
47
from __future__ import annotations
58

69
import json
710
import os
8-
import sys
9-
import time
11+
import queue
1012
import threading
13+
import time
1114
from datetime import datetime
1215
from typing import Any
1316

1417
import streamlit as st
1518

16-
sys.path.insert(0, os.path.dirname(__file__))
1719
from agent.thinking_agent import run_thinking_agent
1820

1921
# ── Page config ───────────────────────────────────────────────────────────────
@@ -64,6 +66,10 @@
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
@@ -193,6 +199,31 @@
193199
st.session_state.setdefault("result", None)
194200
st.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

198229
with st.sidebar:
@@ -201,50 +232,69 @@
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

393442
if 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

409458
if 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

Comments
 (0)