Restore 18+ catalog search and independent character preferences (#273)
Restore 18+ catalog search and independent character preferences
This commit was merged in pull request #273.
This commit is contained in:
1 parent
b460c3ac07
commit
bf5646aece
8 files changed
+244
-68
No files matched your search
@@ -44,13 +44,18 @@ and not affiliated with it.
|
||||
Payments, channels (a character in a creator's Whop) and payouts to creators are Whop's. It is the factory's
|
||||
second Whop app; .mdeck goes first (#60, #61) so the factory learns Whop once.
|
||||
4. **BYOK, with moderation handled by Venice.** The human owner's direction on 2026-10-24 supersedes the earlier
|
||||
Studio-only adult policy and the restrictions built around it. Havre adds no content filters, age confirmation,
|
||||
adult-catalog hiding, text denylist, uploaded-picture gate or mandatory SFW/adults-only prompt. Image requests
|
||||
Studio-only adult policy and the restrictions built around it. Havre adds no content moderation, age confirmation,
|
||||
text denylist, uploaded-picture gate or mandatory SFW/adults-only prompt. Image requests
|
||||
set optional safe mode off. The adult flag remains portable card metadata and may select a saved model preference;
|
||||
it grants or denies no capability. Venice and the selected model handle their own moderation and return their own
|
||||
responses and errors. Remix similarity is advisory and never disables saving. Hosted page generation validates
|
||||
structure and escapes text; it does not reject a card for its adult label. Keep API-key isolation, file/URL
|
||||
validation, spending controls, payment confirmation and provenance intact.
|
||||
The user-requested **18+ catalog** checkbox selects a Venice search category: unchecked sends `isAdult=false`
|
||||
for the standard catalog; checked sends `isAdult=true` for the adult catalog. Both use `/characters`; the
|
||||
categories are exclusive, with no All option or age-confirmation gate. This is a search control, not Havre
|
||||
content moderation. Every saved character, including one from the standard Venice catalog, has an independent
|
||||
editable 18+ setting that chooses model preferences without granting or denying generation capabilities.
|
||||
5. **Venice is the engine.** No multi-provider build. The name, the story, the characters and the abilities are
|
||||
Venice's; a second provider would make Havre a generic card client, of which there are plenty.
|
||||
6. **Scenes wait.** Two characters taking turns is a feature for people who already come back. It stays on the
|
||||
|
||||
+13
-6
@@ -47,7 +47,7 @@ function box(kind) {
|
||||
}
|
||||
const local = box("localStorage"), session = box("sessionStorage");
|
||||
const K = { cards: "havre.cards.v1", sel: "havre.selected", key: "havre.key", model: "havre.model",
|
||||
sort: "havre.vsort", seeded: "havre.seeded.v1", plainModel: "havre.plain.model",
|
||||
catalogAdult: "havre.adult", sort: "havre.vsort", seeded: "havre.seeded.v1", plainModel: "havre.plain.model",
|
||||
own: "havre.venice.models", ab: "havre.abilities", voiceOn: "havre.voicenotes", jobs: "havre.clips.pending",
|
||||
spent: "havre.spent", jobsDone: "havre.clips.done", msort: "havre.msort",
|
||||
// what each character remembers of your chats (memory.js), by card id: never in the card itself
|
||||
@@ -78,7 +78,7 @@ const S = {
|
||||
// on a narrow screen the studio shows one thing at a time: "list" (your chats, the characters) or "chat"
|
||||
pane: "list",
|
||||
demo: {}, rx: null, plain: null, own: {},
|
||||
v: { list: [], offset: 0, more: false, query: "", sort: "imports", tag: "", loading: false, asked: 0 },
|
||||
v: { list: [], offset: 0, more: false, query: "", sort: "imports", tag: "", adult: false, loading: false, asked: 0 },
|
||||
// abilities: Venice's other models by type, what was picked, who sends voice notes, clips on their way, the photo
|
||||
// or clip being asked for, and a voice note being recorded
|
||||
lists: { inpaint: null, image: null, tts: null, asr: null, video: null }, listErr: {},
|
||||
@@ -153,6 +153,7 @@ function boot() {
|
||||
S.left = ["chats", "chars", "harbor"].includes(left) ? left : Object.keys(S.threads).length ? "chats" : "chars";
|
||||
const sort = local.get(K.sort, "imports");
|
||||
S.v.sort = SORTS.includes(sort) ? sort : "imports";
|
||||
S.v.adult = local.get(K.catalogAdult, false) === true; // search catalog only; independent of each saved card
|
||||
S.own = local.get(K.own, {}) || {};
|
||||
S.ab = { ...S.ab, ...(local.get(K.ab, {}) || {}) };
|
||||
S.ab.clip = S.ab.clip && typeof S.ab.clip === "object" ? S.ab.clip : {};
|
||||
@@ -604,7 +605,7 @@ function vStatus(text, bad = false) { const s = $("#venstatus"); s.textContent =
|
||||
// The pages of Venice's characters this tab saw in the last 10 minutes come back at once, without asking Venice
|
||||
// again: another page and back, a tag and off again, a reload. Searching again for the same thing asks afresh.
|
||||
const PAGES_FOR = 10 * 60000;
|
||||
const pageKey = (offset) => JSON.stringify([S.v.query, S.v.sort, S.v.tag, "unfiltered-v1", offset]);
|
||||
const pageKey = (offset) => JSON.stringify([S.v.query, S.v.sort, S.v.tag, "catalog-v1", S.v.adult, offset]);
|
||||
function keptPage(key) {
|
||||
const p = (session.get(K.pages, {}) || {})[key];
|
||||
return p && Array.isArray(p.list) && Date.now() - p.at < PAGES_FOR ? p : null;
|
||||
@@ -623,7 +624,7 @@ async function loadVenice(offset = 0, { paging = false, fresh = false } = {}) {
|
||||
renderGrid();
|
||||
try {
|
||||
const { list, more } = await new Venice(S.key).characterPage({ search: S.v.query, sort: S.v.sort, tags: S.v.tag,
|
||||
limit: PAGE, offset });
|
||||
adult: S.v.adult, limit: PAGE, offset });
|
||||
keepPage(key, list, more);
|
||||
if (asked !== S.v.asked) return; // a newer search is on its way
|
||||
showPage({ list, more }, offset, paging);
|
||||
@@ -2212,8 +2213,8 @@ function adultFor(c) {
|
||||
/** Explain the card label without introducing a local permission gate. */
|
||||
function renderAdultNote(c) {
|
||||
const p = $("#adult-note");
|
||||
p.hidden = !(c && !c.plain && c.adult);
|
||||
if (!p.hidden) p.textContent = "a content label kept with the card. it does not filter chats, prompts, or pictures; venice and the selected model handle content policy.";
|
||||
p.hidden = !(c && !c.plain);
|
||||
if (!p.hidden) p.textContent = "use 18+ model preferences for this character, including one from the standard catalog. this setting does not filter chats, prompts, or pictures; venice and the selected model handle content policy.";
|
||||
}
|
||||
/** A label or saved model preference changed: update the related controls. */
|
||||
function adultChanged() {
|
||||
@@ -3922,6 +3923,7 @@ function bind() {
|
||||
|
||||
$("#vsearch").value = S.v.query;
|
||||
$("#vsort").value = S.v.sort;
|
||||
$("#vadult").checked = S.v.adult;
|
||||
$("#venform").addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
if (!S.key) return needKey("paste your venice key first.");
|
||||
@@ -3934,6 +3936,11 @@ function bind() {
|
||||
local.set(K.sort, S.v.sort);
|
||||
if (S.key) loadVenice(0);
|
||||
});
|
||||
$("#vadult").addEventListener("change", () => {
|
||||
S.v.adult = $("#vadult").checked;
|
||||
local.set(K.catalogAdult, S.v.adult);
|
||||
if (S.key) loadVenice(0); // a different catalog starts at its first page
|
||||
});
|
||||
$("#plain").addEventListener("click", () => selectCard(PLAIN, { talk: true }));
|
||||
$("#msearch").addEventListener("input", renderModels);
|
||||
$("#msort").addEventListener("change", () => {
|
||||
|
||||
@@ -79,8 +79,10 @@
|
||||
<option value="highestRating">top rated</option>
|
||||
<option value="mostRecent">newest</option>
|
||||
</select></label>
|
||||
<label class="check"><input type="checkbox" id="vadult" aria-describedby="vcatalog-note"> 18+ catalog</label>
|
||||
<button type="submit" class="btn" id="vgo">SEARCH</button>
|
||||
</form>
|
||||
<p class="hint" id="vcatalog-note">unchecked: standard venice catalog; checked: 18+ catalog. a saved character's 18+ setting is separate.</p>
|
||||
<p id="vtags" class="tags" hidden></p>
|
||||
<p id="venstatus" class="status" role="status" aria-live="polite"></p>
|
||||
<ul id="vgrid" class="grid" aria-label="Venice characters"></ul>
|
||||
@@ -158,8 +160,8 @@
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="check"><input type="checkbox" name="short_form"> short-form: replies come as a few short texts</label>
|
||||
<label class="check"><input type="checkbox" name="adult"> adult content label (metadata)</label>
|
||||
<p class="hint" id="adult-note" role="status" hidden></p>
|
||||
<label class="check"><input type="checkbox" name="adult" aria-describedby="adult-note"> 18+</label>
|
||||
<p class="hint" id="adult-note" role="status">use 18+ model preferences for this character, including one from the standard catalog. this setting does not filter chats, prompts, or pictures; venice and the selected model handle content policy.</p>
|
||||
<label>HOW THEY TALK <input name="voice_notes" maxlength="2000" placeholder="clipped, wry, asks one question at a time"></label>
|
||||
<details class="group" id="flow-group">
|
||||
<summary>FLOW <span class="hint">optional stages for meeting someone new</span></summary>
|
||||
@@ -330,8 +332,8 @@
|
||||
<p>Characters and chats are saved in this browser, and so are the photos, clips and voice notes in them. Export a character to keep them anywhere else.</p>
|
||||
<p>What a character remembers of your chats is kept in this browser too, beside their card: a standard character-card export carries none of it; a full export or backup includes it. Deleting a chat doesn't make them forget; their card does. Switch memory off under abilities.</p>
|
||||
<p>Your key stays in this browser and goes only to api.venice.ai. The page's security policy blocks every other address, except the download links Venice hands out for clips from its private video models, which are fetched without your key. Conversations run on your Venice account, under Venice's terms.</p>
|
||||
<p>Havre adds no content filtering. Characters, prompts, reference pictures and media requests go to Venice using your own API key. Venice and the model you choose handle moderation; Havre shows the response or error they return.</p>
|
||||
<p>The adult label is card metadata, carried between apps as an nsfw tag. It does not hide characters, gate chats or decide what you can generate. Images are requested with optional safe mode off; Venice's own policies and model limits still apply.</p>
|
||||
<p>Havre does not moderate your content. The catalog selector chooses Venice's standard or 18+ search catalog. Characters, prompts, reference pictures and media requests go to Venice using your own API key. Venice and the model you choose handle moderation; Havre shows the response or error they return.</p>
|
||||
<p>A saved character's 18+ setting is separate from the search catalog. You can change it for any saved character, including one from Venice's standard catalog. It selects your model preferences and travels between apps as an nsfw tag; it does not gate chats or generation. Images are requested with optional safe mode off; Venice's own policies and model limits still apply.</p>
|
||||
<p>What characters do on their own stays within the daily allowance you set under abilities, and you can switch it off there. A clip is never made without your yes.</p>
|
||||
<p>Hearing and seeing are off until you switch them on. Your voice note goes to Venice to be written down, and your photo goes to the model you talk to, or to the one that describes it.</p>
|
||||
<p>Character imports and remixes keep the source credit in their lineage. Remix can write from the ideas you choose; the similarity comparison is advisory, and you decide what to save.</p>
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
Read skill.md for the card format and API workflow. card.js and venice.js are ES modules with no dependencies (Node 20+ or a browser).
|
||||
|
||||
Havre adds no content filtering. Requests use the person's own Venice key; Venice and the selected model handle moderation. Cards, chats and generated media stay in local browser storage; prompts, chat context and supplied media are sent to Venice for generation. The adult label is metadata and remix similarity is advisory.
|
||||
Havre adds no content moderation. Requests use the person's own Venice key; Venice and the selected model handle moderation. Cards, chats and generated media stay in local browser storage; prompts, chat context and supplied media are sent to Venice for generation. Remix similarity is advisory.
|
||||
|
||||
The 18+ catalog checkbox selects one of Venice's exclusive search catalogs on `/characters`: unchecked sends `isAdult=false` (standard), checked sends `isAdult=true` (adult). There is no All option or age-confirmation gate. Any saved character, including one from the standard catalog, has a separate editable 18+ setting for model preferences; that metadata does not gate content or generation.
|
||||
|
||||
## Files
|
||||
|
||||
|
||||
@@ -10,10 +10,15 @@ they are, how they talk, how they meet someone new, and where they came from. Pe
|
||||
public characters, in the browser with their own Venice API key; export them to other apps; and remix the ones they
|
||||
love into someone new, with credit.
|
||||
|
||||
Havre adds no content filtering. It sends requests through the person's own Venice API key; Venice and the chosen
|
||||
Havre adds no content moderation. It sends requests through the person's own Venice API key; Venice and the chosen
|
||||
model handle moderation. Cards, chats and generated media are stored in the browser, while prompts, chat context
|
||||
and supplied media are sent to Venice to carry out those requests.
|
||||
|
||||
Catalog search has two exclusive choices on Venice's `/characters` endpoint. The **18+ catalog** checkbox sends
|
||||
`isAdult=false` when unchecked (standard catalog) and `isAdult=true` when checked (adult catalog). There is no All
|
||||
option or age-confirmation gate. This search choice is separate from a saved character's own 18+ setting: any saved
|
||||
character, including one from the standard Venice catalog, can be marked 18+ independently.
|
||||
|
||||
Everything you need is at https://havre.yolopreneur.lol/:
|
||||
|
||||
- `card.schema.json`: the format, as JSON Schema.
|
||||
@@ -89,8 +94,8 @@ Save it as `<id>.havre.json`. Only `havre` (always 1), `name` and `persona` are
|
||||
`voice` one of its voices, `mood` a short delivery note ("warm, teasing") that Qwen 3 voices follow, and `speed`
|
||||
0.25 to 4.
|
||||
- **adult** (optional): content metadata, carried both ways as an `nsfw` tag in Character Cards. A remix preserves
|
||||
it. The label may select a saved model preference, but does not gate browsing, chatting, uploads or generation and
|
||||
does not inject content rules into the character's prompt.
|
||||
it. The person can edit the 18+ setting on any saved character. It selects saved model preferences independently
|
||||
of the search catalog, and does not gate chatting, uploads or generation or inject content rules into the prompt.
|
||||
- **license**: `remix-with-credit` (the default), `all-rights-reserved`, `cc-by-4.0` or `cc0`.
|
||||
- **lineage**: where the character came from, as `{ relation, name, creator, ref, at, note }` with relation
|
||||
`ported-from`, `inspired-by` or `forked-from` and ref `venice:<slug>`, a URL, or `sha256:<hex>` of the source text.
|
||||
|
||||
@@ -345,10 +345,11 @@ test("venice: models need no key; characters preserve content labels; errors are
|
||||
|
||||
const log2 = [];
|
||||
const k = new Venice("sk-test", { fetch: fakeFetch((p) => p.startsWith("/characters?")
|
||||
? { status: 200, body: { data: [{ slug: "a", name: "A", adult: false, photoUrl: "https://outerface.venice.ai/x", stats: { imports: 3 } }, { slug: "b", adult: true }] } }
|
||||
? { status: 200, body: { data: [{ slug: "a", name: "A", adult: false, photoUrl: "https://outerface.venice.ai/x", stats: { imports: 3 } }] } }
|
||||
: { status: 401, body: { error: "bad key" } }, log2) });
|
||||
const cs = await k.characters({ search: "sea" });
|
||||
assert.deepEqual(cs.map((c) => c.slug), ["a", "b"]);
|
||||
assert.deepEqual(cs.map((c) => c.slug), ["a"]);
|
||||
assert.equal(new URL(log2[0].url).searchParams.get("isAdult"), "false");
|
||||
assert.equal(cs[0].url, "https://venice.ai/c/a");
|
||||
assert.match(log2[0].url, /search=sea/);
|
||||
assert.equal(log2[0].init.headers.Authorization, "Bearer sk-test");
|
||||
@@ -375,41 +376,67 @@ test("venice: chat sends Havre's prompt alone, or speaks through a Venice charac
|
||||
assert.ok(bodies[2].response_format && !bodies[3].response_format, "retried without response_format after a 400");
|
||||
});
|
||||
|
||||
test("venice: a page of characters preserves all results, sorting and pagination", async () => {
|
||||
test("venice: the standard catalog is requested explicitly with sorting and pagination", async () => {
|
||||
const urls = [];
|
||||
const page = (n) => Array.from({ length: n }, (_, i) => ({ slug: "c" + i, name: "C" + i, adult: false, photoUrl: "http://elsewhere.example/x.png" }));
|
||||
const v = new Venice("sk", { fetch: fakeFetch((p) => ({ status: 200, body: { data: p.includes("offset=24") ? page(3) : [...page(23), { slug: "x", adult: true }] } }), urls) });
|
||||
const first = await v.characterPage({ sort: "imports", search: "#pirate", limit: 24 });
|
||||
const q = new URL(urls[0].url).searchParams;
|
||||
assert.equal(q.get("isAdult"), null);
|
||||
assert.equal(q.get("sortBy"), "imports");
|
||||
assert.equal(q.get("search"), "#pirate");
|
||||
assert.equal(first.list.length, 24, "all returned characters remain listed");
|
||||
assert.equal(first.list.at(-1).adult, true, "content labels are still available");
|
||||
const v = new Venice("sk", { fetch: fakeFetch((p) => {
|
||||
const q = new URL(p, "https://api.venice.ai").searchParams;
|
||||
// Venice's omitted/false selection is standard only; true is a separate catalog.
|
||||
return { status: 200, body: { data: q.get("isAdult") === "true" ? [] : page(q.get("offset") === "24" ? 3 : 24) } };
|
||||
}, urls) });
|
||||
const first = await v.characterPage({ sort: "imports", search: "#pirate", tags: "sea", limit: 24 });
|
||||
assert.deepEqual(Object.fromEntries(new URL(urls[0].url).searchParams),
|
||||
{ limit: "24", offset: "0", isAdult: "false", search: "#pirate", sortBy: "imports", tags: "sea" });
|
||||
assert.equal(first.list.length, 24);
|
||||
assert.ok(first.list.every((c) => c.adult === false));
|
||||
assert.equal(first.more, true, "a full page means there may be more");
|
||||
assert.equal(first.list[0].photo, "", "photos only from outerface.venice.ai");
|
||||
const last = await v.characterPage({ limit: 24, offset: 24 });
|
||||
assert.equal(last.list.length, 3);
|
||||
assert.equal(last.more, false);
|
||||
assert.equal(new URL(urls[1].url).searchParams.get("sortBy"), null);
|
||||
assert.deepEqual(Object.fromEntries(new URL(urls[1].url).searchParams), { limit: "24", offset: "24", isAdult: "false" });
|
||||
|
||||
const tries = [];
|
||||
const old = new Venice("sk", { fetch: fakeFetch((p) => (p.includes("sortBy") ? { status: 400, body: { error: "unknown parameter" } } : { status: 200, body: { data: page(2) } }), tries) });
|
||||
const plain = await old.characterPage({ sort: "featured" });
|
||||
assert.equal(plain.list.length, 2);
|
||||
assert.equal(tries.length, 2, "asked again without the sort");
|
||||
assert.ok(!tries[1].url.includes("sortBy") && !tries[1].url.includes("isAdult"));
|
||||
const old = new Venice("sk", { fetch: fakeFetch((p) => (p.includes("sortBy") ? { status: 400, body: { error: "unknown parameter" } }
|
||||
: { status: 200, body: { data: [{ slug: "adult", adult: true }] } }), tries) });
|
||||
const result = await old.characterPage({ adult: true, sort: "featured", search: "captain", tags: "sea", offset: 48, limit: 24 });
|
||||
assert.equal(result.list[0].adult, true);
|
||||
assert.equal(tries.length, 2, "unsupported optional parameters are retried");
|
||||
assert.deepEqual(Object.fromEntries(new URL(tries[1].url).searchParams),
|
||||
{ limit: "24", offset: "48", isAdult: "true", search: "captain" }, "retry must not switch the selected catalog");
|
||||
});
|
||||
|
||||
test("venice: legacy adult option cannot filter the catalog; tags narrow the API request", async () => {
|
||||
test("venice: 18+ is a separate catalog and keeps the selection while paging and searching", async () => {
|
||||
const urls = [];
|
||||
const data = [{ slug: "a", name: "A", adult: false, tags: ["sea"] }, { slug: "b", name: "B", adult: true, tags: [] }];
|
||||
const v = new Venice("sk", { fetch: fakeFetch(() => ({ status: 200, body: { data } }), urls) });
|
||||
const adult = await v.characterPage({ adult: true, tags: "sea" });
|
||||
const q = new URL(urls[0].url).searchParams;
|
||||
assert.equal(q.get("isAdult"), null, "no content-category filter is requested");
|
||||
assert.equal(q.get("tags"), "sea");
|
||||
assert.deepEqual(adult.list.map((c) => [c.slug, c.adult]), [["a", false], ["b", true]], "metadata is preserved for every result");
|
||||
assert.deepEqual((await v.characters()).map((c) => c.slug), ["a", "b"], "the default request is unfiltered too");
|
||||
const data = [{ slug: "standard", name: "Standard", adult: false, tags: ["sea"] },
|
||||
...[1, 2, 3].map((n) => ({ slug: "adult-" + n, name: "Adult " + n, adult: true, tags: ["sea"] }))];
|
||||
const v = new Venice("sk", { fetch: fakeFetch((p) => {
|
||||
const q = new URL(p, "https://api.venice.ai").searchParams;
|
||||
const start = Number(q.get("offset") || 0), limit = Number(q.get("limit") || 24);
|
||||
const rows = data.filter((c) => c.adult === (q.get("isAdult") === "true"));
|
||||
return { status: 200, body: { data: rows.slice(start, start + limit) } };
|
||||
}, urls) });
|
||||
const opts = { adult: true, tags: "sea", search: "captain", sort: "highestRating", limit: 2 };
|
||||
const first = await v.characterPage(opts);
|
||||
assert.deepEqual(Object.fromEntries(new URL(urls[0].url).searchParams),
|
||||
{ limit: "2", offset: "0", isAdult: "true", search: "captain", sortBy: "highestRating", tags: "sea" });
|
||||
assert.deepEqual(first.list.map((c) => [c.slug, c.adult]), [["adult-1", true], ["adult-2", true]]);
|
||||
assert.equal(first.more, true);
|
||||
const last = await v.characterPage({ ...opts, offset: 2 });
|
||||
assert.deepEqual(Object.fromEntries(new URL(urls[1].url).searchParams),
|
||||
{ limit: "2", offset: "2", isAdult: "true", search: "captain", sortBy: "highestRating", tags: "sea" });
|
||||
assert.deepEqual(last.list.map((c) => c.slug), ["adult-3"]);
|
||||
assert.equal(last.more, false);
|
||||
assert.deepEqual((await v.characters()).map((c) => c.slug), ["standard"]);
|
||||
assert.equal(new URL(urls.at(-1).url).searchParams.get("isAdult"), "false");
|
||||
});
|
||||
|
||||
test("venice: catalog selection does not add a local result filter", async () => {
|
||||
const data = [{ slug: "a", adult: false }, { slug: "b", adult: true }];
|
||||
const v = new Venice("sk", { fetch: fakeFetch(() => ({ status: 200, body: { data } }), []) });
|
||||
for (const adult of [false, true]) {
|
||||
assert.deepEqual((await v.characters({ adult })).map((c) => c.slug), ["a", "b"], "preserve exactly what the provider returned");
|
||||
}
|
||||
});
|
||||
|
||||
test("venice: models say what they can do and how private they are; beta-only ones are left out; the balance reads", async () => {
|
||||
|
||||
+151
-23
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// Serves this folder on localhost and drives Chromium through the page on a desktop and a phone: the first visit,
|
||||
// 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, and all content labels) and one who says hello first, the model picker, a plain chat, several chats at once (and in two tabs),
|
||||
// image service that stay put between searches, pages seen lately not asked for again, tags, and independent standard / 18+ catalogs) and one who says hello first, the model picker, a plain chat, several chats at once (and in two tabs),
|
||||
// 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, the harbor's own met without a key (and by a shared link, and their\n// pages), and the page's security policy.
|
||||
@@ -128,6 +128,8 @@ const CHARACTERS = [
|
||||
{ slug: "after-dark", name: "After Dark", description: "Only for grown-ups.", author: "x", photoUrl: null, adult: true, modelId: "m", tags: [], stats: {} },
|
||||
{ slug: "wren", name: "Wren", description: "A deckhand on the night ferry.", author: "q", photoUrl: null, adult: false, modelId: "zai-org-glm-5-2", tags: [], stats: {} },
|
||||
];
|
||||
let catalogPageExtras = [];
|
||||
const characterLookups = [];
|
||||
async function fakeVenice(route) {
|
||||
const req = route.request(), url = req.url(), body = req.postData() || "", auth = req.headers().authorization || "";
|
||||
const cors = { "access-control-allow-origin": "*" };
|
||||
@@ -150,9 +152,19 @@ async function fakeVenice(route) {
|
||||
return ok({ canConsume: true, consumptionCurrency: "USD", balances: { usd: 12.5, diem: null, bundledCredits: null, earnedCredits: null }, diemEpochAllocation: 0 });
|
||||
}
|
||||
if (url.includes("/characters")) {
|
||||
const parsed = new URL(url), slug = parsed.pathname.split("/characters/")[1];
|
||||
if (slug) {
|
||||
characterLookups.push(url);
|
||||
const c = CHARACTERS.find((x) => x.slug === decodeURIComponent(slug));
|
||||
return c ? ok({ object: "character", data: c }) : ok({ error: "Character not found" }, 404);
|
||||
}
|
||||
seen.characters.push(url);
|
||||
const q = new URL(url).searchParams, tag = q.get("tags"), adult = q.get("isAdult");
|
||||
return ok({ object: "list", data: CHARACTERS.filter((c) => (adult === null || String(c.adult) === adult) && (!tag || c.tags.includes(tag))) });
|
||||
const q = parsed.searchParams, tag = q.get("tags"), adult = q.get("isAdult") === "true";
|
||||
const search = (q.get("search") || "").toLowerCase(), offset = Number(q.get("offset") || 0), limit = Number(q.get("limit") || 50);
|
||||
// Venice's real API is exclusive: omission/false is standard, true is adult; each set paginates separately.
|
||||
const data = [...CHARACTERS, ...catalogPageExtras].filter((c) => c.adult === adult && (!tag || c.tags.includes(tag)) &&
|
||||
(!search || [c.name, c.description, ...c.tags].join(" ").toLowerCase().includes(search)));
|
||||
return ok({ object: "list", data: data.slice(offset, offset + limit) });
|
||||
}
|
||||
if (url.includes("/chat/completions")) {
|
||||
const b = JSON.parse(body);
|
||||
@@ -350,7 +362,7 @@ async function main() {
|
||||
await has(page, "#log .line.me .said", /^Worms\?$/);
|
||||
await has(page, "#log .line.them .said", /^Manners\?$/);
|
||||
|
||||
// Old saved switches and filtered catalog caches must not restore client-side filtering.
|
||||
// A saved catalog choice is independent of the retired permission setting and obsolete page caches.
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("havre.adult", "false");
|
||||
localStorage.setItem("havre.adult.18", "false");
|
||||
@@ -373,17 +385,16 @@ async function main() {
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem("havre.key")), null, "an unremembered key stays out of localStorage");
|
||||
assert.equal(await page.evaluate(() => JSON.parse(sessionStorage.getItem("havre.key"))), "sk-good");
|
||||
|
||||
// Venice's mixed catalog is shown as returned, including its adult-labelled metadata.
|
||||
await count(page, "#vgrid li:not(.ghost)", 3);
|
||||
assert.ok(seen.characters.some((u) => !new URL(u).searchParams.has("isAdult") && /sortBy=imports/.test(u)), "no content filter is sent to Venice");
|
||||
assert.equal(await page.locator("#vadult").count(), 0, "no local catalog permission switch");
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem("havre.adult")), "false", "old preference is ignored");
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem("havre.adult.18")), "false", "old confirmation is not needed");
|
||||
await has(page, "#vgrid .nm", /^After Dark$/);
|
||||
await has(page, "#vgrid .x18", /18\+/);
|
||||
// Venice's standard catalog is selected explicitly; the 18+ catalog is a separate provider query.
|
||||
await count(page, "#vgrid li:not(.ghost)", 2);
|
||||
assert.ok(seen.characters.some((u) => new URL(u).searchParams.get("isAdult") === "false" && /sortBy=imports/.test(u)));
|
||||
assert.equal(await page.locator("#vadult").isChecked(), false);
|
||||
assert.equal(await page.locator("#vgrid .tile", { hasText: "After Dark" }).count(), 0);
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem("havre.adult")), "false");
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem("havre.adult.18")), "false", "no age confirmation is needed");
|
||||
await until(page, () => { const i = document.querySelector("#vgrid img"); return !!i && i.complete && i.naturalWidth > 0; }, null, "a character's photo");
|
||||
assert.ok(seen.photos[0].startsWith("https://venice.ai/_next/image?url=https%3A%2F%2Fouterface.venice.ai%2Fsable.png&w="), seen.photos[0]);
|
||||
assert.equal(await page.locator("#vgrid .noface").count(), 2, "missing photos are empty frames");
|
||||
assert.equal(await page.locator("#vgrid .noface").count(), 1, "missing photos are empty frames");
|
||||
await has(page, "#balance", /^balance \$12\.50$/);
|
||||
// A new search draws a tile again only if it changed: the photo stays put.
|
||||
await page.evaluate(() => { document.querySelector('#vgrid .tile[data-slug="sable-penrose"] img').dataset.kept = "yes"; });
|
||||
@@ -392,26 +403,91 @@ async function main() {
|
||||
await poll(() => seen.characters.length > asked, "a sorted search");
|
||||
assert.ok(seen.characters.at(-1).includes("sortBy=featured"));
|
||||
await until(page, () => document.querySelector("#venstatus").textContent === "", null, "the sorted page");
|
||||
await count(page, "#vgrid li:not(.ghost)", 3);
|
||||
await count(page, "#vgrid li:not(.ghost)", 2);
|
||||
await has(page, "#vgrid .nm", /Wren/);
|
||||
assert.equal(await page.locator('#vgrid img[data-kept="yes"]').count(), 1, "Sable's photo wasn't drawn again");
|
||||
// A tag still narrows by that tag, independently of content labels; removing it restores the cached mixed page.
|
||||
// A tag narrows the selected catalog; removing it restores that catalog's cached page.
|
||||
asked = seen.characters.length;
|
||||
await page.getByRole("button", { name: "Only characters tagged neighbor" }).click();
|
||||
await poll(() => seen.characters.length > asked, "a search by tag");
|
||||
assert.ok(seen.characters.at(-1).includes("tags=neighbor"));
|
||||
assert.ok(!new URL(seen.characters.at(-1)).searchParams.has("isAdult"));
|
||||
assert.equal(new URL(seen.characters.at(-1)).searchParams.get("isAdult"), "false");
|
||||
await count(page, "#vgrid li:not(.ghost)", 1);
|
||||
asked = seen.characters.length;
|
||||
await page.getByRole("button", { name: "Show every tag, not just neighbor" }).click();
|
||||
await count(page, "#vgrid li:not(.ghost)", 3);
|
||||
await count(page, "#vgrid li:not(.ghost)", 2);
|
||||
assert.equal(seen.characters.length, asked, "a recently seen page isn't fetched again");
|
||||
assert.equal(await page.locator('#vgrid img[data-kept="yes"]').count(), 1);
|
||||
await has(page, "#vgrid .nm", /^After Dark$/);
|
||||
// The same search again asks Venice afresh.
|
||||
await page.click("#vgo");
|
||||
await poll(() => seen.characters.length > asked, "the same search, asked again");
|
||||
assert.ok(seen.characters.every((u) => !new URL(u).searchParams.has("isAdult")), "every catalog request stays unfiltered");
|
||||
await count(page, "#vgrid li:not(.ghost)", 2);
|
||||
|
||||
const catalogDialogs = [];
|
||||
const unexpectedCatalogDialog = (d) => { catalogDialogs.push(d.message()); void d.dismiss(); };
|
||||
page.on("dialog", unexpectedCatalogDialog);
|
||||
await page.locator("#vadult").check();
|
||||
await count(page, "#vgrid li:not(.ghost)", 1);
|
||||
await has(page, "#vgrid .nm", /^After Dark$/);
|
||||
await has(page, "#vgrid .x18", /18\+/);
|
||||
assert.equal(new URL(seen.characters.at(-1)).searchParams.get("isAdult"), "true");
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem("havre.adult")), "true");
|
||||
asked = seen.characters.length;
|
||||
await page.locator("#vadult").uncheck();
|
||||
await count(page, "#vgrid li:not(.ghost)", 2);
|
||||
assert.equal(seen.characters.length, asked, "standard and adult pages have separate cache entries");
|
||||
await page.locator("#vadult").check();
|
||||
await count(page, "#vgrid li:not(.ghost)", 1);
|
||||
assert.equal(seen.characters.length, asked, "the adult page also comes back from its own cache");
|
||||
await page.reload();
|
||||
await count(page, "#vgrid li:not(.ghost)", 1);
|
||||
assert.equal(await page.locator("#vadult").isChecked(), true, "the selected catalog survives a reload");
|
||||
await has(page, "#vgrid .nm", /^After Dark$/);
|
||||
await page.locator("#vadult").uncheck();
|
||||
await count(page, "#vgrid li:not(.ghost)", 2);
|
||||
|
||||
// A catalog change from page two resets paging while preserving the search and sort.
|
||||
catalogPageExtras = Array.from({ length: 24 }, (_, i) => ({ ...CHARACTERS[2], slug: `page-neighbor-${i}`, name: `Page neighbor ${i}` }));
|
||||
await page.fill("#vsearch", "a");
|
||||
await page.click("#vgo");
|
||||
await count(page, "#vgrid li:not(.ghost)", 24);
|
||||
await page.selectOption("#vsort", "mostRecent");
|
||||
await poll(() => new URL(seen.characters.at(-1)).searchParams.get("sortBy") === "mostRecent", "the paging sort");
|
||||
await count(page, "#vgrid li:not(.ghost)", 24);
|
||||
await page.click("#vnext");
|
||||
await count(page, "#vgrid li:not(.ghost)", 2);
|
||||
assert.equal(new URL(seen.characters.at(-1)).searchParams.get("offset"), "24");
|
||||
await page.locator("#vadult").check();
|
||||
await count(page, "#vgrid li:not(.ghost)", 1);
|
||||
const adultPage = new URL(seen.characters.at(-1)).searchParams;
|
||||
assert.equal(adultPage.get("isAdult"), "true");
|
||||
assert.equal(adultPage.get("offset"), "0");
|
||||
assert.equal(adultPage.get("search"), "a");
|
||||
assert.equal(adultPage.get("sortBy"), "mostRecent");
|
||||
assert.equal(await page.inputValue("#vsearch"), "a");
|
||||
assert.equal(await page.inputValue("#vsort"), "mostRecent");
|
||||
asked = seen.characters.length;
|
||||
await page.locator("#vadult").uncheck();
|
||||
await count(page, "#vgrid li:not(.ghost)", 24);
|
||||
assert.equal(seen.characters.length, asked, "switching back restores the standard first page, not its second page");
|
||||
assert.equal(await page.locator("#vprev").isDisabled(), true);
|
||||
catalogPageExtras = [];
|
||||
await page.fill("#vsearch", "");
|
||||
await page.click("#vgo");
|
||||
await count(page, "#vgrid li:not(.ghost)", 2);
|
||||
await page.selectOption("#vsort", "featured");
|
||||
await count(page, "#vgrid li:not(.ghost)", 2);
|
||||
assert.ok(seen.characters.every((u) => ["false", "true"].includes(new URL(u).searchParams.get("isAdult"))), "every catalog request explicitly selects a provider catalog");
|
||||
const directAdult = await page.evaluate(async () => {
|
||||
const { Venice } = await import("./venice.js");
|
||||
return new Venice("sk-good").character("after-dark");
|
||||
});
|
||||
assert.equal(directAdult.slug, "after-dark");
|
||||
assert.equal(directAdult.adult, true, "direct character lookup works independently of the standard catalog choice");
|
||||
assert.ok(characterLookups.at(-1).endsWith("/characters/after-dark"));
|
||||
assert.equal(await page.locator("#vadult").isChecked(), false);
|
||||
assert.deepEqual(catalogDialogs, [], "catalog choice and direct lookup never ask for content permission");
|
||||
page.off("dialog", unexpectedCatalogDialog);
|
||||
|
||||
// one of them says hello first, through Venice, with their own prompt
|
||||
const chatsBefore = seen.chats.length;
|
||||
@@ -953,10 +1029,10 @@ async function main() {
|
||||
// ---------- content labels and imported references never gate requests ----------
|
||||
await page.locator('#sheet [name="adult"]').check();
|
||||
await until(page, () => JSON.parse(localStorage.getItem("havre.cards.v1")).find((c) => c.id === "ines-corbel").adult === true, null, "her card marked adult");
|
||||
await has(page, "#adult-note", /^a content label kept with the card\. it does not filter/);
|
||||
await has(page, "#adult-note", /use 18\+ model preferences for this character.*does not filter chats, prompts, or pictures/);
|
||||
await has(page, "#roster th", /^Ines Corbel18\+/);
|
||||
await has(page, "#who-meta", /^18\+ \/ /);
|
||||
// The old adult=false preference cannot change this path or add a policy prompt.
|
||||
// The standard catalog choice and retired permission preference cannot gate this labelled card.
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem("havre.adult.18")), "false");
|
||||
await page.locator("#chat").scrollIntoViewIfNeeded();
|
||||
await page.getByRole("button", { name: "ask for a photo" }).click();
|
||||
@@ -1128,8 +1204,60 @@ async function main() {
|
||||
assert.equal(await page.locator("#log .said", { hasText: "Two tabs at once?" }).count(), 0, "the deleted chat left this tab too");
|
||||
await p4.close();
|
||||
|
||||
// a character Venice marks adult is adult here too
|
||||
// A standard-catalog Venice character can independently use the 18+ card label and model preferences.
|
||||
await page.click("#lt-chars");
|
||||
await page.locator("#vadult").uncheck();
|
||||
await count(page, "#vgrid li:not(.ghost)", 2);
|
||||
await page.locator("#vgrid .tile", { hasText: "Sable Penrose" }).click();
|
||||
await has(page, "#who-name", /^Sable Penrose$/);
|
||||
await page.locator("#who-meta").getByRole("button", { name: "card" }).click();
|
||||
assert.equal(await page.locator('#sheet [name="adult"]').isEnabled(), true);
|
||||
assert.equal(await page.locator('#sheet [name="adult"]').isChecked(), false);
|
||||
await page.locator('#sheet [name="adult"]').check();
|
||||
await until(page, () => JSON.parse(localStorage.getItem("havre.cards.v1")).find((c) => c.engine.venice_character === "sable-penrose").adult === true,
|
||||
null, "the standard-catalog character's independent label saved");
|
||||
await has(page, "#adult-note", /including one from the standard catalog/);
|
||||
await has(page, "#who-meta", /^18\+ \/ via venice/);
|
||||
await page.click("#lt-chats");
|
||||
const sableChats = await page.locator("#threads .trow", { hasText: "Sable Penrose" }).count();
|
||||
assert.ok(sableChats > 0);
|
||||
await page.click("#lt-chars");
|
||||
asked = seen.characters.length;
|
||||
await page.click("#vgo");
|
||||
await poll(() => seen.characters.length > asked, "refreshing the standard catalog after a card-label edit");
|
||||
await count(page, "#vgrid li:not(.ghost)", 2);
|
||||
await page.locator("#vgrid .tile", { hasText: "Sable Penrose" }).click();
|
||||
assert.equal(await page.locator('#sheet [name="adult"]').isChecked(), true, "provider adult:false does not overwrite the saved label");
|
||||
await page.locator("#vadult").check();
|
||||
await count(page, "#vgrid li:not(.ghost)", 1);
|
||||
await page.locator("#vadult").uncheck();
|
||||
await count(page, "#vgrid li:not(.ghost)", 2);
|
||||
await page.click("#lt-harbor");
|
||||
await has(page, "#roster th", /^Sable Penrose18\+/);
|
||||
await page.click("#lt-chats");
|
||||
assert.equal(await page.locator("#threads .trow", { hasText: "Sable Penrose" }).count(), sableChats, "catalog switches keep saved chats");
|
||||
await page.reload();
|
||||
await has(page, "#who-name", /^Sable Penrose$/);
|
||||
assert.equal(await page.locator('#sheet [name="adult"]').isChecked(), true, "the local label survives reload");
|
||||
assert.equal(await page.locator("#vadult").isChecked(), false, "the standard catalog remains selected independently");
|
||||
assert.equal(await page.locator("#threads .trow", { hasText: "Sable Penrose" }).count(), sableChats);
|
||||
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, "the relabelled character's photo prompt");
|
||||
await has(page, "#ask", /model: Qwen Edit Uncensored · \$0\.04/);
|
||||
assertNoLocalPolicy(talks().at(-1).messages[0].content);
|
||||
const sableEditsBefore = seen.edits.length;
|
||||
await page.click("#ask-go");
|
||||
await poll(() => seen.edits.length > sableEditsBefore, "the relabelled standard character's preferred image model");
|
||||
const sableLabelledEdit = seen.edits.at(-1).body;
|
||||
assert.equal(sableLabelledEdit.modelId || sableLabelledEdit.model, "qwen-edit-uncensored");
|
||||
assert.equal(sableLabelledEdit.safe_mode, false);
|
||||
assertNoLocalPolicy(sableLabelledEdit.prompt);
|
||||
await has(page, "#log .line.media .cap", /photo · Qwen Edit Uncensored · \$0\.04 · 18\+ · save/);
|
||||
|
||||
// A character Venice marks adult is discoverable through the 18+ catalog and arrives labelled.
|
||||
await page.click("#lt-chars");
|
||||
await page.locator("#vadult").check();
|
||||
await count(page, "#vgrid li:not(.ghost)", 1);
|
||||
await page.locator("#vgrid .tile", { hasText: "After Dark" }).click();
|
||||
await has(page, "#who-name", /^After Dark$/);
|
||||
await until(page, () => (JSON.parse(localStorage.getItem("havre.cards.v1")).find((c) => c.name === "After Dark") || {}).adult === true, null, "After Dark marked adult");
|
||||
@@ -1296,7 +1424,7 @@ async function main() {
|
||||
await shot(p3, "phone.png");
|
||||
await p3.fill("#keyin", "sk-good");
|
||||
await p3.click("#keysave");
|
||||
await count(p3, "#vgrid li:not(.ghost)", 3);
|
||||
await count(p3, "#vgrid li:not(.ghost)", 2);
|
||||
await p3.locator("#vgrid .tile", { hasText: "Wren" }).click();
|
||||
await has(p3, "#log .line.them .said", /Careful, the boxes bite\./);
|
||||
assert.ok(await p3.locator("#say").isVisible(), "the chat is on screen");
|
||||
|
||||
@@ -261,9 +261,9 @@ export class Venice {
|
||||
return { amount: typeof amount === "number" ? amount : null, currency, canConsume: !!(d && d.canConsume) };
|
||||
}
|
||||
|
||||
/** Public Venice characters as returned by the API, without a Havre content-category filter. */
|
||||
async characterPage({ search = "", limit = 24, offset = 0, sort = "", tags = "" } = {}) {
|
||||
const q = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||
/** The selected Venice catalog: standard by default, or 18+. This is a search selection, not a permission gate. */
|
||||
async characterPage({ search = "", limit = 24, offset = 0, sort = "", tags = "", adult = false } = {}) {
|
||||
const q = new URLSearchParams({ limit: String(limit), offset: String(offset), isAdult: String(!!adult) });
|
||||
if (search) q.set("search", search);
|
||||
if (sort) q.set("sortBy", sort);
|
||||
if (tags) q.set("tags", tags);
|
||||
|
||||
Reference in new issue
Block a user