zakerytclarke commited on
Commit
adbc7fe
·
verified ·
1 Parent(s): cbd2e30

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +120 -262
src/streamlit_app.py CHANGED
@@ -3,7 +3,7 @@ import os
3
  import re
4
  import time
5
  import warnings
6
- from typing import List, Dict, Any, Optional
7
 
8
  import requests
9
  import streamlit as st
@@ -14,14 +14,14 @@ from teapotai import TeapotAI
14
 
15
 
16
  # -----------------------
17
- # Warnings (optional)
18
  # -----------------------
19
  warnings.filterwarnings("ignore", message="pkg_resources is deprecated as an API.*")
20
  warnings.filterwarnings("ignore", message='Field name "schema" in "TeapotTool" shadows.*')
21
 
22
 
23
  # -----------------------
24
- # Brand / Assets
25
  # -----------------------
26
  TEAPOT_LOGO_GIF = "https://teapotai.com/assets/logo.gif"
27
 
@@ -31,13 +31,7 @@ SUGGESTED_QUERIES = [
31
  "What is the weather like in NYC today?",
32
  ]
33
 
34
-
35
- # -----------------------
36
- # Models
37
- # -----------------------
38
- MODEL_TINY = "teapotai/tinyteapot"
39
- MODEL_LLM = "teapotai/teapotllm" # if you keep the toggle elsewhere later
40
- DEFAULT_MODEL = MODEL_TINY
41
 
42
  DEFAULT_SYSTEM_PROMPT = (
43
  "You are Teapot, an open-source AI assistant optimized for low-end devices, "
@@ -51,154 +45,23 @@ DEFAULT_DOCUMENTS = [
51
  """Teapot (Tiny Teapot) is an open-source small language model (~77 million parameters) fine-tuned on synthetic data and optimized to run locally on resource-constrained devices such as smartphones and CPUs. Teapot is trained to only answer using context from documents, reducing hallucinations. Teapot can perform a variety of tasks, including hallucination-resistant Question Answering (QnA), Retrieval-Augmented Generation (RAG), and JSON extraction. TeapotLLM is a fine tune of flan-t5-large that was trained on synthetic data generated by Deepseek v3 TeapotLLM can be hosted on low-power devices with as little as 2GB of CPU RAM such as a Raspberry Pi. Teapot is a model built by and for the community."""
52
  ]
53
 
54
-
55
- # -----------------------
56
- # Web search (Brave) config
57
- # -----------------------
58
  BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
59
  TOP_K = 3
60
  TIMEOUT_SECS = 15
61
 
62
 
63
  # -----------------------
64
- # Streamlit page config
65
  # -----------------------
66
  st.set_page_config(page_title="TeapotAI Chat", page_icon="🫖", layout="centered")
67
 
68
 
69
- # -----------------------
70
- # Theme (light/dark) via CSS variables
71
- # -----------------------
72
- LIGHT_THEME = dict(
73
- bg="#fbf7ef",
74
- panel="#fffaf2",
75
- text="#111827",
76
- muted="#6b7280",
77
- accent="#c0841d",
78
- border="rgba(17, 24, 39, 0.12)",
79
- bubble_user="#eef2ff",
80
- bubble_asst="#ffffff",
81
- code_bg="#0b1220",
82
- )
83
-
84
- DARK_THEME = dict(
85
- bg="#0b0f19",
86
- panel="#0f1626",
87
- text="#e5e7eb",
88
- muted="#9ca3af",
89
- accent="#f59e0b",
90
- border="rgba(229, 231, 235, 0.12)",
91
- bubble_user="#111827",
92
- bubble_asst="#0f172a",
93
- code_bg="#0b1220",
94
- )
95
-
96
-
97
- def inject_css(theme: dict):
98
- css = f"""
99
- <style>
100
- :root {{
101
- --bg: {theme["bg"]};
102
- --panel: {theme["panel"]};
103
- --text: {theme["text"]};
104
- --muted: {theme["muted"]};
105
- --accent: {theme["accent"]};
106
- --border: {theme["border"]};
107
- --bubble_user: {theme["bubble_user"]};
108
- --bubble_asst: {theme["bubble_asst"]};
109
- --code_bg: {theme["code_bg"]};
110
- }}
111
-
112
- .stApp {{
113
- background: var(--bg);
114
- color: var(--text);
115
- }}
116
-
117
- section[data-testid="stSidebar"] {{
118
- background: var(--panel);
119
- border-right: 1px solid var(--border);
120
- }}
121
-
122
- /* Header title */
123
- h1, h2, h3, p, span, label {{
124
- color: var(--text) !important;
125
- }}
126
-
127
- a {{
128
- color: var(--accent) !important;
129
- text-decoration: none;
130
- }}
131
- a:hover {{ text-decoration: underline; }}
132
-
133
- /* Chat message containers */
134
- div[data-testid="stChatMessage"] {{
135
- border-radius: 18px;
136
- padding: 10px 12px;
137
- }}
138
-
139
- /* We color bubbles using a wrapper class we add via markdown */
140
- .bubble-user {{
141
- background: var(--bubble_user);
142
- border: 1px solid var(--border);
143
- border-radius: 16px;
144
- padding: 10px 12px;
145
- }}
146
- .bubble-asst {{
147
- background: var(--bubble_asst);
148
- border: 1px solid var(--border);
149
- border-radius: 16px;
150
- padding: 10px 12px;
151
- }}
152
-
153
- /* Inputs and buttons */
154
- .stTextArea textarea {{
155
- border-radius: 12px !important;
156
- }}
157
- .stButton button {{
158
- border-radius: 999px !important;
159
- border: 1px solid var(--border) !important;
160
- }}
161
-
162
- /* Make popover trigger button look like an icon */
163
- button[kind="secondary"] {{
164
- border-radius: 999px !important;
165
- }}
166
-
167
- /* Code blocks */
168
- code, pre {{
169
- background: var(--code_bg) !important;
170
- }}
171
-
172
- /* Suggested chips */
173
- .suggest-row {{
174
- display: flex;
175
- gap: 10px;
176
- flex-wrap: wrap;
177
- margin-top: 6px;
178
- }}
179
- .suggest-chip {{
180
- display: inline-block;
181
- padding: 10px 12px;
182
- border: 1px solid var(--border);
183
- border-radius: 999px;
184
- background: var(--panel);
185
- color: var(--text);
186
- cursor: pointer;
187
- user-select: none;
188
- }}
189
- .suggest-chip:hover {{
190
- border-color: var(--accent);
191
- }}
192
- </style>
193
- """
194
- st.markdown(css, unsafe_allow_html=True)
195
-
196
-
197
  # -----------------------
198
  # Helpers
199
  # -----------------------
200
  def st_image_full_width(img_url: str):
201
- # Streamlit API varies across HF images
202
  try:
203
  st.image(img_url, use_container_width=True)
204
  except TypeError:
@@ -206,23 +69,22 @@ def st_image_full_width(img_url: str):
206
 
207
 
208
  def get_brave_key() -> Optional[str]:
209
- # HF Spaces secrets are usually env vars; also allow Streamlit secrets
210
  return os.getenv("BRAVE_API_KEY") or (st.secrets.get("BRAVE_API_KEY") if hasattr(st, "secrets") else None)
211
 
212
 
213
  def brave_search_snippets(query: str, top_k: int = 3) -> List[Dict[str, str]]:
214
- brave_api_key = get_brave_key()
215
- if not brave_api_key:
216
- raise RuntimeError("Missing BRAVE_API_KEY (set Space secret / env var).")
217
 
218
- headers = {"Accept": "application/json", "X-Subscription-Token": brave_api_key}
219
  params = {"q": query, "count": top_k}
 
 
 
220
 
221
- resp = requests.get(BRAVE_ENDPOINT, headers=headers, params=params, timeout=TIMEOUT_SECS)
222
- resp.raise_for_status()
223
- data = resp.json()
224
-
225
- results = []
226
  web = data.get("web") or {}
227
  items = web.get("results") or []
228
  for item in items[:top_k]:
@@ -235,7 +97,6 @@ def brave_search_snippets(query: str, top_k: int = 3) -> List[Dict[str, str]]:
235
 
236
 
237
  def format_context_from_results(results: List[Dict[str, str]]) -> str:
238
- # Stable formatting + strip <strong> tags exactly as requested
239
  if not results:
240
  return ""
241
 
@@ -245,97 +106,121 @@ def format_context_from_results(results: List[Dict[str, str]]) -> str:
245
  url = re.sub(r"\s+", " ", r.get("url", "")).strip()
246
  snippet = re.sub(r"\s+", " ", r.get("snippet", "")).strip()
247
 
 
248
  title = title.replace("<strong>", "").replace("</strong>", "")
249
  snippet = snippet.replace("<strong>", "").replace("</strong>", "")
250
 
251
  blocks.append(f"[{i}] {title}\nURL: {url}\nSnippet: {snippet}")
 
252
  return "\n\n".join(blocks)
253
 
254
 
255
- def typewriter_render(text: str, container, speed_chars_per_sec: float = 450.0):
256
  if not text:
257
- container.markdown("")
258
- return
259
- delay = 1.0 / max(speed_chars_per_sec, 1.0)
260
- out = ""
261
- for ch in text:
262
- out += ch
263
- container.markdown(out)
264
- time.sleep(delay)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
 
266
 
267
  # -----------------------
268
- # Model / TeapotAI loader (cached)
269
  # -----------------------
270
  @st.cache_resource
271
- def load_teapot_ai(model_name: str) -> TeapotAI:
272
- tokenizer = AutoTokenizer.from_pretrained(model_name)
273
- model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
274
 
275
  device = "cuda" if torch.cuda.is_available() else "cpu"
276
  model.to(device)
277
  model.eval()
278
 
279
- return TeapotAI(
280
  tokenizer=tokenizer,
281
  model=model,
282
  documents=DEFAULT_DOCUMENTS,
283
  )
 
284
 
285
 
286
  # -----------------------
287
- # Session state init
288
  # -----------------------
289
- if "theme" not in st.session_state:
290
- st.session_state.theme = "Light"
291
  if "messages" not in st.session_state:
292
- # Each message can optionally include sources/context metadata
293
- # {"role": "assistant", "content": "...", "sources": [...], "context": "..."}
294
  st.session_state.messages = []
295
  if "pending_query" not in st.session_state:
296
  st.session_state.pending_query = None
297
 
298
- # Inject theme CSS
299
- inject_css(LIGHT_THEME if st.session_state.theme == "Light" else DARK_THEME)
 
 
 
 
 
 
 
 
300
 
301
 
302
  # -----------------------
303
- # Sidebar (ONLY configurable: theme toggle, system prompt, web search checkbox)
304
  # -----------------------
305
  with st.sidebar:
306
  st.markdown("### Settings")
307
- st.session_state.theme = st.radio("Theme", ["Light", "Dark"], horizontal=True, index=0 if st.session_state.theme == "Light" else 1)
308
-
309
  system_prompt = st.text_area("System prompt", value=DEFAULT_SYSTEM_PROMPT, height=160)
310
  use_web_search = st.checkbox("Use web search", value=True)
311
 
312
- # Re-inject after theme changes (Streamlit reruns)
313
- inject_css(LIGHT_THEME if st.session_state.theme == "Light" else DARK_THEME)
314
-
315
- # -----------------------
316
- # Header
317
- # -----------------------
318
- c1, c2 = st.columns([1, 4], vertical_alignment="center")
319
- with c1:
320
- st_image_full_width(TEAPOT_LOGO_GIF)
321
- with c2:
322
- st.markdown("## TeapotAI Chat")
323
- st.caption("A lightweight, grounded chat experience.")
324
 
325
- # Load tiny model on startup (your earlier requirement) and use it (no model toggle in UI now per your request)
326
- _ = load_teapot_ai(DEFAULT_MODEL)
327
- teapot_ai = load_teapot_ai(DEFAULT_MODEL)
328
 
329
 
330
  # -----------------------
331
- # Suggested searches (shown only when chat is empty)
332
  # -----------------------
333
  if len(st.session_state.messages) == 0 and st.session_state.pending_query is None:
334
- st.markdown("#### Try one of these")
335
  cols = st.columns(3)
336
  for i, q in enumerate(SUGGESTED_QUERIES):
337
  with cols[i]:
338
- if st.button(q, key=f"suggest_{i}"):
339
  st.session_state.pending_query = q
340
  st.rerun()
341
 
@@ -343,64 +228,33 @@ if len(st.session_state.messages) == 0 and st.session_state.pending_query is Non
343
  # -----------------------
344
  # Render chat history
345
  # -----------------------
346
- for idx, m in enumerate(st.session_state.messages):
347
  if m["role"] == "user":
348
  with st.chat_message("user"):
349
- st.markdown(f'<div class="bubble-user">{m["content"]}</div>', unsafe_allow_html=True)
350
  else:
351
  with st.chat_message("assistant"):
352
- st.markdown(f'<div class="bubble-asst">{m["content"]}</div>', unsafe_allow_html=True)
353
-
354
- # Info icon → expands sources/context + links
355
- sources = m.get("sources") or []
356
- context = m.get("context") or ""
357
-
358
- if sources or context:
359
- # Prefer popover if available, else expander
360
- try:
361
- with st.popover("ℹ️", use_container_width=False):
362
- st.markdown("**Sources**")
363
- if sources:
364
- for j, s in enumerate(sources, start=1):
365
- title = s.get("title", "").strip() or f"Result {j}"
366
- url = s.get("url", "").strip()
367
- snippet = s.get("snippet", "").strip()
368
- if url:
369
- st.markdown(f"- [{title}]({url})")
370
- else:
371
- st.markdown(f"- {title}")
372
- if snippet:
373
- st.caption(snippet)
374
- else:
375
- st.caption("(No sources returned.)")
376
-
377
- st.markdown("**Full context**")
378
- if context.strip():
379
- st.code(context)
380
- else:
381
- st.caption("(Empty context.)")
382
- except Exception:
383
- with st.expander("ℹ️ Sources / Context"):
384
- st.markdown("**Sources**")
385
- if sources:
386
- for j, s in enumerate(sources, start=1):
387
- title = s.get("title", "").strip() or f"Result {j}"
388
- url = s.get("url", "").strip()
389
- snippet = s.get("snippet", "").strip()
390
- if url:
391
- st.markdown(f"- [{title}]({url})")
392
- else:
393
- st.markdown(f"- {title}")
394
- if snippet:
395
- st.caption(snippet)
396
- else:
397
- st.caption("(No sources returned.)")
398
-
399
- st.markdown("**Full context**")
400
- if context.strip():
401
- st.code(context)
402
- else:
403
- st.caption("(Empty context.)")
404
 
405
 
406
  # -----------------------
@@ -413,37 +267,41 @@ if st.session_state.pending_query and not user_input:
413
  st.session_state.pending_query = None
414
 
415
  if user_input:
416
- # Add user message
417
  st.session_state.messages.append({"role": "user", "content": user_input})
418
 
419
- # Build context (optional web search)
420
- results: List[Dict[str, str]] = []
421
  context = ""
422
 
423
  if use_web_search:
424
  try:
425
- results = brave_search_snippets(user_input, top_k=TOP_K)
426
- context = format_context_from_results(results)
427
  except Exception:
428
- # If web search fails, keep context empty so system prompt can enforce refusal behavior
429
- results = []
430
  context = ""
431
 
432
- # Query TeapotAI
 
433
  answer = teapot_ai.query(
434
  query=user_input,
435
  context=context,
436
  system_prompt=system_prompt,
437
  )
 
 
 
 
 
438
 
439
- # Append assistant message with metadata for info popover
440
  st.session_state.messages.append(
441
  {
442
  "role": "assistant",
443
  "content": answer,
444
- "sources": results,
445
  "context": context,
 
 
 
446
  }
447
  )
448
-
449
  st.rerun()
 
3
  import re
4
  import time
5
  import warnings
6
+ from typing import List, Dict, Optional
7
 
8
  import requests
9
  import streamlit as st
 
14
 
15
 
16
  # -----------------------
17
+ # Optional: quiet noisy warnings from deps
18
  # -----------------------
19
  warnings.filterwarnings("ignore", message="pkg_resources is deprecated as an API.*")
20
  warnings.filterwarnings("ignore", message='Field name "schema" in "TeapotTool" shadows.*')
21
 
22
 
23
  # -----------------------
24
+ # Config
25
  # -----------------------
26
  TEAPOT_LOGO_GIF = "https://teapotai.com/assets/logo.gif"
27
 
 
31
  "What is the weather like in NYC today?",
32
  ]
33
 
34
+ MODEL_NAME = "teapotai/tinyteapot"
 
 
 
 
 
 
35
 
36
  DEFAULT_SYSTEM_PROMPT = (
37
  "You are Teapot, an open-source AI assistant optimized for low-end devices, "
 
45
  """Teapot (Tiny Teapot) is an open-source small language model (~77 million parameters) fine-tuned on synthetic data and optimized to run locally on resource-constrained devices such as smartphones and CPUs. Teapot is trained to only answer using context from documents, reducing hallucinations. Teapot can perform a variety of tasks, including hallucination-resistant Question Answering (QnA), Retrieval-Augmented Generation (RAG), and JSON extraction. TeapotLLM is a fine tune of flan-t5-large that was trained on synthetic data generated by Deepseek v3 TeapotLLM can be hosted on low-power devices with as little as 2GB of CPU RAM such as a Raspberry Pi. Teapot is a model built by and for the community."""
46
  ]
47
 
48
+ # Brave Search
 
 
 
49
  BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
50
  TOP_K = 3
51
  TIMEOUT_SECS = 15
52
 
53
 
54
  # -----------------------
55
+ # Streamlit setup (no custom theming)
56
  # -----------------------
57
  st.set_page_config(page_title="TeapotAI Chat", page_icon="🫖", layout="centered")
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  # -----------------------
61
  # Helpers
62
  # -----------------------
63
  def st_image_full_width(img_url: str):
64
+ # Streamlit API varies across builds
65
  try:
66
  st.image(img_url, use_container_width=True)
67
  except TypeError:
 
69
 
70
 
71
  def get_brave_key() -> Optional[str]:
72
+ # HF Spaces secrets are commonly env vars; support st.secrets too
73
  return os.getenv("BRAVE_API_KEY") or (st.secrets.get("BRAVE_API_KEY") if hasattr(st, "secrets") else None)
74
 
75
 
76
  def brave_search_snippets(query: str, top_k: int = 3) -> List[Dict[str, str]]:
77
+ key = get_brave_key()
78
+ if not key:
79
+ raise RuntimeError("Missing BRAVE_API_KEY (set as a Space secret / env var).")
80
 
81
+ headers = {"Accept": "application/json", "X-Subscription-Token": key}
82
  params = {"q": query, "count": top_k}
83
+ r = requests.get(BRAVE_ENDPOINT, headers=headers, params=params, timeout=TIMEOUT_SECS)
84
+ r.raise_for_status()
85
+ data = r.json()
86
 
87
+ results: List[Dict[str, str]] = []
 
 
 
 
88
  web = data.get("web") or {}
89
  items = web.get("results") or []
90
  for item in items[:top_k]:
 
97
 
98
 
99
  def format_context_from_results(results: List[Dict[str, str]]) -> str:
 
100
  if not results:
101
  return ""
102
 
 
106
  url = re.sub(r"\s+", " ", r.get("url", "")).strip()
107
  snippet = re.sub(r"\s+", " ", r.get("snippet", "")).strip()
108
 
109
+ # per your requirement: strip <strong> tags
110
  title = title.replace("<strong>", "").replace("</strong>", "")
111
  snippet = snippet.replace("<strong>", "").replace("</strong>", "")
112
 
113
  blocks.append(f"[{i}] {title}\nURL: {url}\nSnippet: {snippet}")
114
+
115
  return "\n\n".join(blocks)
116
 
117
 
118
+ def count_tokens(tokenizer: AutoTokenizer, text: str) -> int:
119
  if not text:
120
+ return 0
121
+ try:
122
+ return len(tokenizer.encode(text))
123
+ except Exception:
124
+ return 0
125
+
126
+
127
+ def render_sources_popover(sources: List[Dict[str, str]], context: str):
128
+ """
129
+ Renders ℹ️ popover if available; otherwise uses expander.
130
+ """
131
+ def _body():
132
+ st.markdown("**Sources**")
133
+ if sources:
134
+ for j, s in enumerate(sources, start=1):
135
+ title = (s.get("title") or "").strip() or f"Result {j}"
136
+ url = (s.get("url") or "").strip()
137
+ snippet = (s.get("snippet") or "").strip()
138
+ if url:
139
+ st.markdown(f"- [{title}]({url})")
140
+ else:
141
+ st.markdown(f"- {title}")
142
+ if snippet:
143
+ st.caption(snippet)
144
+ else:
145
+ st.caption("(No sources returned.)")
146
+
147
+ st.markdown("**Full context**")
148
+ if context.strip():
149
+ st.code(context)
150
+ else:
151
+ st.caption("(Empty context.)")
152
+
153
+ try:
154
+ with st.popover("ℹ️"):
155
+ _body()
156
+ except Exception:
157
+ with st.expander("ℹ️ Sources / Context"):
158
+ _body()
159
 
160
 
161
  # -----------------------
162
+ # Load model + TeapotAI (cached)
163
  # -----------------------
164
  @st.cache_resource
165
+ def load_teapot_ai_and_tokenizer():
166
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
167
+ model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_NAME)
168
 
169
  device = "cuda" if torch.cuda.is_available() else "cpu"
170
  model.to(device)
171
  model.eval()
172
 
173
+ teapot_ai = TeapotAI(
174
  tokenizer=tokenizer,
175
  model=model,
176
  documents=DEFAULT_DOCUMENTS,
177
  )
178
+ return teapot_ai, tokenizer
179
 
180
 
181
  # -----------------------
182
+ # Session state
183
  # -----------------------
 
 
184
  if "messages" not in st.session_state:
185
+ # Each assistant message includes: sources/context + timing/tokens
 
186
  st.session_state.messages = []
187
  if "pending_query" not in st.session_state:
188
  st.session_state.pending_query = None
189
 
190
+
191
+ # -----------------------
192
+ # Header
193
+ # -----------------------
194
+ c1, c2 = st.columns([1, 5], vertical_alignment="center")
195
+ with c1:
196
+ st_image_full_width(TEAPOT_LOGO_GIF)
197
+ with c2:
198
+ st.markdown("## TeapotAI Chat")
199
+ st.caption("Fast, grounded answers — with optional web context.")
200
 
201
 
202
  # -----------------------
203
+ # Sidebar (ONLY: system prompt + web search toggle)
204
  # -----------------------
205
  with st.sidebar:
206
  st.markdown("### Settings")
 
 
207
  system_prompt = st.text_area("System prompt", value=DEFAULT_SYSTEM_PROMPT, height=160)
208
  use_web_search = st.checkbox("Use web search", value=True)
209
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
+ # Load tiny model on startup
212
+ teapot_ai, hf_tokenizer = load_teapot_ai_and_tokenizer()
 
213
 
214
 
215
  # -----------------------
216
+ # Suggested queries on empty chat
217
  # -----------------------
218
  if len(st.session_state.messages) == 0 and st.session_state.pending_query is None:
219
+ st.markdown("#### Suggested")
220
  cols = st.columns(3)
221
  for i, q in enumerate(SUGGESTED_QUERIES):
222
  with cols[i]:
223
+ if st.button(q, key=f"suggest_{i}", use_container_width=True):
224
  st.session_state.pending_query = q
225
  st.rerun()
226
 
 
228
  # -----------------------
229
  # Render chat history
230
  # -----------------------
231
+ for m in st.session_state.messages:
232
  if m["role"] == "user":
233
  with st.chat_message("user"):
234
+ st.markdown(m["content"])
235
  else:
236
  with st.chat_message("assistant"):
237
+ st.markdown(m["content"])
238
+
239
+ # metadata row
240
+ meta_cols = st.columns([1, 3, 3, 5])
241
+ with meta_cols[0]:
242
+ render_sources_popover(m.get("sources", []), m.get("context", ""))
243
+
244
+ # tokens/sec, and token counts
245
+ tps = m.get("tps", None)
246
+ out_toks = m.get("output_tokens", None)
247
+ secs = m.get("seconds", None)
248
+
249
+ with meta_cols[1]:
250
+ if tps is not None:
251
+ st.caption(f" {tps:.1f} tokens/s")
252
+ with meta_cols[2]:
253
+ if out_toks is not None:
254
+ st.caption(f"🧮 {out_toks} output tokens")
255
+ with meta_cols[3]:
256
+ if secs is not None:
257
+ st.caption(f"⏱️ {secs:.2f}s")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
 
259
 
260
  # -----------------------
 
267
  st.session_state.pending_query = None
268
 
269
  if user_input:
 
270
  st.session_state.messages.append({"role": "user", "content": user_input})
271
 
272
+ sources: List[Dict[str, str]] = []
 
273
  context = ""
274
 
275
  if use_web_search:
276
  try:
277
+ sources = brave_search_snippets(user_input, top_k=TOP_K)
278
+ context = format_context_from_results(sources)
279
  except Exception:
280
+ sources = []
 
281
  context = ""
282
 
283
+ # Teapot inference + timing
284
+ t0 = time.perf_counter()
285
  answer = teapot_ai.query(
286
  query=user_input,
287
  context=context,
288
  system_prompt=system_prompt,
289
  )
290
+ t1 = time.perf_counter()
291
+
292
+ elapsed = max(t1 - t0, 1e-6)
293
+ output_tokens = count_tokens(hf_tokenizer, answer)
294
+ tps = output_tokens / elapsed if output_tokens > 0 else 0.0
295
 
 
296
  st.session_state.messages.append(
297
  {
298
  "role": "assistant",
299
  "content": answer,
300
+ "sources": sources,
301
  "context": context,
302
+ "seconds": elapsed,
303
+ "output_tokens": output_tokens,
304
+ "tps": tps,
305
  }
306
  )
 
307
  st.rerun()