Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
263e68d040 | ||
|
|
8f7bf1c2d4 | ||
|
|
5fe882dc27 | ||
|
|
e74ae3d29e | ||
|
|
c8cee0e72c | ||
|
|
9b835a4b6e | ||
|
|
3b6bf3930f | ||
|
|
c6f4fe489f | ||
|
|
6fff82ebb8 | ||
|
|
ffb16e5605 | ||
|
|
2e86c329d4 | ||
|
|
cbb01d0a33 | ||
|
|
0c0feec53b | ||
|
|
26265ee0cf | ||
|
|
97610b03ec | ||
|
|
23fde14eda | ||
|
|
79635906e3 | ||
|
|
e82c7e791f | ||
|
|
9841186f80 | ||
|
|
0a263c782b | ||
|
|
f00e5156dc | ||
|
|
67b91770a7 | ||
|
|
1666f0200c | ||
|
|
ffc0e85999 | ||
|
|
3324fcc226 | ||
|
|
7769502e0b | ||
|
|
4ebed492bd | ||
|
|
982d1af057 | ||
|
|
bf2ecbc6b3 | ||
|
|
1c57e075ff | ||
|
|
f733a66fb6 | ||
|
|
c0aea23067 | ||
|
|
b976041118 | ||
|
|
538b93cf75 | ||
|
|
7b66e4dd35 | ||
|
|
0a22d29dd4 | ||
|
|
271243a334 | ||
|
|
60f374527f | ||
|
|
0c6fd2d1f1 | ||
|
|
a7f342b196 | ||
|
|
abbcceba53 | ||
|
|
2631dd6f6c | ||
|
|
4add8c4484 | ||
|
|
c866d70f5c | ||
|
|
70a856db39 | ||
|
|
b3ff0f9672 | ||
|
|
5a56c56a07 | ||
|
|
3a0df54646 | ||
|
|
c435a7be1f | ||
|
|
ce2eead558 | ||
|
|
a78d6db949 | ||
|
|
cd8744c64e | ||
|
|
55a580d0c6 | ||
|
|
9e9fdc0a70 | ||
|
|
6716c81106 | ||
|
|
802ef8233f | ||
|
|
a5720e6b3c | ||
|
|
a2a505a511 | ||
|
|
538fa96aa6 | ||
|
|
df2299a0f7 | ||
|
|
a93ca1ec95 | ||
|
|
57d448443f | ||
|
|
9c9a0dd377 | ||
|
|
f967f739e4 | ||
|
|
a8e8d0efbd | ||
|
|
af7fe043bd | ||
|
|
c54522678b | ||
|
|
df98581056 | ||
|
|
8806f56907 | ||
|
|
86a9ec10d3 | ||
|
|
81805e1a25 | ||
|
|
8d21bf11ff | ||
|
|
64fa4fc0b7 | ||
|
|
6bdb300adb | ||
|
|
050aa296a5 | ||
|
|
52edca2774 | ||
|
|
790fa26b73 | ||
|
|
b3ec97387f | ||
|
|
1b4641c1a4 | ||
|
|
00cbf4f4eb | ||
|
|
fbf54971af | ||
|
|
3a8bdf5f56 | ||
|
|
95ea8ca9a1 |
No files matched your search
@@ -81,6 +81,41 @@ export const voicePrice = (model, chars) => (model && model.perMChars != null ?
|
||||
/** a minute of your voice, heard */
|
||||
export const hearingPerMinute = (model) => (model && model.perSecond != null ? model.perSecond * 60 : null);
|
||||
|
||||
/**
|
||||
* What a model costs, as the numbers the model picker sorts by, the first counting most: a text model's price in, then
|
||||
* out (a chat is mostly what's sent in: the card, the memories, the conversation so far); a photo's price, then what each
|
||||
* picture past the included ones adds; a voice's price for a million characters; hearing's price for a second. null when
|
||||
* Venice lists no price (a clip: it prices each one when asked).
|
||||
*/
|
||||
export function modelCost(type, model) {
|
||||
if (!model) return null;
|
||||
const n = (x) => (typeof x === "number" && Number.isFinite(x) ? x : null);
|
||||
const keys = type === "text" ? [n(model.input), n(model.output)]
|
||||
: type === "inpaint" || type === "image" ? [n(model.price), n(model.extra)]
|
||||
: type === "tts" ? [n(model.perMChars)]
|
||||
: type === "asr" ? [n(model.perSecond)]
|
||||
: [];
|
||||
return keys.length && keys[0] != null ? keys.map((k) => k ?? 0) : null;
|
||||
}
|
||||
/** the orders the model picker has: by name (the one in use first), cheapest first, priciest first */
|
||||
export const MODEL_SORTS = ["name", "cheapest", "priciest"];
|
||||
/**
|
||||
* The model picker's list in order, as a new list. "name": the ones rank puts higher first (the one in use, then the
|
||||
* character's own), then A to Z. "cheapest" and "priciest": by modelCost, A to Z between equals, the ones without a price
|
||||
* last either way, and none held at the top, so the one in use shows where its price puts it.
|
||||
*/
|
||||
export function sortModels(list, how = "name", { type = "text", rank = () => 0 } = {}) {
|
||||
const all = [...(list || [])], byName = (a, b) => String(a.name || a.id).localeCompare(String(b.name || b.id));
|
||||
if (how !== "cheapest" && how !== "priciest") return all.sort((a, b) => rank(b) - rank(a) || byName(a, b));
|
||||
const dir = how === "priciest" ? -1 : 1, cost = new Map(all.map((m) => [m, modelCost(type, m)]));
|
||||
return all.sort((a, b) => {
|
||||
const x = cost.get(a), y = cost.get(b);
|
||||
if (!x || !y) return (x ? 0 : 1) - (y ? 0 : 1) || byName(a, b);
|
||||
for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return dir * (x[i] - y[i]);
|
||||
return byName(a, b);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- what goes into a photo or a clip ----------
|
||||
|
||||
// Content filters (Venice's, and the ones of the models' makers) read the words in a prompt, not the "no" in front of
|
||||
|
||||
+298
-59
@@ -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, {}) || {};
|
||||
@@ -657,15 +678,22 @@ function syncAdult(list) {
|
||||
function renderRoster() {
|
||||
const tb = $("#roster");
|
||||
tb.textContent = "";
|
||||
$("#harbor-empty").hidden = S.cards.length > 0;
|
||||
$("#harbor .tablewrap").hidden = !S.cards.length;
|
||||
$("#harbor-empty").hidden = true;
|
||||
$("#harbor .tablewrap").hidden = false;
|
||||
const cur = card();
|
||||
// the first row is always someone new: the way back to a blank card from anyone's
|
||||
const fresh = () => { S.fresh = true; syncNew(); $("#card").scrollIntoView({ behavior: motion(), block: "start" }); $("#nc-name").focus({ preventScroll: true }); };
|
||||
tb.append(h("tr", { class: "opens newrow", onclick: (e) => { if (!e.target.closest("button, a")) fresh(); } },
|
||||
h("th", { scope: "row" }, linkish("+ new character", fresh, { class: "linkish rowname" })),
|
||||
h("td", { text: "write one here" }), h("td", { text: "" }),
|
||||
h("td", { class: "files" }, linkish("import a card", () => $("#import").click()))));
|
||||
queueMicrotask(syncNew);
|
||||
for (const c of S.cards) {
|
||||
const on = cur && c.id === cur.id;
|
||||
const from = c.engine.venice_character ? "venice, as a link with credit" : c.lineage[0] ? C.creditLine(c.lineage[0]) : "written here";
|
||||
const go = (tab) => () => { selectCard(c.id); openCard(tab); };
|
||||
// the whole row opens their card; the links in it do what they say
|
||||
tb.append(h("tr", { "aria-current": on ? "true" : null, class: "opens", onclick: (e) => { if (!e.target.closest("button, a")) go("edit")(); } },
|
||||
tb.append(h("tr", { "data-id": c.id, class: "opens", onclick: (e) => { if (!e.target.closest("button, a")) go("edit")(); } },
|
||||
h("th", { scope: "row" }, linkish(c.name, go("edit"), { class: "linkish rowname" }), c.adult ? h("span", { class: "x18", title: "for grown-ups only", text: "18+" }) : null,
|
||||
on ? h("span", { class: "now", text: "talking" }) : null),
|
||||
h("td", { text: from }),
|
||||
@@ -1002,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,
|
||||
@@ -1096,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);
|
||||
@@ -1269,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) {
|
||||
@@ -1349,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 ----------
|
||||
@@ -1419,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);
|
||||
@@ -1461,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();
|
||||
}
|
||||
@@ -1500,6 +1551,11 @@ function renderCard() {
|
||||
function syncNew() {
|
||||
const c = card(), real = !!c && !c.plain, fresh = S.left === "harbor" && (S.fresh || !real);
|
||||
$("#card-new").hidden = !fresh;
|
||||
// one row marked: the one whose card is open below
|
||||
for (const tr of document.querySelectorAll("#roster tr")) {
|
||||
const on = tr.classList.contains("newrow") ? fresh : !fresh && real && tr.dataset.id === c.id;
|
||||
if (on) tr.setAttribute("aria-current", "true"); else tr.removeAttribute("aria-current");
|
||||
}
|
||||
$("#card-body").hidden = fresh || !real;
|
||||
$("#card-empty").hidden = fresh || real;
|
||||
$("#card-h").textContent = fresh ? "NEW CHARACTER" : real ? "CARD: " + c.name.toUpperCase() : "CARD";
|
||||
@@ -1610,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;
|
||||
@@ -1641,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 = "";
|
||||
@@ -1658,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) {
|
||||
@@ -1746,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" }));
|
||||
@@ -1806,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;
|
||||
@@ -2279,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) {
|
||||
@@ -2392,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 = "").
|
||||
@@ -2631,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);
|
||||
@@ -3060,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];
|
||||
@@ -3492,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("");
|
||||
@@ -3508,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 */
|
||||
@@ -3758,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 }));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#chat">skip to the chat</a>
|
||||
@@ -30,7 +31,7 @@
|
||||
<header class="mast" id="top">
|
||||
<h1>HAVRE</h1>
|
||||
<p class="motto">a harbor for characters. <br>talk to venice characters on your own key. <br>no account. no server. nothing kept but what you keep.</p>
|
||||
<nav class="nav" aria-label="On this page"><a href="#how">how it works</a> / <a href="#agents">for agents</a> / <a href="#costs">costs</a> / <a href="#plans">early access</a> / <a href="open/">open studio (beta)</a> / <button type="button" class="linkish" id="theme">dark mode</button> / <button type="button" class="linkish" id="reload" title="the latest Havre (installed as an app, there's no other way to reload)">reload</button></nav>
|
||||
<nav class="nav" aria-label="Havre"><a href="open/">studio</a> / <button type="button" class="linkish" id="theme">dark mode</button> / <button type="button" class="linkish" id="reload" title="the latest Havre (installed as an app, there's no other way to reload)">reload</button></nav>
|
||||
</header>
|
||||
|
||||
<form class="keybar" id="keyform" autocomplete="off" aria-label="Your Venice API key">
|
||||
@@ -300,7 +301,7 @@
|
||||
<tr><th scope="row">a remix</th><td>two short calls; five more for a venice character</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="mono">the model pickers show venice's prices, what you ask for shows its price before you pay, and your balance shows next to your key.</p>
|
||||
<p class="mono">the model pickers show venice's prices and can sort by them, what you ask for shows its price before you pay, and your balance shows next to your key.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -370,8 +371,15 @@
|
||||
<h2 id="model-h">MODEL</h2>
|
||||
<div class="dlgbody">
|
||||
<p class="mono" id="model-for"></p>
|
||||
<label class="sr" for="msearch">Search models</label>
|
||||
<input id="msearch" type="search" placeholder="search models: a name, private, uncensored…" autocomplete="off">
|
||||
<div class="mfind">
|
||||
<label class="sr" for="msearch">Search models</label>
|
||||
<input id="msearch" type="search" placeholder="search models: a name, private, uncensored…" autocomplete="off">
|
||||
<label class="pick" id="msortpick">sort <select id="msort">
|
||||
<option value="name" selected>name</option>
|
||||
<option value="cheapest">cheapest first</option>
|
||||
<option value="priciest">priciest first</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<div class="mtable">
|
||||
<table class="table models">
|
||||
<thead id="mhead"><tr><th scope="col">MODEL</th><th scope="col">PRIVACY</th><th scope="col">CAN</th><th scope="col">$ IN / OUT</th><th scope="col">CONTEXT</th></tr></thead>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 78 KiB |
@@ -319,6 +319,9 @@ pre.saw {
|
||||
.newcard input, .newcard textarea { display: block; width: 100%; margin-top: 4px; padding: 6px 8px; font: 16px var(--serif);
|
||||
border: 1px solid var(--ink); border-radius: 0; background: var(--paper); color: var(--ink); }
|
||||
tr.opens { cursor: pointer; }
|
||||
#roster tr.newrow .rowname { font-weight: 700; }
|
||||
#roster tr.newrow[aria-current="true"] { background: var(--ink); color: var(--paper); }
|
||||
#roster tr.newrow[aria-current="true"] .linkish { color: var(--paper); }
|
||||
tr.opens:hover { background: var(--hi); }
|
||||
.rowname { font: inherit; color: inherit; text-decoration: none; }
|
||||
.rewrite { display: flex; gap: 8px; margin: 6px 0; }
|
||||
@@ -425,6 +428,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; }
|
||||
@@ -460,6 +470,8 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi);
|
||||
.dlg::backdrop { background: var(--veil); }
|
||||
.dlg.wide { width: min(1040px, calc(100vw - 32px)); }
|
||||
.mtable { max-height: min(58vh, 560px); overflow: auto; border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink); }
|
||||
.mfind { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
|
||||
.mfind input[type=search] { flex: 1 1 220px; width: auto; }
|
||||
.models { font-size: 16px; }
|
||||
.models thead th { position: sticky; top: 0; background: var(--paper); }
|
||||
.models tbody th .mid { display: block; font: 12px/1.4 var(--mono); font-weight: 400; overflow-wrap: anywhere; }
|
||||
@@ -476,6 +488,7 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi);
|
||||
.dlgbody { display: flex; flex-direction: column; gap: 14px; padding: 16px; }
|
||||
.dlgbody label { display: flex; flex-direction: column; gap: 4px; font: 13px var(--mono); }
|
||||
.dlgbody label.check { flex-direction: row; align-items: center; gap: 8px; }
|
||||
.dlgbody label.pick { flex-direction: row; align-items: center; gap: 6px; }
|
||||
.dlgbody textarea { font: 17px var(--serif); }
|
||||
.drop {
|
||||
display: flex; flex-direction: column; gap: 4px; align-items: flex-start; padding: 18px;
|
||||
@@ -548,6 +561,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; }
|
||||
}
|
||||
@@ -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." },
|
||||
@@ -287,8 +313,8 @@ test("the conversation as the model reads it: a photo it sent is mentioned to it
|
||||
assert.deepEqual(api.map((m) => m.role), ["user", "assistant", "user", "user", "assistant", "user"]);
|
||||
assert.equal(api[1].content, "*sends a photo: On the deck*\n\n*sends a short video: Waving*");
|
||||
assert.equal(api[2].content, "lovely");
|
||||
assert.equal(api[2].content, "[a photo: A gull on a post.] what's this?");
|
||||
assert.equal(api[4].content, "hello there");
|
||||
assert.equal(api[3].content, "[a photo: A gull on a post.] what's this?");
|
||||
assert.equal(api[5].content, "hello there");
|
||||
assert.ok(!api.some((m) => /\[photo\]/.test(m.content)), "the model never sees a photo placeholder in its own voice");
|
||||
const seen = A.withPicture([{ role: "system", content: "s" }, { role: "user", content: "[a photo] look" }], "data:image/jpeg;base64,AAAA");
|
||||
assert.deepEqual(seen[1].content[1], { type: "image_url", image_url: { url: "data:image/jpeg;base64,AAAA" } });
|
||||
|
||||
@@ -325,7 +325,7 @@ async function main() {
|
||||
assert.match(await textOf(page, "#who-name"), /^chat$/i);
|
||||
await has(page, "#log .line.note .said", /paste your venice key/);
|
||||
assert.ok(await page.locator("#cast-box").isHidden(), "the harbor's own aren't listed");
|
||||
assert.equal(await page.locator("#harbor-empty").evaluate((e) => e.hidden), false, "the empty harbor says so");
|
||||
assert.equal(await page.locator("#roster tr.newrow").count(), 1, "the harbor always has a row for someone new");
|
||||
assert.equal(await page.locator("#card-empty").evaluate((e) => e.hidden), false, "so does the empty card");
|
||||
assert.ok(await page.locator("#card").isHidden(), "the card lives in the harbor tab, not under the characters");
|
||||
assert.ok(await page.locator("#say").isDisabled(), "nobody to talk to yet");
|
||||
@@ -334,13 +334,14 @@ async function main() {
|
||||
await page.click("#lt-harbor");
|
||||
assert.ok(await page.locator("#card-new").isVisible(), "an empty harbor opens on a new character to write");
|
||||
assert.match(await textOf(page, "#card-h"), /^NEW CHARACTER$/);
|
||||
assert.match(await textOf(page, "#roster tr.newrow"), /\+ new character/);
|
||||
await page.click("#lt-chars");
|
||||
await shot(page, "desktop.png");
|
||||
|
||||
// a card comes in through the import dialog, and talks from its own examples without a key
|
||||
await page.click("#import");
|
||||
await page.setInputFiles("#file", { name: "ro.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(v2)) });
|
||||
await count(page, "#roster tr", 1);
|
||||
await count(page, "#roster tr:not(.newrow)", 1);
|
||||
await has(page, "#who-name", /^Captain Ro$/);
|
||||
await has(page, "#log .line.them .said", /We're closed\. Mostly\./);
|
||||
await has(page, "#roster td", /Ported from Captain Ro by someone/);
|
||||
@@ -420,7 +421,7 @@ async function main() {
|
||||
assert.equal(hello.messages[0].role, "system");
|
||||
assert.match(hello.messages[0].content, /you can send photos of yourself/);
|
||||
assert.equal(hello.messages[1].role, "user");
|
||||
await count(page, "#roster tr", 2);
|
||||
await count(page, "#roster tr:not(.newrow)", 2);
|
||||
assert.match(await page.locator('#vgrid .tile[aria-pressed="true"]').evaluate((el) => el.textContent), /Sable Penrose.*talking now/);
|
||||
// the model: their own on Venice to start with, and any other Venice model from the picker
|
||||
await has(page, "#model-btn", /^GLM 5\.2$/);
|
||||
@@ -429,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?");
|
||||
@@ -768,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();
|
||||
@@ -794,7 +811,7 @@ async function main() {
|
||||
await shot(page, "desktop-remix.png");
|
||||
await page.locator("#rx-draft button", { hasText: "KEEP IN HARBOR" }).click();
|
||||
await has(page, "#who-name", /^Ines Corbel$/);
|
||||
await count(page, "#roster tr", 4);
|
||||
await count(page, "#roster tr:not(.newrow)", 4);
|
||||
await has(page, "#log .line.them .said", /Where are you headed\?/);
|
||||
assert.equal(await page.locator("#studio.pane-chat").count(), 1, "a kept remix opens their chat, to say hello");
|
||||
assert.ok(await page.locator("#chat-back").isHidden(), "a wide screen shows the list and the chat side by side, so no way back is needed");
|
||||
@@ -1060,7 +1077,7 @@ async function main() {
|
||||
await page.click("#lt-chars");
|
||||
await page.click("#import");
|
||||
await page.setInputFiles("#file", png);
|
||||
await count(page, "#roster tr", 5);
|
||||
await count(page, "#roster tr:not(.newrow)", 5);
|
||||
|
||||
// the security policy: nothing but api.venice.ai (and its private-share links for clips)
|
||||
assert.equal(await page.evaluate(() => fetch("https://example.com/").then(() => "reached", () => "blocked")), "blocked");
|
||||
@@ -1131,7 +1148,7 @@ async function main() {
|
||||
taskmaster: [{ role: "user", content: "Plan my week" }, { role: "assistant", content: "Monday first." }] }));
|
||||
});
|
||||
await p2.reload();
|
||||
await count(p2, "#roster tr", 1);
|
||||
await count(p2, "#roster tr:not(.newrow)", 1);
|
||||
const left = await p2.evaluate(() => {
|
||||
const threads = JSON.parse(localStorage.getItem("havre.threads.v1") || "{}"), t = Object.values(threads)[0];
|
||||
return { cards: JSON.parse(localStorage.getItem("havre.cards.v1")), seeded: localStorage.getItem("havre.seeded.v1"),
|
||||
@@ -1179,7 +1196,7 @@ async function main() {
|
||||
await has(p5, "#cast .tile[data-cast=maren] .go", /^talk again$/);
|
||||
await p5.goto(base + "?meet=maren");
|
||||
await has(p5, "#who-name", /^Maren$/);
|
||||
await count(p5, "#roster tr", 2);
|
||||
await count(p5, "#roster tr:not(.newrow)", 2);
|
||||
assert.ok(await p5.locator("#log .line.me .said", { hasText: "What is this place?" }).isVisible(), "the same chat, not a new copy of her");
|
||||
await own.close();
|
||||
|
||||
@@ -1269,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");
|
||||
|
||||
@@ -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"];
|
||||
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(); });
|
||||
})();
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,7 +10,7 @@ People who make a quick throwaway deck every week — sprint demos, status updat
|
||||
|
||||
## How to use it
|
||||
|
||||
1. Open the page and type markdown-ish text in the editor on the left. It comes preloaded with a sample deck so you can see real output immediately.
|
||||
1. Open the page and type markdown-ish text in the editor on the left. It comes preloaded with a sample deck so you can see real output immediately. Already have a `.md` file? Drop it anywhere on the page, or press **Open .md** above the editor; the file is read in the tab and replaces the text.
|
||||
2. Watch the preview on the right re-typeset live. Navigate with the arrow keys (or swipe on touch screens); a progress bar tracks your place.
|
||||
3. A slide with too much text shrinks to fit, down to 70% of the normal size. Past that it's cut into two or more slides, between lines and never right after a heading, and each new slide repeats its title with "(cont.)". A note under the preview says which slides were cut; put in a `---` yourself to choose the break. Cuts are worked out for a 1280x720 screen, so the copied deck has the same slides on any screen, and on a smaller one they shrink a little more.
|
||||
4. Press **Copy deck as HTML** to copy the whole deck as one self-contained HTML file. Open it in any browser and present with the arrow keys — offline, no dependencies.
|
||||
|
||||
@@ -576,6 +576,64 @@
|
||||
if (copied) done(); else failed();
|
||||
}
|
||||
|
||||
// Files: a .md (or any text file) dropped anywhere on the page, or picked with Open .md, becomes the
|
||||
// deck. It is read in the tab with FileReader; nothing is uploaded.
|
||||
var fileInput = document.getElementById("file");
|
||||
var openBtn = document.getElementById("open");
|
||||
var dragDepth = 0;
|
||||
|
||||
function hasFile(e) {
|
||||
var t = e.dataTransfer && e.dataTransfer.types;
|
||||
return !!t && Array.prototype.indexOf.call(t, "Files") >= 0;
|
||||
}
|
||||
|
||||
function openFile(file) {
|
||||
if (!file) return;
|
||||
if (file.size > 2000000) {
|
||||
say(file.name + " is over 2 MB. A deck is plain text, so something else is in that file.", true);
|
||||
return;
|
||||
}
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
load(String(reader.result).replace(/^/, ""));
|
||||
current = 0;
|
||||
history.replaceState(null, "", location.href.split("#")[0]);
|
||||
parse();
|
||||
say("Opened " + file.name + ". Edits stay in this tab; copy a link or the HTML to keep them.");
|
||||
};
|
||||
reader.onerror = function () { say("Couldn't read " + file.name + ".", true); };
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
openBtn.addEventListener("click", function () { fileInput.click(); });
|
||||
fileInput.addEventListener("change", function () {
|
||||
openFile(fileInput.files[0]);
|
||||
fileInput.value = "";
|
||||
});
|
||||
|
||||
document.addEventListener("dragenter", function (e) {
|
||||
if (!hasFile(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth++;
|
||||
document.body.classList.add("dropping");
|
||||
});
|
||||
document.addEventListener("dragover", function (e) {
|
||||
if (!hasFile(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
});
|
||||
document.addEventListener("dragleave", function (e) {
|
||||
if (!hasFile(e)) return;
|
||||
if (--dragDepth <= 0) { dragDepth = 0; document.body.classList.remove("dropping"); }
|
||||
});
|
||||
document.addEventListener("drop", function (e) {
|
||||
if (!hasFile(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth = 0;
|
||||
document.body.classList.remove("dropping");
|
||||
openFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
|
||||
// A deck in the address opens in place of the sample, and so does one pasted into it later.
|
||||
var linked = /(^#|&)(d|md)=/.test(location.hash);
|
||||
load(linked ? "" : SAMPLE);
|
||||
|
||||
@@ -87,12 +87,34 @@
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.editor-pane .label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.editor-pane label {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.editor-pane .open { padding: 4px 10px; font-size: 0.78rem; }
|
||||
#file { display: none; }
|
||||
body.dropping textarea { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(110,168,254,0.25); }
|
||||
body.dropping::after {
|
||||
content: "Drop your .md file to open it";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(15,17,21,0.75);
|
||||
color: var(--ink);
|
||||
font-size: 1.3rem;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
textarea {
|
||||
flex: 1;
|
||||
@@ -402,12 +424,16 @@
|
||||
</header>
|
||||
<main id="try">
|
||||
<section class="editor-pane">
|
||||
<label for="src">Slides</label>
|
||||
<div class="label-row">
|
||||
<label for="src">Slides</label>
|
||||
<button id="open" class="open" type="button">Open .md</button>
|
||||
<input id="file" type="file" accept=".md,.markdown,.txt,text/markdown,text/plain">
|
||||
</div>
|
||||
<textarea id="src" spellcheck="false"></textarea>
|
||||
<p class="hint">
|
||||
<code># Title</code> heading · <code>## Subtitle</code> · <code>- bullet</code> point ·
|
||||
<code>---</code> on its own line starts a new slide · inline <code>**bold**</code>, <code>*italic*</code>, <code>`code`</code>.
|
||||
Plain lines become paragraphs.
|
||||
Plain lines become paragraphs. Or drop a <code>.md</code> file anywhere on the page.
|
||||
</p>
|
||||
</section>
|
||||
<section class="stage">
|
||||
@@ -429,7 +455,7 @@
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<h3>Type your slides</h3>
|
||||
<p>Markdown-ish text in the editor: headings, bullets, paragraphs, <code>---</code> between slides. The syntax is the stuff you already type.</p>
|
||||
<p>Markdown-ish text in the editor: headings, bullets, paragraphs, <code>---</code> between slides. The syntax is the stuff you already type. Already have a <code>.md</code> file? Drop it on the page.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<h3>Watch it set live</h3>
|
||||
@@ -475,7 +501,7 @@
|
||||
<div class="price">$0</div>
|
||||
<p>For anyone who needs a quick deck right now.</p>
|
||||
<ul>
|
||||
<li>Full plain-text editor</li>
|
||||
<li>Full plain-text editor; drop in a <code>.md</code> file to start from one</li>
|
||||
<li>Live navigable preview with arrow keys and progress bar</li>
|
||||
<li>One-click copy of the deck as self-contained HTML</li>
|
||||
<li>Links with the deck inside them, nothing stored</li>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
@@ -0,0 +1,39 @@
|
||||
#!/bin/sh
|
||||
# tools/build.sh: shots/desktop.png, the first screen at 1440x900, rendered from this folder by headless Chrome.
|
||||
# Time's refresh workflow runs this (Actions -> refresh, slug roll-credits). Needs python3 (for a local server) and
|
||||
# Chrome or Chromium: set CHROME=/path/to/chrome if it isn't found, and CHROME_FLAGS=--no-sandbox when running as root.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
CHROME=${CHROME:-}
|
||||
for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" "$(command -v chromium || true)" \
|
||||
"$(command -v chromium-browser || true)" "$(command -v google-chrome || true)" "$(command -v google-chrome-stable || true)"; do
|
||||
if [ -z "$CHROME" ] && [ -n "$c" ] && [ -x "$c" ]; then CHROME=$c; fi
|
||||
done
|
||||
[ -n "$CHROME" ] || { echo "no Chrome or Chromium found; set CHROME" >&2; exit 1; }
|
||||
|
||||
PORT=${PORT:-8795}
|
||||
python3 -m http.server "$PORT" --bind 127.0.0.1 >/dev/null 2>&1 </dev/null &
|
||||
SRV=$!
|
||||
trap 'kill $SRV 2>/dev/null || true' EXIT INT TERM
|
||||
n=0
|
||||
until curl -fs -o /dev/null --max-time 2 "http://127.0.0.1:$PORT/"; do
|
||||
n=$((n + 1)); [ "$n" -lt 40 ] || { echo "the local server on port $PORT never answered" >&2; exit 1; }; sleep 0.5
|
||||
done
|
||||
|
||||
mkdir -p shots
|
||||
out=$PWD/shots/desktop.png
|
||||
prof=$(mktemp -d)
|
||||
rm -f "$out"
|
||||
# shellcheck disable=SC2086
|
||||
"$CHROME" --headless=new --hide-scrollbars --no-first-run --no-default-browser-check --disable-background-networking \
|
||||
--disable-component-update --user-data-dir="$prof" --virtual-time-budget=6000 --window-size=1440,900 \
|
||||
--screenshot="$out" ${CHROME_FLAGS:-} "http://127.0.0.1:$PORT/" >"$prof.log" 2>&1 &
|
||||
pid=$!
|
||||
n=0
|
||||
while kill -0 "$pid" 2>/dev/null && [ ! -s "$out" ]; do n=$((n + 1)); [ "$n" -lt 120 ] || break; sleep 0.5; done
|
||||
sleep 1
|
||||
kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true
|
||||
[ -s "$out" ] || { echo "Chrome made no screenshot:" >&2; tail -5 "$prof.log" >&2; exit 1; }
|
||||
rm -rf "$prof" "$prof.log"
|
||||
echo "built: shots/desktop.png"
|
||||
+16
-1
@@ -1,5 +1,19 @@
|
||||
{
|
||||
"ventures": [
|
||||
{
|
||||
"slug": "gandora",
|
||||
"name": "gandora",
|
||||
"one_liner": "Type a seed, get a station: every song is written and rendered for you as it plays, every cover is drawn from the song's own settings, and your reactions steer what comes next.",
|
||||
"url": "https://gandora.lol/",
|
||||
"pages": "https://gandora.yolopreneur.lol/",
|
||||
"launched": "2026-10-20",
|
||||
"status": "live",
|
||||
"signups": 0,
|
||||
"source": "yolopreneur",
|
||||
"repo": "https://teahub.xyz/gandora/site",
|
||||
"hosting": "pages",
|
||||
"shot": "https://gandora.yolopreneur.lol/shots/desktop.png"
|
||||
},
|
||||
{
|
||||
"slug": "slyng",
|
||||
"name": "SLYNG",
|
||||
@@ -487,6 +501,7 @@
|
||||
},
|
||||
{
|
||||
"slug": "roll-credits",
|
||||
"shot": "roll-credits/shots/desktop.png",
|
||||
"name": "Roll Credits",
|
||||
"one_liner": "Type the names of the people who made something, pick a credits style, and download a scrolling end-credits video, entirely in your browser.",
|
||||
"for_whom": "A team lead or friend-group organizer who wants to thank the people behind a launch, a trip, a bake-off, or a last day at a job, and wants it to feel like a film ending.",
|
||||
@@ -505,5 +520,5 @@
|
||||
"hosting": "pages"
|
||||
}
|
||||
],
|
||||
"refreshed": "2026-10-20"
|
||||
"refreshed": "2026-10-23"
|
||||
}
|
||||
Reference in new issue
Block a user