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