Compare commits

...
Author SHA1 Message Date
teahubxyz 4eb0d3d4c5 Uncensor Haven: Change default safe mode from true to false 2026-10-23 21:50:57 +00:00
teahubxyzandClaude Opus 5.5 7eaf3afb81 Havre: hide the list like a sidebar (#252)
Havre: hide the list like a sidebar (#252)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjyFZEJivqQMhhCyyQau49
2026-10-23 20:36:04 +00:00
teahubxyzandClaude Opus 5.5 5ca4460d79 Havre: drag the divider between the list and the chat (#251)
Havre: drag the divider between the list and the chat (#251)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjyFZEJivqQMhhCyyQau49
2026-10-23 19:47:05 +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
teahubxyz 2e86c329d4 Havre: voice notes off / on / asmr, ASMR settings, "whisper it", and the /whisper display fix (#216) 2026-10-21 19:46:52 +00:00
teahubxyz cbb01d0a33 Havre: update pill (NEW VERSION · RELOAD) (#215) 2026-10-21 19:39:35 +00:00
teahubxyz 0c0feec53b Havre: /whisper — ASMR voice notes (#214) 2026-10-21 19:30:07 +00:00
12 changed files with 596 additions and 62 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
+283 -56
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
@@ -84,12 +84,29 @@ const S = {
// or clip being asked for, and a voice note being recorded
lists: { inpaint: null, image: null, tts: null, asr: null, video: null }, listErr: {},
ab: { photo: "", clip: {}, hear: false, hearModel: "", see: false, seeModel: "", own: true, allowance: 0.25, memory: true,
adult: true, adultPhoto: "", adultClip: "", awayMin: 360 }, voiceOn: {}, jobs: [], ask: null,
adult: true, adultPhoto: "", adultClip: "", awayMin: 360, asmr: null }, voiceOn: {}, jobs: [], ask: null,
rec: null, moving: new Set(), inflight: 0,
};
// what characters do on their own (photos, voice notes) comes out of a daily allowance, counted here by local day
const ALLOWANCES = [0.1, 0.25, 1, 5];
/** how long you've been away before a character texts first: minutes, as the setting shows them */
// voice notes in ASMR: whispered (W.WHISPER_PRESETS), at a pace, over soft rain; "every" is one reply in so many
const ASMR_DEFAULT = { voice: "luna", pace: 0.77, rain: false, level: 0.09, every: 1, v: 2 };
const ASMR_PACES = [[0.65, "slowest"], [0.77, "slow"], [0.85, "easy"], [1, "as written"]];
const ASMR_EVERY = [[1, "every reply"], [2, "every other"], [4, "now and then"]];
const ASMR_LEVELS = [[0, "no rain"], [0.05, "faint"], [0.09, "soft"], [0.16, "steady"]];
/** off, on or asmr: how a character's replies come (S.voiceOn keeps true for on, "asmr" for asmr) */
const voiceMode = (c) => (!c || !S.voiceOn[c.id] ? "off" : S.voiceOn[c.id] === "asmr" ? "asmr" : "on");
/** whether this reply comes with a voice note: always when on; in asmr, one in every so many */
const voiceDue = (c) => { const m = voiceMode(c); return m === "on" || (m === "asmr" && Math.random() < 1 / Math.max(1, S.ab.asmr.every || 1)); };
/** what a voice note of these words would cost: their own voice, or the whisper preset */
function voiceCost(c, text, asmr) {
const words = A.speakable(text);
if (!asmr) { const vm = voiceModel(c); return vm ? A.voicePrice(vm, words.length) : null; }
const p = W.WHISPER_PRESETS[S.ab.asmr.voice] || W.WHISPER_PRESETS[W.WHISPER_DEFAULT];
const vm = (S.lists.tts || []).find((m) => m.id === p.model);
return vm ? A.voicePrice(vm, W.whisperInput(words, p).length) : null;
}
const AWAYS = [[1, "a minute"], [5, "5 minutes"], [15, "15 minutes"], [30, "half an hour"], [60, "an hour"], [180, "3 hours"], [360, "6 hours"], [1440, "a day"]];
const backAfter = () => (S.ab.awayMin || 360) * 60e3;
const localDay = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; };
@@ -141,6 +158,10 @@ function boot() {
S.own = local.get(K.own, {}) || {};
S.ab = { ...S.ab, ...(local.get(K.ab, {}) || {}) };
S.ab.clip = S.ab.clip && typeof S.ab.clip === "object" ? S.ab.clip : {};
const savedAsmr = S.ab.asmr && typeof S.ab.asmr === "object" ? S.ab.asmr : null;
S.ab.asmr = { ...ASMR_DEFAULT, ...(savedAsmr || {}) };
if (!W.WHISPER_PRESETS[S.ab.asmr.voice]) S.ab.asmr.voice = W.WHISPER_DEFAULT;
if (savedAsmr && (savedAsmr.v || 1) < 2) S.ab.asmr = { ...S.ab.asmr, rain: false, v: 2 }; // rain went off by default: once, for settings saved before
if (!ALLOWANCES.includes(S.ab.allowance)) S.ab.allowance = 0.25;
if (!AWAYS.some(([m]) => m === S.ab.awayMin)) S.ab.awayMin = 360;
S.voiceOn = local.get(K.voiceOn, {}) || {};
@@ -1009,11 +1030,13 @@ function jumpTo(t, m) {
}
// ---------- editing and deleting messages ----------
function msgTools(el, t, m) {
function msgTools(el, t, m, voice = []) {
if (!t || !m || !m.id) return;
el.dataset.msg = m.id;
for (const old of $$(".msgtools", el)) old.remove();
const c = cardOf(t), words = !!String(m.content || "").trim() && c && !c.plain && S.ab.memory;
el.append(h("span", { class: "msgtools" }, ...slashed([
...voice,
m.role === "user" && !m.media ? linkish("edit", () => editMsg(t, m), { "aria-label": "Edit this message" }) : null,
words ? linkish("remember", () => rememberMsg(t, m), { "aria-label": "Add this to what they remember", title: "into their memory, in these words" }) : null,
words ? linkish("remind", () => remindMsg(t, m), { "aria-label": "Remind them of this at a time", title: "back to mind at a time you pick" }) : null,
@@ -1103,7 +1126,7 @@ function renderLog() {
if (m.voice) put(voiceLine(c, m));
let last = null;
for (const b of bubblesOf(c, m.content)) { last = put(line(b, "them")); if (m.id) last.dataset.msg = m.id; }
if (last) { offerVoice(last, c, m, t); msgTools(last, t, m); }
if (last) msgTools(last, t, m, offerVoice(last, c, m, t));
}
}
const ideas = hist.some((m) => m.role === "user" && !m.hidden) ? [] : C.suggestions(c);
@@ -1276,12 +1299,13 @@ async function send(text, { hidden = false, media = null, picture = "", to = nul
const vm = voiceModel(c), price = vm ? A.voicePrice(vm, A.speakable(reply).length) : null;
if (price != null && reserve(price)) mirror = price;
}
if (!demo && !c.plain && (S.voiceOn[c.id] || mirror)) {
const asmrNote = !mirror && voiceMode(c) === "asmr";
if (!demo && !c.plain && (mirror || voiceDue(c))) {
// a voice note first, then the words under it
doing(t, "is recording a voice note…");
voicing.add(msg);
try {
msg.voice = await makeVoice(c, reply, ctrl.signal);
msg.voice = await makeVoice(c, reply, ctrl.signal, { asmr: asmrNote });
if (mirror) msg.voice.own = true;
saveThread(t);
} catch (e) {
@@ -1356,7 +1380,7 @@ async function play(t, bubbles, signal, c, msg, drawn = S.gen) {
if (msg && msg.id) last.dataset.msg = msg.id;
scrollLog();
}
if (last && c && msg) offerVoice(last, c, msg, t);
if (last && c && msg) msgTools(last, t, msg, offerVoice(last, c, msg, t));
}
// ---------- photos, clips, voice notes, and yours, in the conversation ----------
@@ -1426,27 +1450,41 @@ function voiceLine(c, m) {
/** "hear it" after a reply that came as text: its voice note, made when asked for (once, however often it's clicked) */
const voicing = new WeakSet();
function offerVoice(el, c, m, t) {
if (m.voice || m.demo || c.plain || !S.key || !t) return;
const b = linkish(voicing.has(m) ? "recording…" : "hear it", async () => {
if (b.disabled || voicing.has(m) || m.voice) return;
voicing.add(m);
b.disabled = true;
b.textContent = "recording…";
try {
m.voice = await makeVoice(c, m.content);
saveThread(t);
if (b.isConnected) b.replaceWith(audioEl(m.voice.id));
else placeVoice(t, m);
balanceSoon();
} catch (e) { b.disabled = false; b.textContent = "hear it"; toast(e.message); } finally { voicing.delete(m); }
}, { class: "linkish hear", "aria-label": `Hear this as a voice note from ${c.name}`, disabled: voicing.has(m) || null });
$(".said", el).append(" ", b);
if (m.voice || m.demo || c.plain || !S.key || !t) return [];
const btns = [];
const make = (label, asmr) => {
const b = linkish(voicing.has(m) ? "recording…" : label, async () => {
if (b.disabled || voicing.has(m) || m.voice) return;
voicing.add(m);
for (const x of btns) x.disabled = true;
b.textContent = asmr ? "whispering…" : "recording…";
try {
m.voice = await makeVoice(c, m.content, undefined, { asmr });
saveThread(t);
placeVoice(t, m); // the voice note goes above the words
if (el.isConnected) msgTools(el, t, m); // and the line under them loses hear / whisper
balanceSoon();
} catch (e) { for (const x of btns) x.disabled = false; b.textContent = label; toast(e.message); } finally { voicing.delete(m); }
}, { class: "linkish hear", "aria-label": asmr ? `Hear this whispered by ${c.name}, over soft rain` : `Hear this as a voice note from ${c.name}`, disabled: voicing.has(m) || null });
btns.push(b);
return b;
};
return [make("hear", false), make("whisper", true)];
}
/** a voice note of these words, in the character's voice and mood */
async function makeVoice(c, text, signal) {
async function makeVoice(c, text, signal, { asmr = false } = {}) {
S.inflight++;
try {
await loadList("tts");
if (asmr) { // whispered, at the ASMR pace, over soft rain
const a = S.ab.asmr, p = W.WHISPER_PRESETS[a.voice] || W.WHISPER_PRESETS[W.WHISPER_DEFAULT];
const words = A.speakable(text);
if (!words) throw new Error("There are no words to whisper.");
const input = W.whisperInput(words, p), pm = (S.lists.tts || []).find((m) => m.id === p.model);
let blob = await new Venice(S.key).speak({ model: p.model, voice: p.voice, input, speed: a.pace || p.speed, format: p.format, signal });
if (a.rain && a.level > 0) { try { blob = await overRain(blob, a.level); } catch { /* the dry take is still a whisper */ } }
return { id: await M.put(blob), model: pm ? pm.name : p.model, voice: p.voice, price: pm ? A.voicePrice(pm, input.length) : null, whisper: true };
}
const vm = voiceModel(c);
if (!vm) throw new Error(S.listErr.tts || "Venice has no voice models right now.");
const voice = voiceOf(c, vm), words = A.speakable(text);
@@ -1468,7 +1506,13 @@ function deliver(t, msg) {
saveThread(t);
if (here(t)) {
for (const el of $$("#log .suggest, #log .note")) el.remove();
put(mediaLine(c, msg));
if (msg.media) put(mediaLine(c, msg));
else { // a voice note and its words (a /whisper)
if (msg.voice) put(voiceLine(c, msg));
let last = null;
for (const b of bubblesOf(c, msg.content || "")) { last = put(line(b, "them")); last.dataset.msg = msg.id; }
if (last) msgTools(last, t, msg);
}
renderJobs();
scrollLog();
}
@@ -1622,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;
@@ -1653,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 = "";
@@ -1670,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) {
@@ -1758,9 +1819,10 @@ function renderTools() {
const pm = adultFor(c, thread()).ok ? adultPhotoModel() : photoModel();
const pp = pm ? A.photoPrice(pm, Math.min(Math.max(1, refsOf(c).length), Math.max(1, pm.maxIn || 1))) : null;
items.push(linkish(`ask for a photo${pp != null ? " · " + A.usd(pp) : ""}`, () => askFor("photo")), linkish("ask for a clip", () => askFor("clip")));
const on = !!S.voiceOn[c.id];
items.push(linkish(`voice notes: ${on ? "on" : "off"}`, () => setVoiceNotes(c, !on),
{ "aria-pressed": on ? "true" : "false", title: on ? `${c.name} replies with voice notes` : `${c.name} replies in text; "hear it" reads one out` }));
const mode = voiceMode(c), next = { off: "on", on: "asmr", asmr: "off" }[mode];
items.push(linkish(`voice notes: ${mode}`, () => setVoiceNotes(c, next),
{ "aria-pressed": mode === "off" ? "false" : "true", title: mode === "on" ? `${c.name} replies with voice notes. Click for ASMR.`
: mode === "asmr" ? `${c.name} whispers replies over soft rain (abilities → asmr). Click to turn off.` : `${c.name} replies in text; "hear it" or "whisper it" reads one out. Click to turn on.` }));
const left = Math.max(0, S.ab.allowance - spentToday());
items.push(linkish(`on their own: ${S.ab.own ? "on" : "off"}`, () => setOwn(!S.ab.own), { "aria-pressed": S.ab.own ? "true" : "false",
title: S.ab.own ? `Characters text first and send photos and voice notes when they like: ${A.usd(left)} of today's ${A.usd(S.ab.allowance)} left` : "Characters only do what you ask" }));
@@ -1818,9 +1880,17 @@ function renderAbilities() {
adultChanged();
}), ["photos: ", change("adultPhoto", apm), " · clips: ", change("adultClip", acm), ` · portraits: ${aim ? aim.name : "…"}`,
" · without safe mode, only from pictures made here with ai, with the 18+ rule first in every prompt; never anyone under 18, never a real person"]),
row("VOICE NOTES", real ? tick(`${c.name} replies with voice notes`, !!S.voiceOn[c.id], (on) => setVoiceNotes(c, on))
: "pick a character: their voice and mood are on their card.",
row("VOICE NOTES", real ? (() => {
const s = h("select", { "aria-label": `How ${c.name}'s replies come` }, [["off", "off: text"], ["on", "on: their voice"], ["asmr", "asmr: whispered"]].map(([v, t]) => h("option", { value: v, text: t })));
s.value = voiceMode(c);
s.addEventListener("change", () => setVoiceNotes(c, s.value));
return h("label", { class: "check" }, `${c.name}'s replies: `, s);
})() : "pick a character: their voice and mood are on their card.",
real ? ["voice: ", change("voice", vm), ` (${vm ? voiceOf(c, vm) : c.speech.voice || A.DEFAULT_VOICE})`, vm && vm.perMChars != null ? ` · about ${A.usd(A.voicePrice(vm, 200))} a reply` : ""] : ""),
row("ASMR", asmrControls(), (() => {
const p = W.WHISPER_PRESETS[S.ab.asmr.voice], vm = (S.lists.tts || []).find((m) => m.id === p.model);
return `whispered by ${p.voice} on ${vm ? vm.name : p.model}${vm && vm.perMChars != null ? ` · about ${A.usd(A.voicePrice(vm, 200))} a reply` : ""} · for voice notes set to asmr, "whisper it" under a message, and /whisper`;
})()),
row("HEARING", tick("let them hear my voice notes", S.ab.hear, (on, i) => {
if (on && !(navigator.mediaDevices && navigator.mediaDevices.getUserMedia && typeof MediaRecorder !== "undefined")) {
i.checked = false;
@@ -2291,6 +2361,7 @@ function runWorkflow(id, { door = "chat", idea = "", name = "" } = {}) {
if (id === "new") return writeFromIdea(idea, { door, name });
if (id === "photoshoot") return photoshoot(idea, { door });
if (id === "recall") return recall(idea);
if (id === "whisper") return whisper(idea);
}
/** the chat a workflow's result lands in: the one of theirs on screen, else their latest, else a new one */
function threadFor(c) {
@@ -2404,6 +2475,95 @@ async function photoshoot(arg, { door = "chat" } = {}) {
balanceSoon();
}
}
/**
* /whisper: a short ASMR voice note. One call writes the script in their voice from the chat (and what it's about,
* if given); a whisper preset speaks it (W.WHISPER_PRESETS: luna on Inworld, eve on Grok, picked from the
* asmr.gandora.lol bake-off); it's mixed over soft rain made here, and lands in their chat as a voice note.
*/
let whispering = false;
async function whisper(arg) {
const c = card();
if (!c || c.plain) { err("/whisper needs a character: pick one in the harbor first."); return; }
if (whispering) { toast("A whisper is already on its way."); return; }
const { preset: named, brief } = W.parseWhisper(arg);
const key = named || (W.WHISPER_PRESETS[S.ab.asmr.voice] ? S.ab.asmr.voice : W.WHISPER_DEFAULT), p = W.WHISPER_PRESETS[key];
await loadList("tts");
const vm = (S.lists.tts || []).find((m) => m.id === p.model);
const guess = vm ? A.voicePrice(vm, 600) : null;
if (!confirm(`A whispered voice note from ${c.name} (${p.voice})${brief ? " about " + brief : ""}? About ${guess != null ? A.usd(guess) : "a cent"} on your Venice key, plus the words.`)) return;
const t = threadFor(c);
whispering = true;
S.inflight++;
doing(t, "is writing something to whisper…");
try {
const lines = t.msgs.filter((m) => !m.hidden && (m.role === "user" || m.role === "assistant")).slice(-10)
.map((m) => `${m.role === "user" ? "Them" : c.name}: ${String(m.content || "").replace(/\s+/g, " ").trim().slice(0, 300)}`);
const o = await askJson(await jsonModel(c), { messages: W.whisperMessages(c, { brief, lines }), schema: W.WHISPER_SCHEMA, maxTokens: 400, temperature: 0.9, what: "the whisper" });
const script = W.cleanWhisper(o);
if (!script) throw new Error("The model sent back nothing to whisper. Try again, or pick another model on the card.");
doing(t, "is whispering…");
const input = W.whisperInput(script, p);
const voice = await new Venice(S.key).speak({ model: p.model, voice: p.voice, input, speed: S.ab.asmr.pace || p.speed, format: p.format });
let blob = voice;
if (S.ab.asmr.rain && S.ab.asmr.level > 0) { try { blob = await overRain(voice, S.ab.asmr.level); } catch { /* the dry take is still a whisper */ } }
const price = vm ? A.voicePrice(vm, input.length) : null;
done(t);
deliver(t, { role: "assistant", content: script, voice: { id: await M.put(blob), model: vm ? vm.name : p.model, voice: p.voice, price, whisper: true } });
} catch (e) {
done(t);
err(e.message || String(e));
} finally {
whispering = false;
S.inflight--;
balanceSoon();
}
}
/** the voice, close-mic'd (a little low end, gentle compression), over soft rain made here; a wav back */
async function overRain(blob, level = 0.09) {
const AC = window.OfflineAudioContext || window.webkitOfflineAudioContext;
const probe = new (window.AudioContext || window.webkitAudioContext)();
const v = await probe.decodeAudioData(await blob.arrayBuffer());
probe.close && probe.close();
const rate = 44100, pad = 1.5, len = Math.ceil((v.duration + pad * 2) * rate);
const ctx = new AC(2, len, rate);
// rain: pink-ish hiss with a slow flutter, over a low brown rumble
const noise = ctx.createBuffer(2, len, rate);
for (let ch = 0; ch < 2; ch++) {
const d = noise.getChannelData(ch); let b0 = 0, b1 = 0, b2 = 0, br = 0;
for (let i = 0; i < len; i++) {
const w = Math.random() * 2 - 1;
b0 = 0.997 * b0 + 0.029 * w; b1 = 0.985 * b1 + 0.032 * w; b2 = 0.95 * b2 + 0.048 * w;
br = (br + 0.02 * w) / 1.02;
d[i] = (b0 + b1 + b2 + w * 0.05) * 0.6 + br * 2.5;
}
}
const rain = ctx.createBufferSource(); rain.buffer = noise;
const hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 120;
const lp = ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 6500;
const rg = ctx.createGain(); const end = len / rate;
rg.gain.setValueAtTime(0, 0); rg.gain.linearRampToValueAtTime(level, 1.2); rg.gain.setValueAtTime(level, end - 1.2); rg.gain.linearRampToValueAtTime(0, end);
rain.connect(hp); hp.connect(lp); lp.connect(rg); rg.connect(ctx.destination);
const src = ctx.createBufferSource(); src.buffer = v;
const lo = ctx.createBiquadFilter(); lo.type = "lowshelf"; lo.frequency.value = 180; lo.gain.value = 4;
const cmp = ctx.createDynamicsCompressor(); cmp.threshold.value = -30; cmp.ratio.value = 4; cmp.attack.value = 0.01; cmp.release.value = 0.25;
const vg = ctx.createGain(); vg.gain.value = 1.6;
src.connect(lo); lo.connect(cmp); cmp.connect(vg); vg.connect(ctx.destination);
rain.start(0); src.start(pad);
return wavBlob(await ctx.startRendering());
}
/** an AudioBuffer as a 16-bit wav */
function wavBlob(buf) {
const n = buf.numberOfChannels, len = buf.length, out = new DataView(new ArrayBuffer(44 + len * n * 2));
const s = (o, t) => { for (let i = 0; i < t.length; i++) out.setUint8(o + i, t.charCodeAt(i)); };
s(0, "RIFF"); out.setUint32(4, 36 + len * n * 2, true); s(8, "WAVE"); s(12, "fmt "); out.setUint32(16, 16, true);
out.setUint16(20, 1, true); out.setUint16(22, n, true); out.setUint32(24, buf.sampleRate, true);
out.setUint32(28, buf.sampleRate * n * 2, true); out.setUint16(32, n * 2, true); out.setUint16(34, 16, true);
s(36, "data"); out.setUint32(40, len * n * 2, true);
const chans = []; for (let c = 0; c < n; c++) chans.push(buf.getChannelData(c));
let o = 44;
for (let i = 0; i < len; i++) for (let c = 0; c < n; c++) { const x = Math.max(-1, Math.min(1, chans[c][i])); out.setInt16(o, x < 0 ? x * 0x8000 : x * 0x7fff, true); o += 2; }
return new Blob([out], { type: "audio/wav" });
}
/**
* A photo of them from the chat, kept on their card: as their portrait (as = "portrait"; the look it was drawn from goes
* on the card too if it had none), or as one of the pictures their photos and clips are made from (as = "").
@@ -2643,12 +2803,12 @@ async function ownVoice(t) {
const c = cardOf(t), msg = [...t.msgs].reverse().find((m) => m.role === "assistant" && !m.media && !m.demo);
if (!c || !msg || msg.voice || voicing.has(msg)) return;
await loadList("tts");
const vm = voiceModel(c), price = vm ? A.voicePrice(vm, A.speakable(msg.content).length) : null;
const asmr = voiceMode(c) === "asmr", price = voiceCost(c, msg.content, asmr);
if (msg.voice || voicing.has(msg) || !reserve(price)) return;
voicing.add(msg);
doing(t, "is recording a voice note…");
doing(t, asmr ? "is whispering…" : "is recording a voice note…");
try {
const v = { ...(await makeVoice(c, msg.content)), own: true };
const v = { ...(await makeVoice(c, msg.content, undefined, { asmr })), own: true };
if (msg.voice) { refund(price); return; } // "hear it" got there first
msg.voice = v;
saveThread(t);
@@ -3072,8 +3232,23 @@ function setOwn(on) {
if ($("#abilitydlg").open) renderAbilities();
toast(on ? `Characters act on their own now, within ${A.usd(S.ab.allowance)} a day.` : "Characters only do what you ask now.");
}
/** the ASMR settings: voice, pace, rain, how often */
function asmrControls() {
const a = S.ab.asmr, set = (k, v) => { S.ab.asmr = { ...S.ab.asmr, [k]: v }; saveAb(); renderAbilities(); };
const pick = (label, opts, key, num = true) => {
const s = h("select", { "aria-label": label }, opts.map(([v, t]) => h("option", { value: String(v), text: t })));
s.value = String(key === "level" && !a.rain ? 0 : a[key]);
s.addEventListener("change", () => { const v = num ? Number(s.value) : s.value; if (key === "level") { S.ab.asmr = { ...S.ab.asmr, rain: v > 0, level: v || a.level }; saveAb(); renderAbilities(); } else set(key, v); });
return s;
};
const voices = Object.keys(W.WHISPER_PRESETS).map((k) => [k, W.WHISPER_PRESETS[k].voice]);
return h("span", {}, "voice: ", pick("Whisper voice", voices, "voice", false), " · pace: ", pick("Whisper pace", ASMR_PACES, "pace"),
" · rain: ", pick("Rain under the whisper", ASMR_LEVELS, "level"), " · how often: ", pick("How often replies are whispered in asmr", ASMR_EVERY, "every"));
}
function setVoiceNotes(c, on) {
if (on) S.voiceOn[c.id] = true; else delete S.voiceOn[c.id];
const mode = on === true ? "on" : on === false ? "off" : on;
if (mode === "on") S.voiceOn[c.id] = true; else if (mode === "asmr") S.voiceOn[c.id] = "asmr"; else delete S.voiceOn[c.id];
if (mode !== "off" && mode !== "on" && mode !== "asmr") delete S.voiceOn[c.id];
local.set(K.voiceOn, S.voiceOn);
renderTools();
if ($("#speech-on")) $("#speech-on").checked = !!S.voiceOn[(card() || {}).id];
@@ -3504,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("");
@@ -3520,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 */
@@ -3770,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) {
+12 -3
View File
@@ -23,6 +23,8 @@
<link rel="alternate" type="text/markdown" href="skill.md" title="Skill">
<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>
@@ -300,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>
@@ -370,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>
+84
View File
@@ -0,0 +1,84 @@
// 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 list can also be hidden, like a
// sidebar: the ‹ button on the line, dragging it most of the way to the left, or Ctrl/Cmd+B; › brings it back.
// The width and whether it's hidden are 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", HID = "havre.split.hidden", MIN = 320, CHAT_MIN = 380, SNAP = 200;
const get = (k) => { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } };
const put = (k, v) => { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, 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";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "splitbtn";
bar.append(btn);
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)));
let hidden = get(HID) === true;
function show() {
const off = hidden && wide();
studio.classList.toggle("list-hidden", off);
btn.textContent = off ? "›" : "‹";
btn.setAttribute("aria-label", off ? "Show the list" : "Hide the list");
btn.title = off ? "show the list (ctrl+b)" : "hide the list (ctrl+b)";
btn.setAttribute("aria-expanded", off ? "false" : "true");
}
function hide(on) { hidden = on; put(HID, on || null); show(); }
function set(w, keep = true) {
if (w == null || !wide()) { chars.style.flex = ""; bar.removeAttribute("aria-valuenow"); if (w == null && keep) put(KEY, null); return; }
w = clamp(w);
chars.style.flex = `0 0 ${w}px`;
bar.setAttribute("aria-valuenow", String(w));
if (keep) put(KEY, w);
}
set(get(KEY), false);
show();
window.addEventListener("resize", () => { set(get(KEY), false); show(); });
btn.addEventListener("pointerdown", (e) => e.stopPropagation());
btn.addEventListener("dblclick", (e) => e.stopPropagation());
btn.addEventListener("click", () => hide(!hidden));
bar.addEventListener("pointerdown", (e) => {
if (!wide() || e.button !== 0) return;
e.preventDefault();
bar.setPointerCapture(e.pointerId);
const left = studio.getBoundingClientRect().left;
document.body.classList.add("splitting");
const move = (ev) => {
const w = ev.clientX - left;
if (w < SNAP) { if (!hidden) hide(true); return; } // most of the way left: tuck it away
if (hidden) hide(false);
set(w);
};
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", () => { hide(false); set(null); });
bar.addEventListener("keydown", (e) => {
if (e.target !== bar) return;
const now = chars.getBoundingClientRect().width, step = e.shiftKey ? 80 : 20;
if (e.key === "ArrowLeft") { if (hidden) return e.preventDefault(); if (now - step < MIN) hide(true); else set(now - step); }
else if (e.key === "ArrowRight") { if (hidden) hide(false); else set(now + step); }
else if (e.key === "Home") { hide(false); set(null); }
else if (e.key === "Enter" || e.key === " ") hide(!hidden);
else return;
e.preventDefault();
});
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "b" && wide()) { e.preventDefault(); hide(!hidden); }
});
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start();
})();
+31
View File
@@ -144,6 +144,19 @@ 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; }
.splitbtn {
position: sticky; top: 12px; left: 0; margin: 12px 0 0 -9px; width: 26px; height: 32px; padding: 0;
border: 1px solid var(--ink); border-radius: 0; background: var(--paper); color: var(--ink); font: 700 16px/1 var(--mono); cursor: pointer;
}
.splitbtn:hover, .splitbtn:focus-visible { background: var(--hi); }
/* the list hidden, like a sidebar: the chat takes the width, the line stays to bring it back */
.studio.list-hidden .chars { display: none; }
.studio.list-hidden .splitter { flex-basis: 30px; margin: 0; border-right: 1px solid var(--ink); }
.studio.list-hidden .splitbtn { margin-left: 1px; }
.chat {
flex: 1 1 440px; min-width: 0; display: flex; flex-direction: column;
position: sticky; top: 0; max-height: 100vh;
@@ -428,6 +441,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 +483,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 +501,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 +517,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 */
@@ -551,6 +575,13 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi);
.lefthead [role=tab] { font-size: 19px; }
.lefthead .sep::before { font-size: 19px; }
}
/* a new version is out: a bar at the top to reload (update.js) */
#upd {
position: fixed; top: calc(10px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); z-index: 30;
padding: 9px 16px; border: 1px solid var(--paper); border-radius: 0; background: var(--ink); color: var(--paper);
font: 700 13px/1 var(--mono); letter-spacing: .12em; white-space: nowrap; cursor: pointer;
}
#upd:hover, #upd:focus-visible { background: var(--hi); color: var(--ink); border-color: var(--ink); }
@media (prefers-reduced-motion: reduce) {
* { scroll-behavior: auto !important; }
}
+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");
+37
View File
@@ -0,0 +1,37 @@
// update.js: an installed Havre has no address bar to refresh from, so the page notices when a new version has been
// published and offers a reload, like gandora's pill. It fingerprints the files the app is made of when it opens,
// checks again whenever it comes back to the foreground (and every ten minutes), and shows the pill when they differ.
// 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", "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)));
if (parts.some((p) => p === null)) return null; // offline: no verdict
const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(parts.join("\u0000")));
return Array.from(new Uint8Array(d), (b) => b.toString(16).padStart(2, "0")).join("");
}
function show() {
if (shown) return;
shown = true;
const b = document.createElement("button");
b.id = "upd"; b.type = "button"; b.textContent = "NEW VERSION · RELOAD";
b.addEventListener("click", () => location.reload());
document.body.append(b);
}
async function check() {
if (busy || shown || !crypto.subtle) return;
busy = true;
try {
const now = await print();
if (!now) return;
if (!base) base = now;
else if (now !== base) show();
} finally { busy = false; }
}
if (location.protocol !== "https:") return;
setTimeout(check, 3000);
setInterval(check, 10 * 60 * 1000);
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") check(); });
})();
+3 -2
View File
@@ -37,7 +37,7 @@ function sent(e, what) {
}
function explain(status, said, refused) {
// Venice's own words for it ("violates the content policy of Venice.ai or the model provider") say no more than this
if (refused) return "Venice turned that down as against its content rules" + (/content polic/i.test(said) || !said ? "." : ` (“${said.replace(/[.\s]+$/, "")}”).`);
if (refused) return "Venice turned that down as against its content rules" + (/content polic/i.test(said) || !said ? "." : ` ("${said.replace(/[.\s]+$/, "")}").`);
if (status === 401 || status === 403) return "Venice didn't accept that key. Check it at venice.ai → Settings → API.";
if (status === 402) return "Venice says the key is out of credit.";
if (status === 429) return "Venice is rate-limiting this key. Wait a moment and try again.";
@@ -141,8 +141,9 @@ export class Venice {
}
/** a voice note: words read out by a voice model. mood goes to models that take a style prompt; the rest ignore it. */
async speak({ model, voice, input, mood = "", speed = 1, signal } = {}) {
async speak({ model, voice, input, mood = "", speed = 1, format = "", signal } = {}) {
const body = { input: String(input || "").slice(0, 4096), model, speed };
if (format) body.response_format = format;
if (voice) body.voice = voice;
if (mood) body.prompt = String(mood).slice(0, 500);
const res = await this.raw("/audio/speech", { body, signal });
+50
View File
@@ -33,6 +33,11 @@ export const WORKFLOWS = [
about: "a few photos of them from the pictures on their card, one shoot, each a different moment (3 unless you say)",
usage: "/photoshoot [how many] [what the shoot is about]",
},
{
id: "whisper", command: "whisper", title: "a whisper", takes: "card", gives: "voice note", doors: ["chat"],
about: "a short ASMR voice note from them, whispered over soft rain (luna or eve; add what it's about)",
usage: "/whisper [luna|eve] [what it's about]",
},
{
id: "recall", command: "recall", title: "recall", takes: "words", gives: "memory", doors: ["chat"],
about: "what they remember about something (their notes, then the chats themselves), brought to mind for a while",
@@ -128,6 +133,51 @@ export function cardFromIdea(o, { model } = {}) {
return normalizeCard({ ...base, look: { ...base.look, description: look } });
}
// ---------- whisper: a short ASMR voice note, over soft rain ----------
// The voices that whisper well and cheaply (picked in the asmr.gandora.lol bake-off): Venice model, voice, how the
// words are wrapped so the model whispers, and the pace (0.9 of their usual reading speed sounded best).
export const WHISPER_PRESETS = {
luna: { model: "tts-inworld-1-5-max", voice: "Luna", lead: "[whispering] ", tail: "", speed: 0.77, format: "wav" },
eve: { model: "tts-xai-v1", voice: "eve", lead: "<whisper>", tail: "</whisper>", speed: 0.77, format: "mp3" },
};
export const WHISPER_DEFAULT = "luna";
/** the words after /whisper: a preset name first if there is one, the rest what it's about */
export function parseWhisper(arg = "") {
const m = /^\s*(\w+)(?:\s+|$)([\s\S]*)$/.exec(str(arg, 2000));
const first = m ? m[1].toLowerCase() : "";
if (WHISPER_PRESETS[first]) return { preset: first, brief: m[2].replace(/\s+/g, " ").trim() };
return { preset: "", brief: str(arg, 2000).replace(/\s+/g, " ").trim() };
}
export const WHISPER_SCHEMA = {
type: "object", additionalProperties: false, required: ["script"],
properties: { script: { type: "string" } },
};
export function whisperMessages(card, { brief = "", lines = [] } = {}) {
const system = `You are ${card.name}. Write a short ASMR voice note you whisper to the person you're talking with: ` +
"60 to 110 words, in your own voice and manner, intimate and calming, present tense, short phrases with pauses " +
"written as \"...\". Gentle sensory detail (breath, rain, a soft sound nearby) is welcome. No stage directions, no " +
"emoji, no markup, nothing in brackets: only the words to be whispered. Return one JSON object: script.";
const persona = card.persona ? clip(card.persona.replace(/\s+/g, " "), 900) : "";
const user = [
persona ? `Who you are: ${persona}` : "",
lines.length ? "The conversation so far, latest last:\n" + lines.join("\n") : "You haven't talked yet.",
"What it's about: " + (str(brief, 500).trim() || "winding down together; help them relax."),
].filter(Boolean).join("\n\n");
return [{ role: "system", content: system }, { role: "user", content: user }];
}
/** the script, cleaned of anything the model shouldn't have added (brackets, asterisks, emoji-ish markup) */
export function cleanWhisper(o) {
return str(o && o.script, 1500).replace(/\[[^\]]*\]|\([^)]*\)|<[^>]*>|\*[^*]*\*/g, "").replace(/\s+/g, " ").trim();
}
/** what the voice model is sent: the script inside the preset's whisper markup */
export const whisperInput = (script, preset) => (preset.lead || "") + script + (preset.tail || "");
// ---------- photoshoot: a few photos of them, from the pictures on their card ----------
export const SHOOT_MIN = 1, SHOOT_MAX = 6, SHOOT_DEFAULT = 3;
+1 -1
View File
@@ -520,5 +520,5 @@
"hosting": "pages"
}
],
"refreshed": "2026-10-21"
"refreshed": "2026-10-23"
}