Download src/nlw_codex/static/threadstore.js from rvguha/TSNLWeb: direct link, hf CLI and curl.
- Browser
- Download file 9.33 kB
-
https://huggingface.co/spaces/rvguha/TSNLWeb/resolve/main/src/nlw_codex/static/threadstore.js
- Command line
-
hf download hf://spaces/rvguha/TSNLWeb/src/nlw_codex/static/threadstore.js
-
curl -L -o threadstore.js https://huggingface.co/spaces/rvguha/TSNLWeb/resolve/main/src/nlw_codex/static/threadstore.js
9.33 kB
| // Threadstore: conversation threads that survive a reload. | |
| // | |
| // The interface used to keep its conversation in `const history = []`, which | |
| // works and vanishes on reload. The server cannot help — /ask is stateless by | |
| // design — so the browser owns the thread. | |
| // | |
| // Nothing is stored by reference. The corpus is rebuilt, re-extracted and | |
| // re-emitted constantly; a reference resolves to whatever the corpus says now, | |
| // which is precisely not what you were shown. Every turn is stored whole, | |
| // schema_object and all, so reopening a thread shows what you saw whatever has | |
| // happened to the repository since. That is affordable because JSON-LD | |
| // compresses about tenfold — the repeated @context, @type and property names | |
| // are nearly free — so a turn costs roughly 16 KB gzipped rather than 156 KB. | |
| // | |
| // Compression is per turn, not per thread, so appending never rewrites history. | |
| const DB_NAME = "tsnlweb"; | |
| const DB_VERSION = 1; | |
| const THREADS = "threads"; | |
| const TURNS = "turns"; | |
| const SCHEMA = 1; | |
| function idb(request) { | |
| return new Promise((resolve, reject) => { | |
| request.onsuccess = () => resolve(request.result); | |
| request.onerror = () => reject(request.error); | |
| }); | |
| } | |
| async function gzip(value) { | |
| const bytes = new TextEncoder().encode(JSON.stringify(value)); | |
| if (typeof CompressionStream !== "function") return bytes; | |
| const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip")); | |
| return new Uint8Array(await new Response(stream).arrayBuffer()); | |
| } | |
| async function gunzip(bytes) { | |
| if (typeof DecompressionStream !== "function") { | |
| return JSON.parse(new TextDecoder().decode(bytes)); | |
| } | |
| const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); | |
| return JSON.parse(await new Response(stream).text()); | |
| } | |
| // Time-sortable so the conversation list needs no secondary index, and random | |
| // enough that two tabs starting a thread in the same millisecond do not collide. | |
| function threadId() { | |
| const stamp = Date.now().toString(36).padStart(9, "0"); | |
| const noise = Math.random().toString(36).slice(2, 8); | |
| return `t_${stamp}${noise}`; | |
| } | |
| // A lowercase digest of everything worth finding a conversation by: what was | |
| // asked, how it was rewritten, and which models came back. Kept on the thread | |
| // record rather than inside the compressed turns, so searching never has to | |
| // inflate anything -- the alternative gunzips the whole history per keystroke. | |
| // | |
| // Capped, because a long conversation should not grow an unbounded index entry. | |
| // The cap costs nothing in practice: threads are found by how they started and | |
| // what they surfaced, and both are near the front. | |
| const DIGEST_LIMIT = 4000; | |
| function digest(existing, turn) { | |
| const parts = [ | |
| existing || "", | |
| turn.question || "", | |
| turn.interpretedAs || "", | |
| (turn.results || []).map(r => r.name || "").join(" "), | |
| ]; | |
| return parts.join(" ").replace(/\s+/g, " ").trim().toLowerCase().slice(0, DIGEST_LIMIT); | |
| } | |
| function title(question) { | |
| const clean = question.replace(/\s+/g, " ").trim(); | |
| return clean.length > 72 ? `${clean.slice(0, 71)}…` : clean; | |
| } | |
| export class Threadstore { | |
| constructor(db) { | |
| this.db = db; | |
| } | |
| static async open() { | |
| if (!globalThis.indexedDB) return new Threadstore(null); | |
| const request = indexedDB.open(DB_NAME, DB_VERSION); | |
| request.onupgradeneeded = () => { | |
| const db = request.result; | |
| if (!db.objectStoreNames.contains(THREADS)) { | |
| db.createObjectStore(THREADS, { keyPath: "id" }); | |
| } | |
| if (!db.objectStoreNames.contains(TURNS)) { | |
| // Keyed on [threadId, seq] so "this thread's turns, in order" is a | |
| // range scan. Only the key fields live outside the compressed blob. | |
| db.createObjectStore(TURNS, { keyPath: ["threadId", "seq"] }); | |
| } | |
| }; | |
| try { | |
| return new Threadstore(await idb(request)); | |
| } catch { | |
| // Private windows and blocked site data both land here. The interface | |
| // must still work; it just forgets. | |
| return new Threadstore(null); | |
| } | |
| } | |
| get available() { | |
| return this.db !== null; | |
| } | |
| #tx(store, mode) { | |
| return this.db.transaction(store, mode).objectStore(store); | |
| } | |
| async startThread({ scope, corpus }) { | |
| const now = Date.now(); | |
| const thread = { | |
| id: threadId(), title: "", scope, corpus: corpus || null, | |
| createdAt: now, updatedAt: now, turnCount: 0, search: "", schema: SCHEMA, | |
| }; | |
| if (this.db) await idb(this.#tx(THREADS, "readwrite").put(thread)); | |
| return thread; | |
| } | |
| async appendTurn(thread, turn) { | |
| // Next sequence is one past the highest that exists, not turnCount + 1. | |
| // Deleting a middle turn leaves a gap, so a count is not a high-water mark: | |
| // with turns 1 and 3 surviving, turnCount is 2 and the next turn would land | |
| // on 3 and silently overwrite it. | |
| const seq = (await this.#lastSeq(thread.id)) + 1; | |
| thread.turnCount += 1; | |
| thread.updatedAt = Date.now(); | |
| thread.search = digest(thread.search, turn); | |
| if (!thread.title) thread.title = title(turn.question); | |
| if (!this.db) return thread; | |
| const record = { | |
| threadId: thread.id, | |
| seq, | |
| askedAt: turn.askedAt || Date.now(), | |
| blob: await gzip(turn), | |
| }; | |
| await idb(this.#tx(TURNS, "readwrite").put(record)); | |
| await idb(this.#tx(THREADS, "readwrite").put(thread)); | |
| thread.lastSeq = seq; | |
| return thread; | |
| } | |
| /** Highest sequence stored for a thread, or 0 when it has none. */ | |
| async #lastSeq(threadId) { | |
| if (!this.db) return 0; | |
| const range = IDBKeyRange.bound([threadId, 0], [threadId, Infinity]); | |
| const keys = await idb(this.#tx(TURNS, "readonly").getAllKeys(range)); | |
| return keys.reduce((high, key) => Math.max(high, key[1]), 0); | |
| } | |
| async threads() { | |
| if (!this.db) return []; | |
| const all = await idb(this.#tx(THREADS, "readonly").getAll()); | |
| return all.filter(t => t.turnCount > 0).sort((a, b) => b.updatedAt - a.updatedAt); | |
| } | |
| /** Does this thread match a search, by anything in it rather than its title? */ | |
| static matches(thread, needle) { | |
| if (!needle) return true; | |
| const term = needle.toLowerCase(); | |
| return (thread.title || "").toLowerCase().includes(term) | |
| || (thread.search || "").includes(term); | |
| } | |
| /** Build digests for threads stored before this index existed. */ | |
| async backfill() { | |
| if (!this.db) return 0; | |
| const all = await idb(this.#tx(THREADS, "readonly").getAll()); | |
| const stale = all.filter(t => t.turnCount > 0 && !t.search); | |
| for (const thread of stale) { | |
| let text = ""; | |
| for (const turn of await this.turns(thread.id)) text = digest(text, turn); | |
| thread.search = text; | |
| await idb(this.#tx(THREADS, "readwrite").put(thread)); | |
| } | |
| return stale.length; | |
| } | |
| async turns(threadId) { | |
| if (!this.db) return []; | |
| const range = IDBKeyRange.bound([threadId, 0], [threadId, Infinity]); | |
| const rows = await idb(this.#tx(TURNS, "readonly").getAll(range)); | |
| return Promise.all(rows.sort((a, b) => a.seq - b.seq).map(async row => ({ | |
| seq: row.seq, askedAt: row.askedAt, ...(await gunzip(row.blob)), | |
| }))); | |
| } | |
| /** Drop one turn, leaving the rest of the conversation intact. | |
| * | |
| * Sequence numbers keep their gaps rather than being renumbered: they are | |
| * ordering keys, not positions, and rewriting them would mean rewriting every | |
| * later turn's key to fix something nothing reads. The digest and title are | |
| * rebuilt from what survives, because both were derived from the turn that | |
| * just left -- a thread whose first question is deleted should not keep being | |
| * named after it. | |
| * | |
| * Deleting the last turn deletes the thread: an empty conversation is not a | |
| * conversation, and `threads()` would hide it anyway, leaving an orphan. | |
| */ | |
| async removeTurn(threadId, seq) { | |
| if (!this.db) return null; | |
| await idb(this.#tx(TURNS, "readwrite").delete([threadId, seq])); | |
| const thread = await idb(this.#tx(THREADS, "readonly").get(threadId)); | |
| if (!thread) return null; | |
| const remaining = await this.turns(threadId); | |
| if (!remaining.length) { | |
| await this.remove(threadId); | |
| return null; | |
| } | |
| thread.turnCount = remaining.length; | |
| thread.title = title(remaining[0].question); | |
| thread.search = remaining.reduce((text, turn) => digest(text, turn), ""); | |
| thread.updatedAt = Date.now(); | |
| await idb(this.#tx(THREADS, "readwrite").put(thread)); | |
| return thread; | |
| } | |
| async remove(threadId) { | |
| if (!this.db) return; | |
| await idb(this.#tx(THREADS, "readwrite").delete(threadId)); | |
| const range = IDBKeyRange.bound([threadId, 0], [threadId, Infinity]); | |
| await idb(this.#tx(TURNS, "readwrite").delete(range)); | |
| } | |
| async clear() { | |
| if (!this.db) return; | |
| await idb(this.#tx(TURNS, "readwrite").clear()); | |
| await idb(this.#tx(THREADS, "readwrite").clear()); | |
| } | |
| // Rough on-disk size, for a UI that wants to say what it is holding. | |
| async usage() { | |
| if (!this.db) return { threads: 0, turns: 0, bytes: 0 }; | |
| const rows = await idb(this.#tx(TURNS, "readonly").getAll()); | |
| const threads = await idb(this.#tx(THREADS, "readonly").getAll()); | |
| return { | |
| threads: threads.length, | |
| turns: rows.length, | |
| bytes: rows.reduce((total, row) => total + (row.blob?.byteLength || 0), 0), | |
| }; | |
| } | |
| } | |