@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.
- package/LICENSE +21 -0
- package/README.md +114 -2
- package/dist/audio.d.ts +121 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +377 -0
- package/dist/audio.js.map +1 -0
- package/dist/chart.d.ts +15 -0
- package/dist/chart.d.ts.map +1 -0
- package/dist/chart.js +58 -0
- package/dist/chart.js.map +1 -0
- package/dist/client.d.ts +226 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +358 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +70 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +295 -0
- package/dist/commands.js.map +1 -0
- package/dist/debug.d.ts +17 -0
- package/dist/debug.d.ts.map +1 -0
- package/dist/debug.js +29 -0
- package/dist/debug.js.map +1 -0
- package/dist/declarations.d.ts +160 -0
- package/dist/declarations.d.ts.map +1 -0
- package/dist/declarations.js +171 -0
- package/dist/declarations.js.map +1 -0
- package/dist/highlight.d.ts +77 -0
- package/dist/highlight.d.ts.map +1 -0
- package/dist/highlight.js +233 -0
- package/dist/highlight.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/mihe.d.ts +343 -0
- package/dist/mihe.d.ts.map +1 -0
- package/dist/mihe.iife.min.js +206 -0
- package/dist/mihe.iife.min.js.map +7 -0
- package/dist/mihe.js +720 -0
- package/dist/mihe.js.map +1 -0
- package/dist/mihe.min.js +206 -0
- package/dist/mihe.min.js.map +7 -0
- package/dist/store.d.ts +175 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +208 -0
- package/dist/store.js.map +1 -0
- package/dist/voice.d.ts +192 -0
- package/dist/voice.d.ts.map +1 -0
- package/dist/voice.js +443 -0
- package/dist/voice.js.map +1 -0
- package/dist/widget/card.d.ts +15 -0
- package/dist/widget/card.d.ts.map +1 -0
- package/dist/widget/card.js +28 -0
- package/dist/widget/card.js.map +1 -0
- package/dist/widget/dom.d.ts +24 -0
- package/dist/widget/dom.d.ts.map +1 -0
- package/dist/widget/dom.js +49 -0
- package/dist/widget/dom.js.map +1 -0
- package/dist/widget/element.d.ts +88 -0
- package/dist/widget/element.d.ts.map +1 -0
- package/dist/widget/element.js +285 -0
- package/dist/widget/element.js.map +1 -0
- package/dist/widget/format.d.ts +24 -0
- package/dist/widget/format.d.ts.map +1 -0
- package/dist/widget/format.js +64 -0
- package/dist/widget/format.js.map +1 -0
- package/dist/widget/icons.d.ts +5 -0
- package/dist/widget/icons.d.ts.map +1 -0
- package/dist/widget/icons.js +19 -0
- package/dist/widget/icons.js.map +1 -0
- package/dist/widget/launcher.d.ts +13 -0
- package/dist/widget/launcher.d.ts.map +1 -0
- package/dist/widget/launcher.js +27 -0
- package/dist/widget/launcher.js.map +1 -0
- package/dist/widget/mark.d.ts +10 -0
- package/dist/widget/mark.d.ts.map +1 -0
- package/dist/widget/mark.js +20 -0
- package/dist/widget/mark.js.map +1 -0
- package/dist/widget/orb.d.ts +91 -0
- package/dist/widget/orb.d.ts.map +1 -0
- package/dist/widget/orb.js +497 -0
- package/dist/widget/orb.js.map +1 -0
- package/dist/widget/panel.d.ts +40 -0
- package/dist/widget/panel.d.ts.map +1 -0
- package/dist/widget/panel.js +239 -0
- package/dist/widget/panel.js.map +1 -0
- package/dist/widget/styles.d.ts +22 -0
- package/dist/widget/styles.d.ts.map +1 -0
- package/dist/widget/styles.js +181 -0
- package/dist/widget/styles.js.map +1 -0
- 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
|