@webotme/react-native 0.1.2 → 0.1.3
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/package.json +1 -1
- package/src/AutoFlowExecutor.tsx +19 -8
- package/src/chat.ts +2 -0
- package/src/executor.ts +59 -25
- package/src/index.ts +3 -0
- package/src/navigationAdapter.ts +62 -3
- package/src/provider.tsx +31 -0
- package/src/runtime.ts +35 -0
- package/src/screenScanner.tsx +7 -1
- package/src/voice.ts +141 -11
package/package.json
CHANGED
package/src/AutoFlowExecutor.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
View,
|
|
12
12
|
type ViewStyle,
|
|
13
13
|
} from "react-native";
|
|
14
|
+
import { routeTargetFromUrl } from "./navigationAdapter";
|
|
14
15
|
import type {
|
|
15
16
|
WebotMeAction,
|
|
16
17
|
WebotMePendingWorkflow,
|
|
@@ -94,8 +95,12 @@ export function AutoFlowExecutor({
|
|
|
94
95
|
case "navigate": {
|
|
95
96
|
const url = action.data?.url;
|
|
96
97
|
if (!url) throw new Error("Navigate action missing URL");
|
|
97
|
-
|
|
98
|
-
//
|
|
98
|
+
|
|
99
|
+
// Resolve the raw URL/path into a real expo-router path + a
|
|
100
|
+
// friendly PascalCase name for React Navigation named screens.
|
|
101
|
+
const target = routeTargetFromUrl(url);
|
|
102
|
+
|
|
103
|
+
// Try to match known app routes first (named screens).
|
|
99
104
|
const routeMap: Record<string, string> = {
|
|
100
105
|
"/dashboard": "Dashboard",
|
|
101
106
|
"/dashboard/create": "CreateChatbot",
|
|
@@ -106,15 +111,21 @@ export function AutoFlowExecutor({
|
|
|
106
111
|
"/plans": "Plans",
|
|
107
112
|
"/login": "Login",
|
|
108
113
|
};
|
|
109
|
-
|
|
110
|
-
const routeName = routeMap[url] ||
|
|
111
|
-
|
|
114
|
+
|
|
115
|
+
const routeName = routeMap[url] || target.name;
|
|
116
|
+
const params = {
|
|
117
|
+
fromAutoFlow: true,
|
|
118
|
+
originalUrl: url,
|
|
119
|
+
screenName: routeName,
|
|
120
|
+
route: target.route,
|
|
121
|
+
};
|
|
122
|
+
|
|
112
123
|
if (push) {
|
|
113
|
-
push(
|
|
124
|
+
push(target.route, params);
|
|
114
125
|
} else {
|
|
115
|
-
navigate(
|
|
126
|
+
navigate(target.route, params);
|
|
116
127
|
}
|
|
117
|
-
|
|
128
|
+
|
|
118
129
|
return { success: true };
|
|
119
130
|
}
|
|
120
131
|
|
package/src/chat.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { useEffect, useState } from "react";
|
|
8
8
|
import { WebotMeClient } from "./client";
|
|
9
|
+
import { ensureRuntime } from "./runtime";
|
|
9
10
|
import {
|
|
10
11
|
WebotMeError,
|
|
11
12
|
type WebotMeAction,
|
|
@@ -66,6 +67,7 @@ class ChatEngine {
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
configure(config: WebotMeConfig) {
|
|
70
|
+
ensureRuntime();
|
|
69
71
|
const nextClient = new WebotMeClient({
|
|
70
72
|
botId: config.botId,
|
|
71
73
|
apiBaseUrl: config.apiBaseUrl,
|
package/src/executor.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Alert, Linking } from "react-native";
|
|
|
2
2
|
import { getChatState, nextAutoFlowStep, subscribeChat } from "./chat";
|
|
3
3
|
import { isActionAllowed } from "./safety";
|
|
4
4
|
import { findClickHandler, findFillHandler } from "./screenScanner";
|
|
5
|
+
import { routeTargetFromUrl, pushRoute, getCurrentRoute } from "./navigationAdapter";
|
|
5
6
|
import type { WebotMeAction, WebotMeMessage } from "./types";
|
|
6
7
|
|
|
7
8
|
export type ExecutorOptions = {
|
|
@@ -14,27 +15,13 @@ export type ExecutorOptions = {
|
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
let handlers: ExecutorOptions | null = null;
|
|
18
|
+
let currentUnsub: (() => void) | null = null;
|
|
17
19
|
const executedMessageIds = new Set<string>();
|
|
18
20
|
|
|
19
|
-
function
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const parts = trimmed.split("/").map((part) =>
|
|
24
|
-
part.charAt(0).toUpperCase() + part.slice(1),
|
|
25
|
-
);
|
|
26
|
-
return parts.join(":");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function defaultNavigate(routeName: string, params?: Record<string, any>) {
|
|
30
|
-
try {
|
|
31
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
32
|
-
const router = require("expo-router").default ?? require("expo-router").router;
|
|
33
|
-
router?.push?.(routeName as any);
|
|
34
|
-
return true;
|
|
35
|
-
} catch {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
21
|
+
function defaultNavigate(routeName: string, fallbackName?: string, params?: Record<string, any>) {
|
|
22
|
+
if (pushRoute(routeName, params)) return true;
|
|
23
|
+
if (fallbackName && pushRoute(fallbackName, params)) return true;
|
|
24
|
+
return false;
|
|
38
25
|
}
|
|
39
26
|
|
|
40
27
|
/** Run a single server action on the device. */
|
|
@@ -49,17 +36,55 @@ export async function executeAction(action: WebotMeAction): Promise<{ success: b
|
|
|
49
36
|
case "navigate": {
|
|
50
37
|
const url = (action.data as any)?.url;
|
|
51
38
|
if (!url) throw new Error("Navigate action missing URL");
|
|
52
|
-
|
|
39
|
+
// Resolve the raw URL (full URL, path, or native://screen) into a real
|
|
40
|
+
// route path so router.push actually moves the app — the friendly name
|
|
41
|
+
// is only used as a fallback for React Navigation named screens.
|
|
42
|
+
const target = routeTargetFromUrl(url);
|
|
43
|
+
const params = {
|
|
44
|
+
fromAutoFlow: true,
|
|
45
|
+
originalUrl: url,
|
|
46
|
+
screenName: target.name,
|
|
47
|
+
route: target.route,
|
|
48
|
+
};
|
|
53
49
|
if (doNavigate) {
|
|
54
|
-
doNavigate(
|
|
50
|
+
doNavigate(target.route, params);
|
|
55
51
|
return { success: true };
|
|
56
52
|
}
|
|
57
53
|
if (handlers?.push) {
|
|
58
|
-
handlers.push(
|
|
54
|
+
handlers.push(target.route, params);
|
|
59
55
|
return { success: true };
|
|
60
56
|
}
|
|
61
|
-
const
|
|
62
|
-
|
|
57
|
+
const before = getCurrentRoute();
|
|
58
|
+
const ok = defaultNavigate(target.route, target.name, params);
|
|
59
|
+
if (!ok) {
|
|
60
|
+
// No expo-router stack is available — the app may use tabs or a
|
|
61
|
+
// custom layout. Fall back to tapping a matching control on-screen
|
|
62
|
+
// (e.g. the "Settings" tab) so the bot's promise still lands.
|
|
63
|
+
const tapper =
|
|
64
|
+
findClickHandler(target.name) ||
|
|
65
|
+
findClickHandler((action.data as any)?.label) ||
|
|
66
|
+
findClickHandler((action.data as any)?.text);
|
|
67
|
+
if (tapper) {
|
|
68
|
+
tapper();
|
|
69
|
+
return { success: true };
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`No navigation handler for "${url}"`);
|
|
72
|
+
}
|
|
73
|
+
// Give expo-router a beat to move; if nothing changed, the route likely
|
|
74
|
+
// doesn't exist, so tap a same-named tab/button instead of reporting a
|
|
75
|
+
// success that never happened.
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
77
|
+
const after = getCurrentRoute();
|
|
78
|
+
if (after === before && after !== target.route) {
|
|
79
|
+
const tapper =
|
|
80
|
+
findClickHandler(target.name) ||
|
|
81
|
+
findClickHandler((action.data as any)?.label) ||
|
|
82
|
+
findClickHandler((action.data as any)?.text);
|
|
83
|
+
if (tapper) {
|
|
84
|
+
tapper();
|
|
85
|
+
return { success: true };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
63
88
|
return { success: true };
|
|
64
89
|
}
|
|
65
90
|
case "click": {
|
|
@@ -124,6 +149,7 @@ export async function executeActions(actions: WebotMeAction[]): Promise<void> {
|
|
|
124
149
|
*/
|
|
125
150
|
export function setupExecutor(options?: ExecutorOptions): () => void {
|
|
126
151
|
handlers = options ?? handlers ?? {};
|
|
152
|
+
if (currentUnsub) return currentUnsub;
|
|
127
153
|
const unsubscribe = subscribeChat(() => {
|
|
128
154
|
const { messages } = getChatState();
|
|
129
155
|
for (const message of messages) {
|
|
@@ -147,10 +173,18 @@ export function setupExecutor(options?: ExecutorOptions): () => void {
|
|
|
147
173
|
});
|
|
148
174
|
}
|
|
149
175
|
});
|
|
150
|
-
|
|
176
|
+
currentUnsub = () => {
|
|
177
|
+
unsubscribe();
|
|
178
|
+
currentUnsub = null;
|
|
179
|
+
};
|
|
180
|
+
return currentUnsub;
|
|
151
181
|
}
|
|
152
182
|
|
|
153
183
|
export function resetExecutorState() {
|
|
154
184
|
executedMessageIds.clear();
|
|
155
185
|
handlers = null;
|
|
186
|
+
if (currentUnsub) {
|
|
187
|
+
currentUnsub();
|
|
188
|
+
currentUnsub = null;
|
|
189
|
+
}
|
|
156
190
|
}
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ export {
|
|
|
35
35
|
export type { VoiceOptions, VoiceResult, VoiceState } from "./voice";
|
|
36
36
|
|
|
37
37
|
export { WebotMeError } from "./types";
|
|
38
|
+
export { ensureRuntime } from "./runtime";
|
|
38
39
|
export {
|
|
39
40
|
executeAction,
|
|
40
41
|
executeActions,
|
|
@@ -46,6 +47,8 @@ export {
|
|
|
46
47
|
getCurrentRoute,
|
|
47
48
|
goBack,
|
|
48
49
|
navigateTo,
|
|
50
|
+
pushRoute,
|
|
51
|
+
routeTargetFromUrl,
|
|
49
52
|
setCurrentRoute,
|
|
50
53
|
useNavigationAdapter,
|
|
51
54
|
} from "./navigationAdapter";
|
package/src/navigationAdapter.ts
CHANGED
|
@@ -11,15 +11,74 @@ export function setCurrentRoute(route: string): void {
|
|
|
11
11
|
lastRoute = route;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function toPascalName(route: string): string {
|
|
15
|
+
const trimmed = route.replace(/^\/+|\/+$/g, "");
|
|
16
|
+
if (!trimmed) return "Home";
|
|
17
|
+
const parts = trimmed.split("/").map((part) =>
|
|
18
|
+
part.charAt(0).toUpperCase() + part.slice(1),
|
|
19
|
+
);
|
|
20
|
+
return parts.join(":");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Turn any URL a server/AI returns into a navigation target that works with
|
|
25
|
+
* Expo Router (path-first) while keeping a friendly PascalCase name for apps
|
|
26
|
+
* using React Navigation named screens.
|
|
27
|
+
*
|
|
28
|
+
* - "https://host/book" -> { route: "/book", name: "Book" }
|
|
29
|
+
* - "/dashboard/settings" -> { route: "/dashboard/settings", name: "Dashboard:Settings" }
|
|
30
|
+
* - "native://Select Doctor" -> { route: "/select-doctor", name: "Select Doctor" }
|
|
31
|
+
* - "checkout" -> { route: "/checkout", name: "Checkout" }
|
|
32
|
+
*/
|
|
33
|
+
export function routeTargetFromUrl(input?: string | null): { route: string; name: string } {
|
|
34
|
+
let url = String(input ?? "").trim();
|
|
35
|
+
if (!url || url === "/") return { route: "/", name: "Home" };
|
|
36
|
+
|
|
37
|
+
// Native screen snapshots come in as native://<screen name>.
|
|
38
|
+
if (/^native:\/\//i.test(url)) {
|
|
39
|
+
const screenName = decodeURIComponent(url.slice("native://".length)) || "Home";
|
|
40
|
+
const slug = screenName
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/\s+/g, "-")
|
|
43
|
+
.replace(/[^a-z0-9\-_/]/g, "");
|
|
44
|
+
return { route: "/" + slug, name: screenName };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A full URL (from web scans) must become a bare path for expo-router.
|
|
48
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url) || url.startsWith("//")) {
|
|
49
|
+
try {
|
|
50
|
+
url = new URL(url).pathname || "/";
|
|
51
|
+
} catch {
|
|
52
|
+
url = url.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, "") || "/";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Strip query string and hash.
|
|
57
|
+
url = url.replace(/[?#].*$/, "");
|
|
58
|
+
url = "/" + url.replace(/^\/+/, "");
|
|
59
|
+
url = url.replace(/\/{2,}/g, "/");
|
|
60
|
+
|
|
61
|
+
return { route: url, name: toPascalName(url) };
|
|
62
|
+
}
|
|
63
|
+
|
|
14
64
|
/** Navigate imperatively using Expo Router's router. */
|
|
15
65
|
export function navigateTo(route: string, params?: Record<string, any>): void {
|
|
66
|
+
pushRoute(route, params);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Internal helper that reports whether a push actually happened. */
|
|
70
|
+
export function pushRoute(route: string, params?: Record<string, any>): boolean {
|
|
16
71
|
try {
|
|
72
|
+
const { route: target } = routeTargetFromUrl(route);
|
|
17
73
|
const router =
|
|
18
74
|
require("expo-router").default ?? require("expo-router").router;
|
|
19
|
-
|
|
20
|
-
|
|
75
|
+
const push = (router as any)?.push;
|
|
76
|
+
if (typeof push !== "function") return false;
|
|
77
|
+
if (params) push({ pathname: target as any, params } as any);
|
|
78
|
+
else push(target as any);
|
|
79
|
+
return true;
|
|
21
80
|
} catch {
|
|
22
|
-
|
|
81
|
+
return false;
|
|
23
82
|
}
|
|
24
83
|
}
|
|
25
84
|
|
package/src/provider.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import { registerScreenTree, setupFiberScanner } from "./screenScanner";
|
|
|
19
19
|
import { setupExecutor } from "./executor";
|
|
20
20
|
import { setupSafetyLayer } from "./safety";
|
|
21
21
|
import { useNavigationAdapter } from "./navigationAdapter";
|
|
22
|
+
import { configureVoice } from "./voice";
|
|
22
23
|
import type {
|
|
23
24
|
WebotMeConfig,
|
|
24
25
|
WebotMeContract,
|
|
@@ -165,6 +166,36 @@ export function WebotMeProvider({
|
|
|
165
166
|
};
|
|
166
167
|
}, [client, attempt]);
|
|
167
168
|
|
|
169
|
+
// Pull the bot's activation/shutdown voice phrases from the server and arm
|
|
170
|
+
// the native voice engine with them ("hello {botName}" wakes it up, the
|
|
171
|
+
// shutdown phrase turns voice off). Mirrors the web widget exactly.
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
let active = true;
|
|
174
|
+
client
|
|
175
|
+
.settings()
|
|
176
|
+
.then((raw: any) => {
|
|
177
|
+
if (!active) return;
|
|
178
|
+
const bot = raw?.chatbot ?? raw;
|
|
179
|
+
const botName = typeof bot?.name === "string" && bot.name.trim() ? bot.name.trim() : "Assistant";
|
|
180
|
+
const commands: string[] = Array.isArray(bot?.activation?.commands)
|
|
181
|
+
? bot.activation.commands
|
|
182
|
+
.filter((c: unknown): c is string => typeof c === "string" && c.trim().length > 0)
|
|
183
|
+
.map((c: string) => c.replace(/{botName}/gi, botName).trim())
|
|
184
|
+
: [];
|
|
185
|
+
const activateCommands = commands.filter((c) => !/shutdown|bye|stop|off/i.test(c));
|
|
186
|
+
const shutdownCommands = commands.filter((c) => /shutdown|bye|stop|off/i.test(c));
|
|
187
|
+
if (activateCommands.length > 0 || shutdownCommands.length > 0) {
|
|
188
|
+
configureVoice({ botName, activateCommands, shutdownCommands });
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
.catch(() => {
|
|
192
|
+
// Settings unavailable -> voice stays always-on, matching the fallback.
|
|
193
|
+
});
|
|
194
|
+
return () => {
|
|
195
|
+
active = false;
|
|
196
|
+
};
|
|
197
|
+
}, [client]);
|
|
198
|
+
|
|
168
199
|
// New session when the caller bumps resetKey.
|
|
169
200
|
useEffect(() => {
|
|
170
201
|
if (resetKey === undefined) return;
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-healing runtime: apps that chat through `chatWithUs` without mounting
|
|
3
|
+
* `<WebotMeProvider>` should still get screen scanning, action execution and
|
|
4
|
+
* the safety gate. Everything here is idempotent, so double-arming (provider +
|
|
5
|
+
* manual call) is harmless.
|
|
6
|
+
*
|
|
7
|
+
* Modules are required lazily on purpose — `runtime` is imported by `chat`,
|
|
8
|
+
* and `executor`/`scanner` import `chat`, so top-level imports would form a
|
|
9
|
+
* cycle at module load time.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
let executorArmed = false;
|
|
13
|
+
let scannerArmed = false;
|
|
14
|
+
let safetyArmed = false;
|
|
15
|
+
|
|
16
|
+
export function ensureRuntime(): void {
|
|
17
|
+
if (!executorArmed) {
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
19
|
+
const { setupExecutor } = require("./executor");
|
|
20
|
+
setupExecutor();
|
|
21
|
+
executorArmed = true;
|
|
22
|
+
}
|
|
23
|
+
if (!scannerArmed) {
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
25
|
+
const { setupFiberScanner } = require("./screenScanner");
|
|
26
|
+
setupFiberScanner({ intervalMs: 2500 });
|
|
27
|
+
scannerArmed = true;
|
|
28
|
+
}
|
|
29
|
+
if (!safetyArmed) {
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
31
|
+
const { setupSafetyLayer } = require("./safety");
|
|
32
|
+
setupSafetyLayer();
|
|
33
|
+
safetyArmed = true;
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/screenScanner.tsx
CHANGED
|
@@ -344,8 +344,14 @@ export function setupFiberScanner(options?: {
|
|
|
344
344
|
intervalMs?: number;
|
|
345
345
|
onScan?: (ctx: WebotMeScreenContext) => void;
|
|
346
346
|
}): () => void {
|
|
347
|
-
stopFiberScanner();
|
|
348
347
|
const interval = options?.intervalMs ?? 2000;
|
|
348
|
+
// Idempotent: if a scanner is already running, just refresh the snapshot and
|
|
349
|
+
// keep the single timer (avoids duplicate intervals when the provider arms
|
|
350
|
+
// after an app already called chatWithUs/configureChat).
|
|
351
|
+
if (scanTimer) {
|
|
352
|
+
scanScreen();
|
|
353
|
+
return stopFiberScanner;
|
|
354
|
+
}
|
|
349
355
|
scanScreen();
|
|
350
356
|
scanTimer = setInterval(() => {
|
|
351
357
|
const ctx = scanScreen();
|
package/src/voice.ts
CHANGED
|
@@ -33,23 +33,22 @@ export type VoiceState =
|
|
|
33
33
|
export type VoiceOptions = {
|
|
34
34
|
/** BCP-47 language for both recognition and speech. Default "en-US". */
|
|
35
35
|
lang?: string;
|
|
36
|
-
/** Speaking speed, 0.5–2. Default 0.9 (same pace the web widget uses). */
|
|
37
36
|
rate?: number;
|
|
38
|
-
/** Speaking pitch, 0.5–2. Default 1. */
|
|
39
37
|
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
38
|
speakAllReplies?: boolean;
|
|
45
|
-
/** Silence in ms that finalises the spoken query. Default 2200. */
|
|
46
39
|
silenceTimeout?: number;
|
|
47
40
|
onStateChange?: (state: VoiceState) => void;
|
|
48
|
-
/** Interim transcript, updated live while the user speaks. */
|
|
49
41
|
onPartial?: (text: string) => void;
|
|
50
|
-
/** Final transcript once the user stops talking. */
|
|
51
42
|
onUserQuery?: (text: string) => void;
|
|
52
43
|
onError?: (message: string) => void;
|
|
44
|
+
/** Wake words that enable the bot once recognised (e.g. ["hello botName"]). */
|
|
45
|
+
activateCommands?: string[];
|
|
46
|
+
/** Shuts down voice once recognised (e.g. ["off botName"]). */
|
|
47
|
+
shutdownCommands?: string[];
|
|
48
|
+
/** Name used to substitute {botName} in the activation/shutdown phrases. */
|
|
49
|
+
botName?: string;
|
|
50
|
+
onActivated?: (transcript: string) => void;
|
|
51
|
+
onDeactivated?: (transcript: string) => void;
|
|
53
52
|
};
|
|
54
53
|
|
|
55
54
|
/** What `voiceWithUs` hands back, mirroring `ChatResult`. */
|
|
@@ -76,6 +75,14 @@ type SpeechLike = {
|
|
|
76
75
|
|
|
77
76
|
type SpeechEvent = { results?: Array<{ transcript?: string } | string> };
|
|
78
77
|
|
|
78
|
+
function normalizeTranscript(v: unknown): string {
|
|
79
|
+
return String(v ?? "")
|
|
80
|
+
.toLowerCase()
|
|
81
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
82
|
+
.replace(/\s+/g, " ")
|
|
83
|
+
.trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
79
86
|
type SpeechRecognitionLike = {
|
|
80
87
|
start: (options?: Record<string, unknown>) => Promise<void> | void;
|
|
81
88
|
stop: () => Promise<void> | void;
|
|
@@ -191,6 +198,8 @@ class VoiceEngine {
|
|
|
191
198
|
private speaking = false;
|
|
192
199
|
private continuous = false;
|
|
193
200
|
private loopStopped = false;
|
|
201
|
+
private activated = true;
|
|
202
|
+
private commandsConfigured = false;
|
|
194
203
|
|
|
195
204
|
constructor() {
|
|
196
205
|
this.state = {
|
|
@@ -227,6 +236,16 @@ class VoiceEngine {
|
|
|
227
236
|
if (typeof options.speakAllReplies === "boolean") {
|
|
228
237
|
this.patch({ voiceReplies: options.speakAllReplies });
|
|
229
238
|
}
|
|
239
|
+
if (Array.isArray(options.activateCommands) && options.activateCommands.length > 0) {
|
|
240
|
+
// Lock voice until an activation phrase is heard, but only the first time
|
|
241
|
+
// the commands are applied so re-configuring never re-locks a live bot.
|
|
242
|
+
if (!this.commandsConfigured) this.activated = false;
|
|
243
|
+
this.commandsConfigured = true;
|
|
244
|
+
} else if (options.activateCommands !== undefined) {
|
|
245
|
+
// Commands were explicitly cleared -> fall back to always-available voice.
|
|
246
|
+
this.commandsConfigured = false;
|
|
247
|
+
this.activated = true;
|
|
248
|
+
}
|
|
230
249
|
this.patch({ supported: this.isSupported() });
|
|
231
250
|
}
|
|
232
251
|
|
|
@@ -363,10 +382,110 @@ class VoiceEngine {
|
|
|
363
382
|
}
|
|
364
383
|
}
|
|
365
384
|
|
|
385
|
+
/** [a-z0-9] tokens that make up the bot's name (used for phrase matching). */
|
|
386
|
+
private static botNameTokens(botName: string): string[] {
|
|
387
|
+
return String(botName || "assistant")
|
|
388
|
+
.toLowerCase()
|
|
389
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
390
|
+
.split(/\s+/)
|
|
391
|
+
.filter(Boolean);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Remove every mention of the bot's name from a lowercase phrase. */
|
|
395
|
+
private static stripName(text: string, botName: string): string {
|
|
396
|
+
const tokens = VoiceEngine.botNameTokens(botName);
|
|
397
|
+
if (tokens.length === 0) return text;
|
|
398
|
+
let clean = " " + (text || "").toLowerCase() + " ";
|
|
399
|
+
for (const token of tokens) {
|
|
400
|
+
if (!token) continue;
|
|
401
|
+
clean = clean.replace(
|
|
402
|
+
new RegExp(`(^|[^a-z0-9])${token}(?=$|[^a-z0-9])`, "g"),
|
|
403
|
+
"$1 ",
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
return clean.replace(/\s+/g, " ").trim();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* True when the spoken phrase matches one of the commands. The bot name is
|
|
411
|
+
* matched both inside the phrase and without it, so "hello rover", "hello",
|
|
412
|
+
* and "hey rover" all activate when the command is "hello {botName}".
|
|
413
|
+
*/
|
|
414
|
+
private static matchesAny(
|
|
415
|
+
normalized: string,
|
|
416
|
+
commands: string[] | undefined,
|
|
417
|
+
botName: string,
|
|
418
|
+
): boolean {
|
|
419
|
+
if (!Array.isArray(commands) || commands.length === 0) return false;
|
|
420
|
+
const stripped = VoiceEngine.stripName(normalized, botName);
|
|
421
|
+
return commands.some((cmd) => {
|
|
422
|
+
const raw = cmd
|
|
423
|
+
.toLowerCase()
|
|
424
|
+
.replace(/{botname}/g, botName.toLowerCase())
|
|
425
|
+
.replace(/\s+/g, " ")
|
|
426
|
+
.trim();
|
|
427
|
+
if (!raw) return false;
|
|
428
|
+
if (normalized.includes(raw)) return true;
|
|
429
|
+
const rawNoName = VoiceEngine.stripName(raw, botName);
|
|
430
|
+
if (!rawNoName) return false;
|
|
431
|
+
return stripped.includes(rawNoName) || stripped === rawNoName;
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
366
435
|
private async askBot(transcript: string) {
|
|
436
|
+
const normalized = transcript.toLowerCase().replace(/[^a-z0-9\s]/gi, "").trim();
|
|
437
|
+
const botName = this.options.botName || "assistant";
|
|
438
|
+
const wantsActivation = VoiceEngine.matchesAny(normalized, this.options.activateCommands, botName);
|
|
439
|
+
const wantsShutdown = VoiceEngine.matchesAny(normalized, this.options.shutdownCommands, botName);
|
|
440
|
+
|
|
441
|
+
if (wantsShutdown) {
|
|
442
|
+
this.activated = false;
|
|
443
|
+
this.options.onDeactivated?.(transcript);
|
|
444
|
+
this.continuous = false;
|
|
445
|
+
this.loopStopped = true;
|
|
446
|
+
this.stop();
|
|
447
|
+
this.finishSession({
|
|
448
|
+
ok: true,
|
|
449
|
+
transcript,
|
|
450
|
+
reply: "",
|
|
451
|
+
userMessage: null,
|
|
452
|
+
botMessage: null,
|
|
453
|
+
spoken: false,
|
|
454
|
+
});
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (this.options.activateCommands && this.options.activateCommands.length > 0) {
|
|
459
|
+
if (!this.activated) {
|
|
460
|
+
if (wantsActivation) {
|
|
461
|
+
this.activated = true;
|
|
462
|
+
this.options.onActivated?.(transcript);
|
|
463
|
+
} else {
|
|
464
|
+
// Ignore everything before activation; start loop again if continuous.
|
|
465
|
+
if (this.continuous && !this.loopStopped) {
|
|
466
|
+
this.continueLoop();
|
|
467
|
+
} else {
|
|
468
|
+
this.finishSession({
|
|
469
|
+
ok: true,
|
|
470
|
+
transcript,
|
|
471
|
+
reply: "",
|
|
472
|
+
userMessage: null,
|
|
473
|
+
botMessage: null,
|
|
474
|
+
spoken: false,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Send the phrase without the bare bot-name mention (like the web engine),
|
|
483
|
+
// so "hello rover" reaches the bot as just "hello".
|
|
484
|
+
const forwardText = VoiceEngine.stripName(transcript, botName) || transcript;
|
|
485
|
+
|
|
367
486
|
let result: ChatResult;
|
|
368
487
|
try {
|
|
369
|
-
result = await chatWithUs(
|
|
488
|
+
result = await chatWithUs(forwardText);
|
|
370
489
|
} catch (err) {
|
|
371
490
|
// chatWithUs already reports its own failures, so this is a real crash.
|
|
372
491
|
this.fail(
|
|
@@ -460,7 +579,7 @@ class VoiceEngine {
|
|
|
460
579
|
if (this.state.state === "speaking") this.patch({ state: "idle" });
|
|
461
580
|
}
|
|
462
581
|
|
|
463
|
-
/** Stop listening and speaking
|
|
582
|
+
/** Stop listening and speaking; a pending `voiceWithUs()` promise resolves. */
|
|
464
583
|
stop() {
|
|
465
584
|
if (this.settleTimer) {
|
|
466
585
|
clearTimeout(this.settleTimer);
|
|
@@ -475,6 +594,17 @@ class VoiceEngine {
|
|
|
475
594
|
if (this.state.state === "listening" || this.state.state === "thinking" || this.state.state === "speaking") {
|
|
476
595
|
this.patch({ state: "idle" });
|
|
477
596
|
}
|
|
597
|
+
// Never leave a caller hanging on `await voiceWithUs()`.
|
|
598
|
+
if (this.resolveSession) {
|
|
599
|
+
this.finishSession({
|
|
600
|
+
ok: true,
|
|
601
|
+
transcript: this.transcript.trim(),
|
|
602
|
+
reply: "",
|
|
603
|
+
userMessage: null,
|
|
604
|
+
botMessage: null,
|
|
605
|
+
spoken: false,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
478
608
|
}
|
|
479
609
|
|
|
480
610
|
/** A recogniser failure mid-session: report it and go back to idle. */
|