ventures: refresh havre from havre-next

This commit is contained in:
github-actions[bot] committed 2026-10-16 06:07:41 +00:00
1 parent f0b6e6b8e6
commit bb5fc2d25c
7 files changed
+357 -72

No files matched your search

+174 -36
View File
@@ -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;
try {
S.models = await new Venice("").models();
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) fillModels($("#model-select"), c.model);
} catch { /* the recommended list is enough */ }
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 {
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 }));
+29 -7
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob: https://outerface.venice.ai; font-src 'none'; connect-src https://api.venice.ai; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob: https://venice.ai https://outerface.venice.ai; font-src 'none'; connect-src https://api.venice.ai; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
<meta name="referrer" content="strict-origin">
<title>Havre</title>
<meta name="description" content="A harbor for characters. Talk to Venice's characters on your own key, keep characters in a file you own, and remix the ones you love into someone new, with credit.">
@@ -37,6 +37,7 @@
<button type="submit" class="btn inv" id="keysave">SAVE</button>
<label class="check"><input type="checkbox" id="keyremember"> remember on this device</label>
<button type="button" class="btn ghost-inv" id="keyforget" hidden>FORGET</button>
<span id="balance" class="balance" hidden></span>
<span id="keystatus" class="keystatus" role="status" aria-live="polite"></span>
<span class="keyfine">sent to api.venice.ai and nowhere else. <a href="https://venice.ai/settings/api" target="_blank" rel="noopener">get a key</a></span>
</form>
@@ -45,7 +46,7 @@
<section class="chars" id="chars" aria-labelledby="chars-h">
<div class="sechead">
<h2 id="chars-h">CHARACTERS</h2>
<span class="meta">venice's public characters, safe for work.</span>
<span class="meta">venice's public characters.</span>
</div>
<form class="filters" id="venform" role="search" aria-label="Venice characters">
<label class="sr" for="vsearch">Search Venice characters</label>
@@ -56,11 +57,13 @@
<option value="highestRating">top rated</option>
<option value="mostRecent">newest</option>
</select></label>
<label class="check"><input type="checkbox" id="vadult"> adult (18+)</label>
<button type="submit" class="btn" id="vgo">SEARCH</button>
</form>
<p id="vtags" class="tags" hidden></p>
<p id="venstatus" class="status" role="status" aria-live="polite"></p>
<ul id="vgrid" class="grid" aria-label="Venice characters"></ul>
<p class="more"><span id="vpager" hidden><button type="button" class="linkish" id="vprev">previous</button> <span id="vrange"></span> <button type="button" class="linkish" id="vnext">next</button> / </span><button type="button" class="linkish" id="import">import a card</button> (v1, v2, v3, json or png) / <button type="button" class="linkish" id="new">write one</button></p>
<p class="more"><span id="vpager" hidden><button type="button" class="linkish" id="vprev">previous</button> <span id="vrange"></span> <button type="button" class="linkish" id="vnext">next</button> / </span><button type="button" class="linkish" id="plain">chat without a character</button> / <button type="button" class="linkish" id="import">import a card</button> (v1, v2, v3, json or png) / <button type="button" class="linkish" id="new">write one</button></p>
</section>
<section class="chat" id="chat" aria-labelledby="who-name">
@@ -190,8 +193,8 @@
<div class="body">
<ol>
<li>Paste your Venice API key. It stays in this browser, and the page can reach api.venice.ai and nothing else.</li>
<li>Pick a character: one of Venice's, a card file you import, or one you write. Venice's characters say hello first.</li>
<li>Talk. Replies come as a few short texts. Without a key, the characters you write answer from their own examples.</li>
<li>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.</li>
<li>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.</li>
<li>Keep them. Export a character as a file, or remix them into someone new; the credit stays in the card.</li>
</ol>
</div>
@@ -218,7 +221,7 @@
<tr><th scope="row">a remix</th><td>two short calls; five more for a venice character</td></tr>
</tbody>
</table>
<p class="mono">each card's model list shows venice's price per million tokens.</p>
<p class="mono">the model picker shows venice's price per million tokens, and your balance shows next to your key.</p>
</div>
</section>
@@ -249,7 +252,7 @@
<div class="body">
<p>Characters and conversations are saved in this browser. Export them to keep them anywhere else.</p>
<p>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.</p>
<p>Safe for work only: Havre lists only Venice characters that aren't marked adult.</p>
<p>Adult characters stay hidden unless you switch them on and confirm you're 18 or older.</p>
<p>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.</p>
<p>Havre is independent. It isn't part of Venice.ai and isn't endorsed by Venice.</p>
</div>
@@ -280,6 +283,25 @@
</form>
</dialog>
<dialog id="modeldlg" class="dlg wide" aria-labelledby="model-h">
<form method="dialog" id="modelform">
<h2 id="model-h">MODEL</h2>
<div class="dlgbody">
<p class="mono" id="model-for"></p>
<label class="sr" for="msearch">Search models</label>
<input id="msearch" type="search" placeholder="search models: a name, private, uncensored…" autocomplete="off">
<div class="mtable">
<table class="table models">
<thead><tr><th scope="col">MODEL</th><th scope="col">PRIVACY</th><th scope="col">CAN</th><th scope="col">$ IN / OUT</th><th scope="col">CONTEXT</th></tr></thead>
<tbody id="mlist"></tbody>
</table>
</div>
<p class="mono">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.</p>
<p class="actions"><button type="button" class="btn" id="mclose">CLOSE</button></p>
</div>
</form>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

+14
View File
@@ -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); }
+36
View File
@@ -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);
});
+69 -13
View File
@@ -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$/);
+35 -16
View File
@@ -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,
};
}