Havre: sort the model picker by price (#250) The model picker gets a sort beside its search: name (the one in use first, then A to Z), cheapest first, priciest first, kept in this browser. Sorted by price, the price column says which way; a text model goes by its price in, then out. Clips, which Venice prices one at a time, have no sort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KjyFZEJivqQMhhCyyQau49
563 lines
36 KiB
JavaScript
563 lines
36 KiB
JavaScript
// abilities.js: what a character can do besides texting, and what you can send them. Pure functions, no DOM: the
|
||
// page imports this module, and so do the tests.
|
||
//
|
||
// photos an edit of the character's own pictures (their portrait and the reference pictures on their card),
|
||
// with the shot written from the conversation by a text model. Venice's safe mode stays on, except for
|
||
// an adult character whose pictures were all made here with AI (adultCheck, below).
|
||
// clips a few seconds of video from the same pictures (Venice's reference-to-video models), priced by
|
||
// Venice before anything is made
|
||
// voice notes a reply read out in the character's voice, with a mood
|
||
// hearing your voice note, transcribed (only if you switch it on)
|
||
// seeing your photo, shown to a model that can see, or described to one that can't (only if you switch it on)
|
||
|
||
const str = (v, max = 20000) => (typeof v === "string" ? v : v == null ? "" : String(v)).slice(0, max);
|
||
const clip = (s, n) => (s.length <= n ? s : s.slice(0, Math.max(0, n - 1)).replace(/\s+\S*$/, "") + "…");
|
||
|
||
// ---------- the models, and which one to use when nobody picked ----------
|
||
|
||
/** the ones Havre reaches for first, if Venice still has them; otherwise a private one, otherwise the first */
|
||
export const PREFER = {
|
||
photo: ["grok-imagine-edit", "firered-image-edit", "qwen-image-2-1-turbo-edit"],
|
||
portrait: ["grok-imagine-image", "z-image-turbo"],
|
||
clip: ["grok-imagine-1-5-reference-to-video-private", "grok-imagine-reference-to-video-private"],
|
||
voice: ["tts-kokoro"],
|
||
hearing: ["nvidia/parakeet-tdt-0.6b-v3", "openai/whisper-large-v3"],
|
||
// an adult character's photos, clips and portraits: Venice's uncensored models, the private ones first
|
||
adultPhoto: ["qwen-edit-uncensored", "qwen-image-2-1-turbo-edit", "seedream-v5-lite-edit"],
|
||
adultClip: ["minimax-h3-max-reference-to-video", "wan-3-0-reference-to-video", "wan-2-7-reference-to-video"],
|
||
adultPortrait: ["krea-2-turbo", "chroma", "qwen-image-3"],
|
||
};
|
||
export const DEFAULT_VOICE = "af_sky";
|
||
|
||
export function pick(list, prefer = []) {
|
||
if (!list || !list.length) return null;
|
||
for (const id of prefer) { const m = list.find((x) => x.id === id); if (m) return m; }
|
||
return list.find((m) => m.privacy === "private") || list[0];
|
||
}
|
||
/** the model that was picked, if Venice still has it, else the default for that kind */
|
||
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);
|
||
/** an adult character's clips come from the uncensored reference-to-video models, and only from them */
|
||
export const isAdultClipModel = (m) => !!m && m.ref && !!m.uncensored;
|
||
/** and their photos and portraits from the uncensored image models: the others refuse, or blur */
|
||
export const isAdultPhotoModel = (m) => !!m && !!m.uncensored;
|
||
|
||
/** a model that describes your photo when the character's own model can't see: the cheapest private one that can */
|
||
export function seer(textModels, id = "") {
|
||
const can = (textModels || []).filter((m) => m.vision);
|
||
const mine = id && can.find((m) => m.id === id);
|
||
if (mine) return mine;
|
||
const pool = can.filter((m) => m.privacy === "private");
|
||
return [...(pool.length ? pool : can)].sort((a, b) => (a.input ?? 1e9) - (b.input ?? 1e9))[0] || null;
|
||
}
|
||
|
||
// ---------- prices ----------
|
||
|
||
/** "$0.03", "$0.0007", "$12": enough digits to see the price, no more */
|
||
export function usd(n) {
|
||
if (n == null || !Number.isFinite(n)) return "?";
|
||
if (n === 0) return "$0";
|
||
if (n >= 100) return "$" + n.toFixed(0);
|
||
if (n >= 0.01) return "$" + n.toFixed(2);
|
||
if (n < 0.0001) return "under $0.0001";
|
||
return "$" + String(Number(n.toPrecision(2)));
|
||
}
|
||
/** a photo: the model's price, plus what it charges for each picture past the ones included */
|
||
export function photoPrice(model, pictures = 1) {
|
||
if (!model || model.price == null) return null;
|
||
const extra = model.extra ? Math.max(0, pictures - (model.included ?? 1)) * model.extra : 0;
|
||
return Math.round((model.price + extra) * 10000) / 10000;
|
||
}
|
||
/** a voice note of this many characters */
|
||
export const voicePrice = (model, chars) => (model && model.perMChars != null ? (model.perMChars * chars) / 1e6 : null);
|
||
/** a minute of your voice, heard */
|
||
export const hearingPerMinute = (model) => (model && model.perSecond != null ? model.perSecond * 60 : null);
|
||
|
||
/**
|
||
* What a model costs, as the numbers the model picker sorts by, the first counting most: a text model's price in, then
|
||
* out (a chat is mostly what's sent in: the card, the memories, the conversation so far); a photo's price, then what each
|
||
* picture past the included ones adds; a voice's price for a million characters; hearing's price for a second. null when
|
||
* Venice lists no price (a clip: it prices each one when asked).
|
||
*/
|
||
export function modelCost(type, model) {
|
||
if (!model) return null;
|
||
const n = (x) => (typeof x === "number" && Number.isFinite(x) ? x : null);
|
||
const keys = type === "text" ? [n(model.input), n(model.output)]
|
||
: type === "inpaint" || type === "image" ? [n(model.price), n(model.extra)]
|
||
: type === "tts" ? [n(model.perMChars)]
|
||
: type === "asr" ? [n(model.perSecond)]
|
||
: [];
|
||
return keys.length && keys[0] != null ? keys.map((k) => k ?? 0) : null;
|
||
}
|
||
/** the orders the model picker has: by name (the one in use first), cheapest first, priciest first */
|
||
export const MODEL_SORTS = ["name", "cheapest", "priciest"];
|
||
/**
|
||
* The model picker's list in order, as a new list. "name": the ones rank puts higher first (the one in use, then the
|
||
* character's own), then A to Z. "cheapest" and "priciest": by modelCost, A to Z between equals, the ones without a price
|
||
* last either way, and none held at the top, so the one in use shows where its price puts it.
|
||
*/
|
||
export function sortModels(list, how = "name", { type = "text", rank = () => 0 } = {}) {
|
||
const all = [...(list || [])], byName = (a, b) => String(a.name || a.id).localeCompare(String(b.name || b.id));
|
||
if (how !== "cheapest" && how !== "priciest") return all.sort((a, b) => rank(b) - rank(a) || byName(a, b));
|
||
const dir = how === "priciest" ? -1 : 1, cost = new Map(all.map((m) => [m, modelCost(type, m)]));
|
||
return all.sort((a, b) => {
|
||
const x = cost.get(a), y = cost.get(b);
|
||
if (!x || !y) return (x ? 0 : 1) - (y ? 0 : 1) || byName(a, b);
|
||
for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return dir * (x[i] - y[i]);
|
||
return byName(a, b);
|
||
});
|
||
}
|
||
|
||
// ---------- what goes into a photo or a clip ----------
|
||
|
||
// Content filters (Venice's, and the ones of the models' makers) read the words in a prompt, not the "no" in front of
|
||
// 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,
|
||
// as the shot writer (a text model) reads it: it can be told the number. 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 EIGHTEEN = "Hard rule, 18+ only: everyone shown is an adult, 18 or older, with a grown adult face and body.";
|
||
const ADULT = EIGHTEEN + " 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 + " " + UNSAID + " Reply with the shot only.";
|
||
export const PHOTO_SYSTEM = photoSystem(SAFE);
|
||
export const PHOTO_SYSTEM_ADULT = photoSystem(ADULT);
|
||
|
||
const clipSystem = (rules) => "You write the shot for a video clip, a few seconds long, that a character in a chat is " +
|
||
"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 +
|
||
" " + UNSAID + " Reply with the shot only.";
|
||
export const CLIP_SYSTEM = clipSystem(SAFE);
|
||
export const CLIP_SYSTEM_ADULT = clipSystem(ADULT);
|
||
|
||
export const LOOK_SYSTEM = "You describe how an original character looks, for a portrait of them. One paragraph of at most " +
|
||
"45 words: their apparent age (an adult), face, hair, build and clothes, and one detail people remember. Invent what " +
|
||
"isn't given. Never describe a real person. Reply with the description only.";
|
||
|
||
export const SEE_PROMPT = "Describe this photo in two or three plain sentences for someone who can't see it: what is in " +
|
||
"it, what is happening, and where. Don't guess who anyone is. Reply with the description only.";
|
||
|
||
/** the last few lines of the conversation, as a model would read them */
|
||
function transcript(card, history, n = 10) {
|
||
return history.filter((m) => !m.hidden && (m.role === "user" || m.role === "assistant")).slice(-n)
|
||
.map((m) => `${m.role === "user" ? "Them" : card.name}: ${clip(str(m.content).replace(/\s+/g, " ").trim(), 300)}`);
|
||
}
|
||
|
||
/**
|
||
* The messages for a text model to write the shot: the character, the conversation, what was asked for. adult: for an
|
||
* adult character whose pictures can be adult (adultCheck).
|
||
*/
|
||
export function shotMessages(card, history, ask = "", kind = "photo", { adult = false, current = "", change = "" } = {}) {
|
||
const lines = transcript(card, history);
|
||
const look = card.look && card.look.description;
|
||
const now = str(current, 900).trim(), how = str(change, 300).trim();
|
||
// a shot that's there already: changed the way they say, or, written again with nothing said, a different one
|
||
const again = !now ? "" : how
|
||
? `The shot as it stands:\n${now}\n\nChange it: ${how}. Write the whole shot again with that change, keeping what the change doesn't touch.`
|
||
: `The last shot was:\n${now}\n\nWrite a clearly different one: another moment, angle, setting or mood.`;
|
||
const user = [
|
||
`The character: ${card.name}${card.tagline ? ". " + card.tagline : ""}`,
|
||
look ? `How they look: ${look}` : "",
|
||
lines.length ? "The conversation so far, latest last:\n" + lines.join("\n") : "The conversation hasn't started yet.",
|
||
"What was asked for: " + (str(ask, 500).trim() || "nothing in particular. Pick a moment that fits the conversation."),
|
||
again,
|
||
`Write the shot for the ${kind === "clip" ? "clip" : "photo"} ${card.name} sends now.`,
|
||
].filter(Boolean).join("\n\n");
|
||
const system = kind === "clip" ? (adult ? CLIP_SYSTEM_ADULT : CLIP_SYSTEM) : adult ? PHOTO_SYSTEM_ADULT : PHOTO_SYSTEM;
|
||
return [{ role: "system", content: system }, { role: "user", content: user }];
|
||
}
|
||
|
||
/** the messages for a text model to describe how a character looks, when their card doesn't say */
|
||
export function lookMessages(card) {
|
||
const about = [card.tagline, card.persona && clip(card.persona.replace(/\s+/g, " "), 700)].filter(Boolean).join("\n");
|
||
return [{ role: "system", content: LOOK_SYSTEM },
|
||
{ role: "user", content: `The character: ${card.name}\n${about || "Nothing else is known: invent them."}` }];
|
||
}
|
||
|
||
/** a model's reply as one clean paragraph: no thinking, no labels, no quotes around it */
|
||
export function cleanText(text, max = 900) {
|
||
const t = str(text, 20000).replace(/<think>[\s\S]*?<\/think>/gi, "").replace(/^[\s\S]*?<\/think>/i, "")
|
||
.replace(/^\s*(?:\*\*)?(?:the )?(?:shot|description|look|photo|clip)(?:\*\*)?\s*:\s*/i, "")
|
||
.replace(/\s+/g, " ").trim().replace(/^["“'](.*)["”']$/, "$1").trim();
|
||
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. 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 picture model reads, in words a content filter
|
||
// doesn't take for an age or for anyone it guards. The number, "body", "hard rule" and "identifiable person" stay
|
||
// with the shot writer (EIGHTEEN, ADULT), whose instructions never reach a picture filter: a picture prompt with safe
|
||
// mode off is read the hardest, and a from-scratch adult character's photo was turned down on every model, with the
|
||
// plainest shot and look, while the same words went out before every one of them.
|
||
export const ADULT_ONLY = "Everyone shown is a grown adult.";
|
||
const KEEP_ADULT = ADULT_ONLY;
|
||
|
||
/** 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(/[.!?…]+$/, "");
|
||
/** words that end a sentence: a full stop, unless they were cut short with … */
|
||
const ended = (s) => (/[.!?…]$/.test(s) ? s : s + ".");
|
||
// how much of how they look rides in a photo or a clip: enough for a look, and never the room the shot needs
|
||
export const LOOK_ROOM = 600;
|
||
|
||
/**
|
||
* A photo or a clip made from the character's pictures: the rules, then who they are, then what the camera sees (body).
|
||
* Their face comes from the pictures; the rest follows how their card says they look where the pictures differ, so a
|
||
* changed look changes the next one, and the shot where it says what they wear. Nothing else is pinned: an edit model
|
||
* keeps what it isn't told to change, and a shot that asks for other clothes gets them. The look takes the room the
|
||
* rest leaves, up to LOOK_ROOM; with hardly any, it's left out.
|
||
*/
|
||
function fromPictures(card, pictures, body, limit, adult, what) {
|
||
const them = pictures > 1 ? `The reference pictures show ${card.name}.` : `The reference picture shows ${card.name}.`;
|
||
const head = adult ? `${KEEP_ADULT} ${them}` : `${them} ${KEEP}`;
|
||
const withLook = (look) => `${head} Keep their face, so it's clearly them. How they look: ${look} Where the ` +
|
||
`${pictures > 1 ? "pictures differ" : "picture differs"} from these words, follow the words; where the ${what} says what ` +
|
||
`they wear, follow that. ${body}`;
|
||
const look = lookWords(card), room = Math.min(LOOK_ROOM, limit - withLook("").length - 1);
|
||
if (look && room >= 40) return clip(withLook(ended(clip(look, room))), limit);
|
||
return clip(`${head} Keep their face, hair and build, so it's clearly them; their clothes stay as they are unless the ` +
|
||
`${what} says what they wear. ${body}`, limit);
|
||
}
|
||
/**
|
||
* What the photo model is told: who the character is (fromPictures), in the shot. adult: an adult character's photo
|
||
* (adultCheck), which keeps every rule but the one about work, and opens with the 18+ rule.
|
||
*/
|
||
export function photoPrompt(card, shot, pictures = 1, limit = 1500, { adult = false } = {}) {
|
||
return fromPictures(card, pictures, `New photo: ${str(shot).trim()} A natural photo with no text in it.`, limit, adult, "new photo");
|
||
}
|
||
export function clipPrompt(card, shot, limit = 2500, { adult = false } = {}) {
|
||
return fromPictures(card, 2, `The clip: ${str(shot).trim()}`, limit, adult, "clip");
|
||
}
|
||
/**
|
||
* A portrait drawn from how they look (a first one, or a new one for their card): the look takes the room the rules and
|
||
* the framing leave. adult: an adult character's (adultCheck, drawn), which keeps every rule but the one about work,
|
||
* and opens with the 18+ rule.
|
||
*/
|
||
export function portraitPrompt(card, limit = 1500, { adult = false } = {}) {
|
||
const look = lookWords(card) || str(card.tagline).trim().replace(/[.!?…]+$/, "") || "an original character";
|
||
const make = (w) => (adult
|
||
? `${KEEP_ADULT} Portrait photo of ${card.name}, an original adult character: ${w} Head and shoulders, soft light, plain ` +
|
||
"background, no text."
|
||
: `Portrait photo of ${card.name}, an original adult character: ${w} ${KEEP} Head and shoulders, soft light, plain ` +
|
||
"background, no text.");
|
||
const room = limit - make("").length - 1;
|
||
return clip(make(ended(room >= 40 ? clip(look, room) : look)), limit);
|
||
}
|
||
|
||
/**
|
||
* The views of a character sheet: a picture made for a character's card, from the pictures they have, shows them
|
||
* the next way along, so their photos and clips have them from more than one side.
|
||
*/
|
||
export const SHEET_VIEWS = [
|
||
"Full length, standing and facing the camera, against a plain light background, in even light.",
|
||
"Head and shoulders in a three-quarter view, against a plain background, in soft daylight.",
|
||
"Full length, seen from the side in mid-step, against a plain background, in even light.",
|
||
];
|
||
export const sheetView = (n) => SHEET_VIEWS[((Math.trunc(n) || 0) % SHEET_VIEWS.length + SHEET_VIEWS.length) % SHEET_VIEWS.length];
|
||
|
||
/** the words of a reply, ready to be read out: no thinking, no code, no stage directions */
|
||
export function speakable(text, max = 4000) {
|
||
const t = str(text, 100000).replace(/<think>[\s\S]*?<\/think>/gi, "").replace(/```[\s\S]*?(```|$)/g, " ")
|
||
.replace(/\*[^*\n]{1,200}\*/g, " ").replace(/[_#>`]/g, " ").replace(/\s+/g, " ").trim();
|
||
return clip(t, max);
|
||
}
|
||
|
||
// ---------- clips: length, size, shape ----------
|
||
|
||
/** the options a clip model takes, starting from what was picked last time where the model allows it */
|
||
export function clipOptions(model, last = {}) {
|
||
if (!model) return null;
|
||
const has = (list, v) => !!v && list.includes(v);
|
||
const secs = (d) => parseInt(d, 10) || 0;
|
||
const durations = model.durations || [], resolutions = model.resolutions || [], aspects = model.aspects || [];
|
||
const duration = has(durations, last.duration) ? last.duration : durations.includes("5s") ? "5s"
|
||
: durations.find((d) => secs(d) >= 4) || durations[0] || "5s";
|
||
const resolution = has(resolutions, last.resolution) ? last.resolution
|
||
: resolutions.find((r) => /^720p$/i.test(r)) || resolutions[0] || "";
|
||
const aspect = has(aspects, last.aspect) ? last.aspect : aspects.includes("9:16") ? "9:16" : aspects[0] || "";
|
||
const out = { duration, resolution, aspect };
|
||
if (model.audioConfigurable) out.audio = last.audio !== false;
|
||
return out;
|
||
}
|
||
|
||
// ---------- the conversation as the model reads it ----------
|
||
|
||
/**
|
||
* A conversation with pictures and voice notes in it, as plain messages for a text model. A photo or clip the
|
||
* character sent is in their own turn, as a stage direction ("*sends a photo: …*"), the way characters narrate what
|
||
* they do, so the model knows what it sent and who sent it (told in the person's turn, "you sent me a photo" read as
|
||
* the person's, and characters got confused about whose photo it was). Your photo is "[a photo: what it shows]", your
|
||
* voice note is the words you said.
|
||
*/
|
||
export function apiHistory(history) {
|
||
const out = [];
|
||
for (const m of history || []) {
|
||
if (!m || m.demo) continue;
|
||
const media = m.media || null;
|
||
if (m.role === "assistant") {
|
||
if (media && (media.kind === "photo" || media.kind === "clip")) {
|
||
const shot = clip(str(media.shot).trim(), 300).replace(/[.!?…\s]+$/, "");
|
||
const sent = `*sends ${media.kind === "clip" ? "a short video" : "a photo"}${shot ? ": " + shot : ""}*`;
|
||
const last = out.at(-1);
|
||
if (last && last.role === "assistant") last.content += "\n\n" + sent; // with the words it came after
|
||
else out.push({ role: "assistant", content: sent });
|
||
} else if (str(m.content).trim()) out.push({ role: "assistant", content: str(m.content) });
|
||
} else if (m.role === "user") {
|
||
let content = str(m.content);
|
||
if (media && media.kind === "photo-in") content = `[a photo${media.saw ? ": " + media.saw : ""}]` + (content ? " " + content : "");
|
||
out.push({ role: "user", content, ...(m.hidden ? { hidden: true } : {}) });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** the last thing the person said, with their photo attached for a model that can see */
|
||
export function withPicture(messages, dataUrl) {
|
||
if (!dataUrl) return messages;
|
||
const i = messages.map((m) => m.role).lastIndexOf("user");
|
||
if (i < 0) return messages;
|
||
const copy = messages.slice();
|
||
copy[i] = { role: "user", content: [{ type: "text", text: str(copy[i].content) || "[a photo]" }, { type: "image_url", image_url: { url: dataUrl } }] };
|
||
return copy;
|
||
}
|
||
|
||
/** every picture, voice note and clip a set of conversations still points at */
|
||
export function mediaIds(chats) {
|
||
const ids = new Set();
|
||
for (const hist of Object.values(chats || {})) for (const m of Array.isArray(hist) ? hist : []) {
|
||
if (m && m.media && m.media.id) ids.add(m.media.id);
|
||
if (m && m.voice && m.voice.id) ids.add(m.voice.id);
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
// ---------- on their own: a character sends a photo or a voice note when it fits, and offers a clip ----------
|
||
|
||
const moveSystem = (rules) => "You decide whether a character in a chat does something besides texting, right after " +
|
||
"the reply they just sent. Choose one move: \"photo\" (they send a photo of themselves because it fits: they were " +
|
||
"asked for one, or they mention where they are, what they're doing or wearing, or something worth showing), \"voice\" " +
|
||
"(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 + " " + 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);
|
||
|
||
export const MOVE_SCHEMA = {
|
||
type: "object", additionalProperties: false, required: ["move", "shot"],
|
||
properties: { move: { type: "string", enum: ["photo", "voice", "clip", "nothing"] }, shot: { type: "string" } },
|
||
};
|
||
|
||
/** the messages for a text model to decide the character's next move (adult: their pictures can be adult, adultCheck) */
|
||
/** the move prompt as it stands, or, for someone set to send things every round, told to lean in rather than hold back */
|
||
export const EAGER = " This character is set to send things every round: choose \"photo\" (or \"voice\" or \"clip\" when that fits better) " +
|
||
"whenever a picture of them could go with what was just said, however small the reason; \"nothing\" only when a photo would make no sense at all.";
|
||
export function moveMessages(card, history, { adult = false, eager = false } = {}) {
|
||
const lines = transcript(card, history, 12);
|
||
const look = card.look && card.look.description;
|
||
const system = (adult ? MOVE_SYSTEM_ADULT : MOVE_SYSTEM) + (eager ? EAGER : "");
|
||
return [{ role: "system", content: system }, { role: "user", content: [
|
||
`The character: ${card.name}${card.tagline ? ". " + card.tagline : ""}`,
|
||
look ? `How they look: ${look}` : "",
|
||
"The conversation, latest last (their reply just now is the last line):\n" + lines.join("\n"),
|
||
].filter(Boolean).join("\n\n") }];
|
||
}
|
||
|
||
/** a decision as { move, shot }; anything unclear is "nothing" */
|
||
export function normalizeMove(o) {
|
||
const move = ["photo", "voice", "clip", "nothing"].includes(o && o.move) ? o.move : "nothing";
|
||
const shot = cleanText(o && o.shot, 900);
|
||
return (move === "photo" || move === "clip") && !shot ? { move: "nothing", shot: "" } : { move, shot: move === "voice" || move === "nothing" ? "" : shot };
|
||
}
|
||
|
||
// words that make a move likely: asking to see or hear them, or a reply about where they are and what they're doing
|
||
const ASKS = /\b(pics?|picture|photo|selfie|snap|show me|see you|see it|what (?:do )?you look like|look like|wearing|outfit|send (?:me )?(?:a|one|it)|video|clip|film|hear you|your voice|say it|sing|whisper|out loud|aloud)\b/i;
|
||
const SHOWS = /\b(sends? (?:you )?(?:a |another )?(?:photo|pic|selfie|snap)|look at (?:this|my|the)|wearing|outfit|dress|view from|sunset|sunrise|my (?:room|place|desk|garden|kitchen|car|boat|bed|window)|just got (?:back|a|my)|i'?m (?:at|in|on) the|picture|photo|selfie|whisper)\b/i;
|
||
|
||
/**
|
||
* Whether to think about a move after this reply: when the person asked to see or hear them, when the reply talks
|
||
* about something worth showing, or now and then (every sixth reply).
|
||
*/
|
||
export function moveLikely(history, { every = 6 } = {}) {
|
||
const real = (history || []).filter((m) => m && !m.demo && !m.hidden);
|
||
const last = real.at(-1), said = [...real].reverse().find((m) => m.role === "user");
|
||
if (!last || last.role !== "assistant" || last.media) return false;
|
||
if (said && ASKS.test(str(said.content))) return true;
|
||
if (!every) return false; // never on their own: only when asked
|
||
if (SHOWS.test(str(last.content))) return true;
|
||
return real.filter((m) => m.role === "assistant" && !m.media).length % every === 0;
|
||
}
|
||
/** did they send a photo or clip of their own in the last few messages (so not another one yet) */
|
||
export function sentLately(history, gap = 4) {
|
||
return (history || []).filter((m) => m && !m.demo && !m.hidden).slice(-gap).some((m) => m.role === "assistant" && m.media && m.media.own);
|
||
}
|
||
|
||
// ---------- adult characters: their photos and clips ----------
|
||
|
||
// an age under 18, in figures or in words
|
||
const AGE = "(?:[1-9]|1[0-7]|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen)";
|
||
const AGE_FIGURES = "(?:[1-9]|1[0-7])";
|
||
// a number that isn't someone's age: "5 minutes away", "she's 5'4", "he's 6 foot", "turns 9 o'clock"
|
||
const NOT_AN_AGE = "(?![\\s-]*(?:minutes?|mins?|hours?|hrs?|days?|weeks?|months?|feet|foot|ft|inch(?:es)?|cm|met(?:er|re)s?|kg|" +
|
||
"kilos?|lbs?|pounds?|stones?|percent|%|dollars?|bucks|euros?|times|o['’]?clock|am|pm|[:.,]\\d|['’\"″′]))";
|
||
// someone young: an age under 18, a teenager, a minor, a school below college
|
||
const YOUNG = [
|
||
AGE + "[\\s-]*(?:years?|yrs?)[\\s-]*old",
|
||
AGE_FIGURES + "[\\s-]*y\\.?\\s?o",
|
||
"(?:aged?|age of)\\s+" + AGE + "\\b" + NOT_AN_AGE,
|
||
"(?:i['’]?m|i am|she['’]?s|she is|he['’]?s|he is|they['’]?re|they are|you['’]?re|you are|turned|turns|turning)\\s+" +
|
||
"(?:only\\s+|just\\s+)?" + AGE_FIGURES + "\\b" + NOT_AN_AGE,
|
||
"under[\\s-]?(?:age|aged|(?:18|eighteen)\\b" + NOT_AN_AGE + ")",
|
||
"minors",
|
||
"(?:a|is|am|are|was|still|being)\\s+minor\\b(?!\\s+(?:key|chord|details?|things?|issues?|problems?|inconvenience|roles?|" +
|
||
"injur\\w*|changes?|edits?|points?|characters?|league|scale|setbacks?|hiccups?|fix\\w*|bugs?|repairs?))",
|
||
"teens?", "teenagers?", "teenaged?", "pre-?teens?", "tweens?", "adolescen(?:t|ts|ce)", "(?:pre)?pubescent",
|
||
"loli(?:con|s)?", "shota(?:con|s)?", "jail-?bait", "school[\\s-]?(?:girls?|boys?|uniforms?)",
|
||
"(?:high|middle|junior high|elementary|grade|primary|prep)[\\s-]?school(?:ers?)?", "highschool(?:ers?)?",
|
||
"kindergart(?:en|ner)s?", "little (?:girls?|boys?)", "young (?:girls?|boys?)",
|
||
];
|
||
// and children: in a shot or a chat they're someone in the picture; on a card they're more often the character's own
|
||
const CHILDREN = ["child(?:ren)?", "kids?", "kiddos?", "toddlers?", "infants?", "school[\\s-]?(?:kids?|children)"];
|
||
const words = (list) => new RegExp("\\b(?:" + list.join("|") + ")\\b", "i");
|
||
/**
|
||
* Words that say someone in a picture is under 18. A shot or a chat with one in it gets no adult pictures: it errs on
|
||
* the safe side ("my kids are asleep" counts), and the page says which words it found.
|
||
*/
|
||
export const UNDERAGE = words([...YOUNG, ...CHILDREN]);
|
||
/** words that say a character is under 18 themselves (their card can mention their kids, or their childhood) */
|
||
export const UNDERAGE_CARD = words(YOUNG);
|
||
|
||
/** the first words in these texts that say someone is under 18 (re: UNDERAGE, or UNDERAGE_CARD for a card), or "" */
|
||
export function underage(texts, re = UNDERAGE) {
|
||
for (const t of [texts].flat(Infinity)) {
|
||
const m = str(t, 200000).match(re);
|
||
if (m) return m[0];
|
||
}
|
||
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]),
|
||
...(card.flow || []).flatMap((s) => [s.stage, s.goal, s.next])].filter(Boolean).join("\n");
|
||
|
||
/**
|
||
* Whether an adult character's photos and clips can be adult, and if not, why (a sentence for the page; "" for a
|
||
* character who isn't marked adult). They can when you've said you're 18 or older (of18), adult pictures are on (on),
|
||
* every picture on their card was made here with AI (made(picture): a picture someone uploaded could be of anyone), and
|
||
* nothing on their card, in the shot or in the last few messages (said) says anyone is under 18. drawn: a picture for
|
||
* their card drawn from words, made from none of the pictures on it, so those don't count.
|
||
*/
|
||
export function adultCheck(card, { of18 = false, on = true, made = () => false, shot = "", said = [], drawn = false } = {}) {
|
||
if (!card || !card.adult || card.plain) return { ok: false, why: "" };
|
||
const sfw = "so their photos and clips stay safe for work.";
|
||
if (!on) return { ok: false, why: "adult photos and clips are switched off under abilities." };
|
||
if (!of18) return { ok: false, why: "adult photos and clips wait until you say you're 18 or older, under abilities." };
|
||
const pics = drawn ? [] : [card.portrait, ...((card.look && card.look.references) || [])].filter(Boolean);
|
||
if (!drawn && !pics.length) return { ok: false, why: `${card.name} has no picture made here yet (card → look: make one with ai), ${sfw}` };
|
||
if (!pics.every((p) => made(p))) return { ok: false, why: `a picture on their card wasn't made here with ai (it was uploaded, or came from venice), ${sfw}` };
|
||
const onCard = underage(cardWords(card), UNDERAGE_CARD);
|
||
if (onCard) return { ok: false, why: `their card says “${onCard}”, which sounds like someone under 18, ${sfw}` };
|
||
const now = underage([shot, said]);
|
||
if (now) return { ok: false, why: `the shot or the chat says “${now}”, which sounds like someone under 18, so this one stays safe for work.` };
|
||
return { ok: true, why: "" };
|
||
}
|
||
|
||
/**
|
||
* A short fingerprint of a picture (the data: URI a card keeps it as), for the list of pictures this browser made with
|
||
* AI. It only has to tell this browser's pictures apart; it guards nothing secret.
|
||
*/
|
||
export function pictureKey(picture) {
|
||
const s = str(picture, 20000000);
|
||
let h1 = 0xdeadbeef ^ s.length, h2 = 0x41c6ce57 ^ s.length;
|
||
for (let i = 0; i < s.length; i++) {
|
||
const c = s.charCodeAt(i);
|
||
h1 = Math.imul(h1 ^ c, 2654435761);
|
||
h2 = Math.imul(h2 ^ c, 1597334677);
|
||
}
|
||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
||
return (h2 >>> 0).toString(16).padStart(8, "0") + (h1 >>> 0).toString(16).padStart(8, "0") + "-" + s.length.toString(36);
|
||
}
|
||
|
||
// ---------- the daily allowance for what they do on their own ----------
|
||
|
||
/** what was spent today, from a { day, usd } record; a new day starts at zero */
|
||
export function spentToday(rec, day) { return rec && rec.day === day ? Number(rec.usd) || 0 : 0; }
|
||
/** a record with usd more spent (or less, for a refund), never below zero */
|
||
export function spend(rec, day, usd) { return { day, usd: Math.max(0, Math.round((spentToday(rec, day) + (usd || 0)) * 100000) / 100000) }; }
|
||
/** a price that is known, and fits in what's left today */
|
||
export const canSpend = (rec, day, allowance, usd) => usd != null && Number.isFinite(usd) && usd >= 0 && spentToday(rec, day) + usd <= allowance + 1e-9;
|
||
|
||
/** what a text call costs, from Venice's usage and the model's prices per million tokens (unknown prices count high) */
|
||
export function callCost(usage, model) {
|
||
const inP = model && model.input != null ? model.input : 3, outP = model && model.output != null ? model.output : 15;
|
||
const u = usage || {}, inT = u.prompt_tokens ?? (u.total_tokens || 0), outT = u.completion_tokens ?? 0;
|
||
return (inT * inP + outT * outP) / 1e6;
|
||
}
|
||
/** what a text call will cost at most, before it's made: the messages' length, and the most it may write */
|
||
export function callEstimate(messages, maxTokens, model) {
|
||
const chars = (messages || []).reduce((n, m) => n + str(typeof m.content === "string" ? m.content : JSON.stringify(m.content)).length, 0);
|
||
return callCost({ prompt_tokens: Math.ceil(chars / 3) + 50, completion_tokens: maxTokens }, model);
|
||
}
|
||
|
||
// ---------- texting first when you come back ----------
|
||
|
||
export const BACK_AFTER = 6 * 3600e3;
|
||
/**
|
||
* What a character hears (and you don't see) when it's time to text you first after a while: `here` when you just
|
||
* opened their chat, else they reach out while you're somewhere else in Havre.
|
||
*/
|
||
export function comeBack(ms, here = true) {
|
||
const h = Math.round(ms / 3600e3);
|
||
const gap = h < 24 ? `about ${h} hours` : h < 48 ? "about a day" : `about ${Math.round(h / 24)} days`;
|
||
return `[Time passes: ${gap} since you last talked. ` + (here
|
||
? "They just came back to the chat. Text them first, in character, picking up naturally, in a line or two.]"
|
||
: "Reach out to them first, in character, the way you would after a while apart, in a line or two.]");
|
||
}
|
||
/** how long since the conversation's last message, if its time is known */
|
||
export function since(history, now) {
|
||
const last = [...(history || [])].reverse().find((m) => m && !m.demo && typeof m.at === "number");
|
||
return last ? now - last.at : 0;
|
||
}
|