eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// One Eleven + the quick forms: the scripted setup/welcome conversations and
2// their "Fill in a form instead" fast path. The scripted flows and the live
3// agent share a thread — this module bridges the flow engine (setup.flow.js /
4// welcome.flow.js) into the chat app and turns a submitted form into the same
5// recorded steps the conversation would have produced. Extracted verbatim
6// from shell.js; behaviours ride the verbs seam.
7import { verbs } from "/static/shellverbs.js";
8import { session, roster, myUserId, threads, threadKeys, activeId, threadName, embed, ws, instanceKey, threadApp, pendingJoinThread } from "/static/shellstate.js";
9import { encrypt } from "/static/crypto.js";
10import { appStateFor, setupBridge, spaceInfo, emitAppEvent } from "/static/apps.js";
11import { appendNotice, setNoticeTyping, feedGlideToBottom } from "/static/apps/chat.js";
12import { planQuickForm } from "/static/quickform.js";
13import { startWelcome } from "/static/apps/welcome.js";
14import { showDiscover, createThread } from "/static/newchat.js";
15
16// ---- one Eleven: the scripted flows and the live agent share a thread -------
17
18// sendScriptedBubble posts a scripted line as an ORDINARY chat message
19// authored "Eleven" — uid-less, so every client (native included) renders it
20// as the bot's bubble, and the sidecar's agent-name rule keeps the real
21// Eleven from replying to its own scripted half. `key` rides inside the
22// payload for resume dedup (scriptedSaid). Returns whether the frame sent.
23export async function sendScriptedBubble(threadId, text, key) {
24 const tkey = threadKeys[threadId];
25 if (!tkey || !ws || ws.readyState !== WebSocket.OPEN) return false;
26 const blob = await encrypt(tkey, { name: "Eleven", text, ts: Date.now(), key });
27 ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob }));
28 return true;
29}
30
31// sendTypedPayload posts a typed (non-chat) protocol payload into a thread —
32// the flow.state mute handshake rides this.
33export async function sendTypedPayload(threadId, payload) {
34 const tkey = threadKeys[threadId];
35 if (!tkey || !ws || ws.readyState !== WebSocket.OPEN) return false;
36 const blob = await encrypt(tkey, { ...payload, ts: Date.now() });
37 ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob }));
38 return true;
39}
40
41// scriptedSaid: is a scripted line (by dedup key) already in the thread's
42// transcript? Reads the chat app's folded state, which the open feed keeps.
43export function scriptedSaid(threadId, key) {
44 const st = appStateFor(threadId, "chat");
45 return !!(st && st.msgs && st.msgs.some((m) => m.raw && m.raw.key === key && m.name === "Eleven"));
46}
47
48// liveElevenAgent: the roster's real agent Eleven, if one exists (with a key,
49// so a DM can be wrapped to it). The signal that flows should run in ITS DM.
50export function liveElevenAgent() {
51 return roster.find((e) => e.is_agent && e.public_key && /^eleven$/i.test(e.label || "")) || null;
52}
53
54export async function maybeStartWelcome() {
55 const pending = verbs.welcomePending();
56 if (!pending) return; // the marker is set only by a SUCCESSFUL member-invite
57 // claim (join.js clears it on failure), and cleared at stepFinish — so a stale
58 // one can't hijack an add-device sign-in (that path never sets it).
59 try {
60 // One Eleven: with a real agent Eleven on the roster, the welcome runs in
61 // ITS DM — the scripted floor and the AI ceiling in one thread. The
62 // app-thread fallback below stays for instances with no sidecar (a fresh
63 // space must onboard with no bot and no key — docs/conversation-scripts.md).
64 const agent = liveElevenAgent();
65 let live = null;
66 let id;
67 if (agent) {
68 id = await createThread([agent.user_id], { group: false }); // dedupes to the existing DM
69 if (!id) return;
70 await verbs.loadThreads();
71 await verbs.seedAppState(id); // welcome.step resume state (typed events)
72 live = {
73 said: (key) => scriptedSaid(id, key),
74 post: (text, key) => sendScriptedBubble(id, text, key),
75 // The mute handshake: the sidecar brain holds its tongue while the
76 // script has the floor (runtime.go handleTyped "flow.state").
77 flowState: (active) => sendTypedPayload(id, { type: "flow.state", active: !!active }),
78 };
79 await verbs.openThreadById(id);
80 } else {
81 id = await verbs.resolveAppThread("welcome");
82 if (!id) return;
83 await verbs.openThread(id);
84 }
85 const pinFeed = () =>
86 requestAnimationFrame(() => {
87 const feed = document.querySelector(".chatapp-feed");
88 if (feed) feedGlideToBottom(feed); // intent-aware: follow checks stay true mid-glide
89 });
90 // The greeting names who runs the space AND who invited them — both are
91 // encrypted labels, so they need the instance key: a fresh joiner's key
92 // grant usually lands within a couple of seconds of the boot-time nudge.
93 // Wait briefly rather than open with "one of its members" when we're
94 // about to know better. (Everything label-derived reads AFTER this.)
95 for (let i = 0; i < 12 && !instanceKey; i++) await new Promise((r) => setTimeout(r, 250));
96 if (instanceKey) await verbs.decryptRosterLabels();
97 // The DM the invite created (captured before renderApp consumed its
98 // marker): its other member is the inviter, for the greeting and the
99 // "Say hi" chip.
100 let inviterName = "";
101 let openPendingThread = null;
102 // The captured marker survives one load only — on a mid-welcome reload,
103 // fall back to the joiner's (sole) person-DM, which is the same thread.
104 const isAgentUid = (uid) => {
105 const e = roster.find((x) => x.user_id === uid);
106 return !!(e && e.is_agent);
107 };
108 const dm =
109 (pendingJoinThread && threads.find((t) => t.id === pendingJoinThread)) ||
110 threads.find(
111 (t) =>
112 t.kind === "dm" &&
113 !threadApp[t.id] &&
114 // The inviter is a PERSON — the live Eleven DM (one-Eleven mode)
115 // must never be mistaken for the invite's pending thread.
116 (t.members || []).some((uid) => uid !== myUserId && !isAgentUid(uid)),
117 );
118 if (dm) {
119 const other = (dm.members || []).find((uid) => uid !== myUserId);
120 const entry = other && roster.find((x) => x.user_id === other);
121 inviterName = (entry && entry.label) || "";
122 const dmId = dm.id;
123 openPendingThread = async () => {
124 await verbs.openThreadById(dmId);
125 // They tapped "Say hi" — put the cursor where the hi goes.
126 requestAnimationFrame(() => {
127 const ta = document.querySelector(".chatapp-root .composer .composer-input");
128 if (ta) ta.focus();
129 });
130 };
131 }
132 const adminEntry = roster.find((e) => !e.is_agent && e.role === "admin");
133 // Open groups: Eleven's finish mentions the directory when there's
134 // something this member could actually join — existence only, never names
135 // (a locked level's name stays sealed by design). Joined rows don't count:
136 // default-join groups are already in the sidebar.
137 let openGroups = false;
138 try {
139 const r = await (await fetch(`/api/threads/open?token=${encodeURIComponent(session.token)}`)).json();
140 openGroups = (r.threads || []).some((t) => !t.joined && (!t.open_tier || t.eligible));
141 } catch {}
142 startWelcome({
143 threadId: id,
144 live,
145 suggested: (pending.suggested || "").slice(0, 32),
146 inviterName,
147 adminName: (adminEntry && adminEntry.label) || "",
148 openPendingThread,
149 openGroups,
150 openDiscover: () => showDiscover(),
151 openQuickForm: () => openWelcomeQuickForm(id, (pending.suggested || "").slice(0, 32)),
152 typing: (on) => {
153 setNoticeTyping(id, "Eleven", on);
154 if (on) pinFeed();
155 },
156 respond: (spec) => appendNotice(id, spec),
157 });
158 } catch {}
159}
160
161// ---- the quick forms ("Fill in a form instead") ------------------------------
162// The onboarding conversations' fast path: one modal form with every question
163// at once, ending in the same Sign in. Submitting FAST-FILLS the flow's own
164// records — the same bridge verbs and the same step records (labels verbatim —
165// planQuickForm) the conversational path would produce — so the flow's resume
166// machinery does the rest: after the sign-in ceremony the run re-enters, skips
167// the answered steps, and settles through the access/keys step like any other
168// return leg. Escape/Cancel closes without touching the conversation.
169
170// One labelled text field (the shared .eleven-input surface).
171function quickFormField(parent, { id, label, value = "", hint = "", maxLength, type = "text" }) {
172 const lab = document.createElement("label");
173 lab.className = "qf-label";
174 lab.htmlFor = id;
175 lab.textContent = label;
176 const input = document.createElement("input");
177 input.id = id;
178 input.type = type;
179 input.className = "eleven-input sheet-input";
180 input.value = value;
181 if (hint) input.placeholder = hint;
182 if (maxLength) input.maxLength = maxLength;
183 parent.append(lab, input);
184 return input;
185}
186
187// One toggle row (the shared .opt-row recipe: the what, then the so-what).
188function quickFormToggle(parent, { id, label, desc = "", checked = true }) {
189 const row = document.createElement("label");
190 row.className = "opt-row";
191 const cb = document.createElement("input");
192 cb.type = "checkbox";
193 cb.id = id;
194 cb.checked = checked;
195 const txt = document.createElement("span");
196 txt.textContent = label;
197 if (desc) {
198 const d = document.createElement("span");
199 d.className = "opt-desc";
200 d.textContent = desc;
201 txt.appendChild(d);
202 }
203 row.append(cb, txt);
204 parent.appendChild(row);
205 return cb;
206}
207
208// The shared sheet skeleton: title, caller-filled body, an inline error line
209// (inline, occupying layout — never a floating toast), then Sign in / Cancel.
210function quickFormSheet(title) {
211 const backdrop = verbs.modal();
212 const card = backdrop.querySelector(".sheet");
213 const head = document.createElement("div");
214 head.className = "sheet-head";
215 const h = document.createElement("h2");
216 h.textContent = title;
217 head.appendChild(h);
218 card.appendChild(head);
219 const error = document.createElement("p");
220 error.className = "qf-error";
221 error.setAttribute("role", "alert"); // announced when the text lands
222 error.hidden = true;
223 const row = document.createElement("div");
224 row.className = "row";
225 const submit = document.createElement("button");
226 submit.textContent = "Sign in";
227 const cancel = document.createElement("button");
228 cancel.className = "secondary";
229 cancel.textContent = "Cancel";
230 cancel.onclick = () => backdrop.remove(); // the conversation stays untouched
231 row.append(submit, cancel);
232 return {
233 backdrop,
234 card,
235 submit,
236 // Appended by the caller once its fields are in, keeping field → error →
237 // buttons in reading (and tab) order.
238 finish: () => card.append(error, row),
239 setError: (msg) => {
240 error.textContent = msg;
241 error.hidden = !msg;
242 },
243 };
244}
245
246// Run the plan: verbs through the SAME setup bridge the conversation uses
247// (only the flow's own `on`-branched verbs — group name, email — can push
248// back), then the step records, then the sign-in hand-off. Returns null on
249// success or the inline error message to show.
250async function submitQuickForm(kind, threadId, values, backdrop) {
251 const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
252 const plan = planQuickForm(kind, values);
253 const bridge = setupBridge();
254 for (const v of plan.verbs) {
255 let r;
256 try {
257 r = await bridge[v.verb](...v.args);
258 } catch {
259 r = "error";
260 }
261 // The two verbs the conversation branches on push back here the same way
262 // (setup.flow.js `on.invalid`); the rest are fire-and-forget, as in the flow.
263 if (v.verb === "setGroupName" && r !== "ok") {
264 return r === "invalid"
265 ? "That space name didn't stick — it needs to be 1–60 characters."
266 : "Something went wrong saving the space name — try again in a moment.";
267 }
268 if (v.verb === "setContactEmail" && r !== "ok") {
269 return r === "invalid"
270 ? "That doesn't look like an email address — fix it, or leave it empty."
271 : "Something went wrong saving the email — try again in a moment.";
272 }
273 }
274 // The records, through the same emit path as io.record — skipping steps the
275 // conversation already recorded (the flow never re-records a done step).
276 const type = kind + ".step";
277 const stepDone = (step) => {
278 const st = appStateFor(threadId, kind);
279 return !!(st && st.steps && st.steps[step] && st.steps[step].done);
280 };
281 const records = plan.records.filter((r) => !stepDone(r.step));
282 for (const r of records) {
283 // Same send-retry discipline as makeSay: the socket may still be connecting.
284 let sent = false;
285 for (let i = 0; i < 12 && !sent; i++) {
286 sent = await emitAppEvent(threadId, { type, step: r.step, label: r.label, value: r.value });
287 if (!sent) await sleep(150);
288 }
289 if (!sent) return "Couldn't save that — check your connection and try again.";
290 }
291 // Records travel over the WebSocket: wait for the echoes to fold into the
292 // app state BEFORE the sign-in navigation, or a same-tab redirect can outrun
293 // them and lose the fast-fill (capped — a lost echo mustn't wedge the form).
294 for (let i = 0; i < 20 && !records.every((r) => stepDone(r.step)); i++) await sleep(100);
295 backdrop.remove();
296 // Stay-put contexts (the native wrapper, an installed PWA) run the ceremony
297 // elsewhere and keep this page alive — reload it shortly after: the live
298 // flow engine is parked mid-step (its armed ask and settle belong to the
299 // step the form just answered), so a resume over the fast-filled records is
300 // the one honest re-entry. The reloaded flow skips the answered steps and
301 // sits on the sign-in step, whose settle poll (plus the enrolment completion
302 // poller) finishes the job when the ceremony completes. Same-tab contexts
303 // navigate away inside savePasskey — the pagehide guard keeps this timer
304 // from firing under them.
305 let left = false;
306 addEventListener("pagehide", () => (left = true), { once: true });
307 setTimeout(() => {
308 if (!left && document.visibilityState === "visible") location.reload();
309 }, 1200);
310 bridge.savePasskey();
311 return null;
312}
313
314// The joiner's quick form: just the name, then Sign in.
315function openWelcomeQuickForm(threadId, suggested) {
316 const sheet = quickFormSheet("Introduce yourself");
317 const name = quickFormField(sheet.card, {
318 id: "qf-name",
319 label: "Your name",
320 value: suggested || "",
321 hint: "e.g. Finn Wolfhard",
322 maxLength: 32,
323 });
324 const note = document.createElement("p");
325 note.className = "sub qf-note";
326 note.textContent = "You'll be brought straight back here.";
327 sheet.card.appendChild(note);
328 sheet.finish();
329 sheet.submit.onclick = async () => {
330 if (!name.value.trim()) {
331 sheet.setError("Add your name to continue.");
332 name.focus();
333 return;
334 }
335 sheet.submit.disabled = true;
336 const err = await submitQuickForm("welcome", threadId, { name: name.value }, sheet.backdrop);
337 if (err) {
338 sheet.setError(err);
339 sheet.submit.disabled = false;
340 }
341 };
342}
343
344// The admin's quick form: the whole setup at once, then Sign in.
345export async function openSetupQuickForm(threadId) {
346 // The current space name first (the flow's "Keep" affordance becomes a prefill).
347 const space = await spaceInfo();
348 const sheet = quickFormSheet("Set up your space");
349 const name = quickFormField(sheet.card, {
350 id: "qf-name",
351 label: "Your name",
352 value: session.name || "",
353 hint: "e.g. Finn Wolfhard",
354 maxLength: 32,
355 });
356 const spaceName = quickFormField(sheet.card, {
357 id: "qf-space",
358 label: "Space name",
359 value: space && space.nameSet ? space.name || "" : "",
360 hint: "e.g. The Book Club",
361 maxLength: 60,
362 });
363 const email = quickFormField(sheet.card, {
364 id: "qf-email",
365 label: "Contact email (optional)",
366 hint: "e.g. finn@example.com",
367 type: "email",
368 });
369 const invites = quickFormToggle(sheet.card, {
370 id: "qf-invites",
371 label: "Members can invite people",
372 desc: "Otherwise, new faces only arrive through you.",
373 });
374 const extras = document.createElement("div");
375 extras.setAttribute("role", "group");
376 extras.setAttribute("aria-labelledby", "qf-extras-label");
377 const gh = document.createElement("p");
378 gh.id = "qf-extras-label";
379 gh.className = "qf-label";
380 gh.textContent = "Extras";
381 extras.appendChild(gh);
382 const bots = quickFormToggle(extras, {
383 id: "qf-bots",
384 label: "Bots",
385 desc: "Everything essential works with these off — change your mind anytime in Admin.",
386 });
387 const apps = quickFormToggle(extras, { id: "qf-apps", label: "Apps" });
388 // AI is opt-IN — unticked by default, with the honest note (mirrors the
389 // conversation's dedicated ai-optin step; docs/ai-provisioning.md).
390 const ai = quickFormToggle(extras, {
391 id: "qf-ai",
392 label: "AI — bring Eleven in",
393 desc:
394 "Eleven's AI runs on Eleven Messenger's machines and can read the chats it's invited to — that's what lets it help. It never sees chats it isn't in.",
395 checked: false,
396 });
397 sheet.card.appendChild(extras);
398 sheet.finish();
399 sheet.submit.onclick = async () => {
400 if (!name.value.trim()) {
401 sheet.setError("Add your name to continue.");
402 name.focus();
403 return;
404 }
405 if (!spaceName.value.trim()) {
406 sheet.setError("Give your space a name to continue.");
407 spaceName.focus();
408 return;
409 }
410 sheet.submit.disabled = true;
411 const err = await submitQuickForm(
412 "setup",
413 threadId,
414 {
415 name: name.value,
416 spaceName: spaceName.value,
417 email: email.value,
418 invites: invites.checked,
419 ai: ai.checked,
420 bots: bots.checked,
421 apps: apps.checked,
422 },
423 sheet.backdrop,
424 );
425 if (err) {
426 sheet.setError(err);
427 sheet.submit.disabled = false;
428 }
429 };
430}
431
432// Send a normal chat message (name + text) into a thread, as if typed. Same
433// envelope + encryption as the composer and the app-event transport; used by the
434// tour to ask Eleven a question on the member's behalf.
435