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');
+});