@luigisbox/luma 0.7.0 → 0.8.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,216 @@
1
+ // /assist: whether a phrase the shopper typed deserves the assistant, and its
2
+ // answer when it does (lean trackers: `convo.assist`). One request per phrase:
3
+ // 204 the autocomplete covers it (nothing shown), 404 not a lean tracker (never
4
+ // asked again this session), 200 the answer, streamed as a turn's events
5
+ // (CS-RTP). The answer is kept: the autocomplete's nudge and the search page's
6
+ // box show its first words, and the panel takes it over as the conversation's
7
+ // first turn (the same conversation id: no second answer, no second cost).
8
+ //
9
+ // Its own chunk (lbx-assist, build/vite.mjs): the autocomplete loads it when a
10
+ // phrase settles, the entry on a search page; the panel's chunk only once
11
+ // there is an answer. Answers last the tab's session (sessionStorage, the
12
+ // last few): the phrase asked from the autocomplete and searched with Enter
13
+ // is answered once. An answer still streaming when the page goes is cut
14
+ // (an open request keeps it out of the back/forward cache) and forgotten.
15
+
16
+ import { visitorId } from '../../api/token';
17
+ import { session } from '../../core/storage';
18
+ import type { ConvoBlock, ConvoConfigType, GlobalConfigType } from '../../types';
19
+ import { addBlock, contextOf, endpointOf, events, turnBody, words, type Event } from './protocol';
20
+
21
+ /** The tracker is not a lean one (404): /assist is not asked again this session. */
22
+ const LEAN = 'lbx:convo:lean';
23
+ /** Answers of this session, by phrase: the last few. */
24
+ const KEPT = 'lbx:convo:assist';
25
+ const KEEP = 5;
26
+ /** An answer bigger than this (its events as kept) is not kept: the site's own storage shares the quota. */
27
+ const KEEP_BYTES = 64e3;
28
+
29
+ export interface Answer {
30
+ query: string;
31
+ /** Its conversation (the panel goes on with it) and the key of its first turn (sent again, the service replays it). */
32
+ id: string;
33
+ key: string;
34
+ /** What the service sent so far, in order; `done` once the stream ended (`failed` if it broke). */
35
+ events: Event[];
36
+ done: boolean;
37
+ failed: boolean;
38
+ /** Its blocks so far, as the panel draws them (text in its places), and its first words (a line's worth, without Markdown). */
39
+ blocks: ConvoBlock[];
40
+ text: () => string;
41
+ /** Told of every event and of the end. */
42
+ listen: (f: () => void) => () => void;
43
+ /** Its events from the first, then as they come, until it ends (throws if it broke, or once `signal` aborts). */
44
+ stream: (signal?: AbortSignal) => AsyncGenerator<Event>;
45
+ }
46
+
47
+ /** A phrase as asked and as kept: trimmed, single spaces; kept by its lower case. */
48
+ const norm = (q: string) => q.trim().replace(/\s+/g, ' ');
49
+ const keyOf = (q: string) => norm(q).toLowerCase();
50
+
51
+ const make = (query: string, id: string, key: string) => {
52
+ const fns = new Set<() => void>();
53
+ const a: Answer = {
54
+ query,
55
+ id,
56
+ key,
57
+ events: [],
58
+ done: false,
59
+ failed: false,
60
+ blocks: [],
61
+ // Bounded: drawn and named as the answer streams in (a row's worth), never the whole answer each time.
62
+ text: () => words(String(a.blocks.find((b) => b.type === 'text')?.text ?? '').slice(0, 400)).slice(0, 160),
63
+ listen: (f) => (fns.add(f), () => void fns.delete(f)),
64
+ async *stream(signal) {
65
+ for (let i = 0; ;) {
66
+ while (i < a.events.length) {
67
+ if (signal?.aborted) throw Error('stopped');
68
+ yield a.events[i++];
69
+ }
70
+ if (a.done) {
71
+ if (a.failed) throw Error('the answer broke off');
72
+ return;
73
+ }
74
+ if (signal?.aborted) throw Error('stopped');
75
+ await new Promise<void>((r) => {
76
+ const go = () => (off(), signal?.removeEventListener('abort', go), r());
77
+ const off = a.listen(go);
78
+ signal?.addEventListener('abort', go);
79
+ });
80
+ }
81
+ },
82
+ };
83
+ const push = (e: Event) => {
84
+ a.events.push(e);
85
+ addBlock(a.blocks, e.event_type, e.payload);
86
+ for (const f of fns) f();
87
+ };
88
+ const end = (failed: boolean) => {
89
+ a.done = true;
90
+ a.failed = failed;
91
+ for (const f of fns) f();
92
+ };
93
+ return { a, push, end };
94
+ };
95
+
96
+ const asked = new Map<string, Promise<Answer | null>>();
97
+ /** A finished answer of this session's, read back (storage is never trusted to be ours: its shape checked). */
98
+ const kept = (v: { q: string; id: string; key: string; events: Event[] } | undefined): Answer | null => {
99
+ if (!v || typeof v.id !== 'string' || typeof v.key !== 'string' || !Array.isArray(v.events)) return null;
100
+ const { a, push, end } = make(String(v.q), v.id, v.key);
101
+ for (const e of v.events) if (e && typeof e.event_type === 'string') push(e);
102
+ end(false);
103
+ return a;
104
+ };
105
+ const keep = (k: string, a: Answer) => {
106
+ // A text block's deltas as one (the same blocks when replayed), not one envelope per word.
107
+ const events: Event[] = [];
108
+ for (const e of a.events) {
109
+ const last = events[events.length - 1];
110
+ if (e.event_type === 'text.delta' && last?.event_type === 'text.delta' && last.payload?.block_id === e.payload?.block_id)
111
+ events[events.length - 1] = { ...last, payload: { ...last.payload, delta: String(last.payload.delta ?? '') + String(e.payload?.delta ?? '') } };
112
+ else events.push(e);
113
+ }
114
+ const v = { q: a.query, id: a.id, key: a.key, events };
115
+ store(k, JSON.stringify(v).length > KEEP_BYTES ? null : v);
116
+ };
117
+ /** A phrase's entry: a whole answer, one started ({ q, id, key }: asked again, the service replays it), or none. */
118
+ const store = (k: string, v: object | null) => {
119
+ const all = session.get<Record<string, unknown>>(KEPT) ?? {};
120
+ delete all[k];
121
+ if (v) all[k] = v;
122
+ session.set(KEPT, Object.fromEntries(Object.entries(all).slice(-KEEP)));
123
+ };
124
+
125
+ /** Answers streaming in, and how to cut each. */
126
+ const flowing = new Map<string, AbortController>();
127
+ const forget = (k: string) => {
128
+ flowing.get(k)?.abort();
129
+ asked.delete(k);
130
+ answered.delete(k);
131
+ };
132
+ /** Answers something else shows (the search page's box, the panel): the autocomplete typing past them does not cut them. */
133
+ const held = new Set<string>();
134
+ export const hold = (a: Answer) => void held.add(keyOf(a.query));
135
+ /** A phrase's answer still on its way, cut and forgotten (the autocomplete's, once the shopper typed past it; `force`: one held, stopped in the panel). */
136
+ export const drop = (q: string, force = false) => {
137
+ const k = keyOf(q);
138
+ if (flowing.has(k) && (force || !held.has(k))) forget(k);
139
+ };
140
+ /** Answers on their way cut as the page goes: listened to from the first request (no side effect at import: a build without assist drops this module; added again, a listener is not added twice). */
141
+ const leave = () => {
142
+ for (const k of flowing.keys()) forget(k);
143
+ };
144
+ /** The answer asked for this phrase on this page, if it is here (the panel takes it over). */
145
+ export const answered = new Map<string, Answer>();
146
+ export const answerFor = (q: string) => answered.get(keyOf(q));
147
+
148
+ /**
149
+ * The assistant's answer for a phrase, or null (the autocomplete covers it,
150
+ * not a lean tracker, a failure): asked once per phrase per page, kept for
151
+ * the session once whole. Resolves as the answer starts (its first events
152
+ * follow); the request has a 10 s deadline to its start, 30 s between chunks.
153
+ * `keptOnly`: an answer this session already has, never a request (a phrase
154
+ * the shopper did not ask here: a search page reached from another site).
155
+ */
156
+ export const ask = (g: GlobalConfigType, cv: ConvoConfigType, query: string, keptOnly = false): Promise<Answer | null> => {
157
+ const q = norm(query);
158
+ const k = keyOf(q);
159
+ if (!q || session.get(LEAN) === false) return Promise.resolve(null);
160
+ let p = asked.get(k);
161
+ if (p) return p;
162
+ const v = session.get<Record<string, any>>(KEPT)?.[k];
163
+ const old = kept(v);
164
+ if (old) return (answered.set(k, old), (p = Promise.resolve(old)), asked.set(k, p), p);
165
+ // A phrase this tab started asking (its { id, key } kept: Enter while the row's answer streamed) is the shopper's: asked again, replayed.
166
+ if (keptOnly && typeof v?.id !== 'string') return Promise.resolve(null);
167
+ p = (async () => {
168
+ // An answer started on a page left before it was whole (Enter while the nudge's streamed): the same
169
+ // conversation and turn key, so the service replays it rather than answering (and costing) again.
170
+ const id = typeof v?.id === 'string' ? v.id : crypto.randomUUID();
171
+ const key = typeof v?.key === 'string' ? v.key : crypto.randomUUID();
172
+ const c = new AbortController();
173
+ addEventListener('pagehide', leave);
174
+ flowing.set(k, c);
175
+ let idle = window.setTimeout(() => c.abort(), 10e3);
176
+ const kick = () => {
177
+ clearTimeout(idle);
178
+ idle = window.setTimeout(() => c.abort(), 30e3);
179
+ };
180
+ const r = await fetch(`${endpointOf(cv)}/assist`, {
181
+ method: 'POST',
182
+ headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
183
+ body: JSON.stringify(turnBody(id, false, { input: { kind: 'text', text: q }, key }, contextOf(g, cv, visitorId()))),
184
+ signal: c.signal,
185
+ });
186
+ if (r.status === 404) session.set(LEAN, false);
187
+ if (r.status !== 200 || !r.body) return (clearTimeout(idle), flowing.delete(k), null);
188
+ const { a, push, end } = make(q, id, key);
189
+ const body = r.body;
190
+ store(k, { q, id, key });
191
+ (async () => {
192
+ try {
193
+ for await (const e of events(body, kick)) push(e);
194
+ // Only a completed turn is an answer (whatever follows it): an error (a rate limit, the month's cap) or an abandoned one is none.
195
+ const ok = a.events.some((e) => e.event_type === 'turn.completed');
196
+ end(!ok);
197
+ if (ok) keep(k, a);
198
+ else store(k, null), forget(k);
199
+ } catch {
200
+ end(true);
201
+ } finally {
202
+ clearTimeout(idle);
203
+ if (flowing.get(k) === c) flowing.delete(k);
204
+ }
205
+ })();
206
+ answered.set(k, a);
207
+ return a;
208
+ })().catch(() => (flowing.delete(k), null));
209
+ asked.set(k, p);
210
+ if (asked.size > 30) {
211
+ const first = asked.keys().next().value!;
212
+ asked.delete(first);
213
+ answered.delete(first);
214
+ }
215
+ return p;
216
+ };
@@ -0,0 +1,8 @@
1
+ // The convo templates' icons: one path each, in the text's colour, hidden from
2
+ // screen readers (their buttons are named). An integration's template imports them too.
3
+ import { html, svg } from '../../lit';
4
+
5
+ export const icon = (d: string) => html`<svg viewBox="0 0 24 24" aria-hidden="true">${svg`<path d=${d} />`}</svg>`;
6
+ export const SPARKLE = 'M10 3l1.6 4.4L16 9l-4.4 1.6L10 15l-1.6-4.4L4 9l4.4-1.6zM18 13l.9 2.1L21 16l-2.1.9L18 19l-.9-2.1L15 16l2.1-.9z';
7
+ /** The assistant's mark: a sparkle on the accent's tint. */
8
+ export const avatar = html`<span class="lbx-cv__avatar">${icon(SPARKLE)}</span>`;