@mihe-dev/js 0.0.0-stage → 0.2.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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -2
  3. package/dist/audio.d.ts +121 -0
  4. package/dist/audio.d.ts.map +1 -0
  5. package/dist/audio.js +377 -0
  6. package/dist/audio.js.map +1 -0
  7. package/dist/card-view.d.ts +79 -0
  8. package/dist/card-view.d.ts.map +1 -0
  9. package/dist/card-view.js +99 -0
  10. package/dist/card-view.js.map +1 -0
  11. package/dist/chart.d.ts +15 -0
  12. package/dist/chart.d.ts.map +1 -0
  13. package/dist/chart.js +58 -0
  14. package/dist/chart.js.map +1 -0
  15. package/dist/client.d.ts +226 -0
  16. package/dist/client.d.ts.map +1 -0
  17. package/dist/client.js +358 -0
  18. package/dist/client.js.map +1 -0
  19. package/dist/commands.d.ts +70 -0
  20. package/dist/commands.d.ts.map +1 -0
  21. package/dist/commands.js +295 -0
  22. package/dist/commands.js.map +1 -0
  23. package/dist/debug.d.ts +17 -0
  24. package/dist/debug.d.ts.map +1 -0
  25. package/dist/debug.js +29 -0
  26. package/dist/debug.js.map +1 -0
  27. package/dist/declarations.d.ts +160 -0
  28. package/dist/declarations.d.ts.map +1 -0
  29. package/dist/declarations.js +171 -0
  30. package/dist/declarations.js.map +1 -0
  31. package/dist/highlight.d.ts +77 -0
  32. package/dist/highlight.d.ts.map +1 -0
  33. package/dist/highlight.js +233 -0
  34. package/dist/highlight.js.map +1 -0
  35. package/dist/index.d.ts +41 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +32 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/mihe.d.ts +364 -0
  40. package/dist/mihe.d.ts.map +1 -0
  41. package/dist/mihe.iife.min.js +217 -0
  42. package/dist/mihe.iife.min.js.map +7 -0
  43. package/dist/mihe.js +758 -0
  44. package/dist/mihe.js.map +1 -0
  45. package/dist/mihe.min.js +217 -0
  46. package/dist/mihe.min.js.map +7 -0
  47. package/dist/store.d.ts +175 -0
  48. package/dist/store.d.ts.map +1 -0
  49. package/dist/store.js +208 -0
  50. package/dist/store.js.map +1 -0
  51. package/dist/voice.d.ts +192 -0
  52. package/dist/voice.d.ts.map +1 -0
  53. package/dist/voice.js +443 -0
  54. package/dist/voice.js.map +1 -0
  55. package/dist/widget/card.d.ts +17 -0
  56. package/dist/widget/card.d.ts.map +1 -0
  57. package/dist/widget/card.js +31 -0
  58. package/dist/widget/card.js.map +1 -0
  59. package/dist/widget/dom.d.ts +24 -0
  60. package/dist/widget/dom.d.ts.map +1 -0
  61. package/dist/widget/dom.js +49 -0
  62. package/dist/widget/dom.js.map +1 -0
  63. package/dist/widget/element.d.ts +88 -0
  64. package/dist/widget/element.d.ts.map +1 -0
  65. package/dist/widget/element.js +285 -0
  66. package/dist/widget/element.js.map +1 -0
  67. package/dist/widget/format.d.ts +24 -0
  68. package/dist/widget/format.d.ts.map +1 -0
  69. package/dist/widget/format.js +64 -0
  70. package/dist/widget/format.js.map +1 -0
  71. package/dist/widget/icons.d.ts +5 -0
  72. package/dist/widget/icons.d.ts.map +1 -0
  73. package/dist/widget/icons.js +19 -0
  74. package/dist/widget/icons.js.map +1 -0
  75. package/dist/widget/launcher.d.ts +13 -0
  76. package/dist/widget/launcher.d.ts.map +1 -0
  77. package/dist/widget/launcher.js +27 -0
  78. package/dist/widget/launcher.js.map +1 -0
  79. package/dist/widget/mark.d.ts +10 -0
  80. package/dist/widget/mark.d.ts.map +1 -0
  81. package/dist/widget/mark.js +20 -0
  82. package/dist/widget/mark.js.map +1 -0
  83. package/dist/widget/orb.d.ts +92 -0
  84. package/dist/widget/orb.d.ts.map +1 -0
  85. package/dist/widget/orb.js +500 -0
  86. package/dist/widget/orb.js.map +1 -0
  87. package/dist/widget/panel.d.ts +40 -0
  88. package/dist/widget/panel.d.ts.map +1 -0
  89. package/dist/widget/panel.js +240 -0
  90. package/dist/widget/panel.js.map +1 -0
  91. package/dist/widget/styles.d.ts +22 -0
  92. package/dist/widget/styles.d.ts.map +1 -0
  93. package/dist/widget/styles.js +192 -0
  94. package/dist/widget/styles.js.map +1 -0
  95. package/package.json +43 -4
package/dist/mihe.js ADDED
@@ -0,0 +1,758 @@
1
+ /**
2
+ * `Mihe` — the assistant without a framework (doc 08).
3
+ *
4
+ * The wiring between the store, the client and whatever UI draws it. It owns
5
+ * three responsibilities and nothing else:
6
+ *
7
+ * 1. Publish the screen context whenever the app's declaration changes.
8
+ * 2. Execute commands the runtime sends, and ack every one of them.
9
+ * 3. Hold the conversation the UI renders.
10
+ *
11
+ * State is an immutable snapshot, replaced on every change, so React's
12
+ * `useSyncExternalStore` reads it with no adapter: the React provider is a
13
+ * thin wrapper over this class, and the two cannot disagree about what a
14
+ * proposal or a voice event does.
15
+ */
16
+ import { AssistantClient, } from "./client.js";
17
+ import { appView, DESCRIBE_TIMEOUT_MS, hintedView } from "./card-view.js";
18
+ import { executeCommand } from "./commands.js";
19
+ import { formatContextReport } from "./debug.js";
20
+ import { chartDeclaration, chartSignature, tableDeclaration, tableSignature, tabsDeclaration, tabsSignature, } from "./declarations.js";
21
+ import { COMPONENT_ATTRIBUTE, HighlightEngine } from "./highlight.js";
22
+ import { AssistantStore, } from "./store.js";
23
+ import { VoiceSession, voiceUrl } from "./voice.js";
24
+ /** How long to coalesce screen changes before publishing. */
25
+ const SCREEN_DEBOUNCE_MS = 60;
26
+ /**
27
+ * How long a navigate ack waits for the destination to publish. Comfortably
28
+ * under the runtime's 10s command timeout, and long enough for a route that
29
+ * loads data before registering itself.
30
+ */
31
+ const SCREEN_ARRIVAL_TIMEOUT_MS = 2500;
32
+ export const SDK_VERSION = "0.2.0";
33
+ /**
34
+ * DOM id of the assistant panel, so a custom trigger can point
35
+ * `aria-controls` at it.
36
+ */
37
+ export const ASSISTANT_PANEL_ID = "mihe-panel";
38
+ /**
39
+ * How the approaching ceiling is put to the user.
40
+ *
41
+ * Phrased here rather than by the runtime because it is a countdown, and the
42
+ * runtime sends a number. Rounded to five seconds: the figure is approximate
43
+ * by the time it has crossed a socket and been rendered, and a notice reading
44
+ * "27 seconds left" claims a precision nobody has.
45
+ */
46
+ function endingSoonNotice(secondsRemaining) {
47
+ const rounded = Math.max(5, Math.round(secondsRemaining / 5) * 5);
48
+ return `About ${rounded} seconds left of voice. The conversation carries on in text.`;
49
+ }
50
+ function messageId(prefix) {
51
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
52
+ }
53
+ /** Create the assistant and connect it. */
54
+ export function createMihe(options) {
55
+ const mihe = new Mihe(options);
56
+ mihe.connect();
57
+ return mihe;
58
+ }
59
+ export class Mihe {
60
+ /** The app's declaration of itself. */
61
+ store = new AssistantStore();
62
+ highlighter = new HighlightEngine();
63
+ options;
64
+ state;
65
+ listeners = new Set();
66
+ client = null;
67
+ voiceSession = null;
68
+ /**
69
+ * Set while the user is waiting on a permit they asked for.
70
+ *
71
+ * A token arriving with this false is one the runtime issued for a probe at
72
+ * connect time, and starting a session on it would turn the microphone on
73
+ * without anyone having asked.
74
+ */
75
+ voiceRequested = false;
76
+ /** The mode asked for, read when the permit comes back. */
77
+ requestedHandsFree = false;
78
+ /** Whether the session being requested should open the panel. */
79
+ requestedOpenPanel = true;
80
+ levelListeners = new Set();
81
+ /** The route of the last screen context actually sent to the runtime. */
82
+ publishedRoute = null;
83
+ /** The last screen context actually sent, for acks that check a view. */
84
+ publishedScreen = null;
85
+ /** Whether that screen had finished loading its data when it was sent. */
86
+ publishedSettled = false;
87
+ /** Navigate and set_view acks waiting for the screen to report in. */
88
+ screenWaiters = new Set();
89
+ publishTimer = null;
90
+ unsubscribeStore = null;
91
+ tabs = new Map();
92
+ tables = new Map();
93
+ charts = new Map();
94
+ actions = new Map();
95
+ lastScreen = null;
96
+ constructor(options) {
97
+ this.options = { ...options };
98
+ this.state = {
99
+ connection: "idle",
100
+ messages: [],
101
+ proposals: [],
102
+ cardViews: {},
103
+ busy: false,
104
+ lastError: null,
105
+ isOpen: false,
106
+ assistantName: null,
107
+ launcherIconUrl: null,
108
+ widgetSettings: null,
109
+ voice: {
110
+ available: false,
111
+ state: "idle",
112
+ handsFree: false,
113
+ caption: "",
114
+ notice: null,
115
+ start: this.startVoiceRequest,
116
+ stop: this.stopVoice,
117
+ press: (options) => this.voiceSession?.beginUtterance(options),
118
+ release: () => this.voiceSession?.endUtterance(),
119
+ confirm: () => this.voiceSession?.confirmUtterance(),
120
+ discard: () => this.voiceSession?.discardUtterance(),
121
+ onLevel: (listener) => {
122
+ this.levelListeners.add(listener);
123
+ return () => {
124
+ this.levelListeners.delete(listener);
125
+ };
126
+ },
127
+ },
128
+ };
129
+ }
130
+ // ---- state -------------------------------------------------------------
131
+ /** The current state. The same object until something changes. */
132
+ getState = () => this.state;
133
+ /** Called after every change of state. Returns the unsubscribe. */
134
+ subscribe = (listener) => {
135
+ this.listeners.add(listener);
136
+ return () => {
137
+ this.listeners.delete(listener);
138
+ };
139
+ };
140
+ /** Voice controls and state; the same as `getState().voice`. */
141
+ get voice() {
142
+ return this.state.voice;
143
+ }
144
+ setState(patch) {
145
+ this.state = { ...this.state, ...patch };
146
+ // Copied, so a listener that unsubscribes while being called is safe.
147
+ for (const listener of [...this.listeners])
148
+ listener();
149
+ }
150
+ setVoice(patch) {
151
+ this.setState({ voice: { ...this.state.voice, ...patch } });
152
+ }
153
+ setMessages(update) {
154
+ this.setState({ messages: update(this.state.messages) });
155
+ }
156
+ // ---- lifecycle ---------------------------------------------------------
157
+ /**
158
+ * Replace options. Callbacks take effect at once; a new `url`, `publicKey`
159
+ * or `debug` reconnects, since each belongs to the session.
160
+ */
161
+ update(options) {
162
+ const previous = this.options;
163
+ this.options = { ...previous, ...options };
164
+ const identityChanged = this.options.url !== previous.url ||
165
+ this.options.publicKey !== previous.publicKey ||
166
+ Boolean(this.options.debug) !== Boolean(previous.debug);
167
+ if (identityChanged && this.client) {
168
+ this.disconnect();
169
+ this.connect();
170
+ }
171
+ }
172
+ /** Open the session. Called by `createMihe`; does nothing if connected. */
173
+ connect() {
174
+ if (this.client)
175
+ return;
176
+ const client = new AssistantClient({
177
+ url: this.options.url,
178
+ publicKey: this.options.publicKey,
179
+ getUserToken: () => this.options.getUserToken(),
180
+ sdkVersion: SDK_VERSION,
181
+ onEvent: this.handleEvent,
182
+ debug: this.options.debug ?? false,
183
+ });
184
+ this.client = client;
185
+ // Debounced, because a screen with twenty components fires twenty
186
+ // registrations as it mounts and they should travel as one snapshot.
187
+ this.unsubscribeStore = this.store.subscribe(() => {
188
+ if (this.publishTimer !== null)
189
+ clearTimeout(this.publishTimer);
190
+ this.publishTimer = setTimeout(this.publish, SCREEN_DEBOUNCE_MS);
191
+ });
192
+ this.publish();
193
+ void client.connect();
194
+ }
195
+ /**
196
+ * Close the session, the voice session and any highlight. The
197
+ * conversation and the declarations stay, for a later `connect()`.
198
+ */
199
+ disconnect() {
200
+ if (this.publishTimer !== null)
201
+ clearTimeout(this.publishTimer);
202
+ this.publishTimer = null;
203
+ this.unsubscribeStore?.();
204
+ this.unsubscribeStore = null;
205
+ this.client?.close();
206
+ this.client = null;
207
+ void this.voiceSession?.stop();
208
+ this.voiceSession = null;
209
+ this.highlighter.clear();
210
+ }
211
+ /** Disconnect and drop every listener. The instance is done after this. */
212
+ destroy() {
213
+ this.disconnect();
214
+ this.listeners.clear();
215
+ this.levelListeners.clear();
216
+ }
217
+ // ---- conversation ------------------------------------------------------
218
+ send = (text) => {
219
+ const trimmed = text.trim();
220
+ if (!trimmed)
221
+ return;
222
+ // Opening on send is what makes a "Help me add a product" button work as
223
+ // one call: the reply has to be visible somewhere. Harmless for host apps
224
+ // rendering their own always-visible panel.
225
+ const id = messageId("msg");
226
+ this.setState({
227
+ isOpen: true,
228
+ messages: [...this.state.messages, { id, role: "user", text: trimmed }],
229
+ lastError: null,
230
+ });
231
+ this.client?.sendMessage(trimmed, id);
232
+ };
233
+ /**
234
+ * Work out what a card says (amendment 99). The app's description when it
235
+ * gives one in time, else the registry's hints. Until then the card is
236
+ * pending, so nobody confirms a card whose words are about to change.
237
+ */
238
+ describe(proposal) {
239
+ const set = (view) => {
240
+ // A card already answered or withdrawn doesn't come back.
241
+ if (view !== "pending" && !this.state.proposals.some((p) => p.proposalId === proposal.proposalId))
242
+ return;
243
+ this.setState({ cardViews: { ...this.state.cardViews, [proposal.proposalId]: view } });
244
+ };
245
+ const describe = this.options.describeProposal;
246
+ if (!describe) {
247
+ set(hintedView(proposal));
248
+ return;
249
+ }
250
+ set("pending");
251
+ let settled = false;
252
+ const finish = (d) => {
253
+ if (settled)
254
+ return;
255
+ settled = true;
256
+ set(d && Array.isArray(d.lines) ? appView(d) : hintedView(proposal));
257
+ };
258
+ const timer = setTimeout(() => finish(null), DESCRIBE_TIMEOUT_MS);
259
+ Promise.resolve()
260
+ .then(() => describe(proposal))
261
+ .then((d) => { clearTimeout(timer); finish(d); })
262
+ .catch((cause) => {
263
+ clearTimeout(timer);
264
+ console.warn("[mihe] describeProposal failed; the card uses the registry's hints.", cause);
265
+ finish(null);
266
+ });
267
+ }
268
+ respondToProposal = (proposalId, confirmed) => {
269
+ this.client?.confirmAction(proposalId, confirmed);
270
+ if (!confirmed) {
271
+ this.setState({
272
+ proposals: this.state.proposals.filter((p) => p.proposalId !== proposalId),
273
+ });
274
+ }
275
+ };
276
+ open = () => {
277
+ if (!this.state.isOpen)
278
+ this.setState({ isOpen: true });
279
+ };
280
+ close = () => {
281
+ if (this.state.isOpen)
282
+ this.setState({ isOpen: false });
283
+ };
284
+ toggle = () => {
285
+ this.setState({ isOpen: !this.state.isOpen });
286
+ };
287
+ /**
288
+ * A one-tap "ask this", for suggestion chips and help links:
289
+ * `chip.onclick = mihe.prompt("How do I add a product?")`.
290
+ */
291
+ prompt(text) {
292
+ return () => this.send(text);
293
+ }
294
+ // ---- declarations ------------------------------------------------------
295
+ /**
296
+ * Declare the screen the user is on. Call it again when what the screen
297
+ * shows changes; call the returned function when the user leaves it.
298
+ *
299
+ * `visibleData` is what makes answers concrete — "you have 7 low-stock
300
+ * products" rather than "check your stock levels" — so include the summary
301
+ * values already on screen, and only those.
302
+ */
303
+ setScreen(screen) {
304
+ const serialized = JSON.stringify(screen);
305
+ // A render function that declares on every render would otherwise send
306
+ // the same screen over the socket each time.
307
+ if (serialized !== this.lastScreen || this.store.getScreenContext()?.screenId !== screen.screenId) {
308
+ this.lastScreen = serialized;
309
+ this.store.setScreen(JSON.parse(serialized));
310
+ }
311
+ return () => {
312
+ this.store.clearScreen(screen.screenId);
313
+ if (this.store.getScreenContext() === null)
314
+ this.lastScreen = null;
315
+ };
316
+ }
317
+ /**
318
+ * Declare a component the assistant can talk about or point at. Marks
319
+ * `element` so it can be found; pass `null` for one that is described but
320
+ * cannot be highlighted.
321
+ */
322
+ registerComponent(element, componentKey, meta) {
323
+ element?.setAttribute(COMPONENT_ATTRIBUTE, componentKey);
324
+ this.store.registerComponent(componentKey, { ...meta });
325
+ return () => {
326
+ this.store.unregisterComponent(componentKey);
327
+ if (element?.getAttribute(COMPONENT_ATTRIBUTE) === componentKey) {
328
+ element.removeAttribute(COMPONENT_ATTRIBUTE);
329
+ }
330
+ };
331
+ }
332
+ /**
333
+ * Supply the code for an action the assistant may run in the browser.
334
+ *
335
+ * The action itself — its risk tier, permissions, and whether it needs
336
+ * confirmation — is configured in the dashboard, not here. Whatever the
337
+ * handler returns becomes the tool result the model sees, so return
338
+ * something descriptive. Throwing reports the failure honestly.
339
+ */
340
+ registerAction(name, handler) {
341
+ const existing = this.actions.get(name);
342
+ if (existing && this.store.getAction(name) === existing.declaration) {
343
+ existing.latest = handler;
344
+ return () => this.undoAction(name, existing);
345
+ }
346
+ const entry = {
347
+ latest: handler,
348
+ signature: name,
349
+ declaration: { name, handler: (input) => entry.latest(input) },
350
+ };
351
+ this.actions.set(name, entry);
352
+ this.store.registerAction(entry.declaration);
353
+ return () => this.undoAction(name, entry);
354
+ }
355
+ undoAction(name, entry) {
356
+ if (this.actions.get(name) !== entry)
357
+ return;
358
+ this.actions.delete(name);
359
+ if (this.store.getAction(name) === entry.declaration)
360
+ this.store.unregisterAction(name);
361
+ }
362
+ /**
363
+ * Let the assistant see which tab is open and switch to another. Pass the
364
+ * tab that is really showing as `value`, and call this again when it
365
+ * changes: a switch is not reported done until `value` matches.
366
+ */
367
+ registerTabs(tabs) {
368
+ return this.declare(this.tabs, tabs.id, tabs, tabsSignature(tabs), (latest) => tabsDeclaration(latest), (id) => this.store.getControl(id), (id, declaration) => this.store.registerControl(id, declaration), (id) => this.store.unregisterControl(id));
369
+ }
370
+ /**
371
+ * Let the assistant read a table. The rows are not sent with the screen;
372
+ * the model reads them when a question needs them, and only the declared
373
+ * columns ever leave the browser (amendment 67). Call it again when the
374
+ * rows change.
375
+ */
376
+ registerTable(table) {
377
+ return this.declare(this.tables, table.id, table, tableSignature(table), (latest) => tableDeclaration(latest), (id) => this.store.getTable(id), (id, declaration) => this.store.registerTable(id, declaration), (id) => this.store.unregisterTable(id));
378
+ }
379
+ /**
380
+ * Let the assistant explain a chart. Each series is summarised here, in
381
+ * the browser; the points are read when an exact value is wanted. Call it
382
+ * again when the data changes.
383
+ */
384
+ registerChart(chart) {
385
+ return this.declare(this.charts, chart.id, chart, chartSignature(chart), (latest) => chartDeclaration(latest), (id) => this.store.getChart(id), (id, declaration) => this.store.registerChart(id, declaration), (id) => this.store.unregisterChart(id));
386
+ }
387
+ /**
388
+ * Add figures to the screen's `visibleData` under `key`: the stat cards
389
+ * inside a tab, a summary panel. Dropped when the screen changes.
390
+ */
391
+ setValues(key, values) {
392
+ this.store.setValues(key, JSON.parse(JSON.stringify(values)));
393
+ return () => this.store.clearValues(key);
394
+ }
395
+ /**
396
+ * One declaration kind's register-update-undo, shared so tabs, tables and
397
+ * charts behave the same. The store's entry is re-registered only when the
398
+ * signature changes, or when the store no longer holds it because the
399
+ * screen changed underneath.
400
+ */
401
+ declare(entries, id, value, signature, build, current, register, unregister) {
402
+ let entry = entries.get(id);
403
+ if (entry && current(id) === entry.declaration) {
404
+ entry.latest = value;
405
+ if (entry.signature !== signature) {
406
+ const target = entry;
407
+ entry.signature = signature;
408
+ entry.declaration = build(() => target.latest);
409
+ register(id, entry.declaration);
410
+ }
411
+ }
412
+ else {
413
+ const fresh = { latest: value, signature };
414
+ fresh.declaration = build(() => fresh.latest);
415
+ entries.set(id, fresh);
416
+ register(id, fresh.declaration);
417
+ entry = fresh;
418
+ }
419
+ const mine = entry;
420
+ return () => {
421
+ if (entries.get(id) !== mine)
422
+ return;
423
+ entries.delete(id);
424
+ if (current(id) === mine.declaration)
425
+ unregister(id);
426
+ };
427
+ }
428
+ /**
429
+ * Make an element open and close the assistant, keyboard and screen
430
+ * reader included: a `div` with only a click handler is invisible to both,
431
+ * and that is the most common way a custom trigger ships broken.
432
+ */
433
+ bindTrigger(element) {
434
+ const native = element instanceof HTMLButtonElement;
435
+ const added = ["aria-expanded", "aria-controls", "aria-haspopup"];
436
+ if (!native) {
437
+ element.setAttribute("role", "button");
438
+ added.push("role");
439
+ if (!element.hasAttribute("tabindex")) {
440
+ element.setAttribute("tabindex", "0");
441
+ added.push("tabindex");
442
+ }
443
+ }
444
+ element.setAttribute("aria-controls", ASSISTANT_PANEL_ID);
445
+ element.setAttribute("aria-haspopup", "dialog");
446
+ const sync = () => element.setAttribute("aria-expanded", String(this.state.isOpen));
447
+ const onClick = () => this.toggle();
448
+ // Space and Enter are what a native button responds to; a button
449
+ // already turns them into a click.
450
+ const onKeyDown = (event) => {
451
+ if (event.key === "Enter" || event.key === " ") {
452
+ event.preventDefault();
453
+ this.toggle();
454
+ }
455
+ };
456
+ sync();
457
+ const unsubscribe = this.subscribe(sync);
458
+ element.addEventListener("click", onClick);
459
+ if (!native)
460
+ element.addEventListener("keydown", onKeyDown);
461
+ return () => {
462
+ unsubscribe();
463
+ element.removeEventListener("click", onClick);
464
+ element.removeEventListener("keydown", onKeyDown);
465
+ for (const attribute of added)
466
+ element.removeAttribute(attribute);
467
+ };
468
+ }
469
+ // ---- the runtime's events ---------------------------------------------
470
+ handleEvent = (event) => {
471
+ switch (event.type) {
472
+ case "state":
473
+ this.setState({ connection: event.state });
474
+ // Re-sent on every change of connection, so a reconnected session
475
+ // learns the screen without waiting for the user to move.
476
+ if (this.publishTimer !== null)
477
+ clearTimeout(this.publishTimer);
478
+ this.publishTimer = null;
479
+ this.publish();
480
+ break;
481
+ case "turn_start":
482
+ this.setState({
483
+ busy: true,
484
+ messages: [
485
+ ...this.state.messages,
486
+ { id: event.turnId, role: "assistant", text: "", streaming: true },
487
+ ],
488
+ });
489
+ break;
490
+ case "delta":
491
+ this.setMessages((current) => current.map((message) => message.id === event.turnId
492
+ ? { ...message, text: message.text + event.text }
493
+ : message));
494
+ break;
495
+ case "complete":
496
+ this.setState({
497
+ busy: false,
498
+ messages: this.state.messages.map((message) => message.id === event.turnId
499
+ ? {
500
+ ...message,
501
+ text: event.message,
502
+ streaming: false,
503
+ sources: event.sources.length ? event.sources : undefined,
504
+ }
505
+ : message),
506
+ });
507
+ break;
508
+ case "command": {
509
+ const ctx = {
510
+ store: this.store,
511
+ highlighter: this.highlighter,
512
+ navigate: this.options.navigate ??
513
+ ((route) => {
514
+ window.location.assign(route);
515
+ }),
516
+ screenPublished: this.awaitScreenPublished,
517
+ viewPublished: this.awaitViewPublished,
518
+ openModal: this.options.openModal,
519
+ };
520
+ const client = this.client;
521
+ // Always acks, including on a thrown handler, because the runtime is
522
+ // blocking a turn until it hears back.
523
+ void executeCommand(event.command, ctx)
524
+ .then((result) => client?.ackCommand(event.commandId, result.ok, result.result, result.error))
525
+ .catch((cause) => client?.ackCommand(event.commandId, false, undefined, cause instanceof Error ? cause.message : "The command failed."));
526
+ break;
527
+ }
528
+ case "proposal":
529
+ this.setState({ proposals: [...this.state.proposals, event.proposal] });
530
+ this.describe(event.proposal);
531
+ break;
532
+ case "action_status": {
533
+ if (event.status === "executing")
534
+ break;
535
+ // Retire the card and say what became of it. Without this the card
536
+ // simply vanishes on approval and the assistant stays silent — which
537
+ // reads as nothing having happened, for an action that may have just
538
+ // notified a customer. The model does learn the outcome, but not
539
+ // until its next turn, so it cannot be what tells the user.
540
+ const settled = this.state.proposals.find((p) => p.proposalId === event.proposalId);
541
+ if (settled) {
542
+ // Keyed by the proposal, so a status arriving twice — a reconnect
543
+ // replaying it, a double tap — settles to one line rather than two.
544
+ const statusId = `status_${event.proposalId}`;
545
+ if (event.status !== "rejected" && !this.state.messages.some((m) => m.id === statusId)) {
546
+ // The label comes from the registry, like everything else on the
547
+ // card, so this line cannot be steered by model output.
548
+ const outcome = event.status === "executed"
549
+ ? `${settled.label} — done.`
550
+ : `${settled.label} — ${event.error ?? "that did not work."}`;
551
+ this.setMessages((messages) => [
552
+ ...messages,
553
+ { id: statusId, role: "assistant", text: outcome },
554
+ ]);
555
+ }
556
+ // Host apps cache — an action that changed data server-side leaves
557
+ // the page showing what was true a moment ago.
558
+ this.options.onActionComplete?.({
559
+ actionName: settled.actionName,
560
+ status: event.status,
561
+ result: event.result,
562
+ });
563
+ }
564
+ this.setState({
565
+ proposals: this.state.proposals.filter((p) => p.proposalId !== event.proposalId),
566
+ ...(event.error ? { lastError: event.error } : {}),
567
+ });
568
+ break;
569
+ }
570
+ case "voice_available":
571
+ this.setVoice({ available: event.available });
572
+ break;
573
+ case "assistant_name":
574
+ this.setState({ assistantName: event.name });
575
+ break;
576
+ case "launcher_icon":
577
+ this.setState({ launcherIconUrl: event.url });
578
+ break;
579
+ case "widget_settings":
580
+ this.setState({ widgetSettings: event.settings });
581
+ break;
582
+ case "context_report":
583
+ console.info(formatContextReport(event.report));
584
+ break;
585
+ case "voice_token":
586
+ // Only ever the answer to a session the user actually asked for. It
587
+ // used to double as an availability probe, which meant minting a
588
+ // single-use credential to answer a yes/no question.
589
+ if (!this.voiceRequested)
590
+ break;
591
+ this.voiceRequested = false;
592
+ void this.startVoice(event.token);
593
+ break;
594
+ case "error":
595
+ // Voice refusals are not chat errors and must not be shown as one:
596
+ // the chat socket is fine, and the honest reading is "voice is not
597
+ // available here", which is a button that should disappear.
598
+ if (event.code.startsWith("voice_")) {
599
+ this.voiceRequested = false;
600
+ this.setState({ busy: false, voice: { ...this.state.voice, available: false } });
601
+ break;
602
+ }
603
+ this.setState({ busy: false, lastError: event.message });
604
+ break;
605
+ }
606
+ };
607
+ // ---- screen publishing -------------------------------------------------
608
+ publish = () => {
609
+ this.publishTimer = null;
610
+ const screen = this.store.getScreenContext();
611
+ // A null screen is published, not skipped. The user has navigated to a
612
+ // page the host app never declared, and the runtime has to be told —
613
+ // otherwise it answers with the last screen it heard about. Nothing is
614
+ // sent before the first screen ever registers, since there is no move to
615
+ // report yet.
616
+ if (!screen && this.publishedRoute === null)
617
+ return;
618
+ this.client?.updateScreen(screen);
619
+ this.publishedRoute = screen?.route ?? null;
620
+ this.publishedScreen = screen;
621
+ // Published either way, so the runtime knows where the user is and scopes
622
+ // actions to it. But a navigation is not acked against a screen whose
623
+ // data is still loading: the model's next step would read its empty
624
+ // counts as the answer.
625
+ this.publishedSettled = !this.store.isScreenLoading();
626
+ if (!screen || !this.publishedSettled)
627
+ return;
628
+ // Copied, because a waiter that matches removes itself from the set.
629
+ for (const waiter of [...this.screenWaiters])
630
+ waiter(screen);
631
+ };
632
+ /**
633
+ * A navigate command cannot be acked until the destination has published
634
+ * its context, or the runtime's next step would still be reasoning about
635
+ * the screen the user just left. See `CommandContext.screenPublished`.
636
+ */
637
+ awaitScreenPublished = (route, options) => new Promise((resolve) => {
638
+ // Pages publish their path, never the query, so arrival is matched on
639
+ // the path.
640
+ const path = route.split("?")[0];
641
+ if (!options?.fresh && this.publishedRoute === path && this.publishedSettled) {
642
+ return resolve(true);
643
+ }
644
+ const done = (arrived) => {
645
+ this.screenWaiters.delete(waiter);
646
+ clearTimeout(timer);
647
+ resolve(arrived);
648
+ };
649
+ const waiter = (published) => {
650
+ if (published.route === path)
651
+ done(true);
652
+ };
653
+ // Bounded, so a route the app never publishes stalls one turn rather
654
+ // than hanging until the runtime's own ack timeout fires.
655
+ const timer = setTimeout(() => done(false), SCREEN_ARRIVAL_TIMEOUT_MS);
656
+ this.screenWaiters.add(waiter);
657
+ });
658
+ /**
659
+ * A set_view ack waits the same way, for the tab the app was asked to show
660
+ * to be the one the published screen reports. An app whose `onChange` did
661
+ * nothing is reported as a failure rather than a switch that did not
662
+ * happen.
663
+ */
664
+ awaitViewPublished = (controlKey, value) => new Promise((resolve) => {
665
+ const showing = (screen) => screen?.controls.some((control) => control.controlKey === controlKey && control.value === value) ?? false;
666
+ if (this.publishedSettled && showing(this.publishedScreen))
667
+ return resolve(true);
668
+ const done = (shown) => {
669
+ this.screenWaiters.delete(waiter);
670
+ clearTimeout(timer);
671
+ resolve(shown);
672
+ };
673
+ const waiter = (published) => {
674
+ if (showing(published))
675
+ done(true);
676
+ };
677
+ const timer = setTimeout(() => done(false), SCREEN_ARRIVAL_TIMEOUT_MS);
678
+ this.screenWaiters.add(waiter);
679
+ });
680
+ // ---- voice -------------------------------------------------------------
681
+ startVoiceRequest = (options) => {
682
+ // A new session is not the place to still be reading why the last one
683
+ // stopped.
684
+ this.setVoice({ notice: null });
685
+ // A fresh permit every time: they are single use and expire in a minute.
686
+ this.requestedHandsFree = Boolean(options?.handsFree);
687
+ this.requestedOpenPanel = options?.openPanel ?? true;
688
+ this.voiceRequested = true;
689
+ this.client?.requestVoiceToken();
690
+ };
691
+ stopVoice = () => {
692
+ this.voiceRequested = false;
693
+ void this.voiceSession?.stop();
694
+ this.voiceSession = null;
695
+ // A countdown for a session the user has already closed is worse than no
696
+ // notice: it is a wrong one.
697
+ this.setVoice({ handsFree: false, notice: null });
698
+ };
699
+ async startVoice(token) {
700
+ const wanted = this.requestedHandsFree;
701
+ this.setVoice({ handsFree: wanted });
702
+ const session = new VoiceSession({
703
+ url: voiceUrl(this.options.url, token, wanted),
704
+ handsFree: wanted,
705
+ onEvent: this.handleVoiceEvent,
706
+ onLevel: (source, level) => {
707
+ for (const listener of this.levelListeners)
708
+ listener(source, level);
709
+ },
710
+ });
711
+ this.voiceSession = session;
712
+ // Opening the panel so what is said has somewhere to appear, for the
713
+ // same reason `send` does, unless the caller shows voice itself.
714
+ if (this.requestedOpenPanel)
715
+ this.open();
716
+ await session.start();
717
+ }
718
+ handleVoiceEvent = (event) => {
719
+ switch (event.type) {
720
+ case "state":
721
+ this.setVoice({
722
+ state: event.state,
723
+ ...(event.state === "idle" || event.state === "error" ? { caption: "" } : {}),
724
+ });
725
+ break;
726
+ case "transcript":
727
+ // The caption is for words still being spoken. Once a line is final
728
+ // it joins the transcript, so the caption is cleared rather than
729
+ // showing the same sentence twice.
730
+ if (event.final) {
731
+ this.setState({
732
+ voice: { ...this.state.voice, caption: "" },
733
+ messages: [
734
+ ...this.state.messages,
735
+ { id: messageId("voice"), role: event.role, text: event.text },
736
+ ],
737
+ });
738
+ }
739
+ else {
740
+ this.setVoice({ caption: event.text });
741
+ }
742
+ break;
743
+ case "ending_soon":
744
+ this.setVoice({ notice: endingSoonNotice(event.secondsRemaining) });
745
+ break;
746
+ case "ended":
747
+ // The runtime's own words, which say the conversation survives. The
748
+ // session tore itself down, so nothing else will clear the mode; left
749
+ // set, the next push-to-talk session would open as hands-free.
750
+ this.setVoice({ caption: "", notice: event.message, handsFree: false });
751
+ break;
752
+ case "error":
753
+ this.setState({ lastError: event.message });
754
+ break;
755
+ }
756
+ };
757
+ }
758
+ //# sourceMappingURL=mihe.js.map