From 9b835a4b6e180bd8e5e0a3b3aba223e3fa4fb74e Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Fri, 23 Oct 2026 19:12:16 +0000 Subject: [PATCH 1/6] havre: the model picker's order (by name, cheapest first, priciest first) as pure functions --- ventures/havre/abilities.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/ventures/havre/abilities.js b/ventures/havre/abilities.js index 443a385..12dfb5a 100644 --- a/ventures/havre/abilities.js +++ b/ventures/havre/abilities.js @@ -81,6 +81,41 @@ export const voicePrice = (model, chars) => (model && model.perMChars != null ? /** a minute of your voice, heard */ export const hearingPerMinute = (model) => (model && model.perSecond != null ? model.perSecond * 60 : null); +/** + * What a model costs, as the numbers the model picker sorts by, the first counting most: a text model's price in, then + * out (a chat is mostly what's sent in: the card, the memories, the conversation so far); a photo's price, then what each + * picture past the included ones adds; a voice's price for a million characters; hearing's price for a second. null when + * Venice lists no price (a clip: it prices each one when asked). + */ +export function modelCost(type, model) { + if (!model) return null; + const n = (x) => (typeof x === "number" && Number.isFinite(x) ? x : null); + const keys = type === "text" ? [n(model.input), n(model.output)] + : type === "inpaint" || type === "image" ? [n(model.price), n(model.extra)] + : type === "tts" ? [n(model.perMChars)] + : type === "asr" ? [n(model.perSecond)] + : []; + return keys.length && keys[0] != null ? keys.map((k) => k ?? 0) : null; +} +/** the orders the model picker has: by name (the one in use first), cheapest first, priciest first */ +export const MODEL_SORTS = ["name", "cheapest", "priciest"]; +/** + * The model picker's list in order, as a new list. "name": the ones rank puts higher first (the one in use, then the + * character's own), then A to Z. "cheapest" and "priciest": by modelCost, A to Z between equals, the ones without a price + * last either way, and none held at the top, so the one in use shows where its price puts it. + */ +export function sortModels(list, how = "name", { type = "text", rank = () => 0 } = {}) { + const all = [...(list || [])], byName = (a, b) => String(a.name || a.id).localeCompare(String(b.name || b.id)); + if (how !== "cheapest" && how !== "priciest") return all.sort((a, b) => rank(b) - rank(a) || byName(a, b)); + const dir = how === "priciest" ? -1 : 1, cost = new Map(all.map((m) => [m, modelCost(type, m)])); + return all.sort((a, b) => { + const x = cost.get(a), y = cost.get(b); + if (!x || !y) return (x ? 0 : 1) - (y ? 0 : 1) || byName(a, b); + for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return dir * (x[i] - y[i]); + return byName(a, b); + }); +} + // ---------- what goes into a photo or a clip ---------- // Content filters (Venice's, and the ones of the models' makers) read the words in a prompt, not the "no" in front of -- 2.54.0 From c8cee0e72c59b95c832779a9cbea72a7fa88bdf5 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Fri, 23 Oct 2026 19:12:42 +0000 Subject: [PATCH 2/6] havre: sort the model picker by name, cheapest first or priciest first (kept in this browser) --- ventures/havre/app.js | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/ventures/havre/app.js b/ventures/havre/app.js index d11e559..923a4ff 100644 --- a/ventures/havre/app.js +++ b/ventures/havre/app.js @@ -49,7 +49,7 @@ const local = box("localStorage"), session = box("sessionStorage"); const K = { cards: "havre.cards.v1", sel: "havre.selected", key: "havre.key", model: "havre.model", sort: "havre.vsort", seeded: "havre.seeded.v1", adult: "havre.adult", adult18: "havre.adult.18", plainModel: "havre.plain.model", own: "havre.venice.models", ab: "havre.abilities", voiceOn: "havre.voicenotes", jobs: "havre.clips.pending", refOk: "havre.refs.ok", - spent: "havre.spent", jobsDone: "havre.clips.done", + spent: "havre.spent", jobsDone: "havre.clips.done", msort: "havre.msort", // what each character remembers of your chats (memory.js), by card id: never in the card itself memory: "havre.memory.v1", // the pictures this browser made with AI (their fingerprints): an adult character's photos and clips can be adult @@ -1666,6 +1666,12 @@ const KINDS = { note: "turns your voice note into words for the character. " + TEXT_NOTE }, }; let pickKind = "chat"; +// the order the picker lists them in, the same for every kind and kept in this browser: by name, or by price either way +let modelSort = (() => { const s = local.get(K.msort, "name"); return A.MODEL_SORTS.includes(s) ? s : "name"; })(); +/** a kind's price column (its head starts with $), or -1: clips have none, venice prices each one when asked */ +const costCol = (kind) => KINDS[kind].head.findIndex((t) => t.startsWith("$")); +/** how a kind is listed: by price only when it has one */ +const sortOf = (kind) => (costCol(kind) < 0 ? "name" : modelSort); /** the models a kind picks from, or null while Venice is still being asked */ function kindList(kind) { if (kind === "chat") return S.models; @@ -1697,13 +1703,24 @@ function openModels(kind = "chat") { : kind === "adultPhoto" ? "for adult characters' adult photos." : kind === "adultClip" ? "for adult characters' adult clips." : "for describing your photos to a character whose model can't see."; $("#msearch").value = ""; - const head = $("#mhead"); - head.textContent = ""; - head.append(h("tr", {}, k.head.map((t) => h("th", { scope: "col", text: t })))); + $("#msortpick").hidden = costCol(kind) < 0; + $("#msort").value = modelSort; + modelHead(); if (!$("#modeldlg").open) $("#modeldlg").showModal(); renderModels(); loadList(k.type); } +/** the picker's column heads; sorted by price, the price column says which way (aria-sort, and an arrow) */ +function modelHead() { + const k = KINDS[pickKind], how = sortOf(pickKind), at = how === "name" ? -1 : costCol(pickKind); + const up = how === "cheapest", arrow = up ? "↑" : "↓"; + // a text model goes by its price in, so its arrow goes after IN + const label = (t) => (t.startsWith("$ IN ") ? "$ IN " + arrow + " " + t.slice(5) : t + " " + arrow); + const head = $("#mhead"); + head.textContent = ""; + head.append(h("tr", {}, k.head.map((t, i) => h("th", { scope: "col", text: i === at ? label(t) : t, + "aria-sort": i === at ? (up ? "ascending" : "descending") : null })))); +} function renderModels() { const kind = pickKind, k = KINDS[kind], c = card(), tb = $("#mlist"), q = $("#msearch").value.trim().toLowerCase(); tb.textContent = ""; @@ -1714,8 +1731,8 @@ function renderModels() { } const cur = (kindModel(kind) || {}).id, own = kind === "chat" ? ownModel(c) : ""; const rank = (m) => (m.id === cur ? 2 : 0) + (m.id === own ? 1 : 0); - const list = list0.filter((m) => !q || `${m.id} ${m.name} ${(m.traits || []).join(" ")} ${m.privacy} ${m.uncensored ? "uncensored" : ""}`.toLowerCase().includes(q)) - .sort((a, b) => rank(b) - rank(a) || a.name.localeCompare(b.name)); + const list = A.sortModels(list0.filter((m) => !q || `${m.id} ${m.name} ${(m.traits || []).join(" ")} ${m.privacy} ${m.uncensored ? "uncensored" : ""}`.toLowerCase().includes(q)), + sortOf(kind), { type: k.type, rank }); const num = (text) => h("td", { class: "num", text }); const shape = { adultPhoto: "photo", adultClip: "clip" }[kind] || kind; for (const m of list) { @@ -3973,6 +3990,13 @@ function bind() { }); $("#plain").addEventListener("click", () => selectCard(PLAIN, { talk: true })); $("#msearch").addEventListener("input", renderModels); + $("#msort").addEventListener("change", () => { + const how = $("#msort").value; + modelSort = A.MODEL_SORTS.includes(how) ? how : "name"; + local.set(K.msort, modelSort); + modelHead(); + renderModels(); + }); $("#modelform").addEventListener("submit", (e) => e.preventDefault()); // Enter in the search box keeps it open $("#mclose").addEventListener("click", () => $("#modeldlg").close()); $("#vprev").addEventListener("click", () => loadVenice(Math.max(0, S.v.offset - PAGE), { paging: true })); -- 2.54.0 From e74ae3d29eafeb7f86f0638aa32ef8888dec7b4c Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Fri, 23 Oct 2026 19:12:51 +0000 Subject: [PATCH 3/6] havre: a sort beside the model picker's search --- ventures/havre/index.html | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ventures/havre/index.html b/ventures/havre/index.html index 1769d2c..1d4b656 100644 --- a/ventures/havre/index.html +++ b/ventures/havre/index.html @@ -301,7 +301,7 @@ a remixtwo short calls; five more for a venice character -

the model pickers show venice's prices, what you ask for shows its price before you pay, and your balance shows next to your key.

+

the model pickers show venice's prices and can sort by them, what you ask for shows its price before you pay, and your balance shows next to your key.

@@ -371,8 +371,15 @@

MODEL

- - +
+ + + +
-- 2.54.0 From 5fe882dc271608337886bf84615f303c3f6410d0 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Fri, 23 Oct 2026 19:12:51 +0000 Subject: [PATCH 4/6] havre: the model picker's search and sort on one line --- ventures/havre/style.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ventures/havre/style.css b/ventures/havre/style.css index 8f75f86..e9f033d 100644 --- a/ventures/havre/style.css +++ b/ventures/havre/style.css @@ -470,6 +470,8 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi); .dlg::backdrop { background: var(--veil); } .dlg.wide { width: min(1040px, calc(100vw - 32px)); } .mtable { max-height: min(58vh, 560px); overflow: auto; border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink); } +.mfind { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; } +.mfind input[type=search] { flex: 1 1 220px; width: auto; } .models { font-size: 16px; } .models thead th { position: sticky; top: 0; background: var(--paper); } .models tbody th .mid { display: block; font: 12px/1.4 var(--mono); font-weight: 400; overflow-wrap: anywhere; } @@ -486,6 +488,7 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi); .dlgbody { display: flex; flex-direction: column; gap: 14px; padding: 16px; } .dlgbody label { display: flex; flex-direction: column; gap: 4px; font: 13px var(--mono); } .dlgbody label.check { flex-direction: row; align-items: center; gap: 8px; } +.dlgbody label.pick { flex-direction: row; align-items: center; gap: 6px; } .dlgbody textarea { font: 17px var(--serif); } .drop { display: flex; flex-direction: column; gap: 4px; align-items: flex-start; padding: 18px; -- 2.54.0 From 8f7bf1c2d4127cc04243f7d1b0b5fdfa1917eeb2 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Fri, 23 Oct 2026 19:14:06 +0000 Subject: [PATCH 5/6] havre tests: the model picker's order --- ventures/havre/tests/abilities.test.mjs | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/ventures/havre/tests/abilities.test.mjs b/ventures/havre/tests/abilities.test.mjs index 00d5c9f..f1f5436 100644 --- a/ventures/havre/tests/abilities.test.mjs +++ b/ventures/havre/tests/abilities.test.mjs @@ -72,6 +72,32 @@ test("prices: a photo with extra pictures, a voice note, a minute of hearing, wr ["$0.03", "$1.40", "$0.0007", "$0.00072", "$188", "$0", "?", "under $0.0001"]); }); +test("the model picker's order: by name with the one in use first, or by price either way, the unpriced last", () => { + const ids = (l) => l.map((m) => m.id); + const text = [{ id: "glm", name: "GLM 5.2", input: 1.4, output: 4.4 }, { id: "tiny", name: "Tiny", input: 0.1, output: 0.2 }, + { id: "dear", name: "Dear", input: 6, output: 30 }, { id: "even", name: "Even", input: 0.1, output: 0.9 }, + { id: "free", name: "Free", input: 0, output: 0 }, { id: "odd", name: "Odd" }]; + const rank = (m) => (m.id === "tiny" ? 2 : m.id === "glm" ? 1 : 0); + assert.deepEqual(ids(A.sortModels(text, "name", { rank })), ["tiny", "glm", "dear", "even", "free", "odd"], "the one in use, their own, then A to Z"); + assert.deepEqual(ids(A.sortModels(text, "cheapest", { rank })), ["free", "tiny", "even", "glm", "dear", "odd"], + "by the price in, then out; none held at the top; no price last"); + assert.deepEqual(ids(A.sortModels(text, "priciest", { rank })), ["dear", "glm", "even", "tiny", "free", "odd"], "the other way, no price still last"); + assert.deepEqual(ids(A.sortModels(text, "sideways", { rank })), ids(A.sortModels(text, "name", { rank })), "an order it doesn't know is by name"); + assert.equal(text[0].id, "glm", "the list it was given stays as it was"); + assert.deepEqual(A.MODEL_SORTS, ["name", "cheapest", "priciest"]); + const pics = [{ id: "a", name: "A", price: 0.1, extra: 0.03 }, { id: "b", name: "B", price: 0.04 }, { id: "c", name: "C", price: 0.1 }, + { id: "d", name: "D", price: null }]; + assert.deepEqual(ids(A.sortModels(pics, "cheapest", { type: "inpaint" })), ["b", "c", "a", "d"], "a photo by its price, then what more pictures add"); + assert.deepEqual(ids(A.sortModels([{ id: "q", name: "Qwen 3 TTS", perMChars: 112.5 }, { id: "k", name: "Kokoro", perMChars: 3.5 }], "cheapest", { type: "tts" })), + ["k", "q"]); + assert.deepEqual(ids(A.sortModels([{ id: "p", name: "Parakeet", perSecond: 0.0001 }, { id: "w", name: "Whisper", perSecond: 0.0002 }], "priciest", { type: "asr" })), + ["w", "p"]); + assert.equal(A.modelCost("video", { id: "x", durations: ["5s"] }), null, "a clip has no price to sort by: venice prices each one"); + assert.deepEqual(ids(A.sortModels([{ id: "y", name: "Y" }, { id: "x", name: "X" }], "cheapest", { type: "video" })), ["x", "y"], "so clips stay A to Z"); + assert.deepEqual(A.modelCost("text", { input: 1.4, output: null }), [1.4, 0]); + assert.equal(A.modelCost("text", null), null); +}); + // ---------- the shot ---------- test("the shot is written from the conversation, with the rules, and the photo keeps the character", () => { const hist = [{ role: "user", content: "[Start the conversation…]", hidden: true }, { role: "assistant", content: "Ticket? Kidding." }, -- 2.54.0 From 263e68d040d6fc63658960fc1aba01c947a401b8 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Fri, 23 Oct 2026 19:14:07 +0000 Subject: [PATCH 6/6] havre smoke: the model picker sorted by price, on a desktop and a phone; none for clips --- ventures/havre/tests/smoke.mjs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/ventures/havre/tests/smoke.mjs b/ventures/havre/tests/smoke.mjs index c4a8809..862c236 100644 --- a/ventures/havre/tests/smoke.mjs +++ b/ventures/havre/tests/smoke.mjs @@ -430,6 +430,18 @@ async function main() { assert.match(await textOf(page, "#mlist tr"), /GLM 5\.2.*their own/); assert.match(await page.locator("#mlist tr", { hasText: "Tiny" }).evaluate((el) => el.textContent), /anonymized.*sees.*uncensored.*\$0\.10 \/ \$0\.20.*32k/); await shot(page, "desktop-models.png"); + // by price instead: cheapest first, then priciest first (the price column says which way), kept for next time + await page.selectOption("#msort", "cheapest"); + await has(page, "#mlist tr:first-child", /^Tiny/); + assert.match(await textOf(page, "#mhead"), /\$ IN ↑ \/ OUT/); + assert.equal(await page.locator('#mhead th[aria-sort="ascending"]').count(), 1); + await page.selectOption("#msort", "priciest"); + await has(page, "#mhead", /\$ IN ↓ \/ OUT/); + await has(page, "#mlist tr:first-child", /^GLM 5\.2/); + assert.equal(await page.evaluate(() => localStorage.getItem("havre.msort")), JSON.stringify("priciest")); + await page.selectOption("#msort", "name"); + await has(page, "#mlist tr:first-child", /^GLM 5\.2.*their own/); + assert.equal(await page.locator("#mhead th[aria-sort]").count(), 0); await page.getByRole("button", { name: "Use Tiny" }).click(); await has(page, "#model-btn", /^Tiny$/); await page.fill("#say", "Do you have a boat?"); @@ -769,6 +781,10 @@ async function main() { await page.getByRole("button", { name: "abilities" }).click(); await page.getByRole("button", { name: "Change the photo model", exact: true }).click(); await page.getByRole("button", { name: "Use Grok Imagine", exact: true }).click(); + // a clip has no price to sort by (venice prices each one when asked): its picker has no sort + await page.getByRole("button", { name: "Change the clip model", exact: true }).click(); + assert.ok(await page.locator("#msortpick").isHidden(), "no sort for clips"); + await page.click("#mclose"); await page.click("#abclose"); // photos and voice notes stay through a reload, from this browser's own store await page.reload(); @@ -1270,6 +1286,15 @@ async function main() { await p3.waitForTimeout(500); await shot(p3, "phone-chat.png"); await shot(p3, "phone-full.png", { fullPage: true }); + // the model picker on a phone: the table's heads are gone, the sort isn't, and nothing in it to zoom in on + await p3.click("#model-btn"); + await count(p3, "#mlist tr", 2); + assert.ok(await p3.locator("#msort").isVisible(), "the sort, without the table's heads"); + await p3.selectOption("#msort", "cheapest"); + await has(p3, "#mlist tr:first-child", /^Tiny/); + assert.ok(await wide() <= W, `no sideways scroll in the picker (scrollWidth ${await wide()})`); + assert.deepEqual(await small(), [], "nothing in the picker to zoom in on"); + await p3.click("#mclose"); // from a chat, the tabs above it go back to the list; a chat from your chats opens in its place assert.ok(await p3.locator("#chat-back").isHidden(), "no back link: the tabs are the way back"); await p3.click("#lt-chars"); -- 2.54.0
MODELPRIVACYCAN$ IN / OUTCONTEXT