diff --git a/ventures/havre/app.js b/ventures/havre/app.js index 3329dfa..78dfedd 100644 --- a/ventures/havre/app.js +++ b/ventures/havre/app.js @@ -5,6 +5,7 @@ import * as A from "./abilities.js"; import * as M from "./media.js"; import * as T from "./threads.js"; import * as R from "./memory.js"; +import * as W from "./workflows.js"; import { Venice, VeniceError } from "./venice.js"; import CAST from "./cast/index.js"; @@ -1327,7 +1328,9 @@ function mediaLine(c, m) { if (md.kind === "photo" || md.kind === "photo-in") { const alt = md.kind === "photo" ? `A photo from ${c.name}${md.shot ? ": " + md.shot : ""}` : "Your photo"; said.append(h("figure", { class: "pic" }, mediaEl("img", md.id, { alt, title: md.shot || null, decoding: "async" }, "photo"))); - if (md.kind === "photo") said.append(cost(["photo", md.model, md.price != null && A.usd(md.price), md.adult && "18+", md.own && "on their own", md.blurred && "blurred by venice's safe mode"])); + if (md.kind === "photo") said.append(cost([md.offer === "portrait" ? "portrait: " + (md.shot || "") : "photo", md.model, md.price != null && A.usd(md.price), md.adult && "18+", md.own && "on their own", md.blurred && "blurred by venice's safe mode"])); + if (md.offer === "portrait" && !me) said.append(h("p", { class: "mono" }, h("button", { type: "button", class: "linkish", text: md.kept ? "kept as their portrait" : "use as their portrait", disabled: md.kept || null, + onclick: () => useAsPortrait(c, m) }))); else if (md.saw) said.append(h("span", { class: "cap", text: `${md.seer || "a model"} told them: ${md.saw}` })); } else if (md.kind === "clip" && !md.id) { // a clip this browser couldn't download: it plays from Venice's link until the link lapses @@ -2194,7 +2197,141 @@ function renderMakers() { b.textContent = "make one with ai" + (price != null ? " · " + A.usd(price) : ""); b.disabled = drawing || (as !== "portrait" && !!c.portrait && c.look.references.length >= C.MAX_REFERENCES); } + const { price: one } = makerPlan(c, "portrait"), three = $("#portrait-three"); + three.textContent = "three to pick from" + (one != null ? " · " + A.usd(one * 3) : ""); + three.disabled = drawing || portraitsBusy; } +// ---------- workflows: the things the harbor does, by name, from any door (workflows.js) ---------- +/** a slash command from the chat box: /help, a workflow, or a word Havre doesn't know */ +function runCommand({ id, arg }) { + if (id === "help") { put(line("commands:\n" + W.helpLines().map((l) => " " + l).join("\n"), "havre", "note")); scrollLog(); return; } + if (id === "unknown") { put(line(`no /${arg} here. /help lists what there is.`, "havre", "note")); scrollLog(); return; } + runWorkflow(id, { door: "chat", idea: arg }); +} +/** one workflow, by id, from a door: the same run whichever door opened it */ +function runWorkflow(id, { door = "chat", idea = "", name = "" } = {}) { + const w = W.byId(id); + if (!w) return; + if (!S.key) return needKey(`${w.title} needs your venice key.`); + if (id === "portraits") return threePortraits({ door }); + if (id === "new") return writeFromIdea(idea, { door, name }); +} +/** the chat a workflow's result lands in: the one of theirs on screen, else their latest, else a new one */ +function threadFor(c) { + const t = thread(); + if (t && t.card === c.id) return t; + const latest = T.latestOf(Object.values(S.threads), c.id); + if (latest) return latest; + const fresh = startThread(c); + if (here(fresh)) renderChatMeta(); + return fresh; +} +/** + * Three portraits: three ways they could look, written from their card by a text model, each drawn by the portrait + * model, delivered into their chat as three pictures with a link under each to make it their portrait. Costs three + * portraits on your key, asked first. + */ +let portraitsBusy = false; +async function threePortraits({ door = "chat" } = {}) { + const c = card(); + if (!c || c.plain) { if (door === "chat") err("/portraits needs a character: pick one in the harbor first."); return; } + if (portraitsBusy) { toast("Three portraits are already on their way."); return; } + await loadList("image"); + const { model: pm, adult } = drawPlan(c); + if (!pm || pm.price == null) { const why = S.listErr.image || "Venice has no model for that right now."; if (door === "chat") err(why); else lookStatus("#portrait-status", why, true); return; } + const price = pm.price * 3; + if (!confirm(`Draw ${c.name} three ways with ${pm.name}, to pick a portrait from? It costs about ${A.usd(price)} on your Venice key.`)) return; + const t = threadFor(c); + if (door !== "chat") { openThread(t.id); setLeft("chats"); } + portraitsBusy = true; + S.inflight++; + renderMakers(); + doing(t, "is imagining three ways they could look…"); + try { + const v = new Venice(S.key); + const o = await askJson(await jsonModel(c), { messages: W.directionsMessages(c), schema: W.DIRECTIONS_SCHEMA, maxTokens: 900, temperature: 0.95, what: "three looks" }); + const dirs = W.normalizeDirections(o); + if (!dirs.length) throw new Error("The model sent back no looks. Try again, or pick another model on the card."); + doing(t, `is being drawn ${dirs.length} ways with ${pm.name}…`); + const drawn = await Promise.allSettled(dirs.map((d) => drawPicture(c, d.look, v))); + done(t); + let n = 0, why = null; + for (let i = 0; i < dirs.length; i++) { + const r = drawn[i]; + if (r.status !== "fulfilled") { why = why || r.reason; continue; } + const id = await M.put(dataBlob(r.value)); + deliver(t, { role: "assistant", content: "", media: { kind: "photo", id, shot: dirs[i].title, look: dirs[i].look, model: pm.name, price: pm.price, offer: "portrait", ...(adult ? { adult: true } : {}) } }); + n++; + } + if (!n) throw why || new Error("None of the three came out."); + if (why) err(`${dirs.length - n} of the three didn't come out: ${redraw(why)}`); + if (door !== "chat") toast(`${n} portraits of ${c.name} are in your chat with them: pick one.`); + } catch (e) { + done(t); + err(redraw(e)); + } finally { + portraitsBusy = false; + S.inflight--; + renderMakers(); + balanceSoon(); + } +} +/** one of the three, kept as their portrait (the look it was drawn from goes on their card if it had none) */ +async function useAsPortrait(c, m) { + const md = m.media || {}, blob = await M.get(md.id); + if (!blob) { toast("That picture isn't kept in this browser any more."); return; } + const pic = await shrinkImage(blob, 512, "image/webp", { svg: false }); + const now = S.cards.find((x) => x.id === c.id); + if (!now) return; + keepPicture(now, pic, md.look || "", "portrait"); + md.kept = true; + const t = Object.values(S.threads).find((x) => x.msgs.includes(m)); + if (t) saveThread(t); + if (t && here(t)) renderLog(); + toast(`${c.name} has a new portrait.`); +} +/** + * Someone new from an idea: one call writes the card (name, tagline, greeting, persona, examples, how they look), and + * they join the harbor with their chat open, for you to say hello. From the harbor form the idea is what's typed + * under who they are (and the name, if one was given); from the chat it's the words after /new. + */ +let writingIdea = false; +async function writeFromIdea(idea, { door = "chat", name = "" } = {}) { + idea = (idea || "").trim(); + if (!idea) { + if (door === "chat") { put(line("/new needs an idea after it: /new a lighthouse keeper who writes letters to the sea", "havre", "note")); scrollLog(); } + else $("#nc-persona").focus(); + return; + } + if (writingIdea) { toast("Someone is already being written."); return; } + writingIdea = true; + S.inflight++; + const note = door === "chat" ? put(line("havre is writing someone new…", "havre", "typing")) : null; + if (note) scrollLog(); + const btn = $("#nc-ai"); + btn.disabled = true; + try { + const model = S.model, writer = await jsonModel({ model }); + if (door !== "chat") $("#nc-persona").placeholder = `writing with ${modelName(writer)}…`; + const o = await askJson(writer, { messages: W.ideaMessages(idea, name), schema: W.IDEA_SCHEMA, maxTokens: 2000, temperature: 0.95, what: "a character" }); + const c = addCard(W.cardFromIdea(o, { model }), { select: false }); + if (note) note.remove(); + if (door !== "chat") $("#card-new").reset(); + selectCard(c.id, { talk: true, scroll: true, open: null }); + setTab("edit"); + toast(`${c.name} joined the harbor. Say hello, or /portraits to see them.`); + } catch (e) { + if (note) note.remove(); + if (door === "chat") err(e.message); else toast(e.message); + } finally { + writingIdea = false; + S.inflight--; + btn.disabled = false; + $("#nc-persona").placeholder = "who they are, what they care about, how they talk"; + balanceSoon(); + } +} + /** a picture for their card, made with AI on your key: a new portrait ("portrait"), or one more picture of them */ async function makePicture(as = "") { const c = card(), st = as === "portrait" ? "#portrait-status" : "#look-status"; @@ -3382,7 +3519,8 @@ function bind() { if (!t.trim() || (thread() && S.busy.has(thread().id))) return; say.value = ""; growSay(); - send(t); + const cmd = W.parseCommand(t); + if (cmd) runCommand(cmd); else send(t); }); say.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); $("#composer").requestSubmit(); } }); say.addEventListener("input", growSay); @@ -3497,6 +3635,8 @@ function bind() { }); $("#ref-file").addEventListener("change", () => { const rf = $("#ref-file").files[0]; $("#ref-file").value = ""; if (rf) addReference(rf); }); $("#portrait-make").addEventListener("click", () => makePicture("portrait")); + $("#portrait-three").addEventListener("click", () => runWorkflow("portraits", { door: "sheet" })); + $("#nc-ai").addEventListener("click", () => runWorkflow("new", { door: "harbor", idea: $("#nc-persona").value.trim(), name: $("#nc-name").value.trim() })); $("#ref-make").addEventListener("click", () => makePicture()); $("#speech-sample").addEventListener("click", hearSample); $("#speech-group").addEventListener("toggle", () => { if ($("#speech-group").open) loadList("tts"); }); diff --git a/ventures/havre/index.html b/ventures/havre/index.html index cb7c679..5682437 100644 --- a/ventures/havre/index.html +++ b/ventures/havre/index.html @@ -110,7 +110,7 @@
- +
@@ -131,7 +131,7 @@ -

+

@@ -145,7 +145,7 @@
-

/ /

+

/ / /

diff --git a/ventures/havre/tests/workflows.test.mjs b/ventures/havre/tests/workflows.test.mjs new file mode 100644 index 0000000..b37e3bd --- /dev/null +++ b/ventures/havre/tests/workflows.test.mjs @@ -0,0 +1,75 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as W from "../workflows.js"; +import { validateCard } from "../card.js"; + +test("every workflow has a command, doors and a usage line", () => { + for (const w of W.WORKFLOWS) { + assert.ok(w.id && w.command && w.title && w.about && w.usage, w.id); + assert.ok(w.doors.length >= 1, w.id); + assert.equal(W.byId(w.id), w); + assert.equal(W.byCommand(w.command), w); + } +}); + +test("a slash command at the start of the chat box is a workflow; anything else is words", () => { + assert.deepEqual(W.parseCommand("/portraits"), { id: "portraits", arg: "" }); + assert.deepEqual(W.parseCommand(" /Portraits "), { id: "portraits", arg: "" }); + assert.deepEqual(W.parseCommand("/new a lighthouse keeper who\nwrites letters"), { id: "new", arg: "a lighthouse keeper who\nwrites letters" }); + assert.deepEqual(W.parseCommand("/"), { id: "help", arg: "" }); + assert.deepEqual(W.parseCommand("/help"), { id: "help", arg: "" }); + assert.deepEqual(W.parseCommand("/dance"), { id: "unknown", arg: "dance" }); + assert.equal(W.parseCommand("tell me about /new"), null); + assert.equal(W.parseCommand("http://x/y"), null); + assert.equal(W.parseCommand(""), null); +}); + +test("/help lists the workflows with a chat door, one line each", () => { + const lines = W.helpLines(); + assert.equal(lines.length, W.WORKFLOWS.filter((w) => w.doors.includes("chat")).length); + assert.ok(lines.every((l) => l.startsWith("/") && l.includes(" — "))); +}); + +test("three directions come back cleaned, titled, and no more than three", () => { + const d = W.normalizeDirections({ directions: [ + { title: " Harbor dawn ", look: " Thirty, salt-grey hair,\n a coat too big. " }, + { title: "", look: "Forty, sharp bob, a red scarf." }, + { title: "x", look: "" }, + { title: "Four", look: "ok" }, { title: "Five", look: "ok" }, + ] }); + assert.equal(d.length, 3); + assert.deepEqual(d[0], { title: "Harbor dawn", look: "Thirty, salt-grey hair, a coat too big." }); + assert.equal(d[1].title, "look 2"); + assert.equal(d[2].title, "Four"); + assert.deepEqual(W.normalizeDirections({}), []); + assert.deepEqual(W.normalizeDirections(null), []); +}); + +test("the directions ask carries the card and what's known of how they look", () => { + const m = W.directionsMessages({ name: "Odile", tagline: "keeps the light", persona: "You are Odile.", look: { description: "tall, grey coat" } }); + assert.equal(m[0].role, "system"); + assert.match(m[1].content, /Odile/); + assert.match(m[1].content, /How they look so far: tall, grey coat/); + assert.match(W.directionsMessages({ name: "X", look: {} })[1].content, /invent them/); +}); + +test("a card from an idea is a valid Havre card with how they look on it", () => { + const o = { name: "Marguerite Vale", tagline: "the harbor's last cartographer", greeting: "Oh. You again.\n\nSit.", persona: "You are Marguerite, a cartographer who maps what the sea takes back.", + examples: [{ user: "hi", character: "hm." }], tags: ["maps", "sea"], look: "Fifties, ink-stained fingers, a coat with too many pockets." }; + const c = W.cardFromIdea(o, { model: "zai-org-glm-5-2" }); + assert.deepEqual(validateCard(c), []); + assert.equal(c.name, "Marguerite Vale"); + assert.equal(c.look.description, "Fifties, ink-stained fingers, a coat with too many pockets."); + assert.equal(c.model, "zai-org-glm-5-2"); + assert.equal(c.lineage.length, 0); + assert.equal(c.voice.short_form, true); +}); + +test("the idea schema is the writer's schema plus a look", () => { + assert.ok(W.IDEA_SCHEMA.required.includes("look")); + assert.ok(W.IDEA_SCHEMA.required.includes("persona")); + const m = W.ideaMessages("a shy rapper", "Lone"); + assert.match(m[1].content, /"idea": "a shy rapper"/); + assert.match(m[1].content, /"name": "Lone"/); + assert.doesNotMatch(W.ideaMessages("x")[1].content, /"name"/); +}); diff --git a/ventures/havre/workflows.js b/ventures/havre/workflows.js new file mode 100644 index 0000000..cde0ec8 --- /dev/null +++ b/ventures/havre/workflows.js @@ -0,0 +1,109 @@ +// workflows.js: the things Havre does as named workflows, and the doors into each. Pure functions, no DOM: the page +// imports this module, and so do the tests. +// +// A workflow is one named thing the harbor can do, with what it takes, what it gives, and the doors it can be +// started from. The same workflow runs the same way whichever door opens it: +// +// chat a slash command typed in the chat box (/portraits, /new a lighthouse keeper who…) +// sheet a link on the character's card +// harbor the new-character form +// +// The page holds the hands that run them (app.js, runWorkflow); this file holds what they are, what the models are +// asked, and how to read what comes back. More doors (a timer, a hook) are more entries in `doors`, nothing else. + +import { normalizeCard, cardFromWrite, WRITE_SCHEMA } from "./card.js"; + +const str = (v, max = 20000) => (typeof v === "string" ? v : v == null ? "" : String(v)).slice(0, max); +const clip = (s, n) => (s.length <= n ? s : s.slice(0, Math.max(0, n - 1)).replace(/\s+\S*$/, "") + "…"); +const arr = (v) => (Array.isArray(v) ? v : []); + +export const WORKFLOWS = [ + { + id: "portraits", command: "portraits", title: "three portraits", takes: "card", gives: "portrait", doors: ["sheet", "chat"], + about: "three ways they could look, drawn from their card; pick one as their portrait", + usage: "/portraits", + }, + { + id: "new", command: "new", title: "write someone", takes: "idea", gives: "card", doors: ["harbor", "chat"], + about: "a new character from an idea in a line or two, written as a card of your own", + usage: "/new ", + }, +]; + +export const byId = (id) => WORKFLOWS.find((w) => w.id === id) || null; +export const byCommand = (cmd) => WORKFLOWS.find((w) => w.command === cmd) || null; + +/** + * A slash command typed in the chat: { id, arg } for a workflow, { id: "help" } for /help or a bare "/", null for + * anything else (so "/" in the middle of a sentence, or a path, is just words). + */ +export function parseCommand(text) { + const m = /^\/([a-z]*)(?:\s+([\s\S]*))?$/i.exec(str(text, 4000).trim()); + if (!m) return null; + const cmd = m[1].toLowerCase(), arg = str(m[2] || "", 2000).trim(); + if (!cmd || cmd === "help" || cmd === "?") return { id: "help", arg: "" }; + const w = byCommand(cmd); + return w ? { id: w.id, arg } : { id: "unknown", arg: cmd }; +} + +/** what /help says: one line per workflow that has a chat door */ +export function helpLines() { + return WORKFLOWS.filter((w) => w.doors.includes("chat")).map((w) => `${w.usage} — ${w.about}`); +} + +// ---------- portraits: three ways they could look ---------- + +export const DIRECTIONS_SYSTEM = "You describe how an original character looks, for portraits of them. Give three " + + "different directions, each one paragraph of at most 45 words: their apparent age (an adult), face, hair, build and " + + "clothes, and one detail people remember. The three differ in mood, setting or style, not in who they are. Invent " + + "what isn't given. Never describe a real person. Return one JSON object: directions, an array of three objects with " + + "title (at most 4 words) and look (the paragraph)."; + +export const DIRECTIONS_SCHEMA = { + type: "object", additionalProperties: false, required: ["directions"], + properties: { directions: { type: "array", items: { type: "object", additionalProperties: false, required: ["title", "look"], + properties: { title: { type: "string" }, look: { type: "string" } } } } }, +}; + +export function directionsMessages(card) { + const about = [card.tagline, card.look && card.look.description && "How they look so far: " + clip(card.look.description.replace(/\s+/g, " "), 400), + card.persona && clip(card.persona.replace(/\s+/g, " "), 700)].filter(Boolean).join("\n"); + return [{ role: "system", content: DIRECTIONS_SYSTEM }, + { role: "user", content: `The character: ${card.name}\n${about || "Nothing else is known: invent them."}` }]; +} + +/** the three directions, cleaned: each a title and a look; fewer if the model sent fewer, none if it sent none */ +export function normalizeDirections(o, n = 3) { + const list = arr(o && o.directions).map((d) => ({ title: clip(str(d && d.title, 200).replace(/\s+/g, " ").trim(), 40), + look: str(d && d.look, 2000).replace(/\s+/g, " ").trim() })).filter((d) => d.look); + return list.slice(0, n).map((d, i) => ({ ...d, title: d.title || `look ${i + 1}` })); +} + +// ---------- new: someone from an idea ---------- + +export const IDEA_SYSTEM = "You are a character writer. From an idea in a line or two you write a brand-new, original " + + "character for a chat app, in your own words. The character is an adult, 18 or older. If the idea names them, keep " + + "that name; otherwise invent one. Return one JSON object: name, tagline (at most 8 words), greeting (one to three " + + "short texts separated by a blank line), persona (a system prompt in the second person, 120 to 250 words: who they " + + "are, how they talk, what they want, what they won't do), examples (three objects with user and character, the " + + "character's replies short, like texts), tags (at most five lowercase words), look (how they look, one paragraph of " + + "at most 45 words: apparent age, face, hair, build, clothes, one detail people remember; never a real person)."; + +export const IDEA_SCHEMA = { + ...WRITE_SCHEMA, + required: [...WRITE_SCHEMA.required, "look"], + properties: { ...WRITE_SCHEMA.properties, look: { type: "string" } }, +}; + +export function ideaMessages(idea, name = "") { + const ask = { idea: str(idea, 2000).trim(), ...(str(name, 80).trim() ? { name: str(name, 80).trim() } : {}) }; + return [{ role: "system", content: IDEA_SYSTEM }, + { role: "user", content: "Write a character from this idea.\n\n" + JSON.stringify(ask, null, 1) }]; +} + +/** the new card from what the writer sent back: a remix-shaped card (card.js cardFromWrite), plus how they look */ +export function cardFromIdea(o, { model } = {}) { + const base = cardFromWrite(o || {}, { model }); + const look = str(o && o.look, 2000).replace(/\s+/g, " ").trim(); + return normalizeCard({ ...base, look: { ...base.look, description: look } }); +}