Files
stack/src/llm/web/chat.html
kert 2ba846b39a feat(llm): search web UI at /ui/search (refs #578)
A minimal, framework-free search page served by the llm service itself,
in the same editorial style as the chat page, so the retrieval endpoints
(#570) are usable from the dashboard and not only via curl.

- src/llm/web/search.html: query box + collection/kind/docket/year
  filters (exactly the values the API validates), results with kind,
  linked label, date, docket/comment id, title and snippet, a "similar"
  toggle per result carrying a bib item_key (renders /similar/{key} in
  place), limit/offset pagination, and the search state mirrored into
  the query string so a search is shareable and survives a reload.
  Loading/error states surface the API's own 400/404 `detail`. All API
  data becomes text nodes — nothing is assigned to innerHTML — and the
  page carries no external script/style URL, so it renders behind
  oauth2-proxy with no outbound CDN access (fonts fall back to the
  local serif stack). An "llm:" tag filter is left as an HTML comment
  until the tagging chain (#575) makes chunks carry those tags.
- api.py: `GET /ui/search` (HTMLResponse, like `/` serves chat.html),
  registered with and without the trailing slash so the headless probe's
  readiness check gets a 200 instead of a 307 redirect.
- chat header → "Search", search header → "Chat".
- tests/llm/test_api.py::TestSearchPage: the route serves the form and
  every allowed filter value, the trailing-slash form works, the
  results/similar/pagination/error code is present, the page has no
  external script/style URL and no innerHTML assignment, and both nav
  links resolve to routes that return 200.

Headless probe, once the image is rolled (do not run before the roll —
it probes the live service):

    python dev/scripts/nb_fe_smoke.py \
        --url http://llm:8000/ui/search --network gateway

nb_fe_smoke's --url mode probes `<base>/` and `<base>/?file=<notebook>`
and fails on any console or page error; with the base above, both are
the search page (the unused ?file= query is ignored by the route), which
is why the route also answers the trailing-slash form.
2026-09-11 18:48:19 -04:00

599 lines
25 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Library Chat</title>
<link rel="icon" type="image/png" sizes="32x32" href="//dashboard.fhirworx.io/fav32.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Source+Serif+4:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@500&display=swap">
<style>
/* HTI-5 editorial palette — matches the fhirworx platform theme. */
:root {
--background: #F7F5F0; --foreground: #1A1A18; --card: #FAFAF7;
--primary: #1C2B3A; --primary-fg: #F7F5F0; --secondary: #EDEBE6;
--muted-fg: #6B6B68; --border: #D4D0C8; --accent: #2E3D8F;
--destructive: #C0392B;
--font-display: "Playfair Display", Georgia, serif;
--font-body: "Source Serif 4", Georgia, serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
}
* { box-sizing: border-box; }
body {
margin: 0; height: 100vh; display: flex; flex-direction: column;
background: var(--background); color: var(--foreground);
font-family: var(--font-body); font-size: 16px; line-height: 1.55;
}
header {
flex: 0 0 auto; background: var(--primary); color: var(--primary-fg);
border-bottom: 3px solid var(--foreground);
padding: 14px 22px; display: flex; align-items: baseline; gap: 14px;
}
header h1 {
font-family: var(--font-display); font-weight: 700; font-size: 20px; margin: 0;
}
header .sub { font-size: 13px; opacity: .7; }
header a.nav {
font-family: var(--font-mono); font-size: 11px; text-transform: uppercase;
letter-spacing: .08em; color: var(--primary-fg); text-decoration: underline dotted;
}
header .who {
margin-left: auto; font-family: var(--font-mono); font-size: 11px;
text-transform: uppercase; letter-spacing: .08em; opacity: .8;
}
#log {
flex: 1 1 auto; overflow-y: auto; padding: 26px 22px;
display: flex; flex-direction: column; gap: 22px;
max-width: 860px; width: 100%; margin: 0 auto;
}
.msg { display: flex; flex-direction: column; gap: 7px; }
.msg .role {
font-family: var(--font-mono); font-size: 10px; letter-spacing: .12em;
text-transform: uppercase; color: var(--muted-fg);
}
.bubble {
padding: 14px 16px; white-space: pre-wrap; word-wrap: break-word;
border: 1px solid var(--border); border-radius: 3px;
}
.user { align-items: flex-end; }
.user .role { text-align: right; }
.user .bubble {
background: var(--primary); color: var(--primary-fg);
border-color: var(--primary); max-width: 82%;
}
.bot .bubble { background: var(--card); }
/* Model-cited [ID] tokens rendered as editorial small-caps mono. */
.bot .bubble .cite {
font-family: var(--font-mono); font-size: .82em; color: var(--accent);
background: var(--secondary); padding: 1px 5px; border-radius: 2px;
white-space: nowrap;
}
/* A cite with a known paragraph URL is a jump link (FR ¶, eCFR §). */
.bot .bubble a.cite { text-decoration: underline dotted; cursor: pointer; }
.bot .bubble a.cite:hover { text-decoration: underline; }
/* Markdown blocks inside the answer bubble. */
.bot .bubble p { margin: 0 0 .6em; }
.bot .bubble p:last-child { margin-bottom: 0; }
.bot .bubble h3, .bot .bubble h4, .bot .bubble h5, .bot .bubble h6 {
font-family: var(--font-display, inherit); margin: .8em 0 .3em; line-height: 1.25;
}
.bot .bubble h3 { font-size: 1.1em; } .bot .bubble h4 { font-size: 1em; }
.bot .bubble h5, .bot .bubble h6 { font-size: .95em; }
.bot .bubble ul, .bot .bubble ol { margin: 0 0 .6em 1.2em; padding: 0; }
.bot .bubble li { margin: .15em 0; }
.bot .bubble code { font-family: var(--font-mono); font-size: .88em; }
table.valuation {
border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 4px;
font-variant-numeric: tabular-nums;
}
table.valuation th, table.valuation td {
border: 1px solid var(--border); padding: 4px 6px; text-align: right; white-space: nowrap;
}
table.valuation th { font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted-fg); }
table.valuation td:nth-child(-n+3), table.valuation th:nth-child(-n+3) { text-align: left; }
table.valuation tr.proposed td { font-style: italic; opacity: .85; }
table.valuation caption {
caption-side: bottom; text-align: left; font-size: 11px;
color: var(--muted-fg); padding: 4px 2px 0;
}
.valuation-wrap { overflow-x: auto; }
table.lineage {
border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 4px;
}
table.lineage th, table.lineage td {
border: 1px solid var(--border); padding: 4px 6px; text-align: left; white-space: nowrap;
}
table.lineage th { font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted-fg); }
tr.unanchored td { color: var(--muted, #888); font-style: italic; }
.lineage-wrap { overflow-x: auto; margin-bottom: 6px; }
.lineage-wrap .caption { font-size: 12px; color: var(--muted-fg); margin-bottom: 2px; }
ul.elements, ul.guidance {
font-size: 12px; color: var(--muted-fg); margin: 6px 0 0; padding-left: 18px;
}
ul.elements li, ul.guidance li { margin: 2px 0; }
.lineage-wrap .elements-note { font-size: 11px; color: var(--muted-fg); font-style: italic; margin-top: 4px; }
.provenance { font-size: 12px; color: var(--muted-fg); margin-top: 4px; }
.provenance .cite { margin-right: 4px; }
details.sources { margin-top: 2px; font-size: 14px; }
details.sources > summary {
cursor: pointer; color: var(--muted-fg); font-family: var(--font-mono);
font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
list-style: none;
}
details.sources > summary::-webkit-details-marker { display: none; }
details.sources > summary::before { content: "▸ "; }
details.sources[open] > summary::before { content: "▾ "; }
.src {
border-left: 3px solid var(--border); padding: 6px 12px; margin: 10px 0;
color: var(--muted-fg);
}
.src b { color: var(--accent); font-family: var(--font-mono); font-size: .8em; font-weight: 500; }
.err { color: var(--destructive); border-color: var(--destructive); }
form {
flex: 0 0 auto; border-top: 3px solid var(--foreground); background: var(--secondary);
padding: 16px 22px; display: flex; gap: 12px;
max-width: 860px; width: 100%; margin: 0 auto;
}
textarea {
flex: 1; resize: none; height: 46px; padding: 12px 13px;
font-family: var(--font-body); font-size: 16px;
background: var(--card); color: var(--foreground);
border: 1px solid var(--border); border-radius: 3px;
}
textarea:focus {
outline: none; border-color: var(--primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 22%, transparent);
}
button {
padding: 0 22px; border: 1px solid var(--primary); border-radius: 3px;
background: var(--primary); color: var(--primary-fg);
font-family: var(--font-body); font-size: 15px; font-weight: 600; cursor: pointer;
transition: background .15s ease;
}
button:hover:not(:disabled) { background: #253748; }
button:disabled { opacity: .45; cursor: default; }
form label.recent, form label.mode {
display: flex; align-items: center; gap: 6px; font-size: 13px;
color: var(--muted-fg); white-space: nowrap;
}
form label.mode select {
font-family: var(--font-body); font-size: 13px;
background: var(--card); color: var(--foreground);
border: 1px solid var(--border); border-radius: 3px; padding: 3px 4px;
}
.src a { color: var(--primary); text-decoration: underline dotted; }
.src .kind {
font-family: var(--font-mono); font-size: 11px; padding: 1px 5px; border-radius: 3px;
background: color-mix(in srgb, var(--primary) 12%, transparent); margin-right: 6px;
}
.src .date { color: var(--muted-fg); margin-left: 6px; }
.meta { font-size: 12px; color: var(--muted-fg); margin-top: 4px; font-family: var(--font-mono); }
.hint {
margin: auto; max-width: 500px; text-align: center; color: var(--muted-fg);
font-style: italic;
}
</style>
</head>
<body>
<header>
<h1>Library Chat</h1>
<span class="sub">grounded in the indexed library — comments, FR rules, references</span>
<a class="nav" href="/ui/search">Search</a>
<span class="who" id="who"></span>
</header>
<div id="log">
<div class="hint">Ask a question about CMS rulemaking. Answers draw on the
indexed comments, Federal Register rules and reference library, prefer the
most recent comments, and link each citation to its source passage.</div>
</div>
<form id="form">
<textarea id="q" placeholder="e.g. What do commenters say about telehealth originating sites?" autofocus></textarea>
<label class="recent"><input type="checkbox" id="recent"> last 12 months</label>
<label class="mode">mode
<select id="mode">
<option value="auto" selected>auto</option>
<option value="timeline">timeline</option>
<option value="recent">recent</option>
</select>
</label>
<button id="send" type="submit">Send</button>
</form>
<script>
const log = document.getElementById('log');
const form = document.getElementById('form');
const box = document.getElementById('q');
const send = document.getElementById('send');
fetch('whoami').then(r => r.json()).then(d => {
if (d.user) document.getElementById('who').textContent = d.user;
}).catch(() => {});
function bubble(role) {
const wrap = document.createElement('div');
wrap.className = 'msg ' + role;
const label = document.createElement('div');
label.className = 'role';
label.textContent = role === 'user' ? 'you' : 'assistant';
const b = document.createElement('div');
b.className = 'bubble';
wrap.appendChild(label); wrap.appendChild(b);
log.appendChild(wrap);
log.scrollTop = log.scrollHeight;
return { wrap, b };
}
// Render answer text, wrapping [CMS-...] citations in styled spans.
// Inline markdown + citations. Model text is never assigned to
// innerHTML: every run becomes a text node or a created element.
// `links` maps a bracketed label (as the model cites it) to the URL the
// lineage/valuation/sources events carried for it — an FR paragraph
// (federalregister.gov/…#p-NNNN), an eCFR section, a comment — so a
// cited [CY2017 PFS final 81 FR 80255 ¶1196] is a jump link, not a badge.
function paintInline(el, text, links) {
const re = /\[([^\[\]\n]{2,90})\]|\*\*([^*\n]+)\*\*|`([^`\n]+)`/g;
let last = 0, m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) el.appendChild(document.createTextNode(text.slice(last, m.index)));
if (m[1] !== undefined) {
const url = links && links[m[1]];
let c;
if (url) {
c = document.createElement('a');
c.href = url; c.target = '_blank'; c.rel = 'noopener';
} else {
c = document.createElement('span');
}
c.className = 'cite'; c.textContent = m[0];
el.appendChild(c);
} else if (m[2] !== undefined) {
const s = document.createElement('strong'); s.textContent = m[2]; el.appendChild(s);
} else {
const code = document.createElement('code'); code.textContent = m[3]; el.appendChild(code);
}
last = m.index + m[0].length;
}
if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
}
// Block markdown: headings (# … ######), bullet and numbered lists,
// paragraphs. The model writes markdown; showing raw "## History" was
// the bug this replaces.
function paint(el, text, links) {
el.innerHTML = '';
const lines = text.split('\n');
let para = [], list = null;
const flushPara = () => {
if (!para.length) return;
const p = document.createElement('p');
paintInline(p, para.join(' '), links);
el.appendChild(p); para = [];
};
const flushList = () => { list = null; };
for (const raw of lines) {
const line = raw.replace(/\s+$/, '');
const h = /^(#{1,6})\s+(.*)$/.exec(line);
const li = /^\s*(?:[-*•]|\d+[.)])\s+(.*)$/.exec(line);
if (h) {
flushPara(); flushList();
const level = Math.min(h[1].length + 2, 6); // # → h3 inside a bubble
const hd = document.createElement('h' + level);
paintInline(hd, h[2], links); el.appendChild(hd);
} else if (li) {
flushPara();
const ordered = /^\s*\d/.test(line);
if (!list || list.tagName !== (ordered ? 'OL' : 'UL')) {
list = document.createElement(ordered ? 'ol' : 'ul'); el.appendChild(list);
}
const item = document.createElement('li');
paintInline(item, li[1], links); list.appendChild(item);
} else if (!line.trim()) {
flushPara(); flushList();
} else {
flushList(); para.push(line);
}
}
flushPara();
}
// label → url for this turn, filled by the lineage/valuation/sources
// events; the answer is repainted whenever the map grows.
function collectLinks(links, ev) {
// Valuation provenance labels arrive with their brackets
// ("[PFS CY2017 Addendum B]"); the model cites the inside.
const add = (label, url) => {
if (!label || !url) return;
const key = String(label).replace(/^\[|\]$/g, '');
if (!links[key]) links[key] = url;
};
if (ev.type === 'lineage') {
for (const e of ev.events || []) add(e.label, e.url);
for (const g of ev.guidance || []) { add(g.label, g.url); add(g.locator, g.url); }
} else if (ev.type === 'valuation') {
for (const p of ev.provenance || []) add(p.label, p.url);
} else if (ev.type === 'sources') {
for (const s of ev.sources || []) add(s.label, s.url);
}
}
function renderLineage(wrap, ev) {
const events = ev.events || [];
const box = document.createElement('div');
box.className = 'lineage-wrap';
const cap = document.createElement('div');
cap.className = 'caption';
cap.textContent = 'Timeline for ' + (ev.codes || []).join(', ') +
' (' + (ev.families || []).join(', ') + ')';
box.appendChild(cap);
if (events.length) {
const t = document.createElement('table');
t.className = 'lineage';
const thead = t.createTHead().insertRow();
for (const name of ['Year', 'Event', 'Codes', 'Source']) {
const th = document.createElement('th'); th.textContent = name; thead.appendChild(th);
}
const tb = t.createTBody();
for (const e of events) {
const tr = tb.insertRow();
if (e.anchored === false) {
tr.className = 'unanchored';
tr.title = 'no FR/CPT paragraph within a year of this RVU-file event';
}
tr.insertCell().textContent = e.year;
const evCell = tr.insertCell();
let kindText = e.kind;
if ((e.from_codes || []).length || (e.to_codes || []).length) {
kindText += ' (' + (e.from_codes || []).join(', ') + ' → ' +
(e.to_codes || []).join(', ') + ')';
}
evCell.textContent = kindText;
if (e.note) evCell.title = e.note;
tr.insertCell().textContent = e.code;
const srcCell = tr.insertCell();
let cite;
if (e.url) {
cite = document.createElement('a');
cite.href = e.url; cite.target = '_blank'; cite.rel = 'noopener';
} else {
cite = document.createElement('span'); // an empty href reloads the chat
}
cite.textContent = '[' + e.label + ']';
srcCell.appendChild(cite);
}
box.appendChild(t);
}
const diffs = ev.element_diffs || [];
if (diffs.length) {
const ul = document.createElement('ul');
ul.className = 'elements';
for (const d of diffs) {
const li = document.createElement('li');
li.appendChild(document.createTextNode(
d.type + '=' + d.value + ' — in ' + (d.in_codes || []).join(', ') +
'; not in ' + (d.not_in_codes || []).join(', ') + ' '
));
const c = document.createElement('span');
c.className = 'cite'; c.textContent = '[' + d.label + ']';
li.appendChild(c);
ul.appendChild(li);
}
box.appendChild(ul);
}
const guidance = ev.guidance || [];
if (guidance.length) {
const ul = document.createElement('ul');
ul.className = 'guidance';
for (const g of guidance) {
const li = document.createElement('li');
let link;
if (g.url) {
link = document.createElement('a');
link.href = g.url; link.target = '_blank'; link.rel = 'noopener';
} else {
link = document.createElement('span');
}
link.textContent = g.locator + ' (' + g.kind.toUpperCase() + ')';
li.appendChild(link);
li.appendChild(document.createTextNode(' '));
const c = document.createElement('span');
c.className = 'cite'; c.textContent = '[' + g.label + ']';
li.appendChild(c);
ul.appendChild(li);
}
box.appendChild(ul);
}
if (ev.elements_note) {
const note = document.createElement('div');
note.className = 'elements-note';
note.textContent = ev.elements_note;
box.appendChild(note);
}
wrap.appendChild(box);
log.scrollTop = log.scrollHeight;
}
const VAL_COLS = [
['code', 'Code'], ['description', 'Description'], ['vintage', 'Vintage'], ['status', 'St'],
['work', 'Work'], ['pe_nf', 'PE NF'], ['pe_f', 'PE F'], ['mp', 'MP'],
['total_nf', 'Total NF'], ['total_f', 'Total F'], ['cf', 'CF (non-APM)'],
['pay_nf', 'Pay NF'], ['pay_f', 'Pay F'],
];
function fmt(key, v) {
if (v === null || v === undefined) return '—';
if (key === 'cf') return '$' + Number(v).toFixed(4);
if (key === 'pay_nf' || key === 'pay_f') return '$' + Number(v).toFixed(2);
if (typeof v === 'number') return v.toFixed(2);
return String(v);
}
function renderValuation(wrap, ev) {
const rows = ev.rows || [];
const box = document.createElement('div');
box.className = 'valuation-wrap';
if (rows.length) {
const t = document.createElement('table');
t.className = 'valuation';
t.createCaption().textContent =
'National unadjusted amounts (total RVUs \u00d7 CF); no geographic (GPCI) adjustment.';
const thead = t.createTHead().insertRow();
for (const [, name] of VAL_COLS) { const th = document.createElement('th'); th.textContent = name; thead.appendChild(th); }
const tb = t.createTBody();
for (const r of rows) {
const tr = tb.insertRow();
if (r.proposed) tr.className = 'proposed';
for (const [key] of VAL_COLS) {
const td = tr.insertCell();
td.textContent = fmt(key, r[key]);
// why a status is not paid / which of the year's CFs this is
if (key === 'status' && r.status_note) td.title = r.status_note;
if (key === 'cf' && r.cf_note) td.title = r.cf_note;
}
}
box.appendChild(t);
}
const prov = document.createElement('div');
prov.className = 'provenance';
for (const p of ev.provenance || []) {
const c = document.createElement('span'); c.className = 'cite'; c.textContent = p.label;
prov.appendChild(c);
let cite;
if (p.url) {
cite = document.createElement('a');
cite.href = p.url; cite.target = '_blank'; cite.rel = 'noopener';
} else {
cite = document.createElement('span'); // an empty href reloads the chat
}
cite.textContent = p.citation || p.vintage;
prov.appendChild(cite);
prov.appendChild(document.createTextNode(' '));
}
if ((ev.unpriced || []).length) {
prov.appendChild(document.createTextNode('not priced: ' + ev.unpriced.join(', ')));
}
const notes = [];
for (const r of rows) {
if (!r.status_note) continue;
const line = r.code + ' (' + (r.status || '?') + '): ' + r.status_note;
if (!notes.includes(line)) notes.push(line);
}
for (const line of notes) {
const d = document.createElement('div');
d.textContent = line;
prov.appendChild(d);
}
box.appendChild(prov);
wrap.appendChild(box);
log.scrollTop = log.scrollHeight;
}
function renderSources(wrap, ev) {
const sources = ev.sources || [];
if (ev.model) {
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = ev.model + ' @ ' + (ev.host || '').replace(/^https?:\/\//, '') +
(ev.mode === 'timeline' ? ' · timeline' : '');
wrap.appendChild(meta);
}
if (!sources.length) return;
const d = document.createElement('details');
d.className = 'sources';
const s = document.createElement('summary');
s.textContent = sources.length + ' source' + (sources.length > 1 ? 's' : '');
d.appendChild(s);
for (const src of sources) {
const el = document.createElement('div');
el.className = 'src';
const kind = document.createElement('span');
kind.className = 'kind'; kind.textContent = src.kind || 'comment';
el.appendChild(kind);
const id = document.createElement('b');
if (src.url) {
const a = document.createElement('a');
a.href = src.url; a.target = '_blank'; a.rel = 'noopener';
a.textContent = '[' + src.label + ']';
id.appendChild(a);
} else {
id.textContent = '[' + src.label + ']';
}
el.appendChild(id);
if (src.date) {
const dt = document.createElement('span');
dt.className = 'date'; dt.textContent = src.date;
el.appendChild(dt);
}
if (src.title && src.kind !== 'comment') {
el.appendChild(document.createTextNode(' — ' + src.title));
}
el.appendChild(document.createTextNode(' ' + src.snippet));
d.appendChild(el);
}
wrap.appendChild(d);
log.scrollTop = log.scrollHeight;
}
async function ask(question) {
const hint = log.querySelector('.hint');
if (hint) hint.remove();
bubble('user').b.textContent = question;
const { wrap, b } = bubble('bot');
let answer = '';
const links = {}; // label → url, see collectLinks
send.disabled = true;
const recent = document.getElementById('recent').checked;
const since = recent ? new Date(Date.now() - 365 * 864e5).toISOString().slice(0, 10) : null;
const mode = document.getElementById('mode').value;
try {
const resp = await fetch('chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, since, mode })
});
if (!resp.ok) throw new Error('request failed (' + resp.status + ')');
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop();
for (const part of parts) {
const line = part.replace(/^data: /, '').trim();
if (!line) continue;
const ev = JSON.parse(line);
if (ev.type === 'token') { answer += ev.text; paint(b, answer, links); log.scrollTop = log.scrollHeight; }
else if (ev.type === 'lineage') { collectLinks(links, ev); renderLineage(wrap, ev); }
else if (ev.type === 'valuation') { collectLinks(links, ev); renderValuation(wrap, ev); }
else if (ev.type === 'sources') { collectLinks(links, ev); renderSources(wrap, ev); paint(b, answer, links); }
else if (ev.type === 'error') { b.className = 'bubble err'; b.textContent = 'Error: ' + ev.message; }
}
}
if (!answer && b.className === 'bubble') b.textContent = '(no answer)';
} catch (e) {
b.className = 'bubble err';
b.textContent = 'Error: ' + e.message;
} finally {
send.disabled = false;
box.focus();
}
}
form.addEventListener('submit', e => {
e.preventDefault();
const q = box.value.trim();
if (!q) return;
box.value = '';
ask(q);
});
box.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); }
});
</script>
</body>
</html>