| 1 | // Shared machinery for Eleven's scripted conversations (setup, welcome): the |
| 2 | // paced reply queue, the per-step tap guards, and PERSISTENT lines — each new |
| 3 | // line Eleven says is emitted as an app event (encrypted and stored like any |
| 4 | // thread event) and folds into the chat view as a real bubble, so the |
| 5 | // conversation survives reloads as history instead of vanishing. Only the |
| 6 | // action chips and composer hints ride a client-only notice (they need the |
| 7 | // running script's closures); on a resumed run a line already in history |
| 8 | // re-arms its chips instantly, with no typing beat and no duplicate bubble. |
| 9 | import { emitAppEvent, appStateFor, awaitAnswer } from "./apps.js"; |
| 10 | |
| 11 | function sleep(ms) { |
| 12 | return new Promise((r) => setTimeout(r, ms)); |
| 13 | } |
| 14 | |
| 15 | // The per-step tap guard: first advancing tap (or typed answer) wins. `taken` |
| 16 | // is readable so a step's chips can render themselves spent once it resolves. |
| 17 | export function onceStep() { |
| 18 | const go = (fn) => { |
| 19 | if (go.taken) return; |
| 20 | go.taken = true; |
| 21 | fn(); |
| 22 | }; |
| 23 | go.taken = false; |
| 24 | return go; |
| 25 | } |
| 26 | |
| 27 | // An advancing chip: consumes the step; `done` is its past-tense label once |
| 28 | // tapped ("Skipped"), and stale ties its liveness to the step guard. |
| 29 | export const advance = (go, label, fn, done) => ({ label, done, stale: () => go.taken, run: () => go(fn) }); |
| 30 | |
| 31 | // Ask a typed question: the next composer message in this thread is the answer. |
| 32 | export function ask(env, go, onText) { |
| 33 | awaitAnswer(env.threadId, (text) => go(() => onText(text.trim()))); |
| 34 | } |
| 35 | |
| 36 | // makeSay(appName, sayType): build the app's say(env, text, actions, extra). |
| 37 | // Replies play through a queue with a "thinking" beat, then typing scaled to |
| 38 | // the reply's length (with jitter, first message quickest). Each line is |
| 39 | // persisted via a `sayType` event keyed by its text; the app's reducer records |
| 40 | // the key in state.said and its chatMessage projects the text as a bubble. |
| 41 | // extra: { hint, attachHint, label } ride the chips notice (composer |
| 42 | // placeholder + the inset field label); { local: true } keeps a line |
| 43 | // client-only (e.g. an image preview). |
| 44 | export function makeSay(appName, sayType) { |
| 45 | let queue = Promise.resolve(); |
| 46 | let first = true; |
| 47 | const said = (env, key) => { |
| 48 | const st = appStateFor(env.threadId, appName); |
| 49 | return !!(st && st.said && st.said[key]); |
| 50 | }; |
| 51 | return function say(env, text, actions, extra) { |
| 52 | const opener = first; |
| 53 | first = false; |
| 54 | const opts = extra || {}; |
| 55 | // Steps that fold an ack into their question ("Lovely to meet you! …") |
| 56 | // pass a stable key, so a resumed run recognises the question even though |
| 57 | // its ack prefix differs — otherwise the variant re-emits as a duplicate. |
| 58 | const key = opts.key || text; |
| 59 | queue = queue.then(async () => { |
| 60 | // The beat turns the indicator ON and holds it a readable moment (≥1s, |
| 61 | // scaling with reply length) — but does NOT turn it off: the caller drops |
| 62 | // it only once the bubble is actually in the feed, so the list never |
| 63 | // collapses and then re-grows between indicator and message. |
| 64 | const beat = async () => { |
| 65 | if (!env.typing) return; |
| 66 | await sleep(opener ? 150 : 300 + Math.random() * 250); |
| 67 | env.typing(true); |
| 68 | const ms = Math.min(2000, (opener ? 350 : 600) + text.length * 4); |
| 69 | await sleep(Math.max(1000, ms * (0.85 + Math.random() * 0.3))); |
| 70 | }; |
| 71 | const beatOff = () => env.typing && env.typing(false); |
| 72 | if (opts.local) { |
| 73 | await beat(); |
| 74 | beatOff(); |
| 75 | env.respond({ sender: env.sender, text, actions, ...opts }); |
| 76 | return; |
| 77 | } |
| 78 | if (!said(env, key)) { |
| 79 | await beat(); |
| 80 | // Emit ONCE it actually sends. The transport returns false while the |
| 81 | // socket is still connecting (the boot-time race the first line hits), |
| 82 | // so retry only until it reports the frame went out — never re-emit a |
| 83 | // frame that already sent, or a slow echo would persist the line twice. |
| 84 | let sent = false; |
| 85 | for (let attempt = 0; attempt < 12 && !sent && !said(env, key); attempt++) { |
| 86 | sent = await emitAppEvent(env.threadId, { type: sayType, key, text, ts: Date.now() }); |
| 87 | if (!sent) await sleep(150); |
| 88 | } |
| 89 | // Then wait (passively) for the echo to fold into the transcript so the |
| 90 | // chips row below renders under the question it belongs to. |
| 91 | for (let i = 0; i < 30 && !said(env, key); i++) await sleep(100); |
| 92 | beatOff(); // the bubble is in the feed (or we're falling back) — indicator off AFTER it lands |
| 93 | if (!said(env, key)) { |
| 94 | // Persistence failed outright (offline?) — the member still sees the |
| 95 | // line, as a client-only notice; a later run re-says it for keeps. |
| 96 | env.respond({ sender: env.sender, text, actions, ...opts }); |
| 97 | return; |
| 98 | } |
| 99 | } |
| 100 | if ((actions && actions.length) || opts.hint || opts.attachHint || opts.label) { |
| 101 | env.respond({ actions, hint: opts.hint, attachHint: opts.attachHint, label: opts.label }); |
| 102 | } |
| 103 | }); |
| 104 | return queue; |
| 105 | }; |
| 106 | } |
| 107 | |
| 108 | // makeLiveSay: makeSay for a REAL thread — the one-Eleven mode, where the |
| 109 | // scripted conversation runs inside the live agent Eleven's DM instead of a |
| 110 | // parallel app-thread. Lines persist as ordinary chat messages authored |
| 111 | // "Eleven" (uid-less, so every client — including native — renders them as |
| 112 | // the bot's bubbles, and the sidecar's agent-name rule keeps the real Eleven |
| 113 | // from answering its own scripted half). Same pacing/queue/dedup contract as |
| 114 | // makeSay; the host supplies `live.said(key)` (is this line already in the |
| 115 | // transcript?) and `live.post(text, key)` (send it). |
| 116 | export function makeLiveSay(live) { |
| 117 | let queue = Promise.resolve(); |
| 118 | let first = true; |
| 119 | return function say(env, text, actions, extra) { |
| 120 | const opener = first; |
| 121 | first = false; |
| 122 | const opts = extra || {}; |
| 123 | const key = opts.key || text; |
| 124 | queue = queue.then(async () => { |
| 125 | const beat = async () => { |
| 126 | if (!env.typing) return; |
| 127 | await sleep(opener ? 150 : 300 + Math.random() * 250); |
| 128 | env.typing(true); |
| 129 | const ms = Math.min(2000, (opener ? 350 : 600) + text.length * 4); |
| 130 | await sleep(Math.max(1000, ms * (0.85 + Math.random() * 0.3))); |
| 131 | }; |
| 132 | const beatOff = () => env.typing && env.typing(false); |
| 133 | if (opts.local) { |
| 134 | await beat(); |
| 135 | beatOff(); |
| 136 | env.respond({ sender: env.sender, text, actions, ...opts }); |
| 137 | return; |
| 138 | } |
| 139 | if (!live.said(key)) { |
| 140 | await beat(); |
| 141 | let sent = false; |
| 142 | for (let attempt = 0; attempt < 12 && !sent && !live.said(key); attempt++) { |
| 143 | sent = await live.post(text, key); |
| 144 | if (!sent) await sleep(150); |
| 145 | } |
| 146 | for (let i = 0; i < 30 && !live.said(key); i++) await sleep(100); |
| 147 | beatOff(); |
| 148 | if (!live.said(key)) { |
| 149 | env.respond({ sender: env.sender, text, actions, ...opts }); |
| 150 | return; |
| 151 | } |
| 152 | } |
| 153 | if ((actions && actions.length) || opts.hint || opts.attachHint || opts.label) { |
| 154 | env.respond({ actions, hint: opts.hint, attachHint: opts.attachHint, label: opts.label }); |
| 155 | } |
| 156 | }); |
| 157 | return queue; |
| 158 | }; |
| 159 | } |
| 160 | |
| 161 | // The reducer entry for a say event and its chat-view projection — one line |
| 162 | // each in the app's spec: `...sayEvents("welcome.say")` under `events`, and |
| 163 | // `const line = sayBubble(e, "welcome.say"); if (line) return line;` first in |
| 164 | // chatMessage. |
| 165 | export function sayEvents(sayType) { |
| 166 | return { |
| 167 | [sayType]: (s, e) => (e.key ? { ...s, said: { ...(s.said || {}), [e.key]: true } } : s), |
| 168 | }; |
| 169 | } |
| 170 | export function sayBubble(e, sayType) { |
| 171 | if (e.type === sayType && e.text) return { text: e.text }; |
| 172 | return null; |
| 173 | } |
| 174 | |