fix(chat): raise history cap and add 30s frontend timeout
Deploy marketing to Cloud Run / deploy (push) Successful in 3m43s
Stage — Build, push & deploy to marketing-stage / deploy-stage (push) Successful in 3m26s
Dev — Build & local smoke test / build-smoke (push) Successful in 2m29s

The demo chat was silently dropping conversation context past 40 turns
and leaving the thinking bubble spinning forever when the soul backend
hung — visitors saw a frozen UI with no way to know what went wrong.

Changes:
  - Stored history cap raised from 40 → 200 messages so longer
    conversations actually persist across page refreshes.
  - History sent to backend per turn raised from 20 → 50 messages.
  - 30s AbortController timeout on the /api/demo fetch — surfaces a
    distinct "Took too long to respond" error instead of hanging.
  - Restore script (restore-chat-js-with-preview.py) is now correctly
    idempotent in both directions: detects when modal HTML is inlined
    but chat JS got extracted to an asset, and re-injects fresh source
    so extract-js picks up changes on the next build.
This commit is contained in:
Will Anderson
2026-05-04 01:57:38 -05:00
parent 4c5d4b3c84
commit 0508cd77fd
2 changed files with 463 additions and 23 deletions
+64 -22
View File
@@ -352,7 +352,7 @@ CHAT_HTML_AND_JS = r"""
if (!skipSave && role !== 'thinking') {
session.messages = session.messages || [];
session.messages.push({ role: role, text: text });
if (session.messages.length > 40) session.messages = session.messages.slice(-40);
if (session.messages.length > 200) session.messages = session.messages.slice(-200);
saveSession(session);
}
return el;
@@ -388,26 +388,36 @@ CHAT_HTML_AND_JS = r"""
}
if (turnstileVerified && !session._cfSent) { session._cfSent = true; }
try {
var hist = (session.messages || []).slice(-20).filter(function(m){ return m.role !== 'thinking'; }).map(function(m){
var hist = (session.messages || []).slice(-50).filter(function(m){ return m.role !== 'thinking'; }).map(function(m){
return {role: m.role === 'ai' ? 'assistant' : 'user', content: m.text};
});
var activated_nodes = _ra(session._m, msg);
var questionsRemaining = (MAX - msgCount) - 1;
if (questionsRemaining < 0) questionsRemaining = 0;
var r = await fetch('/api/demo', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
message: msg,
history: hist,
cf_token: turnstileVerified && !session._cfSent ? turnstileToken : '',
uid: session.uid || '',
activated_nodes: activated_nodes,
engram_node_count: (session._m && session._m.nodes) ? session._m.nodes.length : 0,
questions_remaining: questionsRemaining,
is_last_question: questionsRemaining === 0
})
});
// 30s frontend timeout — surfaces a real error if the soul hangs
// instead of leaving the thinking bubble spinning forever.
var ctrl = new AbortController();
var timeoutId = setTimeout(function() { ctrl.abort(); }, 30000);
var r;
try {
r = await fetch('/api/demo', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
signal: ctrl.signal,
body: JSON.stringify({
message: msg,
history: hist,
cf_token: turnstileVerified && !session._cfSent ? turnstileToken : '',
uid: session.uid || '',
activated_nodes: activated_nodes,
engram_node_count: (session._m && session._m.nodes) ? session._m.nodes.length : 0,
questions_remaining: questionsRemaining,
is_last_question: questionsRemaining === 0
})
});
} finally {
clearTimeout(timeoutId);
}
var d = await r.json();
if (thinking) thinking.remove();
_um(session, d.sn, d.se);
@@ -426,7 +436,10 @@ CHAT_HTML_AND_JS = r"""
addMsg('ai', reply || 'Stepped out for a moment. Try again.');
} catch(e) {
if (thinking) thinking.remove();
addMsg('ai', 'Stepped out for a moment. Try again.');
var msg = (e && e.name === 'AbortError')
? 'Took too long to respond — try again.'
: 'Stepped out for a moment. Try again.';
addMsg('ai', msg);
}
if (msgCount < MAX && btn) btn.disabled = false;
if (input) input.focus();
@@ -451,12 +464,41 @@ OLD_LINE = '<script src=\\"/assets/js/de72b8b61d75.js\\" defer></script>'
def main():
src = STYLES_EL.read_text(encoding="utf-8")
if MARKER in src:
print("styles.el already contains the share preview modal - skipping")
# If the anchor `<script src=...de72b8b61d75.js...>` is still present, the
# asset extraction took the inline source out — so the chat JS in this
# file is the source-of-truth and we re-inject it. We fall through the
# MARKER check in that case, replacing both the modal HTML and the
# script-tag in one shot.
has_anchor = OLD_LINE in src
if MARKER in src and not has_anchor:
# Modal is inline AND asset has been re-extracted to a fresh hash.
# Re-running here would clobber the new hash. Bail.
print("styles.el already inlined and re-extracted - skipping")
return
if OLD_LINE not in src:
# Maybe extract-js already pulled out a different hash; fail loud.
print("ERROR: anchor `<script src=...fc247ef45b1d.js...>` not found in styles.el")
if MARKER in src and has_anchor:
# Modal HTML is there but the chat JS is still in the obfuscated
# asset. Replace the entire modal+script block so extract-js picks
# up the fresh source on the next build.
import re
# Match from the modal comment opener to (and including) the OLD_LINE.
pattern = re.compile(
r"<!--\s*Share preview modal:.*?" + re.escape(OLD_LINE),
re.DOTALL,
)
if not pattern.search(src):
print("ERROR: modal block + anchor pair not found in styles.el")
raise SystemExit(1)
# Use a lambda so re.sub doesn't parse the replacement as a template
# (the chat HTML/JS contains `\s`, `\d`, etc. which would be treated
# as backreferences in a normal replacement string).
replacement = CHAT_HTML_AND_JS.strip()
new_src = pattern.sub(lambda _m: replacement, src, count=1)
STYLES_EL.write_text(new_src, encoding="utf-8")
print("styles.el patched: replaced existing modal+anchor with fresh chat-widget JS")
return
if not has_anchor:
# No marker, no anchor — file is in an unexpected state. Fail loud.
print(f"ERROR: anchor `{OLD_LINE}` not found in styles.el")
print(" Either it was renamed in a prior commit, or the file is already patched.")
raise SystemExit(1)
new_src = src.replace(OLD_LINE, CHAT_HTML_AND_JS.strip(), 1)