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 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 })); 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 @@
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 | PRIVACY | CAN | $ IN / OUT | CONTEXT |
|---|