// Shared machinery for Eleven's scripted conversations (setup, welcome): the // paced reply queue, the per-step tap guards, and PERSISTENT lines — each new // line Eleven says is emitted as an app event (encrypted and stored like any // thread event) and folds into the chat view as a real bubble, so the // conversation survives reloads as history instead of vanishing. Only the // action chips and composer hints ride a client-only notice (they need the // running script's closures); on a resumed run a line already in history // re-arms its chips instantly, with no typing beat and no duplicate bubble. import { emitAppEvent, appStateFor, awaitAnswer } from "./apps.js"; function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } // The per-step tap guard: first advancing tap (or typed answer) wins. `taken` // is readable so a step's chips can render themselves spent once it resolves. export function onceStep() { const go = (fn) => { if (go.taken) return; go.taken = true; fn(); }; go.taken = false; return go; } // An advancing chip: consumes the step; `done` is its past-tense label once // tapped ("Skipped"), and stale ties its liveness to the step guard. export const advance = (go, label, fn, done) => ({ label, done, stale: () => go.taken, run: () => go(fn) }); // Ask a typed question: the next composer message in this thread is the answer. export function ask(env, go, onText) { awaitAnswer(env.threadId, (text) => go(() => onText(text.trim()))); } // makeSay(appName, sayType): build the app's say(env, text, actions, extra). // Replies play through a queue with a "thinking" beat, then typing scaled to // the reply's length (with jitter, first message quickest). Each line is // persisted via a `sayType` event keyed by its text; the app's reducer records // the key in state.said and its chatMessage projects the text as a bubble. // extra: { hint, attachHint, label } ride the chips notice (composer // placeholder + the inset field label); { local: true } keeps a line // client-only (e.g. an image preview). export function makeSay(appName, sayType) { let queue = Promise.resolve(); let first = true; const said = (env, key) => { const st = appStateFor(env.threadId, appName); return !!(st && st.said && st.said[key]); }; return function say(env, text, actions, extra) { const opener = first; first = false; const opts = extra || {}; // Steps that fold an ack into their question ("Lovely to meet you! …") // pass a stable key, so a resumed run recognises the question even though // its ack prefix differs — otherwise the variant re-emits as a duplicate. const key = opts.key || text; queue = queue.then(async () => { // The beat turns the indicator ON and holds it a readable moment (≥1s, // scaling with reply length) — but does NOT turn it off: the caller drops // it only once the bubble is actually in the feed, so the list never // collapses and then re-grows between indicator and message. const beat = async () => { if (!env.typing) return; await sleep(opener ? 150 : 300 + Math.random() * 250); env.typing(true); const ms = Math.min(2000, (opener ? 350 : 600) + text.length * 4); await sleep(Math.max(1000, ms * (0.85 + Math.random() * 0.3))); }; const beatOff = () => env.typing && env.typing(false); if (opts.local) { await beat(); beatOff(); env.respond({ sender: env.sender, text, actions, ...opts }); return; } if (!said(env, key)) { await beat(); // Emit ONCE it actually sends. The transport returns false while the // socket is still connecting (the boot-time race the first line hits), // so retry only until it reports the frame went out — never re-emit a // frame that already sent, or a slow echo would persist the line twice. let sent = false; for (let attempt = 0; attempt < 12 && !sent && !said(env, key); attempt++) { sent = await emitAppEvent(env.threadId, { type: sayType, key, text, ts: Date.now() }); if (!sent) await sleep(150); } // Then wait (passively) for the echo to fold into the transcript so the // chips row below renders under the question it belongs to. for (let i = 0; i < 30 && !said(env, key); i++) await sleep(100); beatOff(); // the bubble is in the feed (or we're falling back) — indicator off AFTER it lands if (!said(env, key)) { // Persistence failed outright (offline?) — the member still sees the // line, as a client-only notice; a later run re-says it for keeps. env.respond({ sender: env.sender, text, actions, ...opts }); return; } } if ((actions && actions.length) || opts.hint || opts.attachHint || opts.label) { env.respond({ actions, hint: opts.hint, attachHint: opts.attachHint, label: opts.label }); } }); return queue; }; } // makeLiveSay: makeSay for a REAL thread — the one-Eleven mode, where the // scripted conversation runs inside the live agent Eleven's DM instead of a // parallel app-thread. Lines persist as ordinary chat messages authored // "Eleven" (uid-less, so every client — including native — renders them as // the bot's bubbles, and the sidecar's agent-name rule keeps the real Eleven // from answering its own scripted half). Same pacing/queue/dedup contract as // makeSay; the host supplies `live.said(key)` (is this line already in the // transcript?) and `live.post(text, key)` (send it). export function makeLiveSay(live) { let queue = Promise.resolve(); let first = true; return function say(env, text, actions, extra) { const opener = first; first = false; const opts = extra || {}; const key = opts.key || text; queue = queue.then(async () => { const beat = async () => { if (!env.typing) return; await sleep(opener ? 150 : 300 + Math.random() * 250); env.typing(true); const ms = Math.min(2000, (opener ? 350 : 600) + text.length * 4); await sleep(Math.max(1000, ms * (0.85 + Math.random() * 0.3))); }; const beatOff = () => env.typing && env.typing(false); if (opts.local) { await beat(); beatOff(); env.respond({ sender: env.sender, text, actions, ...opts }); return; } if (!live.said(key)) { await beat(); let sent = false; for (let attempt = 0; attempt < 12 && !sent && !live.said(key); attempt++) { sent = await live.post(text, key); if (!sent) await sleep(150); } for (let i = 0; i < 30 && !live.said(key); i++) await sleep(100); beatOff(); if (!live.said(key)) { env.respond({ sender: env.sender, text, actions, ...opts }); return; } } if ((actions && actions.length) || opts.hint || opts.attachHint || opts.label) { env.respond({ actions, hint: opts.hint, attachHint: opts.attachHint, label: opts.label }); } }); return queue; }; } // The reducer entry for a say event and its chat-view projection — one line // each in the app's spec: `...sayEvents("welcome.say")` under `events`, and // `const line = sayBubble(e, "welcome.say"); if (line) return line;` first in // chatMessage. export function sayEvents(sayType) { return { [sayType]: (s, e) => (e.key ? { ...s, said: { ...(s.said || {}), [e.key]: true } } : s), }; } export function sayBubble(e, sayType) { if (e.type === sayType && e.text) return { text: e.text }; return null; }