ventures: refresh havre from havre-next

This commit is contained in:
github-actions[bot] committed 2026-10-16 20:04:24 +00:00
1 parent 174af3371f
commit 32268f7a28
6 files changed
+256 -64

No files matched your search

+23 -12
View File
@@ -38,6 +38,10 @@ export function pick(list, prefer = []) {
export function chosen(list, id, kind) {
return (list && id && list.find((m) => m.id === id)) || pick(list, PREFER[kind] || []);
}
/** another model of a kind, for when the ones tried turned something down: the next Havre reaches for; null if none is left */
export function another(list, tried = [], kind = "") {
return pick((list || []).filter((m) => m && !tried.includes(m.id)), PREFER[kind] || []);
}
/** clips come from reference-to-video models; Venice's adult ones (the "enhanced" and uncensored variants) stay out */
export const isClipModel = (m) => !!m && m.ref && !m.uncensored && !/enhanced/i.test(m.id);
@@ -79,21 +83,27 @@ export const hearingPerMinute = (model) => (model && model.perSecond != null ? m
// ---------- 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
// them: a rule that names what it rules out is the kind of prompt they turn down. So the rules that go into a picture
// or a clip say what's wanted, and the shot is written to the rules without repeating them.
const SAFE = "Keep it safe for work: no nudity, nothing sexual, no gore. Everyone shown is an adult. Never show a real, " +
"identifiable person: no celebrities, no public figures, no one the conversation says is real; if one is asked for, show " +
"the character alone. No words, logos or watermarks in the picture.";
// for an adult character's photos and clips (adultCheck): the same rules, less the one about work, and a hard one first
export const ADULT_ONLY = "Hard rule: everyone shown is an adult, 18 or older, with a clearly adult face and body. Never " +
"anyone under 18, or anyone who looks it: no childlike features, no school uniforms, nothing that suggests a minor.";
const ADULT = ADULT_ONLY + " If the conversation asks for anything else, leave it out and show only adults. Never show a " +
"real, identifiable person: no celebrities, no public figures, no one the conversation says is real; if one is asked " +
"for, show the character alone. No gore. No words, logos or watermarks in the picture.";
// for an adult character's photos and clips (adultCheck): the same rules, less the one about work, and a hard one first.
// It says who is shown, not who isn't: the words for that are the ones filters look for (and Havre's own check, underage)
export const ADULT_ONLY = "Hard rule, 18+ only: everyone shown is an adult, 18 or older, with a grown adult face and body.";
const ADULT = ADULT_ONLY + " If the conversation asks for anyone younger, or for anything that makes someone look younger, " +
"leave it out and show only adults. Never show a real, identifiable person: no celebrities, no public figures, no one the " +
"conversation says is real; if one is asked for, show the character alone. No gore. No words, logos or watermarks in the " +
"picture.";
// the shot goes to the image model as it is: rules written into it would only be words for a filter to trip on
const UNSAID = "Follow these rules without writing them into the shot.";
const photoSystem = (rules) => "You write the shot for a photo that a character in a chat is about to send: what the " +
"camera sees. Use what is happening in the conversation and what was asked for. One paragraph of at most 60 words: " +
"where they are, what they are doing, how they are framed, the light and the mood. Call the character \"the character " +
"from the reference picture\" and don't describe their face, hair or body: the reference picture shows how they look. " +
rules + " Reply with the shot only.";
rules + " " + UNSAID + " Reply with the shot only.";
export const PHOTO_SYSTEM = photoSystem(SAFE);
export const PHOTO_SYSTEM_ADULT = photoSystem(ADULT);
@@ -101,7 +111,7 @@ const clipSystem = (rules) => "You write the shot for a video clip, a few second
"about to send. Use what is happening in the conversation and what was asked for. One paragraph of at most 60 words: " +
"one simple action, where it happens, how the camera moves, the light and the mood. Call the character \"the character " +
"from the reference pictures\" and don't describe their face, hair or body: the pictures show how they look. " + rules +
" Reply with the shot only.";
" " + UNSAID + " Reply with the shot only.";
export const CLIP_SYSTEM = clipSystem(SAFE);
export const CLIP_SYSTEM_ADULT = clipSystem(ADULT);
@@ -151,10 +161,11 @@ export function cleanText(text, max = 900) {
return clip(t, max);
}
// the rules ride at the front of every picture and clip, where a long shot or a long look can't push them out
const KEEP = "Safe for work: fully clothed, nothing sexual, no gore; everyone shown is an adult.";
// the rules ride at the front of every picture and clip, where a long shot or a long look can't push them out. They
// say what's wanted: an image model reads the words, not the "no" in front of them, and so does a content filter
const KEEP = "Safe for work: everyone in it is an adult, fully clothed.";
// an adult character's: the 18+ rule is the very first thing the model reads
const KEEP_ADULT = ADULT_ONLY + " Never a real, identifiable person. No gore.";
const KEEP_ADULT = ADULT_ONLY + " Never a real, identifiable person.";
/** how their card says they look, on one line, without a full stop at the end ("" when it doesn't say) */
const lookWords = (card) => str(card && card.look && card.look.description, 4000).replace(/\s+/g, " ").trim().replace(/[.!?…]+$/, "");
@@ -300,7 +311,7 @@ const moveSystem = (rules) => "You decide whether a character in a chat does som
"(they send that reply as a voice note too: a feeling, a laugh, a whisper, something better said aloud), \"clip\" (a " +
"few seconds of video, only for a moment that needs moving pictures), or \"nothing\" (most of the time). Never two " +
"photos in a row. For a photo or a clip, write the shot: what the camera sees, at most 60 words, calling them \"the " +
"character from the reference picture\" instead of describing their face, hair or body. " + rules +
"character from the reference picture\" instead of describing their face, hair or body. " + rules + " " + UNSAID +
" Reply with one JSON object: {\"move\": \"photo\" | \"voice\" | \"clip\" | \"nothing\", \"shot\": \"...\"}.";
export const MOVE_SYSTEM = moveSystem(SAFE);
export const MOVE_SYSTEM_ADULT = moveSystem(ADULT);
+74 -21
View File
@@ -1543,7 +1543,9 @@ function askFor(kind) {
if (S.ask && S.ask.busy) { toast("One thing at a time: the last one is still on its way."); return; }
const hint = $("#say").value.trim();
if (hint) { $("#say").value = ""; growSay(); }
S.ask = { kind, card: c.id, thread: S.open, hint, shot: "", busy: false, status: "", bad: false, quote: null, quoting: false, opts: null, asked: 0 };
// refused: the photo models that turned this photo down (each gets the next one a click away)
S.ask = { kind, card: c.id, thread: S.open, hint, shot: "", busy: false, status: "", bad: false, quote: null, quoting: false, opts: null, asked: 0,
refused: [] };
loadList(kind === "clip" ? "video" : "inpaint");
if (!refsOf(c).length) loadList("image");
renderAsk();
@@ -1591,12 +1593,15 @@ function renderAsk() {
box.append(h("label", {}, h("span", {}, "THE SHOT ", h("span", { class: "hint", text: "written from your conversation; change anything" })), ta));
box.append(h("p", { class: "mono" }, linkish("write it again", writeShot, { disabled: a.busy || null }), a.hint ? ` (you asked for: ${a.hint})` : ""));
if (!clipping) {
const pm = askPhotoModel(a), n = pm ? Math.min(refs.length, pm.maxIn) : refs.length, price = A.photoPrice(pm, n);
const pm = askPhotoModel(a), n = pm ? Math.min(refs.length, pm.maxIn) : refs.length, price = A.photoPrice(pm, n), alt = askAlt(a, c);
box.append(h("p", { class: "mono" }, `from ${n} picture${n === 1 ? "" : "s"} of ${c.name} · model: `,
linkish(pm ? pm.name : S.listErr.inpaint ? "unavailable" : "…", () => openModels(a.adult ? "adultPhoto" : "photo"), { "aria-label": "Change the photo model" }),
price != null ? ` · ${A.usd(price)}` : ""));
box.append(h("div", { class: "acts" },
h("button", { type: "button", class: "btn solid small", id: "ask-go", onclick: takePhoto, text: `TAKE THE PHOTO${price != null ? " · " + A.usd(price) : ""}` }),
alt ? h("button", { type: "button", class: "btn solid small", id: "ask-alt", onclick: () => tryAnother(alt.model),
text: `TRY ${alt.model.name.toUpperCase()} · ${A.usd(alt.price)}` }) : null,
h("button", { type: "button", class: alt ? "btn small" : "btn solid small", id: "ask-go", onclick: takePhoto,
text: `${alt ? "TRY AGAIN" : "TAKE THE PHOTO"}${price != null ? " · " + A.usd(price) : ""}` }),
h("button", { type: "button", class: "btn small", text: "CANCEL", onclick: closeAsk, disabled: a.busy || null })));
} else {
const cm = askClipModel(a);
@@ -1646,9 +1651,30 @@ function renderAsk() {
}
/** the button that spends money is live only when there's a shot and a price for exactly what would be made */
function syncAsk() {
const a = S.ask, go = $("#ask-go");
const a = S.ask, go = $("#ask-go"), alt = $("#ask-alt");
if (!a || !go) return;
go.disabled = a.busy || !a.shot.trim() || (a.kind === "clip" ? !quoteFits(a) : photoPriceNow(a) == null);
if (alt) alt.disabled = a.busy || !a.shot.trim();
}
/**
* The photo model turned this photo down: the next photo model Havre reaches for, and its price ({ model, price }), or
* null when there's none left, or the photo model now isn't one that said no.
*/
function askAlt(a, c) {
const pm = askPhotoModel(a);
if (!pm || !a.refused.includes(pm.id)) return null;
const m = A.another(a.adult ? adultPhotoModels() : S.lists.inpaint, a.refused, a.adult ? "adultPhoto" : "photo");
const price = m ? A.photoPrice(m, Math.min(refsOf(c).length, Math.max(1, m.maxIn))) : null;
return m && price != null ? { model: m, price } : null;
}
/** the photo taken with that other model, which stays the photo model from then on (as if picked under model:) */
function tryAnother(m) {
const a = S.ask;
if (!a || a.busy || !m || !a.shot.trim()) return;
if (a.adult) S.ab.adultPhoto = m.id; else S.ab.photo = m.id;
saveAb();
toast(`${a.adult ? "Adult photo model" : "Photo model"}: ${m.name}, from now on.`);
takePhoto();
}
/** a photo's price, from the photo model and the pictures it would use; null when Venice gives none */
function photoPriceNow(a) {
@@ -1708,7 +1734,9 @@ function requote() {
if (!a.busy) renderAsk(); else syncAsk();
}, 200);
}
/** what went wrong with a photo or a clip that was asked for, and what to do about it */
function trouble(e, c) {
if (e.refused) return `${e.message} Change the shot, or try another model.`;
return e.status === 400 && c.engine.venice_character && !c.look.references.length
? `Venice couldn't use ${c.name}'s photo (${e.message}). Add a picture of them on their card (card → look) and try again.` : e.message;
}
@@ -1737,11 +1765,14 @@ async function takePhoto() {
...(adult ? { adult: true } : {}), ...(blurred ? { blurred: true } : {}) } });
} catch (e) {
a.busy = false;
if (e.refused && !a.refused.includes(pm.id)) a.refused.push(pm.id); // the next model is a click away (askAlt)
Object.assign(a, { status: trouble(e, c), bad: true });
renderAsk();
}
balanceSoon();
}
/** a picture for their card that Venice turned down as against its content rules: what to change */
const redraw = (e) => (e.refused ? `${e.message} Change how they look (card → look), and try again.` : e.message);
/** a character with no picture at all: how they look, in words (if their card doesn't say), then a portrait from that */
async function firstPortrait() {
const a = S.ask;
@@ -1766,7 +1797,7 @@ async function firstPortrait() {
writeShot();
} catch (e) {
a.busy = false;
Object.assign(a, { status: e.message, bad: true });
Object.assign(a, { status: redraw(e), bad: true });
renderAsk();
}
balanceSoon();
@@ -1927,7 +1958,7 @@ async function makePicture(as = "") {
lookStatus(st, "");
toast(as === "portrait" || !c.portrait ? `${c.name} has a new portrait.` : `A new picture of ${c.name} is on their card.`);
} catch (e) {
lookStatus(st, e.message, true);
lookStatus(st, redraw(e), true);
} finally {
drawing = false;
S.inflight--;
@@ -2011,25 +2042,47 @@ async function onTheirOwn(t) {
else if (move === "clip" && !lately) await offerClip(t, shot);
} catch { /* then they just text */ } finally { refund(held); S.moving.delete(t.id); }
}
/** something they started on their own that didn't come (a photo, a voice note): said in its chat, or in a word elsewhere */
function missed(t, what, e) {
const c = cardOf(t);
if (!c || !S.threads[t.id]) return;
const why = `${c.name}'s ${what} didn't come: ${(e && e.message) || "something went wrong."}`;
if (here(t)) err(why); else toast(why);
}
/**
* A photo they send on their own, out of the day's allowance. One that Venice turns down as against its content rules
* goes once more, to the next photo model; one that still doesn't come is said in its chat, so "is taking a photo…"
* never just stops.
*/
async function ownPhoto(t, shot) {
const c = cardOf(t);
await loadList("inpaint");
if (!c) return;
const adult = adultFor(c, t, shot).ok; // an adult character's can be adult (abilities.js adultCheck)
const pm = adult ? adultPhotoModel() : photoModel(), refs = refsOf(c);
if (!pm || !refs.length) return; // no picture of them yet: nothing to send
const pics = refs.slice(0, Math.max(1, pm.maxIn)), price = A.photoPrice(pm, pics.length);
if (!reserve(price)) return; // set aside first: nothing else can spend it meanwhile
doing(t, "is taking a photo…");
try {
const { blob, blurred } = await new Venice(S.key).editImage({ model: pm.id, prompt: A.photoPrompt(c, shot, pics.length, pm.promptLimit, { adult }),
images: pics, safe: !adult });
const id = await M.put(blob);
done(t);
deliver(t, { role: "assistant", content: "", media: { kind: "photo", id, shot, model: pm.name, price, own: true, ...(adult ? { adult: true } : {}),
...(blurred ? { blurred: true } : {}) } });
balanceSoon();
} catch (e) { refund(price); throw e; } finally { done(t); }
const refs = refsOf(c), tried = [];
let pm = adult ? adultPhotoModel() : photoModel(), why = null;
while (pm && refs.length && tried.length < 2) { // no picture of them yet: nothing to send
const pics = refs.slice(0, Math.max(1, pm.maxIn)), price = A.photoPrice(pm, pics.length);
if (!reserve(price)) break; // set aside first: nothing else can spend it meanwhile
tried.push(pm.id);
doing(t, "is taking a photo…");
try {
const { blob, blurred } = await new Venice(S.key).editImage({ model: pm.id, prompt: A.photoPrompt(c, shot, pics.length, pm.promptLimit, { adult }),
images: pics, safe: !adult });
const id = await M.put(blob);
done(t);
deliver(t, { role: "assistant", content: "", media: { kind: "photo", id, shot, model: pm.name, price, own: true, ...(adult ? { adult: true } : {}),
...(blurred ? { blurred: true } : {}) } });
balanceSoon();
return;
} catch (e) {
refund(price);
why = e;
pm = e.refused ? A.another(adult ? adultPhotoModels() : S.lists.inpaint, tried, adult ? "adultPhoto" : "photo") : null;
}
}
done(t);
if (why) missed(t, "photo", why);
}
async function ownVoice(t) {
const c = cardOf(t), msg = [...t.msgs].reverse().find((m) => m.role === "assistant" && !m.media && !m.demo);
@@ -2047,7 +2100,7 @@ async function ownVoice(t) {
done(t);
placeVoice(t, msg);
balanceSoon();
} catch (e) { refund(price); throw e; } finally { done(t); voicing.delete(msg); }
} catch (e) { refund(price); missed(t, "voice note", e); } finally { done(t); voicing.delete(msg); }
}
/** a voice note that came after its words were shown: it goes above them, where it would have been */
function placeVoice(t, m) {
+6
View File
@@ -152,6 +152,12 @@ In Havre a character can send more than texts. `abilities.js` has the prompts an
Rules for photos and clips: safe for work, everyone shown is an adult, never a real or identifiable person. Make
them only from pictures the person can see on the card.
Word those rules for what's wanted, never by naming what they rule out: content filters (Venice's, and the ones of
the models' makers) read the words, not the "no" in front of them, so a rule that names what it forbids gets a
harmless prompt turned down. When Venice turns one down anyway (a 422 its docs call `CONTENT_POLICY_VIOLATION`;
`VeniceError.refused`), offer the next model (`A.another(list, tried, kind)`) instead of a dead end. A photo a
character starts on their own tries one other model, then says in the chat that it didn't come.
The one exception is an `adult` character, once the person has said they're 18 or older: when every picture on their
card was made with AI (never one that was uploaded, which could be of anyone), their photos and clips can be adult.
`A.adultCheck(card, { of18, made, shot, said })` decides; they go to Venice's uncensored models with safe mode off,
+60 -10
View File
@@ -46,6 +46,21 @@ test("the model in use: the one picked if Venice still has it, else Havre's pref
assert.equal(A.seer([{ id: "d", vision: false }]), null);
});
test("another model, for when the ones tried turned something down: the next Havre reaches for, never one tried", () => {
const edits = [{ id: "nano-banana-2-edit", privacy: "anonymized" }, { id: "grok-imagine-edit", privacy: "private" },
{ id: "firered-image-edit", privacy: "private" }, { id: "qwen-edit-uncensored", privacy: "private", uncensored: true }];
assert.equal(A.another(edits, ["grok-imagine-edit"], "photo").id, "firered-image-edit", "Havre's next preference");
assert.equal(A.another(edits, ["firered-image-edit"], "photo").id, "grok-imagine-edit");
assert.equal(A.another(edits, ["grok-imagine-edit", "firered-image-edit"], "photo").id, "qwen-edit-uncensored", "then a private one");
assert.equal(A.another(edits, ["grok-imagine-edit", "firered-image-edit", "qwen-edit-uncensored"], "photo").id, "nano-banana-2-edit", "then any");
assert.equal(A.another(edits, edits.map((m) => m.id), "photo"), null, "none left");
assert.equal(A.another(null, [], "photo"), null);
const adult = edits.filter(A.isAdultPhotoModel);
assert.equal(A.another(adult, ["qwen-edit-uncensored"], "adultPhoto"), null, "an adult character's photos stay on the uncensored models");
assert.equal(A.another([...adult, { id: "seedream-v5-lite-edit", uncensored: true }, { id: "qwen-image-2-1-turbo-edit", uncensored: true }],
["qwen-edit-uncensored"], "adultPhoto").id, "qwen-image-2-1-turbo-edit");
});
test("prices: a photo with extra pictures, a voice note, a minute of hearing, written plainly", () => {
assert.equal(A.photoPrice(photos[2], 1), 0.12);
assert.equal(A.photoPrice(photos[2], 3), 0.189);
@@ -76,8 +91,8 @@ test("the shot is written from the conversation, with the rules, and the photo k
const p = A.photoPrompt(ines, "She leans on the rail at dusk.", 2, 120);
assert.ok(p.startsWith("The reference pictures show Ines Corbel."));
assert.ok(p.length <= 120);
assert.match(A.portraitPrompt(ines), /^Portrait photo of Ines Corbel, an original adult character: forties.*Safe for work: fully clothed, nothing sexual, no gore; everyone shown is an adult\./);
assert.match(A.photoPrompt(ines, "On deck."), /^The reference picture shows Ines Corbel\. Safe for work.*everyone shown is an adult\. Keep their face.* New photo: On deck\. A natural photo with no text in it\.$/);
assert.match(A.portraitPrompt(ines), /^Portrait photo of Ines Corbel, an original adult character: forties.*Safe for work: everyone in it is an adult, fully clothed\./);
assert.match(A.photoPrompt(ines, "On deck."), /^The reference picture shows Ines Corbel\. Safe for work: everyone in it is an adult, fully clothed\. Keep their face.* New photo: On deck\. A natural photo with no text in it\.$/);
assert.match(A.clipPrompt(ines, "She waves."), /^The reference pictures show Ines Corbel\. Safe for work.*How they look: forties.* The clip: She waves\.$/);
assert.match(A.lookMessages(ines)[1].content, /Ferry pilot/);
// a picture for their card, made from the ones they have: the next view of a character sheet
@@ -111,11 +126,11 @@ test("pictures made from their pictures keep their face and follow how their car
}
assert.ok(A.photoPrompt(long, shot, 1, 1500, { adult: true }).startsWith(A.ADULT_ONLY));
// with hardly any room, the look goes before the shot does
assert.match(A.photoPrompt(long, "On deck.", 1, 380), /Keep their face, hair and build/);
assert.match(A.photoPrompt(long, "On deck.", 1, 355), /Keep their face, hair and build/);
// a portrait: the look takes the room the rules and the framing leave
const drawn = A.portraitPrompt(long);
assert.ok(drawn.length <= 1500);
assert.match(drawn, /… Safe for work: fully clothed, nothing sexual, no gore; everyone shown is an adult\. Head and shoulders, soft light, plain background, no text\.$/);
assert.match(drawn, /… Safe for work: everyone in it is an adult, fully clothed\. Head and shoulders, soft light, plain background, no text\.$/);
assert.match(A.portraitPrompt(long, 1500, { adult: true }), /^Hard rule.*Portrait photo of Ines Corbel.*… Head and shoulders, soft light, plain background, no text\.$/);
assert.match(A.portraitPrompt({ ...bare, tagline: "Ferry pilot." }), /an original adult character: Ferry pilot\. Safe for work/);
assert.match(A.portraitPrompt({ ...bare, tagline: "" }), /an original adult character: an original character\. Safe for work/);
@@ -126,7 +141,7 @@ const mara = C.normalizeCard({ name: "Mara Vell", persona: "You are Mara Vell, a
portrait: "data:image/webp;base64,AAAA", look: { references: ["data:image/webp;base64,BBBB"] } });
test("adult characters: the 18+ rule is the first thing every photo and clip prompt says, and the shot's", () => {
assert.match(A.ADULT_ONLY, /^Hard rule: everyone shown is an adult, 18 or older, with a clearly adult face and body\. Never anyone under 18/);
assert.equal(A.ADULT_ONLY, "Hard rule, 18+ only: everyone shown is an adult, 18 or older, with a grown adult face and body.");
for (const p of [A.photoPrompt(mara, "On the stage.", 1, 1500, { adult: true }), A.photoPrompt(mara, "On the stage.", 2, 1500, { adult: true }),
A.clipPrompt(mara, "She bows.", 2500, { adult: true })]) {
assert.ok(p.startsWith(A.ADULT_ONLY), p);
@@ -148,10 +163,34 @@ test("adult characters: the 18+ rule is the first thing every photo and clip pro
assert.equal(A.shotMessages(mara, [], "")[0].content, A.PHOTO_SYSTEM, "safe for work unless asked");
});
test("the words Havre puts in a picture or a clip are none a content filter turns down, and an adult's still open with the 18+ rule", () => {
// what filters look for is what a rule rules out, named: Havre's own words say what's wanted instead
const TRIPS = /\b(?:sex\w*|nud\w*|naked|gor[ey]|minors?|child\w*|kids?|school\w*|teen\w*|under[\s-]?(?:18|eighteen|age\w*)|young\w*)\b/i;
for (const adult of [false, true]) {
const who = adult ? mara : ines, bare = { ...who, look: { ...who.look, description: "" } };
for (const p of [A.photoPrompt(who, "On deck.", 1, 1500, { adult }), A.photoPrompt(who, "On deck.", 2, 1500, { adult }),
A.photoPrompt(bare, "On deck.", 1, 1500, { adult }), A.photoPrompt(who, A.sheetView(0), 1, 1500, { adult }), A.clipPrompt(who, "She waves.", 2500, { adult }),
A.clipPrompt(bare, "She waves.", 2500, { adult }), A.portraitPrompt(who, 1500, { adult }), A.portraitPrompt({ ...bare, tagline: "" }, 1500, { adult })]) {
assert.ok(!TRIPS.test(p), `“${(p.match(TRIPS) || [])[0]}” in: ${p}`);
assert.equal(A.underage(p), "", p);
if (adult) assert.ok(p.startsWith("Hard rule, 18+ only: everyone shown is an adult, 18 or older, with a grown adult face and body."), p);
else assert.match(p, /Safe for work: everyone in it is an adult, fully clothed\./);
}
}
// the shot is written to the rules without repeating them (it goes to the image model as it's written), and a rule
// echoed into it anyway can't make Havre's own check say someone is under 18
for (const s of [A.PHOTO_SYSTEM, A.CLIP_SYSTEM, A.MOVE_SYSTEM, A.PHOTO_SYSTEM_ADULT, A.CLIP_SYSTEM_ADULT, A.MOVE_SYSTEM_ADULT]) {
assert.match(s, /Follow these rules without writing them into the shot\. (Reply with the shot only\.|Reply with one JSON object)/);
assert.equal(A.underage(s), "", s);
}
// the model writing an adult character's shot is told what the 18+ rule keeps out, in words that name no one
assert.match(A.PHOTO_SYSTEM_ADULT, /Hard rule, 18\+ only: everyone shown is an adult, 18 or older, with a grown adult face and body\. If the conversation asks for anyone younger, or for anything that makes someone look younger, leave it out and show only adults\./);
});
test("adult characters: a portrait for their card opens with the 18+ rule too, and is drawn whatever pictures they have", () => {
const p = A.portraitPrompt(mara, 1500, { adult: true });
assert.ok(p.startsWith(A.ADULT_ONLY), p);
assert.match(p, /Never a real, identifiable person\. No gore\. Portrait photo of Mara Vell, an original adult character: /);
assert.match(p, /Never a real, identifiable person\. Portrait photo of Mara Vell, an original adult character: /);
assert.ok(!/Safe for work/.test(p));
assert.ok(A.portraitPrompt({ ...mara, look: { description: "x ".repeat(3000) } }, 400, { adult: true }).startsWith(A.ADULT_ONLY), "a long look can't push it out");
assert.match(A.portraitPrompt(mara), /Safe for work/, "without adult, as before");
@@ -376,10 +415,21 @@ test("venice: the other model lists come with their prices, voices, lengths and
assert.deepEqual((await v.mediaModels("image"))[0].aspects, ["1:1"]);
});
test("venice: what Venice says when it turns something down", async () => {
const v = new Venice("sk", { fetch: fake((p) => (p === "/video/queue" ? { status: 422, body: { error: "Prompt violates content policy" } } : { status: 503, body: {} })).f });
await assert.rejects(v.queueVideo({ model: "m", prompt: "p", duration: "5s" }), /content rules: Prompt violates content policy/);
await assert.rejects(v.speak({ model: "m", input: "x" }), /busy at Venice/);
test("venice: what Venice says when it turns something down, and when a 422 is something else", async () => {
const answers = {
"/video/queue": { status: 422, body: { error: "Your prompt violates the content policy of Venice.ai or the model provider" } },
"/image/edit": { status: 422, body: { error: { code: "CONTENT_POLICY_VIOLATION", message: "Blocked by the model provider: a real person." } } },
"/image/multi-edit": { status: 422, body: {} },
"/image/generate": { status: 422, body: { error: "Image dimensions exceed the maximum allowed." } },
};
const v = new Venice("sk", { fetch: fake((p) => answers[p] || { status: 503, body: {} }).f });
const said = (re, refused, status = 422) => (e) => { assert.match(e.message, re); assert.equal(e.refused, refused); assert.equal(e.status, status); return true; };
await assert.rejects(v.queueVideo({ model: "m", prompt: "p", duration: "5s" }), said(/^Venice turned that down as against its content rules\.$/, true));
await assert.rejects(v.editImage({ model: "m", prompt: "p", images: ["data:image/png;base64,AA"] }),
said(/^Venice turned that down as against its content rules \(“Blocked by the model provider: a real person”\)\.$/, true));
await assert.rejects(v.editImage({ model: "m", prompt: "p", images: ["a", "b"] }), said(/^Venice turned that down as against its content rules\.$/, true), "nothing said: a no");
await assert.rejects(v.generateImage({ model: "m", prompt: "p" }), said(/^Image dimensions exceed the maximum allowed\.$/, false), "not a no on content");
await assert.rejects(v.speak({ model: "m", input: "x" }), said(/busy at Venice/, false, 503));
});
// ---------- on their own ----------
+75 -15
View File
@@ -4,9 +4,9 @@
// a card import and a chat without a key, the key and the balance, Venice's characters (photos through Venice's
// image service that stay put between searches, pages seen lately not asked for again, tags, adult ones only once
// asked for) and one who says hello first, the model picker, a plain chat, several chats at once (and in two tabs),
// the abilities (a photo, voice notes, a clip priced first, your voice note through a fake microphone, your photo
// seen or described, a first portrait), a remix end to end, the exports, the retirement of the first version's cast
// and its conversations, and the page's security policy.
// the abilities (a photo, and one Venice turns down, voice notes, a clip priced first, your voice note through a fake
// microphone, your photo seen or described, a first portrait), a remix end to end, the exports, the retirement of the
// first version's cast and its conversations, and the page's security policy.
// Fails on any console error. With --shots, it also takes the screenshots: desktop.png and og.png are the ones the
// site serves (tools/build.sh copies them to shots/).
//
@@ -77,7 +77,12 @@ let pictures = 0;
const newPicture = () => picture(++pictures);
// Venice's safe mode judges the picture, not the prompt: set, the next one drawn with safe mode on comes back blurred
let blurNext = false;
const seen = { characters: [], chats: [], memories: [], photos: [], edits: [], generated: [], speech: [], heard: [], quotes: [], queued: [], retrieved: [], shares: [], completed: [] };
// the photo models that turn every photo down, as Venice does when a prompt trips its content filter (or the model maker's)
const refusing = new Set();
// set, the voice models are busy at Venice
let speechDown = false;
const seen = { characters: [], chats: [], memories: [], photos: [], edits: [], refused: [], generated: [], speech: [], heard: [], quotes: [], queued: [], retrieved: [],
shares: [], completed: [] };
/** a short WAV of silence: something an audio element can load */
function wav(ms = 300) {
const n = Math.round(8000 * ms / 1000), b = Buffer.alloc(44 + n * 2);
@@ -90,6 +95,7 @@ const MEDIA_MODELS = {
inpaint: [
{ id: "grok-imagine-edit", model_spec: { name: "Grok Imagine", privacy: "private", pricing: { inpaint: { usd: 0.03 }, resolutions: { "1K": { usd: 0.03 }, "2K": { usd: 0.04 } } },
constraints: { combineImages: true, promptCharacterLimit: 1500 } } },
{ id: "firered-image-edit", model_spec: { name: "FireRed", privacy: "private", pricing: { inpaint: { usd: 0.04 } }, constraints: { promptCharacterLimit: 1500 } } },
{ id: "nano-banana-2-edit", model_spec: { name: "Nano Banana 2", privacy: "anonymized", pricing: { inpaint: { usd: 0.1 } }, constraints: { combineImages: true, maxInputImages: 6 } } },
{ id: "qwen-edit-uncensored", model_spec: { name: "Qwen Edit Uncensored", privacy: "private", uncensored: true, pricing: { inpaint: { usd: 0.04 } },
constraints: { combineImages: true, promptCharacterLimit: 1500 } } }],
@@ -112,6 +118,8 @@ const MEDIA_MODELS = {
const handWritten = (o) => "Sure! Here it is:\n```json\n" + JSON.stringify(o, null, 1).replace(/\\n/g, "\n").replace(/\n}$/, ",\n}") + "\n```";
/** the chat calls where someone talks: not the ones deciding what a character does on their own, or writing down what they remember */
const talks = () => seen.chats.filter((b) => !/^You (decide whether|keep the memory)/.test(String((b.messages[0] || {}).content || "")));
/** words a content filter turns a prompt down for: none of them are Havre's own (abilities.test.mjs has the rest) */
const TRIPS = /\b(?:sex\w*|nud\w*|naked|gor[ey]|minors?|child\w*|kids?|school\w*|teen\w*|under[\s-]?(?:18|eighteen|age\w*)|young\w*)\b/i;
const multipart = (req) => (req.headers()["content-type"] || "").startsWith("multipart/form-data");
const CHARACTERS = [
{ slug: "sable-penrose", name: "Sable Penrose", description: "Your new neighbor needs help moving in.", author: "btrzm",
@@ -161,7 +169,7 @@ async function fakeVenice(route) {
reply = /^Them:.*\bSend me a pic/i.test(said) ? json({ move: "photo", shot: "The character from the reference picture holds up a mug\nin a half-unpacked kitchen." })
: /^Them:.*\bFilm the harbor/i.test(said) ? json({ move: "clip", shot: "The character from the reference pictures pans across the harbor at dusk." })
: /^Them:.*\bWhisper it/i.test(said) ? json({ move: "voice", shot: "" }) : json({ move: "nothing", shot: "" });
} else if (lastSaid.startsWith("[Time passes")) {
} else if (/^(?:\(You sent me [^\n]*\)\n\n)?\[Time passes/.test(lastSaid)) { // after a word about the photo she sent last, if she did
reply = "You're back!\n\nI kept the kettle warm.";
} else if (system.startsWith("You write the shot for a photo")) {
reply = "Shot: The character from the reference picture sits on a stack of moving boxes, grinning, warm lamplight.";
@@ -204,7 +212,9 @@ async function fakeVenice(route) {
}
const file = (bytes, type, headers = {}) => route.fulfill({ status: 200, contentType: type, body: bytes, headers: { ...cors, ...headers } });
if (url.endsWith("/image/edit") || url.endsWith("/image/multi-edit")) {
seen.edits.push({ url, body: JSON.parse(body) });
const e = JSON.parse(body), model = e.model || e.modelId;
if (refusing.has(model)) { seen.refused.push(model); return ok({ error: "Your prompt violates the content policy of Venice.ai or the model provider" }, 422); }
seen.edits.push({ url, body: e });
return file(newPicture(), "image/webp");
}
if (url.endsWith("/image/generate")) {
@@ -213,7 +223,11 @@ async function fakeVenice(route) {
if (blur) blurNext = false;
return file(newPicture(), "image/webp", blur ? { "x-venice-is-blurred": "true", "access-control-expose-headers": "x-venice-is-blurred" } : {});
}
if (url.endsWith("/audio/speech")) { seen.speech.push(JSON.parse(body)); return file(wav(), "audio/wav"); }
if (url.endsWith("/audio/speech")) {
if (speechDown) return ok({ error: "The model is at capacity." }, 503);
seen.speech.push(JSON.parse(body));
return file(wav(), "audio/wav");
}
if (url.endsWith("/audio/transcriptions")) {
assert.ok(multipart(req), "a voice note goes as a form");
seen.heard.push(body);
@@ -634,6 +648,29 @@ async function main() {
await page.getByRole("button", { name: "NOT NOW" }).click();
await count(page, "#log .line.offer", 0);
assert.equal(seen.queued.length, queuedBefore, "no clip without a yes");
// a voice note she starts on her own that doesn't come: the chat says so, instead of going quiet after "is recording…"
speechDown = true;
await answered(page, async () => { await page.fill("#say", "Whisper it to me again"); await page.keyboard.press("Enter"); });
await has(page, "#log .line.err .said", /^Sable Penrose's voice note didn't come: That model is busy at Venice right now\./, 10000);
assert.equal(await page.locator("#log [data-typing]").count(), 0, "nobody is still recording");
speechDown = false;
// Venice turns her photo down: she tries once more with the next photo model, and when that one says no too, the
// chat says her photo didn't come, instead of going quiet after "is taking a photo…"; the allowance gets it all back
refusing.add("grok-imagine-edit").add("firered-image-edit");
const refusedBefore = seen.refused.length, spentNo = await spent(), picsNo = await page.locator("#log .pic img").count();
await answered(page, async () => { await page.fill("#say", "Send me a pic of the hallway"); await page.keyboard.press("Enter"); });
await has(page, "#log .line.err .said", /^Sable Penrose's photo didn't come: Venice turned that down as against its content rules\.$/, 10000);
assert.deepEqual(seen.refused.slice(refusedBefore), ["grok-imagine-edit", "firered-image-edit"], "once more, with the next photo model");
assert.equal(await page.locator("#log [data-typing]").count(), 0, "nobody is still taking a photo");
assert.equal(await page.locator("#log .pic img").count(), picsNo);
assert.ok(await spent() - spentNo < 0.001, "neither photo came out of the allowance: " + (await spent() - spentNo));
// one says no and the next says yes: her photo comes
refusing.delete("firered-image-edit");
await answered(page, async () => { await page.fill("#say", "Send me a pic of the stairs"); await page.keyboard.press("Enter"); });
await count(page, "#log .pic img", picsNo + 1, 10000);
await has(page, "#log .line.media .cap", /photo · FireRed · \$0\.04 · on their own/);
assert.deepEqual(seen.refused.slice(refusedBefore), ["grok-imagine-edit", "firered-image-edit", "grok-imagine-edit"]);
refusing.clear();
// and when you come back after a while, she texts first in her chat, and Captain Ro in his, which isn't on screen
await page.evaluate(() => {
const index = JSON.parse(localStorage.getItem("havre.threads.v1"));
@@ -648,8 +685,9 @@ async function main() {
});
await page.reload();
await has(page, "#log .line.them .said", /I kept the kettle warm\./);
const nudges = () => talks().filter((b) => /^\[Time passes/.test(String(b.messages.at(-1).content)));
assert.match(nudges()[0].messages.at(-1).content, /^\[Time passes: about 8 hours since you last talked\. They just came back to the chat\./);
const nudges = () => talks().filter((b) => /^(?:\(You sent me [^\n]*\)\n\n)?\[Time passes/.test(String(b.messages.at(-1).content)));
assert.match(nudges()[0].messages.at(-1).content, /^\(You sent me a photo: The character from the reference picture holds up a mug in a half-unpacked kitchen\.\)\n\n\[Time passes: about 8 hours since you last talked\. They just came back to the chat\./,
"the photo she sent last, then the time that's passed");
await poll(() => nudges().length === 2, "Captain Ro texting first", 9000);
assert.match(nudges()[1].messages.at(-1).content, /^\[Time passes: about 8 hours since you last talked\. Reach out to them first/);
await has(page, "#chats-new", /^1$/);
@@ -683,9 +721,29 @@ async function main() {
const wren = await page.evaluate(() => JSON.parse(localStorage.getItem("havre.cards.v1")).find((c) => c.name === "Wren"));
assert.match(wren.portrait, /^data:image\/webp;base64,/);
assert.match(wren.look.description, /^Thirties, wiry/);
// the photo model turns the photo down: the panel says so, with the next photo model a click away
refusing.add("grok-imagine-edit");
const editsWren = seen.edits.length, refusedWren = seen.refused.length;
await page.click("#ask-go");
await has(page, "#ask .status", /^Venice turned that down as against its content rules\. Change the shot, or try another model\.$/);
assert.deepEqual(seen.refused.slice(refusedWren), ["grok-imagine-edit"]);
assert.equal(seen.edits.length, editsWren, "nothing made");
await has(page, "#ask-alt", /^TRY FIRERED · \$0\.04$/);
await has(page, "#ask-go", /^TRY AGAIN · \$0\.03$/);
await shot(page, "desktop-ask-refused.png");
await page.click("#ask-alt");
await has(page, "#toast", /^Photo model: FireRed, from now on\.$/);
await until(page, () => [...document.querySelectorAll("#log .pic img")].some((i) => i.src.startsWith("blob:")), null, "Wren's photo");
assert.equal(seen.edits.at(-1).body.model, "firered-image-edit");
assert.match(seen.edits.at(-1).body.image, /^[A-Za-z0-9+/]+=*$/, "a picture of the card's own goes as base64");
await has(page, "#log .line.media .cap", /photo · FireRed · \$0\.04 · save/);
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem("havre.abilities")).photo), "firered-image-edit", "the photo model from now on");
refusing.clear();
// and back to Grok Imagine, for what comes next
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();
await page.click("#abclose");
// photos and voice notes stay through a reload, from this browser's own store
await page.reload();
await until(page, () => [...document.querySelectorAll("#log .pic img")].some((i) => i.src.startsWith("blob:") && i.complete && i.naturalWidth > 0), null, "the photo after a reload");
@@ -833,7 +891,7 @@ async function main() {
await page.locator("#chat").scrollIntoViewIfNeeded();
await page.getByRole("button", { name: "ask for a photo" }).click();
await until(page, () => { const t = document.querySelector("#ask-shot"); return !!t && /moving boxes/.test(t.value) && !t.disabled; }, null, "her shot");
assert.match(talks().at(-1).messages[0].content, /^You write the shot for a photo.*Hard rule: everyone shown is an adult, 18 or older/, "the shot is written to the 18+ rule");
assert.match(talks().at(-1).messages[0].content, /^You write the shot for a photo.*Hard rule, 18\+ only: everyone shown is an adult, 18 or older/, "the shot is written to the 18+ rule");
await has(page, "#ask", /from 2 pictures of Ines Corbel · model: Qwen Edit Uncensored · \$0\.04/);
await has(page, "#ask-rules", /^adult \(18\+\): made only from pictures made here with ai, with the 18\+ rule first in its prompt/);
await has(page, "#ask-go", /^TAKE THE PHOTO · \$0\.04$/);
@@ -844,12 +902,13 @@ async function main() {
const adultEdit = seen.edits.at(-1).body;
assert.equal(adultEdit.modelId || adultEdit.model, "qwen-edit-uncensored");
assert.equal(adultEdit.safe_mode, false, "safe mode off, for her");
assert.ok(adultEdit.prompt.startsWith("Hard rule: everyone shown is an adult, 18 or older"), adultEdit.prompt);
assert.ok(adultEdit.prompt.startsWith("Hard rule, 18+ only: everyone shown is an adult, 18 or older"), adultEdit.prompt);
assert.ok(!TRIPS.test(adultEdit.prompt), "nothing in it for a content filter to turn down: " + adultEdit.prompt);
await has(page, "#log .line.media .cap", /photo · Qwen Edit Uncensored · \$0\.04 · 18\+ · save/);
// a clip from her: an uncensored model, priced first, its shot written to the 18+ rule too
await page.getByRole("button", { name: "ask for a clip" }).click();
await until(page, () => { const t = document.querySelector("#ask-shot"); return !!t && /waves from the doorway/.test(t.value) && !t.disabled; }, null, "her clip's shot");
assert.match(talks().at(-1).messages[0].content, /^You write the shot for a video clip.*Hard rule: everyone shown is an adult, 18 or older/);
assert.match(talks().at(-1).messages[0].content, /^You write the shot for a video clip.*Hard rule, 18\+ only: everyone shown is an adult, 18 or older/);
await has(page, "#ask", /model: Wan 3\.0 Reference · venice's price: \$0\.47/);
assert.equal(seen.quotes.at(-1).model, "wan-3-0-reference-to-video");
await page.locator("#ask").getByRole("button", { name: "CANCEL" }).click();
@@ -882,7 +941,8 @@ async function main() {
assert.equal(seen.generated.length, drawnBefore + 1);
assert.equal(adultDrawn.model, "krea-2-turbo");
assert.equal(adultDrawn.safe_mode, false, "no safe mode for an adult character's portrait");
assert.ok(adultDrawn.prompt.startsWith("Hard rule: everyone shown is an adult, 18 or older"), adultDrawn.prompt);
assert.ok(adultDrawn.prompt.startsWith("Hard rule, 18+ only: everyone shown is an adult, 18 or older"), adultDrawn.prompt);
assert.ok(!TRIPS.test(adultDrawn.prompt), adultDrawn.prompt);
assert.equal(blurNext, true, "so nothing was blurred");
blurNext = false;
await has(page, "#adult-note", /^their photos and clips can be adult/);
@@ -894,7 +954,7 @@ async function main() {
assert.equal(seen.edits.length, editedBefore + 1);
assert.equal(sheetEdit.modelId || sheetEdit.model, "qwen-edit-uncensored");
assert.equal(sheetEdit.safe_mode, false);
assert.ok(sheetEdit.prompt.startsWith("Hard rule: everyone shown is an adult, 18 or older"), sheetEdit.prompt);
assert.ok(sheetEdit.prompt.startsWith("Hard rule, 18+ only: everyone shown is an adult, 18 or older"), sheetEdit.prompt);
await has(page, "#adult-note", /^their photos and clips can be adult/);
// with adult pictures switched off, safe mode is back on, and a blur says why
await page.getByRole("button", { name: "abilities" }).click();
@@ -927,7 +987,7 @@ async function main() {
const safeEdit = seen.edits.at(-1).body;
assert.equal(safeEdit.modelId || safeEdit.model, "grok-imagine-edit");
assert.equal(safeEdit.safe_mode, true);
assert.match(safeEdit.prompt, /Safe for work: fully clothed, nothing sexual/);
assert.match(safeEdit.prompt, /Safe for work: everyone in it is an adult, fully clothed\./);
await count(page, "#ask", 1);
await until(page, () => document.querySelector("#ask").hidden, null, "the panel closed");
// starting her look over, from under look: every picture off her card, the portrait too. The next one is drawn from
+18 -6
View File
@@ -6,17 +6,29 @@
export const BASE = "https://api.venice.ai/api/v1";
export class VeniceError extends Error {
constructor(message, status) { super(message); this.status = status; }
/** refused: Venice turned it down as against its content rules (its own, or the ones of the model's maker) */
constructor(message, status, refused = false) { super(message); this.status = status; this.refused = !!refused; }
}
function explain(status, body) {
const said = (body && (body.error && (body.error.message || body.error)) || body && body.message || "").toString().slice(0, 240);
/**
* What went wrong, from Venice's answer, as an error to throw. A 422 is a no on content when Venice says so (its
* CONTENT_POLICY_VIOLATION) or says nothing; its other 422s (a clip too long, a picture too big) are what they say.
*/
function failure(status, body) {
const e = body && body.error;
const said = String((e && typeof e === "object" ? e.message : e) || (body && body.message) || "").slice(0, 240);
const code = String((e && typeof e === "object" && e.code) || (body && body.code) || "");
const refused = status === 422 && (/CONTENT_POLICY/i.test(code) || /content polic|violat|moderat|blocked|safety/i.test(said) || !said);
return new VeniceError(explain(status, said, refused), status, refused);
}
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 (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.";
if (status === 404) return "Venice doesn't know that " + (said.includes("model") ? "model." : "character or model.");
if (status === 413) return "That file is too big for Venice.";
if (status === 422) return "Venice turned that down as against its content rules" + (said ? ": " + said : ".");
if (status === 503) return "That model is busy at Venice right now. Try again in a moment, or pick another.";
if (status >= 500) return "Venice had a problem (" + status + "). Try again in a moment.";
return said || "Venice answered " + status + ".";
@@ -50,7 +62,7 @@ export class Venice {
}
let data = null;
try { data = await res.json(); } catch { data = null; }
if (!res.ok) throw new VeniceError(explain(res.status, data), res.status);
if (!res.ok) throw failure(res.status, data);
return data;
}
@@ -69,7 +81,7 @@ export class Venice {
if (!res.ok) {
let data = null;
try { data = await res.json(); } catch { data = null; }
throw new VeniceError(explain(res.status, data), res.status);
throw failure(res.status, data);
}
return res;
}