diff --git a/ventures/havre/app.js b/ventures/havre/app.js index 4a2b8d8..3b5aff6 100644 --- a/ventures/havre/app.js +++ b/ventures/havre/app.js @@ -38,17 +38,20 @@ 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" }; + sort: "havre.vsort", seeded: "havre.seeded.v1", adult: "havre.adult", adult18: "havre.adult.18", plainModel: "havre.plain.model", + own: "havre.venice.models" }; const PAGE = 24; +const PLAIN = "_plain"; // a plain chat with a model: no character, no card const SORTS = ["featured", "imports", "highestRating", "mostRecent"]; // what a character hears when they speak first; it stays out of the conversation on screen const OPENER = "[Start the conversation: someone new just came to talk to you. Say hello in character, in a line or two.]"; const S = { - cards: [], sel: "", chats: {}, key: "", keyRemembered: false, model: C.DEFAULT_MODEL, models: null, replying: null, - demo: {}, rx: null, v: { list: [], offset: 0, more: false, query: "", sort: "featured", loading: false, asked: 0 }, + 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 }, }; -const card = () => S.cards.find((c) => c.id === S.sel) || S.cards[0] || null; +const card = () => (S.sel === PLAIN ? S.plain : S.cards.find((c) => c.id === S.sel) || S.cards[0] || null); // ---------- boot ---------- function boot() { @@ -57,16 +60,21 @@ function boot() { for (const [id, hist] of Object.entries(S.chats)) if (!Array.isArray(hist)) delete S.chats[id]; S.sel = local.get(K.sel, "") || ""; retireFirstCast(); - if (!S.cards.some((c) => c.id === S.sel)) S.sel = S.cards[0] ? S.cards[0].id : ""; + if (S.sel !== PLAIN && !S.cards.some((c) => c.id === S.sel)) S.sel = S.cards[0] ? S.cards[0].id : ""; const kept = local.get(K.key, ""); S.key = kept || session.get(K.key, "") || ""; S.keyRemembered = !!kept; S.model = local.get(K.model, C.DEFAULT_MODEL) || C.DEFAULT_MODEL; + S.plain = { ...C.normalizeCard({ name: "no character", voice: { short_form: false } }), id: PLAIN, plain: true, + model: local.get(K.plainModel, C.DEFAULT_MODEL) || C.DEFAULT_MODEL }; const sort = local.get(K.sort, "featured"); 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, {}) || {}; bind(); renderAll(); - if (S.key) loadVenice(0); + loadModels(); + if (S.key) { loadVenice(0); loadBalance(); } } // The first version of Havre arrived with three characters of its own. This one has none, so they leave, unless @@ -93,7 +101,7 @@ function persistCards(now = false) { } function persistChats() { for (const id of Object.keys(S.chats)) { - if (!S.cards.some((c) => c.id === id)) delete S.chats[id]; + 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); } local.set(K.chats, S.chats); @@ -118,10 +126,15 @@ function replaceCard(next) { } // ---------- faces ---------- +// Venice keeps character photos (outerface.venice.ai) behind its own sign-in; its image service hands them out +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) || ""; if (/^data:image\/(png|jpe?g|webp|gif|svg\+xml)[;,]/i.test(p)) return p; - if (/^https:\/\/outerface\.venice\.ai\//.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 ""; @@ -156,16 +169,33 @@ function renderGrid() { $("#vnext").disabled = !S.v.more || S.v.loading; $("#vrange").textContent = showing ? `${S.v.offset + 1}–${S.v.offset + S.v.list.length}` : ""; if (!S.key) vStatus("paste your venice key above and venice's characters show up here."); + renderTags(); } +/** the tags on this page, most common first; one picked narrows the search to it */ +function renderTags() { + const p = $("#vtags"); + p.textContent = ""; + const counts = new Map(); + for (const v of S.v.list) for (const t of v.tags || []) { const k = String(t).toLowerCase().trim(); if (k) counts.set(k, (counts.get(k) || 0) + 1); } + const top = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 14).map(([t]) => t); + if (S.v.tag && !top.includes(S.v.tag)) top.unshift(S.v.tag); + p.hidden = !S.key || !top.length; + if (p.hidden) return; + p.append("tags: ", ...slashed(top.map((t) => (t === S.v.tag + ? h("b", {}, "#" + t + " ", linkish("×", () => setTag(""), { "aria-label": `Show every tag, not just ${t}` })) + : linkish("#" + t, () => setTag(t), { "aria-label": `Only characters tagged ${t}` }))))); +} +function setTag(t) { S.v.tag = t; loadVenice(0); } function tile(v, now) { const here = S.cards.some((c) => c.engine.venice_character === v.slug), on = now === v.slug; const ph = h("span", { class: "ph" }); - paintFace(ph, v.photo); + paintFace(ph, veniceImg(v.photo)); return h("li", {}, h("button", { type: "button", class: "tile", "aria-pressed": on ? "true" : "false", "data-slug": v.slug, "aria-label": `Talk to ${v.name}`, title: v.description ? v.description.slice(0, 300) : null, onclick: () => talkTo(v) }, ph, h("span", { class: "tx" }, h("span", { class: "nm", text: v.name }), v.description ? h("span", { class: "ds", text: v.description }) : null, - h("span", { class: "go", text: on ? "talking now" : here ? "talk again" : "talk" })))); + h("span", { class: "go", text: on ? "talking now" : here ? "talk again" : "talk" }), + v.adult ? h("span", { class: "x18", text: "18+" }) : null))); } /** who is talking now, and who is in your harbor already, without redrawing the photos */ function markTiles() { @@ -185,10 +215,12 @@ async function loadVenice(offset = 0, { paging = false } = {}) { vStatus("asking venice for characters…"); renderGrid(); try { - const { list, more } = await new Venice(S.key).characterPage({ search: S.v.query, sort: S.v.sort, limit: PAGE, offset }); + const { list, more } = await new Venice(S.key).characterPage({ search: S.v.query, sort: S.v.sort, tags: S.v.tag, + adult: S.v.adult, limit: PAGE, offset }); if (asked !== S.v.asked) return; // a newer search is on its way Object.assign(S.v, { list, more, offset, loading: false }); - vStatus(list.length ? "" : S.v.query ? `nothing safe for work matched “${S.v.query}”.` : "venice sent back no characters."); + vStatus(list.length ? "" : S.v.query || S.v.tag ? `nothing matched${S.v.query ? " “" + S.v.query + "”" : ""}${S.v.tag ? " in #" + S.v.tag : ""}.` + : "venice sent back no characters."); renderGrid(); if (paging) $("#chars").scrollIntoView({ behavior: motion(), block: "start" }); } catch (e) { @@ -201,6 +233,7 @@ async function loadVenice(offset = 0, { paging = false } = {}) { function talkTo(v) { if (!S.key) return needKey("paste your venice key first."); + if (v.model && S.own[v.slug] !== v.model) { S.own[v.slug] = v.model; local.set(K.own, S.own); } // the model they use on Venice let c = S.cards.find((x) => x.engine.venice_character === v.slug); if (!c) { c = addCard({ @@ -250,23 +283,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 || !S.key || S.replying || (S.chats[c.id] || []).length) return; + if (!c || c.plain || !S.key || S.replying || (S.chats[c.id] || []).length) return; if (c.engine.venice_character || (!c.greeting && c.persona)) send(OPENER, { hidden: true }); } // ---------- the conversation ---------- +/** characters from Venice, and the plain chat, need a key to say anything */ +const needsKey = (c) => !!(c && (c.plain || c.engine.venice_character)); function renderChat() { const c = card(); $("#who-name").textContent = c ? c.name : "chat"; renderChatMeta(); - const venice = !!(c && c.engine.venice_character); - const locked = !c || (venice && !S.key); + const locked = !c || (needsKey(c) && !S.key); $("#say").disabled = locked; $("#sendbtn").disabled = locked || !!S.replying; - $("#say").placeholder = c ? `say something to ${c.name}` : "say something"; + $("#say").placeholder = c && !c.plain ? `say something to ${c.name}` : "say something"; const kn = $("#keynote"); kn.hidden = !c || !!S.key; - kn.textContent = venice ? `paste your venice key above to talk to ${c.name}.` + kn.textContent = c && c.plain ? "paste your venice key above to chat." + : 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(); } @@ -275,8 +310,9 @@ function renderChatMeta() { meta.textContent = ""; if (!c) return; meta.append(...slashed([ - c.engine.venice_character ? "via venice, with their own prompt" : c.tagline, - linkish("card", () => openCard("edit")), + 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` })), + c.plain ? null : linkish("card", () => openCard("edit")), linkish("start over", startOver, { title: "Start this conversation over" }), ])); } @@ -294,7 +330,7 @@ function startOver() { /** one line of the script: who says it, then what they say. kind: note, err, typing or suggest */ function line(text, who, kind = "") { const c = card(); - const name = who === "me" ? "you" : who === "havre" ? "havre" : c ? c.name : ""; + const name = who === "me" ? "you" : who === "havre" ? "havre" : !c ? "" : c.plain ? modelName(c.model) : c.name; const said = h("div", { class: "said" }); if (kind) said.textContent = text; else { @@ -325,12 +361,15 @@ function renderLog() { return; } const hist = S.chats[c.id] || []; - if (c.engine.venice_character) { + if (c.plain) { + if (!hist.length) put(line(S.key ? "a plain chat: no character, just the model you pick above. say anything." + : "paste your venice key above to chat with a model.", "havre", "note")); + } else if (c.engine.venice_character) { 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 C.splitBubbles(m.content)) put(line(b, "them")); + else if (m.role === "assistant") for (const b of bubblesOf(c, m.content)) put(line(b, "them")); } const ideas = hist.some((m) => m.role === "user" && !m.hidden) ? [] : C.suggestions(c); if (ideas.length) { @@ -342,9 +381,13 @@ function renderLog() { 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 })); function setBusy(on) { const c = card(); - $("#sendbtn").disabled = on || !c || !!(c.engine.venice_character && !S.key); + $("#sendbtn").disabled = on || !c || (needsKey(c) && !S.key); $("#composer").setAttribute("aria-busy", on ? "true" : "false"); } @@ -367,6 +410,7 @@ async function send(text, { hidden = false } = {}) { try { let reply; if (demo) { + if (c.plain) throw new Error("Paste your Venice key above to chat."); if (c.engine.venice_character) throw new Error(`${c.name} lives on Venice, so they need your Venice key to talk.`); await sleep(still ? 250 : 750); const n = S.demo[c.id] || 0; @@ -374,7 +418,8 @@ async function send(text, { hidden = false } = {}) { reply = C.demoReply(c, n, text); } else { const r = await new Venice(S.key).chat({ model: c.model || S.model, characterSlug: c.engine.venice_character || "", - messages: C.chatMessages(c, hist.filter((m) => !m.demo)), signal: ctrl.signal }); + veniceSystemPrompt: !!c.plain, maxTokens: c.plain ? 2000 : 700, + messages: c.plain ? plainMessages(hist) : C.chatMessages(c, hist.filter((m) => !m.demo)), 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."); } @@ -382,7 +427,8 @@ async function send(text, { hidden = false } = {}) { hist.push({ role: "assistant", content: reply, ...(demo ? { demo: true } : {}) }); persistChats(); typing.remove(); - await play(C.splitBubbles(reply), ctrl.signal); + if (!demo) balanceSoon(); + await play(bubblesOf(c, reply), ctrl.signal); } catch (e) { if (e && e.name === "AbortError") return; typing.remove(); @@ -414,10 +460,14 @@ async function play(bubbles, signal) { // ---------- the card ---------- function renderCard() { - const c = card(); - $("#card-empty").hidden = !!c; - $("#card-body").hidden = !c; - $("#card-h").textContent = c ? "CARD: " + c.name.toUpperCase() : "CARD"; + const c = card(), real = !!c && !c.plain; + const empty = $("#card-empty"); + empty.hidden = real; + empty.textContent = c && c.plain ? "a plain chat has no card. pick a character to see theirs." + : "no character yet. the card shows up here once someone is in your harbor."; + $("#card-body").hidden = !real; + $("#card-h").textContent = real ? "CARD: " + c.name.toUpperCase() : "CARD"; + if (!real) return; renderSheet(); resetRemix(); } @@ -444,13 +494,84 @@ function fillModels(sel, current) { if (current && ![...sel.options].some((o) => o.value === current)) sel.prepend(h("option", { value: current, text: current })); sel.value = current || C.DEFAULT_MODEL; } -async function loadModels() { - if (S.models) return; +let modelsAsked = null; +function loadModels() { + if (S.models) return Promise.resolve(); + if (!modelsAsked) { + modelsAsked = new Venice("").models().then((ms) => { + S.models = ms; + S.modelsError = ""; + const c = card(); + if (c && !c.plain) fillModels($("#model-select"), c.model); + renderChatMeta(); + if ($("#modeldlg").open) renderModels(); + }).catch((e) => { S.modelsError = e.message; modelsAsked = null; if ($("#modeldlg").open) renderModels(); }); + } + return modelsAsked; +} +const modelName = (id) => { const m = S.models && S.models.find((x) => x.id === id); return m ? m.name : id || "?"; }; + +// ---------- the model picker ---------- +/** 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 c = card(); + if (!c) return; + const own = ownModel(c); + $("#model-for").textContent = c.plain ? "for the plain chat." : `for ${c.name}.` + (own ? ` their own on venice: ${modelName(own)}.` : ""); + $("#msearch").value = ""; + $("#modeldlg").showModal(); + renderModels(); + loadModels(); +} +function renderModels() { + const 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…" }))); + 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)) + .sort((a, b) => rank(b) - rank(a) || a.name.localeCompare(b.name)); + 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 }, + 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" : "" }))); + } + if (!list.length) tb.append(h("tr", {}, h("td", { colspan: "5", text: `no model matches “${$("#msearch").value.trim()}”.` }))); +} +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); } + $("#modeldlg").close(); + renderChatMeta(); + toast(`${c.plain ? "The plain chat" : c.name} now talks through ${modelName(id)}.`); +} + +// ---------- what is left on the key ---------- +let balanceTimer = 0; +function balanceSoon() { clearTimeout(balanceTimer); balanceTimer = setTimeout(loadBalance, 1500); } +async function loadBalance() { + const el = $("#balance"); + if (!S.key) { el.hidden = true; return; } try { - S.models = await new Venice("").models(); - const c = card(); - if (c) fillModels($("#model-select"), c.model); - } catch { /* the recommended list is enough */ } + const { amount, currency } = await new Venice(S.key).balance(); + if (amount == null || !S.key) { el.hidden = true; return; } + el.textContent = "balance " + (currency === "USD" ? "$" + amount.toFixed(2) + : currency === "DIEM" ? amount.toFixed(2) + " diem" : amount.toFixed(2) + " credits"); + el.hidden = false; + } catch { el.hidden = true; } } function renderSheet() { @@ -612,6 +733,7 @@ async function saveKey(ev) { renderChat(); resetRemix(); loadVenice(0); + loadBalance(); } function forgetKey() { if (S.replying) { S.replying.abort(); S.replying = null; setBusy(false); } @@ -624,6 +746,7 @@ function forgetKey() { $("#keyremember").checked = false; updateKeyUI(); keyStatus("forgotten.", false, true); + $("#balance").hidden = true; renderGrid(); renderChat(); resetRemix(); @@ -710,7 +833,7 @@ async function rxSource(c) { } async function distill() { const c = card(); - if (!c || S.rx.busy) return; + if (!c || c.plain || S.rx.busy) return; if (!S.key) return needKey("remix needs your venice key."); rxBusy(true); try { @@ -944,6 +1067,21 @@ function bind() { local.set(K.sort, S.v.sort); if (S.key) loadVenice(0); }); + $("#vadult").checked = S.v.adult; + $("#vadult").addEventListener("change", () => { + const box = $("#vadult"); + if (box.checked && !local.get(K.adult18, false)) { + if (!confirm("Adult characters are for people 18 or older.\n\nAre you 18 or older?")) { box.checked = false; return; } + local.set(K.adult18, true); + } + S.v.adult = box.checked; + local.set(K.adult, S.v.adult); + if (S.key) loadVenice(0); + }); + $("#plain").addEventListener("click", () => selectCard(PLAIN, { talk: true })); + $("#msearch").addEventListener("input", renderModels); + $("#modelform").addEventListener("submit", (e) => e.preventDefault()); // Enter in the search box keeps it open + $("#mclose").addEventListener("click", () => $("#modeldlg").close()); $("#vprev").addEventListener("click", () => loadVenice(Math.max(0, S.v.offset - PAGE), { paging: true })); $("#vnext").addEventListener("click", () => loadVenice(S.v.offset + PAGE, { paging: true })); diff --git a/ventures/havre/index.html b/ventures/havre/index.html index bddd07f..bf979b7 100644 --- a/ventures/havre/index.html +++ b/ventures/havre/index.html @@ -3,7 +3,7 @@ - + Havre @@ -37,6 +37,7 @@ + sent to api.venice.ai and nowhere else. get a key @@ -45,7 +46,7 @@

CHARACTERS

- venice's public characters, safe for work. + venice's public characters.
+

-

(v1, v2, v3, json or png) /

+

/ (v1, v2, v3, json or png) /

@@ -190,8 +193,8 @@
  1. Paste your Venice API key. It stays in this browser, and the page can reach api.venice.ai and nothing else.
  2. -
  3. Pick a character: one of Venice's, a card file you import, or one you write. Venice's characters say hello first.
  4. -
  5. Talk. Replies come as a few short texts. Without a key, the characters you write answer from their own examples.
  6. +
  7. 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.
  8. +
  9. 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.
  10. Keep them. Export a character as a file, or remix them into someone new; the credit stays in the card.
@@ -218,7 +221,7 @@ a remixtwo short calls; five more for a venice character -

each card's model list shows venice's price per million tokens.

+

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

@@ -249,7 +252,7 @@

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.

-

Safe for work only: Havre lists only Venice characters that aren't marked adult.

+

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

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.

@@ -280,6 +283,25 @@ + +
+

MODEL

+
+

+ + +
+ + + +
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.

+

+
+
+
+ diff --git a/ventures/havre/shots/desktop.png b/ventures/havre/shots/desktop.png index ed78e45..4da71b2 100644 Binary files a/ventures/havre/shots/desktop.png and b/ventures/havre/shots/desktop.png differ diff --git a/ventures/havre/style.css b/ventures/havre/style.css index 63c178e..944a0bb 100644 --- a/ventures/havre/style.css +++ b/ventures/havre/style.css @@ -105,6 +105,7 @@ input:disabled, textarea:disabled, select:disabled { background: #f2f2f2; color: .keystatus.bad { color: #ff8a8a; } .keystatus.ok { color: var(--ink); background: var(--hi); padding: 0 4px; } .keyfine { margin-left: auto; } +.balance { padding: 0 6px; border: 1px solid var(--paper); white-space: nowrap; } .keybar :focus-visible { outline-color: var(--hi); } /* ---------- section heads ---------- */ @@ -131,6 +132,10 @@ input:disabled, textarea:disabled, select:disabled { background: #f2f2f2; color: .pick select { width: auto; font: 14px var(--mono); } #venstatus { padding: 10px var(--gut); border-bottom: 1px solid var(--ink); } #venstatus:empty { display: none; } +.tags { padding: 10px var(--gut); border-bottom: 1px solid var(--ink); font: 14px/1.6 var(--mono); } +.tags b { background: var(--ink); color: var(--paper); padding: 0 4px; font-weight: 400; } +.tags b .linkish { color: var(--paper); text-decoration: none; } +.tags b .linkish:hover { color: var(--ink); } .grid { margin: 0 -1px 0 0; padding: 0; list-style: none; @@ -155,6 +160,7 @@ input:disabled, textarea:disabled, select:disabled { background: #f2f2f2; color: .tile[aria-pressed="true"] { background: var(--ink); color: var(--paper); } .tile[aria-pressed="true"] .go { color: var(--paper); text-decoration: none; } .tile[aria-pressed="true"]:hover .nm { color: var(--ink); } +.tile .x18 { align-self: flex-start; display: inline-block; margin-top: 4px; padding: 0 4px; border: 1px solid currentColor; font: 11px/1.5 var(--mono); } .grid > li.ghost .tile { cursor: default; } .grid > li.ghost .tx { min-height: 64px; } /* no photo: an empty frame with an X through it */ @@ -297,6 +303,14 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi); border: 2px solid var(--ink); border-radius: 0; background: var(--paper); color: var(--ink); } .dlg::backdrop { background: rgba(255, 255, 255, 0.85); } +.dlg.wide { width: min(1040px, calc(100vw - 32px)); } +.mtable { max-height: min(58vh, 560px); overflow: auto; border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink); } +.models { font-size: 16px; } +.models thead th { position: sticky; top: 0; background: var(--paper); } +.models tbody th .mid { display: block; font: 12px/1.4 var(--mono); font-weight: 400; overflow-wrap: anywhere; } +.models td { font: 13px/1.5 var(--mono); } +.models td.num { white-space: nowrap; } +.models tr[aria-current="true"] .mid { color: var(--paper); } .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); } diff --git a/ventures/havre/tests/card.test.mjs b/ventures/havre/tests/card.test.mjs index 4bb312b..b47e9de 100644 --- a/ventures/havre/tests/card.test.mjs +++ b/ventures/havre/tests/card.test.mjs @@ -290,3 +290,39 @@ test("venice: a page of characters asks for safe-for-work ones, sorted, and says assert.equal(tries.length, 2, "asked again without the sort"); assert.ok(!tries[1].url.includes("sortBy") && !tries[1].url.includes("isAdult")); }); + +test("venice: adult characters only when asked for, and tags narrow the list", async () => { + const urls = []; + const data = [{ slug: "a", name: "A", adult: false, tags: ["sea"] }, { slug: "b", name: "B", adult: true, tags: [] }]; + const v = new Venice("sk", { fetch: fakeFetch(() => ({ status: 200, body: { data } }), urls) }); + const all = await v.characterPage({ adult: true, tags: "sea" }); + const q = new URL(urls[0].url).searchParams; + assert.equal(q.get("isAdult"), null, "no filter when adult characters are wanted"); + assert.equal(q.get("tags"), "sea"); + assert.deepEqual(all.list.map((c) => [c.slug, c.adult]), [["a", false], ["b", true]]); + assert.deepEqual((await v.characters()).map((c) => c.slug), ["a"], "and none when they aren't"); +}); + +test("venice: models say what they can do and how private they are; beta-only ones are left out; the balance reads", async () => { + const v = new Venice("sk", { fetch: fakeFetch((p) => (p.startsWith("/models") ? { status: 200, body: { data: [ + { id: "m1", model_spec: { name: "M One", privacy: "private", uncensored: true, availableContextTokens: 65536, traits: ["default"], + capabilities: { supportsVision: true, supportsFunctionCalling: true, supportsReasoning: false, supportsWebSearch: true }, + pricing: { input: { usd: 0.5 }, output: { usd: 2 } }, deprecation: { removesAt: "2026-12-01T00:00:00Z" } } }, + { id: "m2", model_spec: { name: "Beta", beta: true } }] } } + : { status: 200, body: { canConsume: true, consumptionCurrency: "DIEM", balances: { diem: 42.25, usd: 3, bundledCredits: null, earnedCredits: null }, diemEpochAllocation: 100 } }), []) }); + const [m] = await v.models(); + assert.equal((await v.models()).length, 1); + assert.deepEqual([m.name, m.privacy, m.uncensored, m.vision, m.tools, m.reasoning, m.web, m.ctx, m.input, m.output, m.retiring], + ["M One", "private", true, true, true, false, true, 65536, 0.5, 2, "2026-12-01"]); + assert.deepEqual(await v.balance(), { amount: 42.25, currency: "DIEM", canConsume: true }); +}); + +test("venice: a plain chat keeps Venice's own system prompt; a character's chat doesn't", async () => { + const bodies = []; + const v = new Venice("sk", { fetch: async (url, init) => { bodies.push(JSON.parse(init.body)); return { ok: true, status: 200, json: async () => ({ choices: [{ message: { content: "hi" } }] }) }; } }); + await v.chat({ model: "m", messages: [{ role: "user", content: "hi" }], veniceSystemPrompt: true, maxTokens: 2000 }); + assert.equal(bodies[0].venice_parameters.include_venice_system_prompt, true); + assert.equal(bodies[0].max_tokens, 2000); + await v.chat({ model: "m", messages: [] }); + assert.equal(bodies[1].venice_parameters.include_venice_system_prompt, false); +}); diff --git a/ventures/havre/tests/smoke.mjs b/ventures/havre/tests/smoke.mjs index 13a648a..53d200d 100644 --- a/ventures/havre/tests/smoke.mjs +++ b/ventures/havre/tests/smoke.mjs @@ -1,10 +1,11 @@ // node tests/smoke.mjs [--shots DIR]: Havre in a real browser, against a fake Venice (no network, no key). // // 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, Venice's characters (adult ones never listed) and one who says -// hello first, 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/). +// 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. +// 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/). // // Playwright: PW_MODULE (a path to playwright-core's index.mjs, or a package name), else playwright-core, else // playwright. Chrome: CHROME, with CHROME_FLAGS (e.g. --no-sandbox as root); else Playwright's own Chromium. @@ -50,11 +51,11 @@ function serve() { // ---------- a stand-in for api.venice.ai that answers like it does ---------- const PIXEL = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"); -const seen = { characters: [], chats: [] }; +const seen = { characters: [], chats: [], photos: [] }; 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 } }, - { slug: "hidden", name: "Hidden", description: "", author: "x", photoUrl: null, adult: true, modelId: "m", tags: [], stats: {} }, + { slug: "after-dark", name: "After Dark", description: "Only for grown-ups.", author: "x", photoUrl: null, adult: true, modelId: "m", tags: [], stats: {} }, { slug: "wren", name: "Wren", description: "A deckhand on the night ferry.", author: "q", photoUrl: null, adult: false, modelId: "zai-org-glm-5-2", tags: [], stats: {} }, ]; async function fakeVenice(route) { @@ -65,11 +66,22 @@ 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")) { - return ok({ data: [{ id: "zai-org-glm-5-2", model_spec: { pricing: { input: { usd: 1.4 }, output: { usd: 4.4 } } } }, - { id: "tiny-model", model_spec: { pricing: { input: { usd: 0.1 }, output: { usd: 0.2 } } } }] }); + 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 } } } }, + { id: "tiny-model", model_spec: { name: "Tiny", privacy: "anonymized", uncensored: true, availableContextTokens: 32000, + capabilities: { supportsVision: true }, pricing: { input: { usd: 0.1 }, output: { usd: 0.2 } } } }, + { id: "beta-model", model_spec: { name: "Beta only", beta: true, privacy: "private", pricing: {} } }] }); } if (auth !== "Bearer sk-good") return ok({ error: "Authentication failed" }, 401); - if (url.includes("/characters")) { seen.characters.push(url); return ok({ object: "list", data: CHARACTERS }); } + if (url.includes("/billing/balance")) { + return ok({ canConsume: true, consumptionCurrency: "USD", balances: { usd: 12.5, diem: null, bundledCredits: null, earnedCredits: null }, diemEpochAllocation: 0 }); + } + if (url.includes("/characters")) { + seen.characters.push(url); + const q = new URL(url).searchParams, tag = q.get("tags"); + return ok({ object: "list", data: CHARACTERS.filter((c) => (q.get("isAdult") !== "false" || !c.adult) && (!tag || c.tags.includes(tag))) }); + } if (url.includes("/chat/completions")) { const b = JSON.parse(body); seen.chats.push(b); @@ -127,7 +139,9 @@ async function main() { }; const venice = async (page) => { await page.route(/^https:\/\/api\.venice\.ai\//, fakeVenice); - await page.route(/^https:\/\/outerface\.venice\.ai\//, (r) => r.fulfill({ status: 200, contentType: "image/png", body: PIXEL })); + // 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 }); }; @@ -177,21 +191,41 @@ async function main() { assert.equal(await page.evaluate(() => localStorage.getItem("havre.key")), null, "an unremembered key stays out of localStorage"); assert.equal(await page.evaluate(() => JSON.parse(sessionStorage.getItem("havre.key"))), "sk-good"); - // Venice's characters: adult ones are never listed, and a photo from outerface.venice.ai shows + // Venice's characters: adult ones only when asked for, and photos come through Venice's image service await count(page, "#vgrid li:not(.ghost)", 2); - assert.ok(seen.characters.some((u) => /isAdult=false/.test(u) && /sortBy=featured/.test(u)), "asked for safe-for-work characters"); + assert.ok(seen.characters.some((u) => /isAdult=false/.test(u) && /sortBy=featured/.test(u)), "no adult characters unless asked"); await until(page, () => { const i = document.querySelector("#vgrid img"); return !!i && i.complete && i.naturalWidth > 0; }, null, "a character's photo"); + assert.ok(seen.photos[0].startsWith("https://venice.ai/_next/image?url=https%3A%2F%2Fouterface.venice.ai%2Fsable.png&w="), seen.photos[0]); assert.equal(await page.locator("#vgrid .noface").count(), 1, "no photo is an empty frame"); - const asked = seen.characters.length; + await has(page, "#balance", /^balance \$12\.50$/); + let asked = seen.characters.length; await page.selectOption("#vsort", "imports"); await poll(() => seen.characters.length > asked, "a sorted search"); assert.ok(seen.characters.at(-1).includes("sortBy=imports"), "the sort reaches Venice"); await count(page, "#vgrid li:not(.ghost)", 2); await has(page, "#vgrid .nm", /Wren/); + // a tag narrows the list, and comes off again + asked = seen.characters.length; + await page.getByRole("button", { name: "Only characters tagged neighbor" }).click(); + await poll(() => seen.characters.length > asked, "a search by tag"); + assert.ok(seen.characters.at(-1).includes("tags=neighbor")); + await count(page, "#vgrid li:not(.ghost)", 1); + await page.getByRole("button", { name: "Show every tag, not just neighbor" }).click(); + await count(page, "#vgrid li:not(.ghost)", 2); + // adult characters, once someone says they're 18 or older + page.once("dialog", (d) => d.accept()); + await page.check("#vadult"); + await count(page, "#vgrid li:not(.ghost)", 3); + assert.ok(!seen.characters.at(-1).includes("isAdult"), "every character, adult ones too"); + await has(page, "#vgrid .x18", /18\+/); + assert.equal(await page.evaluate(() => localStorage.getItem("havre.adult.18")), "true"); + await page.uncheck("#vadult"); + await count(page, "#vgrid li:not(.ghost)", 2); // one of them says hello first, through Venice, with their own prompt const chatsBefore = seen.chats.length; await page.locator("#vgrid .tile", { hasText: "Sable Penrose" }).click(); + await page.waitForTimeout(50); await has(page, "#who-name", /^Sable Penrose$/); await has(page, "#log .line.them .said", /Careful, the boxes bite\./); assert.equal(await page.locator("#log .line.me").count(), 0, "what started it isn't shown"); @@ -201,12 +235,34 @@ async function main() { assert.equal(hello.messages[0].role, "user"); await count(page, "#roster tr", 2); assert.match(await page.locator('#vgrid .tile[aria-pressed="true"]').evaluate((el) => el.textContent), /Sable Penrose.*talking now/); + // the model: their own on Venice to start with, and any other Venice model from the picker + await has(page, "#model-btn", /^GLM 5\.2$/); + await page.click("#model-btn"); + await count(page, "#mlist tr", 2); + assert.match(await textOf(page, "#mlist tr"), /GLM 5\.2.*their own/); + assert.match(await page.locator("#mlist tr", { hasText: "Tiny" }).evaluate((el) => el.textContent), /anonymized.*sees.*uncensored.*\$0\.10 \/ \$0\.20.*32k/); + await shot(page, "desktop-models.png"); + await page.getByRole("button", { name: "Use Tiny" }).click(); + await has(page, "#model-btn", /^Tiny$/); 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"); await page.waitForTimeout(400); await shot(page, "desktop-chat.png"); + // a plain chat: no character, just a model, in one piece + await page.click("#plain"); + await has(page, "#who-name", /^no character$/); + 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); + 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"); + // 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(); await has(page, "#rx-from", /^Captain Ro$/); diff --git a/ventures/havre/venice.js b/ventures/havre/venice.js index 1f35d70..6d972e9 100644 --- a/ventures/havre/venice.js +++ b/ventures/havre/venice.js @@ -46,60 +46,78 @@ export class Venice { return data; } - /** text models with their prices (USD per million tokens); no key needed */ + /** + * 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. + */ async models() { const d = await this.call("/models?type=text", { auth: false }); - return (d && d.data || []).filter((m) => !(m.model_spec && m.model_spec.offline)).map((m) => { - const s = m.model_spec || {}, p = s.pricing || {}; + return (d && d.data || []).filter((m) => m && !(m.model_spec && (m.model_spec.offline || m.model_spec.beta))).map((m) => { + const s = m.model_spec || {}, p = s.pricing || {}, c = s.capabilities || {}, dep = s.deprecation || null; return { id: m.id, name: s.name || m.id, ctx: s.availableContextTokens || 0, traits: s.traits || [], input: p.input && p.input.usd, output: p.output && p.output.usd, - json: !!(s.capabilities && s.capabilities.supportsResponseSchema), + privacy: s.privacy || "", uncensored: !!s.uncensored, + vision: !!c.supportsVision, tools: !!c.supportsFunctionCalling, reasoning: !!c.supportsReasoning, web: !!c.supportsWebSearch, + json: !!c.supportsResponseSchema, + retiring: dep ? String(dep.removesAt || dep.date || "soon").slice(0, 10) : "", }; }); } + /** what is left to spend on this key: { amount, currency, canConsume } */ + async balance() { + const d = await this.call("/billing/balance"); + const b = d && d.balances || {}, currency = d && d.consumptionCurrency || "USD"; + const amount = { DIEM: b.diem, BUNDLED_CREDITS: b.bundledCredits, EARNED_CREDITS: b.earnedCredits }[currency] ?? b.usd; + return { amount: typeof amount === "number" ? amount : null, currency, canConsume: !!(d && d.canConsume) }; + } + /** - * One page of public Venice characters, safe for work only: { list, more }. sort: featured, imports, highestRating, - * mostRecent (or any other sortBy Venice takes); search matches names, descriptions and #tags. + * One page of public Venice characters: { list, more }. Adult characters only with adult: true. sort: featured, + * imports, highestRating, mostRecent (or any other sortBy Venice takes); search matches names, descriptions and + * #tags; tags narrows to a tag. */ - async characterPage({ search = "", limit = 24, offset = 0, sort = "" } = {}) { - const q = new URLSearchParams({ limit: String(limit), offset: String(offset), isAdult: "false" }); + async characterPage({ search = "", limit = 24, offset = 0, sort = "", tags = "", adult = false } = {}) { + const q = new URLSearchParams({ limit: String(limit), offset: String(offset) }); + if (!adult) q.set("isAdult", "false"); if (search) q.set("search", search); if (sort) q.set("sortBy", sort); + if (tags) q.set("tags", tags); let d; try { d = await this.call("/characters?" + q); } catch (e) { if (e.status !== 400) throw e; - q.delete("sortBy"); q.delete("isAdult"); // asked for something this API doesn't take: ask plainly + for (const k of ["sortBy", "isAdult", "tags"]) q.delete(k); // asked for something this API doesn't take: ask plainly d = await this.call("/characters?" + q); } const raw = d && Array.isArray(d.data) ? d.data : []; - return { list: raw.filter((c) => c && !c.adult).map(characterInfo), more: raw.length >= limit }; + return { list: raw.filter((c) => c && (adult || !c.adult)).map(characterInfo), more: raw.length >= limit }; } - /** public Venice characters, safe for work only */ + /** public Venice characters (adult ones only with adult: true) */ async characters(opts = {}) { return (await this.characterPage(opts)).list; } - async character(slug) { + async character(slug, { adult = false } = {}) { const d = await this.call("/characters/" + encodeURIComponent(slug)); const c = d && d.data; if (!c) throw new VeniceError("Venice doesn't know that character.", 404); - if (c.adult) throw new VeniceError("That character is marked adult; Havre only shows safe-for-work characters for now.", 451); + if (c.adult && !adult) throw new VeniceError("That character is marked adult. Switch on adult characters to see them.", 451); return characterInfo(c); } /** * One chat completion. characterSlug: speak through a public Venice character (its own prompt, which Havre never - * sees). schema: ask for JSON matching it. Returns { text, usage }. + * sees). veniceSystemPrompt: keep Venice's own system prompt (a plain chat with no character). schema: ask for JSON + * matching it. Returns { text, usage }. */ - async chat({ model, messages, characterSlug = "", schema = null, maxTokens = 700, temperature = 0.8, signal } = {}) { + async chat({ model, messages, characterSlug = "", veniceSystemPrompt = false, schema = null, maxTokens = 700, temperature = 0.8, signal } = {}) { // short-form chat wants fast, plain replies: no reasoning pass (a reasoning model can spend the whole budget // thinking and send back nothing), and only Havre's own system prompt - const venice_parameters = { disable_thinking: true, strip_thinking_response: true, include_venice_system_prompt: false }; + const venice_parameters = { disable_thinking: true, strip_thinking_response: true, include_venice_system_prompt: !!veniceSystemPrompt }; if (characterSlug) { venice_parameters.character_slug = characterSlug; delete venice_parameters.include_venice_system_prompt; } const body = { model, messages, max_tokens: maxTokens, temperature, stream: false, venice_parameters }; if (schema) body.response_format = { type: "json_schema", json_schema: { name: "result", strict: true, schema } }; @@ -122,5 +140,6 @@ function characterInfo(c) { photo: /^https:\/\/outerface\.venice\.ai\//.test(c.photoUrl || "") ? c.photoUrl : "", url: "https://venice.ai/c/" + encodeURIComponent(c.slug), model: c.modelId || "", tags: c.tags || [], imports: c.stats && c.stats.imports || 0, rating: c.stats && c.stats.averageRating || 0, web: !!c.webEnabled, + adult: !!c.adult, }; }