From 32268f7a284a5bf67ff370d97812016544daad07 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 16 Oct 2026 20:04:24 +0000 Subject: [PATCH] ventures: refresh havre from havre-next --- ventures/havre/abilities.js | 35 +++++---- ventures/havre/app.js | 95 +++++++++++++++++++------ ventures/havre/skill.md | 6 ++ ventures/havre/tests/abilities.test.mjs | 70 +++++++++++++++--- ventures/havre/tests/smoke.mjs | 90 +++++++++++++++++++---- ventures/havre/venice.js | 24 +++++-- 6 files changed, 256 insertions(+), 64 deletions(-) diff --git a/ventures/havre/abilities.js b/ventures/havre/abilities.js index b6affe7..bc544c9 100644 --- a/ventures/havre/abilities.js +++ b/ventures/havre/abilities.js @@ -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); diff --git a/ventures/havre/app.js b/ventures/havre/app.js index 30c61e2..429cd08 100644 --- a/ventures/havre/app.js +++ b/ventures/havre/app.js @@ -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) { diff --git a/ventures/havre/skill.md b/ventures/havre/skill.md index b2d3418..f891a72 100644 --- a/ventures/havre/skill.md +++ b/ventures/havre/skill.md @@ -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, diff --git a/ventures/havre/tests/abilities.test.mjs b/ventures/havre/tests/abilities.test.mjs index 1aadc71..4b5feee 100644 --- a/ventures/havre/tests/abilities.test.mjs +++ b/ventures/havre/tests/abilities.test.mjs @@ -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 ---------- diff --git a/ventures/havre/tests/smoke.mjs b/ventures/havre/tests/smoke.mjs index da3be6d..a60c8fa 100644 --- a/ventures/havre/tests/smoke.mjs +++ b/ventures/havre/tests/smoke.mjs @@ -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 diff --git a/ventures/havre/venice.js b/ventures/havre/venice.js index 3e7eae0..4bf29cf 100644 --- a/ventures/havre/venice.js +++ b/ventures/havre/venice.js @@ -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; }