Seth Claude Sonnet 5 commited on
Commit
f6dedbf
·
1 Parent(s): 9d44313

Chat Agent: render markdown tables properly, show web-search usage to admins, fix pricing leak

Browse files

- Render GFM pipe tables as real <table> markup (with horizontal scroll) in the
Test Chat panel, Conversations transcript, and the embeddable widget, instead
of dumping raw "| a | b |" text into a narrow chat bubble.
- Track whether a reply actually used the search_the_web tool and surface a
"Used live web search" badge to admins (Test Chat + Conversations), so a
live-web-sourced answer is never mistaken for a knowledge-base answer.
- Fix a real pricing leak: a visitor comparison question ("are you cheaper than
X") caused the agent to recite actual EZOFIS pricing pulled from the tenant's
own indexed content, because the comparison-table instruction pushed the
model to fill every cell from retrieval, overriding the separate "never
disclose pricing" rule. Added a hard, code-level backstop that strips
dollar-amount/discount patterns out of retrieved chunk text before it ever
reaches a prompt, plus reinforced the comparison-table instruction itself.
- Make the Gemini fast-crawl reindex path surface the model's actual response
when it fails to return valid JSON, instead of a generic unhelpful error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

SKILLS-SECURITY.md CHANGED
@@ -4,11 +4,13 @@ The Chat Agent's Skills feature follows Anthropic's Agent Skills anatomy: each s
4
  SKILL.md-format file (real YAML frontmatter) with `name` + `description` + `instructions` (the
5
  "brain," always injected into the agent's system prompt), plus optional `references/` (domain
6
  knowledge), `assets/` (output snippets), `templates/` (reusable output templates), `scripts/`
7
- (mechanical helpers), and `evals/` (test cases). This document covers the four things an admin
8
  authoring skills should understand before relying on them: **the live chat agent never executes
9
  scripts**, **an admin can run scripts explicitly, in a sandboxed subprocess, with real but
10
- limited protection**, **references/assets/templates are visitor-searchable**, and **live web
11
- search is opt-in, off by default, and still genuinely unverified external content**.
 
 
12
 
13
  ## The live chat agent never executes scripts
14
 
@@ -129,6 +131,12 @@ prompt template Gemini receives), and stay true regardless of what a skill's ins
129
  prefixed with that framing before the model ever sees it (`_format_web_search_results`).
130
  - **Never reachable without both code-level gates**, same admin-controlled pattern as every other
131
  opt-in feature in this project (Gemini fast indexing, the Gemini chat provider).
 
 
 
 
 
 
132
 
133
  **What is NOT protected — the residual risk that's genuinely still there, and now wider:**
134
  - **The `query` argument is attacker-influenced, and now open-ended.** It comes from the model,
@@ -148,6 +156,35 @@ prompt template Gemini receives), and stay true regardless of what a skill's ins
148
  leave the toggle off — `search_knowledge_base`-only answers, with "Requires confirmation" for
149
  anything the knowledge base doesn't cover, still work fine without it.
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  ## Summary
152
 
153
  | Skill section | Injected into every reply? | Visitor-searchable via the KB? | Executed? |
 
4
  SKILL.md-format file (real YAML frontmatter) with `name` + `description` + `instructions` (the
5
  "brain," always injected into the agent's system prompt), plus optional `references/` (domain
6
  knowledge), `assets/` (output snippets), `templates/` (reusable output templates), `scripts/`
7
+ (mechanical helpers), and `evals/` (test cases). This document covers the five things an admin
8
  authoring skills should understand before relying on them: **the live chat agent never executes
9
  scripts**, **an admin can run scripts explicitly, in a sandboxed subprocess, with real but
10
+ limited protection**, **references/assets/templates are visitor-searchable**, **live web
11
+ search is opt-in, off by default, and still genuinely unverified external content**, and
12
+ **pricing mentions are stripped out of retrieved knowledge-base content before the model ever
13
+ sees them, as a hard backstop behind the "never disclose pricing" instruction**.
14
 
15
  ## The live chat agent never executes scripts
16
 
 
131
  prefixed with that framing before the model ever sees it (`_format_web_search_results`).
132
  - **Never reachable without both code-level gates**, same admin-controlled pattern as every other
133
  opt-in feature in this project (Gemini fast indexing, the Gemini chat provider).
134
+ - **Every use is visible to admins, per reply.** `ChatAgentMessage.used_web_search` records whether
135
+ a given assistant reply actually invoked the tool, surfaced as a "Used live web search" badge in
136
+ both the Test Chat panel and the Conversations transcript — an admin reviewing a conversation can
137
+ always tell a live-web-sourced answer apart from a knowledge-base answer, rather than having to
138
+ guess or trust the model's own framing. Deliberately not shown to visitors on the public widget —
139
+ it's an admin auditing signal, not visitor-facing UX.
140
 
141
  **What is NOT protected — the residual risk that's genuinely still there, and now wider:**
142
  - **The `query` argument is attacker-influenced, and now open-ended.** It comes from the model,
 
156
  leave the toggle off — `search_knowledge_base`-only answers, with "Requires confirmation" for
157
  anything the knowledge base doesn't cover, still work fine without it.
158
 
159
+ ## Pricing is redacted at retrieval, not just prohibited in the prompt
160
+
161
+ The Non-Negotiable Rules in the default skill's `instructions` say never to disclose EZOFIS's own
162
+ pricing — but a prompt instruction alone isn't a hard guarantee if the tenant's own indexed
163
+ Drive/website content happens to contain real prices (a pricing page got crawled, a proposal
164
+ template lists a rate, etc.). A model asked to fill in a comparison table under pressure can end
165
+ up reciting whatever facts `search_knowledge_base` handed it, instruction or not — this happened
166
+ in practice: a visitor asking "are you cheaper than \<competitor\>" got back a table with real
167
+ per-user-per-month figures pulled straight from indexed content.
168
+
169
+ The fix is a hard, code-level backstop rather than a stronger-worded reminder:
170
+ `chat_agent_retrieval.py`'s `retrieve_relevant_chunks` — the single function both the agentic
171
+ `search_knowledge_base` tool and the Gemini single-shot path call — strips dollar-amount and
172
+ percentage-discount patterns out of chunk text with `_redact_pricing_mentions` *before* that text
173
+ is ever assembled into a prompt. The model never receives the figures, so it has nothing to leak
174
+ regardless of how a question is phrased or which instruction it weighs more heavily. This is
175
+ deliberately broad (any `$<amount>`, not only ones followed by `/user` or `/month`) — the
176
+ Non-Negotiable Rule covers "any subscription price, per-user price, discount, implementation fee,
177
+ or contract value," and an over-redacted non-pricing dollar mention is a far safer failure mode
178
+ than a leaked real price. The always-injected comparison-table instruction was also updated to
179
+ explicitly say a pricing row gets "Not disclosed via chat," not a redaction placeholder repeated
180
+ verbatim, so the reply still reads naturally.
181
+
182
+ **What is NOT protected:** this only redacts content retrieved via `search_knowledge_base` (the
183
+ tenant's own indexed Drive/website/skill content). It cannot redact a price the model might
184
+ otherwise generate on its own without any retrieval backing it, though that's a hallucination risk
185
+ covered by the general "never fabricate" instruction, not a leak of real data. It also does not
186
+ retroactively fix anything already sent in a *past* reply before this change shipped.
187
+
188
  ## Summary
189
 
190
  | Skill section | Injected into every reply? | Visitor-searchable via the KB? | Executed? |
backend/app/chat_agent_chat.py CHANGED
@@ -182,8 +182,11 @@ def build_system_prompt(
182
  "below has no confirmed facts about the other product — mark cells you can't confirm as "
183
  "'Requires confirmation' rather than skipping the comparison entirely or giving only a "
184
  "vague capability summary. Never state a specific claim about the other product unless it "
185
- "came from the context below. End with a recommendation for the single most useful next "
186
- "validation step (e.g. a structured demo using the same use case).",
 
 
 
187
  "- If the knowledge base doesn't cover something, and it's not a comparison request (see "
188
  "the rule above for those), your ENTIRE answer is: say so honestly, in one short sentence, "
189
  "and offer to have the team follow up. Do not add a 'however, based on general "
@@ -284,8 +287,11 @@ def build_agentic_system_prompt(
284
  "have no confirmed facts about the other product — mark cells you can't confirm as "
285
  "'Requires confirmation' rather than skipping the comparison entirely or giving only a "
286
  "vague capability summary. Never state a specific claim about the other product unless it "
287
- "came from an actual search_knowledge_base result. End with a recommendation for the "
288
- "single most useful next validation step (e.g. a structured demo using the same use case).",
 
 
 
289
  "- If the knowledge base still doesn't cover something after searching, and it's not a "
290
  "comparison request (see the rule above for those), your ENTIRE answer is: say so "
291
  "honestly, in one short sentence, and offer to have the team follow up. Do not add a "
@@ -463,6 +469,7 @@ def stream_agentic_reply_openai(
463
  tenant_id: int,
464
  db: Any,
465
  web_search_enabled: bool = False,
 
466
  ) -> Iterator[str]:
467
  """
468
  Agentic RAG: the model decides per-turn whether to call search_knowledge_base, can chain up
@@ -473,6 +480,12 @@ def stream_agentic_reply_openai(
473
 
474
  `web_search_enabled` offers a second tool, search_the_web, ONLY when the tenant has opted in
475
  (ChatAgentConfig.enable_web_search) — see SKILLS-SECURITY.md.
 
 
 
 
 
 
476
  """
477
  from .chat_agent_retrieval import retrieve_relevant_chunks # local import: avoids a cycle
478
  # (chat_agent_retrieval -> chat_agent_ingest -> chat_agent_drive/crawler; none import this module)
@@ -547,6 +560,8 @@ def stream_agentic_reply_openai(
547
  args = {}
548
 
549
  if slot["name"] == "search_the_web" and web_search_enabled:
 
 
550
  web_query = (args.get("query") or "").strip()
551
  try:
552
  from .chat_agent_web_research import search_web_via_gemini
 
182
  "below has no confirmed facts about the other product — mark cells you can't confirm as "
183
  "'Requires confirmation' rather than skipping the comparison entirely or giving only a "
184
  "vague capability summary. Never state a specific claim about the other product unless it "
185
+ "came from the context below. This table is not an exception to the pricing rule below — "
186
+ "if a row would compare pricing, cost, or discounts, write 'Not disclosed via chat' in "
187
+ "the Our approach cell rather than any figure, even one that appears in the context below. "
188
+ "End with a recommendation for the single most useful next validation step (e.g. a "
189
+ "structured demo using the same use case).",
190
  "- If the knowledge base doesn't cover something, and it's not a comparison request (see "
191
  "the rule above for those), your ENTIRE answer is: say so honestly, in one short sentence, "
192
  "and offer to have the team follow up. Do not add a 'however, based on general "
 
287
  "have no confirmed facts about the other product — mark cells you can't confirm as "
288
  "'Requires confirmation' rather than skipping the comparison entirely or giving only a "
289
  "vague capability summary. Never state a specific claim about the other product unless it "
290
+ "came from an actual search_knowledge_base result. This table is not an exception to the "
291
+ "pricing rule below — if a row would compare pricing, cost, or discounts, write 'Not "
292
+ "disclosed via chat' in the Our approach cell rather than any figure, even one that came "
293
+ "back from search_knowledge_base. End with a recommendation for the single most useful "
294
+ "next validation step (e.g. a structured demo using the same use case).",
295
  "- If the knowledge base still doesn't cover something after searching, and it's not a "
296
  "comparison request (see the rule above for those), your ENTIRE answer is: say so "
297
  "honestly, in one short sentence, and offer to have the team follow up. Do not add a "
 
469
  tenant_id: int,
470
  db: Any,
471
  web_search_enabled: bool = False,
472
+ used_web_search: Optional[Dict[str, bool]] = None,
473
  ) -> Iterator[str]:
474
  """
475
  Agentic RAG: the model decides per-turn whether to call search_knowledge_base, can chain up
 
480
 
481
  `web_search_enabled` offers a second tool, search_the_web, ONLY when the tenant has opted in
482
  (ChatAgentConfig.enable_web_search) — see SKILLS-SECURITY.md.
483
+
484
+ `used_web_search`, if given, is a mutable out-param — {"used": False} — flipped to True the
485
+ moment the search_the_web branch is attempted this turn. A generator can't return a value
486
+ alongside its yields, so the caller passes in a dict, reads it back once the generator is
487
+ exhausted, and persists it alongside the saved reply so admins can see which replies drew on
488
+ live web content instead of the knowledge base.
489
  """
490
  from .chat_agent_retrieval import retrieve_relevant_chunks # local import: avoids a cycle
491
  # (chat_agent_retrieval -> chat_agent_ingest -> chat_agent_drive/crawler; none import this module)
 
560
  args = {}
561
 
562
  if slot["name"] == "search_the_web" and web_search_enabled:
563
+ if used_web_search is not None:
564
+ used_web_search["used"] = True
565
  web_query = (args.get("query") or "").strip()
566
  try:
567
  from .chat_agent_web_research import search_web_via_gemini
backend/app/chat_agent_common.py CHANGED
@@ -133,7 +133,7 @@ Escalate when the visitor requests pricing, a quotation, or contract terms; a ca
133
 
134
  ## 14. Live Web Search Guardrails (if enabled)
135
 
136
- When the search_the_web tool is available and the knowledge base doesn't have enough to give a detailed, useful answer, you may use it — but only after first trying search_knowledge_base. Good uses: background on a named competitor or alternative product, general industry or technology context (e.g. what a technical term or standard means), or public information about an integration or partner ecosystem. Never use it to look up or estimate EZOFIS pricing, to find information about EZOFIS's own customers or confidential matters (use the knowledge base for anything about EZOFIS itself), or to look up private or personal information about any individual. Always present results from this tool as unverified public information, not confirmed fact, and note that vendor or source confirmation may be needed.
137
 
138
  ## 15. Final Behaviour Summary
139
 
 
133
 
134
  ## 14. Live Web Search Guardrails (if enabled)
135
 
136
+ Treat search_the_web as a last resort, not a default — reach for it only when it is genuinely required, meaning search_knowledge_base has already been tried for this question and did not return enough to give a detailed, useful answer. Do not use it merely because a question sounds like it needs current information, and do not use it on every turn of a conversation just because it is available. Good uses, once that bar is met: background on a named competitor or alternative product, general industry or technology context (e.g. what a technical term or standard means), or public information about an integration or partner ecosystem. Never use it to look up or estimate EZOFIS pricing, to find information about EZOFIS's own customers or confidential matters (use the knowledge base for anything about EZOFIS itself), or to look up private or personal information about any individual. Always present results from this tool as unverified public information, not confirmed fact, and note that vendor or source confirmation may be needed.
137
 
138
  ## 15. Final Behaviour Summary
139
 
backend/app/chat_agent_gemini_crawler.py CHANGED
@@ -11,7 +11,7 @@ from __future__ import annotations
11
  import json
12
  import logging
13
  import os
14
- from typing import Any, Dict, List
15
  from urllib.parse import urlparse
16
 
17
  from .chat_agent_crawler import _discover_sitemap_urls
@@ -19,7 +19,10 @@ from .chat_agent_crawler import _discover_sitemap_urls
19
  logger = logging.getLogger(__name__)
20
 
21
  DEFAULT_MAX_PAGES = 40
22
- BATCH_SIZE = 8
 
 
 
23
  GEMINI_MODEL = os.getenv("CHAT_AGENT_GEMINI_MODEL") or "gemini-2.5-flash"
24
 
25
 
@@ -55,10 +58,33 @@ def _discover_urls(base_url: str, max_pages: int) -> List[str]:
55
  return urls[:max_pages]
56
 
57
 
58
- def _fetch_batch(client: Any, urls: List[str]) -> List[Dict[str, str]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  """Fetch a batch of URLs via Gemini's url_context tool in one call, asking for a JSON array of
60
- {url, title, markdown} back. Returns [] (not a raised exception) on malformed model output —
61
- the caller just gets fewer pages from that batch, not a broken reindex."""
 
 
62
  from google.genai import types
63
 
64
  urls_block = "\n".join(f"- {u}" for u in urls)
@@ -83,13 +109,12 @@ def _fetch_batch(client: Any, urls: List[str]) -> List[Dict[str, str]]:
83
  if text.lower().startswith("json"):
84
  text = text[4:]
85
  text = text.strip()
86
- try:
87
- data = json.loads(text)
88
- except (json.JSONDecodeError, ValueError):
89
- logger.warning("Gemini fast-crawl batch returned non-JSON output (%d chars); skipping batch", len(text))
90
- return []
91
- if not isinstance(data, list):
92
- return []
93
 
94
  out: List[Dict[str, str]] = []
95
  for item in data:
@@ -101,7 +126,11 @@ def _fetch_batch(client: Any, urls: List[str]) -> List[Dict[str, str]]:
101
  continue
102
  title = (item.get("title") or url).strip()
103
  out.append({"url": url, "title": title, "markdown": markdown})
104
- return out
 
 
 
 
105
 
106
 
107
  def fast_crawl_website_via_gemini(base_url: str, max_pages: int = DEFAULT_MAX_PAGES) -> List[Dict[str, str]]:
@@ -117,18 +146,24 @@ def fast_crawl_website_via_gemini(base_url: str, max_pages: int = DEFAULT_MAX_PA
117
  raise RuntimeError(f"No pages discovered for '{base_url}'")
118
 
119
  results: List[Dict[str, str]] = []
 
120
  for i in range(0, len(urls), BATCH_SIZE):
121
  batch = urls[i : i + BATCH_SIZE]
122
  try:
123
- results.extend(_fetch_batch(client, batch))
 
 
 
124
  except Exception as e:
125
  logger.warning("Gemini fast-crawl batch failed for %s: %s", batch, e)
 
126
  if len(results) >= max_pages:
127
  break
128
 
129
  if not results:
 
130
  raise RuntimeError(
131
  f"Gemini could not fetch any usable content from {base_url}. Try the standard reindex "
132
- "instead, or check that the site is publicly reachable."
133
  )
134
  return results[:max_pages]
 
11
  import json
12
  import logging
13
  import os
14
+ from typing import Any, Dict, List, Optional, Tuple
15
  from urllib.parse import urlparse
16
 
17
  from .chat_agent_crawler import _discover_sitemap_urls
 
19
  logger = logging.getLogger(__name__)
20
 
21
  DEFAULT_MAX_PAGES = 40
22
+ # Smaller batches ask the model to fetch/format less content per call, which in practice makes it
23
+ # much more likely to return strict JSON instead of drifting into prose (e.g. an explanation of
24
+ # why it couldn't format something) partway through a large batch.
25
+ BATCH_SIZE = 4
26
  GEMINI_MODEL = os.getenv("CHAT_AGENT_GEMINI_MODEL") or "gemini-2.5-flash"
27
 
28
 
 
58
  return urls[:max_pages]
59
 
60
 
61
+ def _extract_json_array(text: str) -> Optional[list]:
62
+ """Parse a JSON array out of model output that's supposed to be JSON-only. Tries a direct
63
+ parse first, then falls back to pulling out the outermost [...] substring — the model
64
+ sometimes wraps the array in a sentence ("Here's the JSON: [...]") despite being told not
65
+ to. Returns None if neither approach yields a JSON array."""
66
+ try:
67
+ data = json.loads(text)
68
+ return data if isinstance(data, list) else None
69
+ except (json.JSONDecodeError, ValueError):
70
+ pass
71
+ start = text.find("[")
72
+ end = text.rfind("]")
73
+ if start == -1 or end == -1 or end <= start:
74
+ return None
75
+ try:
76
+ data = json.loads(text[start : end + 1])
77
+ return data if isinstance(data, list) else None
78
+ except (json.JSONDecodeError, ValueError):
79
+ return None
80
+
81
+
82
+ def _fetch_batch(client: Any, urls: List[str]) -> Tuple[List[Dict[str, str]], Optional[str]]:
83
  """Fetch a batch of URLs via Gemini's url_context tool in one call, asking for a JSON array of
84
+ {url, title, markdown} back. Returns ([], error_snippet) — not a raised exception — on
85
+ malformed model output, so the caller just gets fewer pages from that batch, not a broken
86
+ reindex; error_snippet is a truncated view of what the model actually returned, so a total
87
+ failure can be diagnosed from the index log detail instead of guessing blind."""
88
  from google.genai import types
89
 
90
  urls_block = "\n".join(f"- {u}" for u in urls)
 
109
  if text.lower().startswith("json"):
110
  text = text[4:]
111
  text = text.strip()
112
+
113
+ data = _extract_json_array(text)
114
+ if data is None:
115
+ snippet = text[:300]
116
+ logger.warning("Gemini fast-crawl batch returned non-JSON output (%d chars): %r", len(text), snippet)
117
+ return [], (snippet or "(empty response)")
 
118
 
119
  out: List[Dict[str, str]] = []
120
  for item in data:
 
126
  continue
127
  title = (item.get("title") or url).strip()
128
  out.append({"url": url, "title": title, "markdown": markdown})
129
+ if not out:
130
+ # Valid JSON array, but every entry was skipped (missing url/markdown) — the model
131
+ # likely reported it couldn't fetch anything in this batch. Still worth surfacing why.
132
+ return [], f"model returned {len(data)} item(s) but none had usable url+markdown"
133
+ return out, None
134
 
135
 
136
  def fast_crawl_website_via_gemini(base_url: str, max_pages: int = DEFAULT_MAX_PAGES) -> List[Dict[str, str]]:
 
146
  raise RuntimeError(f"No pages discovered for '{base_url}'")
147
 
148
  results: List[Dict[str, str]] = []
149
+ last_error: Optional[str] = None
150
  for i in range(0, len(urls), BATCH_SIZE):
151
  batch = urls[i : i + BATCH_SIZE]
152
  try:
153
+ batch_results, batch_error = _fetch_batch(client, batch)
154
+ results.extend(batch_results)
155
+ if batch_error:
156
+ last_error = batch_error
157
  except Exception as e:
158
  logger.warning("Gemini fast-crawl batch failed for %s: %s", batch, e)
159
+ last_error = str(e)
160
  if len(results) >= max_pages:
161
  break
162
 
163
  if not results:
164
+ detail = f" Gemini's response: {last_error!r}" if last_error else ""
165
  raise RuntimeError(
166
  f"Gemini could not fetch any usable content from {base_url}. Try the standard reindex "
167
+ f"instead, or check that the site is publicly reachable.{detail}"
168
  )
169
  return results[:max_pages]
backend/app/chat_agent_retrieval.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  from typing import Any, Dict, List, Optional
6
 
7
  import numpy as np
@@ -10,6 +11,29 @@ from sqlalchemy.orm import Session
10
  from .chat_agent_ingest import embed_texts
11
  from .database import ChatAgentChunk, ChatAgentDocument
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  def retrieve_relevant_chunks(
15
  db: Session,
@@ -54,7 +78,7 @@ def retrieve_relevant_chunks(
54
  chunk, doc = valid_rows[int(idx)]
55
  out.append(
56
  {
57
- "text": chunk.text,
58
  "title": doc.title,
59
  "source_type": doc.source_type,
60
  "source_ref": doc.source_ref,
 
2
 
3
  from __future__ import annotations
4
 
5
+ import re
6
  from typing import Any, Dict, List, Optional
7
 
8
  import numpy as np
 
11
  from .chat_agent_ingest import embed_texts
12
  from .database import ChatAgentChunk, ChatAgentDocument
13
 
14
+ # Hard, code-level backstop for the "never disclose pricing" rule — a prompt instruction alone
15
+ # isn't enough if the tenant's own indexed Drive/website content happens to contain a real price
16
+ # (e.g. a pricing page got crawled), because a model asked to fill in a comparison table under
17
+ # pressure can end up reciting whatever facts retrieval handed it, instruction or not. This strips
18
+ # dollar-amount and percentage-discount patterns out of retrieved chunk text BEFORE it ever reaches
19
+ # a prompt, so the model has nothing to leak regardless of what the system prompt says. Deliberately
20
+ # broad/aggressive (any "$<amount>", not just ones followed by "/user" or "/month") — the
21
+ # Non-Negotiable Rules cover "any subscription price, per-user price, discount, implementation fee,
22
+ # or contract value," and an over-redacted non-pricing dollar mention is a far safer failure mode
23
+ # than a leaked real price.
24
+ _PRICE_REDACTION_RE = re.compile(
25
+ r"\$\s?\d[\d,]*(?:\.\d{1,2})?(?:\s*(?:/|\bper\b)\s*\w+)?"
26
+ r"|\b\d{1,3}\s?%\s*(?:discount|off)\b"
27
+ r"|\bdiscount(?:s|ed)?\s*of\s*\d{1,3}\s?%",
28
+ re.IGNORECASE,
29
+ )
30
+
31
+
32
+ def _redact_pricing_mentions(text: str) -> str:
33
+ if not text:
34
+ return text
35
+ return _PRICE_REDACTION_RE.sub("[pricing — not disclosed via chat]", text)
36
+
37
 
38
  def retrieve_relevant_chunks(
39
  db: Session,
 
78
  chunk, doc = valid_rows[int(idx)]
79
  out.append(
80
  {
81
+ "text": _redact_pricing_mentions(chunk.text),
82
  "title": doc.title,
83
  "source_type": doc.source_type,
84
  "source_ref": doc.source_ref,
backend/app/chat_agent_routes.py CHANGED
@@ -582,7 +582,12 @@ def get_conversation(conversation_id: int, tc: TenantContext = Depends(get_tenan
582
  return {
583
  **_conversation_to_dict(row),
584
  "messages": [
585
- {"role": m.role, "content": m.content, "created_at": m.created_at.isoformat() if m.created_at else None}
 
 
 
 
 
586
  for m in msgs
587
  ],
588
  }
@@ -768,6 +773,11 @@ async def public_send_message(public_id: str, body: ChatAgentMessageRequest, db:
768
  # happened (either via this exact instruction last time, or via the client-side greeting), so
769
  # drop the instruction from the prompt entirely rather than trusting the model to self-limit.
770
  already_asked_for_contact = any(h["role"] == "assistant" for h in history)
 
 
 
 
 
771
 
772
  if provider == "gemini":
773
  # Secondary/opt-in provider: kept on the original single-shot RAG path (retrieve once,
@@ -799,7 +809,12 @@ async def public_send_message(public_id: str, body: ChatAgentMessageRequest, db:
799
 
800
  def make_reply_iter():
801
  return stream_agentic_reply_openai(
802
- system_prompt, history, tenant_id, db, web_search_enabled=web_search_enabled
 
 
 
 
 
803
  )
804
 
805
  async def event_generator():
@@ -835,13 +850,22 @@ async def public_send_message(public_id: str, body: ChatAgentMessageRequest, db:
835
 
836
  full_text = "".join(full_text_parts).strip()
837
  if full_text:
838
- db.add(ChatAgentMessage(conversation_id=convo.id, role="assistant", content=full_text))
 
 
 
 
 
 
 
839
  convo.last_message_at = datetime.utcnow()
840
  db_commit_with_retry(db)
841
  # Capture/update the lead whenever we have an email, independent of whether the AI
842
  # reply itself succeeded — a dropped LLM call shouldn't lose a volunteered email.
843
  if convo.visitor_email:
844
  _capture_or_update_lead(db, cfg, convo)
 
 
845
  yield "data: [DONE]\n\n"
846
 
847
  return StreamingResponse(event_generator(), media_type="text/event-stream", headers=_CORS_HEADERS)
@@ -866,6 +890,10 @@ _WIDGET_JS_TEMPLATE = r"""(function () {
866
  '.ezofis-chat-msg{max-width:82%;padding:9px 12px;border-radius:12px;font-size:13.5px;line-height:1.45;white-space:pre-wrap;word-wrap:break-word}' +
867
  '.ezofis-chat-msg.assistant{background:#fff;border:1px solid #e2e8f0;color:#1e293b;align-self:flex-start;border-bottom-left-radius:2px}' +
868
  '.ezofis-chat-msg.user{background:#7c3aed;color:#fff;align-self:flex-end;border-bottom-right-radius:2px}' +
 
 
 
 
869
  '.ezofis-chat-disclaimer{padding:6px 14px;font-size:11px;line-height:1.4;color:#94a3b8;background:#f8fafc;border-top:1px solid #eef2f7;text-align:center}' +
870
  '.ezofis-chat-footer{padding:10px;border-top:1px solid #e2e8f0;display:flex;gap:8px;background:#fff}' +
871
  '.ezofis-chat-footer textarea{flex:1;resize:none;border:1px solid #cbd5e1;border-radius:10px;padding:9px 10px;font-size:13.5px;font-family:inherit;max-height:80px;min-height:38px}' +
@@ -905,16 +933,16 @@ _WIDGET_JS_TEMPLATE = r"""(function () {
905
  return el;
906
  }
907
 
908
- // Renders "Sources: [Title](https://...)" (and any inline markdown links) as real,
909
- // safe <a> elements — text is inserted via createTextNode/textContent only, and only
910
- // http(s) URLs matched by the regex itself ever reach an href, so no HTML/script injection.
911
- function renderRichText(el, text) {
912
- el.textContent = '';
913
  var linkRe = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
914
  var lastIndex = 0;
915
  var m;
916
  while ((m = linkRe.exec(text)) !== null) {
917
- if (m.index > lastIndex) el.appendChild(document.createTextNode(text.slice(lastIndex, m.index)));
918
  var a = document.createElement('a');
919
  a.href = m[2];
920
  a.textContent = m[1];
@@ -922,10 +950,92 @@ _WIDGET_JS_TEMPLATE = r"""(function () {
922
  a.rel = 'noopener noreferrer';
923
  a.style.color = 'inherit';
924
  a.style.textDecoration = 'underline';
925
- el.appendChild(a);
926
  lastIndex = linkRe.lastIndex;
927
  }
928
- if (lastIndex < text.length) el.appendChild(document.createTextNode(text.slice(lastIndex)));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
929
  }
930
 
931
  function ensureSession() {
@@ -979,8 +1089,8 @@ _WIDGET_JS_TEMPLATE = r"""(function () {
979
  if (payload === '[DONE]') return;
980
  try {
981
  var obj = JSON.parse(payload);
982
- if (obj.delta) { acc += obj.delta; renderRichText(replyEl, acc); body.scrollTop = body.scrollHeight; }
983
- if (obj.error) { renderRichText(replyEl, acc || 'Sorry, something went wrong.'); }
984
  } catch (e) {}
985
  });
986
  return pump();
 
582
  return {
583
  **_conversation_to_dict(row),
584
  "messages": [
585
+ {
586
+ "role": m.role,
587
+ "content": m.content,
588
+ "used_web_search": bool(m.used_web_search),
589
+ "created_at": m.created_at.isoformat() if m.created_at else None,
590
+ }
591
  for m in msgs
592
  ],
593
  }
 
773
  # happened (either via this exact instruction last time, or via the client-side greeting), so
774
  # drop the instruction from the prompt entirely rather than trusting the model to self-limit.
775
  already_asked_for_contact = any(h["role"] == "assistant" for h in history)
776
+ # Mutable out-param the agentic loop flips to True if it actually invokes search_the_web this
777
+ # turn — read back after streaming finishes so the saved message (and the live SSE stream) can
778
+ # tell admins whether this specific reply drew on live web content. Always False on the Gemini
779
+ # provider path below, which doesn't offer this tool.
780
+ used_web_search_flag: Dict[str, bool] = {"used": False}
781
 
782
  if provider == "gemini":
783
  # Secondary/opt-in provider: kept on the original single-shot RAG path (retrieve once,
 
809
 
810
  def make_reply_iter():
811
  return stream_agentic_reply_openai(
812
+ system_prompt,
813
+ history,
814
+ tenant_id,
815
+ db,
816
+ web_search_enabled=web_search_enabled,
817
+ used_web_search=used_web_search_flag,
818
  )
819
 
820
  async def event_generator():
 
850
 
851
  full_text = "".join(full_text_parts).strip()
852
  if full_text:
853
+ db.add(
854
+ ChatAgentMessage(
855
+ conversation_id=convo.id,
856
+ role="assistant",
857
+ content=full_text,
858
+ used_web_search=1 if used_web_search_flag["used"] else 0,
859
+ )
860
+ )
861
  convo.last_message_at = datetime.utcnow()
862
  db_commit_with_retry(db)
863
  # Capture/update the lead whenever we have an email, independent of whether the AI
864
  # reply itself succeeded — a dropped LLM call shouldn't lose a volunteered email.
865
  if convo.visitor_email:
866
  _capture_or_update_lead(db, cfg, convo)
867
+ if used_web_search_flag["used"]:
868
+ yield f"data: {json.dumps({'meta': {'used_web_search': True}})}\n\n"
869
  yield "data: [DONE]\n\n"
870
 
871
  return StreamingResponse(event_generator(), media_type="text/event-stream", headers=_CORS_HEADERS)
 
890
  '.ezofis-chat-msg{max-width:82%;padding:9px 12px;border-radius:12px;font-size:13.5px;line-height:1.45;white-space:pre-wrap;word-wrap:break-word}' +
891
  '.ezofis-chat-msg.assistant{background:#fff;border:1px solid #e2e8f0;color:#1e293b;align-self:flex-start;border-bottom-left-radius:2px}' +
892
  '.ezofis-chat-msg.user{background:#7c3aed;color:#fff;align-self:flex-end;border-bottom-right-radius:2px}' +
893
+ '.ezofis-chat-table-wrap{overflow-x:auto;margin:4px 0}' +
894
+ '.ezofis-chat-table{border-collapse:collapse;font-size:12px;white-space:normal}' +
895
+ '.ezofis-chat-table th,.ezofis-chat-table td{border:1px solid #e2e8f0;padding:4px 6px;text-align:left;vertical-align:top;white-space:nowrap}' +
896
+ '.ezofis-chat-table th{background:#f1f5f9;font-weight:600}' +
897
  '.ezofis-chat-disclaimer{padding:6px 14px;font-size:11px;line-height:1.4;color:#94a3b8;background:#f8fafc;border-top:1px solid #eef2f7;text-align:center}' +
898
  '.ezofis-chat-footer{padding:10px;border-top:1px solid #e2e8f0;display:flex;gap:8px;background:#fff}' +
899
  '.ezofis-chat-footer textarea{flex:1;resize:none;border:1px solid #cbd5e1;border-radius:10px;padding:9px 10px;font-size:13.5px;font-family:inherit;max-height:80px;min-height:38px}' +
 
933
  return el;
934
  }
935
 
936
+ // Renders "Sources: [Title](https://...)" (and any inline markdown links) as real, safe <a>
937
+ // elements into the given container — text is inserted via createTextNode/textContent only,
938
+ // and only http(s) URLs matched by the regex itself ever reach an href, so no HTML/script
939
+ // injection is possible from model output.
940
+ function appendInlineLinks(container, text) {
941
  var linkRe = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
942
  var lastIndex = 0;
943
  var m;
944
  while ((m = linkRe.exec(text)) !== null) {
945
+ if (m.index > lastIndex) container.appendChild(document.createTextNode(text.slice(lastIndex, m.index)));
946
  var a = document.createElement('a');
947
  a.href = m[2];
948
  a.textContent = m[1];
 
950
  a.rel = 'noopener noreferrer';
951
  a.style.color = 'inherit';
952
  a.style.textDecoration = 'underline';
953
+ container.appendChild(a);
954
  lastIndex = linkRe.lastIndex;
955
  }
956
+ if (lastIndex < text.length) container.appendChild(document.createTextNode(text.slice(lastIndex)));
957
+ }
958
+
959
+ // A GFM pipe-table row: trimmed, starts and ends with "|" — the exact shape the agent is
960
+ // instructed to use for comparison tables.
961
+ function isTableRowLine(line) {
962
+ var t = line.trim();
963
+ return t.length >= 2 && t.charAt(0) === '|' && t.charAt(t.length - 1) === '|';
964
+ }
965
+
966
+ function isTableSeparatorLine(line) {
967
+ if (!isTableRowLine(line)) return false;
968
+ var t = line.trim();
969
+ var cells = t.slice(1, -1).split('|');
970
+ for (var i = 0; i < cells.length; i++) {
971
+ if (!/^:?-+:?$/.test(cells[i].trim())) return false;
972
+ }
973
+ return cells.length > 0;
974
+ }
975
+
976
+ function splitTableRow(line) {
977
+ var t = line.trim();
978
+ return t.slice(1, -1).split('|').map(function (c) { return c.trim(); });
979
+ }
980
+
981
+ // Splits message text into text/table blocks so a markdown pipe table renders as a real
982
+ // <table> instead of raw "| a | b |" text wrapped mid-word inside a narrow chat bubble.
983
+ function renderRichContent(el, text) {
984
+ el.textContent = '';
985
+ var lines = text.split('\n');
986
+ var textBuf = [];
987
+ function flushText() {
988
+ if (textBuf.length) {
989
+ appendInlineLinks(el, textBuf.join('\n'));
990
+ textBuf = [];
991
+ }
992
+ }
993
+ var i = 0;
994
+ while (i < lines.length) {
995
+ var line = lines[i];
996
+ var nextLine = i + 1 < lines.length ? lines[i + 1] : '';
997
+ if (isTableRowLine(line) && isTableSeparatorLine(nextLine)) {
998
+ flushText();
999
+ var header = splitTableRow(line);
1000
+ var j = i + 2;
1001
+ var rows = [];
1002
+ while (j < lines.length && isTableRowLine(lines[j])) {
1003
+ rows.push(splitTableRow(lines[j]));
1004
+ j++;
1005
+ }
1006
+ var wrap = document.createElement('div');
1007
+ wrap.className = 'ezofis-chat-table-wrap';
1008
+ var table = document.createElement('table');
1009
+ table.className = 'ezofis-chat-table';
1010
+ var thead = document.createElement('thead');
1011
+ var headTr = document.createElement('tr');
1012
+ header.forEach(function (cell) {
1013
+ var th = document.createElement('th');
1014
+ appendInlineLinks(th, cell);
1015
+ headTr.appendChild(th);
1016
+ });
1017
+ thead.appendChild(headTr);
1018
+ table.appendChild(thead);
1019
+ var tbody = document.createElement('tbody');
1020
+ rows.forEach(function (row) {
1021
+ var tr = document.createElement('tr');
1022
+ row.forEach(function (cell) {
1023
+ var td = document.createElement('td');
1024
+ appendInlineLinks(td, cell);
1025
+ tr.appendChild(td);
1026
+ });
1027
+ tbody.appendChild(tr);
1028
+ });
1029
+ table.appendChild(tbody);
1030
+ wrap.appendChild(table);
1031
+ el.appendChild(wrap);
1032
+ i = j;
1033
+ continue;
1034
+ }
1035
+ textBuf.push(line);
1036
+ i++;
1037
+ }
1038
+ flushText();
1039
  }
1040
 
1041
  function ensureSession() {
 
1089
  if (payload === '[DONE]') return;
1090
  try {
1091
  var obj = JSON.parse(payload);
1092
+ if (obj.delta) { acc += obj.delta; renderRichContent(replyEl, acc); body.scrollTop = body.scrollHeight; }
1093
+ if (obj.error) { renderRichContent(replyEl, acc || 'Sorry, something went wrong.'); }
1094
  } catch (e) {}
1095
  });
1096
  return pump();
backend/app/database.py CHANGED
@@ -538,6 +538,10 @@ class ChatAgentMessage(Base):
538
  conversation_id = Column(Integer, ForeignKey("chat_agent_conversations.id"), nullable=False, index=True)
539
  role = Column(String, nullable=False) # user | assistant
540
  content = Column(Text, default="")
 
 
 
 
541
  created_at = Column(DateTime, default=datetime.utcnow)
542
 
543
 
@@ -850,6 +854,13 @@ def run_migrations(connection_engine):
850
  text("ALTER TABLE chat_agent_configs ADD COLUMN enable_web_search INTEGER DEFAULT 0")
851
  )
852
 
 
 
 
 
 
 
 
853
 
854
  # Create tables then migrate legacy SQLite schemas
855
  Base.metadata.create_all(bind=engine)
 
538
  conversation_id = Column(Integer, ForeignKey("chat_agent_conversations.id"), nullable=False, index=True)
539
  role = Column(String, nullable=False) # user | assistant
540
  content = Column(Text, default="")
541
+ # True when this reply used the search_the_web tool — surfaced to admins in the Test Chat
542
+ # and Conversations views so they can tell live-web-sourced answers apart from knowledge-base
543
+ # answers at a glance.
544
+ used_web_search = Column(Integer, default=0)
545
  created_at = Column(DateTime, default=datetime.utcnow)
546
 
547
 
 
854
  text("ALTER TABLE chat_agent_configs ADD COLUMN enable_web_search INTEGER DEFAULT 0")
855
  )
856
 
857
+ if insp.has_table("chat_agent_messages"):
858
+ msgcols = [c["name"] for c in insp.get_columns("chat_agent_messages")]
859
+ if "used_web_search" not in msgcols:
860
+ conn.execute(
861
+ text("ALTER TABLE chat_agent_messages ADD COLUMN used_web_search INTEGER DEFAULT 0")
862
+ )
863
+
864
 
865
  # Create tables then migrate legacy SQLite schemas
866
  Base.metadata.create_all(bind=engine)
frontend/src/components/settings/ChatAgentPanel.jsx CHANGED
@@ -4,6 +4,7 @@ import {
4
  Bot,
5
  ClipboardCopy,
6
  ExternalLink,
 
7
  Loader2,
8
  Mail,
9
  MessageSquare,
@@ -72,7 +73,7 @@ function CopyBlock({ label, code }) {
72
 
73
  // Renders "[Title](https://...)" links safely — React escapes plain text nodes by default,
74
  // and only http(s) URLs matched by the regex itself ever become an href.
75
- function TestChatRichText({ text }) {
76
  const linkRe = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
77
  const parts = [];
78
  let lastIndex = 0;
@@ -80,11 +81,13 @@ function TestChatRichText({ text }) {
80
  let key = 0;
81
  while ((match = linkRe.exec(text)) !== null) {
82
  if (match.index > lastIndex) {
83
- parts.push(<React.Fragment key={key++}>{text.slice(lastIndex, match.index)}</React.Fragment>);
 
 
84
  }
85
  parts.push(
86
  <a
87
- key={key++}
88
  href={match[2]}
89
  target="_blank"
90
  rel="noopener noreferrer"
@@ -96,9 +99,108 @@ function TestChatRichText({ text }) {
96
  lastIndex = linkRe.lastIndex;
97
  }
98
  if (lastIndex < text.length) {
99
- parts.push(<React.Fragment key={key++}>{text.slice(lastIndex)}</React.Fragment>);
100
  }
101
- return <>{parts}</>;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  }
103
 
104
  /** Talks to the SAME public session/message endpoints the embedded widget uses — a true
@@ -213,7 +315,7 @@ function TestChatPanel({ publicId, enabled }) {
213
  const snapshot = acc;
214
  setMessages((prev) => {
215
  const next = [...prev];
216
- next[next.length - 1] = { role: 'assistant', content: snapshot };
217
  return next;
218
  });
219
  }
@@ -221,7 +323,14 @@ function TestChatPanel({ publicId, enabled }) {
221
  const fallback = acc || 'Sorry, something went wrong.';
222
  setMessages((prev) => {
223
  const next = [...prev];
224
- next[next.length - 1] = { role: 'assistant', content: fallback };
 
 
 
 
 
 
 
225
  return next;
226
  });
227
  }
@@ -289,7 +398,13 @@ function TestChatPanel({ publicId, enabled }) {
289
  : 'bg-white border border-slate-200 text-slate-800'
290
  )}
291
  >
292
- <TestChatRichText text={m.content} />
 
 
 
 
 
 
293
  </div>
294
  ))
295
  )}
@@ -899,7 +1014,13 @@ export default function ChatAgentPanel({ onBack }) {
899
  : 'bg-slate-100 text-slate-800'
900
  )}
901
  >
902
- {m.content}
 
 
 
 
 
 
903
  </div>
904
  ))}
905
  </div>
 
4
  Bot,
5
  ClipboardCopy,
6
  ExternalLink,
7
+ Globe,
8
  Loader2,
9
  Mail,
10
  MessageSquare,
 
73
 
74
  // Renders "[Title](https://...)" links safely — React escapes plain text nodes by default,
75
  // and only http(s) URLs matched by the regex itself ever become an href.
76
+ function renderInlineLinks(text, keyPrefix) {
77
  const linkRe = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
78
  const parts = [];
79
  let lastIndex = 0;
 
81
  let key = 0;
82
  while ((match = linkRe.exec(text)) !== null) {
83
  if (match.index > lastIndex) {
84
+ parts.push(
85
+ <React.Fragment key={`${keyPrefix}-${key++}`}>{text.slice(lastIndex, match.index)}</React.Fragment>
86
+ );
87
  }
88
  parts.push(
89
  <a
90
+ key={`${keyPrefix}-${key++}`}
91
  href={match[2]}
92
  target="_blank"
93
  rel="noopener noreferrer"
 
99
  lastIndex = linkRe.lastIndex;
100
  }
101
  if (lastIndex < text.length) {
102
+ parts.push(<React.Fragment key={`${keyPrefix}-${key++}`}>{text.slice(lastIndex)}</React.Fragment>);
103
  }
104
+ return parts;
105
+ }
106
+
107
+ // A GFM pipe-table row: trimmed, starts and ends with "|" — matches the exact shape the model is
108
+ // instructed to produce for comparison tables (see chat_agent_chat.py's GROUNDING RULES).
109
+ function isTableRowLine(line) {
110
+ const t = line.trim();
111
+ return t.length >= 2 && t.startsWith('|') && t.endsWith('|');
112
+ }
113
+
114
+ // The "|---|:---:|---|" alignment row that follows a table header — every cell must be only
115
+ // dashes/colons, and at least one dash must be present so a lone "||" doesn't match.
116
+ function isTableSeparatorLine(line) {
117
+ if (!isTableRowLine(line)) return false;
118
+ const inner = line.trim().slice(1, -1);
119
+ const cells = inner.split('|');
120
+ return cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c.trim()));
121
+ }
122
+
123
+ function splitTableRow(line) {
124
+ const inner = line.trim().slice(1, -1);
125
+ return inner.split('|').map((c) => c.trim());
126
+ }
127
+
128
+ // Splits message text into alternating text/table blocks so a markdown pipe table (which the
129
+ // agent is told to use for comparisons) renders as a real, readable table instead of raw
130
+ // "| a | b |" text wrapped mid-word inside a narrow chat bubble.
131
+ function parseRichBlocks(text) {
132
+ const lines = text.split('\n');
133
+ const blocks = [];
134
+ let textBuf = [];
135
+ const flushText = () => {
136
+ if (textBuf.length) {
137
+ blocks.push({ type: 'text', content: textBuf.join('\n') });
138
+ textBuf = [];
139
+ }
140
+ };
141
+ let i = 0;
142
+ while (i < lines.length) {
143
+ const line = lines[i];
144
+ const nextLine = i + 1 < lines.length ? lines[i + 1] : '';
145
+ if (isTableRowLine(line) && isTableSeparatorLine(nextLine)) {
146
+ flushText();
147
+ const header = splitTableRow(line);
148
+ let j = i + 2;
149
+ const rows = [];
150
+ while (j < lines.length && isTableRowLine(lines[j])) {
151
+ rows.push(splitTableRow(lines[j]));
152
+ j++;
153
+ }
154
+ blocks.push({ type: 'table', header, rows });
155
+ i = j;
156
+ continue;
157
+ }
158
+ textBuf.push(line);
159
+ i++;
160
+ }
161
+ flushText();
162
+ return blocks;
163
+ }
164
+
165
+ function RichMessageContent({ text }) {
166
+ const blocks = useMemo(() => parseRichBlocks(text || ''), [text]);
167
+ return (
168
+ <>
169
+ {blocks.map((block, bi) =>
170
+ block.type === 'table' ? (
171
+ <div key={`b${bi}`} className="my-1.5 -mx-1 overflow-x-auto">
172
+ <table className="min-w-full border-collapse text-xs">
173
+ <thead>
174
+ <tr>
175
+ {block.header.map((cell, ci) => (
176
+ <th
177
+ key={ci}
178
+ className="border border-slate-200 bg-slate-100 px-2 py-1 text-left font-semibold whitespace-nowrap"
179
+ >
180
+ {renderInlineLinks(cell, `b${bi}h${ci}`)}
181
+ </th>
182
+ ))}
183
+ </tr>
184
+ </thead>
185
+ <tbody>
186
+ {block.rows.map((row, ri) => (
187
+ <tr key={ri}>
188
+ {row.map((cell, ci) => (
189
+ <td key={ci} className="border border-slate-200 px-2 py-1 align-top">
190
+ {renderInlineLinks(cell, `b${bi}r${ri}c${ci}`)}
191
+ </td>
192
+ ))}
193
+ </tr>
194
+ ))}
195
+ </tbody>
196
+ </table>
197
+ </div>
198
+ ) : (
199
+ <React.Fragment key={`b${bi}`}>{renderInlineLinks(block.content, `b${bi}`)}</React.Fragment>
200
+ )
201
+ )}
202
+ </>
203
+ );
204
  }
205
 
206
  /** Talks to the SAME public session/message endpoints the embedded widget uses — a true
 
315
  const snapshot = acc;
316
  setMessages((prev) => {
317
  const next = [...prev];
318
+ next[next.length - 1] = { ...next[next.length - 1], role: 'assistant', content: snapshot };
319
  return next;
320
  });
321
  }
 
323
  const fallback = acc || 'Sorry, something went wrong.';
324
  setMessages((prev) => {
325
  const next = [...prev];
326
+ next[next.length - 1] = { ...next[next.length - 1], role: 'assistant', content: fallback };
327
+ return next;
328
+ });
329
+ }
330
+ if (obj.meta && obj.meta.used_web_search) {
331
+ setMessages((prev) => {
332
+ const next = [...prev];
333
+ next[next.length - 1] = { ...next[next.length - 1], usedWebSearch: true };
334
  return next;
335
  });
336
  }
 
398
  : 'bg-white border border-slate-200 text-slate-800'
399
  )}
400
  >
401
+ {m.usedWebSearch ? (
402
+ <span className="mb-1 flex w-fit items-center gap-1 rounded-full bg-sky-50 px-2 py-0.5 text-[10px] font-medium text-sky-700 ring-1 ring-sky-200">
403
+ <Globe className="h-3 w-3" />
404
+ Used live web search
405
+ </span>
406
+ ) : null}
407
+ <RichMessageContent text={m.content} />
408
  </div>
409
  ))
410
  )}
 
1014
  : 'bg-slate-100 text-slate-800'
1015
  )}
1016
  >
1017
+ {m.used_web_search ? (
1018
+ <span className="mb-1 flex w-fit items-center gap-1 rounded-full bg-sky-50 px-2 py-0.5 text-[10px] font-medium text-sky-700 ring-1 ring-sky-200">
1019
+ <Globe className="h-3 w-3" />
1020
+ Used live web search
1021
+ </span>
1022
+ ) : null}
1023
+ <RichMessageContent text={m.content} />
1024
  </div>
1025
  ))}
1026
  </div>