ventures: refresh havre from havre-next
This commit is contained in:
1 parent
f0b6e6b8e6
commit
bb5fc2d25c
7 files changed
+357
-72
No files matched your search
+174
-36
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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 |
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user