Spaces:
Sleeping
Sleeping
Download static/app.js from jostlebot/secondthoughts: direct link, hf CLI and curl.
- Browser
- Download file 8.99 kB
-
https://huggingface.co/spaces/jostlebot/secondthoughts/resolve/main/static/app.js
- Command line
-
hf download hf://spaces/jostlebot/secondthoughts/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/jostlebot/secondthoughts/resolve/main/static/app.js
8.99 kB
| // Second Thoughts — front end. | |
| // Conversation state lives only in this tab's memory: no localStorage, no | |
| // analytics, nothing written to disk. Closing the tab ends it. | |
| const scroll = document.getElementById("scroll"); | |
| const thread = document.getElementById("thread"); | |
| const form = document.getElementById("composer"); | |
| const input = document.getElementById("msg"); | |
| const sendBtn = document.getElementById("send"); | |
| const starters = document.getElementById("starters"); | |
| const startersLabel = document.getElementById("starters-label"); | |
| const optional = document.getElementById("optional"); | |
| const hint = document.getElementById("hint"); | |
| const path = document.getElementById("path"); | |
| /** @type {{role: "user"|"assistant", content: string}[]} */ | |
| const messages = []; | |
| let busy = false; | |
| // Practice mode. `mode` is sent with each request; "practice_end" is a | |
| // one-shot note that the person stopped, so the model returns to listening. | |
| const practice = { active: false, step: 0, draft: "", endNote: false, breathed: false }; | |
| const MARKER = /\[\[step:(\d|done)\]\]/g; | |
| // Hide markers from the person, including one still arriving mid-stream. | |
| function visible(text) { | |
| return text.replace(MARKER, "").replace(/\[\[[^\]]*$/, "").trimEnd(); | |
| } | |
| function atBottom() { | |
| return scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 80; | |
| } | |
| function scrollDown() { | |
| scroll.scrollTop = scroll.scrollHeight; | |
| } | |
| function bubble(kind, text) { | |
| const el = document.createElement("div"); | |
| el.className = `bubble bubble--${kind}`; | |
| el.textContent = text; | |
| thread.appendChild(el); | |
| scrollDown(); | |
| return el; | |
| } | |
| function typingIndicator() { | |
| const el = document.createElement("div"); | |
| el.className = "bubble bubble--them"; | |
| el.innerHTML = '<span class="typing"><span></span><span></span><span></span></span>'; | |
| thread.appendChild(el); | |
| scrollDown(); | |
| return el; | |
| } | |
| // Once the conversation starts, the starter prompts have done their job. The | |
| // optional doors are the exception — both act on what was already shared, so | |
| // they only become useful now. Dock them instead of dropping them. | |
| function retireStarters() { | |
| for (const el of [starters, startersLabel, hint]) { | |
| if (el) el.remove(); | |
| } | |
| if (optional) optional.classList.add("optional--docked"); | |
| } | |
| function fromTemplate(id) { | |
| const node = document.getElementById(id).content.firstElementChild.cloneNode(true); | |
| thread.appendChild(node); | |
| scrollDown(); | |
| return node; | |
| } | |
| function renderPath() { | |
| for (const li of path.querySelectorAll("li")) { | |
| const n = Number(li.dataset.step); | |
| li.classList.toggle("is-done", n < practice.step); | |
| li.classList.toggle("is-current", n === practice.step); | |
| if (n === practice.step) li.setAttribute("aria-current", "step"); | |
| else li.removeAttribute("aria-current"); | |
| } | |
| } | |
| function setPractice(active) { | |
| practice.active = active; | |
| path.hidden = !active; | |
| optional.hidden = active; | |
| if (active) path.classList.add("optional--docked"); | |
| renderPath(); | |
| } | |
| function beginPractice() { | |
| Object.assign(practice, { step: 1, draft: "", endNote: false, breathed: false }); | |
| setPractice(true); | |
| send("I'd like to practice saying it to someone."); | |
| } | |
| function stopPractice() { | |
| setPractice(false); | |
| practice.endNote = true; | |
| bubble("notice", "Practice stopped. That counts — you can start again anytime, or just keep talking here."); | |
| } | |
| function finishPractice() { | |
| setPractice(false); | |
| const card = fromTemplate("practice-done"); | |
| if (practice.draft) { | |
| card.querySelector(".pcard__draft").hidden = false; | |
| card.querySelector(".pcard__quote").textContent = practice.draft; | |
| } | |
| } | |
| // Apply the step marker from a finished reply. | |
| function advance(reply) { | |
| const marks = [...reply.matchAll(MARKER)]; | |
| if (!practice.active || !marks.length) return; | |
| const mark = marks[marks.length - 1][1]; | |
| if (mark === "done") return finishPractice(); | |
| practice.step = Number(mark); | |
| renderPath(); | |
| if (practice.step === 4 && !practice.breathed) { | |
| practice.breathed = true; | |
| fromTemplate("breathe"); | |
| } | |
| } | |
| function setBusy(state) { | |
| busy = state; | |
| sendBtn.disabled = state; | |
| input.disabled = state; | |
| if (!state) input.focus(); | |
| } | |
| function autoGrow() { | |
| input.style.height = "auto"; | |
| input.style.height = `${Math.min(input.scrollHeight, 120)}px`; | |
| } | |
| async function send(text) { | |
| const content = text.trim(); | |
| if (!content || busy) return; | |
| retireStarters(); | |
| bubble("you", content); | |
| messages.push({ role: "user", content }); | |
| // What they say while on "Say it" is the draft they get to keep. | |
| if (practice.active && practice.step === 2) practice.draft = content; | |
| let mode; | |
| if (practice.active) mode = "practice"; | |
| else if (practice.endNote) mode = "practice_end"; | |
| practice.endNote = false; | |
| input.value = ""; | |
| autoGrow(); | |
| setBusy(true); | |
| const pending = typingIndicator(); | |
| let reply = ""; | |
| let target = null; | |
| const fail = (message) => { | |
| pending.remove(); | |
| if (target) target.remove(); | |
| bubble("notice", message); | |
| // Drop the unanswered turn so a retry doesn't double up. | |
| messages.pop(); | |
| }; | |
| try { | |
| const res = await fetch("/api/chat", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ messages, mode }), | |
| }); | |
| if (!res.ok || !res.body) { | |
| let message = "I couldn't reach the other side of this just now. Try again in a moment."; | |
| try { | |
| const body = await res.json(); | |
| if (body && body.error) message = body.error; | |
| } catch (_) { | |
| /* keep the default */ | |
| } | |
| fail(message); | |
| setBusy(false); | |
| return; | |
| } | |
| const reader = res.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ""; | |
| let failed = false; | |
| while (true) { | |
| const { value, done } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const chunks = buffer.split("\n\n"); | |
| buffer = chunks.pop() || ""; | |
| for (const chunk of chunks) { | |
| const line = chunk.trim(); | |
| if (!line.startsWith("data:")) continue; | |
| let event; | |
| try { | |
| event = JSON.parse(line.slice(5).trim()); | |
| } catch (_) { | |
| continue; | |
| } | |
| if (event.type === "delta") { | |
| const stick = atBottom(); | |
| if (!target) { | |
| pending.remove(); | |
| target = bubble("them", ""); | |
| } | |
| reply += event.text; | |
| target.textContent = visible(reply); | |
| if (stick) scrollDown(); | |
| } else if (event.type === "error") { | |
| fail(event.message); | |
| failed = true; | |
| } | |
| } | |
| } | |
| if (failed) { | |
| setBusy(false); | |
| return; | |
| } | |
| pending.remove(); | |
| if (reply.trim()) { | |
| // Keep markers in history so the model can see where the steps stand. | |
| messages.push({ role: "assistant", content: reply }); | |
| advance(reply); | |
| } else if (!target) { | |
| fail("I went quiet there, which wasn't intentional. Try sending that again."); | |
| } | |
| } catch (_) { | |
| fail("The connection dropped. Whatever you typed is still yours — try again when you're ready."); | |
| } finally { | |
| setBusy(false); | |
| } | |
| } | |
| form.addEventListener("submit", (e) => { | |
| e.preventDefault(); | |
| send(input.value); | |
| }); | |
| // Enter sends; Shift+Enter makes a new line. On touch keyboards the return | |
| // key should insert a newline instead, so people can write more than a line. | |
| input.addEventListener("keydown", (e) => { | |
| const touch = window.matchMedia("(pointer: coarse)").matches; | |
| if (e.key === "Enter" && !e.shiftKey && !touch) { | |
| e.preventDefault(); | |
| send(input.value); | |
| } | |
| }); | |
| input.addEventListener("input", autoGrow); | |
| for (const btn of document.querySelectorAll("[data-send]")) { | |
| btn.addEventListener("click", () => send(btn.dataset.send)); | |
| } | |
| document.addEventListener("click", (e) => { | |
| const btn = e.target.closest("[data-action]"); | |
| if (!btn || busy) return; | |
| const action = btn.dataset.action; | |
| if (action === "practice-intro") { | |
| retireStarters(); | |
| fromTemplate("practice-intro"); | |
| } else if (action === "practice-begin") { | |
| btn.closest(".pcard").classList.add("pcard--started"); | |
| btn.remove(); | |
| beginPractice(); | |
| } else if (action === "practice-stop") { | |
| stopPractice(); | |
| } else if (action === "copy-draft") { | |
| navigator.clipboard?.writeText(practice.draft).then( | |
| () => { btn.textContent = "Copied"; }, | |
| () => { btn.textContent = "Couldn't copy — select the text instead"; }, | |
| ); | |
| } | |
| }); | |
| // "How this space works" sheet. | |
| for (const btn of document.querySelectorAll("[data-open]")) { | |
| btn.addEventListener("click", () => document.getElementById(btn.dataset.open).showModal()); | |
| } | |
| for (const dlg of document.querySelectorAll("dialog")) { | |
| dlg.querySelector("[data-close]").addEventListener("click", () => dlg.close()); | |
| // Tapping the dimmed backdrop closes it too. | |
| dlg.addEventListener("click", (e) => { if (e.target === dlg) dlg.close(); }); | |
| } | |