/* TuringDNA · Assistant — conversational orchestrator client. * * External file (not inline) because assistant.html is served statically and * the site CSP (script-src 'self' 'nonce-…') refuses un-nonced inline scripts; * a same-origin /static/ file is permitted by 'self'. * * Talks to the agent server: POST /api/agent/session to open a session, then * POST /api/agent/step { session_id, message } → StepResult { assistant_message, * tool_result, tool_name, phase, error }. Conversations persist in localStorage * so the history dropdown (anchored under the conversation title) survives * reloads. * When the agent backend isn't reachable (e.g. OPENROUTER_API_KEY unset on * this deploy), sends degrade to a quiet inline notice — the interface stays * fully navigable. */ (function () { "use strict"; // Preview mode: set true to disable the composer and show only the seeded // demo sessions, with zero backend calls — useful for a design/QA pass // without spending on OpenRouter. Live as of 2026-07-10. var PREVIEW_MODE = false; // The four suggested openers on an empty conversation, one per phase. var SUGGESTIONS = [ { k: "§01 · Design", t: "Design a saturation-mutagenesis library for my protein's active site." }, { k: "§02 · Build", t: "Map and annotate this plasmid, then design cloning primers." }, { k: "§03 · Edit", t: "Find high-specificity CRISPR guides to knock out a gene." }, { k: "§04 · Learn", t: "Here are my bench results — plan a smarter Round 2." }, ]; // While a request is in flight there's no real progress feed from the // server (one blocking POST, no streaming) — so this doesn't claim // false precision about what's happening server-side. It's a cheap, // honest keyword guess at which tool the message will route to, used // only to make the wait feel like part of the Design/Build/Edit/Learn // workflow instead of a content-free spinner (requested 2026-07-11: // "create a preview for the workflow so it's more interactive when the // actual engineering takes place"). var THINKING_STEPS = { crispr: ["Scanning the target sequence…", "Scoring candidate guides…", "Checking off-target sites…"], primers: ["Reading the insert boundaries…", "Searching primer candidates…", "Checking melting temperatures…"], variant: ["Scoring substitutions with ESM-2…", "Ranking candidate variants…", "Assembling the library…"], general: ["Reading the request…", "Routing across Design → Build → Edit → Learn…", "Running the right tool…"], }; function guessTrack(text) { text = String(text || "").toLowerCase(); if (/crispr|guide ?rna|sgrna|knockout|cas9|cas12|off-target/.test(text)) return "crispr"; if (/primer|pcr|clon|amplicon|\binsert\b|gibson|golden gate/.test(text)) return "primers"; if (/variant|mutation|evolve|library|esm|fitness|saturation|round ?2/.test(text)) return "variant"; return "general"; } // Exact titles of the seedExamples() below — used to retroactively // identify seed chats that predate the seed:true tag (persisted by an // earlier version of this file, before that tag existed). Matched only // when sessionId is still null (see load()): a thread the user // actually typed into, even one that started from a seed template, // already has a real sessionId and must never be silently dropped. var SEED_TITLES = [ "Saturation library — TEM-1 β-lactamase", "CRISPR knockouts — E. coli polA", "Cloning primers — pET-28a insert", "Round 2 from activity assay", ]; var STORE = "td-assistant-chats"; var els = { list: document.getElementById("chatList"), thread: document.getElementById("thread"), inner: document.getElementById("threadInner"), input: document.getElementById("input"), send: document.getElementById("send"), newBtn: document.getElementById("newChat"), topTitle: document.getElementById("topTitle"), topSub: document.getElementById("topSub"), phaseRail: document.getElementById("phaseRail"), hint: document.querySelector(".asst-hint"), topLead: document.getElementById("topLead"), titleBtn: document.getElementById("titleBtn"), historyMenu: document.getElementById("historyMenu"), previewBar: document.getElementById("previewBar"), contextMeter: document.getElementById("contextMeter"), contextFill: document.getElementById("contextFill"), contextPct: document.getElementById("contextPct"), charCount: document.getElementById("charCount"), liveRegion: document.getElementById("liveRegion"), }; // Mirrors _AGENT_MESSAGE_MAX_CHARS in dee/server.py — keep in sync. // The server rejects (400, kind=message_too_long) over this rather // than silently truncating (a real incident: a pasted sequence got // cut mid-strand with zero indication). This is the pre-emptive, // client-side half of that fix. var MESSAGE_MAX_CHARS = 4000; var DEFAULT_HINT = els.hint ? els.hint.innerHTML : ""; var state = { chats: [], activeId: null, busy: false, lockUntil: 0 }; // ── Persistence --------------------------------------------------------- function load() { try { state.chats = JSON.parse(localStorage.getItem(STORE)) || []; } catch (e) { state.chats = []; } if (PREVIEW_MODE) { if (!state.chats.length) { state.chats = seedExamples(); persist(); } return; } // Live: strip seed demo chats already sitting in this browser's // localStorage — they read as real history now, which is // misleading. Two generations: chats tagged seed:true (current // seedExamples()), and older ones persisted before that tag // existed, matched by title + still-null sessionId (see // SEED_TITLES comment — never drops a chat the user has actually // used, even one that started from a seed template). var before = state.chats.length; state.chats = state.chats.filter(function (c) { if (c.seed) return false; if (!c.sessionId && SEED_TITLES.indexOf(c.title) !== -1) return false; return true; }); if (state.chats.length !== before) persist(); } function persist() { try { localStorage.setItem(STORE, JSON.stringify(state.chats)); } catch (e) {} } // Seed a realistic set of conversations so the surface reads as a working // instrument, not an empty shell, in PREVIEW_MODE only. Never shown live. function seedExamples() { var now = Date.now(), hr = 3600e3, dy = 24 * hr; return [ { id: uid(), sessionId: null, seed: true, phase: "Design", updatedAt: now - 40 * 60e3, title: "Saturation library — TEM-1 β-lactamase", messages: [ { role: "user", text: "Design a saturation-mutagenesis library across the TEM-1 β-lactamase active site (residues 68–72, 235–240)." }, { role: "assistant", text: "I scored every substitution at those positions with ESM-2 and ranked a focused library. Prioritising variants with a favourable predicted ΔLL keeps the plate size down while covering the catalytic pocket." }, { role: "tool", tool: { name: "design_library", status: "done", rows: [["variants ranked", "380"], ["positions", "10"], ["top ΔLL", "+4.7"], ["est. plate", "1 × 384"]] } }, { role: "assistant", text: "Top candidates are ready to open in the Plasmid Editor, or I can move straight to primers for the winners. Want to proceed to Build?" }, ] }, { id: uid(), sessionId: null, seed: true, phase: "Edit", updatedAt: now - 5 * hr, title: "CRISPR knockouts — E. coli polA", messages: [ { role: "user", text: "Find high-specificity guides to knock out polA in E. coli K-12." }, { role: "assistant", text: "I designed guides against polA and screened each for genome-wide off-targets by CFD. Three clear the specificity bar with cut sites early in the ORF." }, { role: "tool", tool: { name: "design_crispr", status: "done", rows: [["guides", "24"], ["pass specificity", "3"], ["best on-target", "0.82"], ["min off-target", "0.02"]] } }, ] }, { id: uid(), sessionId: null, seed: true, phase: "Build", updatedAt: now - 1 * dy - 2 * hr, title: "Cloning primers — pET-28a insert", messages: [ { role: "user", text: "Design Gibson primers to drop my insert into pET-28a at NdeI/XhoI." }, { role: "assistant", text: "Here are balanced Gibson primers with matched Tm and clean 3′ ends, plus the overlap arms for the pET-28a backbone." }, { role: "tool", tool: { name: "design_primers", status: "done", rows: [["primer pairs", "2"], ["mean Tm", "61.4 °C"], ["overlap", "22 nt"]] } }, ] }, { id: uid(), sessionId: null, seed: true, phase: "Learn", updatedAt: now - 3 * dy, title: "Round 2 from activity assay", messages: [ { role: "user", text: "I logged activity for 44 variants — propose Round 2." }, { role: "assistant", text: "I fit the surrogate to your 44 outcomes and proposed a Round-2 batch that trades off exploitation of the current best against exploring under-sampled positions." }, { role: "tool", tool: { name: "propose_round2", status: "done", rows: [["fit R²", "0.71"], ["proposed", "48"], ["new positions", "6"]] } }, ] }, ]; } // ── Rendering: history dropdown ------------------------------------- function relTime(ts) { var d = Date.now() - ts, m = 60e3, h = 60 * m, day = 24 * h; if (d < m) return "just now"; if (d < h) return Math.floor(d / m) + "m ago"; if (d < day) return Math.floor(d / h) + "h ago"; if (d < 7 * day) return Math.floor(d / day) + "d ago"; return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" }); } function groupOf(ts) { var d = Date.now() - ts, day = 24 * 3600e3; if (d < day) return "Today"; if (d < 2 * day) return "Yesterday"; if (d < 7 * day) return "Previous 7 days"; return "Earlier"; } function renderList() { var sorted = state.chats.slice().sort(function (a, b) { return b.updatedAt - a.updatedAt; }); var html = "", lastGroup = null; sorted.forEach(function (c) { var g = groupOf(c.updatedAt); if (g !== lastGroup) { html += '
' + g + "
"; lastGroup = g; } html += '
' + '
' + esc(c.title) + "
" + '
' + esc(phaseTag(c.phase)) + " · " + relTime(c.updatedAt) + "
" + '" + "
"; }); els.list.innerHTML = html || '
No conversations yet
'; } function phaseTag(p) { return "§0" + ({ Design: 1, Build: 2, Edit: 3, Learn: 4 }[p] || 1) + " " + (p || "Design"); } // ── Rendering: thread --------------------------------------------------- function active() { return state.chats.find(function (c) { return c.id === state.activeId; }) || null; } function renderThread() { var c = active(); setPhase(c ? c.phase : "Design"); els.topTitle.textContent = c ? c.title : "New conversation"; els.topSub.textContent = c && c.sessionId ? "session " + c.sessionId.slice(0, 8) + " · the engine runs your tools" : "Turing · the engine runs your tools"; renderContextMeter(c); if (!c || !c.messages.length) { els.inner.innerHTML = emptyState(); return; } var html = ""; c.messages.forEach(function (m) { if (m.role === "user") { html += '
' + formatMessageText(m.text) + "
"; } else if (m.role === "assistant") { html += '
TuringDNA
' + '
' + formatMessageText(m.text) + "
"; } else if (m.role === "tool") { html += toolCard(m.tool); } else if (m.role === "limit") { html += '
' + '
' + esc(m.text) + "
" + '
Upgrade to Pro for a higher daily limit' + (m.resets_in_s ? " · resets in " + Math.max(1, Math.ceil(m.resets_in_s / 3600)) + " h" : "") + ".
" + 'Upgrade to Pro' + "
"; } else if (m.role === "signin") { // Anonymous trial window expired — distinct from "limit" // above (no Pro upsell, just "make a free account"). html += '
' + '
' + esc(m.text) + "
" + 'Sign in' + "
"; } else if (m.role === "system") { html += '
' + esc(m.text) + "
"; } }); els.inner.innerHTML = html; // Re-append the thinking indicator if THIS chat is the one a // request is actually in flight for — see the long comment on // _thinkTrack below for why this render (not just the one // showThinking() itself triggers) needs to know about it. if (state.busy && _thinkTrack && c.id === _thinkChatId) appendThinkingNode(); scrollBottom(); } function toolCard(t) { if (t.status === "signin_required") { return '
' + '
' + esc(t.name) + "" + '
' + '
' + '
' + esc(t.signin_text || "This requires a free account.") + "
" + 'Sign in' + "
" + "
"; } var rows = (t.rows || []).map(function (r) { return '
' + esc(r[0]) + '' + esc(r[1]) + "
"; }).join(""); var items = (t.items || []).map(function (it) { return '
' + esc(it[0]) + '' + esc(it[1]) + "
"; }).join(""); // Real, orderable sequences (e.g. DE variant DNA) — each gets its // own labeled seqBlock (collapse/expand + Copy + Download), not // just a text summary row. Kept separate from `items` rather than // overloading that plain 2-column format with something that needs // its own interactive controls. var seqItems = (t.seqItems || []).map(function (si) { return '
' + esc(si.label) + "
" + seqBlock(si.seq, { label: si.label, highlightRanges: si.highlightRanges }) + "
"; }).join(""); var failed = t.status === "failed"; var body = rows || items || seqItems ? (rows + (items ? '
' + items + "
" : "") + seqItems) : esc(JSON.stringify(t.result || {})); return '
' + '
' + esc(t.name) + "" + '' + esc(t.status || "done") + "
" + '
' + body + "
" + "
"; } // Turns a raw tool_result (dee/core/agent_tools.py's JSON return value) // into the {name, status, rows, items} shape toolCard() renders — a // short stats summary plus a preview of what the tool actually // produced, instead of a JSON.stringify dump. Unknown tool names or // unrecognized result shapes fall back to that dump (toolCard's own // fallback), so a new tool added later without a matching case here // still renders something, just not richly. Raw snake_case names // (design_crispr_guides, etc.) are used as-is for the card title — // matches the seed demo conversations' own naming (design_library, // design_crispr, ...) and the monospace "function call" styling. function shapeToolResult(name, result) { result = result || {}; var label = name || "tool"; if (result.kind === "signin_required") { // A gated tool call from an anonymous session (agent_tools.py's // execute_tool, requires_signin check) — distinct from a real // failure. Falling through to the generic ok===false case below // used to render this as a plain red error row with no way to // act on it; give it the same clickable-CTA treatment as the // route-level trial-expiry signin card (role: "signin" above) // instead (found 2026-07-12, more visible now Turing is the // default entry point and anonymous visitors hit it more). return { name: label, status: "signin_required", result: result, signin_url: "https://turingdna.com/signin/?from=assistant", signin_text: result.error || "This requires a free account." }; } if (result.ok === false) { return { name: label, status: "failed", result: result, rows: [["error", result.error || "unknown error"]] }; } if (name === "recommend_promoter") { var proms = result.promoters || []; return { name: label, status: "done", result: result, rows: [["host", String(result.host || "?")], ["promoters found", String(proms.length)]], items: proms.map(function (p) { return [p.name || "?", p.tier || "?"]; }) }; } if (name === "fetch_sequence") { // No `items` here on purpose — the resolved sequence itself // (which can be thousands of nt) goes back to the model to // feed the next tool call, not re-rendered raw a second time // in this card. Keeps the card a scannable summary instead of // risking the same "wall of text" the sequence-collapse fix // (2026-07-11) exists to prevent. return { name: label, status: "done", result: result, rows: [["resolved", String(result.label || result.kind || "?")], ["length", result.length != null ? result.length.toLocaleString() + " bp" : "?"], ["source", String(result.source || "?")]] }; } if (name === "design_crispr_guides") { var guides = result.guides || []; var crisprRows = [["guides found", String(result.n_guides != null ? result.n_guides : guides.length)], ["enzyme", String(result.enzyme || "cas9")]]; // Only shown once a rebuild has actually populated the cross-user // aggregate for this tool — 0/absent means no blend happened, // not an error (see load_cached_tool_prior in outcomes.py). if (result.field_prior_keys) crisprRows.push(["field priors applied", result.field_prior_keys + " substitution types"]); return { name: label, status: "done", result: result, rows: crisprRows, items: guides.slice(0, 5).map(function (g) { return [g.spacer || "?", "score " + (g.composite_score != null ? g.composite_score.toFixed(2) : "?")]; }) }; } if (name === "design_primers") { var pairs = result.pairs || []; return { name: label, status: "done", result: result, rows: [["pairs found", String(pairs.length)], ["forward candidates", String(result.n_forward != null ? result.n_forward : "?")], ["reverse candidates", String(result.n_reverse != null ? result.n_reverse : "?")]], items: pairs.slice(0, 5).map(function (p) { return [(p.forward_seq || "?") + " / " + (p.reverse_seq || "?"), (p.product_size != null ? p.product_size + " bp" : "?")]; }) }; } if (name === "design_variant_library") { var variants = result.variants || []; var deRows = [["positions scored", String(result.n_scored_positions != null ? result.n_scored_positions : "?")], ["variants proposed", String(variants.length)], ["model", String(result.model || "small")]]; // Same caveat as the CRISPR card above — 0/absent just means no // rebuild has populated public.mutation_priors yet, not a failure. if (result.field_prior_substitutions) deRows.push(["field priors applied", result.field_prior_substitutions + " substitution types"]); return { name: label, status: "done", result: result, rows: deRows, // Each variant's actual DNA (added 2026-07-12 — this tool // used to stop at mutation labels like "W44K" with nothing // to copy/paste or order). Falls back to the old plain-text // summary if a variant somehow has no `dna` (best-effort // encoding failed for that one — see agent_tools.py). seqItems: variants.slice(0, 5).filter(function (v) { return v.dna; }).map(function (v) { var muts = (v.mutations || []).join(", ") || "WT"; // mutated_positions are 0-indexed AMINO ACID positions // (agent_tools.py) — codon i is nt [i*3, i*3+3), an // exact mapping since reverse_translate() is strict // 1:1 codon-per-residue with no leading offset. var ranges = (v.mutated_positions || []).map(function (p) { return [p * 3, p * 3 + 3]; }); return { label: muts + " · fitness " + (v.fitness != null ? v.fitness : "?") + (v.length_bp != null ? " · " + v.length_bp.toLocaleString() + " bp" : ""), seq: v.dna, highlightRanges: ranges }; }), items: variants.slice(0, 5).some(function (v) { return v.dna; }) ? [] : variants.slice(0, 5).map(function (v) { return [(v.mutations || []).join(", ") || "?", "fitness " + (v.fitness != null ? v.fitness : "?")]; }) }; } // Unrecognized tool/shape — toolCard falls back to JSON.stringify. return { name: label, status: "done", result: result }; } function emptyState() { var chips = SUGGESTIONS.map(function (s, i) { return '"; }).join(""); return '
' + "

What are we engineering today?

" + "

Talk to the engine in plain language. It routes across the Design → Build → Edit → Learn loop and runs the right tools — no menus to hunt through.

" + '
' + chips + "
" + "
"; } function setPhase(p) { [].forEach.call(els.phaseRail.children, function (el) { el.classList.toggle("on", el.dataset.phase === p); }); } // Hidden until a chat has an actual token count (i.e. after its first // real reply — a fresh/empty conversation has nothing to show yet). // Thresholds are conservative on purpose: a long tool-result message // gets bundled into the NEXT request's prompt too, so usage can jump // between renders — better to warn a little early than not at all. function renderContextMeter(c) { var limit = c && c.contextTokensLimit; if (!limit) { els.contextMeter.hidden = true; return; } var used = c.contextTokensUsed || 0; var pct = Math.min(100, Math.round((used / limit) * 100)); els.contextMeter.hidden = false; els.contextMeter.classList.toggle("warn", pct >= 70 && pct < 90); els.contextMeter.classList.toggle("crit", pct >= 90); els.contextFill.style.width = pct + "%"; els.contextPct.textContent = pct + "%"; } // ── Interaction --------------------------------------------------------- // ── Conversation history (popover anchored under the title) ------------- // Recent chats used to live in a permanent left rail — a second sidebar // right next to the engine's own tool nav. Now they're a compact dropdown: // one nav rail total, and switching chats is a click on the title away. function openHistory() { if (!els.topLead) return; els.topLead.classList.add("open"); if (els.titleBtn) els.titleBtn.setAttribute("aria-expanded", "true"); } function closeHistory() { if (!els.topLead) return; els.topLead.classList.remove("open"); if (els.titleBtn) els.titleBtn.setAttribute("aria-expanded", "false"); } function toggleHistory() { if (els.topLead && els.topLead.classList.contains("open")) closeHistory(); else openHistory(); } // Attaches the signed-in user's Authorization header to /api/agent/* // calls. assistant.html is a standalone document in its own iframe — // it does NOT load auth.js (which wraps window.fetch globally site- // wide), deliberately: auth.js also wires a full-page trial-timer // modal + top-window redirect-on-402, which would fight this panel's // own inline "Sign in" card. Reads the SAME sessionStorage key auth.js // stashes the Supabase access token under — sessionStorage is shared // across same-origin frames in one tab, so a token the top-level page // already stashed is visible here without any extra plumbing. Without // this, every Turing request looked anonymous to the server even for // a signed-in user (confirmed bug, 2026-07-10). function _authToken() { try { return sessionStorage.getItem("td_access_token") || null; } catch (e) { return null; } } function _apiFetch(url, opts) { opts = opts || {}; var token = _authToken(); if (token) { var headers = new Headers(opts.headers || {}); if (!headers.has("Authorization")) headers.set("Authorization", "Bearer " + token); opts = Object.assign({}, opts, { headers: headers }); } return fetch(url, opts); } // In-flight session-creation promises, keyed by chat id — never persisted // (a chat can be reopened seconds after a page reload, well past any // fetch's lifetime, and Promises don't survive JSON.stringify anyway). // Session creation always funnels through here so a fast typist hitting // send() right after newChat() shares the same in-flight request instead // of opening two sessions for one conversation. var sessionPromises = {}; function ensureSession(c) { if (c.sessionId) return Promise.resolve(c.sessionId); if (!sessionPromises[c.id]) { sessionPromises[c.id] = _apiFetch("/api/agent/session", { method: "POST" }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (j) { if (j && j.session_id) { c.sessionId = j.session_id; c.phase = j.phase || c.phase; persist(); if (state.activeId === c.id) renderThread(); } return c.sessionId || ""; }) .catch(function () { return c.sessionId || ""; }) .then(function (sid) { delete sessionPromises[c.id]; return sid; }); } return sessionPromises[c.id]; } function newChat() { var c = { id: uid(), sessionId: null, phase: "Design", updatedAt: Date.now(), title: "New conversation", messages: [] }; state.chats.unshift(c); state.activeId = c.id; persist(); renderList(); renderThread(); scrollBottom(true); // always jump to the bottom of a freshly created (empty) chat closeHistory(); els.input.focus(); if (PREVIEW_MODE) return; // no backend calls in preview // Best-effort: open a real agent session ahead of the first send. // Failure is silent — the UI still works and send() will retry via // ensureSession, surfacing a clear notice only if that also fails. ensureSession(c); } function selectChat(id) { state.activeId = id; renderList(); renderThread(); scrollBottom(true); closeHistory(); } function deleteChat(id) { // No undo, no trash — this is permanent the moment it's clicked. // The rest of the engine confirms destructive actions (e.g. the // "stop the engine server" control); a stray misclick here // shouldn't be the one place that silently isn't. var c = state.chats.find(function (x) { return x.id === id; }); var label = c ? ("“" + c.title + "”") : "this conversation"; if (!window.confirm("Delete " + label + "? This can't be undone.")) return; state.chats = state.chats.filter(function (x) { return x.id !== id; }); if (state.activeId === id) state.activeId = state.chats.length ? state.chats[0].id : null; persist(); renderList(); renderThread(); } function send() { if (PREVIEW_MODE) return; // composer disabled in preview — never reaches the backend var text = els.input.value.trim(); if (!text || state.busy) return; if (text.length > MESSAGE_MAX_CHARS) return; // send button is already disabled for this; belt-and-suspenders if (!state.activeId) newChat(); var c = active(); c.messages.push({ role: "user", text: text }); if (c.title === "New conversation") c.title = text.length > 52 ? text.slice(0, 52) + "…" : text; c.updatedAt = Date.now(); els.input.value = ""; autoGrow(); persist(); renderList(); renderThread(); state.busy = true; els.send.disabled = true; showThinking(text); // Set only on a successful text reply — {chat, index, text}. Pushed // with empty text below, then animated in via revealText() once the // render at the end of this chain creates its DOM node, so the // reply appears to type rather than pop in all at once. var pendingReply = null; function stepOnce(sessionId) { return _apiFetch("/api/agent/step", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ session_id: sessionId, message: text }), }).then(function (r) { var retry = parseInt(r.headers.get("Retry-After") || "", 10); return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok, status: r.status, retry: retry, j: j }; }); }); } ensureSession(c) .then(function (sessionId) { return stepOnce(sessionId); }) .then(function (res) { if (res.j && res.j.kind === "session_expired") { // Server memory (not a database) lost this session, most // likely a deploy restarted the worker since it was // created — invisible to the user. Recover transparently: // fresh session, retry this exact message once. If the // retry ALSO comes back session_expired, something else is // wrong and it falls through to the generic error branch // below rather than retrying forever. c.sessionId = null; return ensureSession(c).then(function (sid2) { return stepOnce(sid2); }); } return res; }) .then(function (res) { var j = res.j || {}; if (res.status === 429 || j.kind === "rate_limited") { // Server-side rate limit — pace the user, never error out. var secs = res.retry > 0 ? res.retry : 30; c.messages.push({ role: "system", text: "Usage limit reached. Try again in about " + secs + " s." }); lockComposer(secs); } else if (j.kind === "chat_limit") { // Daily message budget exhausted (credit system) — offer Pro. c.messages.push({ role: "limit", text: j.credits_limit ? ("You've used today's " + j.credits_limit + " assistant messages.") : "You've reached today's assistant message limit.", resets_in_s: j.resets_in_s }); } else if (res.status === 402 || j.kind === "auth_required") { // Anonymous trial window expired (dee/auth.py's time-based // gate) — a real, expected state, not a backend hiccup, so // it gets its own honest message + direct sign-in link // instead of falling into the generic error bubble below. c.messages.push({ role: "signin", text: j.error || "Your free trial has ended. Sign in to keep going.", signin_url: j.signup_url || "https://turingdna.com/signin/?from=assistant" }); } else if (j.kind === "agent_unavailable") { // Model backend isn't reachable right now — missing config // or the upstream account is out of credits. Deliberately // a fixed, calm message rather than j.error: never surface // "insufficient credits"/billing detail to end users, and // this isn't a "retry in a second" hiccup like the generic // branch below implies. c.messages.push({ role: "system", text: "Turing is still in active development and will be back shortly." }); } else if (j.kind === "message_too_long") { // Belt-and-suspenders — the composer already blocks sending // past MESSAGE_MAX_CHARS, so this only fires if that check // was somehow bypassed (stale cached JS, etc.). Server's own // message already explains it clearly. c.messages.push({ role: "system", text: j.error }); } else if (!res.ok || j.error) { c.messages.push({ role: "system", text: "Turing hit a snag reaching the model — try again in a moment. (" + (j.error || "unavailable") + ")" }); } else { if (j.phase) c.phase = j.phase; if (j.context_tokens_limit) { c.contextTokensUsed = j.context_tokens_used || 0; c.contextTokensLimit = j.context_tokens_limit; } if (j.tool_result) c.messages.push({ role: "tool", tool: shapeToolResult(j.tool_name, j.tool_result) }); pendingReply = { chat: c, index: c.messages.length, text: j.assistant_message || "(no reply)" }; c.messages.push({ role: "assistant", text: "" }); } }) .catch(function () { c.messages.push({ role: "system", text: "Couldn't reach the assistant backend — check your connection and try again." }); }) .then(function () { stopThinking(); // clear the caption cycle before this node is torn down below state.busy = false; if (!composerLocked()) els.send.disabled = els.input.value.trim() === ""; c.updatedAt = Date.now(); persist(); renderList(); renderThread(); if (!pendingReply) return; var bodies = els.inner.querySelectorAll(".asst-turn.assistant .asst-body"); var node = bodies[bodies.length - 1]; var finish = function () { pendingReply.chat.messages[pendingReply.index].text = pendingReply.text; persist(); // Announced once, on completion — not wired into the // thread's own render (see the liveRegion comment in // assistant.html for why). Skipped if the user has since // switched to a different chat; announcing a reply to a // conversation they're not even looking at would be // confusing, not helpful. if (els.liveRegion && state.activeId === pendingReply.chat.id) { els.liveRegion.textContent = "TuringDNA: " + pendingReply.text; } }; // No node means the user already switched away from this chat // before the render above ran — just commit the text silently // so it's there whenever they come back; nothing to animate. if (node) revealText(node, pendingReply.text).then(finish); else finish(); }); } // Reveals `fullText` into `el` progressively instead of setting it // instantly — the Claude/ChatGPT-style "typing" effect. Re-runs // formatMessageText() on each growing prefix of the PLAIN text (not a // substring of the final HTML) each tick, so paragraphs/inline-code/ // fences still format correctly mid-reveal instead of flashing a // half-open tag. An in-progress, not-yet-closed fence briefly shows its // raw backticks until the closing ``` arrives a few ticks later — same // class of transient artifact as a half-typed inline-code span, and // self-corrects on its own. function revealText(el, fullText) { var CHARS_PER_TICK = 3, TICK_MS = 15; var i = 0; return new Promise(function (resolve) { (function tick() { i = Math.min(fullText.length, i + CHARS_PER_TICK); el.innerHTML = formatMessageText(fullText.slice(0, i)); scrollBottom(); if (i < fullText.length) setTimeout(tick, TICK_MS); else resolve(); })(); }); } // _thinkTrack/_thinkChatId are read by renderThread() itself (see the // "state.busy" check near its end) so the indicator survives ANY // render that happens to fire mid-flight — not just the one showThinking() // triggers. That mid-flight re-render is real, not hypothetical: // ensureSession()'s own success callback calls renderThread() the // moment a fresh session is created, which for a brand-new conversation // resolves fast (a lightweight POST, no LLM involved) — well before the // actual /api/agent/step reply comes back. Before this fix, that second // render wiped the freshly-appended thinking node straight back out, // leaving a dead, silent gap for however long the real call took — // exactly the "no preview" experience reported 2026-07-11. _thinkChatId // scopes this to the chat that's actually busy: state.busy is a single // global flag (the composer already blocks a second concurrent send), // but the user can still switch to a DIFFERENT, idle chat mid-flight — // that chat's render must NOT get a thinking bubble it never asked for. var _thinkTrack = null, _thinkChatId = null, _thinkStep = 0, _thinkTimer = null; function stopThinking() { if (_thinkTimer) { clearInterval(_thinkTimer); _thinkTimer = null; } _thinkTrack = null; _thinkChatId = null; _thinkStep = 0; if (els.phaseRail) els.phaseRail.classList.remove("busy"); } function appendThinkingNode() { var steps = THINKING_STEPS[_thinkTrack]; var node = document.createElement("div"); node.className = "asst-turn assistant"; node.innerHTML = '
TuringDNA
' + '
' + '' + '' + esc(steps[_thinkStep % steps.length]) + "" + "
"; els.inner.appendChild(node); } function showThinking(userText) { stopThinking(); // clear any previous cycle before this one starts var c = active(); if (!c) return; _thinkTrack = guessTrack(userText); _thinkChatId = c.id; if (els.phaseRail) els.phaseRail.classList.add("busy"); // pulses the current phase pill renderThread(); // state.busy is already true here — appends the node itself scrollBottom(); _thinkTimer = setInterval(function () { _thinkStep++; // Fresh lookup every tick instead of closing over the node // created above — a mid-flight renderThread() (see comment // above) creates a NEW node each time, and a stale closure // reference would silently keep animating a detached one. var cap = els.inner.querySelector(".asst-think-caption"); if (!cap || !_thinkTrack) { stopThinking(); return; } cap.textContent = THINKING_STEPS[_thinkTrack][_thinkStep % THINKING_STEPS[_thinkTrack].length]; }, 1400); } // ── Composer plumbing --------------------------------------------------- function autoGrow() { els.input.style.height = "auto"; els.input.style.height = Math.min(els.input.scrollHeight, 180) + "px"; var len = els.input.value.length; var overLimit = len > MESSAGE_MAX_CHARS; els.send.disabled = els.input.value.trim() === "" || state.busy || composerLocked() || overLimit; // Only shown once it's actually relevant — no point cluttering the // composer under a normal-length message. if (els.charCount) { var showAt = MESSAGE_MAX_CHARS - 500; if (len < showAt) { els.charCount.hidden = true; } else { els.charCount.hidden = false; els.charCount.classList.toggle("over", overLimit); els.charCount.classList.toggle("warn", !overLimit && len >= MESSAGE_MAX_CHARS - 200); els.charCount.textContent = overLimit ? (len - MESSAGE_MAX_CHARS).toLocaleString() + " over the " + MESSAGE_MAX_CHARS.toLocaleString() + "-character limit — trim before sending" : len.toLocaleString() + " / " + MESSAGE_MAX_CHARS.toLocaleString(); } } } // Unforced calls only scroll if the user was already near the bottom — // every render used to jump straight to the bottom unconditionally, // which yanks a user who scrolled up mid-conversation to reread // something back down the instant anything re-renders (every tick of // the typewriter reveal included). force=true is for the moments that // really should always jump (switching to a different chat, starting // a new one) — checked AFTER any DOM update already happened is fine, // since scrollTop doesn't move on its own when content is appended. function scrollBottom(force) { var el = els.thread; var nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80; if (force || nearBottom) el.scrollTop = el.scrollHeight; } // ── Rate-limit composer lock: disable input for `secs`, counting down in // the hint line, then restore. Honors the server's Retry-After. function composerLocked() { return Date.now() < (state.lockUntil || 0); } function lockComposer(secs) { state.lockUntil = Date.now() + secs * 1000; els.input.disabled = true; els.send.disabled = true; (function tick() { var left = Math.ceil((state.lockUntil - Date.now()) / 1000); if (left <= 0) { els.input.disabled = false; if (els.hint) els.hint.innerHTML = DEFAULT_HINT; autoGrow(); return; } if (els.hint) els.hint.textContent = "Usage limit · resuming in " + left + " s"; setTimeout(tick, 500); })(); } // ── Helpers ------------------------------------------------------------- function uid() { return "c_" + Math.random().toString(36).slice(2, 10); } function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (ch) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]; }); } // Light markdown: paragraphs, `inline code`, **bold**, *italic*, // '# '/'## ' headings, and '* '/'- ' bullet lists. Content is escaped // first, so this only ever turns OUR inserted tags into HTML — model // text can never inject markup. The model routinely emits real // markdown (and occasionally a LaTeX arrow macro) even though this // panel has no markdown/LaTeX renderer, so unhandled syntax used to // leak as literal characters — "### ", "**", "$\rightarrow$" all shown // raw (reported 2026-07-11, same report as the sequence-formatting // bug). System prompt now also tells the model to stick to this // subset; this is the client-side safety net for whatever still slips // through. function fmt(s) { s = esc(s).replace(/\$\\rightarrow\$|\\rightarrow/g, "→"); var lines = s.split("\n"); var html = "", para = [], list = []; function flushPara() { if (para.length) { html += "

" + para.join("
") + "

"; para = []; } } function flushList() { if (list.length) { html += ""; list = []; } } function inline(t) { return t.replace(/`([^`]+)`/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\*([^*\n]+)\*/g, "$1"); } lines.forEach(function (line) { var h = line.match(/^(#{1,4})\s+(.*)$/); var b = line.match(/^[*-]\s+(.*)$/); if (h) { flushPara(); flushList(); html += '

' + inline(h[2]) + "

"; } else if (b) { flushPara(); list.push("
  • " + inline(b[1]) + "
  • "); } else if (line.trim() === "") { flushPara(); flushList(); } else { flushList(); para.push(inline(line)); } }); flushPara(); flushList(); return html; } // A long unbroken run of letters (a pasted or generated DNA/RNA/protein // sequence — no real English word runs 60+ characters with no // whitespace) gets pulled out and rendered as a collapsed, expandable // block instead of wrapping raw inline with the rest of the message. // Without this, a sequence + surrounding prose read as one unreadable // wall of text (reported 2026-07-11). Applied to BOTH user and // assistant text — the model routinely answers with a full DNA // sequence per variant, not just short summary fields. var SEQ_RE = /[A-Za-z*]{60,}/g; // The model also wraps sequences in fenced code blocks (```text\n...\n```). // fmt() only understands single-backtick inline code, so a fence used to // leak its backtick markers as literal text AND, worse, dump the whole // sequence into one unbroken inline with no wrap rule — forcing // the message wide and requiring horizontal scroll (reported // 2026-07-11, screenshot showed both symptoms together). Fences are // parsed out first; a long sequence inside one collapses exactly like a // pasted sequence, anything shorter renders as a normal wrapped block. var FENCE_RE = /```[a-zA-Z0-9_-]*\n?([\s\S]*?)```/g; var _seqStore = {}; // seq element id -> full sequence text, for the toggle handler var _seqIdCounter = 0; function formatMessageText(s) { s = String(s == null ? "" : s); FENCE_RE.lastIndex = 0; if (!FENCE_RE.test(s)) return formatProse(s); // common case — no fence, skip the split work FENCE_RE.lastIndex = 0; var html = "", last = 0, m; while ((m = FENCE_RE.exec(s))) { if (m.index > last) html += formatProse(s.slice(last, m.index)); html += fencedBlock(m[1]); last = m.index + m[0].length; } if (last < s.length) html += formatProse(s.slice(last)); return html; } function fencedBlock(raw) { var trimmed = raw.replace(/\n$/, ""); var collapsed = trimmed.replace(/\s+/g, ""); if (collapsed.length >= 60 && /^[A-Za-z*]+$/.test(collapsed)) return seqBlock(collapsed); return '
    ' + esc(trimmed) + "
    "; } // A paste where every line is itself sequence-looking is conventional // FASTA-style line wrapping (typically 60-80 chars/line) — ONE sequence // that happens to be pre-wrapped, not N separate ones. SEQ_RE alone // can't see this: a character class never matches across the newline // between lines, so each wrapped line tripped its own seqBlock — a // routine multi-line paste rendered as a tall stack of near-identical // "Show full" rows (reported 2026-07-12). Joining same-paragraph // sequence lines back together BEFORE the scan below fixes it at the // source: the merged text has one long unbroken run, so it becomes one // block. The wrap was a paste-time artifact, not part of the sequence, // so dropping it also makes the "Show full" text a clean, continuous, // actually-copyable sequence instead of one with embedded line breaks. var SEQ_LINE_RE = /^[A-Za-z*]{20,}$/; function mergeWrappedSequenceParagraphs(s) { // Scans every line (not per-paragraph — a paragraph containing one // leading prose line + the wrapped sequence would otherwise fail an // "every line qualifies" check and never merge at all). Consecutive // sequence-looking lines accumulate into one run and get joined with // no separator when the run ends (a non-sequence line, a blank // line, or the end of input); everything else passes through // untouched, so surrounding prose and genuine paragraph breaks // between two SEPARATE pastes are both preserved. var lines = s.split("\n"); var out = []; var run = []; function flushRun() { if (run.length >= 2) out.push(run.join("")); else if (run.length === 1) out.push(run[0]); run = []; } lines.forEach(function (line) { if (SEQ_LINE_RE.test(line.trim())) { run.push(line.trim()); } else { flushRun(); out.push(line); } }); flushRun(); return out.join("\n"); } function formatProse(s) { s = mergeWrappedSequenceParagraphs(s); SEQ_RE.lastIndex = 0; if (!SEQ_RE.test(s)) return fmt(s); // common case — no bare sequence, skip the split work SEQ_RE.lastIndex = 0; var html = "", last = 0, m; while ((m = SEQ_RE.exec(s))) { if (m.index > last) html += fmt(s.slice(last, m.index)); html += seqBlock(m[0]); last = m.index + m[0].length; } if (last < s.length) html += fmt(s.slice(last)); return html; } function seqPreview(full) { return full.length > 60 ? full.slice(0, 36) + "…" + full.slice(-10) : full; } // Renders `seq` as escaped HTML with `ranges` ([start,end) pairs, 0- // indexed, half-open) wrapped in a highlight span. Ranges are the // MUTATED CODON positions (see mutated_positions in agent_tools.py) — // never a naive diff against a WT string, which would also catch // unrelated silent codon differences from independent restriction-site // scrubbing and mislabel them as "the mutation." function seqHtmlWithHighlights(seq, ranges) { if (!ranges || !ranges.length) return esc(seq); var sorted = ranges.slice().sort(function (a, b) { return a[0] - b[0]; }); var html = "", last = 0; sorted.forEach(function (r) { var start = Math.max(last, r[0]), end = Math.min(seq.length, r[1]); if (start >= end) return; if (start > last) html += esc(seq.slice(last, start)); html += '' + esc(seq.slice(start, end)) + ""; last = end; }); if (last < seq.length) html += esc(seq.slice(last)); return html; } // Client-side only — the sequence text is already in the browser, no // server round-trip needed. Mirrors the Blob-download pattern already // used elsewhere in the engine (app.js's export/save flows). function downloadFasta(label, seq) { var header = String(label || "sequence").replace(/\s+/g, "_").slice(0, 80); var body = ">" + header + "\n" + seq.replace(/(.{70})/g, "$1\n").replace(/\n$/, "") + "\n"; var blob = new Blob([body], { type: "text/x-fasta" }); var url = URL.createObjectURL(blob); var a = document.createElement("a"); a.href = url; a.download = header.replace(/[^A-Za-z0-9_.-]/g, "_") + ".fasta"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function () { URL.revokeObjectURL(url); }, 1000); } // opts.label becomes the FASTA header + suggested filename for the // download button — e.g. a DE variant passes its mutation list so the // downloaded file is self-describing, not "sequence.fasta" x N when a // reply has several variants. // opts.highlightRanges marks changed codons red (see // seqHtmlWithHighlights). A block with highlights starts EXPANDED — // showing red text inside a collapsed, truncated preview would usually // just hide the very thing being pointed out, since the mutated codon // is rarely within the first-36/last-10-char snippet. function seqBlock(seq, opts) { opts = opts || {}; var id = "seq" + (_seqIdCounter++); var ranges = opts.highlightRanges || null; _seqStore[id] = { seq: seq, label: opts.label || "sequence", ranges: ranges }; var startExpanded = !!(ranges && ranges.length); var previewHtml = startExpanded ? seqHtmlWithHighlights(seq, ranges) : esc(seqPreview(seq)); return '
    ' + '' + previewHtml + "" + '' + seq.length.toLocaleString() + " chars" + '' + '' + '" + "
    "; } // ── Wire events --------------------------------------------------------- els.newBtn.addEventListener("click", newChat); els.send.addEventListener("click", send); els.input.addEventListener("input", autoGrow); els.input.addEventListener("keydown", function (e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }); els.list.addEventListener("click", function (e) { var del = e.target.closest("[data-del]"); if (del) { e.stopPropagation(); deleteChat(del.getAttribute("data-del")); return; } var row = e.target.closest("[data-id]"); if (row) selectChat(row.getAttribute("data-id")); }); // .asst-chat rows are role="button" tabindex="0" but plain
    s — a // real