Compare commits

..
Author SHA1 Message Date
teahubxyzandClaude Opus 5.5 f2e8f1cb91 havre: the update pill watches split.js too
ci / test (pull_request) Successful in 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjyFZEJivqQMhhCyyQau49
2026-10-23 19:37:22 +00:00
teahubxyzandClaude Opus 5.5 9b85b15744 havre: load split.js
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjyFZEJivqQMhhCyyQau49
2026-10-23 19:37:21 +00:00
teahubxyzandClaude Opus 5.5 15d9ab268f havre: the divider's look; hidden where the list and chat take turns
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjyFZEJivqQMhhCyyQau49
2026-10-23 19:37:20 +00:00
teahubxyzandClaude Opus 5.5 058d0b069e havre: a divider to drag between the list and the chat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjyFZEJivqQMhhCyyQau49
2026-10-23 19:37:07 +00:00
teahubxyzandClaude Opus 5.5 d821cc5bee Havre: sort the model picker by price (#250)
Havre: sort the model picker by price (#250)

The model picker gets a sort beside its search: name (the one in use first, then A to Z), cheapest first, priciest first, kept in this browser. Sorted by price, the price column says which way; a text model goes by its price in, then out. Clips, which Venice prices one at a time, have no sort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjyFZEJivqQMhhCyyQau49
2026-10-23 19:29:44 +00:00
time 3b6bf3930f ventures: daily signup counts 2026-10-23 00:28:14 +00:00
time c6f4fe489f ventures: daily signup counts 2026-10-22 00:28:21 +00:00
teahubxyz 6fff82ebb8 Havre remix: editable write-up + rewrite box (#218) 2026-10-21 21:52:33 +00:00
teahubxyz ffb16e5605 Havre: hear / whisper in the message tools line (#217) 2026-10-21 20:12:40 +00:00
10 changed files with 270 additions and 29 deletions

No files matched your search

+35
View File
@@ -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
+93 -24
View File
@@ -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) {
@@ -3662,12 +3679,34 @@ async function writeFresh(avoid = []) {
rxStatus(avoid.length ? `Rewriting with ${modelName(writer)}, steering clear of what was shared…` : `Writing someone new with ${modelName(writer)}…`);
const o = await askJson(writer, { messages: C.writeMessages(S.rx.ideas, $("#rx-twist").value, avoid), schema: C.WRITE_SCHEMA,
maxTokens: 2000, temperature: 0.95, what: "a character" });
const first = C.cardFromWrite(o, { model });
const src = S.rx.source;
const check = C.similarity(C.cardText(first), src.text, { names: [src.name].filter(Boolean), newName: first.name });
// someone new from an adult character is adult too: a rewrite doesn't change who it's for
S.rx.draft = C.cardFromWrite(o, { source: src.name || src.ref ? src : null, check, model, adult: !!src.adult });
S.rx.check = check;
buildDraft(o, model);
renderDraft();
reveal($("#rx-draft"));
rxStatus("");
} catch (e) { rxStatus(e.message, true); } finally { rxBusy(false); }
}
/** the draft from the writer's words (S.rx.raw, which you can edit), checked against the source here in the browser */
function buildDraft(o, model) {
const src = S.rx.source;
S.rx.raw = { ...o };
S.rx.model = model;
const first = C.cardFromWrite(o, { model });
const check = C.similarity(C.cardText(first), src.text, { names: [src.name].filter(Boolean), newName: first.name });
// someone new from an adult character is adult too: a rewrite doesn't change who it's for
S.rx.draft = C.cardFromWrite(o, { source: src.name || src.ref ? src : null, check, model, adult: !!src.adult });
S.rx.check = check;
}
/** "rewrite": the writer revises the draft as it stands (your edits included) with your instruction */
async function reviseDraft(instruction, avoid = []) {
const c = card();
if (!c || !S.rx.raw || S.rx.busy) return;
rxBusy(true);
try {
const writer = await jsonModel(c);
rxStatus(`Rewriting with ${modelName(writer)}…`);
const o = await askJson(writer, { messages: C.reviseMessages(S.rx.ideas, S.rx.raw, instruction, avoid), schema: C.WRITE_SCHEMA,
maxTokens: 2000, temperature: 0.8, what: "a character" });
buildDraft(o, S.rx.model || c.model || S.model);
renderDraft();
reveal($("#rx-draft"));
rxStatus("");
@@ -3678,21 +3717,44 @@ function renderDraft() {
const d = S.rx.draft, chk = S.rx.check, box = $("#rx-draft");
box.textContent = "";
box.hidden = false;
const pct = Math.round(chk.share * 1000) / 10;
box.append(h("h4", { text: d.name }), h("p", { class: "tagline", text: d.tagline }),
h("p", { class: "persona", text: d.persona }),
h("span", { class: `verdict ${chk.verdict}`, text: VERDICT[chk.verdict] }),
h("p", { class: "mono", text: `${pct}% of its five-word phrases appear in the source; the longest shared run is ${chk.run.len} words.` }));
if (chk.reasons.length) box.append(h("ul", { class: "why-list" }, chk.reasons.map((r) => h("li", { text: r }))));
const raw = S.rx.raw || {};
// the write-up, editable: every change re-runs the check, here in the browser
const field = (key, label, tag, attrs = {}) => {
const el = h(tag, { "aria-label": label, class: `ed-${key}`, ...attrs });
el.value = Array.isArray(raw[key]) ? raw[key].join("\n\n") : String(raw[key] || "");
el.addEventListener("input", () => { S.rx.raw[key] = el.value; recheck(); });
if (tag === "textarea") { const fit = () => { el.style.height = "auto"; el.style.height = el.scrollHeight + 2 + "px"; }; el.addEventListener("input", fit); requestAnimationFrame(fit); }
return h("label", { class: "edfield" }, h("span", { class: "mono", text: label }), el);
};
const verdict = h("span"), stat = h("p", { class: "mono" }), why = h("ul", { class: "why-list" });
const keep = h("button", { type: "button", class: "btn solid small", text: "KEEP IN HARBOR", onclick: () => {
buildDraft(S.rx.raw, S.rx.model); // the last keystroke counts, even inside the recheck's pause
if (S.rx.check.verdict === "too-close") { paint(); return; }
keepDraft();
} });
const avoidBtn = h("button", { type: "button", class: "btn small", text: "REWRITE, AVOIDING THESE",
onclick: () => reviseDraft("change whatever is listed under avoid, in your own words", C.avoidList(S.rx.check, [S.rx.source.name])) });
const paint = () => {
const k = S.rx.check, pct = Math.round(k.share * 1000) / 10;
verdict.className = `verdict ${k.verdict}`; verdict.textContent = VERDICT[k.verdict];
stat.textContent = `${pct}% of its five-word phrases appear in the source; the longest shared run is ${k.run.len} words.`;
why.textContent = ""; for (const r of k.reasons) why.append(h("li", { text: r })); why.hidden = !k.reasons.length;
keep.disabled = k.verdict === "too-close"; avoidBtn.hidden = k.verdict === "fresh";
};
let timer = 0;
const recheck = () => { clearTimeout(timer); timer = setTimeout(() => { buildDraft(S.rx.raw, S.rx.model); paint(); }, 250); };
box.append(field("name", "NAME", "input", { maxlength: 80, class: "ed-name" }), field("tagline", "TAGLINE", "input", { maxlength: 120 }),
field("greeting", "GREETING", "textarea", { rows: 3 }), field("persona", "PERSONA", "textarea", { rows: 8 }),
verdict, stat, why);
paint();
if (d.lineage[0]) box.append(h("p", { class: "mono", text: "lineage: " + C.creditLine(d.lineage[0]) + "." }));
if (d.adult) box.append(h("p", { class: "mono", text: "adult (18+), like the character they came from." }));
const ask = h("input", { "aria-label": "How to rewrite it", placeholder: "rewrite: e.g. older, drier humour, a lighthouse keeper", maxlength: 400 });
const go = () => { const v = ask.value.trim(); if (v) reviseDraft(v); };
ask.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); go(); } });
box.append(h("div", { class: "rewrite" }, ask, h("button", { type: "button", class: "btn small", text: "REWRITE", onclick: go })));
const row = h("div", { class: "acts" });
const keep = h("button", { type: "button", class: "btn solid small", text: "KEEP IN HARBOR", onclick: keepDraft });
if (chk.verdict === "too-close") keep.disabled = true;
row.append(keep);
if (chk.verdict !== "fresh") row.append(h("button", { type: "button", class: "btn small", text: "REWRITE, AVOIDING THESE",
onclick: () => writeFresh(C.avoidList(chk, [S.rx.source.name])) }));
row.append(h("button", { type: "button", class: "btn small", text: "WRITE ANOTHER", onclick: () => writeFresh() }));
row.append(keep, avoidBtn, h("button", { type: "button", class: "btn small", text: "WRITE ANOTHER", onclick: () => writeFresh() }));
box.append(row);
}
/** a remix kept: they join the harbor, and their chat opens, with their greeting, for you to say hello */
@@ -3928,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 }));
+9
View File
@@ -665,6 +665,15 @@ export function writeMessages(distillate, twist = "", avoid = []) {
{ role: "user", content: "Write a new character from these ideas. Do not use anything listed under avoid.\n\n" + JSON.stringify(ask, null, 1) }];
}
/** a rewrite of a draft: the reviser sees the ideas, the draft and what to change, never the source */
export function reviseMessages(distillate, draft, instruction = "", avoid = []) {
const ask = { ideas: distillate, draft: { name: draft.name, tagline: draft.tagline, greeting: draft.greeting, persona: draft.persona,
examples: draft.examples, tags: draft.tags }, change: str(instruction, 1000).trim() || "tighten it; keep who they are", avoid: avoid.slice(0, 20) };
return [{ role: "system", content: WRITE_SYSTEM + " Here you are revising a draft you wrote: make the change asked for, keep " +
"everything else as it is unless the change needs it to move, and return the whole character again." },
{ role: "user", content: "Revise this character. Do not use anything listed under avoid.\n\n" + JSON.stringify(ask, null, 1) }];
}
export function normalizeDistillate(o = {}) {
const out = {};
for (const k of DISTILL_KEYS) {
+11 -3
View File
@@ -24,6 +24,7 @@
<link rel="stylesheet" href="style.css">
<script type="module" src="app.js"></script>
<script src="update.js" defer></script>
<script src="split.js" defer></script>
</head>
<body>
<a class="skip" href="#chat">skip to the chat</a>
@@ -301,7 +302,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 +372,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>
+54
View File
@@ -0,0 +1,54 @@
// split.js: on a wide screen, the line between the list (chats / characters / harbor) and the chat can be dragged, or
// moved with the arrow keys once focused. Double-click (or Home) puts it back. The width is kept in this browser.
// Below 1040px the two take turns on screen, so there is nothing to drag and the divider hides (style.css).
(() => {
const KEY = "havre.split", MIN = 320, CHAT_MIN = 380;
const get = () => { try { return JSON.parse(localStorage.getItem(KEY)); } catch { return null; } };
const put = (v) => { try { v == null ? localStorage.removeItem(KEY) : localStorage.setItem(KEY, JSON.stringify(v)); } catch { /* not kept */ } };
function start() {
const studio = document.getElementById("studio"), chars = document.getElementById("chars"), chat = document.getElementById("chat");
if (!studio || !chars || !chat) return;
const bar = document.createElement("div");
bar.className = "splitter";
bar.tabIndex = 0;
bar.setAttribute("role", "separator");
bar.setAttribute("aria-orientation", "vertical");
bar.setAttribute("aria-controls", "chars");
bar.setAttribute("aria-label", "Resize the list and the chat");
bar.title = "drag to resize · double-click to reset";
chat.before(bar);
const wide = () => window.matchMedia("(min-width: 1041px)").matches;
const clamp = (w) => Math.round(Math.max(MIN, Math.min(w, studio.clientWidth - CHAT_MIN - bar.offsetWidth)));
function set(w, keep = true) {
if (w == null || !wide()) { chars.style.flex = ""; bar.removeAttribute("aria-valuenow"); if (w == null && keep) put(null); return; }
w = clamp(w);
chars.style.flex = `0 0 ${w}px`;
bar.setAttribute("aria-valuenow", String(w));
if (keep) put(w);
}
set(get(), false);
window.addEventListener("resize", () => set(get(), false));
bar.addEventListener("pointerdown", (e) => {
if (!wide() || e.button !== 0) return;
e.preventDefault();
bar.setPointerCapture(e.pointerId);
const left = chars.getBoundingClientRect().left;
document.body.classList.add("splitting");
const move = (ev) => set(ev.clientX - left);
const up = () => { bar.removeEventListener("pointermove", move); bar.removeEventListener("pointerup", up); bar.removeEventListener("pointercancel", up); document.body.classList.remove("splitting"); };
bar.addEventListener("pointermove", move);
bar.addEventListener("pointerup", up);
bar.addEventListener("pointercancel", up);
});
bar.addEventListener("dblclick", () => set(null));
bar.addEventListener("keydown", (e) => {
const now = chars.getBoundingClientRect().width, step = e.shiftKey ? 80 : 20;
if (e.key === "ArrowLeft") set(now - step);
else if (e.key === "ArrowRight") set(now + step);
else if (e.key === "Home") set(null);
else return;
e.preventDefault();
});
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start();
})();
+15
View File
@@ -144,6 +144,10 @@ input:disabled, textarea:disabled, select:disabled { background: var(--well); co
/* ---------- the studio: characters | chat ---------- */
.studio { display: flex; flex-wrap: wrap; align-items: flex-start; border-bottom: 2px solid var(--ink); }
.chars { flex: 1.15 1 560px; min-width: 0; align-self: stretch; border-right: 1px solid var(--ink); }
/* the line between the list and the chat, to drag (split.js) */
.splitter { flex: 0 0 7px; align-self: stretch; margin-left: -4px; margin-right: -3px; position: relative; z-index: 2; cursor: col-resize; touch-action: none; }
.splitter:hover, .splitter:focus-visible, .splitting .splitter { background: var(--hi); outline: none; }
body.splitting { cursor: col-resize; user-select: none; }
.chat {
flex: 1 1 440px; min-width: 0; display: flex; flex-direction: column;
position: sticky; top: 0; max-height: 100vh;
@@ -428,6 +432,13 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi);
.verdict.too-close { background: var(--bad); border-color: var(--bad); color: var(--paper); }
.why-list { margin: 0; padding-left: 20px; font: 13px var(--mono); }
.draft .acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.draft .edfield { display: flex; flex-direction: column; gap: 3px; }
.draft .edfield input, .draft .edfield textarea { width: 100%; border: 1px solid var(--ink); background: transparent; color: inherit; padding: 6px 8px; font: inherit; border-radius: 0; }
.draft .edfield textarea { resize: vertical; font-size: 17px; line-height: 1.4; overflow: hidden; }
.draft .edfield .ed-name { font-size: 26px; font-weight: 700; }
.draft .edfield .ed-tagline { font-style: italic; }
.draft .rewrite { display: flex; gap: 8px; }
.draft .rewrite input { flex: 1; min-width: 0; border: 1px solid var(--ink); background: transparent; color: inherit; padding: 6px 8px; font: inherit; border-radius: 0; }
.exports { margin: 0 0 12px; padding: 0; list-style: none; display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 0; }
.exports li { margin: 0 -1px -1px 0; }
@@ -463,6 +474,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; }
@@ -479,6 +492,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;
@@ -494,6 +508,7 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi);
/* ---------- narrow screens ---------- */
@media (max-width: 1040px) {
.splitter { display: none; }
.chars { border-right: 0; border-bottom: 1px solid var(--ink); }
/* one at a time, like a phone's messages: the list, or the chat. In the chat the left column's tabs stay above it,
without their panels, so CHATS / CHARACTERS / HARBOR are always one tap away */
+26
View File
@@ -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." },
+25
View File
@@ -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");
+1 -1
View File
@@ -4,7 +4,7 @@
// Checking fetches with cache: "reload", which also refreshes the browser's copies, so the reload gets the new files.
(() => {
const FILES = ["./", "app.js", "style.css", "workflows.js", "abilities.js", "card.js", "venice.js", "memory.js",
"threads.js", "media.js", "backup.js", "theme.js"];
"threads.js", "media.js", "backup.js", "theme.js", "split.js"];
let base = null, shown = false, busy = false;
async function print() {
const parts = await Promise.all(FILES.map((f) => fetch(f, { cache: "reload" }).then((r) => (r.ok ? r.text() : "")).catch(() => null)));
+1 -1
View File
@@ -520,5 +520,5 @@
"hosting": "pages"
}
],
"refreshed": "2026-10-21"
"refreshed": "2026-10-23"
}