Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
263e68d040 | ||
|
|
8f7bf1c2d4 | ||
|
|
5fe882dc27 | ||
|
|
e74ae3d29e | ||
|
|
c8cee0e72c | ||
|
|
9b835a4b6e |
No files matched your search
@@ -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
|
||||
|
||||
+30
-6
@@ -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 }));
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
<tr><th scope="row">a remix</th><td>two short calls; five more for a venice character</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="mono">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.</p>
|
||||
<p class="mono">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.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -371,8 +371,15 @@
|
||||
<h2 id="model-h">MODEL</h2>
|
||||
<div class="dlgbody">
|
||||
<p class="mono" id="model-for"></p>
|
||||
<label class="sr" for="msearch">Search models</label>
|
||||
<input id="msearch" type="search" placeholder="search models: a name, private, uncensored…" autocomplete="off">
|
||||
<div class="mfind">
|
||||
<label class="sr" for="msearch">Search models</label>
|
||||
<input id="msearch" type="search" placeholder="search models: a name, private, uncensored…" autocomplete="off">
|
||||
<label class="pick" id="msortpick">sort <select id="msort">
|
||||
<option value="name" selected>name</option>
|
||||
<option value="cheapest">cheapest first</option>
|
||||
<option value="priciest">priciest first</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<div class="mtable">
|
||||
<table class="table models">
|
||||
<thead id="mhead"><tr><th scope="col">MODEL</th><th scope="col">PRIVACY</th><th scope="col">CAN</th><th scope="col">$ IN / OUT</th><th scope="col">CONTEXT</th></tr></thead>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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." },
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user