@webotme/react-native 0.1.1 → 0.1.2
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/README.md +79 -0
- package/package.json +16 -5
- package/src/chat.ts +369 -0
- package/src/client.ts +4 -1
- package/src/executor.ts +156 -0
- package/src/index.ts +54 -0
- package/src/lazy-webview.tsx +59 -0
- package/src/navigationAdapter.ts +58 -0
- package/src/provider.tsx +72 -76
- package/src/safety.ts +72 -0
- package/src/scan.txt +1 -0
- package/src/screenHook.ts +82 -0
- package/src/screenScanner.tsx +376 -0
- package/src/types.ts +2 -0
- package/src/voice.ts +592 -0
- package/src/widget.tsx +3 -2
package/src/voice.ts
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-function voice chat, the same way `chatWithUs` is one-function text chat.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* const res = await voiceWithUs(); // tap the mic
|
|
6
|
+
* // user speaks -> device transcribes -> chatWithUs() -> reply spoken aloud
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* Like the web widget, everything runs on the device: the phone's own speech
|
|
10
|
+
* recogniser transcribes the question and the phone's own synthesiser speaks the
|
|
11
|
+
* answer. Your server only ever sees plain text, exactly as it does today, so
|
|
12
|
+
* no server change and no API keys are required.
|
|
13
|
+
*
|
|
14
|
+
* Both native modules are optional peer dependencies and are resolved on first
|
|
15
|
+
* use, so an app that only sends text never loads them and never breaks.
|
|
16
|
+
*/
|
|
17
|
+
import { useEffect, useState } from "react";
|
|
18
|
+
import {
|
|
19
|
+
chatWithUs,
|
|
20
|
+
type ChatResult,
|
|
21
|
+
} from "./chat";
|
|
22
|
+
import type { WebotMeMessage } from "./types";
|
|
23
|
+
|
|
24
|
+
/** Mirrors the web widget's voice states (minus wake-word, which is browser-only). */
|
|
25
|
+
export type VoiceState =
|
|
26
|
+
| "idle"
|
|
27
|
+
| "listening"
|
|
28
|
+
| "thinking"
|
|
29
|
+
| "speaking"
|
|
30
|
+
| "unsupported"
|
|
31
|
+
| "denied";
|
|
32
|
+
|
|
33
|
+
export type VoiceOptions = {
|
|
34
|
+
/** BCP-47 language for both recognition and speech. Default "en-US". */
|
|
35
|
+
lang?: string;
|
|
36
|
+
/** Speaking speed, 0.5–2. Default 0.9 (same pace the web widget uses). */
|
|
37
|
+
rate?: number;
|
|
38
|
+
/** Speaking pitch, 0.5–2. Default 1. */
|
|
39
|
+
pitch?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Speak every bot reply, including ones the user typed. Default false, so
|
|
42
|
+
* only answers to spoken questions are read out.
|
|
43
|
+
*/
|
|
44
|
+
speakAllReplies?: boolean;
|
|
45
|
+
/** Silence in ms that finalises the spoken query. Default 2200. */
|
|
46
|
+
silenceTimeout?: number;
|
|
47
|
+
onStateChange?: (state: VoiceState) => void;
|
|
48
|
+
/** Interim transcript, updated live while the user speaks. */
|
|
49
|
+
onPartial?: (text: string) => void;
|
|
50
|
+
/** Final transcript once the user stops talking. */
|
|
51
|
+
onUserQuery?: (text: string) => void;
|
|
52
|
+
onError?: (message: string) => void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** What `voiceWithUs` hands back, mirroring `ChatResult`. */
|
|
56
|
+
export type VoiceResult = {
|
|
57
|
+
ok: boolean;
|
|
58
|
+
/** What the user actually said. */
|
|
59
|
+
transcript: string;
|
|
60
|
+
/** The bot's reply, or an empty string when it failed. */
|
|
61
|
+
reply: string;
|
|
62
|
+
userMessage: WebotMeMessage | null;
|
|
63
|
+
botMessage: WebotMeMessage | null;
|
|
64
|
+
/** False when the reply was not spoken (voice replies disabled, or TTS failed). */
|
|
65
|
+
spoken: boolean;
|
|
66
|
+
error?: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ── Optional native modules, resolved on first use ──────────────────────────
|
|
70
|
+
|
|
71
|
+
type SpeechLike = {
|
|
72
|
+
speak: (text: string, options?: Record<string, unknown>) => void;
|
|
73
|
+
stop: () => Promise<void> | void;
|
|
74
|
+
isSpeakingAsync?: () => Promise<boolean>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
type SpeechEvent = { results?: Array<{ transcript?: string } | string> };
|
|
78
|
+
|
|
79
|
+
type SpeechRecognitionLike = {
|
|
80
|
+
start: (options?: Record<string, unknown>) => Promise<void> | void;
|
|
81
|
+
stop: () => Promise<void> | void;
|
|
82
|
+
abort: () => Promise<void> | void;
|
|
83
|
+
requestPermissionsAsync?: () => Promise<{ granted?: boolean; status?: string }>;
|
|
84
|
+
isRecognitionAvailable?: () => Promise<boolean>;
|
|
85
|
+
addListener?: (
|
|
86
|
+
event: string,
|
|
87
|
+
listener: (event: any) => void,
|
|
88
|
+
) => { remove: () => void };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
let speechCache: SpeechLike | null | undefined;
|
|
92
|
+
let recognitionCache: SpeechRecognitionLike | null | undefined;
|
|
93
|
+
|
|
94
|
+
const REBUILD_HINT =
|
|
95
|
+
"Then rebuild the app — a JS reload is not enough, the native module has to be compiled in: npx expo run:android / run:ios, or eas build.";
|
|
96
|
+
|
|
97
|
+
const MISSING_STT = `Voice input is not available in this build. Install it with "npx expo install expo-speech-recognition". ${REBUILD_HINT}`;
|
|
98
|
+
const MISSING_TTS = `Voice replies are not available in this build. Install them with "npx expo install expo-speech". ${REBUILD_HINT}`;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Both packages call `requireNativeModule` at module scope, which throws when
|
|
102
|
+
* the native side is missing — for example after adding the package but before
|
|
103
|
+
* rebuilding, or inside a build made before the package existed. Asking the
|
|
104
|
+
* native registry first lets us report "unsupported" instead of throwing on
|
|
105
|
+
* every mount.
|
|
106
|
+
*/
|
|
107
|
+
function hasNativeModule(name: string): boolean {
|
|
108
|
+
try {
|
|
109
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
110
|
+
const { NativeModules } = require("react-native");
|
|
111
|
+
if (NativeModules?.[name]) return true;
|
|
112
|
+
// TurboModules are not always mirrored into NativeModules, so fall back to
|
|
113
|
+
// a probe that returns null instead of throwing when the module is absent.
|
|
114
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
115
|
+
const { requireOptionalNativeModule } = require("expo");
|
|
116
|
+
return Boolean(requireOptionalNativeModule?.(name));
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `expo-speech`, or null when the app has not installed it. */
|
|
123
|
+
function loadSpeech(): SpeechLike | null {
|
|
124
|
+
if (speechCache !== undefined) return speechCache;
|
|
125
|
+
if (!hasNativeModule("ExpoSpeech")) {
|
|
126
|
+
speechCache = null;
|
|
127
|
+
return speechCache;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
131
|
+
const mod = require("expo-speech");
|
|
132
|
+
const candidate = (mod?.Speech ?? mod?.default) as SpeechLike | undefined;
|
|
133
|
+
speechCache = typeof candidate?.speak === "function" ? candidate : null;
|
|
134
|
+
} catch {
|
|
135
|
+
speechCache = null;
|
|
136
|
+
}
|
|
137
|
+
return speechCache;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** `expo-speech-recognition`, or null when the app has not installed it. */
|
|
141
|
+
function loadRecognition(): SpeechRecognitionLike | null {
|
|
142
|
+
if (recognitionCache !== undefined) return recognitionCache;
|
|
143
|
+
if (!hasNativeModule("ExpoSpeechRecognition")) {
|
|
144
|
+
recognitionCache = null;
|
|
145
|
+
return recognitionCache;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
149
|
+
const mod = require("expo-speech-recognition");
|
|
150
|
+
const candidate = (mod?.ExpoSpeechRecognitionModule ??
|
|
151
|
+
mod?.default ??
|
|
152
|
+
mod) as SpeechRecognitionLike | undefined;
|
|
153
|
+
recognitionCache =
|
|
154
|
+
candidate && typeof candidate.start === "function" ? candidate : null;
|
|
155
|
+
} catch {
|
|
156
|
+
recognitionCache = null;
|
|
157
|
+
}
|
|
158
|
+
return recognitionCache;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readTranscript(event: SpeechEvent | undefined): string {
|
|
162
|
+
const first = event?.results?.[0];
|
|
163
|
+
if (typeof first === "string") return first.trim();
|
|
164
|
+
if (first && typeof first.transcript === "string") return first.transcript.trim();
|
|
165
|
+
return "";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Engine ──────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
type Listener = () => void;
|
|
171
|
+
|
|
172
|
+
type VoiceStateShape = {
|
|
173
|
+
state: VoiceState;
|
|
174
|
+
supported: boolean;
|
|
175
|
+
transcript: string;
|
|
176
|
+
error: string | null;
|
|
177
|
+
voiceReplies: boolean;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
class VoiceEngine {
|
|
181
|
+
private options: VoiceOptions = {};
|
|
182
|
+
private listeners = new Set<Listener>();
|
|
183
|
+
private subscriptions: Array<{ remove: () => void }> = [];
|
|
184
|
+
private state: VoiceStateShape;
|
|
185
|
+
|
|
186
|
+
private transcript = "";
|
|
187
|
+
private settleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
188
|
+
private onFinal: ((text: string) => void) | null = null;
|
|
189
|
+
private session: Promise<VoiceResult> | null = null;
|
|
190
|
+
private resolveSession: ((result: VoiceResult) => void) | null = null;
|
|
191
|
+
private speaking = false;
|
|
192
|
+
private continuous = false;
|
|
193
|
+
private loopStopped = false;
|
|
194
|
+
|
|
195
|
+
constructor() {
|
|
196
|
+
this.state = {
|
|
197
|
+
state: "idle",
|
|
198
|
+
supported: false,
|
|
199
|
+
transcript: "",
|
|
200
|
+
error: null,
|
|
201
|
+
voiceReplies: false,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private patch(next: Partial<VoiceStateShape> & { state?: VoiceState }) {
|
|
206
|
+
const previous = this.state.state;
|
|
207
|
+
this.state = { ...this.state, ...next };
|
|
208
|
+
if (next.state && next.state !== previous) {
|
|
209
|
+
this.options.onStateChange?.(next.state);
|
|
210
|
+
}
|
|
211
|
+
for (const listener of this.listeners) listener();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
subscribe(listener: Listener) {
|
|
215
|
+
this.listeners.add(listener);
|
|
216
|
+
return () => {
|
|
217
|
+
this.listeners.delete(listener);
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
getState(): VoiceStateShape {
|
|
222
|
+
return this.state;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
configure(options: VoiceOptions) {
|
|
226
|
+
this.options = { ...this.options, ...options };
|
|
227
|
+
if (typeof options.speakAllReplies === "boolean") {
|
|
228
|
+
this.patch({ voiceReplies: options.speakAllReplies });
|
|
229
|
+
}
|
|
230
|
+
this.patch({ supported: this.isSupported() });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
setVoiceReplies(enabled: boolean) {
|
|
234
|
+
this.patch({ voiceReplies: enabled });
|
|
235
|
+
if (!enabled) void this.stopSpeaking();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
isSupported() {
|
|
239
|
+
try {
|
|
240
|
+
return loadRecognition() !== null && loadSpeech() !== null;
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async ensureMicPermission(): Promise<boolean> {
|
|
247
|
+
const recognition = loadRecognition();
|
|
248
|
+
if (!recognition?.requestPermissionsAsync) return true;
|
|
249
|
+
try {
|
|
250
|
+
const result = await recognition.requestPermissionsAsync();
|
|
251
|
+
return result?.granted !== false;
|
|
252
|
+
} catch {
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private attachListeners() {
|
|
258
|
+
if (this.subscriptions.length > 0) return;
|
|
259
|
+
const recognition = loadRecognition();
|
|
260
|
+
if (!recognition?.addListener) return;
|
|
261
|
+
|
|
262
|
+
const push = (event: any) => {
|
|
263
|
+
const text = readTranscript(event);
|
|
264
|
+
if (!text) return;
|
|
265
|
+
this.transcript = text;
|
|
266
|
+
this.patch({ transcript: text });
|
|
267
|
+
this.options.onPartial?.(text);
|
|
268
|
+
this.scheduleSettle();
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const fail = (event: any) => {
|
|
272
|
+
const message: string =
|
|
273
|
+
event?.error?.message ?? event?.message ?? event?.error ?? "Voice input failed.";
|
|
274
|
+
this.failRecognition(message);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
this.subscriptions = [
|
|
278
|
+
recognition.addListener("result", push),
|
|
279
|
+
recognition.addListener("end", () => {
|
|
280
|
+
// The recogniser finalises on its own after a short pause; settle
|
|
281
|
+
// whatever it heard so a dropped event can never hang the promise.
|
|
282
|
+
this.settle();
|
|
283
|
+
}),
|
|
284
|
+
recognition.addListener("error", fail),
|
|
285
|
+
];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private scheduleSettle() {
|
|
289
|
+
if (this.settleTimer) clearTimeout(this.settleTimer);
|
|
290
|
+
this.settleTimer = setTimeout(
|
|
291
|
+
() => this.settle(),
|
|
292
|
+
this.options.silenceTimeout ?? 2200,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private settle() {
|
|
297
|
+
if (this.settleTimer) {
|
|
298
|
+
clearTimeout(this.settleTimer);
|
|
299
|
+
this.settleTimer = null;
|
|
300
|
+
}
|
|
301
|
+
const text = this.transcript.trim();
|
|
302
|
+
if (!text) return;
|
|
303
|
+
void loadRecognition()?.stop();
|
|
304
|
+
this.onFinal?.(text);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Listen once, then resolve with the transcript and the spoken reply. */
|
|
308
|
+
listen(options?: { continuous?: boolean }): Promise<VoiceResult> {
|
|
309
|
+
if (this.session) return this.session;
|
|
310
|
+
|
|
311
|
+
if (typeof options?.continuous === "boolean") {
|
|
312
|
+
this.continuous = options.continuous;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!this.isSupported()) {
|
|
316
|
+
const error = loadRecognition()
|
|
317
|
+
? MISSING_TTS
|
|
318
|
+
: MISSING_STT;
|
|
319
|
+
this.patch({ state: "unsupported", supported: false, error });
|
|
320
|
+
this.options.onError?.(error);
|
|
321
|
+
return Promise.resolve(this.failure(error));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
this.session = new Promise<VoiceResult>((resolve) => {
|
|
325
|
+
this.resolveSession = resolve;
|
|
326
|
+
this.onFinal = (text) => {
|
|
327
|
+
this.onFinal = null;
|
|
328
|
+
this.patch({ state: "thinking" });
|
|
329
|
+
this.askBot(text);
|
|
330
|
+
};
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
void this.beginListening();
|
|
334
|
+
return this.session;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private async beginListening() {
|
|
338
|
+
const recognition = loadRecognition();
|
|
339
|
+
if (!recognition) return;
|
|
340
|
+
|
|
341
|
+
await this.ensureMicPermission();
|
|
342
|
+
this.attachListeners();
|
|
343
|
+
this.transcript = "";
|
|
344
|
+
this.patch({ state: "listening", transcript: "", error: null });
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
await recognition.start({
|
|
348
|
+
lang: this.options.lang ?? "en-US",
|
|
349
|
+
interimResults: true,
|
|
350
|
+
continuous: false,
|
|
351
|
+
// Keep audio on the phone where the OS supports it.
|
|
352
|
+
requiresOnDeviceRecognition: undefined,
|
|
353
|
+
});
|
|
354
|
+
} catch (err) {
|
|
355
|
+
const message =
|
|
356
|
+
err instanceof Error ? err.message : "Could not start voice input.";
|
|
357
|
+
// If continuous loop fails to restart, exit loop gracefully.
|
|
358
|
+
if (this.continuous) {
|
|
359
|
+
this.continuous = false;
|
|
360
|
+
this.loopStopped = true;
|
|
361
|
+
}
|
|
362
|
+
this.fail(message);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private async askBot(transcript: string) {
|
|
367
|
+
let result: ChatResult;
|
|
368
|
+
try {
|
|
369
|
+
result = await chatWithUs(transcript);
|
|
370
|
+
} catch (err) {
|
|
371
|
+
// chatWithUs already reports its own failures, so this is a real crash.
|
|
372
|
+
this.fail(
|
|
373
|
+
err instanceof Error ? err.message : "Could not reach the bot.",
|
|
374
|
+
"denied",
|
|
375
|
+
);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
this.options.onUserQuery?.(transcript);
|
|
379
|
+
|
|
380
|
+
if (!result.ok) {
|
|
381
|
+
this.fail(result.error ?? "Could not reach the bot.");
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const spoken = await this.speakReply(result.reply);
|
|
386
|
+
if (this.continuous && !this.loopStopped) {
|
|
387
|
+
this.continueLoop();
|
|
388
|
+
} else {
|
|
389
|
+
this.finishSession({
|
|
390
|
+
ok: true,
|
|
391
|
+
transcript,
|
|
392
|
+
reply: result.reply,
|
|
393
|
+
userMessage: result.userMessage,
|
|
394
|
+
botMessage: result.botMessage,
|
|
395
|
+
spoken,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private continueLoop() {
|
|
401
|
+
this.loopStopped = false;
|
|
402
|
+
this.transcript = "";
|
|
403
|
+
this.patch({ transcript: "", error: null });
|
|
404
|
+
void this.beginListening();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Speak a reply and resolve when playback ends. */
|
|
408
|
+
private speakReply(text: string): Promise<boolean> {
|
|
409
|
+
const speech = loadSpeech();
|
|
410
|
+
const trimmed = text.trim();
|
|
411
|
+
if (!speech || !trimmed) return Promise.resolve(false);
|
|
412
|
+
|
|
413
|
+
return new Promise<boolean>((resolve) => {
|
|
414
|
+
this.speaking = true;
|
|
415
|
+
this.patch({ state: "speaking" });
|
|
416
|
+
|
|
417
|
+
let settled = false;
|
|
418
|
+
const finish = (ok: boolean) => {
|
|
419
|
+
if (settled) return;
|
|
420
|
+
settled = true;
|
|
421
|
+
this.speaking = false;
|
|
422
|
+
if (this.state.state === "speaking") this.patch({ state: "idle" });
|
|
423
|
+
resolve(ok);
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// iOS never fires an end event for empty or whitespace-only text.
|
|
427
|
+
try {
|
|
428
|
+
speech.speak(trimmed, {
|
|
429
|
+
language: this.options.lang ?? "en-US",
|
|
430
|
+
rate: this.options.rate ?? 0.9,
|
|
431
|
+
pitch: this.options.pitch ?? 1,
|
|
432
|
+
onDone: () => finish(true),
|
|
433
|
+
onStopped: () => finish(false),
|
|
434
|
+
onError: () => finish(false),
|
|
435
|
+
});
|
|
436
|
+
} catch {
|
|
437
|
+
finish(false);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Safety net: never let a missing end event hang the promise.
|
|
442
|
+
setTimeout(() => finish(true), Math.max(8000, trimmed.length * 120));
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Speak any text, e.g. to read a reply the user typed. */
|
|
447
|
+
speak(text: string): Promise<boolean> {
|
|
448
|
+
return this.speakReply(text);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async stopSpeaking() {
|
|
452
|
+
const speech = loadSpeech();
|
|
453
|
+
if (!speech) return;
|
|
454
|
+
this.speaking = false;
|
|
455
|
+
try {
|
|
456
|
+
await speech.stop();
|
|
457
|
+
} catch {
|
|
458
|
+
/* nothing playing */
|
|
459
|
+
}
|
|
460
|
+
if (this.state.state === "speaking") this.patch({ state: "idle" });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Stop listening and speaking without settling the promise. */
|
|
464
|
+
stop() {
|
|
465
|
+
if (this.settleTimer) {
|
|
466
|
+
clearTimeout(this.settleTimer);
|
|
467
|
+
this.settleTimer = null;
|
|
468
|
+
}
|
|
469
|
+
const recognition = loadRecognition();
|
|
470
|
+
void recognition?.abort?.();
|
|
471
|
+
void recognition?.stop?.();
|
|
472
|
+
void this.stopSpeaking();
|
|
473
|
+
this.continuous = false;
|
|
474
|
+
this.loopStopped = true;
|
|
475
|
+
if (this.state.state === "listening" || this.state.state === "thinking" || this.state.state === "speaking") {
|
|
476
|
+
this.patch({ state: "idle" });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** A recogniser failure mid-session: report it and go back to idle. */
|
|
481
|
+
private failRecognition(message: string) {
|
|
482
|
+
this.patch({ state: "idle", error: message });
|
|
483
|
+
this.options.onError?.(message);
|
|
484
|
+
this.finishSession(this.failure(message, this.transcript.trim()));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Voice could not start at all (permission denied, module missing, no mic). */
|
|
488
|
+
private fail(message: string, state: VoiceState = "denied") {
|
|
489
|
+
this.patch({ state, error: message });
|
|
490
|
+
this.options.onError?.(message);
|
|
491
|
+
this.finishSession(this.failure(message, this.transcript.trim()));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private failure(error: string, transcript = ""): VoiceResult {
|
|
495
|
+
return {
|
|
496
|
+
ok: false,
|
|
497
|
+
transcript,
|
|
498
|
+
reply: "",
|
|
499
|
+
userMessage: null,
|
|
500
|
+
botMessage: null,
|
|
501
|
+
spoken: false,
|
|
502
|
+
error,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private finishSession(result: VoiceResult) {
|
|
507
|
+
const resolve = this.resolveSession;
|
|
508
|
+
this.resolveSession = null;
|
|
509
|
+
this.onFinal = null;
|
|
510
|
+
this.session = null;
|
|
511
|
+
this.loopStopped = true;
|
|
512
|
+
this.continuous = false;
|
|
513
|
+
if (this.state.state !== "unsupported") this.patch({ state: "idle" });
|
|
514
|
+
resolve?.(result);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const engine = new VoiceEngine();
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Point the voice engine at a language and wire the callbacks. Optional — every
|
|
522
|
+
* setting has a sensible default, so a bare `voiceWithUs()` works too.
|
|
523
|
+
*
|
|
524
|
+
* ```tsx
|
|
525
|
+
* configureVoice({
|
|
526
|
+
* lang: "en-US",
|
|
527
|
+
* speakAllReplies: true,
|
|
528
|
+
* onPartial: (text) => setText(text),
|
|
529
|
+
* });
|
|
530
|
+
* ```
|
|
531
|
+
*/
|
|
532
|
+
export function configureVoice(options: VoiceOptions) {
|
|
533
|
+
engine.configure(options);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Read every bot reply aloud, not only the ones that were spoken. */
|
|
537
|
+
export function setVoiceReplies(enabled: boolean) {
|
|
538
|
+
engine.setVoiceReplies(enabled);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Stop listening and stop talking. */
|
|
542
|
+
export function stopVoice() {
|
|
543
|
+
engine.stop();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Ask out loud and hear the answer.
|
|
548
|
+
*
|
|
549
|
+
* ```tsx
|
|
550
|
+
* const res = await voiceWithUs();
|
|
551
|
+
* if (res.ok) console.log(res.transcript, "->", res.reply);
|
|
552
|
+
* ```
|
|
553
|
+
*/
|
|
554
|
+
export function voiceWithUs(options?: { continuous?: boolean }): Promise<VoiceResult> {
|
|
555
|
+
return engine.listen(options);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Say a line with the device synthesiser, e.g. to read a typed reply. */
|
|
559
|
+
export function speakWithUs(text: string): Promise<boolean> {
|
|
560
|
+
return engine.speak(text);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Read a reply aloud only when `configureVoice({ speakAllReplies: true })`. */
|
|
564
|
+
export function speakReplyIfEnabled(reply: string): Promise<boolean> {
|
|
565
|
+
return engine.getState().voiceReplies ? engine.speak(reply) : Promise.resolve(false);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** True when both native modules are installed. */
|
|
569
|
+
export function isVoiceSupported(): boolean {
|
|
570
|
+
return engine.isSupported();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Live voice state for rendering, the same way `useChat` exposes chat state. */
|
|
574
|
+
export function useVoiceChat() {
|
|
575
|
+
const [state, setState] = useState<VoiceStateShape>(() => engine.getState());
|
|
576
|
+
|
|
577
|
+
useEffect(() => {
|
|
578
|
+
setState(engine.getState());
|
|
579
|
+
return engine.subscribe(() => setState(engine.getState()));
|
|
580
|
+
}, []);
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
...state,
|
|
584
|
+
listening: state.state === "listening",
|
|
585
|
+
thinking: state.state === "thinking",
|
|
586
|
+
speaking: state.state === "speaking",
|
|
587
|
+
start: (options?: { continuous?: boolean }) => voiceWithUs(options),
|
|
588
|
+
speak: speakWithUs,
|
|
589
|
+
stop: stopVoice,
|
|
590
|
+
setVoiceReplies,
|
|
591
|
+
};
|
|
592
|
+
}
|
package/src/widget.tsx
CHANGED
|
@@ -16,8 +16,7 @@ import {
|
|
|
16
16
|
KeyboardAvoidingView,
|
|
17
17
|
Platform,
|
|
18
18
|
} from "react-native";
|
|
19
|
-
import {
|
|
20
|
-
import { WebView } from "react-native-webview";
|
|
19
|
+
import { loadWebView } from "./lazy-webview";
|
|
21
20
|
import { useWebotMe } from "./provider";
|
|
22
21
|
import { AutoFlowExecutor } from "./AutoFlowExecutor";
|
|
23
22
|
import type { WebotMeTheme, WebotMeAction, WebotMePendingWorkflow, WebotMeAutoTask, WebotMeScreenContext } from "./types";
|
|
@@ -342,6 +341,8 @@ export function WebotMeWidget({
|
|
|
342
341
|
);
|
|
343
342
|
}
|
|
344
343
|
|
|
344
|
+
const WebView = loadWebView();
|
|
345
|
+
|
|
345
346
|
const web = (
|
|
346
347
|
<WebView
|
|
347
348
|
source={{ uri: `${chatUrl}?embed=1&bot=${botId}` }}
|