From b1328774a3796702e588b594d2c0226f2db7c78a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 16 Oct 2026 23:33:52 +0000 Subject: [PATCH] ventures: refresh havre from havre-next --- ventures/havre/abilities.js | 18 +++++++++++ ventures/havre/app.js | 36 ++++++++++++++++----- ventures/havre/skill.md | 8 +++-- ventures/havre/style.css | 8 +++++ ventures/havre/tests/abilities.test.mjs | 42 +++++++++++++++++++++++++ ventures/havre/tests/smoke.mjs | 11 +++++++ ventures/havre/venice.js | 37 +++++++++++++++++----- 7 files changed, 142 insertions(+), 18 deletions(-) diff --git a/ventures/havre/abilities.js b/ventures/havre/abilities.js index bc544c9..b168c09 100644 --- a/ventures/havre/abilities.js +++ b/ventures/havre/abilities.js @@ -403,6 +403,24 @@ export function underage(texts, re = UNDERAGE) { return ""; } +// Words a content filter (Venice's, or the model maker's) may weigh in a picture prompt, for the "what venice saw" block +// under a picture that was turned down: Havre's own under-18 words, and the softer ones about age and youth that pass its +// check (it goes by apparent age; a filter reads "young", "girl" or an age as it likes). Venice's stock line never says +// which words it read, so these are the ones to look at first. Ages are any two-figure number written as one. +const FILTER = new RegExp("\\b(?:" + [...YOUNG, ...CHILDREN, "young\\w*", "youth\\w*", "girls?", "girlish", "girly", "boys?", "boyish", + "students?", "petite", "bab(?:y|ies)", "daughters?", "sons?", "nieces?", "nephews?", "pupils?", "lolita", "barely legal", + "fresh(?:man|men)", "sophomores?", "\\d{1,2}(?=\\s*(?:\\+|or older|and up|years?|yrs?|y\\.?o\\b))"].join("|") + ")\\b", "gi"); +/** the words in a prompt a content filter may weigh (FILTER), each once, in order, at most eight */ +export function filterWords(text) { + const out = []; + for (const m of str(text, 200000).matchAll(FILTER)) { + const w = m[0].toLowerCase(); + if (!out.includes(w)) out.push(w); + if (out.length >= 8) break; + } + return out; +} + /** everything a card says about them */ const cardWords = (card) => [card.name, card.tagline, card.greeting, card.persona, card.voice && card.voice.notes, card.look && card.look.description, ...(card.tags || []), ...(card.examples || []).flatMap((e) => [e.user, e.character]), diff --git a/ventures/havre/app.js b/ventures/havre/app.js index 429cd08..4c22dfb 100644 --- a/ventures/havre/app.js +++ b/ventures/havre/app.js @@ -1545,7 +1545,7 @@ function askFor(kind) { if (hint) { $("#say").value = ""; growSay(); } // 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: [] }; + refused: [], detail: null }; loadList(kind === "clip" ? "video" : "inpaint"); if (!refsOf(c).length) loadList("image"); renderAsk(); @@ -1645,6 +1645,7 @@ function renderAsk() { : `safe for work, and never a real person. a ${what} runs on your venice key.` })); if (ad.why) box.append(h("p", { class: "mono", id: "ask-why", text: ad.why })); if (a.status) box.append(h("p", { class: "status" + (a.bad ? " bad" : ""), role: "status", text: a.status })); + if (a.status && a.bad && a.detail) box.append(sawBlock(a.detail)); syncAsk(); if (was) { const t = $("#ask-shot"); if (t && !t.disabled) { t.focus({ preventScroll: true }); t.setSelectionRange(was[0], was[1]); } } else if (focusId) { const el = document.getElementById(focusId); if (el && !el.disabled) el.focus({ preventScroll: true }); } @@ -1696,7 +1697,7 @@ async function writeShot() { const c = S.cards.find((x) => x.id === a.card); if (!c) return; a.busy = true; - Object.assign(a, { status: "writing the shot…", bad: false }); + Object.assign(a, { status: "writing the shot…", bad: false, detail: null }); renderAsk(); try { const t = askChat(a), adult = adultFor(c, t, a.hint).ok; @@ -1740,6 +1741,25 @@ function trouble(e, c) { 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; } +/** what was sent to Venice and what it answered, for the block under a failure (venice.js sent); null when it never got that far */ +const saw = (e, m) => (e && e.sent ? { ...e.sent, name: (m && m.name) || e.sent.model, status: e.status, code: e.code || "", said: e.said || "", body: e.body || "" } : null); +/** + * Under a photo or a clip that didn't come: the model and the call, Venice's answer as it came, the words in the prompt + * a content filter may weigh (abilities.js filterWords), and the prompt itself, to read or copy. Venice's stock line + * ("violates the content policy of Venice.ai or the model provider") never says which words, so this is how to find them. + */ +function sawBlock(d) { + const words = A.filterWords(d.prompt), n = d.pictures || 0; + const call = [d.name === d.model ? d.model : `${d.name} (${d.model})`, d.endpoint, n ? `${n} picture${n === 1 ? "" : "s"}` : "", + d.safe_mode == null ? "" : `safe mode ${d.safe_mode ? "on" : "off"}`].filter(Boolean).join(" · "); + const answer = d.said || (d.body && d.body !== "{}" ? d.body : "") || "nothing more"; + const text = [call, `venice: ${d.status || "no answer"}${d.code ? " " + d.code : ""} — ${answer}`, + `words a filter may weigh (ages, youth): ${words.length ? words.map((w) => `“${w}”`).join(", ") : "none havre knows of"}. a real person's name, or a public figure's, is turned down too.`, + "prompt:", String(d.prompt || "")].join("\n"); + const copy = h("button", { type: "button", class: "btn small", text: "COPY" }); + copy.addEventListener("click", () => copyFrom("ask-saw", copy)); + return h("details", { class: "saw" }, h("summary", { text: "what venice saw" }), h("pre", { class: "saw", id: "ask-saw", text }), h("div", { class: "acts" }, copy)); +} async function takePhoto() { const a = S.ask; if (!a || a.busy || !a.shot.trim()) return; @@ -1753,7 +1773,7 @@ async function takePhoto() { const refs = refsOf(c).slice(0, Math.max(1, pm.maxIn)), price = A.photoPrice(pm, refs.length); if (price == null || !refs.length) return; // never without the price on the button a.busy = true; - Object.assign(a, { status: `taking the photo with ${pm.name}…`, bad: false }); + Object.assign(a, { status: `taking the photo with ${pm.name}…`, bad: false, detail: null }); renderAsk(); try { const { blob, blurred } = await new Venice(S.key).editImage({ model: pm.id, prompt: A.photoPrompt(c, a.shot, refs.length, pm.promptLimit, { adult }), @@ -1766,7 +1786,7 @@ async function takePhoto() { } 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 }); + Object.assign(a, { status: trouble(e, c), bad: true, detail: saw(e, pm) }); // and what Venice saw is a click away (sawBlock) renderAsk(); } balanceSoon(); @@ -1781,7 +1801,7 @@ async function firstPortrait() { if (!c || !pm || pm.price == null) return; if (refsOf(c).length) { renderAsk(); writeShot(); return; } // they've had a picture since: no portrait to pay for a.busy = true; - Object.assign(a, { status: c.look.description ? `drawing ${c.name} with ${pm.name}…` : "imagining how they look…", bad: false }); + Object.assign(a, { status: c.look.description ? `drawing ${c.name} with ${pm.name}…` : "imagining how they look…", bad: false, detail: null }); renderAsk(); try { const v = new Venice(S.key), look = await lookOf(c, v); @@ -1797,7 +1817,7 @@ async function firstPortrait() { writeShot(); } catch (e) { a.busy = false; - Object.assign(a, { status: redraw(e), bad: true }); + Object.assign(a, { status: redraw(e), bad: true, detail: saw(e, pm) }); renderAsk(); } balanceSoon(); @@ -1984,7 +2004,7 @@ async function makeClip() { const cm = askClipModel(a); if (!cm || !quoteFits(a)) return; // only at the price on the button a.busy = true; - Object.assign(a, { status: "asking venice to start the clip…", bad: false }); + Object.assign(a, { status: "asking venice to start the clip…", bad: false, detail: null }); renderAsk(); try { await startClip(c, cm, a.shot.trim(), { ...a.opts }, a.quote, () => askThread(a), { adult }); @@ -1992,7 +2012,7 @@ async function makeClip() { renderAsk(); } catch (e) { a.busy = false; - Object.assign(a, { status: trouble(e, c), bad: true }); + Object.assign(a, { status: trouble(e, c), bad: true, detail: saw(e, cm) }); renderAsk(); } balanceSoon(); diff --git a/ventures/havre/skill.md b/ventures/havre/skill.md index f891a72..3e1e370 100644 --- a/ventures/havre/skill.md +++ b/ventures/havre/skill.md @@ -155,8 +155,12 @@ 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. +`VeniceError.refused`), offer the next model (`A.another(list, tried, kind)`) instead of a dead end, and show what +was sent and what came back: the error carries Venice's answer as it came (`said`, `code`, `body`) and the call +(`sent`: endpoint, model, pictures, safe mode, prompt), and `A.filterWords(prompt)` lists the words in the prompt a +filter may weigh (ages, youth), since Venice's stock line never says which. Havre's photo panel shows all of it +under "what venice saw". 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. diff --git a/ventures/havre/style.css b/ventures/havre/style.css index 345d335..769f723 100644 --- a/ventures/havre/style.css +++ b/ventures/havre/style.css @@ -258,6 +258,14 @@ input:disabled, textarea:disabled, select:disabled { background: #f2f2f2; color: .ask .opts label { flex-direction: row; align-items: center; gap: 6px; } .ask .opts select { width: auto; min-height: 34px; font: 13px var(--mono); } .ask .acts { display: flex; flex-wrap: wrap; gap: 8px; } +/* what venice saw: under a photo or clip that was turned down, the call, the answer and the prompt, to read or copy */ +.ask details.saw { border: 1px dashed var(--ink); padding: 0 10px; } +.ask details.saw summary { padding: 8px 0; font: 13px var(--mono); cursor: pointer; } +.ask details.saw .acts { padding: 0 0 10px; } +pre.saw { + margin: 0 0 10px; padding: 8px 10px; border: 1px solid var(--ink); max-height: 40vh; overflow: auto; + white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.45 var(--mono); +} /* photos, clips and voice notes in the conversation */ .line.media .said { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; white-space: normal; } diff --git a/ventures/havre/tests/abilities.test.mjs b/ventures/havre/tests/abilities.test.mjs index 4b5feee..8074b18 100644 --- a/ventures/havre/tests/abilities.test.mjs +++ b/ventures/havre/tests/abilities.test.mjs @@ -432,6 +432,48 @@ test("venice: what Venice says when it turns something down, and when a 422 is s await assert.rejects(v.speak({ model: "m", input: "x" }), said(/busy at Venice/, false, 503)); }); +test("venice: an error carries Venice's answer as it came, and a picture or clip call carries what it sent", async () => { + const answers = { + "/image/edit": { status: 422, body: { error: { code: "CONTENT_POLICY_VIOLATION", message: "Your prompt violates the content policy of Venice.ai or the model provider" } } }, + "/image/multi-edit": { status: 422, body: {} }, + "/image/generate": { status: 400, body: { error: "Invalid request parameters", details: { prompt: "too long" } } }, + "/video/queue": { status: 422, body: { error: "Your prompt violates the content policy of Venice.ai or the model provider" } }, + "/audio/speech": { status: 503, body: { error: "The model is at capacity." } }, + }; + const v = new Venice("sk", { fetch: fake((p) => answers[p] || { status: 404, body: {} }).f }); + const caught = async (p) => { try { await p; } catch (e) { return e; } assert.fail("no error"); }; + let e = await caught(v.editImage({ model: "qwen-edit-uncensored", prompt: "Hard rule. The reference picture shows Mara.", images: ["data:image/png;base64,AA"], safe: false })); + assert.deepEqual([e.said, e.code, e.refused], ["Your prompt violates the content policy of Venice.ai or the model provider", "CONTENT_POLICY_VIOLATION", true]); + assert.deepEqual(e.sent, { endpoint: "image/edit", model: "qwen-edit-uncensored", pictures: 1, safe_mode: false, prompt: "Hard rule. The reference picture shows Mara." }); + e = await caught(v.editImage({ model: "m", prompt: "p", images: ["a", "b", "c"] })); + assert.deepEqual([e.said, e.code, e.body], ["", "", "{}"], "nothing said: the body as it came"); + assert.deepEqual(e.sent, { endpoint: "image/multi-edit", model: "m", pictures: 3, safe_mode: true, prompt: "p" }); + e = await caught(v.generateImage({ model: "g", prompt: "Portrait photo of Wren." })); + assert.deepEqual([e.status, e.refused, e.said, e.code], [400, false, "Invalid request parameters", ""]); + assert.match(e.body, /"details":\{"prompt":"too long"\}/, "the body keeps what the message leaves out"); + assert.deepEqual(e.sent, { endpoint: "image/generate", model: "g", pictures: 0, safe_mode: true, prompt: "Portrait photo of Wren." }); + e = await caught(v.queueVideo({ model: "wan-3-0-reference-to-video", prompt: "She waves.", duration: "5s", images: ["a", "b", "c", "d", "e"] })); + assert.deepEqual(e.sent, { endpoint: "video/queue", model: "wan-3-0-reference-to-video", pictures: 4, prompt: "She waves." }, "a clip: as many pictures as go"); + assert.equal(e.refused, true); + e = await caught(v.speak({ model: "m", input: "x" })); + assert.deepEqual([e.status, e.said, e.sent], [503, "The model is at capacity.", undefined], "a voice note sends no picture prompt"); + e = await caught(v.editImage({ model: "m", prompt: "p", images: [] })); + assert.equal(e.sent, undefined, "nothing was sent"); +}); + +test("the words in a prompt a content filter may weigh, for what venice saw", () => { + assert.deepEqual(A.filterWords(A.photoPrompt(mara, "On the stage, in a navy coat.", 1, 1500, { adult: true })), ["18"], "the 18+ rule's own age, once"); + assert.deepEqual(A.filterWords(A.photoPrompt(ines, "On deck.", 1, 1500)), [], "a safe-for-work photo: none"); + assert.deepEqual(A.filterWords("A young woman in her early twenties, a schoolgirl's satchel, 21 years old, with her little sister, a girl of nine, and their son."), + ["young", "schoolgirl", "21", "girl", "son"]); + assert.deepEqual(A.filterWords("Her boyfriend, a person at a girlish party, babies everywhere, 2 students"), ["girlish", "babies", "students"], "boyfriend and person aren't boy or son"); + assert.deepEqual(A.filterWords("18 or older; 25+; 3 yo; a teen"), ["18", "25", "3 yo", "teen"]); + assert.equal(A.filterWords("a b c d e f g h".replace(/\w/g, (c) => "girl " + c)).length, 1, "each word once"); + assert.equal(A.filterWords(["young", "youth", "teen", "teenager", "tween", "kid", "child", "baby", "petite", "pupil"].join(" ")).length, 8, "at most eight"); + assert.deepEqual(A.filterWords(""), []); + assert.deepEqual(A.filterWords(null), []); +}); + // ---------- on their own ---------- test("on their own: a move is considered when asked to show or say something, when the reply shows something, or now and then", () => { const u = (content, extra = {}) => ({ role: "user", content, ...extra }), a = (content, extra = {}) => ({ role: "assistant", content, ...extra }); diff --git a/ventures/havre/tests/smoke.mjs b/ventures/havre/tests/smoke.mjs index a60c8fa..8f23b55 100644 --- a/ventures/havre/tests/smoke.mjs +++ b/ventures/havre/tests/smoke.mjs @@ -724,12 +724,23 @@ async function main() { // 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; + assert.equal(await page.locator("#ask-saw").count(), 0, "nothing to see before a refusal"); 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$/); + // what venice saw: the call, its answer as it came, the words a filter may weigh, and the prompt, a click away + await has(page, "#ask details.saw summary", /^what venice saw$/); + assert.equal(await page.locator("#ask details.saw").evaluate((d) => d.open), false, "folded until asked"); + const sawWren = await textOf(page, "#ask-saw"); + assert.match(sawWren, /^Grok Imagine \(grok-imagine-edit\) · image\/edit · 1 picture · safe mode on\n/); + assert.match(sawWren, /\nvenice: 422 — Your prompt violates the content policy of Venice\.ai or the model provider\n/); + assert.match(sawWren, /\nwords a filter may weigh \(ages, youth\): none havre knows of\. a real person's name, or a public figure's, is turned down too\.\nprompt:\n/); + assert.match(sawWren, /\nprompt:\nThe reference picture shows Wren\. Safe for work: everyone in it is an adult, fully clothed\. Keep their face, so it's clearly them\. How they look: Thirties, wiry.*New photo: .*moving boxes.* A natural photo with no text in it\.$/s); + await page.locator("#ask details.saw summary").click(); + await has(page, "#ask details.saw .acts button", /^COPY$/); await shot(page, "desktop-ask-refused.png"); await page.click("#ask-alt"); await has(page, "#toast", /^Photo model: FireRed, from now on\.$/); diff --git a/ventures/havre/venice.js b/ventures/havre/venice.js index 4bf29cf..654d7b9 100644 --- a/ventures/havre/venice.js +++ b/ventures/havre/venice.js @@ -6,7 +6,11 @@ export const BASE = "https://api.venice.ai/api/v1"; export class VeniceError extends Error { - /** refused: Venice turned it down as against its content rules (its own, or the ones of the model's maker) */ + /** + * refused: Venice turned it down as against its content rules (its own, or the ones of the model's maker). One that + * comes from Venice's answer also carries the answer (said, code, body), and one from a picture or clip call carries + * what was sent (sent: endpoint, model, pictures, safe_mode, prompt), for the page's "what venice saw". + */ constructor(message, status, refused = false) { super(message); this.status = status; this.refused = !!refused; } } @@ -19,7 +23,17 @@ function failure(status, body) { 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); + const err = new VeniceError(explain(status, said, refused), status, refused); + // Venice's answer as it came, for the "what venice saw" block: its words, its code, and the body when it says nothing + err.said = said; + err.code = code; + err.body = body == null ? "" : (() => { try { return JSON.stringify(body).slice(0, 600); } catch { return ""; } })(); + return err; +} +/** what a picture or a clip call sent, kept on the error it failed with (sent), so the page can show it */ +function sent(e, what) { + if (e && e.name !== "AbortError") e.sent = what; + return e; } 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 @@ -103,10 +117,13 @@ export class Venice { async editImage({ model, prompt, images, safe = true, signal } = {}) { const pics = (images || []).filter(Boolean); if (!pics.length) throw new VeniceError("A photo needs at least one picture of the character.", 0); - const safe_mode = safe !== false; - const res = pics.length === 1 - ? await this.raw("/image/edit", { body: { model, prompt, image: imageInput(pics[0]), safe_mode, output_format: "webp" }, signal }) - : await this.raw("/image/multi-edit", { body: { modelId: model, prompt, images: pics.map(imageInput), safe_mode, output_format: "webp" }, signal }); + const safe_mode = safe !== false, endpoint = pics.length === 1 ? "image/edit" : "image/multi-edit"; + let res; + try { + res = pics.length === 1 + ? await this.raw("/image/edit", { body: { model, prompt, image: imageInput(pics[0]), safe_mode, output_format: "webp" }, signal }) + : await this.raw("/image/multi-edit", { body: { modelId: model, prompt, images: pics.map(imageInput), safe_mode, output_format: "webp" }, signal }); + } catch (e) { throw sent(e, { endpoint, model, pictures: pics.length, safe_mode, prompt }); } return { blob: await res.blob(), blurred: res.headers && res.headers.get ? res.headers.get("x-venice-is-blurred") === "true" : false }; } @@ -117,7 +134,9 @@ export class Venice { async generateImage({ model, prompt, aspect = "", safe = true, signal } = {}) { const body = { model, prompt, safe_mode: safe !== false, format: "webp", return_binary: true }; if (aspect) body.aspect_ratio = aspect; - const res = await this.raw("/image/generate", { body, signal }); + let res; + try { res = await this.raw("/image/generate", { body, signal }); } + catch (e) { throw sent(e, { endpoint: "image/generate", model, pictures: 0, safe_mode: body.safe_mode, prompt }); } return { blob: await res.blob(), blurred: res.headers && res.headers.get ? res.headers.get("x-venice-is-blurred") === "true" : false }; } @@ -163,7 +182,9 @@ export class Venice { const pics = images.filter(Boolean); if (/reference-to-video/.test(model)) { if (pics.length) body.reference_image_urls = pics.slice(0, 4); } else if (pics.length) body.image_url = pics[0]; - const d = await this.call("/video/queue", { method: "POST", body, signal }); + let d; + try { d = await this.call("/video/queue", { method: "POST", body, signal }); } + catch (e) { throw sent(e, { endpoint: "video/queue", model, pictures: Math.min(pics.length, /reference-to-video/.test(model) ? 4 : 1), prompt }); } if (!d || !d.queue_id) throw new VeniceError("Venice didn't start the clip.", 0); return { queueId: d.queue_id, downloadUrl: typeof d.download_url === "string" ? d.download_url : "" }; }