From e612eaa5a5fb8a2582b0008939684cec3a013b19 Mon Sep 17 00:00:00 2001 From: mikkel Date: Mon, 9 Nov 2026 03:04:35 +0000 Subject: [PATCH] Protect demo edits and conversation state across asynchronous runs --- README.md | 19 ++ comic.html | 222 +++++++++++++----- count.html | 156 +++++++++---- index.html | 193 ++++++++++++---- marvis.html | 329 +++++++++++++++++++-------- object3d.html | 220 +++++++++++++----- overlay.html | 222 +++++++++++++----- test/catalog-recovery.test.mjs | 59 +++++ test/completion-terminal.test.mjs | 86 +++++++ test/demo-models.test.mjs | 3 +- test/project-lifecycle.test.mjs | 362 ++++++++++++++++++++++++++++++ 11 files changed, 1526 insertions(+), 345 deletions(-) create mode 100644 test/catalog-recovery.test.mjs create mode 100644 test/completion-terminal.test.mjs create mode 100644 test/project-lifecycle.test.mjs diff --git a/README.md b/README.md index 9f8ae9c..66189eb 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,13 @@ browser are replaced; custom choices are preserved. Picture Me starts with `flux-2-klein-base-4b/edit` for images and MiMo 2.6 Flash for prompt writing. Live catalogs still populate every picker. +Catalog failures use a temporary fallback and retry on the next picker open. +Signing in or out invalidates catalog prices; late responses from an older +credential cannot replace the current catalog. Inline streaming clients reject +EOF without a terminal event and explicit length/content-filter truncation, +release their response readers, and do not replay partial requests. These +checks are inline so each demo retains its standalone `file://` behavior. + IDs, capabilities, prices, and image sizes were verified on 2026-10-13 using NanoGPT's [text catalog](https://nano-gpt.com/api/v1/models?detailed=true) and [image catalog](https://nano-gpt.com/api/v1/image-models). The offline snapshot @@ -51,6 +58,18 @@ Open the page, authenticate one of two ways, then edit the file + goal and hit * - **paste key** — paste a key from [nano-gpt.com](https://nano-gpt.com/r/FXrrGWE4) → Settings → API. Works even when the file is opened directly (`file://`). +The editor demos keep the files and model choices submitted at the start of each +request. If you edit, add, delete a file, or select an older version while it is +running, the older result is discarded and your newer work stays in the editor. Run the +request again against that version. A successful multi-file apply can be undone +until the next manual project edit; a failed request keeps any still-valid undo. + +In MARVIS, conversation replies remain independent of personality changes and +survive undoing a personality change. Clearing the conversation or loading a save +invalidates pending replies, personality changes, and portrait results. An older +save-file read cannot replace a newer load or edits made while it was being read. +The saved JSON format stays unchanged. + ### Serve it locally (for OAuth) ```bash diff --git a/comic.html b/comic.html index 86bb1a8..90081a8 100644 --- a/comic.html +++ b/comic.html @@ -225,7 +225,7 @@

patchling · AI-liftoff comic

// ---------- key storage ---------- const getKey = () => localStorage.getItem("ngpt_key"); -const setKey = (k) => { k ? localStorage.setItem("ngpt_key", k) : localStorage.removeItem("ngpt_key"); if (k) setEnv("GPTDIFF_LLM_API_KEY", k); }; +const setKey = (k) => { k ? localStorage.setItem("ngpt_key", k) : localStorage.removeItem("ngpt_key"); if (k) setEnv("GPTDIFF_LLM_API_KEY", k); invalidateModels(); }; if (getKey()) setEnv("GPTDIFF_LLM_API_KEY", getKey()); // ---------- browser OAuth PKCE ---------- @@ -280,7 +280,7 @@

patchling · AI-liftoff comic

$("signin").style.display = signed ? "none" : ""; $("pastebtn").style.display = signed ? "none" : ""; $("signout").style.display = signed ? "" : "none"; - $("run").disabled = !signed; + $("run").disabled = !signed || applying; $("render").disabled = !signed; $("newtake").disabled = !signed; } @@ -291,6 +291,7 @@

patchling · AI-liftoff comic

// ---------- model selection ---------- let allModels = null, topOrder = new Map(); +let modelsLoaded = false, modelsKey = null, modelsRequest = null; const LABS = [ ["OpenAI", /^openai\/(gpt-[0-9]|o[1-9])/i], ["Anthropic", /claude-(opus|sonnet)/i], @@ -331,18 +332,51 @@

patchling · AI-liftoff comic

$(`modeltags-${target}`).replaceChildren(...tagEls(m)); } async function loadModels() { - if (allModels) return allModels; - let arr = []; - try { - const headers = {}; const k = getKey(); if (k) headers.Authorization = `Bearer ${k}`; - arr = (await (await fetch(`${NANOGPT}/api/v1/models?detailed=true`, { headers })).json()).data || []; - } catch { /* ignore */ } - allModels = arr.map((m) => ({ id: m.id, prompt: m.pricing?.prompt, completion: m.pricing?.completion, owned_by: m.owned_by, created: m.created })).filter((m) => m.id); - if (!allModels.length) allModels = [{ id: DEFAULT_MODEL }]; - computeTop(); - allModels.sort((a, b) => (rank(a.id) - rank(b.id)) || a.id.localeCompare(b.id)); + const key = getKey() || ""; + if (modelsLoaded && modelsKey === key) return allModels; + if (modelsRequest && modelsRequest.key === key) return modelsRequest.promise; + const request = { key, promise: null }; + modelsRequest = request; + request.promise = (async () => { + let next, loaded = false; + try { + const headers = {}; if (key) headers.Authorization = `Bearer ${key}`; + const response = await fetch("https://nano-gpt.com/api/v1/models?detailed=true", { headers }); + if (!response.ok) throw new Error("Model catalog request failed"); + const data = (await response.json()).data; + if (!Array.isArray(data)) throw new Error("Invalid model catalog"); + next = data.filter((m) => m && typeof m.id === "string" && m.id.trim()).map((m) => ({ + id: m.id, owned_by: m.owned_by, prompt: m.pricing?.prompt, completion: m.pricing?.completion, created: m.created, + })); + if (!next.length) throw new Error("Empty model catalog"); + loaded = true; + } catch { next = [{ id: DEFAULT_MODEL }]; } // exact-id entry remains available; the next open retries + try { + // A sign-in/out or newer request must not inherit an older account's catalog/prices. + if (modelsRequest !== request || (getKey() || "") !== key) return allModels || [{ id: DEFAULT_MODEL }]; + allModels = next; + modelsLoaded = loaded; + modelsKey = key; + computeTop(); + allModels.sort((a, b) => (rank(a.id) - rank(b.id)) || a.id.localeCompare(b.id)); + renderModelRow("diff"); renderModelRow("apply"); + return allModels; + } finally { + if (modelsRequest === request) modelsRequest = null; + } + })(); + return request.promise; +} +function invalidateModels() { + allModels = null; + modelsLoaded = false; + modelsKey = null; + modelsRequest = null; + topOrder = new Map(); + $("modelpicker").style.display = "none"; + $("modellist").innerHTML = ""; + $("modelcount").textContent = ""; renderModelRow("diff"); renderModelRow("apply"); - return allModels; } function tagsFor(m) { const t = []; @@ -405,6 +439,55 @@

patchling · AI-liftoff comic

$("outTok").textContent = n(prog.outDone + prog.liveOut); $("cost").textContent = fmtCost(prog.cost + prog.liveCost); // committed + in-flight estimate } +// Inline so each example remains a standalone file, including file:// use. +async function readCompletionStream(response, onChunk) { + if (!response.body || typeof response.body.getReader !== "function") throw new Error("No completion stream"); + const reader = response.body.getReader(), decoder = new TextDecoder(); + let buffer = "", dataLines = [], content = "", pricing = null, finished = false, finishReason = null; + const incomplete = (message) => { const error = new Error(message); error.name = "IncompleteCompletionError"; return error; }; + const dispatch = () => { + if (!dataLines.length) return false; + const data = dataLines.join("\n"); dataLines = []; + if (data.trim() === "[DONE]") return true; + let chunk; try { chunk = JSON.parse(data); } catch { return false; } + if (chunk.error) throw new Error("LLM stream error: " + JSON.stringify(chunk.error)); + const reason = chunk.choices?.[0]?.finish_reason; + if (reason === "length" || reason === "content_filter") throw incomplete("Incomplete completion: " + reason); + if (reason != null) finishReason = reason; + const text = chunk.choices?.[0]?.delta?.content; + if (typeof text === "string") content += text; + if (chunk.x_nanogpt_pricing) pricing = chunk.x_nanogpt_pricing; + onChunk?.(chunk); + return false; + }; + const line = (text) => { + if (text.endsWith("\r")) text = text.slice(0, -1); + if (!text) return dispatch(); + if (text.startsWith("data:")) dataLines.push(text.slice(text[5] === " " ? 6 : 5)); + return false; + }; + try { + while (!finished) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let at; + while ((at = buffer.indexOf("\n")) !== -1) { + const part = buffer.slice(0, at); buffer = buffer.slice(at + 1); + if (line(part)) { finished = true; break; } + } + } + buffer += decoder.decode(); + if (!finished && buffer) finished = line(buffer); + if (!finished) finished = dispatch(); + if (!finished && !["stop", "tool_calls", "function_call"].includes(finishReason)) throw incomplete("Stream ended before a terminal completion event"); + return { content, pricing }; + } finally { + try { if (typeof reader.cancel === "function") Promise.resolve(reader.cancel()).catch(() => {}); } catch { /* preserve result/error */ } + try { if (typeof reader.releaseLock === "function") reader.releaseLock(); } catch { /* already released */ } + } +} + function streamingCallLlm(phase) { return async ({ apiKey, baseUrl, model, messages, maxTokens = null, temperature = 1.0 }) => { $("phase").textContent = phase; @@ -425,34 +508,23 @@

patchling · AI-liftoff comic

}); } catch (e) { prog.liveIn -= inEst; prog.liveCost -= myCost; throw e; } if (!r.ok) { prog.liveIn -= inEst; prog.liveCost -= myCost; throw new Error(`LLM ${r.status}: ${await r.text()}`); } - const reader = r.body.getReader(); - const dec = new TextDecoder(); - let buf = "", content = "", reasoning = "", pricing = null, lastTick = 0; - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - buf += dec.decode(value, { stream: true }); - const lines = buf.split("\n"); - buf = lines.pop(); - for (const line of lines) { - const t = line.trim(); - if (!t.startsWith("data:")) continue; - const data = t.slice(5).trim(); - if (data === "[DONE]") continue; - let j; try { j = JSON.parse(data); } catch { continue; } - const d = j.choices?.[0]?.delta; - if (d) { - if (d.content) { content += d.content; appendText(phase, d.content); } - if (d.reasoning) reasoning += d.reasoning; - if (d.content || d.reasoning) { - const cur = words(content) + words(reasoning); - prog.liveOut += cur - prevOut; prevOut = cur; recost(); - const now = performance.now(); - if (now - lastTick > 80) { lastTick = now; paintUsage(); $("livelabel").textContent = content ? "" : "· thinking…"; } - } + let content = "", reasoning = "", pricing = null, lastTick = 0; + try { + ({ content, pricing } = await readCompletionStream(r, (chunk) => { + const delta = chunk.choices?.[0]?.delta; + if (!delta) return; + if (delta.content) { content += delta.content; appendText(phase, delta.content); } + if (delta.reasoning) reasoning += delta.reasoning; + if (delta.content || delta.reasoning) { + const current = words(content) + words(reasoning); + prog.liveOut += current - prevOut; prevOut = current; recost(); + const now = performance.now(); + if (now - lastTick > 80) { lastTick = now; paintUsage(); $("livelabel").textContent = content ? "" : "· thinking…"; } } - if (j.x_nanogpt_pricing) pricing = j.x_nanogpt_pricing; - } + })); + } catch (error) { + prog.liveIn -= inEst; prog.liveOut -= prevOut; prog.liveCost -= myCost; paintUsage(); + throw error; } prog.liveIn -= inEst; prog.liveOut -= prevOut; prog.liveCost -= myCost; prog.inDone += pricing?.inputTokens ?? inEst; @@ -468,6 +540,32 @@

patchling · AI-liftoff comic

let active = null; // selected path let undoSnapshot = null; +/* PROJECT_RUN_STATE:START */ +// Only publish a result while it still belongs to the version the user ran. +let projectRevision = 0, applyGeneration = 0, applying = false; +function projectEdited() { + projectRevision += 1; + undoSnapshot = null; + $("undo").disabled = true; +} +function commitEditor() { + if (active != null && files[active] !== undefined && files[active] !== $("editor").value) { + files[active] = $("editor").value; + projectEdited(); + } +} +function currentProjectRun(id, revision) { + return id === applyGeneration && revision === projectRevision; +} +function discardProjectRun(id) { + if (id !== applyGeneration) return; + $("changes").textContent = "Kept your newer edits. Run again to apply this request to the new version."; + $("phase").textContent = "newer edits kept"; + document.querySelector("#progress .spin").style.display = "none"; +} +/* PROJECT_RUN_STATE:END */ + + // Built-in seed (used when the comic/ folder can't be fetched, e.g. file://). const EMBEDDED_SEED = { "config.json": `{ @@ -595,7 +693,7 @@

patchling · AI-liftoff comic

host.appendChild(add); } function selectFile(path) { - if (active && files[active] !== undefined) files[active] = $("editor").value; // commit edits + commitEditor(); active = path; $("activelabel").textContent = path; $("editor").value = files[path] ?? ""; @@ -607,6 +705,7 @@

patchling · AI-liftoff comic

if (!path) return; const p = path.trim(); if (!p || files[p] !== undefined) { if (files[p] !== undefined) selectFile(p); return; } + projectEdited(); files[p] = isPanel(p) ? `{\n "span": "half",\n "shot": "dynamic action shot",\n "cast": ["NOVA"],\n "scene": "describe the panel here",\n "caption": "",\n "dialogue": [],\n "fx": ""\n}\n` : ""; @@ -614,11 +713,12 @@

patchling · AI-liftoff comic

} function deleteFile(path) { if (!confirm("Delete " + path + " ?")) return; + projectEdited(); delete files[path]; if (active === path) active = fileOrder()[0] || null; renderTabs(); loadEditor(); scheduleLive(); } -$("editor").addEventListener("input", () => { if (active != null) { files[active] = $("editor").value; refreshPromptPreview(); scheduleLive(); } }); +$("editor").addEventListener("input", () => { if (active != null) { commitEditor(); refreshPromptPreview(); scheduleLive(); } }); // ---------- compose every panel into one image prompt ("organize") ---------- const SIZE_HINT = { full: "wide", wide: "wide", half: "med", third: "small", tall: "tall" }; @@ -654,7 +754,7 @@

patchling · AI-liftoff comic

} function currentPanels() { return panelFiles().map(parsePanel).filter(Boolean); } function refreshPromptPreview() { - if (active && files[active] !== undefined) files[active] = $("editor").value; + commitEditor(); const panels = currentPanels(); if (!panels.length) { $("pagePrompt").textContent = "(no panels yet — add a panels/*.json file)"; return; } const { prompt, dropped } = composePagePrompt(parseCfg(), panels); @@ -702,7 +802,7 @@

patchling · AI-liftoff comic

async function renderComic(opts = {}) { const key = getKey(); if (!key) return; - if (active && files[active] !== undefined) files[active] = $("editor").value; // commit edits + commitEditor(); const cfg = parseCfg(); const panels = currentPanels(); if (!panels.length) { setImgStatus("No panels — add a panels/*.json file.", "dim"); return; } @@ -796,9 +896,12 @@

patchling · AI-liftoff comic

// ---------- run: diff → smartapply → reconcile → single render ---------- async function run() { - const key = getKey(); if (!key) return; - if (active && files[active] !== undefined) files[active] = $("editor").value; // commit edits - undoSnapshot = JSON.parse(JSON.stringify(files)); + const key = getKey(); if (!key || applying) return; + commitEditor(); + const inputFiles = { ...files }; + const runId = ++applyGeneration, runRevision = projectRevision; + const goal = $("goal").value, diffModel = MODEL, applyModel = APPLY; + applying = true; $("run").disabled = true; $("undo").disabled = true; curPhase = ""; $("diff").textContent = ""; $("changes").textContent = ""; prog.inDone = prog.outDone = prog.cost = prog.liveIn = prog.liveOut = prog.liveCost = 0; paintUsage(); $("progress").style.display = "block"; document.querySelector("#progress .spin").style.display = ""; @@ -807,22 +910,25 @@

patchling · AI-liftoff comic

const timer = setInterval(() => { $("elapsed").textContent = ((performance.now() - t0) / 1000).toFixed(1); }, 100); try { - const diff = await generateDiff(buildEnvironment(files), $("goal").value, { apiKey: key, model: MODEL, callLlm: streamingCallLlm("Generating diff") }); + const diff = await generateDiff(buildEnvironment(inputFiles), goal, { apiKey: key, model: diffModel, callLlm: streamingCallLlm("Generating diff") }); + if (!currentProjectRun(runId, runRevision)) { discardProjectRun(runId); return; } renderDiff(diff); const parsed = parseDiffPerFile(diff); if (!diff.trim() || !parsed.length) { $("changes").textContent = "⚠ The model returned no applicable diff. Try rephrasing the goal."; } else { const applyLlm = streamingCallLlm("Applying diff"); - const before = { ...files }; - const updated = await smartapply(diff, files, { - apiKey: key, model: APPLY, + const before = inputFiles; + const updated = await smartapply(diff, inputFiles, { + apiKey: key, model: applyModel, callLlmForApply: (p, o, d, m, o2) => callLlmForApply(p, o, d, m, { ...o2, callLlm: applyLlm }), }); + if (!currentProjectRun(runId, runRevision)) { discardProjectRun(runId); return; } const beforeKeys = new Set(Object.keys(before)), afterKeys = new Set(Object.keys(updated)); const added = [...afterKeys].filter((k) => !beforeKeys.has(k)); const removed = [...beforeKeys].filter((k) => !afterKeys.has(k)); const changed = [...afterKeys].filter((k) => beforeKeys.has(k) && before[k] !== updated[k]); + undoSnapshot = inputFiles; files = updated; if (active == null || files[active] === undefined) active = fileOrder()[0] || null; renderTabs(); loadEditor(); refreshPromptPreview(); @@ -832,17 +938,25 @@

patchling · AI-liftoff comic

} $("phase").textContent = "done"; document.querySelector("#progress .spin").style.display = "none"; } catch (e) { + if (!currentProjectRun(runId, runRevision)) { discardProjectRun(runId); return; } $("changes").textContent = "Error: " + e.message; $("progress").style.display = "none"; } finally { - clearInterval(timer); $("run").disabled = false; + clearInterval(timer); + if (runId === applyGeneration) { + applying = false; + $("run").disabled = !getKey(); + $("undo").disabled = !undoSnapshot; + } } } $("run").addEventListener("click", run); $("goal").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); if (!$("run").disabled) run(); } }); $("undo").addEventListener("click", () => { - if (!undoSnapshot) return; - files = undoSnapshot; undoSnapshot = null; + if (!undoSnapshot || applying) return; + const previous = undoSnapshot; + projectEdited(); + files = previous; if (active == null || files[active] === undefined) active = fileOrder()[0] || null; renderTabs(); loadEditor(); refreshPromptPreview(); if (hasRendered) renderComic(); diff --git a/count.html b/count.html index 776554c..afd4f8e 100644 --- a/count.html +++ b/count.html @@ -169,7 +169,7 @@

patchling · count to 100

// ---------- key storage ---------- const getKey = () => localStorage.getItem("ngpt_key"); -const setKey = (k) => { k ? localStorage.setItem("ngpt_key", k) : localStorage.removeItem("ngpt_key"); if (k) setEnv("GPTDIFF_LLM_API_KEY", k); }; +const setKey = (k) => { k ? localStorage.setItem("ngpt_key", k) : localStorage.removeItem("ngpt_key"); if (k) setEnv("GPTDIFF_LLM_API_KEY", k); invalidateModels(); }; if (getKey()) setEnv("GPTDIFF_LLM_API_KEY", getKey()); // ---------- browser OAuth PKCE (token + register are CORS-open) ---------- @@ -237,6 +237,7 @@

patchling · count to 100

// ---------- model selection ---------- let allModels = null; // [{ id, prompt, completion, owned_by, created }] prices = USD per 1M +let modelsLoaded = false, modelsKey = null, modelsRequest = null; let topOrder = new Map(); // id -> rank for the curated top list; everything else sorts below // Top list = each frontier lab's latest flagship (newest release matching the lab's @@ -288,18 +289,51 @@

patchling · count to 100

} async function loadModels() { - if (allModels) return allModels; - let arr = []; - try { - const headers = {}; const k = getKey(); if (k) headers.Authorization = `Bearer ${k}`; - arr = (await (await fetch(`${NANOGPT}/api/v1/models?detailed=true`, { headers })).json()).data || []; - } catch { /* ignore */ } - allModels = arr.map((m) => ({ id: m.id, prompt: m.pricing?.prompt, completion: m.pricing?.completion, owned_by: m.owned_by, created: m.created })).filter((m) => m.id); - if (!allModels.length) allModels = [{ id: DEFAULT_MODEL }]; // fallback; custom ids still typeable - computeTop(); - allModels.sort((a, b) => (rank(a.id) - rank(b.id)) || a.id.localeCompare(b.id)); - renderModelRow("diff"); renderModelRow("apply"); // refresh prices/tags now that we have data - return allModels; + const key = getKey() || ""; + if (modelsLoaded && modelsKey === key) return allModels; + if (modelsRequest && modelsRequest.key === key) return modelsRequest.promise; + const request = { key, promise: null }; + modelsRequest = request; + request.promise = (async () => { + let next, loaded = false; + try { + const headers = {}; if (key) headers.Authorization = `Bearer ${key}`; + const response = await fetch("https://nano-gpt.com/api/v1/models?detailed=true", { headers }); + if (!response.ok) throw new Error("Model catalog request failed"); + const data = (await response.json()).data; + if (!Array.isArray(data)) throw new Error("Invalid model catalog"); + next = data.filter((m) => m && typeof m.id === "string" && m.id.trim()).map((m) => ({ + id: m.id, owned_by: m.owned_by, prompt: m.pricing?.prompt, completion: m.pricing?.completion, created: m.created, + })); + if (!next.length) throw new Error("Empty model catalog"); + loaded = true; + } catch { next = [{ id: DEFAULT_MODEL }]; } // exact-id entry remains available; the next open retries + try { + // A sign-in/out or newer request must not inherit an older account's catalog/prices. + if (modelsRequest !== request || (getKey() || "") !== key) return allModels || [{ id: DEFAULT_MODEL }]; + allModels = next; + modelsLoaded = loaded; + modelsKey = key; + computeTop(); + allModels.sort((a, b) => (rank(a.id) - rank(b.id)) || a.id.localeCompare(b.id)); + renderModelRow("diff"); renderModelRow("apply"); + return allModels; + } finally { + if (modelsRequest === request) modelsRequest = null; + } + })(); + return request.promise; +} +function invalidateModels() { + allModels = null; + modelsLoaded = false; + modelsKey = null; + modelsRequest = null; + topOrder = new Map(); + $("modelpicker").style.display = "none"; + $("modellist").innerHTML = ""; + $("modelcount").textContent = ""; + renderModelRow("diff"); renderModelRow("apply"); } // Tags: TEE = private (runs in a Trusted Execution Environment); OpenAI/Anthropic = closed source. @@ -371,6 +405,55 @@

patchling · count to 100

$("cost").textContent = fmtCost(prog.cost + prog.liveCost); // committed + in-flight estimate } +// Inline so each example remains a standalone file, including file:// use. +async function readCompletionStream(response, onChunk) { + if (!response.body || typeof response.body.getReader !== "function") throw new Error("No completion stream"); + const reader = response.body.getReader(), decoder = new TextDecoder(); + let buffer = "", dataLines = [], content = "", pricing = null, finished = false, finishReason = null; + const incomplete = (message) => { const error = new Error(message); error.name = "IncompleteCompletionError"; return error; }; + const dispatch = () => { + if (!dataLines.length) return false; + const data = dataLines.join("\n"); dataLines = []; + if (data.trim() === "[DONE]") return true; + let chunk; try { chunk = JSON.parse(data); } catch { return false; } + if (chunk.error) throw new Error("LLM stream error: " + JSON.stringify(chunk.error)); + const reason = chunk.choices?.[0]?.finish_reason; + if (reason === "length" || reason === "content_filter") throw incomplete("Incomplete completion: " + reason); + if (reason != null) finishReason = reason; + const text = chunk.choices?.[0]?.delta?.content; + if (typeof text === "string") content += text; + if (chunk.x_nanogpt_pricing) pricing = chunk.x_nanogpt_pricing; + onChunk?.(chunk); + return false; + }; + const line = (text) => { + if (text.endsWith("\r")) text = text.slice(0, -1); + if (!text) return dispatch(); + if (text.startsWith("data:")) dataLines.push(text.slice(text[5] === " " ? 6 : 5)); + return false; + }; + try { + while (!finished) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let at; + while ((at = buffer.indexOf("\n")) !== -1) { + const part = buffer.slice(0, at); buffer = buffer.slice(at + 1); + if (line(part)) { finished = true; break; } + } + } + buffer += decoder.decode(); + if (!finished && buffer) finished = line(buffer); + if (!finished) finished = dispatch(); + if (!finished && !["stop", "tool_calls", "function_call"].includes(finishReason)) throw incomplete("Stream ended before a terminal completion event"); + return { content, pricing }; + } finally { + try { if (typeof reader.cancel === "function") Promise.resolve(reader.cancel()).catch(() => {}); } catch { /* preserve result/error */ } + try { if (typeof reader.releaseLock === "function") reader.releaseLock(); } catch { /* already released */ } + } +} + function streamingCallLlm(phase) { return async ({ apiKey, baseUrl, model, messages, maxTokens = null, temperature = 1.0 }) => { $("phase").textContent = phase; @@ -397,43 +480,24 @@

patchling · count to 100

} catch (e) { prog.liveIn -= inEst; prog.liveCost -= myCost; throw e; } if (!r.ok) { prog.liveIn -= inEst; prog.liveCost -= myCost; throw new Error(`LLM ${r.status}: ${await r.text()}`); } - const reader = r.body.getReader(); - const dec = new TextDecoder(); - let buf = "", content = "", reasoning = "", pricing = null, lastTick = 0; + let content = "", reasoning = "", pricing = null, lastTick = 0; try { - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - buf += dec.decode(value, { stream: true }); - const lines = buf.split("\n"); - buf = lines.pop(); - for (const line of lines) { - const t = line.trim(); - if (!t.startsWith("data:")) continue; - const data = t.slice(5).trim(); - if (data === "[DONE]") continue; - let j; try { j = JSON.parse(data); } catch { continue; } - const d = j.choices?.[0]?.delta; - if (d) { - if (d.content) { content += d.content; appendText(phase, d.content); } - if (d.reasoning) reasoning += d.reasoning; - if (d.content || d.reasoning) { - // recount from accumulated text (avoids miscounting words split across chunks) - const cur = words(content) + words(reasoning); - prog.liveOut += cur - prevOut; prevOut = cur; recost(); - const now = performance.now(); - if (now - lastTick > 80) { lastTick = now; paintUsage(); $("live").textContent = content ? "" : "· thinking…"; } - } - } - if (j.x_nanogpt_pricing) pricing = j.x_nanogpt_pricing; + ({ content, pricing } = await readCompletionStream(r, (chunk) => { + const delta = chunk.choices?.[0]?.delta; + if (!delta) return; + if (delta.content) { content += delta.content; appendText(phase, delta.content); } + if (delta.reasoning) reasoning += delta.reasoning; + if (delta.content || delta.reasoning) { + const current = words(content) + words(reasoning); + prog.liveOut += current - prevOut; prevOut = current; recost(); + const now = performance.now(); + if (now - lastTick > 80) { lastTick = now; paintUsage(); $("live").textContent = content ? "" : "· thinking…"; } } - } - } catch (e) { - // Stop() aborts the fetch mid-stream — unwind this call's live estimates and rethrow. + })); + } catch (error) { prog.liveIn -= inEst; prog.liveOut -= prevOut; prog.liveCost -= myCost; paintUsage(); - throw e; + throw error; } - // Commit: drop this call's estimates, add exact tokens (fall back to the estimate). prog.liveIn -= inEst; prog.liveOut -= prevOut; prog.liveCost -= myCost; prog.inDone += pricing?.inputTokens ?? inEst; prog.outDone += pricing?.outputTokens ?? prevOut; diff --git a/index.html b/index.html index f7f4acf..38d992b 100644 --- a/index.html +++ b/index.html @@ -183,7 +183,7 @@

patchling · diff → smartapply

// ---------- key storage ---------- const getKey = () => localStorage.getItem("ngpt_key"); -const setKey = (k) => { k ? localStorage.setItem("ngpt_key", k) : localStorage.removeItem("ngpt_key"); if (k) setEnv("GPTDIFF_LLM_API_KEY", k); }; +const setKey = (k) => { k ? localStorage.setItem("ngpt_key", k) : localStorage.removeItem("ngpt_key"); if (k) setEnv("GPTDIFF_LLM_API_KEY", k); invalidateModels(); }; if (getKey()) setEnv("GPTDIFF_LLM_API_KEY", getKey()); // ---------- browser OAuth PKCE (token + register are CORS-open) ---------- @@ -241,7 +241,7 @@

patchling · diff → smartapply

$("signin").style.display = signed ? "none" : ""; $("pastebtn").style.display = signed ? "none" : ""; $("signout").style.display = signed ? "" : "none"; - $("run").disabled = !signed; + $("run").disabled = !signed || applying; } $("signin").addEventListener("click", (e) => { e.preventDefault(); signIn().catch((err) => $("status").textContent = "sign-in error: " + err.message); }); @@ -251,6 +251,7 @@

patchling · diff → smartapply

// ---------- model selection ---------- let allModels = null; // [{ id, prompt, completion, owned_by, created }] prices = USD per 1M +let modelsLoaded = false, modelsKey = null, modelsRequest = null; let topOrder = new Map(); // id -> rank for the curated top list; everything else sorts below // Top list = each frontier lab's latest flagship (newest release matching the lab's @@ -303,18 +304,51 @@

patchling · diff → smartapply

} async function loadModels() { - if (allModels) return allModels; - let arr = []; - try { - const headers = {}; const k = getKey(); if (k) headers.Authorization = `Bearer ${k}`; - arr = (await (await fetch(`${NANOGPT}/api/v1/models?detailed=true`, { headers })).json()).data || []; - } catch { /* ignore */ } - allModels = arr.map((m) => ({ id: m.id, prompt: m.pricing?.prompt, completion: m.pricing?.completion, owned_by: m.owned_by, created: m.created })).filter((m) => m.id); - if (!allModels.length) allModels = [{ id: DEFAULT_MODEL }]; // fallback; custom ids still typeable - computeTop(); - allModels.sort((a, b) => (rank(a.id) - rank(b.id)) || a.id.localeCompare(b.id)); - renderModelRow("diff"); renderModelRow("apply"); // refresh prices/tags now that we have data - return allModels; + const key = getKey() || ""; + if (modelsLoaded && modelsKey === key) return allModels; + if (modelsRequest && modelsRequest.key === key) return modelsRequest.promise; + const request = { key, promise: null }; + modelsRequest = request; + request.promise = (async () => { + let next, loaded = false; + try { + const headers = {}; if (key) headers.Authorization = `Bearer ${key}`; + const response = await fetch("https://nano-gpt.com/api/v1/models?detailed=true", { headers }); + if (!response.ok) throw new Error("Model catalog request failed"); + const data = (await response.json()).data; + if (!Array.isArray(data)) throw new Error("Invalid model catalog"); + next = data.filter((m) => m && typeof m.id === "string" && m.id.trim()).map((m) => ({ + id: m.id, owned_by: m.owned_by, prompt: m.pricing?.prompt, completion: m.pricing?.completion, created: m.created, + })); + if (!next.length) throw new Error("Empty model catalog"); + loaded = true; + } catch { next = [{ id: DEFAULT_MODEL }]; } // exact-id entry remains available; the next open retries + try { + // A sign-in/out or newer request must not inherit an older account's catalog/prices. + if (modelsRequest !== request || (getKey() || "") !== key) return allModels || [{ id: DEFAULT_MODEL }]; + allModels = next; + modelsLoaded = loaded; + modelsKey = key; + computeTop(); + allModels.sort((a, b) => (rank(a.id) - rank(b.id)) || a.id.localeCompare(b.id)); + renderModelRow("diff"); renderModelRow("apply"); + return allModels; + } finally { + if (modelsRequest === request) modelsRequest = null; + } + })(); + return request.promise; +} +function invalidateModels() { + allModels = null; + modelsLoaded = false; + modelsKey = null; + modelsRequest = null; + topOrder = new Map(); + $("modelpicker").style.display = "none"; + $("modellist").innerHTML = ""; + $("modelcount").textContent = ""; + renderModelRow("diff"); renderModelRow("apply"); } // Tags: TEE = private (runs in a Trusted Execution Environment); OpenAI/Anthropic = closed source. @@ -385,6 +419,55 @@

patchling · diff → smartapply

$("cost").textContent = fmtCost(prog.cost + prog.liveCost); // committed + in-flight estimate } +// Inline so each example remains a standalone file, including file:// use. +async function readCompletionStream(response, onChunk) { + if (!response.body || typeof response.body.getReader !== "function") throw new Error("No completion stream"); + const reader = response.body.getReader(), decoder = new TextDecoder(); + let buffer = "", dataLines = [], content = "", pricing = null, finished = false, finishReason = null; + const incomplete = (message) => { const error = new Error(message); error.name = "IncompleteCompletionError"; return error; }; + const dispatch = () => { + if (!dataLines.length) return false; + const data = dataLines.join("\n"); dataLines = []; + if (data.trim() === "[DONE]") return true; + let chunk; try { chunk = JSON.parse(data); } catch { return false; } + if (chunk.error) throw new Error("LLM stream error: " + JSON.stringify(chunk.error)); + const reason = chunk.choices?.[0]?.finish_reason; + if (reason === "length" || reason === "content_filter") throw incomplete("Incomplete completion: " + reason); + if (reason != null) finishReason = reason; + const text = chunk.choices?.[0]?.delta?.content; + if (typeof text === "string") content += text; + if (chunk.x_nanogpt_pricing) pricing = chunk.x_nanogpt_pricing; + onChunk?.(chunk); + return false; + }; + const line = (text) => { + if (text.endsWith("\r")) text = text.slice(0, -1); + if (!text) return dispatch(); + if (text.startsWith("data:")) dataLines.push(text.slice(text[5] === " " ? 6 : 5)); + return false; + }; + try { + while (!finished) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let at; + while ((at = buffer.indexOf("\n")) !== -1) { + const part = buffer.slice(0, at); buffer = buffer.slice(at + 1); + if (line(part)) { finished = true; break; } + } + } + buffer += decoder.decode(); + if (!finished && buffer) finished = line(buffer); + if (!finished) finished = dispatch(); + if (!finished && !["stop", "tool_calls", "function_call"].includes(finishReason)) throw incomplete("Stream ended before a terminal completion event"); + return { content, pricing }; + } finally { + try { if (typeof reader.cancel === "function") Promise.resolve(reader.cancel()).catch(() => {}); } catch { /* preserve result/error */ } + try { if (typeof reader.releaseLock === "function") reader.releaseLock(); } catch { /* already released */ } + } +} + function streamingCallLlm(phase) { return async ({ apiKey, baseUrl, model, messages, maxTokens = null, temperature = 1.0 }) => { $("phase").textContent = phase; @@ -410,37 +493,24 @@

patchling · diff → smartapply

} catch (e) { prog.liveIn -= inEst; prog.liveCost -= myCost; throw e; } if (!r.ok) { prog.liveIn -= inEst; prog.liveCost -= myCost; throw new Error(`LLM ${r.status}: ${await r.text()}`); } - const reader = r.body.getReader(); - const dec = new TextDecoder(); - let buf = "", content = "", reasoning = "", pricing = null, lastTick = 0; - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - buf += dec.decode(value, { stream: true }); - const lines = buf.split("\n"); - buf = lines.pop(); - for (const line of lines) { - const t = line.trim(); - if (!t.startsWith("data:")) continue; - const data = t.slice(5).trim(); - if (data === "[DONE]") continue; - let j; try { j = JSON.parse(data); } catch { continue; } - const d = j.choices?.[0]?.delta; - if (d) { - if (d.content) { content += d.content; appendText(phase, d.content); } - if (d.reasoning) reasoning += d.reasoning; - if (d.content || d.reasoning) { - // recount from accumulated text (avoids miscounting words split across chunks) - const cur = words(content) + words(reasoning); - prog.liveOut += cur - prevOut; prevOut = cur; recost(); - const now = performance.now(); - if (now - lastTick > 80) { lastTick = now; paintUsage(); $("live").textContent = content ? "" : "· thinking…"; } - } + let content = "", reasoning = "", pricing = null, lastTick = 0; + try { + ({ content, pricing } = await readCompletionStream(r, (chunk) => { + const delta = chunk.choices?.[0]?.delta; + if (!delta) return; + if (delta.content) { content += delta.content; appendText(phase, delta.content); } + if (delta.reasoning) reasoning += delta.reasoning; + if (delta.content || delta.reasoning) { + const current = words(content) + words(reasoning); + prog.liveOut += current - prevOut; prevOut = current; recost(); + const now = performance.now(); + if (now - lastTick > 80) { lastTick = now; paintUsage(); $("live").textContent = content ? "" : "· thinking…"; } } - if (j.x_nanogpt_pricing) pricing = j.x_nanogpt_pricing; - } + })); + } catch (error) { + prog.liveIn -= inEst; prog.liveOut -= prevOut; prog.liveCost -= myCost; paintUsage(); + throw error; } - // Commit: drop this call's estimates, add exact tokens (fall back to the estimate). prog.liveIn -= inEst; prog.liveOut -= prevOut; prog.liveCost -= myCost; prog.inDone += pricing?.inputTokens ?? inEst; prog.outDone += pricing?.outputTokens ?? prevOut; @@ -471,12 +541,29 @@

patchling · diff → smartapply

} } +/* SINGLE_RUN_STATE:START */ +let editorRevision = 0, applyGeneration = 0, applying = false; +function editorEdited() { editorRevision += 1; } +function currentEditorRun(id, revision) { + return id === applyGeneration && revision === editorRevision; +} +function discardEditorRun(id) { + if (id !== applyGeneration) return; + $("result").textContent = "Kept your newer edits. Run again to apply this request to the new version."; + $("phase").textContent = "newer edits kept"; + document.querySelector("#progress .spin").style.display = "none"; +} +/* SINGLE_RUN_STATE:END */ + async function run() { const key = getKey(); - if (!key) return; + if (!key || applying) return; const path = $("path").value.trim() || "file.txt"; const content = $("content").value; const files = { [path]: content }; + const runId = ++applyGeneration, runRevision = editorRevision; + const goal = $("goal").value, diffModel = MODEL, applyModel = APPLY; + applying = true; $("run").disabled = true; curPhase = ""; $("diff").textContent = ""; $("result").textContent = ""; prog.inDone = prog.outDone = prog.cost = prog.liveIn = prog.liveOut = prog.liveCost = 0; paintUsage(); @@ -488,7 +575,8 @@

patchling · diff → smartapply

try { // Diff uses the selected model; smartapply uses its separately selected model. - const diff = await generateDiff(buildEnvironment(files), $("goal").value, { apiKey: key, model: MODEL, callLlm: streamingCallLlm("Generating diff") }); + const diff = await generateDiff(buildEnvironment(files), goal, { apiKey: key, model: diffModel, callLlm: streamingCallLlm("Generating diff") }); + if (!currentEditorRun(runId, runRevision)) { discardEditorRun(runId); return; } const parsed = parseDiffPerFile(diff); if (!diff.trim() || !parsed.length) { renderDiff(diff || ""); @@ -496,9 +584,10 @@

patchling · diff → smartapply

} else { const applyLlm = streamingCallLlm("Applying diff"); const updated = await smartapply(diff, files, { - apiKey: key, model: APPLY, + apiKey: key, model: applyModel, callLlmForApply: (p, o, d, m, o2) => callLlmForApply(p, o, d, m, { ...o2, callLlm: applyLlm }), }); + if (!currentEditorRun(runId, runRevision)) { discardEditorRun(runId); return; } renderDiff(diff); const result = updated[path] ?? "(file was deleted)"; if (result === content) { @@ -508,18 +597,22 @@

patchling · diff → smartapply

} else { $("content").value = result; // apply to the editor automatically — no button needed $("result").textContent = result; - pushVersion(result, $("goal").value); // snapshot as a new, revisitable version + pushVersion(result, goal); // snapshot as a new, revisitable version updatePlayState(); } } $("phase").textContent = "done"; document.querySelector("#progress .spin").style.display = "none"; } catch (e) { + if (!currentEditorRun(runId, runRevision)) { discardEditorRun(runId); return; } $("diff").textContent = "Error: " + e.message; $("progress").style.display = "none"; } finally { clearInterval(timer); - $("run").disabled = false; + if (runId === applyGeneration) { + applying = false; + $("run").disabled = !getKey(); + } } } @@ -561,7 +654,8 @@

patchling · diff → smartapply

playModal.classList.add("open"); playFrame.focus(); } -$("content").addEventListener("input", updatePlayState); +$("content").addEventListener("input", () => { editorEdited(); updatePlayState(); }); +$("path").addEventListener("input", editorEdited); function closePlay() { playModal.classList.remove("open"); playFrame.srcdoc = ""; } $("play").addEventListener("click", openPlay); $("playclose").addEventListener("click", closePlay); @@ -598,6 +692,7 @@

patchling · diff → smartapply

function selectVersion(i) { if (i < 0 || i >= versions.length || i === curVer) return; + editorEdited(); curVer = i; $("content").value = versions[i].content; updatePlayState(); diff --git a/marvis.html b/marvis.html index 1623611..f1de558 100644 --- a/marvis.html +++ b/marvis.html @@ -407,7 +407,7 @@

MARVIS

// ---------- key storage ---------- const getKey = () => localStorage.getItem("ngpt_key"); -const setKey = (k) => { k ? localStorage.setItem("ngpt_key", k) : localStorage.removeItem("ngpt_key"); if (k) setEnv("GPTDIFF_LLM_API_KEY", k); }; +const setKey = (k) => { k ? localStorage.setItem("ngpt_key", k) : localStorage.removeItem("ngpt_key"); if (k) setEnv("GPTDIFF_LLM_API_KEY", k); invalidateModels(); }; if (getKey()) setEnv("GPTDIFF_LLM_API_KEY", getKey()); // ---------- browser OAuth PKCE ---------- @@ -463,8 +463,8 @@

MARVIS

$("signin").style.display = signed ? "none" : ""; $("pastebtn").style.display = signed ? "none" : ""; $("signout").style.display = signed ? "" : "none"; - $("run").disabled = !signed; - $("send").disabled = !signed; + $("run").disabled = !signed || applying; + $("send").disabled = !signed || chatRunning; $("renderface").disabled = !signed; $("newface").disabled = !signed; } @@ -475,6 +475,7 @@

MARVIS

// ---------- model selection ---------- let allModels = null, topOrder = new Map(); +let modelsLoaded = false, modelsKey = null, modelsRequest = null; const LABS = [ ["OpenAI", /^openai\/(gpt-[0-9]|o[1-9])/i], ["Anthropic", /claude-(opus|sonnet)/i], @@ -515,18 +516,51 @@

MARVIS

$(`modeltags-${target}`).replaceChildren(...tagEls(m)); } async function loadModels() { - if (allModels) return allModels; - let arr = []; - try { - const headers = {}; const k = getKey(); if (k) headers.Authorization = `Bearer ${k}`; - arr = (await (await fetch(`${NANOGPT}/api/v1/models?detailed=true`, { headers })).json()).data || []; - } catch { /* ignore */ } - allModels = arr.map((m) => ({ id: m.id, prompt: m.pricing?.prompt, completion: m.pricing?.completion, owned_by: m.owned_by, created: m.created })).filter((m) => m.id); - if (!allModels.length) allModels = [{ id: DEFAULT_MODEL }]; - computeTop(); - allModels.sort((a, b) => (rank(a.id) - rank(b.id)) || a.id.localeCompare(b.id)); + const key = getKey() || ""; + if (modelsLoaded && modelsKey === key) return allModels; + if (modelsRequest && modelsRequest.key === key) return modelsRequest.promise; + const request = { key, promise: null }; + modelsRequest = request; + request.promise = (async () => { + let next, loaded = false; + try { + const headers = {}; if (key) headers.Authorization = `Bearer ${key}`; + const response = await fetch("https://nano-gpt.com/api/v1/models?detailed=true", { headers }); + if (!response.ok) throw new Error("Model catalog request failed"); + const data = (await response.json()).data; + if (!Array.isArray(data)) throw new Error("Invalid model catalog"); + next = data.filter((m) => m && typeof m.id === "string" && m.id.trim()).map((m) => ({ + id: m.id, owned_by: m.owned_by, prompt: m.pricing?.prompt, completion: m.pricing?.completion, created: m.created, + })); + if (!next.length) throw new Error("Empty model catalog"); + loaded = true; + } catch { next = [{ id: DEFAULT_MODEL }]; } // exact-id entry remains available; the next open retries + try { + // A sign-in/out or newer request must not inherit an older account's catalog/prices. + if (modelsRequest !== request || (getKey() || "") !== key) return allModels || [{ id: DEFAULT_MODEL }]; + allModels = next; + modelsLoaded = loaded; + modelsKey = key; + computeTop(); + allModels.sort((a, b) => (rank(a.id) - rank(b.id)) || a.id.localeCompare(b.id)); + renderModelRow("diff"); renderModelRow("apply"); + return allModels; + } finally { + if (modelsRequest === request) modelsRequest = null; + } + })(); + return request.promise; +} +function invalidateModels() { + allModels = null; + modelsLoaded = false; + modelsKey = null; + modelsRequest = null; + topOrder = new Map(); + $("modelpicker").style.display = "none"; + $("modellist").innerHTML = ""; + $("modelcount").textContent = ""; renderModelRow("diff"); renderModelRow("apply"); - return allModels; } function tagsFor(m) { const t = []; @@ -589,6 +623,55 @@

MARVIS

$("outTok").textContent = n(prog.outDone + prog.liveOut); $("cost").textContent = fmtCost(prog.cost + prog.liveCost); } +// Inline so each example remains a standalone file, including file:// use. +async function readCompletionStream(response, onChunk) { + if (!response.body || typeof response.body.getReader !== "function") throw new Error("No completion stream"); + const reader = response.body.getReader(), decoder = new TextDecoder(); + let buffer = "", dataLines = [], content = "", pricing = null, finished = false, finishReason = null; + const incomplete = (message) => { const error = new Error(message); error.name = "IncompleteCompletionError"; return error; }; + const dispatch = () => { + if (!dataLines.length) return false; + const data = dataLines.join("\n"); dataLines = []; + if (data.trim() === "[DONE]") return true; + let chunk; try { chunk = JSON.parse(data); } catch { return false; } + if (chunk.error) throw new Error("LLM stream error: " + JSON.stringify(chunk.error)); + const reason = chunk.choices?.[0]?.finish_reason; + if (reason === "length" || reason === "content_filter") throw incomplete("Incomplete completion: " + reason); + if (reason != null) finishReason = reason; + const text = chunk.choices?.[0]?.delta?.content; + if (typeof text === "string") content += text; + if (chunk.x_nanogpt_pricing) pricing = chunk.x_nanogpt_pricing; + onChunk?.(chunk); + return false; + }; + const line = (text) => { + if (text.endsWith("\r")) text = text.slice(0, -1); + if (!text) return dispatch(); + if (text.startsWith("data:")) dataLines.push(text.slice(text[5] === " " ? 6 : 5)); + return false; + }; + try { + while (!finished) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let at; + while ((at = buffer.indexOf("\n")) !== -1) { + const part = buffer.slice(0, at); buffer = buffer.slice(at + 1); + if (line(part)) { finished = true; break; } + } + } + buffer += decoder.decode(); + if (!finished && buffer) finished = line(buffer); + if (!finished) finished = dispatch(); + if (!finished && !["stop", "tool_calls", "function_call"].includes(finishReason)) throw incomplete("Stream ended before a terminal completion event"); + return { content, pricing }; + } finally { + try { if (typeof reader.cancel === "function") Promise.resolve(reader.cancel()).catch(() => {}); } catch { /* preserve result/error */ } + try { if (typeof reader.releaseLock === "function") reader.releaseLock(); } catch { /* already released */ } + } +} + function streamingCallLlm(phase) { return async ({ apiKey, baseUrl, model, messages, maxTokens = null, temperature = 1.0 }) => { $("phase").textContent = phase; @@ -609,34 +692,23 @@

MARVIS

}); } catch (e) { prog.liveIn -= inEst; prog.liveCost -= myCost; throw e; } if (!r.ok) { prog.liveIn -= inEst; prog.liveCost -= myCost; throw new Error(`LLM ${r.status}: ${await r.text()}`); } - const reader = r.body.getReader(); - const dec = new TextDecoder(); - let buf = "", content = "", reasoning = "", pricing = null, lastTick = 0; - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - buf += dec.decode(value, { stream: true }); - const lines = buf.split("\n"); - buf = lines.pop(); - for (const line of lines) { - const t = line.trim(); - if (!t.startsWith("data:")) continue; - const data = t.slice(5).trim(); - if (data === "[DONE]") continue; - let j; try { j = JSON.parse(data); } catch { continue; } - const d = j.choices?.[0]?.delta; - if (d) { - if (d.content) { content += d.content; appendText(phase, d.content); } - if (d.reasoning) reasoning += d.reasoning; - if (d.content || d.reasoning) { - const cur = words(content) + words(reasoning); - prog.liveOut += cur - prevOut; prevOut = cur; recost(); - const now = performance.now(); - if (now - lastTick > 80) { lastTick = now; paintUsage(); $("livelabel").textContent = content ? "" : "· thinking…"; } - } + let content = "", reasoning = "", pricing = null, lastTick = 0; + try { + ({ content, pricing } = await readCompletionStream(r, (chunk) => { + const delta = chunk.choices?.[0]?.delta; + if (!delta) return; + if (delta.content) { content += delta.content; appendText(phase, delta.content); } + if (delta.reasoning) reasoning += delta.reasoning; + if (delta.content || delta.reasoning) { + const current = words(content) + words(reasoning); + prog.liveOut += current - prevOut; prevOut = current; recost(); + const now = performance.now(); + if (now - lastTick > 80) { lastTick = now; paintUsage(); $("livelabel").textContent = content ? "" : "· thinking…"; } } - if (j.x_nanogpt_pricing) pricing = j.x_nanogpt_pricing; - } + })); + } catch (error) { + prog.liveIn -= inEst; prog.liveOut -= prevOut; prog.liveCost -= myCost; paintUsage(); + throw error; } prog.liveIn -= inEst; prog.liveOut -= prevOut; prog.liveCost -= myCost; prog.inDone += pricing?.inputTokens ?? inEst; @@ -654,31 +726,17 @@

MARVIS

// soul, look, theme) is the separate patchling pass below. // low-level SSE streamer — fetch + parse, calling onDelta(text) as content arrives -async function streamCompletion({ key, model, messages, onDelta }) { +async function streamCompletion({ key, model, messages, onDelta, signal }) { const r = await fetch(`${NANOGPT}/api/v1/chat/completions`, { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, body: JSON.stringify({ model, messages, temperature: 1.0, stream: true }), + signal, }); if (!r.ok) throw new Error(`LLM ${r.status}: ${(await r.text()).slice(0, 300)}`); - const reader = r.body.getReader(), dec = new TextDecoder(); - let buf = "", content = "", pricing = null; - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - buf += dec.decode(value, { stream: true }); - const lines = buf.split("\n"); buf = lines.pop(); - for (const line of lines) { - const t = line.trim(); - if (!t.startsWith("data:")) continue; - const data = t.slice(5).trim(); - if (data === "[DONE]") continue; - let j; try { j = JSON.parse(data); } catch { continue; } - const c = j.choices?.[0]?.delta?.content; - if (c) { content += c; onDelta?.(c); } - if (j.x_nanogpt_pricing) pricing = j.x_nanogpt_pricing; - } - } - return { content, pricing }; + return readCompletionStream(r, (chunk) => { + const content = chunk.choices?.[0]?.delta?.content; + if (content) onDelta?.(content); + }); } const capitalize = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s; @@ -730,6 +788,7 @@

MARVIS

function appendChat(block) { const cur = (files["chat.md"] || "").replace(/\s+$/, ""); files["chat.md"] = (cur ? cur + "\n\n" : "") + block + "\n"; + if (active === "chat.md") loadEditor(); } // append an empty MARVIS bubble to stream into, return its bubble node @@ -755,10 +814,15 @@

MARVIS

} async function sendChat() { - const key = getKey(); if (!key) return; + const key = getKey(); if (!key || chatRunning) return; const text = $("chatinput").value.trim(); if (!text) return; - if (active === "chat.md" && files["chat.md"] !== undefined) files["chat.md"] = $("editor").value; // pick up manual edits + if (active === "chat.md") commitEditor(); + const generation = ++chatGeneration, replyName = NAME, replyModel = MODEL; + const controller = new AbortController(); + chatAbort = controller; + chatRunning = true; + loadGeneration += 1; $("chatinput").value = ""; $("send").disabled = true; appendChat(`**${userLabel()}:** ${text}`); @@ -768,20 +832,28 @@

MARVIS

let reply = ""; try { const { content, pricing } = await streamCompletion({ - key, model: MODEL, messages: [{ role: "system", content: chatSystemPrompt() }, ...chatToMessages()], - onDelta: (chunk) => { reply += chunk; bubble.innerHTML = para(reply) + '▍'; $("stage-chat").scrollTop = $("stage-chat").scrollHeight; }, + key, model: replyModel, signal: controller.signal, messages: [{ role: "system", content: chatSystemPrompt() }, ...chatToMessages()], + onDelta: (chunk) => { if (generation !== chatGeneration) return; reply += chunk; bubble.innerHTML = para(reply) + '▍'; $("stage-chat").scrollTop = $("stage-chat").scrollHeight; }, }); + if (generation !== chatGeneration) return; reply = (content || reply).trim(); if (!reply) { bubble.innerHTML = "(it said nothing)"; return; } - appendChat(`**${NAME}:** ${reply}`); + appendChat(`**${replyName}:** ${reply}`); renderChat(); if (active === "chat.md") loadEditor(); showChatCost(pricing); persist(); } catch (e) { + if (generation !== chatGeneration) return; bubble.innerHTML = `⚠ ${esc(e.message)}`; } finally { - thinking(false); $("send").disabled = false; $("chatinput").focus(); + if (generation === chatGeneration) { + chatRunning = false; + chatAbort = null; + thinking(applying); + $("send").disabled = !getKey(); + $("chatinput").focus(); + } } } @@ -790,6 +862,56 @@

MARVIS

let active = null; // selected path let undoSnapshot = null; +/* PROJECT_RUN_STATE:START */ +// Persona edits and conversation resets have separate lifetimes: chatting may +// continue during a persona change, but a clear/load invalidates both operations. +let projectRevision = 0, applyGeneration = 0, applying = false; +let chatGeneration = 0, chatRunning = false, chatAbort = null, loadGeneration = 0; +function projectEdited() { + projectRevision += 1; + loadGeneration += 1; + undoSnapshot = null; + $("undo").disabled = true; +} +function invalidateChat() { + chatGeneration += 1; + chatRunning = false; + chatAbort?.abort(); + chatAbort = null; + thinking(applying); + $("send").disabled = !getKey(); +} +function commitEditor() { + if (active != null && files[active] !== undefined && files[active] !== $("editor").value) { + files[active] = $("editor").value; + if (active === "chat.md") { invalidateChat(); loadGeneration += 1; } + else projectEdited(); + } +} +function currentProjectRun(id, revision) { + return id === applyGeneration && revision === projectRevision; +} +function discardProjectRun(id, direction) { + if (id !== applyGeneration) return; + if (!$("direction").value.trim()) $("direction").value = direction; + $("changes").textContent = "Kept your newer edits. Run again to apply this request to the new version."; + $("phase").textContent = "newer edits kept"; + document.querySelector("#progress .spin").style.display = "none"; +} +function resetProjectOperations() { + applyGeneration += 1; + applying = false; + projectEdited(); + invalidateChat(); + faceToken += 1; + faceLoading(false); + clearTimeout(persistTimer); + $("progress").style.display = "none"; + $("run").disabled = !getKey(); +} +/* PROJECT_RUN_STATE:END */ + + async function loadSeed() { try { const man = await (await fetch("marvis/manifest.json", { cache: "no-store" })).json(); @@ -851,7 +973,7 @@

MARVIS

host.appendChild(add); } function selectFile(path) { - if (active && files[active] !== undefined) files[active] = $("editor").value; + commitEditor(); active = path; $("activelabel").textContent = path; $("editor").value = files[path] ?? ""; @@ -863,11 +985,14 @@

MARVIS

if (!path) return; const p = path.trim(); if (!p || files[p] !== undefined) { if (files[p] !== undefined) selectFile(p); return; } + projectEdited(); files[p] = "# " + p.replace(/\.md$/, "") + "\n\n"; renderTabs(); selectFile(p); persist(); } function deleteFile(path) { if (!confirm("Delete " + path + " ?")) return; + if (path === "chat.md") invalidateChat(); + projectEdited(); delete files[path]; if (active === path) active = fileOrder()[0] || null; renderTabs(); loadEditor(); persist(); @@ -875,7 +1000,7 @@

MARVIS

let persistTimer = null; $("editor").addEventListener("input", () => { if (active == null) return; - files[active] = $("editor").value; + commitEditor(); if (active === "chat.md") renderChat(); else if (active === "mood.md") updateMood(); else if (active === "config.json") applyConfig(); // hot-reload name/copy/prompt-default @@ -988,12 +1113,13 @@

MARVIS

} async function renderFace(opts = {}) { const key = getKey(); if (!key) return; - if (active && files[active] !== undefined) files[active] = $("editor").value; + commitEditor(); + const myToken = ++faceToken; const prompt = portraitPrompt(); if (!prompt) { setFaceStatus("portrait.md is empty."); return; } const nonce = opts.force ? ++faceNonce : 0; const h = await promptHash(JSON.stringify({ m: IMG_MODEL, s: IMG_SIZE, p: prompt, nonce })); - const myToken = ++faceToken; + if (myToken !== faceToken) return; if (imageCache[h]) { showFace(imageCache[h]); idleFaceStatus(); persist(); return; } faceLoading(true); setFaceStatus(`◐ rendering its face with ${IMG_MODEL}…`); @@ -1043,9 +1169,13 @@

MARVIS

// ---------- run a beat ---------- function thinking(on, text) { $("thinktext").textContent = text || `${NAME} is reworking who it is…`; $("thinking").style.display = on ? "flex" : "none"; } async function run() { - const key = getKey(); if (!key) return; - if (active && files[active] !== undefined) files[active] = $("editor").value; - undoSnapshot = JSON.parse(JSON.stringify(files)); + const key = getKey(); if (!key || applying) return; + commitEditor(); + const inputFiles = { ...files }; + const runId = ++applyGeneration, runRevision = projectRevision; + const diffModel = MODEL, applyModel = APPLY; + const direction = $("direction").value; + applying = true; $("run").disabled = true; $("undo").disabled = true; curPhase = ""; $("diff").textContent = ""; $("changes").textContent = ""; prog.inDone = prog.outDone = prog.cost = prog.liveIn = prog.liveOut = prog.liveCost = 0; paintUsage(); @@ -1054,30 +1184,33 @@

MARVIS

const t0 = performance.now(); const timer = setInterval(() => { $("elapsed").textContent = ((performance.now() - t0) / 1000).toFixed(1); }, 100); - const portraitBefore = files["portrait.md"]; + const portraitBefore = inputFiles["portrait.md"]; try { - const goal = composeGoal($("direction").value); + const goal = composeGoal(direction); $("direction").value = ""; // each change is one-shot; blank lets it drift // chat.md is the live conversation — keep it OUT of the diff env so the pass can // never read or rewrite it. The recent log goes in as read-only context instead. - const persona = { ...files }; delete persona["chat.md"]; - const diff = await generateDiff(buildEnvironment(persona), goal, { apiKey: key, model: MODEL, callLlm: streamingCallLlm("Changing its personality") }); + const persona = { ...inputFiles }; delete persona["chat.md"]; + const diff = await generateDiff(buildEnvironment(persona), goal, { apiKey: key, model: diffModel, callLlm: streamingCallLlm("Changing its personality") }); + if (!currentProjectRun(runId, runRevision)) { discardProjectRun(runId, direction); return; } renderDiff(diff); const parsed = parseDiffPerFile(diff); if (!diff.trim() || !parsed.length) { $("changes").textContent = "⚠ The model returned no change. Try a clearer direction."; } else { const applyLlm = streamingCallLlm("Applying the change"); - const before = { ...files }; const updatedPersona = await smartapply(diff, persona, { - apiKey: key, model: APPLY, + apiKey: key, model: applyModel, callLlmForApply: (p, o, d, m, o2) => callLlmForApply(p, o, d, m, { ...o2, callLlm: applyLlm }), }); + if (!currentProjectRun(runId, runRevision)) { discardProjectRun(runId, direction); return; } + const before = { ...inputFiles, "chat.md": files["chat.md"] }; const updated = { ...updatedPersona, "chat.md": files["chat.md"] }; // restore the untouched conversation const beforeKeys = new Set(Object.keys(before)), afterKeys = new Set(Object.keys(updated)); const added = [...afterKeys].filter((k) => !beforeKeys.has(k)); const removed = [...beforeKeys].filter((k) => !afterKeys.has(k)); const changed = [...afterKeys].filter((k) => beforeKeys.has(k) && before[k] !== updated[k]); + undoSnapshot = inputFiles; files = updated; if (active == null || files[active] === undefined) active = fileOrder()[0] || null; applyStyle(); applyConfig(); // MARVIS may have rewritten its own theme / config this beat @@ -1089,10 +1222,18 @@

MARVIS

} $("phase").textContent = "done"; document.querySelector("#progress .spin").style.display = "none"; } catch (e) { + if (!currentProjectRun(runId, runRevision)) { discardProjectRun(runId, direction); return; } + if (!$("direction").value.trim()) $("direction").value = direction; $("changes").textContent = "Error: " + e.message; $("progress").style.display = "none"; } finally { - clearInterval(timer); $("run").disabled = false; thinking(false); + clearInterval(timer); + if (runId === applyGeneration) { + applying = false; + $("run").disabled = !getKey(); + $("undo").disabled = !undoSnapshot; + thinking(chatRunning, chatRunning ? `${NAME} is replying…` : undefined); + } } } $("run").addEventListener("click", run); @@ -1100,8 +1241,10 @@

MARVIS

$("send").addEventListener("click", sendChat); $("chatinput").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); if (!$("send").disabled) sendChat(); } }); $("undo").addEventListener("click", () => { - if (!undoSnapshot) return; - files = { ...undoSnapshot, "chat.md": files["chat.md"] }; undoSnapshot = null; // keep the live conversation + if (!undoSnapshot || applying) return; + const previous = undoSnapshot; + projectEdited(); + files = { ...previous, "chat.md": files["chat.md"] }; // keep the live conversation if (active == null || files[active] === undefined) active = fileOrder()[0] || null; renderTabs(); loadEditor(); applyStyle(); applyConfig(); $("undo").disabled = true; $("changes").textContent = "↶ reverted the personality change."; persist(); @@ -1111,12 +1254,13 @@

MARVIS

let toastTimer = null; function toast(msg) { const el = $("toast"); el.style.opacity = "1"; el.textContent = msg; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.style.opacity = "0"; }, 6000); } -function filesNow() { if (active && files[active] !== undefined) files[active] = $("editor").value; return { ...files }; } +function filesNow() { commitEditor(); return { ...files }; } function snapshot(withFace) { return { v: 1, name: NAME, files: filesNow(), face: withFace ? currentImage : undefined }; } function persist() { try { localStorage.setItem(LS_KEY, JSON.stringify(snapshot(true))); } catch { /* quota / private mode */ } } function loadState(st) { if (!st || !st.files || !Object.keys(st.files).length) return false; + resetProjectOperations(); files = { ...st.files }; for (const f of CONFIG_FILES) if (files[f] == null) files[f] = EMBEDDED_SEED[f]; // backfill config/brief/theme onto older saves active = fileOrder().includes("soul.md") ? "soul.md" : (fileOrder()[0] || null); @@ -1153,9 +1297,17 @@

MARVIS

$("loadbtn").addEventListener("click", () => $("loadfile").click()); $("loadfile").addEventListener("change", async (e) => { const f = e.target.files[0]; if (!f) return; - try { if (loadState(JSON.parse(await f.text()))) { persist(); toast("loaded — welcome back, " + NAME); } else toast("that file has no files in it"); } - catch (err) { toast("couldn't read that file: " + err.message); } - e.target.value = ""; + const generation = ++loadGeneration; + try { + const text = await f.text(); + if (generation !== loadGeneration) return; + if (loadState(JSON.parse(text))) { persist(); toast("loaded — welcome back, " + NAME); } + else toast("that file has no files in it"); + } catch (err) { + if (generation === loadGeneration) toast("couldn't read that file: " + err.message); + } finally { + if (e.target.files[0] === f) e.target.value = ""; + } }); // Share → fold the whole being (persona + config + prompt + theme) into the URL hash, no server $("sharebtn").addEventListener("click", async () => { @@ -1176,7 +1328,8 @@

MARVIS

// Clear conversation → reset chat / mood / memory to the opening; keep who it is (soul, look, brief, config, face) $("clearbtn").addEventListener("click", () => { if (!confirm("Clear the conversation? The chat, its mood and its memory start fresh — but it keeps who it is (soul, look, prompt, config and face).")) return; - if (active && files[active] !== undefined) files[active] = $("editor").value; + commitEditor(); + resetProjectOperations(); for (const f of ["chat.md", "mood.md", "memory.md"]) if (EMBEDDED_SEED[f] != null) files[f] = EMBEDDED_SEED[f]; if (active == null || files[active] === undefined) active = fileOrder()[0] || null; renderTabs(); loadEditor(); renderChat(); updateMood(); diff --git a/object3d.html b/object3d.html index 1cb88dd..33f213a 100644 --- a/object3d.html +++ b/object3d.html @@ -191,7 +191,7 @@

patchling · 3d object studio

// ---------- key storage ---------- const getKey = () => localStorage.getItem("ngpt_key"); -const setKey = (k) => { k ? localStorage.setItem("ngpt_key", k) : localStorage.removeItem("ngpt_key"); if (k) setEnv("GPTDIFF_LLM_API_KEY", k); }; +const setKey = (k) => { k ? localStorage.setItem("ngpt_key", k) : localStorage.removeItem("ngpt_key"); if (k) setEnv("GPTDIFF_LLM_API_KEY", k); invalidateModels(); }; if (getKey()) setEnv("GPTDIFF_LLM_API_KEY", getKey()); // ---------- browser OAuth PKCE ---------- @@ -246,7 +246,7 @@

patchling · 3d object studio

$("signin").style.display = signed ? "none" : ""; $("pastebtn").style.display = signed ? "none" : ""; $("signout").style.display = signed ? "" : "none"; - $("run").disabled = !signed; + $("run").disabled = !signed || applying; } $("signin").addEventListener("click", (e) => { e.preventDefault(); signIn().catch((err) => $("status").textContent = "sign-in error: " + err.message); }); $("pastebtn").addEventListener("click", () => { $("key").style.display = "block"; $("keyinput").focus(); }); @@ -255,6 +255,7 @@

patchling · 3d object studio

// ---------- model selection ---------- let allModels = null, topOrder = new Map(); +let modelsLoaded = false, modelsKey = null, modelsRequest = null; const LABS = [ ["OpenAI", /^openai\/(gpt-[0-9]|o[1-9])/i], ["Anthropic", /claude-(opus|sonnet)/i], @@ -295,18 +296,51 @@

patchling · 3d object studio

$(`modeltags-${target}`).replaceChildren(...tagEls(m)); } async function loadModels() { - if (allModels) return allModels; - let arr = []; - try { - const headers = {}; const k = getKey(); if (k) headers.Authorization = `Bearer ${k}`; - arr = (await (await fetch(`${NANOGPT}/api/v1/models?detailed=true`, { headers })).json()).data || []; - } catch { /* ignore */ } - allModels = arr.map((m) => ({ id: m.id, prompt: m.pricing?.prompt, completion: m.pricing?.completion, owned_by: m.owned_by, created: m.created })).filter((m) => m.id); - if (!allModels.length) allModels = [{ id: DEFAULT_MODEL }]; - computeTop(); - allModels.sort((a, b) => (rank(a.id) - rank(b.id)) || a.id.localeCompare(b.id)); + const key = getKey() || ""; + if (modelsLoaded && modelsKey === key) return allModels; + if (modelsRequest && modelsRequest.key === key) return modelsRequest.promise; + const request = { key, promise: null }; + modelsRequest = request; + request.promise = (async () => { + let next, loaded = false; + try { + const headers = {}; if (key) headers.Authorization = `Bearer ${key}`; + const response = await fetch("https://nano-gpt.com/api/v1/models?detailed=true", { headers }); + if (!response.ok) throw new Error("Model catalog request failed"); + const data = (await response.json()).data; + if (!Array.isArray(data)) throw new Error("Invalid model catalog"); + next = data.filter((m) => m && typeof m.id === "string" && m.id.trim()).map((m) => ({ + id: m.id, owned_by: m.owned_by, prompt: m.pricing?.prompt, completion: m.pricing?.completion, created: m.created, + })); + if (!next.length) throw new Error("Empty model catalog"); + loaded = true; + } catch { next = [{ id: DEFAULT_MODEL }]; } // exact-id entry remains available; the next open retries + try { + // A sign-in/out or newer request must not inherit an older account's catalog/prices. + if (modelsRequest !== request || (getKey() || "") !== key) return allModels || [{ id: DEFAULT_MODEL }]; + allModels = next; + modelsLoaded = loaded; + modelsKey = key; + computeTop(); + allModels.sort((a, b) => (rank(a.id) - rank(b.id)) || a.id.localeCompare(b.id)); + renderModelRow("diff"); renderModelRow("apply"); + return allModels; + } finally { + if (modelsRequest === request) modelsRequest = null; + } + })(); + return request.promise; +} +function invalidateModels() { + allModels = null; + modelsLoaded = false; + modelsKey = null; + modelsRequest = null; + topOrder = new Map(); + $("modelpicker").style.display = "none"; + $("modellist").innerHTML = ""; + $("modelcount").textContent = ""; renderModelRow("diff"); renderModelRow("apply"); - return allModels; } function tagsFor(m) { const t = []; @@ -369,6 +403,55 @@

patchling · 3d object studio

$("outTok").textContent = n(prog.outDone + prog.liveOut); $("cost").textContent = fmtCost(prog.cost + prog.liveCost); // committed + in-flight estimate } +// Inline so each example remains a standalone file, including file:// use. +async function readCompletionStream(response, onChunk) { + if (!response.body || typeof response.body.getReader !== "function") throw new Error("No completion stream"); + const reader = response.body.getReader(), decoder = new TextDecoder(); + let buffer = "", dataLines = [], content = "", pricing = null, finished = false, finishReason = null; + const incomplete = (message) => { const error = new Error(message); error.name = "IncompleteCompletionError"; return error; }; + const dispatch = () => { + if (!dataLines.length) return false; + const data = dataLines.join("\n"); dataLines = []; + if (data.trim() === "[DONE]") return true; + let chunk; try { chunk = JSON.parse(data); } catch { return false; } + if (chunk.error) throw new Error("LLM stream error: " + JSON.stringify(chunk.error)); + const reason = chunk.choices?.[0]?.finish_reason; + if (reason === "length" || reason === "content_filter") throw incomplete("Incomplete completion: " + reason); + if (reason != null) finishReason = reason; + const text = chunk.choices?.[0]?.delta?.content; + if (typeof text === "string") content += text; + if (chunk.x_nanogpt_pricing) pricing = chunk.x_nanogpt_pricing; + onChunk?.(chunk); + return false; + }; + const line = (text) => { + if (text.endsWith("\r")) text = text.slice(0, -1); + if (!text) return dispatch(); + if (text.startsWith("data:")) dataLines.push(text.slice(text[5] === " " ? 6 : 5)); + return false; + }; + try { + while (!finished) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let at; + while ((at = buffer.indexOf("\n")) !== -1) { + const part = buffer.slice(0, at); buffer = buffer.slice(at + 1); + if (line(part)) { finished = true; break; } + } + } + buffer += decoder.decode(); + if (!finished && buffer) finished = line(buffer); + if (!finished) finished = dispatch(); + if (!finished && !["stop", "tool_calls", "function_call"].includes(finishReason)) throw incomplete("Stream ended before a terminal completion event"); + return { content, pricing }; + } finally { + try { if (typeof reader.cancel === "function") Promise.resolve(reader.cancel()).catch(() => {}); } catch { /* preserve result/error */ } + try { if (typeof reader.releaseLock === "function") reader.releaseLock(); } catch { /* already released */ } + } +} + function streamingCallLlm(phase) { return async ({ apiKey, baseUrl, model, messages, maxTokens = null, temperature = 1.0 }) => { $("phase").textContent = phase; @@ -389,34 +472,23 @@

patchling · 3d object studio

}); } catch (e) { prog.liveIn -= inEst; prog.liveCost -= myCost; throw e; } if (!r.ok) { prog.liveIn -= inEst; prog.liveCost -= myCost; throw new Error(`LLM ${r.status}: ${await r.text()}`); } - const reader = r.body.getReader(); - const dec = new TextDecoder(); - let buf = "", content = "", reasoning = "", pricing = null, lastTick = 0; - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - buf += dec.decode(value, { stream: true }); - const lines = buf.split("\n"); - buf = lines.pop(); - for (const line of lines) { - const t = line.trim(); - if (!t.startsWith("data:")) continue; - const data = t.slice(5).trim(); - if (data === "[DONE]") continue; - let j; try { j = JSON.parse(data); } catch { continue; } - const d = j.choices?.[0]?.delta; - if (d) { - if (d.content) { content += d.content; appendText(phase, d.content); } - if (d.reasoning) reasoning += d.reasoning; - if (d.content || d.reasoning) { - const cur = words(content) + words(reasoning); - prog.liveOut += cur - prevOut; prevOut = cur; recost(); - const now = performance.now(); - if (now - lastTick > 80) { lastTick = now; paintUsage(); $("livenote").textContent = content ? "" : "· thinking…"; } - } + let content = "", reasoning = "", pricing = null, lastTick = 0; + try { + ({ content, pricing } = await readCompletionStream(r, (chunk) => { + const delta = chunk.choices?.[0]?.delta; + if (!delta) return; + if (delta.content) { content += delta.content; appendText(phase, delta.content); } + if (delta.reasoning) reasoning += delta.reasoning; + if (delta.content || delta.reasoning) { + const current = words(content) + words(reasoning); + prog.liveOut += current - prevOut; prevOut = current; recost(); + const now = performance.now(); + if (now - lastTick > 80) { lastTick = now; paintUsage(); $("live").textContent = content ? "" : "· thinking…"; } } - if (j.x_nanogpt_pricing) pricing = j.x_nanogpt_pricing; - } + })); + } catch (error) { + prog.liveIn -= inEst; prog.liveOut -= prevOut; prog.liveCost -= myCost; paintUsage(); + throw error; } prog.liveIn -= inEst; prog.liveOut -= prevOut; prog.liveCost -= myCost; prog.inDone += pricing?.inputTokens ?? inEst; @@ -432,6 +504,32 @@

patchling · 3d object studio

let active = null; // selected path let undoSnapshot = null; +/* PROJECT_RUN_STATE:START */ +// Only publish a result while it still belongs to the version the user ran. +let projectRevision = 0, applyGeneration = 0, applying = false; +function projectEdited() { + projectRevision += 1; + undoSnapshot = null; + $("undo").disabled = true; +} +function commitEditor() { + if (active != null && files[active] !== undefined && files[active] !== $("editor").value) { + files[active] = $("editor").value; + projectEdited(); + } +} +function currentProjectRun(id, revision) { + return id === applyGeneration && revision === projectRevision; +} +function discardProjectRun(id) { + if (id !== applyGeneration) return; + $("changes").textContent = "Kept your newer edits. Run again to apply this request to the new version."; + $("phase").textContent = "newer edits kept"; + document.querySelector("#progress .spin").style.display = "none"; +} +/* PROJECT_RUN_STATE:END */ + + // Built-in 3D scene. preview.html stitches the pieces together with Three.js. const EMBEDDED_SEED = { "preview.html": ` @@ -680,7 +778,7 @@

patchling · 3d object studio

host.appendChild(add); } function selectFile(path) { - if (active && files[active] !== undefined) files[active] = $("editor").value; // commit edits + commitEditor(); active = path; $("activelabel").textContent = path; $("editor").value = files[path] ?? ""; @@ -692,16 +790,18 @@

patchling · 3d object studio

if (!path) return; const p = path.trim(); if (!p || files[p] !== undefined) { if (files[p] !== undefined) selectFile(p); return; } + projectEdited(); files[p] = ""; renderTabs(); selectFile(p); scheduleLive(); } function deleteFile(path) { if (!confirm("Delete " + path + " ?")) return; + projectEdited(); delete files[path]; if (active === path) active = fileOrder()[0] || null; renderTabs(); loadEditor(); scheduleLive(); } -$("editor").addEventListener("input", () => { if (active != null) { files[active] = $("editor").value; scheduleLive(); } }); +$("editor").addEventListener("input", () => { if (active != null) { commitEditor(); scheduleLive(); } }); // ---------- preview bundler: stitch every file into one HTML ---------- function bundle(map) { @@ -735,7 +835,7 @@

patchling · 3d object studio

// ---------- live preview ---------- let liveTimer = null; function refreshPreview() { - if (active && files[active] !== undefined) files[active] = $("editor").value; + commitEditor(); $("pvframe").srcdoc = bundle(files); } function scheduleLive() { @@ -803,9 +903,12 @@

patchling · 3d object studio

// ---------- run: diff → smartapply → reconcile → re-render ---------- async function run() { - const key = getKey(); if (!key) return; - if (active && files[active] !== undefined) files[active] = $("editor").value; // commit edits - undoSnapshot = JSON.parse(JSON.stringify(files)); + const key = getKey(); if (!key || applying) return; + commitEditor(); + const inputFiles = { ...files }; + const runId = ++applyGeneration, runRevision = projectRevision; + const goal = $("goal").value, diffModel = MODEL, applyModel = APPLY; + applying = true; $("run").disabled = true; $("undo").disabled = true; curPhase = ""; $("diff").textContent = ""; $("changes").textContent = ""; prog.inDone = prog.outDone = prog.cost = prog.liveIn = prog.liveOut = prog.liveCost = 0; paintUsage(); $("progress").style.display = "block"; document.querySelector("#progress .spin").style.display = ""; @@ -814,22 +917,25 @@

patchling · 3d object studio

const timer = setInterval(() => { $("elapsed").textContent = ((performance.now() - t0) / 1000).toFixed(1); }, 100); try { - const diff = await generateDiff(buildEnvironment(files), $("goal").value, { apiKey: key, model: MODEL, callLlm: streamingCallLlm("Generating diff") }); + const diff = await generateDiff(buildEnvironment(inputFiles), goal, { apiKey: key, model: diffModel, callLlm: streamingCallLlm("Generating diff") }); + if (!currentProjectRun(runId, runRevision)) { discardProjectRun(runId); return; } renderDiff(diff); const parsed = parseDiffPerFile(diff); if (!diff.trim() || !parsed.length) { $("changes").textContent = "⚠ The model returned no applicable diff. Try rephrasing the goal."; } else { const applyLlm = streamingCallLlm("Applying diff"); - const before = { ...files }; - const updated = await smartapply(diff, files, { - apiKey: key, model: APPLY, + const before = inputFiles; + const updated = await smartapply(diff, inputFiles, { + apiKey: key, model: applyModel, callLlmForApply: (p, o, d, m, o2) => callLlmForApply(p, o, d, m, { ...o2, callLlm: applyLlm }), }); + if (!currentProjectRun(runId, runRevision)) { discardProjectRun(runId); return; } const beforeKeys = new Set(Object.keys(before)), afterKeys = new Set(Object.keys(updated)); const added = [...afterKeys].filter((k) => !beforeKeys.has(k)); const removed = [...beforeKeys].filter((k) => !afterKeys.has(k)); const changed = [...afterKeys].filter((k) => beforeKeys.has(k) && before[k] !== updated[k]); + undoSnapshot = inputFiles; files = updated; if (active == null || files[active] === undefined) active = fileOrder()[0] || null; renderTabs(); loadEditor(); refreshPreview(); @@ -838,17 +944,25 @@

patchling · 3d object studio

} $("phase").textContent = "done"; document.querySelector("#progress .spin").style.display = "none"; } catch (e) { + if (!currentProjectRun(runId, runRevision)) { discardProjectRun(runId); return; } $("changes").textContent = "Error: " + e.message; $("progress").style.display = "none"; } finally { - clearInterval(timer); $("run").disabled = false; + clearInterval(timer); + if (runId === applyGeneration) { + applying = false; + $("run").disabled = !getKey(); + $("undo").disabled = !undoSnapshot; + } } } $("run").addEventListener("click", run); $("goal").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); if (!$("run").disabled) run(); } }); $("undo").addEventListener("click", () => { - if (!undoSnapshot) return; - files = undoSnapshot; undoSnapshot = null; + if (!undoSnapshot || applying) return; + const previous = undoSnapshot; + projectEdited(); + files = previous; if (active == null || files[active] === undefined) active = fileOrder()[0] || null; renderTabs(); loadEditor(); refreshPreview(); $("undo").disabled = true; $("changes").textContent = "↶ reverted to the previous version."; diff --git a/overlay.html b/overlay.html index a93e06e..2a57394 100644 --- a/overlay.html +++ b/overlay.html @@ -207,7 +207,7 @@

patchling · realtime video overlay

// ---------- key storage ---------- const getKey = () => localStorage.getItem("ngpt_key"); -const setKey = (k) => { k ? localStorage.setItem("ngpt_key", k) : localStorage.removeItem("ngpt_key"); if (k) setEnv("GPTDIFF_LLM_API_KEY", k); }; +const setKey = (k) => { k ? localStorage.setItem("ngpt_key", k) : localStorage.removeItem("ngpt_key"); if (k) setEnv("GPTDIFF_LLM_API_KEY", k); invalidateModels(); }; if (getKey()) setEnv("GPTDIFF_LLM_API_KEY", getKey()); // ---------- browser OAuth PKCE ---------- @@ -262,7 +262,7 @@

patchling · realtime video overlay

$("signin").style.display = signed ? "none" : ""; $("pastebtn").style.display = signed ? "none" : ""; $("signout").style.display = signed ? "" : "none"; - $("run").disabled = !signed; + $("run").disabled = !signed || applying; } $("signin").addEventListener("click", (e) => { e.preventDefault(); signIn().catch((err) => $("status").textContent = "sign-in error: " + err.message); }); $("pastebtn").addEventListener("click", () => { $("key").style.display = "block"; $("keyinput").focus(); }); @@ -271,6 +271,7 @@

patchling · realtime video overlay

// ---------- model selection ---------- let allModels = null, topOrder = new Map(); +let modelsLoaded = false, modelsKey = null, modelsRequest = null; const LABS = [ ["OpenAI", /^openai\/(gpt-[0-9]|o[1-9])/i], ["Anthropic", /claude-(opus|sonnet)/i], @@ -311,18 +312,51 @@

patchling · realtime video overlay

$(`modeltags-${target}`).replaceChildren(...tagEls(m)); } async function loadModels() { - if (allModels) return allModels; - let arr = []; - try { - const headers = {}; const k = getKey(); if (k) headers.Authorization = `Bearer ${k}`; - arr = (await (await fetch(`${NANOGPT}/api/v1/models?detailed=true`, { headers })).json()).data || []; - } catch { /* ignore */ } - allModels = arr.map((m) => ({ id: m.id, prompt: m.pricing?.prompt, completion: m.pricing?.completion, owned_by: m.owned_by, created: m.created })).filter((m) => m.id); - if (!allModels.length) allModels = [{ id: DEFAULT_MODEL }]; - computeTop(); - allModels.sort((a, b) => (rank(a.id) - rank(b.id)) || a.id.localeCompare(b.id)); + const key = getKey() || ""; + if (modelsLoaded && modelsKey === key) return allModels; + if (modelsRequest && modelsRequest.key === key) return modelsRequest.promise; + const request = { key, promise: null }; + modelsRequest = request; + request.promise = (async () => { + let next, loaded = false; + try { + const headers = {}; if (key) headers.Authorization = `Bearer ${key}`; + const response = await fetch("https://nano-gpt.com/api/v1/models?detailed=true", { headers }); + if (!response.ok) throw new Error("Model catalog request failed"); + const data = (await response.json()).data; + if (!Array.isArray(data)) throw new Error("Invalid model catalog"); + next = data.filter((m) => m && typeof m.id === "string" && m.id.trim()).map((m) => ({ + id: m.id, owned_by: m.owned_by, prompt: m.pricing?.prompt, completion: m.pricing?.completion, created: m.created, + })); + if (!next.length) throw new Error("Empty model catalog"); + loaded = true; + } catch { next = [{ id: DEFAULT_MODEL }]; } // exact-id entry remains available; the next open retries + try { + // A sign-in/out or newer request must not inherit an older account's catalog/prices. + if (modelsRequest !== request || (getKey() || "") !== key) return allModels || [{ id: DEFAULT_MODEL }]; + allModels = next; + modelsLoaded = loaded; + modelsKey = key; + computeTop(); + allModels.sort((a, b) => (rank(a.id) - rank(b.id)) || a.id.localeCompare(b.id)); + renderModelRow("diff"); renderModelRow("apply"); + return allModels; + } finally { + if (modelsRequest === request) modelsRequest = null; + } + })(); + return request.promise; +} +function invalidateModels() { + allModels = null; + modelsLoaded = false; + modelsKey = null; + modelsRequest = null; + topOrder = new Map(); + $("modelpicker").style.display = "none"; + $("modellist").innerHTML = ""; + $("modelcount").textContent = ""; renderModelRow("diff"); renderModelRow("apply"); - return allModels; } function tagsFor(m) { const t = []; @@ -385,6 +419,55 @@

patchling · realtime video overlay

$("outTok").textContent = n(prog.outDone + prog.liveOut); $("cost").textContent = fmtCost(prog.cost + prog.liveCost); // committed + in-flight estimate } +// Inline so each example remains a standalone file, including file:// use. +async function readCompletionStream(response, onChunk) { + if (!response.body || typeof response.body.getReader !== "function") throw new Error("No completion stream"); + const reader = response.body.getReader(), decoder = new TextDecoder(); + let buffer = "", dataLines = [], content = "", pricing = null, finished = false, finishReason = null; + const incomplete = (message) => { const error = new Error(message); error.name = "IncompleteCompletionError"; return error; }; + const dispatch = () => { + if (!dataLines.length) return false; + const data = dataLines.join("\n"); dataLines = []; + if (data.trim() === "[DONE]") return true; + let chunk; try { chunk = JSON.parse(data); } catch { return false; } + if (chunk.error) throw new Error("LLM stream error: " + JSON.stringify(chunk.error)); + const reason = chunk.choices?.[0]?.finish_reason; + if (reason === "length" || reason === "content_filter") throw incomplete("Incomplete completion: " + reason); + if (reason != null) finishReason = reason; + const text = chunk.choices?.[0]?.delta?.content; + if (typeof text === "string") content += text; + if (chunk.x_nanogpt_pricing) pricing = chunk.x_nanogpt_pricing; + onChunk?.(chunk); + return false; + }; + const line = (text) => { + if (text.endsWith("\r")) text = text.slice(0, -1); + if (!text) return dispatch(); + if (text.startsWith("data:")) dataLines.push(text.slice(text[5] === " " ? 6 : 5)); + return false; + }; + try { + while (!finished) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let at; + while ((at = buffer.indexOf("\n")) !== -1) { + const part = buffer.slice(0, at); buffer = buffer.slice(at + 1); + if (line(part)) { finished = true; break; } + } + } + buffer += decoder.decode(); + if (!finished && buffer) finished = line(buffer); + if (!finished) finished = dispatch(); + if (!finished && !["stop", "tool_calls", "function_call"].includes(finishReason)) throw incomplete("Stream ended before a terminal completion event"); + return { content, pricing }; + } finally { + try { if (typeof reader.cancel === "function") Promise.resolve(reader.cancel()).catch(() => {}); } catch { /* preserve result/error */ } + try { if (typeof reader.releaseLock === "function") reader.releaseLock(); } catch { /* already released */ } + } +} + function streamingCallLlm(phase) { return async ({ apiKey, baseUrl, model, messages, maxTokens = null, temperature = 1.0 }) => { $("phase").textContent = phase; @@ -407,34 +490,23 @@

patchling · realtime video overlay

}); } catch (e) { prog.liveIn -= inEst; prog.liveCost -= myCost; throw e; } if (!r.ok) { prog.liveIn -= inEst; prog.liveCost -= myCost; throw new Error(`LLM ${r.status}: ${await r.text()}`); } - const reader = r.body.getReader(); - const dec = new TextDecoder(); - let buf = "", content = "", reasoning = "", pricing = null, lastTick = 0; - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - buf += dec.decode(value, { stream: true }); - const lines = buf.split("\n"); - buf = lines.pop(); - for (const line of lines) { - const t = line.trim(); - if (!t.startsWith("data:")) continue; - const data = t.slice(5).trim(); - if (data === "[DONE]") continue; - let j; try { j = JSON.parse(data); } catch { continue; } - const d = j.choices?.[0]?.delta; - if (d) { - if (d.content) { content += d.content; appendText(phase, d.content); } - if (d.reasoning) reasoning += d.reasoning; - if (d.content || d.reasoning) { - const cur = words(content) + words(reasoning); - prog.liveOut += cur - prevOut; prevOut = cur; recost(); - const now = performance.now(); - if (now - lastTick > 80) { lastTick = now; paintUsage(); $("live").textContent = content ? "" : "· thinking…"; } - } + let content = "", reasoning = "", pricing = null, lastTick = 0; + try { + ({ content, pricing } = await readCompletionStream(r, (chunk) => { + const delta = chunk.choices?.[0]?.delta; + if (!delta) return; + if (delta.content) { content += delta.content; appendText(phase, delta.content); } + if (delta.reasoning) reasoning += delta.reasoning; + if (delta.content || delta.reasoning) { + const current = words(content) + words(reasoning); + prog.liveOut += current - prevOut; prevOut = current; recost(); + const now = performance.now(); + if (now - lastTick > 80) { lastTick = now; paintUsage(); $("live").textContent = content ? "" : "· thinking…"; } } - if (j.x_nanogpt_pricing) pricing = j.x_nanogpt_pricing; - } + })); + } catch (error) { + prog.liveIn -= inEst; prog.liveOut -= prevOut; prog.liveCost -= myCost; paintUsage(); + throw error; } prog.liveIn -= inEst; prog.liveOut -= prevOut; prog.liveCost -= myCost; prog.inDone += pricing?.inputTokens ?? inEst; @@ -450,6 +522,32 @@

patchling · realtime video overlay

let active = null; // selected path let undoSnapshot = null; +/* PROJECT_RUN_STATE:START */ +// Only publish a result while it still belongs to the version the user ran. +let projectRevision = 0, applyGeneration = 0, applying = false; +function projectEdited() { + projectRevision += 1; + undoSnapshot = null; + $("undo").disabled = true; +} +function commitEditor() { + if (active != null && files[active] !== undefined && files[active] !== $("editor").value) { + files[active] = $("editor").value; + projectEdited(); + } +} +function currentProjectRun(id, revision) { + return id === applyGeneration && revision === projectRevision; +} +function discardProjectRun(id) { + if (id !== applyGeneration) return; + $("changes").textContent = "Kept your newer edits. Run again to apply this request to the new version."; + $("phase").textContent = "newer edits kept"; + document.querySelector("#progress .spin").style.display = "none"; +} +/* PROJECT_RUN_STATE:END */ + + // Built-in sample (used when the overlay/ folder can't be fetched, e.g. file://). const EMBEDDED_SEED = { "preview.html": ` @@ -615,7 +713,7 @@

patchling · realtime video overlay

host.appendChild(add); } function selectFile(path) { - if (active && files[active] !== undefined) files[active] = $("editor").value; // commit edits + commitEditor(); active = path; $("activelabel").textContent = path; $("editor").value = files[path] ?? ""; @@ -627,16 +725,18 @@

patchling · realtime video overlay

if (!path) return; const p = path.trim(); if (!p || files[p] !== undefined) { if (files[p] !== undefined) selectFile(p); return; } + projectEdited(); files[p] = ""; renderTabs(); selectFile(p); scheduleLive(); } function deleteFile(path) { if (!confirm("Delete " + path + " ?")) return; + projectEdited(); delete files[path]; if (active === path) active = fileOrder()[0] || null; renderTabs(); loadEditor(); scheduleLive(); } -$("editor").addEventListener("input", () => { if (active != null) { files[active] = $("editor").value; scheduleLive(); } }); +$("editor").addEventListener("input", () => { if (active != null) { commitEditor(); scheduleLive(); } }); // ---------- preview bundler: stitch every file + GSAP into one HTML ---------- function bundle(map) { @@ -675,7 +775,7 @@

patchling · realtime video overlay

// ---------- live preview ---------- let liveTimer = null; function refreshPreview() { - if (active && files[active] !== undefined) files[active] = $("editor").value; + commitEditor(); $("pvframe").srcdoc = bundle(files); } function scheduleLive() { @@ -714,7 +814,7 @@

patchling · realtime video overlay

// ---------- export standalone bundle ---------- $("export").addEventListener("click", () => { - if (active && files[active] !== undefined) files[active] = $("editor").value; + commitEditor(); const blob = new Blob([bundle(files)], { type: "text/html" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "overlay-bundle.html"; @@ -751,9 +851,12 @@

patchling · realtime video overlay

// ---------- run: diff → smartapply → reconcile → re-render ---------- async function run() { - const key = getKey(); if (!key) return; - if (active && files[active] !== undefined) files[active] = $("editor").value; // commit edits - undoSnapshot = JSON.parse(JSON.stringify(files)); + const key = getKey(); if (!key || applying) return; + commitEditor(); + const inputFiles = { ...files }; + const runId = ++applyGeneration, runRevision = projectRevision; + const goal = $("goal").value, diffModel = MODEL, applyModel = APPLY; + applying = true; $("run").disabled = true; $("undo").disabled = true; curPhase = ""; $("diff").textContent = ""; $("changes").textContent = ""; prog.inDone = prog.outDone = prog.cost = prog.liveIn = prog.liveOut = prog.liveCost = 0; paintUsage(); $("progress").style.display = "block"; document.querySelector("#progress .spin").style.display = ""; @@ -762,22 +865,25 @@

patchling · realtime video overlay

const timer = setInterval(() => { $("elapsed").textContent = ((performance.now() - t0) / 1000).toFixed(1); }, 100); try { - const diff = await generateDiff(buildEnvironment(files), $("goal").value, { apiKey: key, model: MODEL, callLlm: streamingCallLlm("Generating diff") }); + const diff = await generateDiff(buildEnvironment(inputFiles), goal, { apiKey: key, model: diffModel, callLlm: streamingCallLlm("Generating diff") }); + if (!currentProjectRun(runId, runRevision)) { discardProjectRun(runId); return; } renderDiff(diff); const parsed = parseDiffPerFile(diff); if (!diff.trim() || !parsed.length) { $("changes").textContent = "⚠ The model returned no applicable diff. Try rephrasing the goal."; } else { const applyLlm = streamingCallLlm("Applying diff"); - const before = { ...files }; - const updated = await smartapply(diff, files, { - apiKey: key, model: APPLY, + const before = inputFiles; + const updated = await smartapply(diff, inputFiles, { + apiKey: key, model: applyModel, callLlmForApply: (p, o, d, m, o2) => callLlmForApply(p, o, d, m, { ...o2, callLlm: applyLlm }), }); + if (!currentProjectRun(runId, runRevision)) { discardProjectRun(runId); return; } const beforeKeys = new Set(Object.keys(before)), afterKeys = new Set(Object.keys(updated)); const added = [...afterKeys].filter((k) => !beforeKeys.has(k)); const removed = [...beforeKeys].filter((k) => !afterKeys.has(k)); const changed = [...afterKeys].filter((k) => beforeKeys.has(k) && before[k] !== updated[k]); + undoSnapshot = inputFiles; files = updated; if (active == null || files[active] === undefined) active = fileOrder()[0] || null; renderTabs(); loadEditor(); refreshPreview(); @@ -786,17 +892,25 @@

patchling · realtime video overlay

} $("phase").textContent = "done"; document.querySelector("#progress .spin").style.display = "none"; } catch (e) { + if (!currentProjectRun(runId, runRevision)) { discardProjectRun(runId); return; } $("changes").textContent = "Error: " + e.message; $("progress").style.display = "none"; } finally { - clearInterval(timer); $("run").disabled = false; + clearInterval(timer); + if (runId === applyGeneration) { + applying = false; + $("run").disabled = !getKey(); + $("undo").disabled = !undoSnapshot; + } } } $("run").addEventListener("click", run); $("goal").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); if (!$("run").disabled) run(); } }); $("undo").addEventListener("click", () => { - if (!undoSnapshot) return; - files = undoSnapshot; undoSnapshot = null; + if (!undoSnapshot || applying) return; + const previous = undoSnapshot; + projectEdited(); + files = previous; if (active == null || files[active] === undefined) active = fileOrder()[0] || null; renderTabs(); loadEditor(); refreshPreview(); $("undo").disabled = true; $("changes").textContent = "↶ reverted to the previous version."; diff --git a/test/catalog-recovery.test.mjs b/test/catalog-recovery.test.mjs new file mode 100644 index 0000000..e41359e --- /dev/null +++ b/test/catalog-recovery.test.mjs @@ -0,0 +1,59 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; + +const pages = ['index', 'overlay', 'object3d', 'comic', 'marvis', 'count']; +const deferred = () => { let resolve; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; }; +const catalog = (id, owner = 'test-owner') => ({ ok: true, json: async () => ({ data: [{ id, owned_by: owner, pricing: { prompt: 0.1, completion: 0.2 } }] }) }); +function picker(page, fetch) { + const html = readFileSync(new URL(`../${page}.html`, import.meta.url), 'utf8'); + const block = html.slice(html.indexOf('let allModels ='), html.indexOf('function modelButton')); + const setter = html.match(/const setKey = \(k\) => \{[^\n]+\};/)[0]; + const storage = new Map(), elements = new Map(); + const $ = (id) => { + if (!elements.has(id)) elements.set(id, { style: {}, replaceChildren() {} }); + return elements.get(id); + }; + return runInNewContext(`${block}\n${setter}\n({ loadModels, setKey, $ })`, { + DEFAULT_MODEL: 'fallback', MODEL: 'fallback', APPLY: 'fallback', NANOGPT: 'https://nano-gpt.com', + localStorage: { getItem: (key) => storage.get(key), setItem: (key, value) => storage.set(key, value), removeItem: (key) => storage.delete(key) }, + getKey: () => storage.get('ngpt_key'), setEnv() {}, + $, document: { createElement: () => ({}) }, fetch, + }); +} + +for (const page of pages) { + test(`${page}: offline/HTTP fallback is retryable and successful catalog is cached`, async () => { + let requests = 0; + const api = picker(page, async () => { + requests += 1; + if (requests === 1) throw new Error('offline'); + if (requests === 2) return { ok: false }; + return catalog('recovered'); + }); + assert.equal((await api.loadModels())[0].id, 'fallback'); + assert.equal((await api.loadModels())[0].id, 'fallback'); + const models = await api.loadModels(); + assert.equal(models[0].id, 'recovered'); assert.equal(models[0].owned_by, 'test-owner'); + await api.loadModels(); assert.equal(requests, 3); + }); + test(`${page}: sign-in invalidates pricing and old responses cannot overwrite the new catalog`, async () => { + const old = deferred(); let requests = 0; + const api = picker(page, async () => (++requests === 1 ? old.promise : catalog('new-account'))); + const first = api.loadModels(), duplicate = api.loadModels(); + assert.equal(requests, 1); + api.$('modelpicker').style.display = 'block'; + api.$('modellist').innerHTML = 'old-account buttons'; + api.$('modelcount').textContent = 'old count'; + api.setKey('new-key'); + assert.equal(api.$('modelpicker').style.display, 'none'); + assert.equal(api.$('modellist').innerHTML, ''); + assert.equal(api.$('modelcount').textContent, ''); + assert.equal((await api.loadModels())[0].id, 'new-account'); + old.resolve(catalog('old-account')); await Promise.all([first, duplicate]); + assert.equal((await api.loadModels())[0].id, 'new-account'); + assert.equal(requests, 2); + api.setKey(null); await api.loadModels(); assert.equal(requests, 3); + }); +} diff --git a/test/completion-terminal.test.mjs b/test/completion-terminal.test.mjs new file mode 100644 index 0000000..6c445ee --- /dev/null +++ b/test/completion-terminal.test.mjs @@ -0,0 +1,86 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; + +const pages = ['index', 'overlay', 'object3d', 'comic', 'marvis', 'count']; +const frame = (value) => `data: ${JSON.stringify(value)}\n\n`; +const delta = frame({ choices: [{ delta: { content: 'diff prefix' } }] }); +function harness(page, chunks, error, stallCancel = false) { + const html = readFileSync(new URL(`../${page}.html`, import.meta.url), 'utf8'); + const parser = html.match(/async function readCompletionStream\([^]*?\n\}/)[0]; + const client = html.match(/function streamingCallLlm\(phase\) \{[^]*?\n\}/)[0]; + const chat = page === 'marvis' ? html.match(/async function streamCompletion\([^]*?\n\}/)[0] : ''; + const state = { requests: 0, cancelled: 0, released: 0, signal: null }; + const prog = { inDone: 0, outDone: 0, cost: 0, liveIn: 0, liveOut: 0, liveCost: 0 }; + let at = 0; + const context = { + NANOGPT: 'https://example.test', TextDecoder, performance, abort: null, prog, + $: () => ({}), priceOf: () => ({ prompt: 1, completion: 1 }), + words: (text) => String(text).split(/\s+/).length, + paintUsage() {}, appendText() {}, + fetch: async (_url, init) => { + state.requests += 1; state.signal = init.signal; + return { ok: true, body: { getReader: () => ({ + async read() { + if (at < chunks.length) return { value: new TextEncoder().encode(chunks[at++]), done: false }; + if (error) throw error; + return { done: true }; + }, + async cancel() { state.cancelled += 1; if (stallCancel) return new Promise(() => {}); }, + releaseLock() { state.released += 1; }, + }) } }; + }, + }; + const api = runInNewContext(`${parser}\n${client}\n${chat}\n({ streamingCallLlm${chat ? ', streamCompletion' : ''} })`, context); + return { api, state, prog }; +} +const options = { apiKey: 'test-key', model: 'test-model', messages: [{ role: 'user', content: 'edit' }] }; + +for (const page of pages) { + test(`${page}: truncated SSE rejects without retry or committing live cost estimates`, async () => { + const h = harness(page, [delta]); + await assert.rejects(h.api.streamingCallLlm('Generating diff')(options), { name: 'IncompleteCompletionError' }); + assert.equal(h.state.requests, 1); + assert.equal(h.state.cancelled, 1); assert.equal(h.state.released, 1); + assert.equal(h.prog.liveIn, 0); assert.equal(h.prog.liveOut, 0); assert.equal(h.prog.liveCost, 0); + assert.equal(h.prog.inDone, 0); assert.equal(h.prog.outDone, 0); + }); + test(`${page}: length-limited response fails even with DONE`, async () => { + const h = harness(page, [delta, frame({ choices: [{ delta: {}, finish_reason: 'length' }] }) + 'data: [DONE]\n\n']); + await assert.rejects(h.api.streamingCallLlm('Generating diff')(options), { name: 'IncompleteCompletionError' }); + assert.equal(h.state.requests, 1); assert.equal(h.state.released, 1); + }); + test(`${page}: final stop without a trailing newline is complete and keeps pricing`, async () => { + const h = harness(page, [delta, 'data: ' + JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }], x_nanogpt_pricing: { inputTokens: 7, outputTokens: 3, costUsd: 0.01 } })]); + const result = await h.api.streamingCallLlm('Generating diff')(options); + assert.equal(result.choices[0].message.content, 'diff prefix'); + assert.equal(h.prog.inDone, 7); assert.equal(h.prog.outDone, 3); assert.equal(h.prog.cost, 0.01); + assert.equal(h.state.cancelled, 1); assert.equal(h.state.released, 1); + }); +} + +test('MARVIS chat rejects incomplete output and forwards its cancellation signal', async () => { + const h = harness('marvis', [delta]); + const controller = new AbortController(); + await assert.rejects(h.api.streamCompletion({ key: 'test-key', model: 'fake', messages: [], signal: controller.signal }), { name: 'IncompleteCompletionError' }); + assert.equal(h.state.signal, controller.signal); + assert.equal(h.state.requests, 1); assert.equal(h.state.released, 1); +}); + +test('MARVIS chat preserves ordinary completed replies and streams their content', async () => { + const h = harness('marvis', [delta, 'data: [DONE]\n\n']); + const tokens = []; + const result = await h.api.streamCompletion({ key: 'test-key', model: 'fake', messages: [], onDelta: (text) => tokens.push(text) }); + assert.equal(result.content, 'diff prefix'); + assert.deepEqual(tokens, ['diff prefix']); + assert.equal(h.state.cancelled, 1); assert.equal(h.state.released, 1); +}); + +for (const page of pages) { + test(`${page}: stalled transport cleanup does not hold completion pending`, { timeout: 1000 }, async () => { + const h = harness(page, [delta, 'data: [DONE]\n\n'], null, true); + assert.equal((await h.api.streamingCallLlm('Generating diff')(options)).choices[0].message.content, 'diff prefix'); + assert.equal(h.state.cancelled, 1); assert.equal(h.state.released, 1); + }); +} diff --git a/test/demo-models.test.mjs b/test/demo-models.test.mjs index a2b4474..756fe46 100644 --- a/test/demo-models.test.mjs +++ b/test/demo-models.test.mjs @@ -43,7 +43,8 @@ for (const page of pages) { test(`${page}: the actual demo client streams the selected model via chat completions`, async () => { const client = html.match(/function streamingCallLlm\(phase\) \{[^]*?\n\}/)[0]; let body; - const api = runInNewContext(`${client}\nstreamingCallLlm`, { + const parser = html.match(/async function readCompletionStream\([^]*?\n\}/)[0]; + const api = runInNewContext(`${parser}\n${client}\nstreamingCallLlm`, { NANOGPT: 'https://nano-gpt.com', TextDecoder, performance, abort: null, $: () => ({}), words: (s) => String(s).split(/\s+/).length, priceOf: () => ({}), paintUsage() {}, appendText() {}, diff --git a/test/project-lifecycle.test.mjs b/test/project-lifecycle.test.mjs new file mode 100644 index 0000000..1c06e4b --- /dev/null +++ b/test/project-lifecycle.test.mjs @@ -0,0 +1,362 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; + +const read = (page) => readFileSync(new URL(`../${page}.html`, import.meta.url), 'utf8'); +const deferred = () => { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +}; +function functionSource(html, name) { + const match = html.match(new RegExp(`^(?:async )?function ${name}\\([^]*?^\\}`, 'm')); + assert.ok(match, `${name} exists`); + return match[0]; +} +function stateSource(html, kind = 'PROJECT') { + const start = html.indexOf(`/* ${kind}_RUN_STATE:START */`); + const end = html.indexOf(`/* ${kind}_RUN_STATE:END */`); + assert.ok(start >= 0 && end > start); + return html.slice(start, end); +} +function listenerSource(html, id, event) { + const start = html.indexOf(`$("${id}").addEventListener("${event}",`); + assert.ok(start >= 0, `${id}/${event} exists`); + const rest = html.slice(start); + const firstLine = rest.slice(0, rest.indexOf('\n')); + return firstLine.endsWith(');') ? firstLine : rest.slice(0, rest.indexOf('\n});') + 4); +} +function ui() { + const nodes = new Map(); + const $ = (id) => { + if (!nodes.has(id)) nodes.set(id, { + value: '', textContent: '', innerHTML: '', disabled: false, checked: false, + style: {}, files: [], handlers: {}, + classList: { add() {}, remove() {}, toggle() {} }, + addEventListener(event, callback) { this.handlers[event] = callback; }, + emit(event, value = { target: this }) { return this.handlers[event]?.(value); }, + appendChild() {}, replaceChildren() {}, focus() {}, + }); + return nodes.get(id); + }; + return { $, document: { querySelector: $ } }; +} + +function project(page, options = {}) { + const html = read(page), elements = ui(), storage = new Map(); + const generated = deferred(), applyStarted = deferred(); + const requests = { generate: [], apply: [], chat: [] }; + const prompts = []; + const timers = new Map(); let timerId = 0; + const fixture = { + 'scene.js': 'old scene', 'notes.txt': 'original notes', 'chat.md': 'old chat', + 'portrait.md': 'portrait', 'soul.md': 'soul', 'mood.md': 'mood', 'memory.md': 'memory', + }; + const seed = { 'chat.md': 'fresh chat', 'mood.md': 'fresh mood', 'memory.md': 'fresh memory' }; + let api; + const context = { + ...elements, performance, AbortController, + setInterval: () => 1, clearInterval() {}, + setTimeout: (fn) => { timers.set(++timerId, fn); return timerId; }, + clearTimeout: (id) => timers.delete(id), + prompt: () => prompts.shift(), confirm: () => true, + MODEL: 'diff-model', APPLY: 'apply-model', NAME: 'MARVIS', + CONFIG_FILES: new Set(), EMBEDDED_SEED: seed, LS_KEY: 'test-save', + currentImage: null, IMG_MODEL: 'image-model', IMG_SIZE: 'size', + imageCache: {}, faceStats: { count: 0, cost: 0, exact: true }, lastBalance: null, + localStorage: { setItem: (key, value) => storage.set(key, value) }, + getKey: () => 'test-key', prog: { inDone: 0, outDone: 0, cost: 0, liveIn: 0, liveOut: 0, liveCost: 0 }, + paintUsage() {}, streamingCallLlm: () => async () => {}, callLlmForApply() {}, + buildEnvironment: (map) => ({ ...map }), + generateDiff: async (map, goal, opts) => { + requests.generate.push({ map, goal, opts }); generated.resolve(); + return options.generate ? options.generate(map, goal, opts) : 'diff'; + }, + parseDiffPerFile: () => [['scene.js', 'diff']], renderDiff() {}, + smartapply: async (diff, map, opts) => { + requests.apply.push({ diff, map: { ...map }, opts }); applyStarted.resolve(); + return options.apply ? options.apply(diff, map, opts) : { ...map, 'scene.js': 'updated scene' }; + }, + renderTabs() {}, refreshPreview() {}, refreshPromptPreview() {}, renderComic() {}, + hasRendered: false, showChanges() {}, scheduleLive() {}, isPanel: () => false, + applyStyle() {}, applyConfig() {}, renderChat() {}, updateMood() {}, thinking() {}, + toast() {}, userLabel: () => 'YOU', liveBubble: () => ({}), + para: (value) => value, esc: (value) => value, + chatSystemPrompt: () => 'persona', chatToMessages: () => [], showChatCost() {}, + streamCompletion: async (req) => { + requests.chat.push(req); + return options.chat ? options.chat(req) : { content: 'new reply', pricing: {} }; + }, + composeGoal: (goal) => goal, portraitPrompt: () => 'portrait', + promptHash: async () => 'hash', genFace: async () => ({ url: 'new-face', cost: 0, exact: true }), + showFace() {}, idleFaceStatus() {}, setFaceStatus() {}, faceLoading() {}, + }; + elements.$('editor').value = fixture['scene.js']; + elements.$('goal').value = 'change scene'; elements.$('direction').value = 'change persona'; + const marvis = page === 'marvis'; + const names = ['selectFile', 'loadEditor', 'addFile', 'deleteFile', 'run']; + if (marvis) names.push('appendChat', 'sendChat', 'filesNow', 'snapshot', 'persist', 'loadState', 'renderFace'); + const code = [ + `let files = ${JSON.stringify(fixture)}, active = 'scene.js', undoSnapshot = null;`, + 'let curPhase = "", persistTimer = null, faceToken = 0, faceNonce = 0;', + 'const fileOrder = () => Object.keys(files);', + stateSource(html), ...names.map((name) => functionSource(html, name)), + listenerSource(html, 'editor', 'input'), listenerSource(html, 'undo', 'click'), + ...(marvis ? [listenerSource(html, 'clearbtn', 'click'), listenerSource(html, 'loadfile', 'change')] : []), + `({ ${names.join(', ')}, state: () => ({ files: { ...files }, undo: undoSnapshot && { ...undoSnapshot }, applying, revision: projectRevision${marvis ? ', chatRunning' : ''} }) })`, + ].join('\n'); + api = runInNewContext(code, context); + return { + ...elements, context, api, requests, storage, generated, applyStarted, + add(path) { prompts.push(path); api.addFile(); }, + edit(path, text) { api.selectFile(path); elements.$('editor').value = text; elements.$('editor').emit('input'); }, + undo() { elements.$('undo').emit('click'); }, + saved() { return storage.has('test-save') ? JSON.parse(storage.get('test-save')).files : undefined; }, + }; +} + +for (const page of ['overlay', 'object3d', 'comic', 'marvis']) { + test(`${page}: a valid apply publishes and undo restores the input`, async () => { + const h = project(page); + await h.api.run(); + assert.equal(h.api.state().files['scene.js'], 'updated scene'); + assert.equal(h.$('editor').value, 'updated scene'); + assert.equal(h.$('undo').disabled, false); + h.undo(); + assert.equal(h.api.state().files['scene.js'], 'old scene'); + assert.equal(h.$('undo').disabled, true); + }); + + test(`${page}: edits during generation skip the stale apply entirely`, async () => { + const finish = deferred(), h = project(page, { generate: () => finish.promise }); + const pending = h.api.run(); await h.generated.promise; + h.edit('notes.txt', 'manual edit'); h.add('manual.txt'); + finish.resolve('diff'); await pending; + assert.equal(h.requests.apply.length, 0); + assert.equal(h.api.state().files['notes.txt'], 'manual edit'); + assert.ok('manual.txt' in h.api.state().files); + assert.match(h.$('changes').textContent, /Kept your newer edits/); + assert.equal(h.api.state().applying, false); + }); + + test(`${page}: stale apply cannot replace edits, additions, or deletions`, async () => { + const finish = deferred(); + const h = project(page, { apply: async (_diff, map) => { + const result = { ...map, 'scene.js': 'stale scene' }; await finish.promise; return result; + } }); + const pending = h.api.run(); await h.applyStarted.promise; + h.edit('notes.txt', 'manual edit'); h.add('manual.txt'); h.api.deleteFile('scene.js'); + await h.api.run(); // A second invocation while pending cannot start another call. + finish.resolve(); await pending; + const state = h.api.state(); + assert.equal(state.files['notes.txt'], 'manual edit'); + assert.ok('manual.txt' in state.files); assert.equal(state.files['scene.js'], undefined); + assert.equal(h.requests.generate.length, 1); assert.equal(h.requests.apply.length, 1); + assert.equal(h.requests.apply[0].map['notes.txt'], 'original notes'); + assert.equal(state.undo, null); h.undo(); + assert.equal(h.api.state().files['notes.txt'], 'manual edit'); + }); + + test(`${page}: manual changes after success invalidate undo`, async () => { + const h = project(page); await h.api.run(); + h.edit('notes.txt', 'keep after apply'); h.add('new.txt'); h.undo(); + assert.equal(h.api.state().files['scene.js'], 'updated scene'); + assert.equal(h.api.state().files['notes.txt'], 'keep after apply'); + assert.ok('new.txt' in h.api.state().files); assert.equal(h.$('undo').disabled, true); + }); + + test(`${page}: changing only the selected tab does not discard a valid run`, async () => { + const finish = deferred(), h = project(page, { generate: () => finish.promise }); + const pending = h.api.run(); await h.generated.promise; + h.api.selectFile('notes.txt'); + h.context.MODEL = 'new-diff-model'; h.context.APPLY = 'new-apply-model'; + finish.resolve('diff'); await pending; + assert.equal(h.api.state().files['scene.js'], 'updated scene'); + assert.equal(h.requests.generate[0].opts.model, 'diff-model'); + assert.equal(h.requests.apply[0].opts.model, 'apply-model'); + }); + + test(`${page}: a failed request restores controls and retains a still-valid undo`, async () => { + let calls = 0; + const h = project(page, { generate: async () => { + if (++calls === 2) throw new Error('temporary provider failure'); + return 'diff'; + } }); + await h.api.run(); await h.api.run(); + assert.equal(h.api.state().applying, false); + assert.equal(h.$('run').disabled, false); assert.equal(h.$('undo').disabled, false); + assert.match(h.$('changes').textContent, /temporary provider failure/); + assert.equal(h.api.state().files['scene.js'], 'updated scene'); + h.undo(); assert.equal(h.api.state().files['scene.js'], 'old scene'); + }); +} + +test('MARVIS: clear aborts a pending reply and late callbacks cannot repopulate the save', async () => { + const reply = deferred(), h = project('marvis', { chat: () => reply.promise }); + h.$('chatinput').value = 'old question'; const pending = h.api.sendChat(); + assert.equal(h.requests.chat.length, 1); + h.$('clearbtn').emit('click'); + assert.equal(h.requests.chat[0].signal.aborted, true); + const saved = h.saved(); assert.equal(saved['chat.md'], 'fresh chat'); + h.requests.chat[0].onDelta('late chunk'); + reply.resolve({ content: 'late reply', pricing: {} }); await pending; + assert.equal(h.api.state().files['chat.md'], 'fresh chat'); + assert.equal(h.saved()['chat.md'], 'fresh chat'); +}); + +test('MARVIS: an old reply finalizer cannot enable Send during a newer reply', async () => { + const first = deferred(), second = deferred(); let call = 0; + const h = project('marvis', { chat: () => (++call === 1 ? first.promise : second.promise) }); + h.$('chatinput').value = 'old question'; const old = h.api.sendChat(); + h.$('clearbtn').emit('click'); + h.$('chatinput').value = 'new question'; const current = h.api.sendChat(); + first.resolve({ content: 'old reply', pricing: {} }); await old; + assert.equal(h.$('send').disabled, true); assert.equal(h.api.state().chatRunning, true); + second.resolve({ content: 'new reply', pricing: {} }); await current; + assert.match(h.api.state().files['chat.md'], /fresh chat[^]*new question[^]*new reply/); + assert.doesNotMatch(h.api.state().files['chat.md'], /old reply/); + assert.equal(h.$('send').disabled, false); +}); + +test('MARVIS: loading a save invalidates the pending chat and persona result', async () => { + const reply = deferred(), apply = deferred(); + const h = project('marvis', { chat: () => reply.promise, apply: async (_diff, map) => { + const result = { ...map, 'scene.js': 'stale scene' }; await apply.promise; return result; + } }); + const pendingApply = h.api.run(); await h.applyStarted.promise; + h.$('chatinput').value = 'old question'; const pendingChat = h.api.sendChat(); + h.api.loadState({ files: { 'soul.md': 'loaded soul', 'chat.md': 'loaded chat' } }); + apply.resolve(); reply.resolve({ content: 'old reply', pricing: {} }); + await Promise.all([pendingApply, pendingChat]); + assert.equal(h.api.state().files['soul.md'], 'loaded soul'); + assert.equal(h.api.state().files['scene.js'], undefined); + assert.equal(h.api.state().files['chat.md'], 'loaded chat'); + assert.equal(h.requests.chat[0].signal.aborted, true); +}); + +test('MARVIS: an old persona finalizer cannot enable Run during a newer request', async () => { + const first = deferred(), second = deferred(), newApply = deferred(); let call = 0; + const h = project('marvis', { apply: async (_diff, map) => { + const current = ++call; + const result = { ...map, 'scene.js': current === 1 ? 'old persona result' : 'new persona result' }; + if (current === 2) newApply.resolve(); + await (current === 1 ? first.promise : second.promise); + return result; + } }); + const older = h.api.run(); await h.applyStarted.promise; + h.$('clearbtn').emit('click'); + h.$('direction').value = 'new direction'; const newer = h.api.run(); await newApply.promise; + first.resolve(); await older; + assert.equal(h.$('run').disabled, true); assert.equal(h.api.state().applying, true); + second.resolve(); await newer; + assert.equal(h.api.state().files['scene.js'], 'new persona result'); + assert.equal(h.api.state().files['chat.md'], 'fresh chat'); + assert.equal(h.$('run').disabled, false); +}); + +test('MARVIS: editing chat by hand invalidates a pending reply without losing the edit', async () => { + const reply = deferred(), h = project('marvis', { chat: () => reply.promise }); + h.$('chatinput').value = 'old question'; const pending = h.api.sendChat(); + h.edit('chat.md', 'my manual conversation'); + reply.resolve({ content: 'old reply', pricing: {} }); await pending; + h.api.persist(); + assert.equal(h.saved()['chat.md'], 'my manual conversation'); + assert.equal(h.requests.chat[0].signal.aborted, true); +}); + +test('MARVIS: a valid persona apply and undo preserve concurrent conversation', async () => { + const finish = deferred(), h = project('marvis', { apply: async (_diff, map) => { + const result = { ...map, 'scene.js': 'new persona' }; await finish.promise; return result; + } }); + const pending = h.api.run(); await h.applyStarted.promise; + h.$('chatinput').value = 'hello'; await h.api.sendChat(); + const conversation = h.api.state().files['chat.md']; + finish.resolve(); await pending; + assert.equal(h.api.state().files['scene.js'], 'new persona'); + assert.equal(h.api.state().files['chat.md'], conversation); + h.undo(); assert.equal(h.api.state().files['scene.js'], 'old scene'); + assert.equal(h.api.state().files['chat.md'], conversation); +}); + +test('MARVIS: clear cancels a portrait that is still hashing before its request', async () => { + const hash = deferred(), h = project('marvis'); let requests = 0; + h.context.promptHash = () => hash.promise; + h.context.genFace = async () => { requests += 1; return { url: 'old face' }; }; + const pending = h.api.renderFace(); h.$('clearbtn').emit('click'); hash.resolve('hash'); + await pending; assert.equal(requests, 0); +}); + +test('MARVIS: only the most recent asynchronous save load may publish', async () => { + const first = deferred(), second = deferred(), h = project('marvis'); + const input = h.$('loadfile'), a = { text: () => first.promise }, b = { text: () => second.promise }; + input.files = [a]; const older = input.emit('change'); + input.files = [b]; const newer = input.emit('change'); + second.resolve(JSON.stringify({ files: { 'soul.md': 'new save', 'chat.md': 'new chat' } })); await newer; + first.resolve(JSON.stringify({ files: { 'soul.md': 'old save', 'chat.md': 'old chat' } })); await older; + assert.equal(h.api.state().files['soul.md'], 'new save'); + assert.equal(h.api.state().files['chat.md'], 'new chat'); +}); + +test('MARVIS: manual edits invalidate an asynchronous save load', async () => { + const finish = deferred(), h = project('marvis'), input = h.$('loadfile'); + input.files = [{ text: () => finish.promise }]; const pending = input.emit('change'); + h.edit('notes.txt', 'new manual edit'); + finish.resolve(JSON.stringify({ files: { 'soul.md': 'old imported state' } })); await pending; + assert.equal(h.api.state().files['notes.txt'], 'new manual edit'); +}); + +test('MARVIS: discarded requests restore the submitted direction without replacing a newer one', async () => { + for (const nextDirection of ['', 'my next direction']) { + const finish = deferred(), h = project('marvis', { generate: () => finish.promise }); + const pending = h.api.run(); await h.generated.promise; + h.edit('notes.txt', 'new edit'); h.$('direction').value = nextDirection; + finish.resolve('diff'); await pending; + assert.equal(h.$('direction').value, nextDirection || 'change persona'); + } +}); + +function single(options = {}) { + const html = read('index'), elements = ui(), generated = deferred(), applyStarted = deferred(); + const requests = { generate: [], apply: [] }; + const context = { + ...elements, performance, getKey: () => 'test-key', MODEL: 'diff-model', APPLY: 'apply-model', + setInterval: () => 1, clearInterval() {}, paintUsage() {}, updatePlayState() {}, renderVersions() {}, + prog: {}, buildEnvironment: (map) => map, parseDiffPerFile: () => [['file.txt', 'diff']], + streamingCallLlm: () => async () => {}, callLlmForApply() {}, renderDiff() {}, + generateDiff: async (_env, goal, opts) => { requests.generate.push({ goal, opts }); generated.resolve(); return options.generate ? options.generate() : 'diff'; }, + smartapply: async (_diff, map, opts) => { requests.apply.push({ opts }); applyStarted.resolve(); return options.apply ? options.apply(map) : { ...map, 'file.txt': 'new content' }; }, + }; + elements.$('path').value = 'file.txt'; elements.$('content').value = 'original'; elements.$('goal').value = 'original goal'; + const code = [stateSource(html, 'SINGLE'), + 'let curPhase = "", versions = [{ content: "original" }, { content: "older version" }], curVer = 0;', + ...['run', 'selectVersion', 'pushVersion'].map((name) => functionSource(html, name)), + listenerSource(html, 'content', 'input'), listenerSource(html, 'path', 'input'), + '({ run, selectVersion, state: () => ({ versions, applying }) })', + ].join('\n'); + return { ...elements, context, requests, generated, applyStarted, api: runInNewContext(code, context) }; +} + +test('single-file demo: editing during generation skips the stale apply', async () => { + const finish = deferred(), h = single({ generate: () => finish.promise }); + const pending = h.api.run(); await h.generated.promise; + h.$('content').value = 'manual content'; h.$('content').emit('input'); + finish.resolve('diff'); await pending; + assert.equal(h.$('content').value, 'manual content'); assert.equal(h.requests.apply.length, 0); +}); + +test('single-file demo: switching history while applying keeps the selected version', async () => { + const finish = deferred(), h = single({ apply: async (map) => { await finish.promise; return { ...map, 'file.txt': 'stale content' }; } }); + const pending = h.api.run(); await h.applyStarted.promise; h.api.selectVersion(1); + finish.resolve(); await pending; + assert.equal(h.$('content').value, 'older version'); assert.equal(h.api.state().versions.length, 2); +}); + +test('single-file demo: successful history entry records the submitted goal', async () => { + const finish = deferred(), h = single({ generate: () => finish.promise }); + const pending = h.api.run(); await h.generated.promise; h.$('goal').value = 'next goal'; + h.context.APPLY = 'next apply model'; finish.resolve('diff'); await pending; + assert.equal(h.$('content').value, 'new content'); + assert.equal(h.api.state().versions.at(-1).goal, 'original goal'); + assert.equal(h.requests.apply[0].opts.model, 'apply-model'); +});