Spaces:
Running
Running
| /* 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 += '<div class="asst-group-label">' + g + "</div>"; lastGroup = g; } | |
| html += '<div class="asst-chat' + (c.id === state.activeId ? " active" : "") + '" data-id="' + c.id + '" role="button" tabindex="0">' + | |
| '<div class="asst-chat-title">' + esc(c.title) + "</div>" + | |
| '<div class="asst-chat-meta">' + esc(phaseTag(c.phase)) + " Β· " + relTime(c.updatedAt) + "</div>" + | |
| '<button class="asst-chat-del" data-del="' + c.id + '" aria-label="Delete conversation" title="Delete">' + | |
| '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M9 7V5h6v2M7 7l1 12h8l1-12"/></svg>' + | |
| "</button>" + | |
| "</div>"; | |
| }); | |
| els.list.innerHTML = html || '<div class="asst-group-label">No conversations yet</div>'; | |
| } | |
| 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 += '<div class="asst-turn user"><div class="asst-bubble">' + formatMessageText(m.text) + "</div></div>"; | |
| } else if (m.role === "assistant") { | |
| html += '<div class="asst-turn assistant"><div class="asst-role"><span class="dot"></span>TuringDNA</div>' + | |
| '<div class="asst-body">' + formatMessageText(m.text) + "</div></div>"; | |
| } else if (m.role === "tool") { | |
| html += toolCard(m.tool); | |
| } else if (m.role === "limit") { | |
| html += '<div class="asst-turn"><div class="asst-limit">' + | |
| '<div class="asst-limit-t">' + esc(m.text) + "</div>" + | |
| '<div class="asst-limit-s">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" : "") + ".</div>" + | |
| '<a class="asst-limit-cta" href="https://turingdna.com/signup/?from=assistant">Upgrade to Pro</a>' + | |
| "</div></div>"; | |
| } else if (m.role === "signin") { | |
| // Anonymous trial window expired β distinct from "limit" | |
| // above (no Pro upsell, just "make a free account"). | |
| html += '<div class="asst-turn"><div class="asst-limit">' + | |
| '<div class="asst-limit-t">' + esc(m.text) + "</div>" + | |
| '<a class="asst-limit-cta" href="' + esc(m.signin_url) + '">Sign in</a>' + | |
| "</div></div>"; | |
| } else if (m.role === "system") { | |
| html += '<div class="asst-turn"><div class="asst-sys">' + esc(m.text) + "</div></div>"; | |
| } | |
| }); | |
| 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 '<div class="asst-turn assistant"><div class="asst-tool">' + | |
| '<div class="asst-tool-head"><span class="glyph">β</span><span class="asst-tool-name">' + esc(t.name) + "</span>" + | |
| '<span class="asst-tool-status signin"><span class="tick"></span>sign in required</span></div>' + | |
| '<div class="asst-tool-body"><div class="asst-limit asst-limit-inline">' + | |
| '<div class="asst-limit-t">' + esc(t.signin_text || "This requires a free account.") + "</div>" + | |
| '<a class="asst-limit-cta" href="' + esc(t.signin_url) + '">Sign in</a>' + | |
| "</div></div>" + | |
| "</div></div>"; | |
| } | |
| var rows = (t.rows || []).map(function (r) { | |
| return '<div class="kv"><span>' + esc(r[0]) + '</span><b>' + esc(r[1]) + "</b></div>"; | |
| }).join(""); | |
| var items = (t.items || []).map(function (it) { | |
| return '<div class="asst-tool-item"><span>' + esc(it[0]) + '</span><span>' + esc(it[1]) + "</span></div>"; | |
| }).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 '<div class="asst-tool-seq"><div class="asst-tool-seq-label">' + esc(si.label) + "</div>" + | |
| seqBlock(si.seq, { label: si.label, highlightRanges: si.highlightRanges }) + | |
| "</div>"; | |
| }).join(""); | |
| var failed = t.status === "failed"; | |
| var body = rows || items || seqItems | |
| ? (rows + (items ? '<div class="asst-tool-items">' + items + "</div>" : "") + seqItems) | |
| : esc(JSON.stringify(t.result || {})); | |
| return '<div class="asst-turn assistant"><div class="asst-tool">' + | |
| '<div class="asst-tool-head"><span class="glyph">β</span><span class="asst-tool-name">' + esc(t.name) + "</span>" + | |
| '<span class="asst-tool-status' + (failed ? " failed" : "") + '"><span class="tick"></span>' + esc(t.status || "done") + "</span></div>" + | |
| '<div class="asst-tool-body">' + body + "</div>" + | |
| "</div></div>"; | |
| } | |
| // 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 '<button class="asst-chip" data-sugg="' + i + '"><span class="k">' + esc(s.k) + '</span><span class="t">' + esc(s.t) + "</span></button>"; | |
| }).join(""); | |
| return '<div class="asst-empty">' + | |
| "<h2>What are we engineering today?</h2>" + | |
| "<p>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.</p>" + | |
| '<div class="asst-chips">' + chips + "</div>" + | |
| "</div>"; | |
| } | |
| 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 = '<div class="asst-role"><span class="dot"></span>TuringDNA</div>' + | |
| '<div class="asst-body"><div class="asst-thinking">' + | |
| '<span class="asst-think"><span></span><span></span><span></span></span>' + | |
| '<span class="asst-think-caption">' + esc(steps[_thinkStep % steps.length]) + "</span>" + | |
| "</div></div>"; | |
| 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 += "<p>" + para.join("<br>") + "</p>"; para = []; } } | |
| function flushList() { if (list.length) { html += "<ul>" + list.join("") + "</ul>"; list = []; } } | |
| function inline(t) { | |
| return t.replace(/`([^`]+)`/g, "<code>$1</code>") | |
| .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") | |
| .replace(/\*([^*\n]+)\*/g, "<em>$1</em>"); | |
| } | |
| lines.forEach(function (line) { | |
| var h = line.match(/^(#{1,4})\s+(.*)$/); | |
| var b = line.match(/^[*-]\s+(.*)$/); | |
| if (h) { flushPara(); flushList(); html += '<p class="asst-h">' + inline(h[2]) + "</p>"; } | |
| else if (b) { flushPara(); list.push("<li>" + inline(b[1]) + "</li>"); } | |
| 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 <code> 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 '<pre class="asst-code">' + esc(trimmed) + "</pre>"; | |
| } | |
| // 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 += '<mark class="asst-seq-mut">' + esc(seq.slice(start, end)) + "</mark>"; | |
| 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 '<div class="asst-seq' + (startExpanded ? " expanded" : "") + '" data-seq-id="' + id + '">' + | |
| '<code class="asst-seq-preview">' + previewHtml + "</code>" + | |
| '<span class="asst-seq-meta">' + seq.length.toLocaleString() + " chars</span>" + | |
| '<button class="asst-seq-copy" type="button" data-seq-copy="' + id + '" title="Copy sequence">Copy</button>' + | |
| '<button class="asst-seq-dl" type="button" data-seq-dl="' + id + '" title="Download FASTA">β FASTA</button>' + | |
| '<button class="asst-seq-toggle" type="button" data-seq-toggle="' + id + '">' + (startExpanded ? "Show less" : "Show full") + "</button>" + | |
| "</div>"; | |
| } | |
| // ββ 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 <div>s β a | |
| // real <button> gets Enter/Space activation for free from the browser, | |
| // a div with an ARIA button role does not; that has to be wired up by | |
| // hand or the row is keyboard-focusable but not keyboard-usable. | |
| els.list.addEventListener("keydown", function (e) { | |
| if (e.key !== "Enter" && e.key !== " ") return; | |
| var row = e.target.closest("[data-id]"); | |
| if (!row) return; | |
| e.preventDefault(); | |
| selectChat(row.getAttribute("data-id")); | |
| }); | |
| els.inner.addEventListener("click", function (e) { | |
| var chip = e.target.closest("[data-sugg]"); | |
| if (chip) { els.input.value = SUGGESTIONS[+chip.getAttribute("data-sugg")].t; autoGrow(); els.input.focus(); } | |
| var toggle = e.target.closest("[data-seq-toggle]"); | |
| if (toggle) { | |
| var id = toggle.getAttribute("data-seq-toggle"); | |
| var stored = _seqStore[id] || {}; | |
| var full = stored.seq || ""; | |
| var box = toggle.closest(".asst-seq"); | |
| var preview = box.querySelector(".asst-seq-preview"); | |
| var expanded = box.classList.toggle("expanded"); | |
| // Highlights only render in the expanded view β collapsing | |
| // truncates to a plain snippet, same as any other sequence. | |
| preview.innerHTML = expanded ? seqHtmlWithHighlights(full, stored.ranges) : esc(seqPreview(full)); | |
| toggle.textContent = expanded ? "Show less" : "Show full"; | |
| } | |
| var copyBtn = e.target.closest("[data-seq-copy]"); | |
| if (copyBtn) { | |
| var entry = _seqStore[copyBtn.getAttribute("data-seq-copy")]; | |
| if (entry && navigator.clipboard) { | |
| navigator.clipboard.writeText(entry.seq).then(function () { | |
| var orig = copyBtn.textContent; | |
| copyBtn.textContent = "Copied"; | |
| setTimeout(function () { copyBtn.textContent = orig; }, 1400); | |
| }).catch(function () {}); | |
| } | |
| } | |
| var dlBtn = e.target.closest("[data-seq-dl]"); | |
| if (dlBtn) { | |
| var toDl = _seqStore[dlBtn.getAttribute("data-seq-dl")]; | |
| if (toDl) downloadFasta(toDl.label, toDl.seq); | |
| } | |
| }); | |
| // History popover: click the title to toggle, click anywhere else (or | |
| // Escape) to close. stopPropagation on the trigger so its own click | |
| // doesn't immediately re-trigger the document-level outside-click check. | |
| if (els.titleBtn) els.titleBtn.addEventListener("click", function (e) { e.stopPropagation(); toggleHistory(); }); | |
| document.addEventListener("click", function (e) { | |
| if (!els.topLead || !els.topLead.classList.contains("open")) return; | |
| if (e.target.closest("#historyMenu") || e.target.closest("#titleBtn")) return; | |
| closeHistory(); | |
| }); | |
| document.addEventListener("keydown", function (e) { if (e.key === "Escape") closeHistory(); }); | |
| // Live theme sync: assistant-boot.js only sets <html data-theme> once, | |
| // pre-paint, from localStorage β so flipping the engine's theme toggle | |
| // while this panel is already open (embedded in an iframe) never reached | |
| // it. The parent (app.js) posts { type: "td-theme" } on every toggle; | |
| // apply it here so the panel follows along live instead of needing a reload. | |
| window.addEventListener("message", function (e) { | |
| // The only legitimate sender is app.js on the engine's own page β | |
| // it explicitly targets window.location.origin when it posts this | |
| // (see app.js's postMessage call). Any other origin is rejected | |
| // before even checking the payload shape β postMessage delivery | |
| // itself doesn't enforce this, the receiver has to. | |
| if (e.origin !== window.location.origin) return; | |
| if (!e.data || e.data.type !== "td-theme") return; | |
| var theme = e.data.theme === "light" ? "light" : "dark"; | |
| document.documentElement.setAttribute("data-theme", theme); | |
| try { localStorage.setItem("td-theme", theme); } catch (err) {} | |
| }); | |
| // ββ Boot ---------------------------------------------------------------- | |
| load(); | |
| state.activeId = state.chats.length ? state.chats.slice().sort(function (a, b) { return b.updatedAt - a.updatedAt; })[0].id : null; | |
| renderList(); renderThread(); autoGrow(); | |
| // `?seed=<prompt>`: Mission Control's "What are we engineering today?" input | |
| // reloads this iframe with the typed prompt so the composer opens pre-filled | |
| // (not auto-sent β the user still hits send). Purely a convenience prefill. | |
| try { | |
| var seed = new URLSearchParams(location.search).get("seed"); | |
| if (seed && els.input && !PREVIEW_MODE) { | |
| els.input.value = seed.slice(0, MESSAGE_MAX_CHARS); | |
| autoGrow(); | |
| if (els.send) els.send.disabled = els.input.value.trim() === ""; | |
| els.input.focus(); | |
| } | |
| } catch (e) {} | |
| // Preview: lock the composer so nothing is sent, but keep the UI fully | |
| // browsable (recents, phase rail, demo sessions all interactive). | |
| if (PREVIEW_MODE) { | |
| if (els.previewBar) els.previewBar.hidden = false; | |
| els.input.disabled = true; | |
| els.send.disabled = true; | |
| els.input.placeholder = "Preview β Turing isn't live yet. Browse the example sessions to see how it works."; | |
| if (els.hint) els.hint.innerHTML = "This is a preview of <b>Turing</b> Β· the composer turns on when the engine's agent goes live"; | |
| } | |
| })(); | |