feat(chat): stream model thinking over SSE and show it in a collapsible block
This commit is contained in:
+111
-16
@@ -14,16 +14,28 @@
|
||||
* • thinking — pre-token: typing dots + disabled "Thinking…" button;
|
||||
* after 10s the indicator's aria-label shows elapsed
|
||||
* seconds so screen-reader users are never left guessing.
|
||||
* Phase 17: while the model streams reasoning (`thinking`
|
||||
* SSE events), the live collapsible Thinking block IS the
|
||||
* visible feedback (it replaces the typing dots; the UI
|
||||
* state stays "thinking" — button still disabled,
|
||||
* "Thinking…") and the 120s guard clears on the first
|
||||
* thinking *or* delta event.
|
||||
* • streaming — the first delta removes the dots and appends live into
|
||||
* the answer bubble; the button stays busy until `done`.
|
||||
* the answer bubble (auto-collapsing the Thinking block,
|
||||
* phase 17); the button stays busy until `done`.
|
||||
* • error — red banner (role="alert") with an actionable retry hint;
|
||||
* the 120s guard (TURN_TIMEOUT_MS) catches hung pre-token
|
||||
* streams, so the button can never sit zombified.
|
||||
* streams and the sawDone guard (phase 17) catches a
|
||||
* stream that dies after frames but before `done`, so the
|
||||
* button can never sit zombified.
|
||||
*
|
||||
* Conversation persistence (phase 14) makes the chat a durable LOCAL
|
||||
* session: the message list (raw text + turn metadata) lives in
|
||||
* localStorage under the versioned key `bor.chat.v1` and is re-rendered on
|
||||
* load — refresh, tab close, and a trip to Sources never lose it. A10 is
|
||||
* load — refresh, tab close, and a trip to Sources never lose it. Phase
|
||||
* 17: a brain record may carry an optional `thinking` field — the
|
||||
* collapsed Thinking block is restored with it; records without it (old
|
||||
* sessions) restore exactly as before, so no version bump. A10 is
|
||||
* untouched: the API stays stateless, nothing is stored server-side.
|
||||
* "New chat" (#new-chat-btn) clears the key + the list back to the empty
|
||||
* state.
|
||||
@@ -77,6 +89,10 @@ const SEND_STATUS = Object.freeze({
|
||||
|
||||
const TYPING_LABEL = "Brain of Reese is thinking";
|
||||
const ERROR_HINT = "Try again — if this persists, check the LLM is reachable.";
|
||||
/* A turn with no answer content (an empty stream, or reasoning that
|
||||
exhausted max_tokens — phase 17) still renders a bubble, and this exact
|
||||
text is what gets persisted: what the user saw is what is stored. */
|
||||
const EMPTY_ANSWER_FALLBACK = "Hmm — that came back empty. Ask me again?";
|
||||
|
||||
/* Calm, don't remove: smooth scrolling is the one motion JS controls. */
|
||||
const reducedMotion =
|
||||
@@ -347,6 +363,33 @@ function removeTyping() {
|
||||
document.querySelector("#typing-indicator")?.remove();
|
||||
}
|
||||
|
||||
/* ---------- thinking block (phase 17) ----------
|
||||
* The model's reasoning streams into a collapsible <details> block ABOVE
|
||||
* the answer bubble: created OPEN on the first `thinking` event,
|
||||
* auto-collapsed when the first answer token lands, and user-toggleable
|
||||
* afterwards (native <details>/<summary> — a real focusable control).
|
||||
* ensureThinkingBlock is idempotent (returns the existing block if any);
|
||||
* closeThinkingBlock never reopens a block once the answer has started,
|
||||
* so a late/interleaved `thinking` event only appends to the closed text. */
|
||||
function ensureThinkingBlock(wrap) {
|
||||
let block = wrap.querySelector(".thinking");
|
||||
if (!block) {
|
||||
block = document.createElement("details");
|
||||
block.className = "thinking";
|
||||
block.open = true;
|
||||
block.innerHTML =
|
||||
`<summary>Thinking</summary><div class="thinking-text"></div>`;
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
body.insertBefore(block, body.querySelector(".bubble"));
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
function closeThinkingBlock(wrap) {
|
||||
const block = wrap?.querySelector?.(".thinking");
|
||||
if (block) block.open = false; // idempotent; no-op without a block
|
||||
}
|
||||
|
||||
/* ---------- suggestions (shared chip component, phase 05) ----------
|
||||
*
|
||||
* One component, two homes: the onboarding row in the empty state and the
|
||||
@@ -555,7 +598,8 @@ function appendMaybeTry(wrap, suggestions) {
|
||||
* lives in localStorage under a versioned key; a format bump = clean start:
|
||||
*
|
||||
* bor.chat.v1 → { v: 1, messages: [{ who: "user"|"brain", text,
|
||||
* sources?, deflected?, suggestions? }] }
|
||||
* sources?, deflected?, suggestions?,
|
||||
* thinking? }] }
|
||||
*
|
||||
* Only RAW TEXT is stored — restore re-renders it through the escape-first
|
||||
* markdown renderer, so no HTML is ever persisted. Save points: the user
|
||||
@@ -631,6 +675,12 @@ function renderStoredMessage(m) {
|
||||
return;
|
||||
}
|
||||
const wrap = addMessage("brain", renderMarkdown(m.text), "auto");
|
||||
if (m.thinking) {
|
||||
// Phase 17: restore the thinking block COLLAPSED above the bubble.
|
||||
const block = ensureThinkingBlock(wrap);
|
||||
block.open = false;
|
||||
block.querySelector(".thinking-text").innerHTML = renderMarkdown(m.thinking);
|
||||
}
|
||||
if (m.deflected) {
|
||||
wrap.classList.add("is-deflected");
|
||||
appendMaybeTry(wrap, m.suggestions);
|
||||
@@ -647,8 +697,11 @@ function restoreConversation() {
|
||||
for (const m of conversation) renderStoredMessage(m);
|
||||
}
|
||||
|
||||
/* Brain message save point (on `done`): raw accumulated text only. An
|
||||
empty stream keeps the "…" placeholder that was actually rendered. */
|
||||
/* Brain message save point (on `done`): raw accumulated text + metadata.
|
||||
Phase 17: meta.thinking is optional — `undefined` drops the key from
|
||||
the JSON, so turns without thinking persist exactly as before. An empty
|
||||
answer keeps the fallback/"…" text that was actually rendered — what
|
||||
the user saw is what is stored. */
|
||||
function rememberBrainTurn(rawText, meta) {
|
||||
conversation.push({ who: "brain", text: rawText || "…", ...meta });
|
||||
saveConversation();
|
||||
@@ -754,11 +807,16 @@ async function handleSend(e) {
|
||||
let acc = "";
|
||||
let res = null;
|
||||
let aborted = false; // the 120s guard already took the turn to error
|
||||
// Phase 17 (thinking display): turn-local reasoning state.
|
||||
let thinkingAcc = ""; // accumulated thinking text (persisted with the turn)
|
||||
let sawThinking = false; // did any `thinking` frame arrive this turn?
|
||||
let sawDone = false; // did the stream end with a `done` event?
|
||||
|
||||
try {
|
||||
// thinking = pre-token: dots + busy button. The guard is armed so a
|
||||
// hung stream can never leave the button zombified; it clears on the
|
||||
// first delta (entering "streaming") and on every terminal transition.
|
||||
// first thinking OR delta event (phase 17) and on every terminal
|
||||
// transition.
|
||||
setUiState(UI_STATE.thinking);
|
||||
armTurnTimeout(() => {
|
||||
aborted = true;
|
||||
@@ -781,16 +839,34 @@ async function handleSend(e) {
|
||||
}
|
||||
await readSSE(res, (ev) => {
|
||||
if (aborted) return;
|
||||
if (ev.type === "delta") {
|
||||
acc += ev.text || "";
|
||||
if (!wrap) {
|
||||
// First token: dots out, live bubble in; the button stays busy.
|
||||
setUiState(UI_STATE.streaming);
|
||||
wrap = addMessage("brain", "");
|
||||
if (ev.type === "thinking") {
|
||||
// Phase 17: model reasoning — stream it live into the collapsible
|
||||
// Thinking block. No setUiState here: the UI state stays
|
||||
// "thinking" (button still disabled with "Thinking…", #send-status
|
||||
// unchanged) — the live block simply replaces the typing dots as
|
||||
// the visible feedback.
|
||||
thinkingAcc += ev.text || "";
|
||||
sawThinking = true;
|
||||
clearTurnTimeout(); // the stream is alive — as the first delta says
|
||||
if (!wrap) wrap = addMessage("brain", "");
|
||||
removeTyping(); // the live block replaces the dots as feedback
|
||||
const block = ensureThinkingBlock(wrap);
|
||||
const textEl = block.querySelector(".thinking-text");
|
||||
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
|
||||
if (block.open) {
|
||||
textEl.scrollTop = textEl.scrollHeight; // pin the stream to the bottom
|
||||
wrap.scrollIntoView({ behavior: SCROLL, block: "end" });
|
||||
}
|
||||
} else if (ev.type === "delta") {
|
||||
acc += ev.text || "";
|
||||
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
|
||||
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
|
||||
closeThinkingBlock(wrap); // auto-collapse; idempotent, never reopens
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
|
||||
wrap.scrollIntoView({ behavior: SCROLL, block: "end" });
|
||||
} else if (ev.type === "done") {
|
||||
sawDone = true;
|
||||
closeThinkingBlock(wrap); // the turn is over: settle the block closed
|
||||
if (!wrap) {
|
||||
setUiState(UI_STATE.streaming);
|
||||
wrap = addMessage("brain", "…");
|
||||
@@ -801,9 +877,18 @@ async function handleSend(e) {
|
||||
}
|
||||
appendSources(wrap, ev.sources);
|
||||
appendTuneButton(wrap); // every completed brain bubble is tunable
|
||||
// Thinking-without-answer (reasoning can exhaust max_tokens): the
|
||||
// bubble gets the empty-answer fallback — what the user saw is
|
||||
// what gets persisted.
|
||||
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
|
||||
if (!acc && sawThinking) {
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
|
||||
}
|
||||
// Persistence save point 2: the answer lands only when the turn is
|
||||
// complete (raw text + the done metadata).
|
||||
rememberBrainTurn(acc, {
|
||||
// complete (raw text + the done metadata; phase 17: + optional
|
||||
// thinking — `undefined` drops the key from the JSON).
|
||||
rememberBrainTurn(finalText || acc, {
|
||||
thinking: thinkingAcc || undefined,
|
||||
deflected: !!ev.deflected,
|
||||
sources: ev.sources,
|
||||
suggestions: ev.suggestions,
|
||||
@@ -812,8 +897,18 @@ async function handleSend(e) {
|
||||
throw new Error(ev.detail || "Something went wrong on my side.");
|
||||
}
|
||||
});
|
||||
// Stream-drop guard (phase 17): frames arrived but no `done` event —
|
||||
// the connection died mid-turn. Say so; never settle silently into
|
||||
// idle with a half bubble. The zero-frame case falls through to the
|
||||
// existing empty-answer fallback below.
|
||||
if (!sawDone && !aborted && (acc || thinkingAcc)) {
|
||||
setUiState(
|
||||
UI_STATE.error,
|
||||
"The stream ended before my answer finished — try again?"
|
||||
);
|
||||
}
|
||||
if (!aborted && !wrap) {
|
||||
const fallback = "Hmm — that came back empty. Ask me again?";
|
||||
const fallback = EMPTY_ANSWER_FALLBACK;
|
||||
const fwrap = addMessage("brain", fallback);
|
||||
appendTuneButton(fwrap);
|
||||
rememberBrainTurn(fallback, {}); // persist what the user actually saw
|
||||
|
||||
Reference in New Issue
Block a user