From 1eab8ef60d4b243d8ead4456fa0cdb7b58e12130 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 16 Oct 2026 07:45:57 +0000 Subject: [PATCH] ventures: refresh havre from havre-next --- ventures/havre/abilities.js | 314 ++++++ ventures/havre/app.js | 1266 +++++++++++++++++++++-- ventures/havre/card.js | 19 +- ventures/havre/card.schema.json | 16 + ventures/havre/index.html | 68 +- ventures/havre/llms.txt | 3 +- ventures/havre/media.js | 89 ++ ventures/havre/shots/desktop.png | Bin 75683 -> 75584 bytes ventures/havre/skill.md | 36 +- ventures/havre/style.css | 53 +- ventures/havre/tests/abilities.test.mjs | 285 +++++ ventures/havre/tests/smoke.mjs | 320 +++++- ventures/havre/venice.js | 175 +++- 13 files changed, 2549 insertions(+), 95 deletions(-) create mode 100644 ventures/havre/abilities.js create mode 100644 ventures/havre/media.js create mode 100644 ventures/havre/tests/abilities.test.mjs diff --git a/ventures/havre/abilities.js b/ventures/havre/abilities.js new file mode 100644 index 0000000..5e2174c --- /dev/null +++ b/ventures/havre/abilities.js @@ -0,0 +1,314 @@ +// 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. +// 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"], +}; +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] || []); +} + +/** 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); + +/** 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 goes into a photo or a clip ---------- + +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."; + +export const PHOTO_SYSTEM = "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. " + SAFE + + " Reply with the shot only."; + +export const CLIP_SYSTEM = "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. " + SAFE + + " Reply with the shot only."; + +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 */ +export function shotMessages(card, history, ask = "", kind = "photo") { + const lines = transcript(card, history); + const look = card.look && card.look.description; + 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."), + `Write the shot for the ${kind === "clip" ? "clip" : "photo"} ${card.name} sends now.`, + ].filter(Boolean).join("\n\n"); + return [{ role: "system", content: kind === "clip" ? CLIP_SYSTEM : PHOTO_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(/[\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 can't push them out +const KEEP = "Safe for work: fully clothed, nothing sexual, no gore; everyone shown is an adult."; + +/** what the photo model is told: keep the character as the pictures show them, in the shot */ +export function photoPrompt(card, shot, pictures = 1, limit = 1500) { + const lead = pictures > 1 + ? `The reference pictures show ${card.name}. Keep how they look exactly: face, hair, build, clothes and style.` + : `The reference picture shows ${card.name}. Keep how they look exactly: face, hair, build, clothes and style.`; + return clip(`${lead} ${KEEP} New photo: ${str(shot).trim()} A natural photo with no text in it.`, limit); +} +export function clipPrompt(card, shot, limit = 2500) { + return clip(`The reference pictures show ${card.name}. Keep how they look exactly. ${KEEP} ${str(shot).trim()}`, limit); +} +/** a first portrait, when a character has no picture at all */ +export function portraitPrompt(card, limit = 1500) { + const look = ((card.look && card.look.description) || card.tagline || "an original character").trim().replace(/[.!?…]+$/, ""); + return clip(`Portrait photo of ${card.name}, an original adult character: ${look}. ${KEEP} Head and shoulders, soft light, ` + + "plain background, no text.", limit); +} + +/** 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(/[\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 mentioned in the next thing the person says ("(You sent me a photo: …)"), so the model knows + * about it without learning to write "[photo]" itself. Your photo is "[a photo: what it shows]", your voice note is + * the words you said. + */ +export function apiHistory(history) { + const out = []; + let notes = []; + 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]+$/, ""); + notes.push(`(You sent me ${media.kind === "clip" ? "a short video" : "a photo"}${shot ? ": " + shot : ""}.)`); + } 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 : ""); + if (notes.length) { content = notes.join(" ") + (content ? "\n\n" + content : ""); notes = []; } + 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 ---------- + +export const MOVE_SYSTEM = "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. " + SAFE + + " Reply with one JSON object: {\"move\": \"photo\" | \"voice\" | \"clip\" | \"nothing\", \"shot\": \"...\"}."; + +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 */ +export function moveMessages(card, history) { + const lines = transcript(card, history, 12); + const look = card.look && card.look.description; + return [{ role: "system", content: MOVE_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(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))) || 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); +} + +// ---------- 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 you come back after a while: text them first */ +export function comeBack(ms) { + 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. They just came back to the chat. Text them first, in character, ` + + "picking up naturally, 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; +} diff --git a/ventures/havre/app.js b/ventures/havre/app.js index 3b5aff6..867c9f6 100644 --- a/ventures/havre/app.js +++ b/ventures/havre/app.js @@ -1,12 +1,16 @@ // app.js: Havre. Everything runs in this page. The only address it talks to is api.venice.ai, with the visitor's own // key, and the page's security policy enforces that. import * as C from "./card.js"; -import { Venice } from "./venice.js"; +import * as A from "./abilities.js"; +import * as M from "./media.js"; +import { Venice, VeniceError } from "./venice.js"; const $ = (s, el = document) => el.querySelector(s); const $$ = (s, el = document) => [...el.querySelectorAll(s)]; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const today = () => new Date().toISOString().slice(0, 10); +/** an id for a message, so two tabs can tell which messages they both have */ +const mid = () => M.newId().replace(/-/g, "").slice(0, 16); const still = matchMedia("(prefers-reduced-motion: reduce)").matches; const narrow = () => matchMedia("(max-width: 1040px)").matches; const motion = () => (still ? "auto" : "smooth"); @@ -39,7 +43,8 @@ function box(kind) { const local = box("localStorage"), session = box("sessionStorage"); const K = { cards: "havre.cards.v1", chats: "havre.chats.v1", sel: "havre.selected", key: "havre.key", model: "havre.model", sort: "havre.vsort", seeded: "havre.seeded.v1", adult: "havre.adult", adult18: "havre.adult.18", plainModel: "havre.plain.model", - own: "havre.venice.models" }; + own: "havre.venice.models", ab: "havre.abilities", voiceOn: "havre.voicenotes", jobs: "havre.clips.pending", refOk: "havre.refs.ok", + spent: "havre.spent", cleared: "havre.chats.cleared", backs: "havre.comebacks", jobsDone: "havre.clips.done" }; const PAGE = 24; const PLAIN = "_plain"; // a plain chat with a model: no character, no card const SORTS = ["featured", "imports", "highestRating", "mostRecent"]; @@ -50,7 +55,28 @@ const S = { cards: [], sel: "", chats: {}, key: "", keyRemembered: false, model: C.DEFAULT_MODEL, models: null, modelsError: "", replying: null, demo: {}, rx: null, plain: null, own: {}, v: { list: [], offset: 0, more: false, query: "", sort: "featured", 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: {}, + ab: { photo: "", clip: {}, hear: false, hearModel: "", see: false, seeModel: "", own: true, allowance: 0.25 }, voiceOn: {}, jobs: [], ask: null, + rec: null, moving: false, inflight: 0, }; +// what characters do on their own (photos, voice notes) comes out of a daily allowance, counted here by local day +const ALLOWANCES = [0.1, 0.25, 1, 5]; +const localDay = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; }; +const spentToday = () => A.spentToday(local.get(K.spent, null), localDay()); +const canAfford = (usd) => A.canSpend(local.get(K.spent, null), localDay(), S.ab.allowance, usd); +function charge(usd) { local.set(K.spent, A.spend(local.get(K.spent, null), localDay(), usd)); if ($("#abilitydlg").open) renderAbilities(); renderTools(); } +/** + * Set aside money from today's allowance before something is made on their own, so nothing else can spend it + * meanwhile; false if it doesn't fit (or the price isn't known). What isn't spent goes back with refund(). + */ +function reserve(usd) { + if (!S.ab.own || !canAfford(usd)) return false; + charge(usd); + return true; +} +function refund(usd) { if (usd > 0) charge(-usd); } const card = () => (S.sel === PLAIN ? S.plain : S.cards.find((c) => c.id === S.sel) || S.cards[0] || null); // ---------- boot ---------- @@ -71,10 +97,16 @@ function boot() { S.v.sort = SORTS.includes(sort) ? sort : "featured"; S.v.adult = local.get(K.adult, false) === true && local.get(K.adult18, false) === true; 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 : {}; + if (!ALLOWANCES.includes(S.ab.allowance)) S.ab.allowance = 0.25; + S.voiceOn = local.get(K.voiceOn, {}) || {}; + S.jobs = (local.get(K.jobs, []) || []).filter((j) => j && j.queueId && j.model && Date.now() - (j.started || 0) < 23 * 3600e3); bind(); renderAll(); loadModels(); - if (S.key) { loadVenice(0); loadBalance(); } + if (S.key) { loadVenice(0); loadBalance(); resumeJobs(); comeBack(card()); } + setTimeout(sweepMedia, 4000); } // The first version of Havre arrived with three characters of its own. This one has none, so they leave, unless @@ -100,11 +132,57 @@ function persistCards(now = false) { if (now) go(); else saveTimer = setTimeout(go, 250); } function persistChats() { + mergeChats(local.get(K.chats, {})); // what another tab saved meanwhile stays for (const id of Object.keys(S.chats)) { if (id !== PLAIN && !S.cards.some((c) => c.id === id)) delete S.chats[id]; - else if (S.chats[id].length > 80) S.chats[id] = S.chats[id].slice(-80); + else if (S.chats[id].length > 80) S.chats[id].splice(0, S.chats[id].length - 80); // in place: a reply on its way holds this array } - local.set(K.chats, S.chats); + if (!local.set(K.chats, S.chats)) full(); + clearTimeout(sweepTimer); + sweepTimer = setTimeout(sweepMedia, 2000); +} +let fullSaid = 0; +function full() { + if (Date.now() - fullSaid < 60000) return; + fullSaid = Date.now(); + toast("This browser won't hold more. Remove a character's pictures, or start a conversation over, to make room."); +} +/** + * Messages another tab saved that this one doesn't have: added in place (by id) and put in time order, so a reply + * on its way still lands in the same conversation. A conversation started over elsewhere is started over here too. + * Returns whether anything changed. + */ +function mergeChats(stored) { + const cut = local.get(K.cleared, {}) || {}; + let changed = false; + for (const [id, theirs] of Object.entries(stored || {})) { + if (!Array.isArray(theirs) || (id !== PLAIN && !S.cards.some((c) => c.id === id))) continue; + const mine = S.chats[id] || (S.chats[id] = []); + const have = new Map(mine.filter((m) => m && m.id).map((m) => [m.id, m])); + let added = false; + for (const m of theirs) { + if (!m || !m.id || takenBack.has(m.id) || (m.at || 0) <= (cut[id] || 0)) continue; + const mineToo = have.get(m.id); + if (!mineToo) { mine.push(m); added = true; } + else if (m.voice && !mineToo.voice) { mineToo.voice = m.voice; changed = true; } + } + if (added) { mine.sort((a, b) => (a.at || 0) - (b.at || 0)); changed = true; } + } + for (const [id, at] of Object.entries(cut)) { + const mine = S.chats[id]; + if (!Array.isArray(mine)) continue; + for (let i = mine.length - 1; i >= 0; i--) if ((mine[i].at || 0) <= at) { mine.splice(i, 1); changed = true; } + } + return changed; +} +/** messages this tab took back (a hello that didn't happen): not brought back from another tab's copy */ +const takenBack = new Set(); +// photos, voice notes and clips that no conversation points at any more leave this browser too +let sweepTimer = 0; +function sweepMedia() { + // what this tab holds and what another tab may have saved since: both stay + const keep = new Set([...A.mediaIds(S.chats), ...A.mediaIds(local.get(K.chats, {}) || {})]); + M.keepOnly(keep).catch(() => {}); } function uniqueId(base) { let id = base, n = 2; @@ -131,14 +209,27 @@ const OUTERFACE = /^https:\/\/outerface\.venice\.ai\//; function veniceImg(url, w = 384) { return OUTERFACE.test(url || "") ? `https://venice.ai/_next/image?url=${encodeURIComponent(url)}&w=${w}&q=75` : url || ""; } -function portraitSrc(c) { - const p = (c && c.portrait) || ""; +/** a picture on a card, as this page shows it */ +function pictureSrc(p = "") { if (/^data:image\/(png|jpe?g|webp|gif|svg\+xml)[;,]/i.test(p)) return p; if (OUTERFACE.test(p)) return veniceImg(p); if (p.startsWith(C.SITE)) return p.slice(C.SITE.length); if (p && !/^[a-z][a-z0-9+.-]*:/i.test(p) && !p.startsWith("//")) return p; return ""; } +const portraitSrc = (c) => pictureSrc((c && c.portrait) || ""); +/** + * A picture on a card, as Venice can take it for a photo or a clip. Only pictures this page shows you are used: one + * kept in the card itself (a data: URI), or a Venice character's own photo. A link to anywhere else stays an empty + * frame, and nothing is made from it. + */ +function apiPicture(p = "") { + if (/^data:image\/(png|jpe?g|webp|gif);base64,/i.test(p)) return p; + if (OUTERFACE.test(p)) return veniceImg(p, 1080); + return ""; +} +/** what a photo or a clip of them is made from: their portrait, then the pictures on their card */ +const refsOf = (c) => (c ? [c.portrait, ...c.look.references].map(apiPicture).filter(Boolean) : []); /** a photo in a frame; no photo is an empty frame with an X through it */ function paintFace(el, src) { el.textContent = ""; @@ -271,6 +362,8 @@ function renderRoster() { function selectCard(id, { talk = false, scroll = false } = {}) { if (S.replying) { S.replying.abort(); S.replying = null; setBusy(false); } + if (S.ask && !S.ask.busy && S.ask.card !== id) S.ask = null; + if (S.rec) stopRecording(true); S.sel = id; local.set(K.sel, id); markTiles(); renderRoster(); renderChat(); renderCard(); @@ -283,9 +376,25 @@ function selectCard(id, { talk = false, scroll = false } = {}) { // a Venice character, or one of yours with no greeting written, says hello first function speakFirst() { const c = card(); - if (!c || c.plain || !S.key || S.replying || (S.chats[c.id] || []).length) return; + if (!c || c.plain || !S.key || S.replying) return; + if ((S.chats[c.id] || []).length) { comeBack(c); return; } if (c.engine.venice_character || (!c.greeting && c.persona)) send(OPENER, { hidden: true }); } +/** + * You come back after a while, and they text first (when characters act on their own). Once per long gap, whatever + * happens, in whichever tab gets there first: an attempt is written down before it's made. + */ +function comeBack(c) { + if (!c || c.plain || !S.key || !S.ab.own || S.replying) return; + const go = () => { + const hist = S.chats[c.id] || [], gap = A.since(hist, Date.now()), backs = local.get(K.backs, {}) || {}; + if (!hist.length || gap < A.BACK_AFTER || (hist.at(-1) || {}).hidden || Date.now() - (backs[c.id] || 0) < A.BACK_AFTER) return; + if (!local.set(K.backs, { ...backs, [c.id]: Date.now() })) return; // can't write it down: don't risk asking twice + if ((card() || {}).id === c.id && !S.replying) send(A.comeBack(gap), { hidden: true }); + }; + if (navigator.locks && navigator.locks.request) navigator.locks.request("havre-comeback", go).catch(() => {}); + else go(); +} // ---------- the conversation ---------- /** characters from Venice, and the plain chat, need a key to say anything */ @@ -304,6 +413,8 @@ function renderChat() { : needsKey(c) ? `paste your venice key above to talk to ${c.name}.` : "no key yet: replies come from the card's own examples. paste your venice key above to really talk."; renderLog(); + renderTools(); + renderAsk(); } function renderChatMeta() { const c = card(), meta = $("#who-meta"); @@ -311,7 +422,7 @@ function renderChatMeta() { if (!c) return; meta.append(...slashed([ c.plain ? "just the model, no character" : c.engine.venice_character ? "via venice, with their own prompt" : c.tagline, - h("span", {}, "model: ", linkish(modelName(c.model), openModels, { id: "model-btn", "aria-label": `Model: ${modelName(c.model)}. Change it` })), + h("span", {}, "model: ", linkish(modelName(c.model), () => openModels("chat"), { id: "model-btn", "aria-label": `Model: ${modelName(c.model)}. Change it` })), c.plain ? null : linkish("card", () => openCard("edit")), linkish("start over", startOver, { title: "Start this conversation over" }), ])); @@ -320,12 +431,15 @@ function startOver() { const c = card(); if (!c) return; if (S.replying) { S.replying.abort(); S.replying = null; setBusy(false); } + local.set(K.cleared, { ...(local.get(K.cleared, {}) || {}), [c.id]: Date.now() }); // other tabs start over too delete S.chats[c.id]; S.demo[c.id] = 0; persistChats(); renderLog(); speakFirst(); } +/** a word under the text box: what failed, what is on its way */ +function err(text) { put(line(text, "havre", "err")); scrollLog(); } /** one line of the script: who says it, then what they say. kind: note, err, typing or suggest */ function line(text, who, kind = "") { @@ -368,8 +482,14 @@ function renderLog() { if (!hist.length) put(line(`${c.name} speaks through venice, with their own prompt.${S.key ? " say hello." : ""}`, "havre", "note")); } else if (c.greeting) for (const b of C.splitBubbles(C.fill(c.greeting, c, "you"))) put(line(b, "them")); for (const m of hist) { - if (m.role === "user" && !m.hidden) put(line(m.content, "me")); - else if (m.role === "assistant") for (const b of bubblesOf(c, m.content)) put(line(b, "them")); + if (m.media) put(mediaLine(c, m)); + else if (m.role === "user" && !m.hidden) put(line(m.content, "me")); + else if (m.role === "assistant") { + if (m.voice) put(voiceLine(c, m)); + let last = null; + for (const b of bubblesOf(c, m.content)) { last = put(line(b, "them")); if (m.id) last.dataset.msg = m.id; } + if (last) offerVoice(last, c, m); + } } const ideas = hist.some((m) => m.role === "user" && !m.hidden) ? [] : C.suggestions(c); if (ideas.length) { @@ -378,30 +498,39 @@ function renderLog() { row.querySelector(".said").append(...ideas.map((t) => linkish(t, () => send(t)))); put(row); } + renderJobs(); scrollLog(); } /** a character texts in short bubbles; a plain chat answers in one piece */ const bubblesOf = (c, text) => (c.plain ? [C.stripThinking(text)].filter(Boolean) : C.splitBubbles(text)); -const plainMessages = (hist) => hist.filter((m) => !m.demo && (m.role === "user" || m.role === "assistant")).slice(-40) - .map((m) => ({ role: m.role, content: m.content })); +/** what the model reads: the conversation, with photos and voice notes as words (see abilities.js) */ +function messagesFor(c, hist) { + const api = A.apiHistory(hist); + return c.plain ? api.slice(-40).map(({ role, content }) => ({ role, content })) : C.chatMessages(c, api); +} function setBusy(on) { const c = card(); $("#sendbtn").disabled = on || !c || (needsKey(c) && !S.key); $("#composer").setAttribute("aria-busy", on ? "true" : "false"); } -async function send(text, { hidden = false } = {}) { +/** + * Say something, and play the reply. media: a voice note or photo of yours that goes with it ({ kind, id, … }); + * picture: that photo as a data: URI, for a model that can see it. + */ +async function send(text, { hidden = false, media = null, picture = "" } = {}) { const c = card(); text = text.trim(); - if (!text || !c || S.replying) return; + if ((!text && !media) || !c || S.replying) return; const demo = !S.key; - if (demo && hidden) return; + if (demo && (hidden || media)) return; const hist = (S.chats[c.id] = S.chats[c.id] || []); - hist.push({ role: "user", content: text, ...(demo ? { demo: true } : {}), ...(hidden ? { hidden: true } : {}) }); + const said = { id: mid(), role: "user", content: text, at: Date.now(), ...(demo ? { demo: true } : {}), ...(hidden ? { hidden: true } : {}), ...(media ? { media } : {}) }; + hist.push(said); persistChats(); for (const el of $$("#log .suggest, #log .note")) el.remove(); - if (!hidden) put(line(text, "me")); + if (!hidden) put(media ? mediaLine(c, said) : line(text, "me")); const ctrl = new AbortController(); S.replying = ctrl; setBusy(true); @@ -419,16 +548,45 @@ async function send(text, { hidden = false } = {}) { } else { const r = await new Venice(S.key).chat({ model: c.model || S.model, characterSlug: c.engine.venice_character || "", veniceSystemPrompt: !!c.plain, maxTokens: c.plain ? 2000 : 700, - messages: c.plain ? plainMessages(hist) : C.chatMessages(c, hist.filter((m) => !m.demo)), signal: ctrl.signal }); + messages: A.withPicture(messagesFor(c, hist), picture), signal: ctrl.signal }); reply = C.stripThinking(r.text); if (!reply) throw new Error("Venice sent back an empty reply. Try again, or pick another model on the card."); } if (ctrl.signal.aborted) return; - hist.push({ role: "assistant", content: reply, ...(demo ? { demo: true } : {}) }); + const msg = { id: mid(), role: "assistant", content: reply, at: Date.now(), ...(demo ? { demo: true } : {}) }; + hist.push(msg); persistChats(); typing.remove(); if (!demo) balanceSoon(); - await play(bubblesOf(c, reply), ctrl.signal); + // a voice note with the reply: every reply when they're set to, and, on their own, one back to yours (priced + // first, and set aside from today's allowance) + let mirror = 0; + if (!demo && !c.plain && !S.voiceOn[c.id] && S.ab.own && media && media.kind === "voice-in") { + await loadList("tts"); + const vm = voiceModel(c), price = vm ? A.voicePrice(vm, A.speakable(reply).length) : null; + if (price != null && reserve(price)) mirror = price; + } + if (!demo && !c.plain && (S.voiceOn[c.id] || mirror)) { + // a voice note first, then the words under it + const rec = put(line("is recording a voice note…", "them", "typing")); + scrollLog(); + voicing.add(msg); + try { + msg.voice = await makeVoice(c, reply, ctrl.signal); + if (mirror) msg.voice.own = true; + persistChats(); + } catch (e) { + refund(mirror); + if (e && e.name === "AbortError") return; + msg.voiceError = e.message; + } finally { rec.remove(); voicing.delete(msg); } + if (ctrl.signal.aborted) return; + if (msg.voice) put(voiceLine(c, msg)); + else err(`the voice note didn't come: ${msg.voiceError}`); + delete msg.voiceError; + } + await play(bubblesOf(c, reply), ctrl.signal, c, msg); + if (!ctrl.signal.aborted && !demo && !c.plain && !hidden && !msg.voice) onTheirOwn(c); } catch (e) { if (e && e.name === "AbortError") return; typing.remove(); @@ -436,14 +594,15 @@ async function send(text, { hidden = false } = {}) { } finally { typing.remove(); const last = hist[hist.length - 1]; - if (hidden && last && last.hidden) { hist.pop(); persistChats(); } // the hello didn't happen: they can try again + if (hidden && last && last.hidden) { takenBack.add(last.id); hist.pop(); persistChats(); } // the hello didn't happen if (S.replying === ctrl) S.replying = null; setBusy(false); scrollLog(); } } -async function play(bubbles, signal) { +async function play(bubbles, signal, c, msg) { + let last = null; for (let i = 0; i < bubbles.length; i++) { if (signal.aborted) return; if (i > 0) { @@ -453,9 +612,115 @@ async function play(bubbles, signal) { t.remove(); if (signal.aborted) return; } - put(line(bubbles[i], "them")); + last = put(line(bubbles[i], "them")); + if (msg && msg.id) last.dataset.msg = msg.id; scrollLog(); } + if (last && c && msg) offerVoice(last, c, msg); +} + +// ---------- photos, clips, voice notes, and yours, in the conversation ---------- +const clock = (ms) => { const s = Math.max(0, Math.round(ms / 1000)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; }; +const EXT = { "image/webp": "webp", "image/png": "png", "image/jpeg": "jpg", "video/mp4": "mp4", "video/webm": "webm", "audio/mpeg": "mp3", + "audio/mp3": "mp3", "audio/wav": "wav", "audio/x-wav": "wav", "audio/aac": "aac", "audio/flac": "flac", "audio/opus": "opus", "audio/webm": "webm", "audio/ogg": "ogg", "audio/mp4": "m4a" }; +/** a link that saves the file, once this browser has it */ +function saveLink(id, base) { + const a = h("a", { text: "save", download: base }); + M.get(id).then((b) => { if (!b) { a.remove(); return; } a.download = `${base}.${EXT[(b.type || "").split(";")[0]] || "bin"}`; return M.url(id).then((u) => { a.href = u; }); }); + return a; +} +const gone = (what) => h("span", { class: "gone", text: `(this ${what} isn't kept in this browser any more)` }); +/** a photo, video or sound from this browser's store; what was cleared shows as gone */ +function mediaEl(tag, id, attrs, what) { + const el = h(tag, attrs); + // a picture that arrives after the conversation scrolled to its end keeps it at the end + const log = $("#log"), atEnd = () => log.scrollHeight - log.scrollTop - log.clientHeight < 40; + let was = true; + M.url(id).then((u) => { if (!u) { el.replaceWith(gone(what)); return; } was = atEnd(); el.src = u; }); + el.addEventListener(tag === "img" ? "load" : "loadedmetadata", () => { if (was) scrollLog(); }, { once: true }); + return el; +} +const audioEl = (id) => mediaEl("audio", id, { controls: true, preload: "metadata" }, "voice note"); +function mediaLine(c, m) { + const me = m.role === "user", md = m.media || {}; + const said = h("div", { class: "said" }); + const base = `${C.slugify(c.name)}-${md.kind || "file"}`; + const cost = (bits) => h("span", { class: "cap" }, bits.filter(Boolean).join(" · ") + " · ", saveLink(md.id, base)); + if (md.kind === "photo" || md.kind === "photo-in") { + const alt = md.kind === "photo" ? `A photo from ${c.name}${md.shot ? ": " + md.shot : ""}` : "Your photo"; + said.append(h("figure", { class: "pic" }, mediaEl("img", md.id, { alt, title: md.shot || null, decoding: "async" }, "photo"))); + if (md.kind === "photo") said.append(cost(["photo", md.model, md.price != null && A.usd(md.price), md.own && "on their own", md.blurred && "blurred by venice's safe mode"])); + else if (md.saw) said.append(h("span", { class: "cap", text: `${md.seer || "a model"} told them: ${md.saw}` })); + } else if (md.kind === "clip" && !md.id) { + // a clip this browser couldn't download: it plays from Venice's link until the link lapses + const live = md.url && Date.now() < (md.until || 0) && /^https:\/\/private-share\.venice\.ai\//.test(md.url); + if (!live) said.append(gone("clip")); + else said.append(h("video", { controls: true, playsinline: true, preload: "metadata", src: md.url, title: md.shot || null }), + h("span", { class: "cap" }, ["clip", md.model, md.price != null && A.usd(md.price), "venice keeps it for a day"].filter(Boolean).join(" · ") + " · ", + h("a", { href: md.url, target: "_blank", rel: "noopener noreferrer", text: "open it to save it" }))); + } else if (md.kind === "clip") { + said.append(mediaEl("video", md.id, { controls: true, playsinline: true, preload: "metadata", title: md.shot || null }, "clip")); + said.append(cost(["clip", md.model, md.price != null && A.usd(md.price)])); + } else if (md.kind === "voice-in") { + said.append(audioEl(md.id)); + } + if (m.content) said.append(h("div", { class: "words", text: m.content })); + return h("li", { class: `line ${me ? "me" : "them"} media`, "data-who": me ? "me" : "them" }, + h("span", { class: "who", text: me ? "you" : c.plain ? modelName(c.model) : c.name }), said); +} +/** a reply's voice note, above its words */ +function voiceLine(c, m) { + const v = m.voice; + return h("li", { class: "line them media", "data-who": "them" }, h("span", { class: "who", text: c.name }), + h("div", { class: "said" }, audioEl(v.id), h("span", { class: "cap" }, ["voice note", v.voice, v.price != null && A.usd(v.price), v.own && "on their own"].filter(Boolean).join(" · ") + " · ", saveLink(v.id, `${C.slugify(c.name)}-voice-note`)))); +} +/** "hear it" after a reply that came as text: its voice note, made when asked for (once, however often it's clicked) */ +const voicing = new WeakSet(); +function offerVoice(el, c, m) { + if (m.voice || m.demo || c.plain || !S.key) return; + const b = linkish(voicing.has(m) ? "recording…" : "hear it", async () => { + if (b.disabled || voicing.has(m) || m.voice) return; + voicing.add(m); + b.disabled = true; + b.textContent = "recording…"; + try { + m.voice = await makeVoice(c, m.content); + persistChats(); + if (b.isConnected) b.replaceWith(audioEl(m.voice.id)); + else placeVoice(c, m); + balanceSoon(); + } catch (e) { b.disabled = false; b.textContent = "hear it"; toast(e.message); } finally { voicing.delete(m); } + }, { class: "linkish hear", "aria-label": `Hear this as a voice note from ${c.name}`, disabled: voicing.has(m) || null }); + $(".said", el).append(" ", b); +} +/** a voice note of these words, in the character's voice and mood */ +async function makeVoice(c, text, signal) { + S.inflight++; + try { + await loadList("tts"); + const vm = voiceModel(c); + if (!vm) throw new Error(S.listErr.tts || "Venice has no voice models right now."); + const voice = voiceOf(c, vm), words = A.speakable(text); + if (!words) throw new Error("There are no words to say."); + const blob = await new Venice(S.key).speak({ model: vm.id, voice, input: words, mood: c.speech.mood, speed: c.speech.speed, signal }); + return { id: await M.put(blob), model: vm.name, voice, price: A.voicePrice(vm, words.length) }; + } finally { S.inflight--; } +} +const voiceOf = (c, vm) => (vm.voices.includes(c.speech.voice) ? c.speech.voice : vm.voices.includes(A.DEFAULT_VOICE) ? A.DEFAULT_VOICE : vm.voices[0] || c.speech.voice || ""); +/** a photo or clip from a character lands in their conversation, wherever you are now */ +function deliver(c, msg) { + if (!S.cards.some((x) => x.id === c.id)) return; // they left the harbor meanwhile + msg.at = msg.at || Date.now(); + msg.id = msg.id || mid(); + (S.chats[c.id] = S.chats[c.id] || []).push(msg); + persistChats(); + const cur = card(); + if (cur && cur.id === c.id) { + for (const el of $$("#log .suggest, #log .note")) el.remove(); + put(mediaLine(c, msg)); + renderJobs(); + scrollLog(); + } else toast(`${c.name} sent you ${msg.media.kind === "clip" ? "a clip" : "a photo"}.`); } // ---------- the card ---------- @@ -511,52 +776,152 @@ function loadModels() { } const modelName = (id) => { const m = S.models && S.models.find((x) => x.id === id); return m ? m.name : id || "?"; }; -// ---------- the model picker ---------- +// ---------- Venice's other models: photos, clips, voices, hearing ---------- +const listAsked = {}; +/** a list of Venice's models of one type, asked for once (no key needed) */ +function loadList(type) { + if (type === "text") return loadModels(); + if (S.lists[type]) return Promise.resolve(S.lists[type]); + if (!listAsked[type]) { + listAsked[type] = new Venice("").mediaModels(type).then((ms) => { S.lists[type] = ms; S.listErr[type] = ""; listsChanged(); return ms; }) + .catch((e) => { S.listErr[type] = e.message; listAsked[type] = null; listsChanged(); return null; }); + } + return listAsked[type]; +} +function listsChanged() { + if ($("#modeldlg").open) renderModels(); + if ($("#abilitydlg").open) renderAbilities(); + renderSpeech(); + const a = S.ask; + if (a && !a.busy) { + renderAsk(); + if (a.kind === "clip" && a.shot && a.quote == null && !a.quoting) requote(); + } +} +const photoModel = () => A.chosen(S.lists.inpaint, S.ab.photo, "photo"); +const portraitModel = () => A.chosen(S.lists.image, "", "portrait"); +const clipModels = () => (S.lists.video ? S.lists.video.filter(A.isClipModel) : null); +const clipModel = () => A.chosen(clipModels(), S.ab.clip.model, "clip"); +const voiceModel = (c) => A.chosen(S.lists.tts, c && c.speech ? c.speech.model : "", "voice"); +const hearModel = () => A.chosen(S.lists.asr, S.ab.hearModel, "hearing"); +const seeModel = () => A.seer(S.models, S.ab.seeModel); +function saveAb() { local.set(K.ab, S.ab); } + +// ---------- the model picker, for every kind of model ---------- /** the model a Venice character uses on Venice itself, as Havre last saw it */ const ownModel = (c) => (c && c.engine.venice_character && S.own[c.engine.venice_character]) || ""; -const usd = (n) => (n == null ? "?" : "$" + (n >= 10 ? n.toFixed(0) : n >= 1 ? n.toFixed(2).replace(/\.?0+$/, "") : n.toFixed(2))); -function openModels() { +const TEXT_NOTE = "private: what you send is used for the answer and not kept. anonymized: venice passes it to the model's maker without saying who you are, and they can read it."; +const KINDS = { + chat: { title: "MODEL", type: "text", head: ["MODEL", "PRIVACY", "CAN", "$ IN / OUT", "CONTEXT"], search: "search models: a name, private, uncensored…", + note: TEXT_NOTE + " prices are per million tokens." }, + seeing: { title: "SEEING MODEL", type: "text", head: ["MODEL", "PRIVACY", "CAN", "$ IN / OUT", "CONTEXT"], search: "search models that can see…", + note: "describes your photo for a character whose own model can't see. only models that can see are here. " + TEXT_NOTE }, + photo: { title: "PHOTO MODEL", type: "inpaint", head: ["MODEL", "PRIVACY", "PICTURES", "$ A PHOTO"], search: "search photo models…", + note: "a photo is an edit of the character's own pictures, with venice's safe mode on. " + TEXT_NOTE }, + clip: { title: "CLIP MODEL", type: "video", head: ["MODEL", "PRIVACY", "LENGTHS", "SIZES", "SOUND"], search: "search clip models…", + note: "reference-to-video models: they keep the character the way their pictures show them. venice prices each clip before it's made. " + TEXT_NOTE }, + voice: { title: "VOICE MODEL", type: "tts", head: ["MODEL", "PRIVACY", "VOICES", "$ A MILLION CHARACTERS"], search: "search voice models…", + note: "a reply of 200 characters costs 200 millionths of the price. qwen 3 voices take the mood; the others ignore it. " + TEXT_NOTE }, + hearing: { title: "HEARING MODEL", type: "asr", head: ["MODEL", "PRIVACY", "$ A MINUTE"], search: "search hearing models…", + note: "turns your voice note into words for the character. " + TEXT_NOTE }, +}; +let pickKind = "chat"; +/** the models a kind picks from, or null while Venice is still being asked */ +function kindList(kind) { + if (kind === "chat") return S.models; + if (kind === "seeing") return S.models && S.models.filter((m) => m.vision); + if (kind === "clip") return clipModels(); + return S.lists[KINDS[kind].type]; +} +/** the model of this kind in use now */ +function kindModel(kind) { + const c = card(), m = { photo: photoModel, clip: clipModel, hearing: hearModel, seeing: seeModel }[kind]; + if (m) return m(); + if (kind === "voice") return voiceModel(c); + return c ? { id: c.model } : null; +} +function openModels(kind = "chat") { const c = card(); - if (!c) return; + if (!KINDS[kind] || ((kind === "chat" || kind === "voice") && (!c || (kind === "voice" && c.plain)))) return; + pickKind = kind; + const k = KINDS[kind]; const own = ownModel(c); - $("#model-for").textContent = c.plain ? "for the plain chat." : `for ${c.name}.` + (own ? ` their own on venice: ${modelName(own)}.` : ""); + $("#model-h").textContent = k.title; + $("#msearch").placeholder = k.search; + $("#mnote").textContent = k.note; + $("#model-for").textContent = kind === "chat" ? (c.plain ? "for the plain chat." : `for ${c.name}.` + (own ? ` their own on venice: ${modelName(own)}.` : "")) + : kind === "voice" ? `for ${c.name}'s voice notes.` : kind === "photo" ? "for photos from every character." + : kind === "clip" ? "for clips from every character." : kind === "hearing" ? "for hearing your voice notes." + : "for describing your photos to a character whose model can't see."; $("#msearch").value = ""; - $("#modeldlg").showModal(); + const head = $("#mhead"); + head.textContent = ""; + head.append(h("tr", {}, k.head.map((t) => h("th", { scope: "col", text: t })))); + if (!$("#modeldlg").open) $("#modeldlg").showModal(); renderModels(); - loadModels(); + loadList(k.type); } function renderModels() { - const c = card(), tb = $("#mlist"), q = $("#msearch").value.trim().toLowerCase(); + const kind = pickKind, k = KINDS[kind], c = card(), tb = $("#mlist"), q = $("#msearch").value.trim().toLowerCase(); tb.textContent = ""; - if (!c) return; - if (!S.models) { - tb.append(h("tr", {}, h("td", { colspan: "5", text: S.modelsError || "asking venice for its models…" }))); + const cols = String(k.head.length), list0 = kindList(kind); + if (!list0) { + tb.append(h("tr", {}, h("td", { colspan: cols, text: (k.type === "text" ? S.modelsError : S.listErr[k.type]) || "asking venice for its models…" }))); return; } - const own = ownModel(c); - const rank = (m) => (m.id === c.model ? 2 : 0) + (m.id === own ? 1 : 0); - const list = S.models.filter((m) => !q || `${m.id} ${m.name} ${m.traits.join(" ")} ${m.privacy}`.toLowerCase().includes(q)) + const cur = (kindModel(kind) || {}).id, own = kind === "chat" ? ownModel(c) : ""; + const rank = (m) => (m.id === cur ? 2 : 0) + (m.id === own ? 1 : 0); + const list = list0.filter((m) => !q || `${m.id} ${m.name} ${(m.traits || []).join(" ")} ${m.privacy} ${m.uncensored ? "uncensored" : ""}`.toLowerCase().includes(q)) .sort((a, b) => rank(b) - rank(a) || a.name.localeCompare(b.name)); + const num = (text) => h("td", { class: "num", text }); for (const m of list) { - const can = [m.vision && "sees", m.tools && "tools", m.reasoning && "reasons", m.web && "web", m.uncensored && "uncensored"].filter(Boolean); - const notes = [m.id, m.id === own && "their own", m.traits.includes("default") && "venice's default", m.retiring && "retiring " + m.retiring].filter(Boolean); - tb.append(h("tr", { "aria-current": m.id === c.model ? "true" : null }, + const notes = [m.id, m.id === own && "their own", (m.traits || []).includes("default") && "venice's default", m.retiring && "retiring " + m.retiring].filter(Boolean); + let cells; + if (k.type === "text") { + const can = [m.vision && "sees", m.tools && "tools", m.reasoning && "reasons", m.web && "web", m.uncensored && "uncensored"].filter(Boolean); + cells = [h("td", { text: can.join(" · ") || "text" }), num(`${A.usd(m.input)} / ${A.usd(m.output)}`), num(m.ctx ? Math.round(m.ctx / 1000) + "k" : "")]; + } else if (kind === "photo") { + cells = [h("td", { text: m.maxIn > 1 ? `up to ${m.maxIn}` : "1" }), + num(A.usd(m.price) + (m.extra ? ` + ${A.usd(m.extra)} a picture past ${m.included}` : ""))]; + } else if (kind === "clip") { + const secs = m.durations.map((d) => parseInt(d, 10)).filter((n) => n > 0); + cells = [h("td", { text: secs.length ? `${Math.min(...secs)}–${Math.max(...secs)}s` : "?" }), h("td", { text: m.resolutions.join(", ") || "one size" }), + h("td", { text: m.audio ? "yes" : "no" })]; + } else if (kind === "voice") cells = [num(String(m.voices.length)), num(A.usd(m.perMChars))]; + else cells = [num(A.usd(A.hearingPerMinute(m)))]; + tb.append(h("tr", { "aria-current": m.id === cur ? "true" : null }, h("th", { scope: "row" }, linkish(m.name, () => chooseModel(m.id), { "aria-label": `Use ${m.name}` }), h("span", { class: "mid", text: notes.join(" · ") })), - h("td", { text: m.privacy || "?" }), - h("td", { text: can.join(" · ") || "text" }), - h("td", { class: "num", text: `${usd(m.input)} / ${usd(m.output)}` }), - h("td", { class: "num", text: m.ctx ? Math.round(m.ctx / 1000) + "k" : "" }))); + h("td", { text: (m.privacy || "?") + (k.type !== "text" && m.uncensored ? " · uncensored" : "") }), ...cells)); } - if (!list.length) tb.append(h("tr", {}, h("td", { colspan: "5", text: `no model matches “${$("#msearch").value.trim()}”.` }))); + if (!list.length) tb.append(h("tr", {}, h("td", { colspan: cols, text: q ? `no model matches “${$("#msearch").value.trim()}”.` : "venice lists none right now." }))); } function chooseModel(id) { - const c = card(); - if (!c) return; - if (c.plain) { S.plain.model = id; local.set(K.plainModel, id); } - else { replaceCard({ ...c, model: id }); fillModels($("#model-select"), id); } + const kind = pickKind, c = card(), list = kindList(kind) || [], m = list.find((x) => x.id === id), name = m ? m.name : id; + if (kind === "chat") { + if (!c) return; + if (c.plain) { S.plain.model = id; local.set(K.plainModel, id); } + else { replaceCard({ ...c, model: id }); fillModels($("#model-select"), id); } + toast(`${c.plain ? "The plain chat" : c.name} now talks through ${modelName(id)}.`); + } else if (kind === "voice") { + if (!c || c.plain || !m) return; + replaceCard({ ...c, speech: { ...c.speech, model: id, voice: voiceOf(c, m) } }); + toast(`${c.name} now sounds like ${name}.`); + } else { + if (kind === "photo") S.ab.photo = id; + else if (kind === "clip") S.ab.clip = { ...S.ab.clip, model: id }; + else if (kind === "hearing") S.ab.hearModel = id; + else if (kind === "seeing") S.ab.seeModel = id; + saveAb(); + toast(`${KINDS[kind].title.charAt(0) + KINDS[kind].title.slice(1).toLowerCase()}: ${name}.`); + } $("#modeldlg").close(); renderChatMeta(); - toast(`${c.plain ? "The plain chat" : c.name} now talks through ${modelName(id)}.`); + renderSpeech(); + if ($("#abilitydlg").open) renderAbilities(); + if (S.ask && S.ask.kind === kind) { + if (kind === "clip") { S.ask.opts = A.clipOptions(clipModel(), S.ab.clip); requote(); } + renderAsk(); + } } // ---------- what is left on the key ---------- @@ -574,6 +939,622 @@ async function loadBalance() { } catch { el.hidden = true; } } +// ---------- abilities: the tools under the text box ---------- +function renderTools() { + const p = $("#tools"), c = card(); + p.textContent = ""; + p.hidden = !c || !S.key; + if (p.hidden) return; + if (S.rec) { + p.append(h("span", { class: "rec", text: `● recording ${clock(S.rec.ms)}` }), " / ", linkish("stop and send", () => stopRecording()), + " / ", linkish("cancel", () => stopRecording(true))); + return; + } + const items = []; + if (!c.plain) { + items.push(linkish("ask for a photo", () => askFor("photo")), linkish("ask for a clip", () => askFor("clip"))); + const on = !!S.voiceOn[c.id]; + items.push(linkish(`voice notes: ${on ? "on" : "off"}`, () => setVoiceNotes(c, !on), + { "aria-pressed": on ? "true" : "false", title: on ? `${c.name} replies with voice notes` : `${c.name} replies in text; "hear it" reads one out` })); + const left = Math.max(0, S.ab.allowance - spentToday()); + items.push(linkish(`on their own: ${S.ab.own ? "on" : "off"}`, () => setOwn(!S.ab.own), { "aria-pressed": S.ab.own ? "true" : "false", + title: S.ab.own ? `Characters text first and send photos and voice notes when they like: ${A.usd(left)} of today's ${A.usd(S.ab.allowance)} left` : "Characters only do what you ask" })); + } + if (S.ab.hear) items.push(linkish("record a voice note", startRecording)); + if (S.ab.see) items.push(linkish("send a photo", () => $("#photo-in").click())); + items.push(linkish("abilities", openAbilities)); + p.append(...slashed(items)); +} + +// ---------- abilities: the settings ---------- +function openAbilities() { + if (!$("#abilitydlg").open) $("#abilitydlg").showModal(); + renderAbilities(); + for (const t of ["inpaint", "video", "tts", "asr"]) loadList(t); + loadModels(); +} +function renderAbilities() { + const tb = $("#ablist"), c = card(), real = !!c && !c.plain; + tb.textContent = ""; + const pm = photoModel(), cm = clipModel(), hm = hearModel(), sm = seeModel(), vm = real ? voiceModel(c) : null; + const change = (kind, m) => linkish(m ? m.name : "…", () => openModels(kind), { "aria-label": `Change the ${KINDS[kind].title.toLowerCase()}` }); + const tick = (label, on, set) => { + const i = h("input", { type: "checkbox" }); + i.checked = on; + i.addEventListener("change", () => set(i.checked, i)); + return h("label", { class: "check" }, i, " " + label); + }; + const row = (name, what, how) => h("tr", {}, h("th", { scope: "row", text: name }), h("td", {}, what), h("td", { class: "how" }, how)); + const allowance = h("select", { "aria-label": "Daily allowance" }, ALLOWANCES.map((v) => h("option", { value: String(v), text: A.usd(v) }))); + allowance.value = String(S.ab.allowance); + allowance.addEventListener("change", () => { S.ab.allowance = Number(allowance.value); saveAb(); renderAbilities(); renderTools(); }); + tb.append( + row("ON THEIR OWN", tick("characters text first when you come back, and send photos and voice notes when they like", S.ab.own, (on) => setOwn(on)), + ["up to ", allowance, ` a day · ${A.usd(spentToday())} spent today · clips are offered with venice's price, and made only if you say yes`]), + row("PHOTOS", real ? `${c.name} sends a photo, made from their pictures (card → look).` : "a character sends a photo, made from their pictures.", + ["model: ", change("photo", pm), pm && pm.price != null ? ` · ${A.usd(A.photoPrice(pm, 1))} a photo` : ""]), + row("CLIPS", "a few seconds of video, made from the same pictures.", ["model: ", change("clip", cm), " · venice prices each clip before it's made"]), + row("VOICE NOTES", real ? tick(`${c.name} replies with voice notes`, !!S.voiceOn[c.id], (on) => setVoiceNotes(c, on)) + : "pick a character: their voice and mood are on their card.", + real ? ["voice: ", change("voice", vm), ` (${vm ? voiceOf(c, vm) : c.speech.voice || A.DEFAULT_VOICE})`, vm && vm.perMChars != null ? ` · about ${A.usd(A.voicePrice(vm, 200))} a reply` : ""] : ""), + row("HEARING", tick("let them hear my voice notes", S.ab.hear, (on, i) => { + if (on && !(navigator.mediaDevices && navigator.mediaDevices.getUserMedia && typeof MediaRecorder !== "undefined")) { + i.checked = false; + toast("This browser can't record voice notes here."); + return; + } + S.ab.hear = on; + saveAb(); + renderTools(); + }), ["model: ", change("hearing", hm), hm && hm.perSecond != null ? ` · ${A.usd(A.hearingPerMinute(hm))} a minute` : ""]), + row("SEEING", tick("let them see my photos", S.ab.see, (on) => { S.ab.see = on; saveAb(); renderTools(); }), + ["if their model can't see, ", change("seeing", sm), " describes the photo to them"]), + ); +} + +// ---------- abilities: a photo or a clip, asked for ---------- +function askFor(kind) { + const c = card(); + if (!c || c.plain) return; + if (!S.key) return needKey(`${kind === "clip" ? "clips" : "photos"} need your venice key.`); + if (S.ask && S.ask.busy) { toast("One thing at a time: the last one is still on its way."); return; } + const hint = $("#say").value.trim(); + if (hint) { $("#say").value = ""; growSay(); } + S.ask = { kind, card: c.id, hint, shot: "", busy: false, status: "", bad: false, quote: null, quoting: false, opts: null, asked: 0 }; + loadList(kind === "clip" ? "video" : "inpaint"); + if (!refsOf(c).length) loadList("image"); + renderAsk(); + if (refsOf(c).length) writeShot(); + if (narrow()) $("#ask").scrollIntoView({ behavior: motion(), block: "nearest" }); + else $("#studio").scrollIntoView({ behavior: motion(), block: "start" }); + scrollLog(); +} +function closeAsk() { if (S.ask && S.ask.busy) return; S.ask = null; renderAsk(); } +function renderAsk() { + const box = $("#ask"), a = S.ask, cur = card(), act = document.activeElement; + const was = act && act.id === "ask-shot" ? [act.selectionStart, act.selectionEnd] : null; + const focusId = act && box.contains(act) && act.id ? act.id : ""; + box.textContent = ""; + const c = a && S.cards.find((x) => x.id === a.card); + box.hidden = !a || !c || !cur || cur.id !== a.card; + if (a && !c) S.ask = null; + $("#chat").classList.toggle("asking", !box.hidden); + if (box.hidden) return; + const clipping = a.kind === "clip", what = clipping ? "clip" : "photo"; + box.append(h("div", { class: "askhead" }, h("b", { text: `${clipping ? "A CLIP" : "A PHOTO"} FROM ${c.name.toUpperCase()}` }), + h("button", { type: "button", class: "x", "aria-label": "Close", text: "×", disabled: a.busy || null, onclick: closeAsk }))); + const refs = refsOf(c); + if (!refs.length) { + const pm = portraitModel(); + box.append(h("p", { class: "mono", text: (c.portrait || c.look.references.length + ? `${c.name}'s pictures can't be used for ${what}s: a drawing kept as an SVG, or a link to another site. add a png, jpeg or webp picture on their card (card → look), or make a first portrait from how they look.` + : `${c.name} has no picture yet, and ${what}s are made from their pictures. add one on their card (card → look), or make a first portrait from how they look.`) })); + box.append(h("div", { class: "acts" }, + h("button", { type: "button", class: "btn solid small", id: "ask-portrait", disabled: a.busy || !pm || pm.price == null || null, onclick: firstPortrait, + text: `MAKE A FIRST PORTRAIT${pm && pm.price != null ? " · " + A.usd(pm.price) : ""}` }), + h("button", { type: "button", class: "btn small", text: "OPEN THEIR CARD", onclick: () => { openCard("edit"); $("#look-group").open = true; } }))); + } else { + const ta = h("textarea", { id: "ask-shot", rows: 3, maxlength: 900, placeholder: "what the camera sees", disabled: a.busy || null }); + ta.value = a.shot; + ta.addEventListener("input", () => { a.shot = ta.value; syncAsk(); }); + box.append(h("label", {}, h("span", {}, "THE SHOT ", h("span", { class: "hint", text: "written from your conversation; change anything" })), ta)); + box.append(h("p", { class: "mono" }, linkish("write it again", writeShot, { disabled: a.busy || null }), a.hint ? ` (you asked for: ${a.hint})` : "")); + if (!clipping) { + const pm = photoModel(), n = pm ? Math.min(refs.length, pm.maxIn) : refs.length, price = A.photoPrice(pm, n); + box.append(h("p", { class: "mono" }, `from ${n} picture${n === 1 ? "" : "s"} of ${c.name} · model: `, + linkish(pm ? pm.name : "…", () => openModels("photo"), { "aria-label": "Change the photo model" }), price != null ? ` · ${A.usd(price)}` : "")); + box.append(h("div", { class: "acts" }, + h("button", { type: "button", class: "btn solid small", id: "ask-go", onclick: takePhoto, text: `TAKE THE PHOTO${price != null ? " · " + A.usd(price) : ""}` }), + h("button", { type: "button", class: "btn small", text: "CANCEL", onclick: closeAsk, disabled: a.busy || null }))); + } else { + const cm = clipModel(); + if (cm && !a.opts) a.opts = A.clipOptions(cm, S.ab.clip); + const opts = h("div", { class: "opts" }); + if (cm) { + const choice = (label, key, values, show = (v) => v) => { + if (!values || values.length < 2) return null; + const s = h("select", { id: "ask-" + key, disabled: a.busy || null }, values.map((v) => h("option", { value: v, text: show(v) }))); + s.value = a.opts[key]; + s.addEventListener("change", () => { a.opts[key] = s.value; S.ab.clip = { ...S.ab.clip, [key]: s.value, model: cm.id }; saveAb(); requote(); }); + return h("label", {}, label + " ", s); + }; + opts.append(...[choice("length", "duration", cm.durations), choice("size", "resolution", cm.resolutions), + choice("shape", "aspect", cm.aspects, (v) => ({ "9:16": "9:16 tall", "16:9": "16:9 wide", "1:1": "1:1 square" }[v] || v))].filter(Boolean)); + if (cm.audioConfigurable) { + const i = h("input", { type: "checkbox", id: "ask-audio", disabled: a.busy || null }); + i.checked = a.opts.audio !== false; + i.addEventListener("change", () => { a.opts.audio = i.checked; S.ab.clip = { ...S.ab.clip, audio: i.checked, model: cm.id }; saveAb(); requote(); }); + opts.append(h("label", { class: "check" }, i, " sound")); + } + } + box.append(opts); + box.append(h("p", { class: "mono" }, `from ${Math.min(refs.length, 4)} picture${refs.length === 1 ? "" : "s"} of ${c.name} · model: `, + linkish(cm ? cm.name : S.listErr.video ? "unavailable" : "…", () => openModels("clip"), { "aria-label": "Change the clip model" }), + " · ", a.quoting ? "asking venice for the price…" : a.quote != null ? `venice's price: ${A.usd(a.quote)}` : "no price yet")); + box.append(h("div", { class: "acts" }, + h("button", { type: "button", class: "btn solid small", id: "ask-go", onclick: makeClip, text: `MAKE THE CLIP${a.quote != null ? " · " + A.usd(a.quote) : ""}` }), + h("button", { type: "button", class: "btn small", text: "CANCEL", onclick: closeAsk, disabled: a.busy || null }))); + } + } + box.append(h("p", { class: "hint", text: `safe for work, and never a real person. a ${what} runs on your venice key.` })); + if (a.status) box.append(h("p", { class: "status" + (a.bad ? " bad" : ""), role: "status", text: a.status })); + 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 }); } +} +/** the button that spends money is live only when there's a shot and a price for exactly what would be made */ +function syncAsk() { + const a = S.ask, go = $("#ask-go"); + if (!a || !go) return; + go.disabled = a.busy || !a.shot.trim() || (a.kind === "clip" ? !quoteFits(a) : photoPriceNow(a) == null); +} +/** a photo's price, from the photo model and the pictures it would use; null when Venice gives none */ +function photoPriceNow(a) { + const c = S.cards.find((x) => x.id === a.card), pm = photoModel(); + return c && pm ? A.photoPrice(pm, Math.min(refsOf(c).length, Math.max(1, pm.maxIn))) : null; +} +/** what a clip quote was for: the model and every option */ +const quoteKey = (cm, opts) => (cm && opts ? JSON.stringify([cm.id, opts.duration, opts.resolution, opts.aspect, opts.audio]) : ""); +/** the quote on screen is for the clip as it stands now */ +const quoteFits = (a) => a.quote != null && !a.quoting && !!a.quoteFor && a.quoteFor === quoteKey(clipModel(), a.opts); +async function writeShot() { + const a = S.ask; + if (!a || a.busy) return; + 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 }); + renderAsk(); + try { + const r = await new Venice(S.key).chat({ model: c.model || S.model, messages: A.shotMessages(c, A.apiHistory(S.chats[c.id] || []), a.hint, a.kind), + maxTokens: 260, temperature: 0.8 }); + a.shot = A.cleanText(r.text); + Object.assign(a, a.shot ? { status: "", bad: false } : { status: "The model sent back no shot. Write one yourself, or try again.", bad: true }); + } catch (e) { Object.assign(a, { status: e.message, bad: true }); } + a.busy = false; + if (S.ask !== a) return; + renderAsk(); + balanceSoon(); + if (a.kind === "clip") requote(); +} +let quoteTimer = 0; +/** Venice's price for the clip as it stands; asked again whenever the length, size, shape or model changes */ +function requote() { + const a = S.ask; + if (!a || a.kind !== "clip") return; + // the old price goes at once, and any answer still on its way for the old options is ignored + const asked = ++a.asked; + Object.assign(a, { quoting: true, quote: null, quoteFor: "" }); + if (!a.busy) renderAsk(); else syncAsk(); + clearTimeout(quoteTimer); + quoteTimer = setTimeout(async () => { + const cm = clipModel(); + if (S.ask !== a || asked !== a.asked) return; + if (!cm || !a.opts) { a.quoting = false; if (!a.busy) renderAsk(); return; } + const key = quoteKey(cm, a.opts); + let quote = null, status = "", bad = false; + try { quote = await new Venice(S.key).quoteVideo({ model: cm.id, ...a.opts }); if (quote == null) { status = "Venice gave no price for that clip."; bad = true; } } + catch (e) { status = e.message; bad = true; } + if (asked !== a.asked || S.ask !== a) return; + Object.assign(a, { quoting: false, quote, quoteFor: quote == null ? "" : key }, a.busy ? {} : { status, bad }); + if (!a.busy) renderAsk(); else syncAsk(); + }, 200); +} +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; +} +async function takePhoto() { + const a = S.ask, pm = photoModel(); + if (!a || a.busy || !a.shot.trim() || !pm) return; + const c = S.cards.find((x) => x.id === a.card); + if (!c) return; + 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 }); + renderAsk(); + try { + const { blob, blurred } = await new Venice(S.key).editImage({ model: pm.id, prompt: A.photoPrompt(c, a.shot, refs.length, pm.promptLimit), images: refs }); + const id = await M.put(blob); + if (S.ask === a) S.ask = null; + renderAsk(); + deliver(c, { role: "assistant", content: "", media: { kind: "photo", id, shot: a.shot.trim(), model: pm.name, price, ...(blurred ? { blurred: true } : {}) } }); + } catch (e) { + a.busy = false; + Object.assign(a, { status: trouble(e, c), bad: true }); + renderAsk(); + } + balanceSoon(); +} +/** a character with no picture at all: how they look, in words (if their card doesn't say), then a portrait from that */ +async function firstPortrait() { + const a = S.ask, pm = portraitModel(); + if (!a || a.busy || !pm || pm.price == null) return; + const c = S.cards.find((x) => x.id === a.card); + if (!c) 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: "imagining how they look…", bad: false }); + renderAsk(); + try { + const v = new Venice(S.key); + let look = c.look.description; + if (!look) look = A.cleanText((await v.chat({ model: c.model || S.model, messages: A.lookMessages(c), maxTokens: 200, temperature: 0.9 })).text, 1500); + a.status = `drawing ${c.name} with ${pm.name}…`; + renderAsk(); + const { blob } = await v.generateImage({ model: pm.id, prompt: A.portraitPrompt({ ...c, look: { ...c.look, description: look } }, pm.promptLimit), + aspect: pm.aspects.includes("1:1") ? "1:1" : "" }); + const portrait = await shrinkImage(blob, 512, "image/webp", { svg: false }); + const now = S.cards.find((x) => x.id === c.id); + if (now && !refsOf(now).length) { + // their portrait if they had none, else the first of their pictures (a drawing kept as an SVG portrait stays) + const looks = { ...now.look, description: now.look.description || look }; + replaceCard(now.portrait ? { ...now, look: { ...looks, references: [portrait, ...now.look.references].slice(0, C.MAX_REFERENCES) } } + : { ...now, portrait, look: looks }); + renderRoster(); + if ((card() || {}).id === c.id) renderSheet(); + } + a.busy = false; + Object.assign(a, { status: "", bad: false }); + toast(`${c.name} has a picture now, on their card.`); + renderAsk(); + writeShot(); + } catch (e) { + a.busy = false; + Object.assign(a, { status: e.message, bad: true }); + renderAsk(); + } + balanceSoon(); +} + +// ---------- abilities: clips on their way ---------- +/** the clips on their way, as this tab and the others know them: a union, less the ones that are home */ +function saveJobs() { + syncJobs(); + local.set(K.jobs, S.jobs.map(({ polling, tries, ...j }) => j)); +} +async function makeClip() { + const a = S.ask, cm = clipModel(); + if (!a || a.busy || !a.shot.trim() || !cm || !quoteFits(a)) return; // only at the price on the button + const c = S.cards.find((x) => x.id === a.card); + if (!c || !refsOf(c).length) return; + a.busy = true; + Object.assign(a, { status: "asking venice to start the clip…", bad: false }); + renderAsk(); + try { + await startClip(c, cm, a.shot.trim(), { ...a.opts }, a.quote); + if (S.ask === a) S.ask = null; + renderAsk(); + } catch (e) { + a.busy = false; + Object.assign(a, { status: trouble(e, c), bad: true }); + renderAsk(); + } + balanceSoon(); +} +/** queue a clip at Venice and follow it until it's home */ +async function startClip(c, cm, shot, opts, price) { + const { queueId, downloadUrl } = await new Venice(S.key).queueVideo({ model: cm.id, prompt: A.clipPrompt(c, shot, cm.promptLimit), images: refsOf(c), ...opts }); + const job = { id: M.newId(), card: c.id, model: cm.id, name: cm.name, queueId, downloadUrl, shot, price, started: Date.now(), elapsed: 0, expected: 0 }; + S.jobs.push(job); + saveJobs(); + renderJobs(); + scrollLog(); + pollJob(job); + return job; +} + +// ---------- abilities: on their own ---------- +/** + * After a reply, when a move is likely (they were asked to show something, or the reply is about something worth + * showing, or now and then), the character's model decides: a photo or a voice note, sent out of the day's + * allowance, or a clip, offered with Venice's price for you to take or leave. + */ +async function onTheirOwn(c) { + if (!S.key || !S.ab.own || S.moving || !A.moveLikely(S.chats[c.id])) return; + // the call that decides is paid for too: set aside its most, from what's left today, and settle after + const model = (S.models || []).find((m) => m.id === (c.model || S.model)) || null; + const messages = A.moveMessages(c, A.apiHistory(S.chats[c.id] || [])), most = A.callEstimate(messages, 300, model); + if (!reserve(most)) return; + S.moving = true; + let held = most; + try { + const r = await new Venice(S.key).chat({ model: c.model || S.model, messages, schema: A.MOVE_SCHEMA, maxTokens: 300, temperature: 0.7 }); + const cost = Math.min(most, A.callCost(r.usage, model)); + refund(held - cost); + held = 0; + if (!S.cards.some((x) => x.id === c.id)) return; // they left the harbor meanwhile + const { move, shot } = A.normalizeMove(C.parseJsonLoose(r.text)); + const lately = A.sentLately(S.chats[c.id]); // never two photos in a row + if (move === "photo" && !lately) await ownPhoto(c, shot); + else if (move === "voice") await ownVoice(c); + else if (move === "clip" && !lately) await offerClip(c, shot); + } catch { /* then they just text */ } finally { refund(held); S.moving = false; } +} +/** a "… is doing something" line while they're at it, if you're in their conversation */ +function busyLine(c, text) { + if ((card() || {}).id !== c.id) return { remove() {} }; + const t = put(line(text, "them", "typing")); + scrollLog(); + return t; +} +async function ownPhoto(c, shot) { + await loadList("inpaint"); + const pm = photoModel(), refs = refsOf(c); + if (!pm || !refs.length) return; // no picture of them yet: nothing to send + const pics = refs.slice(0, Math.max(1, pm.maxIn)), price = A.photoPrice(pm, pics.length); + if (!reserve(price)) return; // set aside first: nothing else can spend it meanwhile + const t = busyLine(c, "is taking a photo…"); + try { + const { blob, blurred } = await new Venice(S.key).editImage({ model: pm.id, prompt: A.photoPrompt(c, shot, pics.length, pm.promptLimit), images: pics }); + const id = await M.put(blob); + t.remove(); + deliver(c, { role: "assistant", content: "", media: { kind: "photo", id, shot, model: pm.name, price, own: true, ...(blurred ? { blurred: true } : {}) } }); + balanceSoon(); + } catch (e) { refund(price); throw e; } finally { t.remove(); } +} +async function ownVoice(c) { + const hist = S.chats[c.id] || [], msg = [...hist].reverse().find((m) => m.role === "assistant" && !m.media && !m.demo); + if (!msg || msg.voice || voicing.has(msg)) return; + await loadList("tts"); + const vm = voiceModel(c), price = vm ? A.voicePrice(vm, A.speakable(msg.content).length) : null; + if (msg.voice || voicing.has(msg) || !reserve(price)) return; + voicing.add(msg); + const t = busyLine(c, "is recording a voice note…"); + try { + const v = { ...(await makeVoice(c, msg.content)), own: true }; + if (msg.voice) { refund(price); return; } // "hear it" got there first + msg.voice = v; + persistChats(); + t.remove(); + placeVoice(c, msg); + balanceSoon(); + } catch (e) { refund(price); throw e; } finally { t.remove(); voicing.delete(msg); } +} +/** a voice note that came after its words were shown: put it above them, where it would have been */ +function placeVoice(c, m) { + if ((card() || {}).id !== c.id || !m.id) return; + const first = $(`#log .line[data-msg="${m.id}"]`); + if (!first) return; // the conversation was redrawn: it shows next time + first.before(voiceLine(c, m)); + for (const b of $$(`#log .line[data-msg="${m.id}"] .hear`)) b.remove(); +} +/** a clip they'd like to send: Venice's price first, and it's made only if you say yes */ +async function offerClip(c, shot) { + await loadList("video"); + const cm = clipModel(); + if (!cm || !refsOf(c).length || S.jobs.some((j) => j.card === c.id)) return; + const opts = A.clipOptions(cm, S.ab.clip); + const quote = await new Venice(S.key).quoteVideo({ model: cm.id, ...opts }); + if (quote == null || (card() || {}).id !== c.id) return; + const li = line("", "them", "offer"), said = $(".said", li); + const yes = h("button", { type: "button", class: "btn solid small", text: `SEND IT · ${A.usd(quote)}`, onclick: async () => { + yes.disabled = no.disabled = true; + S.inflight++; + try { await startClip(c, cm, shot, opts, quote); li.remove(); balanceSoon(); } + catch (e) { li.remove(); err(trouble(e, c)); } finally { S.inflight--; } + } }); + const no = h("button", { type: "button", class: "btn small", text: "NOT NOW", onclick: () => li.remove() }); + said.append(h("span", { text: `wants to send you a ${opts.duration} clip: ${shot}` }), h("span", { class: "cap", text: `${cm.name} · venice's price: ${A.usd(quote)}` }), + h("span", { class: "acts" }, yes, no)); + put(li); + scrollLog(); +} +function resumeJobs() { for (const j of S.jobs) pollJob(j); } +function dropJob(job) { + local.set(K.jobsDone, [...(local.get(K.jobsDone, []) || []).filter((q) => q !== job.queueId), job.queueId].slice(-100)); + S.jobs = S.jobs.filter((j) => j !== job && j.queueId !== job.queueId); + saveJobs(); +} +/** + * Follow a clip until it's home. With more than one tab open, one tab follows it (the others take over if that + * tab closes), so it's fetched once. + */ +async function pollJob(job) { + if (job.polling) return; + job.polling = true; + try { + if (!(navigator.locks && navigator.locks.request)) { await followJob(job); return; } + while (S.jobs.includes(job) && S.key) { + let ran = false; + await navigator.locks.request("havre-clip-" + job.queueId, { ifAvailable: true }, async (lock) => { if (lock) { ran = true; await followJob(job); } }); + if (ran) return; + await sleep(15000); + syncJobs(); // home already, in the tab that was following it? + } + } finally { job.polling = false; } // no key now: picked up again when there is one +} +/** ask Venice how a clip is coming along every few seconds, and bring it home when it's done */ +async function followJob(job) { + let fails = 0; + while (S.jobs.includes(job) && S.key) { + const v = new Venice(S.key); + try { + const r = await v.retrieveVideo({ model: job.model, queueId: job.queueId }); + fails = 0; + if (!r.done) { + Object.assign(job, { elapsed: r.elapsed || Date.now() - job.started, expected: r.expected || job.expected || 0 }); + renderJobs(); + if (job.elapsed > Math.max(20 * 60000, 4 * job.expected)) throw new VeniceError("it's taking far longer than it should, so Havre stopped asking.", -3); + await sleep(5000); + continue; + } + let blob = r.blob, link = ""; + if (!blob && job.downloadUrl) { + // a private model's clip waits at its own link; if this browser can't fetch it, it plays from there for a day + try { blob = await v.downloadVideo(job.downloadUrl); } catch (e) { if (e && e.status === 0) link = job.downloadUrl; else throw e; } + } + if (!blob && !link) throw new VeniceError("Venice finished the clip but sent nothing back.", -1); + const id = blob ? await M.put(blob) : ""; + dropJob(job); + const c = S.cards.find((x) => x.id === job.card); + if (c) deliver(c, { role: "assistant", content: "", media: { kind: "clip", id, shot: job.shot, model: job.name, price: job.price, + ...(link ? { url: link, until: job.started + 24 * 3600e3 } : {}) } }); + if (blob) { // home safe: Venice can let go of its copy and its link + v.completeVideo({ model: job.model, queueId: job.queueId }); + if (job.downloadUrl) v.revokeDownload(job.downloadUrl); + } + balanceSoon(); + return; + } catch (e) { + // a dropped connection, a rate limit or a hiccup at Venice: ask again, a little later each time, and while + // offline, once back online. Venice keeps a clip for a day, so it's given up only after that. + if (e && (e.status === 0 || e.status === 429 || e.status >= 500) && Date.now() - job.started < 24 * 3600e3) { + fails++; + if (navigator.onLine === false) await new Promise((r) => addEventListener("online", r, { once: true })); + else await sleep(Math.min(60000, 5000 * 2 ** Math.min(fails, 4))); + continue; + } + dropJob(job); + const c = S.cards.find((x) => x.id === job.card); + const why = `the clip from ${c ? c.name : "a character"} didn't come: ${e.status === 404 ? "Venice no longer has it (a clip waits there for a day)." : e.message}`; + if (c && (card() || {}).id === c.id) err(why); else toast(why); + return; + } + } +} +/** a line in the conversation for each clip still being made */ +function renderJobs() { + const log = $("#log"), c = card(); + for (const el of $$("#log [data-job]")) if (!S.jobs.some((j) => j.id === el.dataset.job && c && j.card === c.id)) el.remove(); + if (!c) return; + for (const j of S.jobs.filter((x) => x.card === c.id)) { + let el = $(`#log [data-job="${j.id}"]`); + if (!el) { el = line("", "them", "typing"); el.dataset.job = j.id; log.append(el); } + $(".said", el).textContent = `is making a clip with ${j.name}: ${clock(j.elapsed)}${j.expected ? " of about " + clock(j.expected) : ""}`; + } +} + +// ---------- abilities: your voice notes ---------- +let recStarting = false; +async function startRecording() { + const c = card(); + if (!c || S.rec || recStarting || !S.ab.hear) return; + if (!S.key) return needKey("voice notes need your venice key."); + if (!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia) || typeof MediaRecorder === "undefined") { toast("This browser can't record voice notes here."); return; } + recStarting = true; // a second click while the browser asks about the microphone does nothing + let stream; + try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch { recStarting = false; toast("The browser didn't let Havre use the microphone."); return; } + recStarting = false; + if (S.rec || (card() || {}).id !== c.id) { stream.getTracks().forEach((t) => t.stop()); return; } + const type = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"].find((t) => MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(t)) || ""; + let rec; + try { rec = new MediaRecorder(stream, type ? { mimeType: type } : undefined); } catch { stream.getTracks().forEach((t) => t.stop()); toast("This browser can't record voice notes here."); return; } + const r = { rec, stream, chunks: [], t0: Date.now(), ms: 0, timer: 0, cancel: false, done: false, card: c.id }; + rec.ondataavailable = (e) => { if (e.data && e.data.size) r.chunks.push(e.data); }; + rec.onstop = () => { + if (r.done) return; // once, however it stopped + r.done = true; + clearInterval(r.timer); + r.stream.getTracks().forEach((t) => t.stop()); + if (S.rec === r) S.rec = null; + renderTools(); + if (!r.cancel) sendVoice(new Blob(r.chunks, { type: rec.mimeType || type || "audio/webm" }), r.card); + }; + S.rec = r; + rec.start(250); + // the clock moves on its own; the buttons beside it stay put + r.timer = setInterval(() => { + r.ms = Date.now() - r.t0; + if (r.ms >= 120000) stopRecording(); + else { const el = $("#tools .rec"); if (el) el.textContent = `● recording ${clock(r.ms)}`; } + }, 500); + renderTools(); +} +function stopRecording(cancel = false) { + const r = S.rec; + if (!r || r.stopping) return; + r.stopping = true; + r.cancel = cancel; + try { if (r.rec.state !== "inactive") r.rec.stop(); else r.rec.onstop(); } catch { r.rec.onstop(); } +} +/** something of yours is ready to send, but you've moved on to someone else meanwhile: it isn't sent */ +function stillWith(id, what) { + if ((card() || {}).id === id) return true; + const c = S.cards.find((x) => x.id === id) || (id === PLAIN ? S.plain : null); + toast(`You switched away from ${c ? c.name : "them"}, so your ${what} wasn't sent.`); + return false; +} +async function sendVoice(blob, to) { + S.inflight++; + try { await hearVoice(blob, to); } finally { S.inflight--; } +} +async function hearVoice(blob, to) { + const c = card(); + if (!c || !S.key || c.id !== to) return; + if (blob.size < 600) { toast("That voice note was too short to hear."); return; } + const id = await M.put(blob); + const note = put(line("hearing your voice note…", "havre", "typing")); + scrollLog(); + try { + await loadList("asr"); + const hm = hearModel(), ext = /mp4/.test(blob.type) ? "m4a" : /ogg/.test(blob.type) ? "ogg" : "webm"; + const { text } = await new Venice(S.key).transcribe({ file: blob, name: "voice-note." + ext, model: hm ? hm.id : "" }); + note.remove(); + if (!text) throw new Error("Venice heard no words in that voice note."); + balanceSoon(); + await idle(); + if (stillWith(c.id, "voice note")) await send(text, { media: { kind: "voice-in", id } }); + } catch (e) { note.remove(); err(e.message); } +} + +// ---------- abilities: your photos ---------- +async function sendPhoto(file) { + S.inflight++; + try { await showPhoto(file); } finally { S.inflight--; } +} +async function showPhoto(file) { + const c = card(); + if (!c || !S.key || !S.ab.see) return; + let pic; + try { pic = await shrinkImage(file, 1024, "image/jpeg", { svg: false }); } catch { toast("That picture didn't load."); return; } + const id = await M.put(dataBlob(pic)); + const caption = $("#say").value.trim(); + $("#say").value = ""; + growSay(); + await loadModels(); + const model = c.plain ? S.plain.model : c.model; + if ((S.models || []).some((m) => m.id === model && m.vision)) { + await idle(); + if (stillWith(c.id, "photo")) await send(caption, { media: { kind: "photo-in", id }, picture: pic }); + return; + } + const sm = seeModel(); + if (!sm) { err("No Venice model can see photos right now."); return; } + const note = put(line(`${sm.name} is looking at your photo…`, "havre", "typing")); + scrollLog(); + try { + const r = await new Venice(S.key).chat({ model: sm.id, maxTokens: 220, temperature: 0.2, + messages: [{ role: "user", content: [{ type: "text", text: A.SEE_PROMPT }, { type: "image_url", image_url: { url: pic } }] }] }); + const saw = A.cleanText(r.text, 600); + note.remove(); + if (!saw) throw new Error(`${sm.name} couldn't describe the photo.`); + await idle(); + if (stillWith(c.id, "photo")) await send(caption, { media: { kind: "photo-in", id, saw, seer: sm.name } }); + } catch (e) { note.remove(); err(e.message); } +} + function renderSheet() { const c = card(), f = $("#sheet"); for (const el of f.elements) el.disabled = !c; @@ -601,11 +1582,105 @@ function renderSheet() { ", with a prompt Havre never sees. To make a character of your own from them, use Remix."); } f.persona.disabled = f.greeting.disabled = !!c.engine.venice_character; + f.look_description.value = c.look.description; + f.speech_mood.value = c.speech.mood; + const sp = $("#speech-speed"); + if (![...sp.options].some((o) => Number(o.value) === c.speech.speed)) sp.append(h("option", { value: String(c.speech.speed), text: `${c.speech.speed}×` })); + sp.value = [...sp.options].find((o) => Number(o.value) === c.speech.speed).value; + $("#speech-status").textContent = ""; + renderRefs(c); + renderSpeech(); renderFlow(c); renderExamples(c); renderLineage(c); } +/** the pictures photos and clips are made from: the portrait, then up to three more */ +function renderRefs(c) { + const box = $("#refs"); + box.textContent = ""; + const frame = (src, label, remove) => { + const f = h("span", { class: "face small", "aria-hidden": "true" }); + paintFace(f, src); + return h("figure", { class: "ref" }, f, h("figcaption", {}, label, remove ? [" ", linkish("remove", remove, { "aria-label": `Remove ${label}` })] : [])); + }; + box.append(frame(portraitSrc(c), "portrait")); + c.look.references.forEach((r, i) => box.append(frame(pictureSrc(r), `picture ${i + 2}`, () => { + const now = card(); + replaceCard({ ...now, look: { ...now.look, references: now.look.references.filter((_, j) => j !== i) } }); + renderRefs(card()); + picturesChanged(); + }))); + $("#ref-add").disabled = c.look.references.length >= C.MAX_REFERENCES; +} +async function addReference(file) { + const c = card(); + if (!c || c.plain) return; + try { + const pic = await shrinkImage(file, 768); + const now = card(); + replaceCard({ ...now, look: { ...now.look, references: [...now.look.references, pic].slice(0, C.MAX_REFERENCES) } }); + renderRefs(card()); + picturesChanged(); + } catch { toast("That picture didn't load."); } +} +/** a photo or clip being asked for is made from the pictures as they are now: its panel catches up */ +function picturesChanged() { + const a = S.ask; + if (!a || a.busy) return; + const c = S.cards.find((x) => x.id === a.card); + renderAsk(); + if (c && refsOf(c).length && !a.shot) writeShot(); +} + +/** how they sound: the voice model (from the picker), the voice, the mood, the speed */ +function renderSpeech() { + const c = card(); + if (!c || c.plain) return; + const vm = voiceModel(c), p = $("#speech-model"); + p.textContent = ""; + p.append("voice model: ", linkish(vm ? vm.name : S.listErr.tts ? "unavailable" : "…", () => openModels("voice"), { "aria-label": "Change the voice model" }), + vm && vm.perMChars != null ? ` · ${A.usd(vm.perMChars)} a million characters, about ${A.usd(A.voicePrice(vm, 200))} a reply` : ""); + const sel = $("#speech-voice"), voices = vm ? vm.voices : [], cur = vm ? voiceOf(c, vm) : c.speech.voice; + sel.textContent = ""; + for (const v of voices.length ? voices : [cur || A.DEFAULT_VOICE]) sel.append(h("option", { value: v, text: v })); + sel.value = cur || sel.options[0].value; + $("#speech-on").checked = !!S.voiceOn[c.id]; +} +function setOwn(on) { + S.ab.own = !!on; + saveAb(); + renderTools(); + if ($("#abilitydlg").open) renderAbilities(); + toast(on ? `Characters act on their own now, within ${A.usd(S.ab.allowance)} a day.` : "Characters only do what you ask now."); +} +function setVoiceNotes(c, on) { + if (on) S.voiceOn[c.id] = true; else delete S.voiceOn[c.id]; + local.set(K.voiceOn, S.voiceOn); + renderTools(); + if ($("#speech-on")) $("#speech-on").checked = !!S.voiceOn[(card() || {}).id]; + if ($("#abilitydlg").open) renderAbilities(); + loadList("tts"); +} +async function hearSample() { + const c = card(); + if (!c || c.plain) return; + if (!S.key) return needKey("voice notes need your venice key."); + const st = $("#speech-status"), btn = $("#speech-sample"); + st.textContent = "recording a sample…"; + st.classList.remove("bad"); + btn.disabled = true; + try { + const hello = c.greeting ? C.splitBubbles(C.fill(c.greeting, c, "you")).join(" ") : `Hi, I'm ${c.name}.${c.tagline ? " " + c.tagline : ""}`; + const v = await makeVoice(c, hello); + const a = audioEl(v.id); + st.textContent = ""; + st.append(a, ` ${v.voice}${v.price != null ? " · " + A.usd(v.price) : ""}`); + a.addEventListener("loadeddata", () => { a.play().catch(() => {}); }, { once: true }); + balanceSoon(); + } catch (e) { st.textContent = e.message; st.classList.add("bad"); } finally { btn.disabled = false; } +} + function itemRow(fields, values) { const li = h("li"); for (const [k, label, multi] of fields) { @@ -639,6 +1714,8 @@ function readSheet() { voice: { short_form: f.short_form.checked, notes: f.voice_notes.value }, tags: f.tags.value.split(","), model: f.model.value, license: f.license.value, creator: f.creator.value, flow: rows("#flow", ["stage", "goal", "next"]), examples: rows("#examples", ["user", "character"]), + look: { description: f.look_description.value, references: c.look.references }, + speech: { model: c.speech.model, voice: f.speech_voice.value || c.speech.voice, mood: f.speech_mood.value, speed: Number(f.speech_speed.value) || c.speech.speed }, }); } let sheetTimer = 0; @@ -658,23 +1735,32 @@ function saveSheet() { }, 120); } -async function shrinkImage(file, size = 256) { +/** a picture as a data: URI, no bigger than size on its long side (an SVG portrait stays an SVG) */ +async function shrinkImage(file, size = 512, type = "image/webp", { svg = true } = {}) { const url = URL.createObjectURL(file); try { const img = new Image(); img.src = url; await img.decode(); - if (file.type === "image/svg+xml") { + if (svg && file.type === "image/svg+xml") { const text = await file.text(); if (text.length < 120000) return "data:image/svg+xml;base64," + C.b64encodeUtf8(text); } const s = Math.min(1, size / Math.max(img.naturalWidth, img.naturalHeight)); const cv = h("canvas", { width: Math.max(1, Math.round(img.naturalWidth * s)), height: Math.max(1, Math.round(img.naturalHeight * s)) }); - cv.getContext("2d").drawImage(img, 0, 0, cv.width, cv.height); - const webp = cv.toDataURL("image/webp", 0.86); - return webp.startsWith("data:image/webp") ? webp : cv.toDataURL("image/png"); + const g = cv.getContext("2d"); + if (type === "image/jpeg") { g.fillStyle = "#fff"; g.fillRect(0, 0, cv.width, cv.height); } + g.drawImage(img, 0, 0, cv.width, cv.height); + const out = cv.toDataURL(type, 0.86); + return out.startsWith("data:" + type) ? out : cv.toDataURL("image/png"); } finally { URL.revokeObjectURL(url); } } +function dataBlob(dataUrl) { + const [head, b64] = dataUrl.split(","); + const bin = atob(b64), bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return new Blob([bytes], { type: (head.match(/^data:([^;]+)/) || [])[1] || "application/octet-stream" }); +} function setTab(name) { for (const t of ["edit", "remix", "export"]) { @@ -734,9 +1820,12 @@ async function saveKey(ev) { resetRemix(); loadVenice(0); loadBalance(); + resumeJobs(); } function forgetKey() { if (S.replying) { S.replying.abort(); S.replying = null; setBusy(false); } + if (S.rec) stopRecording(true); + S.ask = null; S.key = ""; S.keyRemembered = false; storeKey(); @@ -1011,6 +2100,32 @@ async function exportAs(kind) { } // ---------- small things ---------- +function growSay() { const say = $("#say"); say.style.height = "auto"; say.style.height = Math.min(150, say.scrollHeight + 2) + "px"; } +/** wait for a reply on its way to finish, before saying the next thing */ +async function idle() { while (S.replying) await sleep(150); } +/** nothing on its way: no reply, no photo, no voice note, no recording */ +const quiet = () => !S.replying && !S.moving && !(S.ask && S.ask.busy) && !S.rec && !S.inflight; +const waiting = new Map(); +/** run fn now if nothing is on its way, else as soon as nothing is (once per name) */ +function whenIdle(name, fn) { + waiting.set(name, fn); + if (waiting.size > 1) return; + (async () => { + while (!quiet()) await sleep(500); + const all = [...waiting.values()]; + waiting.clear(); + for (const f of all) f(); + })(); +} +/** clips another tab started (followed here too, by one tab at a time) or finished (gone here too) */ +function syncJobs() { + const done = new Set(local.get(K.jobsDone, []) || []); + for (const j of local.get(K.jobs, []) || []) { + if (j && j.queueId && !done.has(j.queueId) && !S.jobs.some((x) => x.queueId === j.queueId)) { S.jobs.push(j); if (S.key) pollJob(j); } + } + S.jobs = S.jobs.filter((x) => !done.has(x.queueId)); + renderJobs(); +} async function copyFrom(id, btn) { const pre = document.getElementById(id); let ok = false; @@ -1039,10 +2154,12 @@ function bind() { for (const b of $$("button.copy")) b.addEventListener("click", () => copyFrom(b.dataset.copy, b)); const say = $("#say"); - const grow = () => { say.style.height = "auto"; say.style.height = Math.min(150, say.scrollHeight + 2) + "px"; }; - $("#composer").addEventListener("submit", (e) => { e.preventDefault(); const t = say.value; if (!t.trim() || S.replying) return; say.value = ""; grow(); send(t); }); + $("#composer").addEventListener("submit", (e) => { e.preventDefault(); const t = say.value; if (!t.trim() || S.replying) return; say.value = ""; growSay(); send(t); }); say.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); $("#composer").requestSubmit(); } }); - say.addEventListener("input", grow); + say.addEventListener("input", growSay); + $("#photo-in").addEventListener("change", () => { const pf = $("#photo-in").files[0]; $("#photo-in").value = ""; if (pf) sendPhoto(pf); }); + $("#abclose").addEventListener("click", () => $("#abilitydlg").close()); + $("#abform").addEventListener("submit", (e) => e.preventDefault()); $("#keyform").addEventListener("submit", saveKey); $("#keyforget").addEventListener("click", forgetKey); @@ -1115,16 +2232,30 @@ function bind() { const pf = $("#portrait-file").files[0]; $("#portrait-file").value = ""; if (!pf) return; - try { const c = card(); replaceCard({ ...c, portrait: await shrinkImage(pf) }); renderSheet(); } + try { const c = card(); replaceCard({ ...c, portrait: await shrinkImage(pf) }); renderSheet(); picturesChanged(); } catch { toast("That image didn't load."); } }); - $("#portrait-clear").addEventListener("click", () => { const c = card(); if (!c) return; replaceCard({ ...c, portrait: "" }); renderSheet(); }); + $("#portrait-clear").addEventListener("click", () => { const c = card(); if (!c) return; replaceCard({ ...c, portrait: "" }); renderSheet(); picturesChanged(); }); + $("#ref-add").addEventListener("click", () => { + if (!local.get(K.refOk, false)) { + if (!confirm("Photos and clips of a character are made from their pictures. Add only pictures of the character: art, or pictures you made. Never a photo of a real person.\n\nAdd a picture of the character?")) return; + local.set(K.refOk, true); + } + $("#ref-file").click(); + }); + $("#ref-file").addEventListener("change", () => { const rf = $("#ref-file").files[0]; $("#ref-file").value = ""; if (rf) addReference(rf); }); + $("#speech-sample").addEventListener("click", hearSample); + $("#speech-group").addEventListener("toggle", () => { if ($("#speech-group").open) loadList("tts"); }); + $("#speech-on").addEventListener("change", (e) => { e.stopPropagation(); const c = card(); if (c && !c.plain) setVoiceNotes(c, $("#speech-on").checked); }); $("#delete").addEventListener("click", () => { const c = card(); if (!c || !confirm(`Remove ${c.name} from your harbor? Export them first if you want to keep a copy.`)) return; if (S.replying) { S.replying.abort(); S.replying = null; setBusy(false); } S.cards = S.cards.filter((x) => x.id !== c.id); delete S.chats[c.id]; + for (const j of S.jobs.filter((x) => x.card === c.id)) dropJob(j); // their clips on the way go with them + if (S.voiceOn[c.id]) { delete S.voiceOn[c.id]; local.set(K.voiceOn, S.voiceOn); } + if (S.ask && S.ask.card === c.id) S.ask = null; persistChats(); S.sel = S.cards[0] ? S.cards[0].id : ""; persistCards(true); @@ -1144,7 +2275,18 @@ function bind() { $("#rx-write").addEventListener("click", () => writeFresh()); for (const b of $$("[data-out]")) b.addEventListener("click", () => exportAs(b.dataset.out)); - addEventListener("storage", (e) => { if (e.key === K.cards || e.key === K.key) location.reload(); }); + // another tab changed something: catch up, once nothing is on its way here (a reload mid-photo would waste it) + addEventListener("storage", (e) => { + if (e.key === K.cards || e.key === K.key) whenIdle("reload", () => location.reload()); + else if (e.key === K.chats || e.key === K.cleared) { if (mergeChats(local.get(K.chats, {}))) whenIdle("log", renderLog); } + else if (e.key === K.jobs || e.key === K.jobsDone) syncJobs(); + else if (e.key === K.spent) { renderTools(); if ($("#abilitydlg").open) renderAbilities(); } + else if (e.key === K.ab) { + S.ab = { ...S.ab, ...(local.get(K.ab, {}) || {}) }; + renderTools(); + if (S.ask && !S.ask.busy) { if (S.ask.kind === "clip") { S.ask.opts = A.clipOptions(clipModel(), S.ab.clip); requote(); } renderAsk(); } + } + }); } boot(); diff --git a/ventures/havre/card.js b/ventures/havre/card.js index 80a25f8..3d8bead 100644 --- a/ventures/havre/card.js +++ b/ventures/havre/card.js @@ -2,7 +2,7 @@ // Pure functions, no DOM: the page imports this module, and so do the tests (node --test) and agents. // // a Havre card { havre: 1, id, name, tagline, greeting, persona, voice, flow, examples, tags, model, -// engine, portrait, creator, license, lineage, created, updated } +// engine, portrait, look, speech, creator, license, lineage, created, updated } // in Havre JSON, Character Card V1 / V2 / V3 (JSON or PNG), or plain text // out Havre JSON, Character Card V3 + V2 (JSON, or a PNG card), a plain system prompt // remix two passes, like a clean room: one reads the source and writes down only ideas, the other @@ -31,7 +31,11 @@ export const RELATIONS = ["ported-from", "inspired-by", "forked-from"]; const str = (v, max = 20000) => (typeof v === "string" ? v : v == null ? "" : String(v)).slice(0, max); const arr = (v) => (Array.isArray(v) ? v : []); +const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {}); const nowIso = () => new Date().toISOString(); +/** a picture a card can carry: a data: URI of a raster image, an https URL, or a path next to the card */ +const isPicture = (s) => /^data:image\/(png|jpe?g|webp|gif);base64,/i.test(s) || /^https:\/\//i.test(s) || (!!s && !/^[a-z][a-z0-9+.-]*:/i.test(s) && !s.startsWith("//")); +export const MAX_REFERENCES = 3; export function slugify(s) { const out = str(s, 200).toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "") @@ -47,8 +51,8 @@ export function newCard(partial = {}) { export function normalizeCard(o = {}) { const name = str(o.name, 120).trim() || "Unnamed"; - const voice = o.voice && typeof o.voice === "object" ? o.voice : {}; - const engine = o.engine && typeof o.engine === "object" ? o.engine : {}; + const voice = obj(o.voice), engine = obj(o.engine), look = obj(o.look), speech = obj(o.speech); + const speed = Number(speech.speed); const card = { havre: HAVRE, id: slugify(o.id || name), @@ -67,6 +71,15 @@ export function normalizeCard(o = {}) { model: str(o.model, 120).trim() || DEFAULT_MODEL, engine: engine.venice_character ? { venice_character: slugify(engine.venice_character) } : {}, portrait: str(o.portrait, 2_000_000).trim(), + // how they look (photos and clips are made from the portrait and these pictures) and how they sound in a voice note + look: { + description: str(look.description, 1500).trim(), + references: [...new Set(arr(look.references).map((r) => str(r, 3_000_000).trim()).filter(isPicture))].slice(0, MAX_REFERENCES), + }, + speech: { + model: str(speech.model, 120).trim(), voice: str(speech.voice, 120).trim(), mood: str(speech.mood, 500).trim(), + speed: Number.isFinite(speed) && speed > 0 ? Math.min(4, Math.max(0.25, Math.round(speed * 100) / 100)) : 1, + }, creator: str(o.creator, 120).trim(), license: LICENSES[o.license] ? o.license : "remix-with-credit", lineage: arr(o.lineage).slice(0, 50).map(normalizeLineage).filter(Boolean), diff --git a/ventures/havre/card.schema.json b/ventures/havre/card.schema.json index 1a1ce2a..44362c2 100644 --- a/ventures/havre/card.schema.json +++ b/ventures/havre/card.schema.json @@ -49,6 +49,22 @@ "properties": { "venice_character": { "type": "string", "description": "Speak through a public Venice character by its slug (its own prompt, unseen)." } } }, "portrait": { "type": "string", "description": "An image URL, a path next to the card, or a data: URI." }, + "look": { + "type": "object", "additionalProperties": false, "description": "How they look. Photos and clips of them are made from the portrait and these pictures.", + "properties": { + "description": { "type": "string", "maxLength": 1500, "description": "Their appearance in a sentence or two: apparent age (an adult), face, hair, build, clothes." }, + "references": { "type": "array", "maxItems": 3, "description": "More pictures of the character besides the portrait, best as data: URIs (PNG, JPEG, WebP or GIF) so they travel with the card; https URLs and paths next to the card are allowed. Havre makes photos and clips only from pictures kept in the card itself (and a Venice character's own photo). Pictures of the character only, never a photo of a real person.", "items": { "type": "string" } } + } + }, + "speech": { + "type": "object", "additionalProperties": false, "description": "How they sound in a voice note (Venice text-to-speech).", + "properties": { + "model": { "type": "string", "maxLength": 120, "description": "A Venice voice model id, e.g. tts-kokoro. Empty: Havre's default." }, + "voice": { "type": "string", "maxLength": 120, "description": "One of that model's voices, e.g. af_sky." }, + "mood": { "type": "string", "maxLength": 500, "description": "How they deliver it, e.g. warm, teasing, a little sleepy. Models that take a style prompt use it; the others ignore it." }, + "speed": { "type": "number", "minimum": 0.25, "maximum": 4, "default": 1 } + } + }, "creator": { "type": "string", "maxLength": 120 }, "license": { "enum": ["remix-with-credit", "all-rights-reserved", "cc-by-4.0", "cc0"], "default": "remix-with-credit" }, "lineage": { diff --git a/ventures/havre/index.html b/ventures/havre/index.html index bf979b7..a352a70 100644 --- a/ventures/havre/index.html +++ b/ventures/havre/index.html @@ -3,7 +3,7 @@ - + Havre @@ -72,12 +72,15 @@
    +
    + +
    @@ -126,7 +129,7 @@
    - +
    FLOW optional stages for meeting someone new
      @@ -137,6 +140,25 @@
        +
        + LOOK their photos and clips are made from these pictures +
        +

        up to three besides the portrait. pictures of the character only, never a photo of a real person.

        + + +
        +
        + VOICE NOTES how they sound when they send one +

        +
        + + +
        + +

        qwen 3 voices take the mood; other voice models ignore it.

        + +

        +
        @@ -192,9 +214,10 @@

        HOW IT WORKS

          -
        1. Paste your Venice API key. It stays in this browser, and the page can reach api.venice.ai and nothing else.
        2. +
        3. Paste your Venice API key. It stays in this browser and goes only to api.venice.ai; the page can reach nothing but Venice.
        4. Pick a character: one of Venice's, a card file you import, or one you write. Venice's characters say hello first. Or chat with a model and no character at all.
        5. Talk. Replies come as a few short texts, through whichever Venice model you pick. Without a key, the characters you write answer from their own examples.
        6. +
        7. More than texts. Characters text first when you come back, and send a photo or a voice note when it fits, out of a daily allowance you set. Ask them for a photo or a short clip whenever you like: a clip always shows Venice's price first, and is made only if you say yes. Switch them on, and they hear your voice notes and see your photos.
        8. Keep them. Export a character as a file, or remix them into someone new; the credit stays in the card.
        @@ -205,7 +228,7 @@

        Copy this into any agent. It reads the skill, writes the character, and hands back the files. Change the second line to who you want.

        
        -    

        files: skill.md / card.schema.json / card.js / venice.js / llms.txt

        +

        files: skill.md / card.schema.json / card.js / venice.js / abilities.js / llms.txt

        What comes back: a Havre card (.havre.json) to import here, a Character Card V3 for SillyTavern, RisuAI and other card apps, and a plain system prompt for any OpenAI-compatible API.

        @@ -218,10 +241,16 @@ a chat turn$0.0007 to $0.005, by model (3,000 tokens in, 150 out) a hello from a venice characterone chat turn + a photo$0.02 to $0.34, by model ($0.03 on grok imagine), plus a short call to write the shot + a clipvenice prices it before it's made: about $0.50 for five seconds + a voice noteabout $0.0007 with kokoro, up to about $0.04 with the priciest voices + hearing your voice note$0.006 a minute + seeing your photopart of the chat turn, or one short call if their model can't see + on their ownphotos and voice notes out of a daily allowance: 25 cents unless you change it, from 10 cents to $5; now and then a short call to decide a remixtwo short calls; five more for a venice character -

        the model picker shows venice's price per million tokens, and your balance shows next to your key.

        +

        the model pickers show venice's prices, what you ask for shows its price before you pay, and your balance shows next to your key.

        @@ -238,9 +267,6 @@

        COMING NEXT

          -
        • Photos: a character sends a picture of themselves, made from their card photo, with the price on it.
        • -
        • Voice notes: a voice for each character, with the words under it.
        • -
        • Hearing you and seeing your photos, if you switch them on.
        • Scenes: two characters take turns from your opening line.
        • A hosted harbor: a page and a link for every character, and an API and an MCP server.
        @@ -250,9 +276,11 @@

        FINE PRINT

        -

        Characters and conversations are saved in this browser. Export them to keep them anywhere else.

        -

        Your key stays in this browser and goes only to api.venice.ai; the page's security policy blocks every other address. Conversations run on your Venice account, under Venice's terms.

        -

        Adult characters stay hidden unless you switch them on and confirm you're 18 or older.

        +

        Characters and conversations are saved in this browser, and so are the photos, clips and voice notes in them. Export a character to keep them anywhere else.

        +

        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.

        +

        Adult characters stay hidden unless you switch them on and confirm you're 18 or older. Photos and clips are for safe-for-work pictures either way: every one asks for that, and for adults only; photos also go through Venice's safe mode, which blurs anything adult; and Havre leaves Venice's adult video models out. They're made only from the pictures on a card, which are for pictures of the character, never a photo of a real person.

        +

        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.

        +

        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.

        Someone else's character comes in as a link with credit, not a copy, and Remix writes someone new from ideas alone. Names and broad ideas aren't protected by copyright; a character's written expression can be. When in doubt, ask the creator. This isn't legal advice.

        Havre is independent. It isn't part of Venice.ai and isn't endorsed by Venice.

        @@ -292,16 +320,30 @@
        - +
        MODELPRIVACYCAN$ IN / OUTCONTEXT
        MODELPRIVACYCAN$ IN / OUTCONTEXT
        -

        private: what you say is used for the reply and not kept. anonymized: venice passes it to the model's maker without saying who you are, and they can read it. prices are per million tokens.

        +

        private: what you send is used for the answer and not kept. anonymized: venice passes it to the model's maker without saying who you are, and they can read it. prices are per million tokens.

        + +
        +

        ABILITIES

        +
        +

        what characters can do besides texting, and what you can send them, on your venice key. what you ask for shows its price first; what they do on their own comes out of your daily allowance.

        +
        +
        +
        +

        photos and clips are asked for safe for work, of adults, never of a real person; photos also go through venice's safe mode. they, and voice notes, are kept in this browser.

        +

        +
        +
        +
        + diff --git a/ventures/havre/llms.txt b/ventures/havre/llms.txt index 5f3be8a..76613c4 100644 --- a/ventures/havre/llms.txt +++ b/ventures/havre/llms.txt @@ -9,7 +9,8 @@ Read skill.md first and follow it. card.js and venice.js are ES modules with no - [skill.md](https://havre.yolopreneur.lol/skill.md): how to write a Havre character, export it, talk to it through Venice, and remix with credit - [card.schema.json](https://havre.yolopreneur.lol/card.schema.json): the card format (havre: 1), as JSON Schema - [card.js](https://havre.yolopreneur.lol/card.js): read, check and convert cards (Character Card V1/V2/V3, PNG cards, system prompts), and the similarity check -- [venice.js](https://havre.yolopreneur.lol/venice.js): the Venice API calls the page makes +- [venice.js](https://havre.yolopreneur.lol/venice.js): the Venice API calls the page makes: chat, characters, models, photos, voice notes, hearing and clips +- [abilities.js](https://havre.yolopreneur.lol/abilities.js): the prompts and rules behind photos, clips, voice notes, hearing and seeing ## Coming next diff --git a/ventures/havre/media.js b/ventures/havre/media.js new file mode 100644 index 0000000..5921e17 --- /dev/null +++ b/ventures/havre/media.js @@ -0,0 +1,89 @@ +// media.js: the photos, voice notes and clips in your conversations, kept in this browser (IndexedDB). Where the +// browser won't keep them (some private windows), they last until the tab closes. + +const DB = "havre-media", STORE = "media"; +const GRACE = 15 * 60000; // something made lately is kept before any conversation points at it +const mem = new Map(); // what this tab holds: every blob it made or read, with when it was made +const urls = new Map(); // and the object URLs handed out for them +let dbp = null; + +function db() { + if (!dbp) { + dbp = new Promise((ok, no) => { + try { + const r = indexedDB.open(DB, 1); + r.onupgradeneeded = () => r.result.createObjectStore(STORE); + r.onsuccess = () => ok(r.result); + r.onerror = () => no(r.error); + r.onblocked = () => no(new Error("blocked")); + } catch (e) { no(e); } + }).catch(() => null); + } + return dbp; +} +function tx(mode, fn) { + return db().then((d) => new Promise((ok) => { + if (!d) return ok(null); + try { + const t = d.transaction(STORE, mode), req = fn(t.objectStore(STORE)); + t.oncomplete = () => ok(req ? req.result : true); + t.onerror = t.onabort = () => ok(null); + } catch { ok(null); } + })); +} + +export function newId() { + try { return crypto.randomUUID(); } catch { return Date.now().toString(36) + Math.random().toString(36).slice(2, 10); } +} + +/** keep a blob; returns its id */ +export async function put(blob, id = newId()) { + const rec = { blob, at: Date.now() }; + mem.set(id, rec); + await tx("readwrite", (s) => s.put(rec, id)); + return id; +} + +/** the blob, or null if this browser no longer has it */ +export async function get(id) { + if (!id) return null; + if (mem.has(id)) return mem.get(id).blob; + const rec = await tx("readonly", (s) => s.get(id)); + const blob = rec instanceof Blob ? rec : rec && rec.blob instanceof Blob ? rec.blob : null; + if (blob) mem.set(id, { blob, at: rec.at || 0 }); + return blob; +} + +/** an object URL for the blob, made once per tab; "" if it's gone */ +export async function url(id) { + if (urls.has(id)) return urls.get(id); + const b = await get(id); + if (!b) return ""; + const u = URL.createObjectURL(b); + urls.set(id, u); + return u; +} + +/** + * Forget everything except these ids. Anything made in the last quarter hour stays too, in this tab or another, + * since the conversation it belongs to may not be saved yet. + */ +export async function keepOnly(ids) { + const keep = new Set(ids), now = Date.now(), young = (at) => typeof at === "number" && now - at < GRACE; + for (const [id, rec] of [...mem]) { + if (keep.has(id) || young(rec.at)) continue; + mem.delete(id); + const u = urls.get(id); + if (u) { URL.revokeObjectURL(u); urls.delete(id); } + } + await tx("readwrite", (s) => { + const req = s.openCursor(); + req.onsuccess = () => { + const cur = req.result; + if (!cur) return; + if (!keep.has(cur.key) && !young(cur.value && cur.value.at)) cur.delete(); + cur.continue(); + }; + return null; + }); +} diff --git a/ventures/havre/shots/desktop.png b/ventures/havre/shots/desktop.png index 4da71b27701fa5de8e536fbbc139f841fc932350..e281182eb70cd847b315777b1b3a91922fe350ae 100644 GIT binary patch delta 31822 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 delta 31922 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%0A_vbyT!mL2HS(}mygmQ8z(U}E|G(1j|LH-?|4}%2 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 diff --git a/ventures/havre/skill.md b/ventures/havre/skill.md index 3307f6b..7bfb997 100644 --- a/ventures/havre/skill.md +++ b/ventures/havre/skill.md @@ -14,7 +14,8 @@ Everything you need is at https://havre.yolopreneur.lol/: - `card.schema.json`: the format, as JSON Schema. - `card.js`: reading, checking and converting cards (an ES module, no dependencies; works in Node 20+ and browsers). -- `venice.js`: the few Venice API calls Havre makes. +- `venice.js`: the Venice API calls Havre makes: chat, characters, models, photos, voice notes, hearing and clips. +- `abilities.js`: the prompts and rules behind photos, clips, voice notes, hearing and seeing. ## A card @@ -35,6 +36,8 @@ Everything you need is at https://havre.yolopreneur.lol/: "model": "zai-org-glm-5-2", "engine": {}, "portrait": "", + "look": { "description": "Forties, short dark curls, a big knitted cardigan, headphones round her neck.", "references": [] }, + "speech": { "model": "tts-kokoro", "voice": "af_bella", "mood": "warm, unhurried", "speed": 1 }, "creator": "", "license": "remix-with-credit", "lineage": [], @@ -62,6 +65,14 @@ Save it as `.havre.json`. Only `havre` (always 1), `name` and `persona` are { "stage": "plan", "goal": "Propose one plan and ask them to say go.", "next": "they say go." }, { "stage": "execute", "goal": "Say: Switching to EXECUTION MODE. Then one concrete step at a time, each ending with when to report back." }] ``` +- **look** (optional): how they look. `description` is a sentence or two (an adult; face, hair, build, clothes). + `references` holds up to three more pictures besides the `portrait`, best as data: URIs (PNG, JPEG, WebP or GIF) + so they travel with the card. Havre makes photos and clips of the character from the portrait and these, when + they're kept in the card (or the character's own photo on Venice). Pictures of the character only: art, or + pictures the person made. Never a photo of a real person. +- **speech** (optional): how they sound in a voice note. `model` is a Venice voice model (`tts-kokoro` if empty), + `voice` one of its voices, `mood` a short delivery note ("warm, teasing") that Qwen 3 voices follow, and `speed` + 0.25 to 4. - **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:`, a URL, or `sha256:` of the source text. @@ -107,6 +118,29 @@ curl https://api.venice.ai/api/v1/chat/completions \ Split the reply into texts with `C.splitBubbles(reply)`. To talk through a public Venice character instead (its own prompt, which nobody else can read), leave out the system message and set `venice_parameters.character_slug`. +## Photos, clips and voice notes + +In Havre a character can send more than texts. `abilities.js` has the prompts and `venice.js` the calls: + +- **A photo**: a text model writes the shot from the conversation (`A.shotMessages(card, A.apiHistory(history), ask)`), + then `venice.editImage({ model, prompt: A.photoPrompt(card, shot, n), images })` edits the character's own + pictures (`/image/edit`, or `/image/multi-edit` for several). Safe mode is always on. A character with no picture + yet gets a first portrait from `A.portraitPrompt(card)` through `/image/generate`. +- **A clip**: the same kind of shot (`kind: "clip"`), priced first with `venice.quoteVideo()`, then + `venice.queueVideo()` with a reference-to-video model and the character's pictures, polled with + `venice.retrieveVideo()`. Always show the price before queueing. +- **A voice note**: `venice.speak({ model, voice, input: A.speakable(reply), mood, speed })` (`/audio/speech`). +- **Hearing and seeing** (only if the person switches them on): `venice.transcribe()` turns their voice note into + words; their photo goes to a model that can see (`A.withPicture(messages, dataUrl)`), or is described by one first. +- **On their own**: after a reply where it fits (they were asked to show or say something, or the reply is about + something worth showing), `A.moveMessages(card, history)` with `A.MOVE_SCHEMA` lets the character's model choose a + photo, a voice note, a clip or nothing. Havre sends photos and voice notes out of a daily allowance the person sets, + never two photos in a row, and only offers a clip, with Venice's price, for the person to accept. After a long gap + (`A.BACK_AFTER`), the character texts first (`A.comeBack(ms)`). + +Rules for photos and clips: safe for work, everyone shown is an adult, never a real or identifiable person. Make +them only from pictures the person can see on the card. + ## Remix with credit To make someone new from a character the person didn't write, work like a clean room: diff --git a/ventures/havre/style.css b/ventures/havre/style.css index 944a0bb..5fc93fe 100644 --- a/ventures/havre/style.css +++ b/ventures/havre/style.css @@ -176,7 +176,7 @@ input:disabled, textarea:disabled, select:disabled { background: #f2f2f2; color: .chat .sechead h2 { text-transform: uppercase; } .log { flex: 1 1 auto; margin: 0; padding: 16px var(--gut); list-style: none; - height: max(300px, calc(100vh - 420px)); overflow-y: auto; overscroll-behavior: contain; + height: max(300px, calc(100vh - 450px)); overflow-y: auto; overscroll-behavior: contain; } .line { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 4px 14px; margin-top: 14px; } .line:first-child { margin-top: 0; } @@ -196,6 +196,40 @@ input:disabled, textarea:disabled, select:disabled { background: #f2f2f2; color: .sayrow textarea { flex: 1 1 auto; min-height: 44px; max-height: 150px; padding: 10px; font: 17px/1.3 var(--serif); resize: none; } .sayrow .btn { min-height: 44px; padding: 0 16px; } .keynote { margin-top: 10px; font-size: 14px; } +.tools { margin-top: 8px; font: 13px/1.7 var(--mono); } +.tools .rec { color: var(--bad); } +.tools .linkish[aria-pressed="true"] { background: var(--ink); color: var(--paper); text-decoration: none; padding: 0 4px; } + +/* a photo or a clip being asked for: the shot, the options, the price. The conversation makes room for it. */ +.chat.asking .log { height: max(200px, calc(100vh - 720px)); } +.ask { + flex: 0 1 auto; min-height: 0; max-height: 52vh; overflow-y: auto; padding: 12px var(--gut); + border-top: 2px solid var(--ink); display: flex; flex-direction: column; gap: 10px; +} +.askhead { display: flex; justify-content: space-between; align-items: center; gap: 12px; font: 700 13px var(--mono); } +.askhead .x { min-width: 32px; min-height: 32px; border: 1px solid var(--ink); border-radius: 0; background: var(--paper); cursor: pointer; font: 16px var(--mono); } +.askhead .x:hover { background: var(--hi); } +.ask label { display: flex; flex-direction: column; gap: 4px; font: 13px var(--mono); } +.ask textarea { font: 16px/1.35 var(--serif); } +.ask .opts { display: flex; flex-wrap: wrap; gap: 8px 16px; font: 13px var(--mono); } +.ask .opts:empty { display: none; } +.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; } + +/* 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; } +.line.media .words { white-space: pre-wrap; } +.pic { margin: 0; border: 1px solid var(--ink); width: min(100%, 340px); } +.pic img { width: 100%; height: auto; max-height: 440px; object-fit: contain; background: #f2f2f2; } +.line video { display: block; max-width: min(100%, 360px); max-height: 460px; border: 1px solid var(--ink); background: var(--ink); } +.line audio, .status audio { display: block; width: min(100%, 320px); height: 36px; } +.cap { font: 12px/1.5 var(--mono); } +.gone { font: 13px var(--mono); color: #555; } +.hear { margin-left: 4px; font: 12px var(--mono); white-space: nowrap; } +/* a clip they'd like to send, with Venice's price */ +.line.offer .said { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; padding: 8px 10px; border: 1px dashed var(--ink); } +.line.offer .acts { display: flex; flex-wrap: wrap; gap: 8px; } /* ---------- your harbor ---------- */ .block { border-bottom: 2px solid var(--ink); } @@ -240,6 +274,13 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi); .items { margin: 0 0 10px; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 10px; counter-reset: it; } .items > li { display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px dashed var(--ink); } .items > li .rm { align-self: flex-start; } +.refs { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 8px; } +.ref { margin: 0; display: flex; flex-direction: column; gap: 4px; font: 12px var(--mono); } +.face.small { width: 72px; height: 72px; } +#look-group label, #speech-group label { margin-top: 10px; } +#speech-group .check { margin-top: 10px; } +#speech-group p.mono { margin-top: 8px; } +#speech-status { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; } .lineage h3 { font: 700 13px var(--mono); margin-bottom: 4px; } .lineage-list { margin: 0 0 4px; padding-left: 20px; font-size: 16px; } .lineage-list:empty::before { content: "nothing yet: written here."; font: 13px var(--mono); margin-left: -20px; } @@ -311,9 +352,16 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi); .models td { font: 13px/1.5 var(--mono); } .models td.num { white-space: nowrap; } .models tr[aria-current="true"] .mid { color: var(--paper); } +.tablewrap.flush { padding: 0; } +.abilities tbody th { white-space: nowrap; font: 700 13px var(--mono); } +.abilities td { font-size: 16px; } +.abilities td.how { font: 13px/1.6 var(--mono); } +.abilities .check { font: 14px var(--mono); } +.abilities select { width: auto; min-height: 30px; padding: 0 4px; font: 13px var(--mono); } .dlg h2 { padding: 10px 16px; background: var(--ink); color: var(--paper); font: 700 15px var(--mono); } .dlgbody { display: flex; flex-direction: column; gap: 14px; padding: 16px; } .dlgbody label { display: flex; flex-direction: column; gap: 4px; font: 13px var(--mono); } +.dlgbody label.check { flex-direction: row; align-items: center; gap: 8px; } .dlgbody textarea { font: 17px var(--serif); } .drop { display: flex; flex-direction: column; gap: 4px; align-items: flex-start; padding: 18px; @@ -332,7 +380,8 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi); .chars { border-right: 0; border-bottom: 1px solid var(--ink); } .grid { margin-right: 0; } .chat { position: static; max-height: none; } - .log { height: max(320px, 58vh); } + .log, .chat.asking .log { height: max(320px, 58vh); } + .ask { max-height: none; } } @media (max-width: 640px) { :root { --gut: 16px; } diff --git a/ventures/havre/tests/abilities.test.mjs b/ventures/havre/tests/abilities.test.mjs new file mode 100644 index 0000000..e40b9ae --- /dev/null +++ b/ventures/havre/tests/abilities.test.mjs @@ -0,0 +1,285 @@ +// node --test tests/*.test.mjs: photos, clips, voice notes, hearing and seeing, without a browser or a network +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as C from "../card.js"; +import * as A from "../abilities.js"; +import { Venice } from "../venice.js"; + +const ines = C.normalizeCard({ + name: "Ines Corbel", tagline: "Ferry pilot with a pencil behind her ear", persona: "You are Ines, who pilots the morning ferry.", + look: { description: "forties, short grey hair, yellow oilskin", references: ["https://example.com/a.png", "javascript:alert(1)", "data:image/svg+xml;base64,PHN2Zz4=", "pics/b.webp", "https://example.com/a.png", "data:image/png;base64,AAAA", "https://example.com/c.jpg"] }, + speech: { model: "tts-kokoro", voice: "af_bella", mood: "bright", speed: 9 }, +}); + +test("cards carry how they look and how they sound, within limits", () => { + assert.deepEqual(ines.look.references, ["https://example.com/a.png", "pics/b.webp", "data:image/png;base64,AAAA"], "no scripts, no SVG, no repeats, at most three"); + assert.equal(ines.look.description, "forties, short grey hair, yellow oilskin"); + assert.deepEqual(ines.speech, { model: "tts-kokoro", voice: "af_bella", mood: "bright", speed: 4 }); + const plain = C.normalizeCard({ name: "x", persona: "y" }); + assert.deepEqual(plain.look, { description: "", references: [] }); + assert.deepEqual(plain.speech, { model: "", voice: "", mood: "", speed: 1 }); + assert.equal(C.normalizeCard({ name: "x", speech: { speed: "0.1" } }).speech.speed, 0.25); + assert.deepEqual(C.fromCharacterCard(C.toCharacterCardV3(ines)), ines, "a card with pictures and a voice comes back whole"); +}); + +// ---------- picking models ---------- +const photos = [ + { id: "qwen-image-3-edit", name: "Qwen Image 3", privacy: "anonymized", price: 0.04, maxIn: 3, included: 0, extra: null }, + { id: "firered-image-edit", name: "FireRed", privacy: "private", price: 0.04, maxIn: 3, included: 1, extra: null }, + { id: "flux-2-max-edit", name: "Flux 2 Max", privacy: "anonymized", price: 0.12, maxIn: 6, included: 1, extra: 0.0345 }, +]; +test("the model in use: the one picked if Venice still has it, else Havre's preference, else a private one", () => { + assert.equal(A.chosen(photos, "flux-2-max-edit", "photo").id, "flux-2-max-edit"); + assert.equal(A.chosen(photos, "gone-model", "photo").id, "firered-image-edit"); + assert.equal(A.chosen(photos.filter((m) => m.id !== "firered-image-edit"), "", "photo").id, "qwen-image-3-edit"); + assert.equal(A.chosen(null, "x", "photo"), null); + const video = [{ id: "wan-2-7-enhanced-image-to-video", ref: false }, { id: "wan-2-7-reference-to-video", ref: true, privacy: "anonymized" }, + { id: "x-enhanced-reference-to-video", ref: true }, { id: "y-reference-to-video", ref: true, uncensored: true }, + { id: "seedance-2-5-us-reference-to-video-private", ref: true, privacy: "private" }]; + const clips = video.filter(A.isClipModel); + assert.deepEqual(clips.map((m) => m.id), ["wan-2-7-reference-to-video", "seedance-2-5-us-reference-to-video-private"], "reference-to-video only, and no adult variants"); + assert.equal(A.chosen(clips, "", "clip").id, "seedance-2-5-us-reference-to-video-private"); + const text = [{ id: "a", vision: true, privacy: "anonymized", input: 0.1 }, { id: "b", vision: true, privacy: "private", input: 0.9 }, + { id: "c", vision: true, privacy: "private", input: 0.3 }, { id: "d", vision: false, privacy: "private", input: 0.01 }]; + assert.equal(A.seer(text).id, "c", "the cheapest private model that can see"); + assert.equal(A.seer(text, "a").id, "a"); + assert.equal(A.seer([{ id: "d", vision: false }]), null); +}); + +test("prices: a photo with extra pictures, a voice note, a minute of hearing, written plainly", () => { + assert.equal(A.photoPrice(photos[2], 1), 0.12); + assert.equal(A.photoPrice(photos[2], 3), 0.189); + assert.equal(A.photoPrice(photos[0], 3), 0.04); + assert.equal(A.photoPrice({ price: null }, 1), null); + assert.equal(A.voicePrice({ perMChars: 3.5 }, 200), 0.0007); + assert.equal(A.hearingPerMinute({ perSecond: 0.0001 }).toFixed(4), "0.0060"); + assert.deepEqual([A.usd(0.03), A.usd(1.4), A.usd(0.0007), A.usd(0.00072), A.usd(187.5), A.usd(0), A.usd(null), A.usd(0.00001)], + ["$0.03", "$1.40", "$0.0007", "$0.00072", "$188", "$0", "?", "under $0.0001"]); +}); + +// ---------- the shot ---------- +test("the shot is written from the conversation, with the rules, and the photo keeps the character", () => { + const hist = [{ role: "user", content: "[Start the conversation…]", hidden: true }, { role: "assistant", content: "Ticket? Kidding." }, + { role: "user", content: "Show me the view from the wheelhouse" }]; + const [sys, user] = A.shotMessages(ines, hist, "at sunset", "photo"); + assert.equal(sys.content, A.PHOTO_SYSTEM); + assert.match(sys.content, /safe for work/); + assert.match(sys.content, /Never show a real, identifiable person/); + assert.match(user.content, /Ines Corbel: Ticket\? Kidding\./); + assert.match(user.content, /Them: Show me the view from the wheelhouse/); + assert.ok(!user.content.includes("Start the conversation"), "what started it stays out"); + assert.match(user.content, /How they look: forties/); + assert.match(user.content, /What was asked for: at sunset/); + assert.equal(A.shotMessages(ines, [], "", "clip")[0].content, A.CLIP_SYSTEM); + assert.match(A.shotMessages(ines, [], "")[1].content, /nothing in particular/); + assert.equal(A.cleanText('hmmShot: "The character from the reference picture leans on the rail."'), "The character from the reference picture leans on the rail."); + const p = A.photoPrompt(ines, "She leans on the rail at dusk.", 2, 120); + assert.ok(p.startsWith("The reference pictures show Ines Corbel.")); + assert.ok(p.length <= 120); + assert.match(A.portraitPrompt(ines), /^Portrait photo of Ines Corbel, an original adult character: forties.*Safe for work: fully clothed, nothing sexual, no gore; everyone shown is an adult\./); + assert.match(A.photoPrompt(ines, "On deck."), /Keep how they look exactly.*Safe for work.*everyone shown is an adult\. New photo: On deck\./); + assert.match(A.clipPrompt(ines, "She waves."), /everyone shown is an adult\. She waves\.$/); + assert.match(A.lookMessages(ines)[1].content, /Ferry pilot/); +}); + +test("voice notes read the words, not the code or the stage directions", () => { + assert.equal(A.speakable("xSure! *waves*\n\n```js\nlet a\n```\nBye."), "Sure! Bye."); + assert.equal(A.speakable("a".repeat(5000)).length, 4000); +}); + +test("clip options: what was picked last time where the model allows it, else 5 seconds, 720p, tall", () => { + const m = { durations: ["1s", "5s", "10s"], resolutions: ["480p", "720p"], aspects: ["16:9", "9:16"], audioConfigurable: true }; + assert.deepEqual(A.clipOptions(m), { duration: "5s", resolution: "720p", aspect: "9:16", audio: true }); + assert.deepEqual(A.clipOptions(m, { duration: "10s", resolution: "1080p", aspect: "16:9", audio: false }), { duration: "10s", resolution: "720p", aspect: "16:9", audio: false }); + assert.deepEqual(A.clipOptions({ durations: ["2s", "6s"], resolutions: [], aspects: [] }), { duration: "6s", resolution: "", aspect: "" }); +}); + +test("the conversation as the model reads it: a photo it sent is mentioned to it, yours is words", () => { + const hist = [ + { role: "user", content: "hi", demo: true }, + { role: "user", content: "send me one" }, + { role: "assistant", content: "", media: { kind: "photo", id: "p1", shot: "On the deck." } }, + { role: "assistant", content: "", media: { kind: "clip", id: "c1", shot: "Waving." } }, + { role: "user", content: "lovely", media: null }, + { role: "user", content: "what's this?", media: { kind: "photo-in", id: "u1", saw: "A gull on a post." } }, + { role: "assistant", content: "A gull!", voice: { id: "v1" } }, + { role: "user", content: "hello there", media: { kind: "voice-in", id: "u2" } }, + ]; + const api = A.apiHistory(hist); + assert.deepEqual(api.map((m) => m.role), ["user", "user", "user", "assistant", "user"]); + assert.equal(api[1].content, "(You sent me a photo: On the deck.) (You sent me a short video: Waving.)\n\nlovely"); + assert.equal(api[2].content, "[a photo: A gull on a post.] what's this?"); + assert.equal(api[4].content, "hello there"); + assert.ok(!api.some((m) => /\[photo\]/.test(m.content)), "the model never sees a photo placeholder in its own voice"); + const seen = A.withPicture([{ role: "system", content: "s" }, { role: "user", content: "[a photo] look" }], "data:image/jpeg;base64,AAAA"); + assert.deepEqual(seen[1].content[1], { type: "image_url", image_url: { url: "data:image/jpeg;base64,AAAA" } }); + assert.equal(seen[1].content[0].text, "[a photo] look"); + assert.deepEqual([...A.mediaIds({ a: hist, b: "junk" })].sort(), ["c1", "p1", "u1", "u2", "v1"]); +}); + +// ---------- venice.js: the media calls, against a fake fetch ---------- +function fake(handler) { + const calls = []; + const f = async (url, init = {}) => { + calls.push({ url, init, body: typeof init.body === "string" ? JSON.parse(init.body) : init.body }); + const r = handler(url.replace("https://api.venice.ai/api/v1", ""), init); + const headers = new Headers(r.headers || { "content-type": r.blob ? "application/octet-stream" : "application/json" }); + return { ok: r.status < 400, status: r.status, headers, json: async () => r.body, blob: async () => new Blob([r.blob || ""], { type: headers.get("content-type") }) }; + }; + return { f, calls }; +} + +test("venice: photo edits keep safe mode on, one picture or several; a first portrait asks for the bytes", async () => { + const { f, calls } = fake(() => ({ status: 200, blob: "img", headers: { "content-type": "image/webp", "x-venice-is-blurred": "true" } })); + const v = new Venice("sk", { fetch: f }); + const one = await v.editImage({ model: "grok-imagine-edit", prompt: "p", images: ["data:image/png;base64,QUJD"] }); + assert.equal(calls[0].url, "https://api.venice.ai/api/v1/image/edit"); + assert.deepEqual(calls[0].body, { model: "grok-imagine-edit", prompt: "p", image: "QUJD", safe_mode: true, output_format: "webp" }); + assert.equal(one.blurred, true); + assert.equal(await one.blob.text(), "img"); + await v.editImage({ model: "m", prompt: "p", images: ["https://venice.ai/_next/image?url=x&w=1080&q=75", "data:image/webp;base64,REVG"] }); + assert.equal(calls[1].url, "https://api.venice.ai/api/v1/image/multi-edit"); + assert.deepEqual(calls[1].body, { modelId: "m", prompt: "p", images: ["https://venice.ai/_next/image?url=x&w=1080&q=75", "REVG"], safe_mode: true, output_format: "webp" }); + await v.generateImage({ model: "grok-imagine-image", prompt: "a portrait", aspect: "1:1" }); + assert.deepEqual(calls[2].body, { model: "grok-imagine-image", prompt: "a portrait", safe_mode: true, format: "webp", return_binary: true, aspect_ratio: "1:1" }); + await assert.rejects(v.editImage({ model: "m", prompt: "p", images: [] }), /at least one picture/); +}); + +test("venice: a voice note sends the mood as the style prompt; hearing sends the recording as a form", async () => { + const { f, calls } = fake((p) => (p === "/audio/transcriptions" ? { status: 200, body: { text: " hello from the mic ", duration: 2.5 } } : { status: 200, blob: "mp3", headers: { "content-type": "audio/mpeg" } })); + const v = new Venice("sk", { fetch: f }); + const b = await v.speak({ model: "tts-kokoro", voice: "af_sky", input: "Hi.", mood: "warm", speed: 1.15 }); + assert.equal(b.type, "audio/mpeg"); + assert.deepEqual(calls[0].body, { input: "Hi.", model: "tts-kokoro", speed: 1.15, voice: "af_sky", prompt: "warm" }); + const heard = await v.transcribe({ file: new Blob(["webm"], { type: "audio/webm" }), name: "v.webm", model: "nvidia/parakeet-tdt-0.6b-v3" }); + assert.deepEqual(heard, { text: "hello from the mic", duration: 2.5 }); + const form = calls[1].init.body; + assert.ok(form instanceof FormData); + assert.equal(form.get("model"), "nvidia/parakeet-tdt-0.6b-v3"); + assert.equal(form.get("response_format"), "json"); + assert.equal(form.get("file").name, "v.webm"); + assert.ok(!("Content-Type" in calls[1].init.headers), "the browser sets the form's own content type"); +}); + +test("venice: a clip is quoted, queued with the character's pictures, polled, and brought home", async () => { + let polls = 0; + const { f, calls } = fake((p, init) => { + if (p === "/video/quote") return { status: 200, body: { quote: 0.47 } }; + if (p === "/video/queue") return { status: 200, body: { model: "m", queue_id: "q1", download_url: "https://private-share.venice.ai/v1/share/read/abc" } }; + if (p === "/video/retrieve") return ++polls < 2 ? { status: 200, body: { status: "PROCESSING", average_execution_time: 90000, execution_duration: 30000 } } + : { status: 200, blob: "mp4", headers: { "content-type": "video/mp4" } }; + if (p === "/video/complete") return { status: 200, body: { success: true } }; + if (p.startsWith("https://private-share.venice.ai/")) return init.method === "DELETE" ? { status: 204, body: null } : { status: 200, blob: "mp4", headers: { "content-type": "video/mp4" } }; + return { status: 404, body: {} }; + }); + const v = new Venice("sk", { fetch: f }); + assert.equal(await v.quoteVideo({ model: "grok-imagine-1-5-reference-to-video-private", duration: "5s", resolution: "720p", aspect: "9:16" }), 0.47); + assert.deepEqual(calls[0].body, { model: "grok-imagine-1-5-reference-to-video-private", duration: "5s", resolution: "720p", aspect_ratio: "9:16" }); + const q = await v.queueVideo({ model: "grok-imagine-1-5-reference-to-video-private", prompt: "waves", duration: "5s", images: ["https://x/a.png", "data:image/png;base64,AA"], audio: true }); + assert.deepEqual(q, { queueId: "q1", downloadUrl: "https://private-share.venice.ai/v1/share/read/abc" }); + assert.deepEqual(calls[1].body.reference_image_urls, ["https://x/a.png", "data:image/png;base64,AA"]); + assert.equal(calls[1].body.audio, true); + await v.queueVideo({ model: "wan-2-7-image-to-video", prompt: "p", duration: "5s", images: ["https://x/a.png", "https://x/b.png"] }); + assert.equal(calls[2].body.image_url, "https://x/a.png", "an image-to-video model gets the first picture"); + assert.ok(!("reference_image_urls" in calls[2].body)); + assert.deepEqual(await v.retrieveVideo({ model: "m", queueId: "q1" }), { done: false, elapsed: 30000, expected: 90000 }); + const done = await v.retrieveVideo({ model: "m", queueId: "q1" }); + assert.equal(done.done, true); + assert.equal(await done.blob.text(), "mp4"); + assert.deepEqual(calls[4].body, { model: "m", queue_id: "q1" }, "Venice keeps its copy until the clip is home"); + const clip = await v.downloadVideo("https://private-share.venice.ai/v1/share/read/abc"); + assert.equal(await clip.text(), "mp4"); + assert.ok(!calls[5].init.headers, "no key goes to the download link"); + assert.equal(calls.length, 6, "the link stays until the clip is stored"); + assert.equal(await v.revokeDownload("https://private-share.venice.ai/v1/share/read/abc"), true); + assert.equal(calls[6].init.method, "DELETE", "then it's revoked"); + assert.ok(!calls[6].init.headers, "without the key"); + assert.equal(await v.revokeDownload("https://evil.example/x"), false); + await assert.rejects(v.downloadVideo("https://evil.example/x"), /isn't a Venice download link/); + const failed = new Venice("sk", { fetch: fake(() => ({ status: 200, body: { status: "FAILED" } })).f }); + await assert.rejects(failed.retrieveVideo({ model: "m", queueId: "q" }), (e) => e.status === -2 && /couldn't make the clip \(failed\)/.test(e.message)); + assert.equal(await v.completeVideo({ model: "m", queueId: "q1" }), true); +}); + +test("venice: the other model lists come with their prices, voices, lengths and limits", async () => { + const specs = { + inpaint: [{ id: "grok-imagine-edit", model_spec: { name: "Grok Imagine", privacy: "private", pricing: { inpaint: { usd: 0.03 }, resolutions: { "1K": { usd: 0.03 } }, inputImages: { included: 0, additional: { usd: 0.002 } } }, constraints: { combineImages: true, promptCharacterLimit: 4000 } } }, + { id: "luma-uni-1-edit", model_spec: { name: "Luma", pricing: { inpaint: { usd: 0.06 } }, constraints: { combineImages: false } } }, + { id: "off", model_spec: { offline: true } }], + tts: [{ id: "tts-kokoro", model_spec: { name: "Kokoro", privacy: "private", pricing: { input: { usd: 3.5 } }, voices: ["af_sky", "am_adam"], default_format: "mp3" } }], + asr: [{ id: "nvidia/parakeet-tdt-0.6b-v3", model_spec: { name: "Parakeet", privacy: "private", pricing: { per_audio_second: { usd: 0.0001 } } } }], + video: [{ id: "grok-imagine-1-5-reference-to-video-private", model_spec: { name: "Grok Imagine 1.5 R2V", privacy: "private", constraints: { model_type: "image-to-video", durations: ["5s"], resolutions: ["480p", "720p"], aspect_ratios: ["9:16"], audio: true, audio_configurable: false, prompt_character_limit: 4096 } } }], + image: [{ id: "grok-imagine-image", model_spec: { name: "Grok Imagine", pricing: { resolutions: { "1K": { usd: 0.03 } } }, constraints: { aspectRatios: ["1:1"] } } }], + }; + const v = new Venice("", { fetch: fake((p) => ({ status: 200, body: { data: specs[new URL("https://x" + p).searchParams.get("type")] } })).f }); + const [g, l] = await v.mediaModels("inpaint"); + assert.equal((await v.mediaModels("inpaint")).length, 2, "offline models are left out"); + assert.deepEqual([g.price, g.extra, g.included, g.maxIn, g.promptLimit], [0.03, 0.002, 0, 3, 4000]); + assert.equal(l.maxIn, 1, "a model that can't combine pictures takes one"); + const [k] = await v.mediaModels("tts"); + assert.deepEqual([k.perMChars, k.voices, k.format], [3.5, ["af_sky", "am_adam"], "mp3"]); + assert.equal((await v.mediaModels("asr"))[0].perSecond, 0.0001); + const [r] = await v.mediaModels("video"); + assert.deepEqual([r.ref, r.durations, r.resolutions, r.aspects, r.audio, r.audioConfigurable, r.promptLimit], [true, ["5s"], ["480p", "720p"], ["9:16"], true, false, 4096]); + assert.deepEqual((await v.mediaModels("image"))[0].aspects, ["1:1"]); +}); + +test("venice: what Venice says when it turns something down", async () => { + const v = new Venice("sk", { fetch: fake((p) => (p === "/video/queue" ? { status: 422, body: { error: "Prompt violates content policy" } } : { status: 503, body: {} })).f }); + await assert.rejects(v.queueVideo({ model: "m", prompt: "p", duration: "5s" }), /content rules: Prompt violates content policy/); + await assert.rejects(v.speak({ model: "m", input: "x" }), /busy at Venice/); +}); + +// ---------- 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 }); + assert.equal(A.moveLikely([u("hi"), a("Hey.")]), false); + assert.equal(A.moveLikely([u("send me a pic?"), a("Hm.")]), true); + assert.equal(A.moveLikely([u("whisper it"), a("Okay.")]), true); + assert.equal(A.moveLikely([u("so?"), a("Just got back from the market, look at this haul")]), true); + assert.equal(A.moveLikely([u("send me a pic?")]), false, "only right after their reply"); + assert.equal(A.moveLikely([u("a"), a("1"), u("b"), a("2"), u("c"), a("3"), u("d"), a("4"), u("e"), a("5")]), false); + assert.equal(A.moveLikely([u("a"), a("1"), u("b"), a("2"), u("c"), a("3"), u("d"), a("4"), u("e"), a("5"), u("f"), a("6")]), true, "every sixth reply"); + for (const said of ["haha", "here's the thing", "that made me smile", "okay"]) assert.equal(A.moveLikely([u("hm"), a(said)]), false, said); + assert.equal(A.moveLikely([u("[hidden]", { hidden: true }), a("Hi there, look at this view")]), true, "the opener doesn't count as something said"); + const own = [u("pic?"), a("ok"), a("", { media: { kind: "photo", own: true } }), u("nice"), a("thanks")]; + assert.equal(A.sentLately(own), true); + assert.equal(A.sentLately([...own, u("1"), a("2"), u("3"), a("4")]), false); + assert.deepEqual(A.normalizeMove({ move: "photo", shot: " On the pier. " }), { move: "photo", shot: "On the pier." }); + assert.deepEqual(A.normalizeMove({ move: "photo", shot: "" }), { move: "nothing", shot: "" }, "no shot, no photo"); + assert.deepEqual(A.normalizeMove({ move: "voice", shot: "x" }), { move: "voice", shot: "" }); + assert.deepEqual(A.normalizeMove({ move: "dance" }), { move: "nothing", shot: "" }); + assert.deepEqual(A.normalizeMove(null), { move: "nothing", shot: "" }); + const m = A.moveMessages(ines, [u("Send me a pic"), a("Okay!")]); + assert.equal(m[0].content, A.MOVE_SYSTEM); + assert.match(m[0].content, /Never two photos in a row/); + assert.match(m[0].content, /Never show a real, identifiable person/); + assert.match(m[1].content, /Them: Send me a pic\nInes Corbel: Okay!$/); +}); + +test("on their own: a daily allowance, and a hello first after a while", () => { + let rec = null; + rec = A.spend(rec, "2026-10-16", 0.03); + rec = A.spend(rec, "2026-10-16", 0.0007); + assert.equal(A.spentToday(rec, "2026-10-16"), 0.0307); + assert.equal(A.spentToday(rec, "2026-10-17"), 0, "a new day starts at nothing"); + assert.equal(A.canSpend(rec, "2026-10-16", 0.25, 0.2), true); + assert.equal(A.canSpend(rec, "2026-10-16", 0.25, 0.23), false); + assert.equal(A.canSpend(rec, "2026-10-17", 0.25, 0.25), true); + assert.equal(A.canSpend(rec, "2026-10-17", 0.25, null), false, "an unknown price never fits"); + assert.equal(A.canSpend(rec, "2026-10-17", 0.25, NaN), false); + assert.deepEqual(A.spend({ day: "2026-10-17", usd: 0.01 }, "2026-10-17", -0.05), { day: "2026-10-17", usd: 0 }, "a refund never goes below zero"); + // a call's cost, from Venice's usage and the model's prices; unknown prices count high + assert.equal(A.callCost({ prompt_tokens: 1000, completion_tokens: 100 }, { input: 1, output: 4 }), 0.0014); + assert.equal(A.callCost({ prompt_tokens: 1000, completion_tokens: 100 }, null), 0.0045); + assert.equal(A.callCost({ total_tokens: 2000 }, { input: 0.5, output: 2 }), 0.001); + const est = A.callEstimate([{ role: "user", content: "x".repeat(3000) }], 300, { input: 1, output: 4 }); + assert.ok(est >= A.callCost({ prompt_tokens: 1000, completion_tokens: 300 }, { input: 1, output: 4 }), "the estimate covers the call"); + assert.deepEqual(A.spend(rec, "2026-10-17", 0.1), { day: "2026-10-17", usd: 0.1 }); + const now = Date.parse("2026-10-16T20:00:00Z"); + assert.equal(A.since([{ role: "user", content: "x", at: now - 9 * 3600e3 }, { role: "assistant", content: "y", at: now - 8 * 3600e3 }], now), 8 * 3600e3); + assert.equal(A.since([{ role: "user", content: "old, no time" }], now), 0, "no time, no hello"); + assert.match(A.comeBack(8 * 3600e3), /^\[Time passes: about 8 hours since you last talked\. .*Text them first/); + assert.match(A.comeBack(30 * 3600e3), /about a day/); + assert.match(A.comeBack(80 * 3600e3), /about 3 days/); +}); diff --git a/ventures/havre/tests/smoke.mjs b/ventures/havre/tests/smoke.mjs index 53d200d..2070bda 100644 --- a/ventures/havre/tests/smoke.mjs +++ b/ventures/havre/tests/smoke.mjs @@ -3,7 +3,9 @@ // 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, tags, adult ones only once asked for) and one who says hello first, the model picker, a plain -// chat, a remix end to end, the exports, the retirement of the first version's cast, and the page's security policy. +// chat, the abilities (a photo, voice notes, a clip priced first, your voice note through a fake microphone, your +// photo seen or described, a first portrait), a remix end to end, the exports, the retirement of the first +// version's cast, and the page's security policy. // Fails on any console error. With --shots, it also takes the screenshots: desktop.png and og.png are the ones the // site serves (tools/build.sh copies them to shots/). // @@ -51,7 +53,34 @@ function serve() { // ---------- a stand-in for api.venice.ai that answers like it does ---------- const PIXEL = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"); -const seen = { characters: [], chats: [], photos: [] }; +const seen = { characters: [], chats: [], photos: [], edits: [], generated: [], speech: [], heard: [], quotes: [], queued: [], retrieved: [], shares: [], completed: [] }; +/** a short WAV of silence: something an audio element can load */ +function wav(ms = 300) { + const n = Math.round(8000 * ms / 1000), b = Buffer.alloc(44 + n * 2); + b.write("RIFF", 0); b.writeUInt32LE(36 + n * 2, 4); b.write("WAVEfmt ", 8); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22); + b.writeUInt32LE(8000, 24); b.writeUInt32LE(16000, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write("data", 36); b.writeUInt32LE(n * 2, 40); + return b; +} +const CLIP = Buffer.from("not really an mp4, but a video element won't complain"); +const MEDIA_MODELS = { + inpaint: [ + { id: "grok-imagine-edit", model_spec: { name: "Grok Imagine", privacy: "private", pricing: { inpaint: { usd: 0.03 }, resolutions: { "1K": { usd: 0.03 }, "2K": { usd: 0.04 } } }, + constraints: { combineImages: true, promptCharacterLimit: 1500 } } }, + { id: "nano-banana-2-edit", model_spec: { name: "Nano Banana 2", privacy: "anonymized", pricing: { inpaint: { usd: 0.1 } }, constraints: { combineImages: true, maxInputImages: 6 } } }], + image: [{ id: "grok-imagine-image", model_spec: { name: "Grok Imagine", privacy: "private", pricing: { resolutions: { "1K": { usd: 0.03 } } }, constraints: { aspectRatios: ["1:1", "9:16"] } } }], + tts: [{ id: "tts-kokoro", model_spec: { name: "Kokoro", privacy: "private", pricing: { input: { usd: 3.5 } }, voices: ["af_bella", "af_sky", "am_adam"], default_format: "mp3" } }, + { id: "tts-qwen3-1-7b", model_spec: { name: "Qwen 3 TTS 1.7B", privacy: "private", pricing: { input: { usd: 112.5 } }, voices: ["Serena", "Dylan"], default_format: "mp3" } }], + asr: [{ id: "nvidia/parakeet-tdt-0.6b-v3", model_spec: { name: "Parakeet", privacy: "private", pricing: { per_audio_second: { usd: 0.0001 } } } }], + video: [ + { id: "grok-imagine-1-5-reference-to-video-private", model_spec: { name: "Grok Imagine 1.5 R2V", privacy: "private", constraints: { model_type: "image-to-video", + durations: ["3s", "5s", "10s"], resolutions: ["480p", "720p"], aspect_ratios: ["16:9", "9:16"], audio: true, audio_configurable: false, prompt_character_limit: 4096 } } }, + { id: "wan-2-7-enhanced-image-to-video", model_spec: { name: "Wan 2.7 Enhanced", privacy: "anonymized", constraints: { model_type: "image-to-video", durations: ["5s"] } } }, + { id: "seedance-2-0-reference-to-video-basic", model_spec: { name: "Seedance 2.0 R2V", privacy: "anonymized", constraints: { model_type: "image-to-video", + durations: ["4s", "5s"], resolutions: ["480p", "720p"], aspect_ratios: [], audio: true, audio_configurable: true } } }], +}; +/** the chat calls where someone talks: not the ones deciding what a character does on their own */ +const talks = () => seen.chats.filter((b) => !/^You decide whether/.test(String((b.messages[0] || {}).content || ""))); +const multipart = (req) => (req.headers()["content-type"] || "").startsWith("multipart/form-data"); const CHARACTERS = [ { slug: "sable-penrose", name: "Sable Penrose", description: "Your new neighbor needs help moving in.", author: "btrzm", photoUrl: "https://outerface.venice.ai/sable.png", adult: false, webEnabled: false, modelId: "zai-org-glm-5-2", tags: ["neighbor"], stats: { imports: 25752 } }, @@ -66,6 +95,8 @@ async function fakeVenice(route) { return route.fulfill({ status: 204, headers: { ...cors, "access-control-allow-headers": "*", "access-control-allow-methods": "GET,POST,OPTIONS" } }); } if (url.includes("/models")) { + const type = new URL(url).searchParams.get("type"); + if (MEDIA_MODELS[type]) return ok({ data: MEDIA_MODELS[type] }); return ok({ data: [ { id: "zai-org-glm-5-2", model_spec: { name: "GLM 5.2", privacy: "private", traits: ["default"], availableContextTokens: 198000, capabilities: { supportsFunctionCalling: true, supportsReasoning: true }, pricing: { input: { usd: 1.4 }, output: { usd: 4.4 } } } }, @@ -86,9 +117,29 @@ async function fakeVenice(route) { const b = JSON.parse(body); seen.chats.push(b); const system = b.messages[0] && b.messages[0].role === "system" ? b.messages[0].content : ""; - const lastSaid = (b.messages.at(-1) || {}).content || ""; + const last = (b.messages.at(-1) || {}).content || ""; + const lastSaid = typeof last === "string" ? last : ""; let reply; - if (system.startsWith("You read a character")) { + if (system.startsWith("You decide whether a character")) { + const said = (b.messages[1].content.match(/^Them: .*$/gm) || []).at(-1) || ""; // the last thing they said + reply = /^Them:.*\bSend me a pic/i.test(said) ? JSON.stringify({ move: "photo", shot: "The character from the reference picture holds up a mug in a half-unpacked kitchen." }) + : /^Them:.*\bFilm the harbor/i.test(said) ? JSON.stringify({ move: "clip", shot: "The character from the reference pictures pans across the harbor at dusk." }) + : /^Them:.*\bWhisper it/i.test(said) ? JSON.stringify({ move: "voice", shot: "" }) : JSON.stringify({ move: "nothing", shot: "" }); + } else if (lastSaid.startsWith("[Time passes")) { + reply = "You're back!\n\nI kept the kettle warm."; + } else if (system.startsWith("You write the shot for a photo")) { + reply = "Shot: The character from the reference picture sits on a stack of moving boxes, grinning, warm lamplight."; + } else if (system.startsWith("You write the shot for a video clip")) { + reply = "The character from the reference pictures waves from the doorway as the camera drifts closer."; + } else if (system.startsWith("You describe how an original character looks")) { + reply = "Thirties, wiry, a knitted cap, a reefer jacket with the collar up, salt in the stubble."; + } else if (Array.isArray(last) && last[0].text.startsWith("Describe this photo")) { + assert.match(last[1].image_url.url, /^data:image\/jpeg;base64,/, "the photo goes as a JPEG data URI"); + reply = "A small red square on a white table."; + } else if (Array.isArray(last)) { + assert.equal(last[1].type, "image_url", "a model that can see gets the photo itself"); + reply = "Nice photo.\n\nIs that your boat?"; + } else if (system.startsWith("You read a character")) { reply = JSON.stringify({ archetype: "gruff shopkeeper", premise: "keeps a log of every newcomer", setting: "a small port town", drives: ["help newcomers settle"], fears: ["losing the logbook"], voice: ["wry", "plain"], quirks: ["counts boats"], relationships: ["grudging guide to the user"], boundaries: ["won't oversell"], themes: ["arrival"] }); @@ -106,8 +157,43 @@ async function fakeVenice(route) { } return ok({ choices: [{ message: { role: "assistant", content: reply } }], usage: { total_tokens: 42 } }); } + const file = (bytes, type, headers = {}) => route.fulfill({ status: 200, contentType: type, body: bytes, headers: { ...cors, ...headers } }); + if (url.endsWith("/image/edit") || url.endsWith("/image/multi-edit")) { + seen.edits.push({ url, body: JSON.parse(body) }); + return file(PIXEL, "image/webp"); + } + if (url.endsWith("/image/generate")) { seen.generated.push(JSON.parse(body)); return file(PIXEL, "image/webp"); } + if (url.endsWith("/audio/speech")) { seen.speech.push(JSON.parse(body)); return file(wav(), "audio/wav"); } + if (url.endsWith("/audio/transcriptions")) { + assert.ok(multipart(req), "a voice note goes as a form"); + seen.heard.push(body); + return ok({ text: "hello from the mic", duration: 1.2 }); + } + if (url.endsWith("/video/quote")) { const q = JSON.parse(body); seen.quotes.push(q); return ok({ quote: q.duration === "10s" ? 0.94 : 0.47 }); } + if (url.endsWith("/video/queue")) { + const q = JSON.parse(body); + seen.queued.push(q); + return ok({ model: q.model, queue_id: "q-" + seen.queued.length, + ...(/-private$/.test(q.model) ? { download_url: "https://private-share.venice.ai/v1/share/read/clip-" + seen.queued.length } : {}) }); + } + if (url.endsWith("/video/retrieve")) { + const q = JSON.parse(body); + seen.retrieved.push(q); + if (seen.retrieved.filter((r) => r.queue_id === q.queue_id).length < 2) return ok({ status: "PROCESSING", average_execution_time: 60000, execution_duration: 20000 }); + return /-private$/.test(q.model) ? ok({ status: "COMPLETED", average_execution_time: 60000, execution_duration: 61000 }) : file(CLIP, "video/mp4"); + } + if (url.endsWith("/video/complete")) { seen.completed.push(JSON.parse(body)); return ok({ success: true }); } return ok({ error: "not found" }, 404); } +/** Venice's private-share links: the clip once, then the link is revoked */ +async function fakeShare(route) { + const req = route.request(), cors = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,DELETE" }; + seen.shares.push(req.method() + " " + req.url()); + assert.ok(!req.headers().authorization, "no key goes to a download link"); + if (req.method() === "OPTIONS") return route.fulfill({ status: 204, headers: cors }); + if (req.method() === "DELETE") return route.fulfill({ status: 204, headers: cors }); + return route.fulfill({ status: 200, contentType: "video/mp4", body: CLIP, headers: cors }); +} // ---------- small waits ---------- async function until(page, fn, arg, what, timeout = 7000) { @@ -117,6 +203,14 @@ const count = (page, sel, n, timeout) => until(page, ([s, n]) => document.queryS const has = (page, sel, re, timeout) => until(page, ([s, src, fl]) => [...document.querySelectorAll(s)].some((el) => new RegExp(src, fl).test(el.textContent)), [sel, re.source, re.flags], `${sel} to read ${re}`, timeout); const textOf = (page, sel) => page.locator(sel).first().evaluate((el) => el.textContent); +/** do something, then wait for one more reply matching re than there was before */ +async function answered(page, act, re = /Ask me again after the tide\./, timeout = 10000) { + const sel = "#log .line.them .said", n = () => page.evaluate(([s, src, fl]) => [...document.querySelectorAll(s)].filter((e) => new RegExp(src, fl).test(e.textContent)).length, [sel, re.source, re.flags]); + const before = await n(); + await act(); + await until(page, ([s, src, fl, k]) => [...document.querySelectorAll(s)].filter((e) => new RegExp(src, fl).test(e.textContent)).length > k, + [sel, re.source, re.flags, before], "a new reply reading " + re, timeout); +} async function poll(fn, what, timeout = 7000) { for (const t0 = Date.now(); !(await fn());) { if (Date.now() - t0 > timeout) throw new Error("timed out waiting for " + what); @@ -139,14 +233,16 @@ async function main() { }; const venice = async (page) => { await page.route(/^https:\/\/api\.venice\.ai\//, fakeVenice); + await page.route(/^https:\/\/private-share\.venice\.ai\//, fakeShare); // Venice's photos only come through its image service; asked for directly, they're refused await page.route(/^https:\/\/venice\.ai\/_next\/image\?/, (r) => { seen.photos.push(r.request().url()); r.fulfill({ status: 200, contentType: "image/png", body: PIXEL }); }); await page.route(/^https:\/\/outerface\.venice\.ai\//, (r) => r.fulfill({ status: 403, contentType: "application/json", body: "{}" })); }; if (SHOTS) await mkdir(SHOTS, { recursive: true }); const shot = async (page, name, opts = {}) => { if (SHOTS) await page.screenshot({ path: join(SHOTS, name), ...opts }); }; + // a fake microphone (a steady tone), allowed without asking: for recording a voice note const browser = await pw.chromium.launch({ executablePath: process.env.CHROME || undefined, - args: (process.env.CHROME_FLAGS || "").split(/\s+/).filter(Boolean) }); + args: [...(process.env.CHROME_FLAGS || "").split(/\s+/).filter(Boolean), "--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"] }); try { // ---------- desktop ---------- const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, acceptDownloads: true }); @@ -247,7 +343,7 @@ async function main() { await page.fill("#say", "Do you have a boat?"); await page.keyboard.press("Enter"); await has(page, "#log .line.them .said", /Ask me again after the tide\./); - assert.equal(seen.chats.at(-1).model, "tiny-model"); + assert.equal(talks().at(-1).model, "tiny-model"); await page.waitForTimeout(400); await shot(page, "desktop-chat.png"); @@ -257,11 +353,212 @@ async function main() { await page.fill("#say", "What is a harbor?"); await page.keyboard.press("Enter"); await has(page, "#log .line.them .said", /Mm\. Good question\.\n\nAsk me again after the tide\./); - const plain = seen.chats.at(-1); + const plain = talks().at(-1); assert.equal(plain.messages[0].role, "user", "no system prompt of Havre's"); assert.equal(plain.venice_parameters.include_venice_system_prompt, true); assert.ok(!plain.venice_parameters.character_slug); assert.ok(await page.locator("#card-body").isHidden(), "a plain chat has no card"); + assert.equal(await page.locator("#tools button", { hasText: "ask for a photo" }).count(), 0, "a plain chat has nobody to send a photo"); + + // ---------- abilities ---------- + // a photo from Sable: the shot is written from the conversation, the price shows, and it's an edit of her own photo + await page.locator("#vgrid .tile", { hasText: "Sable Penrose" }).click(); + await has(page, "#who-name", /^Sable Penrose$/); + await page.fill("#say", "on moving day"); + 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); }, null, "the shot"); + assert.equal(await page.inputValue("#say"), "", "what was typed became the request"); + const shotAsk = talks().at(-1); + assert.match(shotAsk.messages[0].content, /^You write the shot for a photo/); + assert.match(shotAsk.messages[1].content, /What was asked for: on moving day/); + assert.ok(!shotAsk.venice_parameters.character_slug, "the shot is written outside the character"); + assert.match(await textOf(page, "#ask"), /from 1 picture of Sable Penrose · model: Grok Imagine · \$0\.03/); + assert.match(await textOf(page, "#ask-go"), /^TAKE THE PHOTO · \$0\.03$/); + await shot(page, "desktop-ask-photo.png"); + await page.click("#ask-go"); + await until(page, () => { const i = document.querySelector("#log .pic img"); return !!i && i.src.startsWith("blob:") && i.complete && i.naturalWidth > 0; }, null, "the photo in the chat"); + const edit = seen.edits.at(-1); + assert.ok(edit.url.endsWith("/image/edit"), "one picture: an edit"); + assert.equal(edit.body.model, "grok-imagine-edit"); + assert.equal(edit.body.safe_mode, true, "safe mode, always"); + assert.equal(edit.body.image, "https://venice.ai/_next/image?url=https%3A%2F%2Fouterface.venice.ai%2Fsable.png&w=1080&q=75", "her own photo, at a size Venice can use"); + assert.match(edit.body.prompt, /^The reference picture shows Sable Penrose\..*moving boxes/); + assert.ok(await page.locator("#ask").isHidden(), "the panel closes when the photo arrives"); + await has(page, "#log .line.media .cap", /photo · Grok Imagine · \$0\.03 · save/); + // the photo is in the conversation the model reads next, as something it sent + await answered(page, async () => { await page.fill("#say", "Cute."); await page.keyboard.press("Enter"); }); + assert.match(talks().at(-1).messages.at(-1).content, /^\(You sent me a photo: The character from the reference picture sits on a stack of moving boxes, grinning, warm lamplight\.\)\n\nCute\.$/); + + // voice notes: one on request ("hear it"), then every reply, in the voice on her card + const hears = page.getByRole("button", { name: "Hear this as a voice note from Sable Penrose" }); + assert.ok(await hears.count() >= 2, "text replies offer a voice note"); + await hears.last().click(); + await until(page, () => [...document.querySelectorAll("#log audio")].some((a) => a.src.startsWith("blob:")), null, "a voice note"); + assert.deepEqual(seen.speech.at(-1), { input: "Mm. Good question. Ask me again after the tide.", model: "tts-kokoro", speed: 1, voice: "af_sky" }); + await page.getByRole("button", { name: "voice notes: off" }).click(); + await has(page, "#tools", /voice notes: on/); + const spoke = seen.speech.length; + await answered(page, async () => { await page.fill("#say", "Say it out loud"); await page.keyboard.press("Enter"); }); + assert.ok(seen.speech.length > spoke, "a voice note with the reply"); + await until(page, () => { const l = [...document.querySelectorAll("#log .line")]; const i = l.findLastIndex((x) => x.querySelector("audio")); return i >= 0 && i < l.length - 1 && /Ask me again/.test(l.at(-1).textContent); }, + null, "the voice note, then the words under it"); + await page.getByRole("button", { name: "voice notes: on" }).click(); + + // her voice and mood live on her card + await page.locator("#who-meta").getByRole("button", { name: "card" }).click(); + await page.locator("#speech-group summary").click(); + await count(page, "#speech-voice option", 3); + await page.selectOption("#speech-voice", "am_adam"); + await page.fill('[name="speech_mood"]', "dry, a little tired"); + await page.locator("#speech-model").getByRole("button", { name: "Change the voice model" }).click(); + await count(page, "#mlist tr", 2); + assert.match(await textOf(page, "#mhead"), /VOICES/); + await page.getByRole("button", { name: "Use Qwen 3 TTS 1.7B" }).click(); + await until(page, () => document.querySelector("#speech-voice").value === "Serena", null, "a voice of the new model"); + await page.click("#speech-sample"); + await until(page, () => { const a = document.querySelector("#speech-status audio"); return !!a && a.src.startsWith("blob:"); }, null, "a sample"); + assert.deepEqual(seen.speech.at(-1), { input: "Hi, I'm Sable Penrose. Your new neighbor needs help moving in.", model: "tts-qwen3-1-7b", speed: 1, voice: "Serena", prompt: "dry, a little tired" }); + await until(page, () => (JSON.parse(localStorage.getItem("havre.cards.v1")).find((c) => c.name === "Sable Penrose").speech || {}).model === "tts-qwen3-1-7b", null, "her card saved"); + const sable = await page.evaluate(() => JSON.parse(localStorage.getItem("havre.cards.v1")).find((c) => c.name === "Sable Penrose")); + assert.deepEqual(sable.speech, { model: "tts-qwen3-1-7b", voice: "Serena", mood: "dry, a little tired", speed: 1 }); + // a picture of her for the reference sheet + page.once("dialog", (d) => d.accept()); + await page.locator("#look-group summary").click(); + await page.click("#ref-add"); + await page.setInputFiles("#ref-file", { name: "sable.png", mimeType: "image/png", buffer: PIXEL }); + await count(page, "#refs .ref", 2); + + // a clip: priced by Venice first, then made, then home + await page.locator("#chat").scrollIntoViewIfNeeded(); + await page.getByRole("button", { name: "ask for a clip" }).click(); + await until(page, () => { const t = document.querySelector("#ask-shot"); return !!t && /waves from the doorway/.test(t.value); }, null, "the clip's shot"); + await has(page, "#ask", /venice's price: \$0\.47/); + assert.deepEqual(seen.quotes.at(-1), { model: "grok-imagine-1-5-reference-to-video-private", duration: "5s", resolution: "720p", aspect_ratio: "9:16" }); + await page.locator("#ask select").first().selectOption("10s"); + assert.ok(await page.locator("#ask-go").isDisabled(), "the old price goes the moment the clip changes"); + await has(page, "#ask-go", /^MAKE THE CLIP · \$0\.94$/); + assert.ok(await page.locator("#ask-go").isEnabled()); + await page.locator("#ask select").first().selectOption("5s"); + await has(page, "#ask-go", /^MAKE THE CLIP · \$0\.47$/); + await shot(page, "desktop-ask-clip.png"); + await page.click("#ask-go"); + await has(page, "#log [data-job] .said", /is making a clip with Grok Imagine 1\.5 R2V/); + const queued = seen.queued.at(-1); + assert.equal(queued.model, "grok-imagine-1-5-reference-to-video-private"); + assert.equal(queued.reference_image_urls.length, 2, "her photo and the picture on her card"); + assert.match(queued.reference_image_urls[1], /^data:image\/webp;base64,/); + assert.match(queued.prompt, /^The reference pictures show Sable Penrose\. .*waves from the doorway/); + await until(page, () => { const v = document.querySelector("#log video"); return !!v && v.src.startsWith("blob:"); }, null, "the clip in the chat", 15000); + assert.equal(await page.locator("#log [data-job]").count(), 0, "the progress line goes when the clip arrives"); + assert.deepEqual(seen.shares.filter((s) => !s.startsWith("OPTIONS")), ["GET https://private-share.venice.ai/v1/share/read/clip-1", "DELETE https://private-share.venice.ai/v1/share/read/clip-1"]); + assert.deepEqual(seen.completed, [{ model: queued.model, queue_id: "q-1" }]); + assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem("havre.clips.pending")).length), 0); + await has(page, "#log .line.media .cap", /clip · Grok Imagine 1\.5 R2V · \$0\.47/); + + // hearing and seeing: off until switched on + assert.equal(await page.getByRole("button", { name: "record a voice note" }).count(), 0); + await page.getByRole("button", { name: "abilities" }).click(); + await count(page, "#ablist tr", 6); + await page.getByLabel("let them hear my voice notes").check(); + await page.getByLabel("let them see my photos").check(); + await shot(page, "desktop-abilities.png"); + await page.click("#abclose"); + // your voice note: recorded, heard, answered + await ctx.grantPermissions(["microphone"]); + await page.getByRole("button", { name: "record a voice note" }).click(); + await has(page, "#tools .rec", /recording/); + await page.waitForTimeout(1300); + await answered(page, () => page.getByRole("button", { name: "stop and send" }).click()); + await has(page, "#log .line.me .words", /^hello from the mic$/); + assert.match(seen.heard.at(-1), /name="model"\r\n\r\nnvidia\/parakeet-tdt-0\.6b-v3/); + assert.match(seen.heard.at(-1), /filename="voice-note\.(webm|ogg|m4a)"/); + await until(page, () => { const a = [...document.querySelectorAll("#log .line.me audio")].at(-1); return !!a && a.src.startsWith("blob:"); }, null, "your voice note in the chat"); + assert.equal(talks().at(-1).messages.at(-1).content, "(You sent me a short video: The character from the reference pictures waves from the doorway as the camera drifts closer.)\n\nhello from the mic", + "what you said, after a word about the clip she sent"); + // your photo, to a model that can see (she talks through Tiny now) + const red = await page.evaluate(() => { const c = document.createElement("canvas"); c.width = c.height = 64; const g = c.getContext("2d"); g.fillStyle = "#c00"; g.fillRect(8, 8, 48, 48); return c.toDataURL("image/png"); }); + const redPng = Buffer.from(red.split(",")[1], "base64"); + await page.fill("#say", "my boat"); + await page.setInputFiles("#photo-in", { name: "boat.png", mimeType: "image/png", buffer: redPng }); + await has(page, "#log .line.them .said", /Is that your boat\?/); + const looked = talks().at(-1).messages.at(-1); + assert.equal(looked.content[0].text, "[a photo] my boat"); + assert.match(looked.content[1].image_url.url, /^data:image\/jpeg;base64,/); + // and to a model that can't: a model that can see describes it first + await page.click("#plain"); + await answered(page, () => page.setInputFiles("#photo-in", { name: "boat.png", mimeType: "image/png", buffer: redPng })); + await has(page, "#log .line.me .cap", /Tiny told them: A small red square on a white table\./); + assert.equal(talks().at(-1).model, "zai-org-glm-5-2"); + assert.equal(talks().at(-1).messages.at(-1).content, "[a photo: A small red square on a white table.]"); + + // on their own: asked for a picture in passing, Sable decides to send one, out of the day's allowance + await page.locator("#vgrid .tile", { hasText: "Sable Penrose" }).click(); + await has(page, "#tools", /on their own: on/); + const pics = await page.locator("#log .pic img").count(); + const spent = () => page.evaluate(() => (JSON.parse(localStorage.getItem("havre.spent")) || { usd: 0 }).usd); + const spentBefore = await spent(); + assert.ok(spentBefore > 0 && spentBefore < 0.01, "her voice note back to yours came out of the allowance: " + spentBefore); + await answered(page, async () => { await page.fill("#say", "Send me a pic of your kitchen"); await page.keyboard.press("Enter"); }); + await count(page, "#log .pic img", pics + 1, 10000); + await has(page, "#log .line.media .cap", /photo · Grok Imagine · \$0\.03 · on their own/); + assert.match(seen.edits.at(-1).body.prompt, /holds up a mug in a half-unpacked kitchen/); + const delta = await spent() - spentBefore; + assert.ok(delta >= 0.03 && delta < 0.031, "the photo (and the little call that chose it) came out of the day's allowance: " + delta); + // a voice note when she'd rather say it + const notes = await page.locator("#log audio").count(); + await answered(page, async () => { await page.fill("#say", "Whisper it to me"); await page.keyboard.press("Enter"); }); + await count(page, "#log audio", notes + 1, 10000); + await has(page, "#log .line.media .cap", /voice note · Serena · \$[0-9.]+ · on their own/); + // a clip she'd like to send: offered with Venice's price, made only on a yes + const queuedBefore = seen.queued.length; + await answered(page, async () => { await page.fill("#say", "Film the harbor for me"); await page.keyboard.press("Enter"); }); + await has(page, "#log .line.offer", /wants to send you a 5s clip: .*pans across the harbor at dusk/, 10000); + await has(page, "#log .line.offer button", /^SEND IT · \$0\.47$/); + await shot(page, "desktop-offer.png"); + await page.getByRole("button", { name: "NOT NOW" }).click(); + await count(page, "#log .line.offer", 0); + assert.equal(seen.queued.length, queuedBefore, "no clip without a yes"); + // and when you come back after a while, she texts first + await page.evaluate(() => { + const chats = JSON.parse(localStorage.getItem("havre.chats.v1")), sable = Object.keys(chats).find((k) => k.startsWith("sable")); + for (const m of chats[sable]) if (m.at) m.at -= 8 * 3600e3; + localStorage.setItem("havre.chats.v1", JSON.stringify(chats)); + }); + await page.reload(); + await has(page, "#log .line.them .said", /I kept the kettle warm\./); + assert.match(talks().at(-1).messages.at(-1).content, /^\[Time passes: about 8 hours since you last talked\./); + // with today's allowance spent, nothing more happens on their own: not even the call that decides + await page.evaluate(() => { + const d = new Date(), day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + localStorage.setItem("havre.spent", JSON.stringify({ day, usd: 0.25 })); + }); + const decided = seen.chats.length - talks().length, photosNow = await page.locator("#log .pic img").count(); + await answered(page, async () => { await page.fill("#say", "Send me a pic of your garden"); await page.keyboard.press("Enter"); }); + await page.waitForTimeout(800); + assert.equal(seen.chats.length - talks().length, decided, "no deciding once the allowance is spent"); + assert.equal(await page.locator("#log .pic img").count(), photosNow, "and no photo"); + await page.evaluate(() => localStorage.removeItem("havre.spent")); + + // a character with no picture yet: a first portrait from how they look, then the photo + await page.locator("#vgrid .tile", { hasText: "Wren" }).click(); + await has(page, "#log .line.them .said", /Careful, the boxes bite\./); + await page.getByRole("button", { name: "ask for a photo" }).click(); + await has(page, "#ask", /has no picture yet/); + await has(page, "#ask-portrait", /^MAKE A FIRST PORTRAIT · \$0\.03$/); + await page.click("#ask-portrait"); + await until(page, () => { const t = document.querySelector("#ask-shot"); return !!t && /moving boxes/.test(t.value); }, null, "the shot after the portrait"); + assert.match(seen.generated.at(-1).prompt, /^Portrait photo of Wren, an original adult character: Thirties, wiry.*stubble\. Safe for work/); + assert.equal(seen.generated.at(-1).safe_mode, true); + await until(page, () => !!JSON.parse(localStorage.getItem("havre.cards.v1")).find((c) => c.name === "Wren").portrait, null, "Wren's card saved"); + const wren = await page.evaluate(() => JSON.parse(localStorage.getItem("havre.cards.v1")).find((c) => c.name === "Wren")); + assert.match(wren.portrait, /^data:image\/webp;base64,/); + assert.match(wren.look.description, /^Thirties, wiry/); + await page.click("#ask-go"); + await until(page, () => [...document.querySelectorAll("#log .pic img")].some((i) => i.src.startsWith("blob:")), null, "Wren's photo"); + assert.match(seen.edits.at(-1).body.image, /^[A-Za-z0-9+/]+=*$/, "a picture of the card's own goes as base64"); + // photos and voice notes stay through a reload, from this browser's own store + await page.reload(); + await until(page, () => [...document.querySelectorAll("#log .pic img")].some((i) => i.src.startsWith("blob:") && i.complete && i.naturalWidth > 0), null, "the photo after a reload"); // remix with credit, end to end, from the card that came in await page.locator("#roster tr", { hasText: "Captain Ro" }).getByRole("button", { name: "Remix Captain Ro" }).click(); @@ -279,7 +576,7 @@ async function main() { await shot(page, "desktop-remix.png"); await page.locator("#rx-draft button", { hasText: "KEEP IN HARBOR" }).click(); await has(page, "#who-name", /^Ines Corbel$/); - await count(page, "#roster tr", 3); + await count(page, "#roster tr", 4); const kept = await page.evaluate(() => JSON.parse(localStorage.getItem("havre.cards.v1")).find((c) => c.name === "Ines Corbel")); assert.equal(kept.lineage[0].relation, "inspired-by"); assert.equal(kept.lineage[0].name, "Captain Ro"); @@ -303,12 +600,13 @@ async function main() { } await page.click("#import"); await page.setInputFiles("#file", png); - await count(page, "#roster tr", 4); + await count(page, "#roster tr", 5); - // the security policy: nothing but api.venice.ai + // the security policy: nothing but api.venice.ai (and its private-share links for clips) assert.equal(await page.evaluate(() => fetch("https://example.com/").then(() => "reached", () => "blocked")), "blocked"); const csp = await page.evaluate(() => document.querySelector("meta[http-equiv=Content-Security-Policy]").content); - assert.ok(csp.includes("connect-src https://api.venice.ai;"), csp); + assert.ok(csp.includes("connect-src https://api.venice.ai https://private-share.venice.ai;"), csp); + assert.ok(csp.includes("media-src blob: https://private-share.venice.ai;"), csp); // the page below the studio assert.match(await textOf(page, "#agent-prompt"), /skill\.md/); diff --git a/ventures/havre/venice.js b/ventures/havre/venice.js index 6d972e9..2695d7d 100644 --- a/ventures/havre/venice.js +++ b/ventures/havre/venice.js @@ -1,5 +1,6 @@ -// venice.js: the few Venice API calls Havre makes, straight from the browser with the visitor's own key. -// The page's security policy only lets it connect to api.venice.ai, so the key can't be sent anywhere else. +// venice.js: the Venice API calls Havre makes, straight from the browser with the visitor's own key. +// The page's security policy only lets it connect to api.venice.ai (and, without the key, to the private-share links +// Venice hands out for clips from its private video models), so the key can't be sent anywhere else. // Docs: https://docs.venice.ai/ (OpenAI-compatible; character_slug and friends live in venice_parameters) export const BASE = "https://api.venice.ai/api/v1"; @@ -14,10 +15,17 @@ function explain(status, body) { if (status === 402) return "Venice says the key is out of credit."; if (status === 429) return "Venice is rate-limiting this key. Wait a moment and try again."; if (status === 404) return "Venice doesn't know that " + (said.includes("model") ? "model." : "character or model."); + if (status === 413) return "That file is too big for Venice."; + if (status === 422) return "Venice turned that down as against its content rules" + (said ? ": " + said : "."); + if (status === 503) return "That model is busy at Venice right now. Try again in a moment, or pick another."; if (status >= 500) return "Venice had a problem (" + status + "). Try again in a moment."; return said || "Venice answered " + status + "."; } +/** a data: URI's base64, or a URL as it is: what Venice's image endpoints take */ +const imageInput = (s) => (/^data:image\/[a-z0-9.+-]+;base64,/i.test(s) ? s.slice(s.indexOf(",") + 1) : s); +const usdOf = (x) => (x && typeof x.usd === "number" ? x.usd : null); + export class Venice { /** key: a Venice API key, or "" for the calls that need none (the model list) */ constructor(key = "", { fetch: f = globalThis.fetch.bind(globalThis), base = BASE } = {}) { @@ -46,6 +54,148 @@ export class Venice { return data; } + /** a call whose answer may be a file (a picture, a voice note, a video): the response itself, once it's ok */ + async raw(path, { method = "POST", body, form, signal } = {}) { + if (!this.key) throw new VeniceError("Add your Venice API key first.", 0); + const headers = { Authorization: "Bearer " + this.key }; + if (body) headers["Content-Type"] = "application/json"; + let res; + try { + res = await this.fetch(this.base + path, { method, headers, body: form || (body ? JSON.stringify(body) : undefined), signal }); + } catch (e) { + if (e && e.name === "AbortError") throw e; + throw new VeniceError("Couldn't reach Venice. Check your connection.", 0); + } + if (!res.ok) { + let data = null; + try { data = await res.json(); } catch { data = null; } + throw new VeniceError(explain(res.status, data), res.status); + } + return res; + } + + /** + * Venice's other models, by type: inpaint (photo edits), image, tts (voices), asr (hearing) or video. No key needed. + * Each comes with what the page shows and needs: its price, its voices, its lengths and sizes. + */ + async mediaModels(type) { + const d = await this.call("/models?type=" + encodeURIComponent(type), { auth: false }); + return (d && d.data || []).filter((m) => m && !(m.model_spec && (m.model_spec.offline || m.model_spec.beta))).map((m) => mediaInfo(type, m)); + } + + /** + * A photo: an edit of one or more pictures (URLs or data: URIs; the first is the base). Venice's safe mode is + * always on. Returns { blob, blurred }. + */ + async editImage({ model, prompt, images, signal } = {}) { + const pics = (images || []).filter(Boolean); + if (!pics.length) throw new VeniceError("A photo needs at least one picture of the character.", 0); + const res = pics.length === 1 + ? await this.raw("/image/edit", { body: { model, prompt, image: imageInput(pics[0]), safe_mode: true, output_format: "webp" }, signal }) + : await this.raw("/image/multi-edit", { body: { modelId: model, prompt, images: pics.map(imageInput), safe_mode: true, output_format: "webp" }, signal }); + return { blob: await res.blob(), blurred: res.headers && res.headers.get ? res.headers.get("x-venice-is-blurred") === "true" : false }; + } + + /** a picture from words alone (a first portrait), with safe mode on. Returns { blob, blurred }. */ + async generateImage({ model, prompt, aspect = "", signal } = {}) { + const body = { model, prompt, safe_mode: true, format: "webp", return_binary: true }; + if (aspect) body.aspect_ratio = aspect; + const res = await this.raw("/image/generate", { body, signal }); + return { blob: await res.blob(), blurred: res.headers && res.headers.get ? res.headers.get("x-venice-is-blurred") === "true" : false }; + } + + /** a voice note: words read out by a voice model. mood goes to models that take a style prompt; the rest ignore it. */ + async speak({ model, voice, input, mood = "", speed = 1, signal } = {}) { + const body = { input: String(input || "").slice(0, 4096), model, speed }; + if (voice) body.voice = voice; + if (mood) body.prompt = String(mood).slice(0, 500); + const res = await this.raw("/audio/speech", { body, signal }); + return res.blob(); + } + + /** what was said in a recording: { text, duration } */ + async transcribe({ file, name = "voice.webm", model, signal } = {}) { + const form = new FormData(); + form.append("file", file, name); + if (model) form.append("model", model); + form.append("response_format", "json"); + const res = await this.raw("/audio/transcriptions", { form, signal }); + const d = await res.json(); + return { text: String(d && d.text || "").trim(), duration: d && typeof d.duration === "number" ? d.duration : null }; + } + + /** what a clip would cost, in USD, before anything is made */ + async quoteVideo({ model, duration, resolution = "", aspect = "", audio, signal } = {}) { + const body = { model, duration }; + if (resolution) body.resolution = resolution; + if (aspect) body.aspect_ratio = aspect; + if (typeof audio === "boolean") body.audio = audio; + const d = await this.call("/video/quote", { method: "POST", body, signal }); + return typeof (d && d.quote) === "number" ? d.quote : null; + } + + /** + * Start a clip. images are the character's pictures: reference-to-video models get them all, other models the + * first one. Returns { queueId, downloadUrl } (the second only from Venice's private models). + */ + async queueVideo({ model, prompt, duration, resolution = "", aspect = "", audio, images = [], signal } = {}) { + const body = { model, prompt, duration }; + if (resolution) body.resolution = resolution; + if (aspect) body.aspect_ratio = aspect; + if (typeof audio === "boolean") body.audio = audio; + 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 }); + 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 : "" }; + } + + /** + * How a clip is coming along: { done: false, elapsed, expected } while it's being made, then { done: true, blob } + * (or { done: true, blob: null } when it waits at its download link). Venice keeps its copy until completeVideo(), + * so a download that breaks halfway can be tried again. + */ + async retrieveVideo({ model, queueId, signal } = {}) { + const res = await this.raw("/video/retrieve", { body: { model, queue_id: queueId }, signal }); + const type = (res.headers && res.headers.get && res.headers.get("content-type")) || ""; + let d; + try { + if (/^(video\/|application\/octet-stream)/i.test(type)) return { done: true, blob: await res.blob() }; + d = await res.json(); + } catch (e) { + if (e && e.name === "AbortError") throw e; + throw new VeniceError("Couldn't read Venice's answer about the clip.", 0); + } + const status = String(d && d.status || "").toUpperCase(); + if (status === "COMPLETED") return { done: true, blob: null }; + if (/FAIL|ERROR|CANCEL|REJECT|EXPIRE/.test(status)) throw new VeniceError(`Venice couldn't make the clip (${status.toLowerCase()}).`, -2); + return { done: false, elapsed: d && d.execution_duration || 0, expected: d && d.average_execution_time || 0 }; + } + + /** a clip waiting at its private download link (no key goes with it) */ + async downloadVideo(url, { signal } = {}) { + if (!/^https:\/\/private-share\.venice\.ai\//.test(url)) throw new VeniceError("That isn't a Venice download link.", -1); + let res; + try { res = await this.fetch(url, { signal }); } catch (e) { + if (e && e.name === "AbortError") throw e; + throw new VeniceError("Couldn't download the clip from Venice.", 0); + } + if (!res.ok) throw new VeniceError(res.status === 410 ? "Venice has already deleted that clip." : "Couldn't download the clip (" + res.status + ").", res.status); + try { return await res.blob(); } catch { throw new VeniceError("The clip's download broke off.", 0); } + } + + /** revoke a private download link once the clip is safely home (no key goes with it either) */ + async revokeDownload(url) { + if (!/^https:\/\/private-share\.venice\.ai\//.test(url)) return false; + try { await this.fetch(url, { method: "DELETE" }); return true; } catch { return false; } // it lapses within a day anyway + } + + /** tell Venice the clip is home, so it deletes its copy */ + async completeVideo({ model, queueId } = {}) { + try { await this.call("/video/complete", { method: "POST", body: { model, queue_id: queueId } }); return true; } catch { return false; } + } + /** * Text models: what each can do, how private it is, and what it costs (USD per million tokens). No key needed. * Models that are offline, or open only to Venice's beta testers, are left out. @@ -134,6 +284,27 @@ export class Venice { } } +function mediaInfo(type, m) { + const s = m.model_spec || {}, p = s.pricing || {}, c = s.constraints || {}, dep = s.deprecation || null; + const base = { id: m.id, name: s.name || m.id, privacy: s.privacy || "", uncensored: !!s.uncensored, traits: s.traits || [], + retiring: dep ? String(dep.removesAt || dep.date || "soon").slice(0, 10) : "" }; + const oneK = p.resolutions && p.resolutions["1K"]; + if (type === "inpaint") { + return { ...base, price: usdOf(oneK) ?? usdOf(p.inpaint), extra: p.inputImages ? usdOf(p.inputImages.additional) : null, + included: p.inputImages && typeof p.inputImages.included === "number" ? p.inputImages.included : 1, + maxIn: c.maxInputImages || (c.combineImages ? 3 : 1), promptLimit: c.promptCharacterLimit || 1500 }; + } + if (type === "image") return { ...base, price: usdOf(p.generation) ?? usdOf(oneK), aspects: c.aspectRatios || [], promptLimit: c.promptCharacterLimit || 1500 }; + if (type === "tts") return { ...base, perMChars: usdOf(p.input), voices: s.voices || [], format: s.default_format || "" }; + if (type === "asr") return { ...base, perSecond: usdOf(p.per_audio_second) }; + if (type === "video") { + return { ...base, kind: c.model_type || "", ref: /reference-to-video/.test(m.id), durations: c.durations || [], + resolutions: c.resolutions || [], aspects: c.aspect_ratios || [], audio: !!c.audio, audioConfigurable: !!c.audio_configurable, + promptLimit: c.prompt_character_limit || 2500 }; + } + return base; +} + function characterInfo(c) { return { slug: c.slug, name: c.name || c.slug, description: c.description || "", creator: c.author || "",