Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
222 changes: 168 additions & 54 deletions comic.html

Large diffs are not rendered by default.

156 changes: 110 additions & 46 deletions count.html
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ <h1>patchling · count to 100</h1>

// ---------- 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) ----------
Expand Down Expand Up @@ -237,6 +237,7 @@ <h1>patchling · count to 100</h1>

// ---------- 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
Expand Down Expand Up @@ -288,18 +289,51 @@ <h1>patchling · count to 100</h1>
}

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.
Expand Down Expand Up @@ -371,6 +405,55 @@ <h1>patchling · count to 100</h1>
$("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;
Expand All @@ -397,43 +480,24 @@ <h1>patchling · count to 100</h1>
} 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;
Expand Down
Loading