@webotme/react-native 0.1.1 → 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/README.md CHANGED
@@ -149,6 +149,81 @@ const { text, action } = await client.send({
149
149
  });
150
150
  ```
151
151
 
152
+ ## Voice
153
+
154
+ `voiceWithUs()` is the voice equivalent of `chatWithUs()` — one call, ask out
155
+ loud, hear the answer:
156
+
157
+ ```tsx
158
+ import { voiceWithUs } from "@webotme/react-native";
159
+
160
+ const res = await voiceWithUs(); // tap the mic
161
+ // user speaks -> device transcribes -> bot replies -> device speaks the reply
162
+ console.log(res.transcript, "->", res.reply);
163
+ ```
164
+
165
+ Just like the web widget, **everything runs on the device**. The phone's own
166
+ recogniser transcribes the question and the phone's own synthesiser speaks the
167
+ answer, so your server only ever sees plain text. No server change, no API keys,
168
+ and no audio ever reaches your backend.
169
+
170
+ ### Install
171
+
172
+ ```bash
173
+ npx expo install expo-speech expo-speech-recognition
174
+ ```
175
+
176
+ Then add the permission strings to `app.json`:
177
+
178
+ ```json
179
+ {
180
+ "expo": {
181
+ "plugins": [
182
+ [
183
+ "expo-speech-recognition",
184
+ {
185
+ "microphonePermission": "Allow Rover to hear you.",
186
+ "speechRecognitionPermission": "Allow Rover to turn your speech into text."
187
+ }
188
+ ]
189
+ ]
190
+ }
191
+ }
192
+ ```
193
+
194
+ Both packages are **optional peer dependencies**, resolved on first use. An app
195
+ that only sends text never loads them and never breaks. Speech recognition is
196
+ native code, so it needs a development build — `npx expo run:android` or
197
+ `npx expo run:ios` — not Expo Go. `expo-speech` alone also needs a rebuild.
198
+
199
+ ### Options
200
+
201
+ ```tsx
202
+ configureVoice({
203
+ lang: "en-US", // BCP-47, used for both listening and speaking
204
+ rate: 0.9, // web widget uses the same friendly pace
205
+ speakAllReplies: false, // true = read every reply, not just spoken ones
206
+ onPartial: (text) => setText(text), // live transcript while talking
207
+ onUserQuery: (text) => console.log(text),
208
+ onError: (msg) => alert(msg),
209
+ });
210
+ ```
211
+
212
+ ### UI state
213
+
214
+ `useVoiceChat()` mirrors `useChat()`:
215
+
216
+ ```tsx
217
+ import { useVoiceChat } from "@webotme/react-native";
218
+
219
+ const { state, transcript, listening, speaking, start, stop } = useVoiceChat();
220
+ // state: "idle" | "listening" | "thinking" | "speaking" | "unsupported" | "denied"
221
+ ```
222
+
223
+ Other helpers: `speakWithUs(text)` to read any line aloud, `stopVoice()` to stop
224
+ listening and talking, `setVoiceReplies(on)` to toggle replies, and
225
+ `isVoiceSupported()` to feature-detect.
226
+
152
227
  ## Errors
153
228
 
154
229
  Every failure is a `WebotMeError` with a stable `code`:
@@ -184,6 +259,10 @@ your own backend and use `POST /api/sdk/chat`.
184
259
  | New session | `POST /api/chatbot/public/{botId}/session/new` |
185
260
  | Web chat page | `GET {chatBaseUrl}/chat/{botId}` |
186
261
 
262
+ Voice uses **none** of these. `voiceWithUs()` transcribes and speaks on the
263
+ device, so `/native-chat/v1/stt` and `/native-chat/v1/tts` are never called —
264
+ the same way the web widget runs on the browser's own speech APIs.
265
+
187
266
  ## License
188
267
 
189
268
  UNLICENSED
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webotme/react-native",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Drop-in AI chatbot for React Native and Expo apps. Same bot, training and AutoFlow as the WebotMe web widget.",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -11,12 +11,18 @@
11
11
  "scripts": {
12
12
  "build": "tsc --noEmit",
13
13
  "prepublishOnly": "npm run build",
14
- "typecheck": "tsc --noEmit"
14
+ "typecheck": "tsc --noEmit",
15
+ "test:voice": "tsc -p tsconfig.smoke.json && node .smoke-build/voice.smoke.js && node .smoke-build/voice-nomodules.smoke.js",
16
+ "test": "npm run typecheck"
15
17
  },
16
18
  "keywords": [
17
19
  "chatbot",
18
20
  "ai",
19
21
  "chat",
22
+ "voice",
23
+ "speech",
24
+ "text-to-speech",
25
+ "speech-recognition",
20
26
  "react-native",
21
27
  "expo",
22
28
  "webotme",
@@ -24,11 +30,19 @@
24
30
  ],
25
31
  "license": "UNLICENSED",
26
32
  "peerDependencies": {
33
+ "expo-speech": ">=13",
34
+ "expo-speech-recognition": ">=3",
27
35
  "react": ">=18",
28
36
  "react-native": ">=0.72",
29
37
  "react-native-webview": ">=13"
30
38
  },
31
39
  "peerDependenciesMeta": {
40
+ "expo-speech": {
41
+ "optional": true
42
+ },
43
+ "expo-speech-recognition": {
44
+ "optional": true
45
+ },
32
46
  "react-native-webview": {
33
47
  "optional": true
34
48
  }
@@ -39,8 +53,5 @@
39
53
  "react-native": "^0.76.9",
40
54
  "react-native-webview": "^13.17.0",
41
55
  "typescript": "^5.9.3"
42
- },
43
- "dependencies": {
44
- "expo-av": "^16.0.8"
45
56
  }
46
57
  }
@@ -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
- // Try to match with known app routes first
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] || url.replace(/^\//, "").replace(/\//g, "_") || "Home";
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(routeName, { fromAutoFlow: true, originalUrl: url });
124
+ push(target.route, params);
114
125
  } else {
115
- navigate(routeName, { fromAutoFlow: true, originalUrl: url });
126
+ navigate(target.route, params);
116
127
  }
117
-
128
+
118
129
  return { success: true };
119
130
  }
120
131
 
package/src/chat.ts ADDED
@@ -0,0 +1,371 @@
1
+ /**
2
+ * One-function chat for apps that already have their own UI.
3
+ *
4
+ * The provider owns the session when it is mounted, so `chatWithUs` works with
5
+ * no extra setup. Apps that skip the provider can call `configureChat` once.
6
+ */
7
+ import { useEffect, useState } from "react";
8
+ import { WebotMeClient } from "./client";
9
+ import { ensureRuntime } from "./runtime";
10
+ import {
11
+ WebotMeError,
12
+ type WebotMeAction,
13
+ type WebotMeAutoTask,
14
+ type WebotMeConfig,
15
+ type WebotMeMessage,
16
+ type WebotMePendingWorkflow,
17
+ type WebotMeScreenContext,
18
+ } from "./types";
19
+
20
+ const makeId = (prefix: string) =>
21
+ `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
22
+
23
+ type Listener = () => void;
24
+
25
+ type ChatState = {
26
+ sessionId: string;
27
+ messages: WebotMeMessage[];
28
+ sending: boolean;
29
+ error: string | null;
30
+ pendingWorkflow: WebotMePendingWorkflow | null;
31
+ autoTask: WebotMeAutoTask | null;
32
+ };
33
+
34
+ /** What `chatWithUs` hands back so a custom UI can react without a hook. */
35
+ export type ChatResult = {
36
+ ok: boolean;
37
+ /** The bot's reply, or an empty string when the send failed. */
38
+ reply: string;
39
+ /** The user's message plus the bot's reply, ready to render. */
40
+ userMessage: WebotMeMessage | null;
41
+ botMessage: WebotMeMessage | null;
42
+ action?: WebotMeAction;
43
+ actions?: WebotMeAction[];
44
+ pendingWorkflow?: WebotMePendingWorkflow;
45
+ autoTask?: WebotMeAutoTask;
46
+ error?: string;
47
+ };
48
+
49
+ class ChatEngine {
50
+ private client: WebotMeClient | null = null;
51
+ private userId = "";
52
+ private sessionId = makeId("s");
53
+ private screenContext: WebotMeScreenContext | undefined;
54
+ private listeners = new Set<Listener>();
55
+ private inflight = false;
56
+ private state: ChatState;
57
+
58
+ constructor() {
59
+ this.state = {
60
+ sessionId: this.sessionId,
61
+ messages: [],
62
+ sending: false,
63
+ error: null,
64
+ pendingWorkflow: null,
65
+ autoTask: null,
66
+ };
67
+ }
68
+
69
+ configure(config: WebotMeConfig) {
70
+ ensureRuntime();
71
+ const nextClient = new WebotMeClient({
72
+ botId: config.botId,
73
+ apiBaseUrl: config.apiBaseUrl,
74
+ timeoutMs: config.timeoutMs,
75
+ });
76
+ const changedBot = !this.client || this.client.botId !== config.botId;
77
+ this.client = nextClient;
78
+ if (config.userId) this.userId = config.userId;
79
+ if (!this.userId) this.userId = makeId("v");
80
+ this.screenContext = config.screenContext;
81
+ if (changedBot) this.reset();
82
+ if (config.welcome && this.state.messages.length === 0) {
83
+ this.patch({
84
+ messages: [
85
+ { id: makeId("m"), from: "bot", text: config.welcome, createdAt: Date.now() },
86
+ ],
87
+ });
88
+ } else {
89
+ this.emit();
90
+ }
91
+ }
92
+
93
+ /** Keeps the live screen context in sync without re-creating the client. */
94
+ updateScreenContext(screenContext: WebotMeScreenContext | undefined) {
95
+ this.screenContext = screenContext;
96
+ }
97
+
98
+ private patch(next: Partial<ChatState>) {
99
+ this.state = { ...this.state, ...next };
100
+ this.emit();
101
+ }
102
+
103
+ private emit() {
104
+ for (const listener of this.listeners) listener();
105
+ }
106
+
107
+ subscribe(listener: Listener) {
108
+ this.listeners.add(listener);
109
+ return () => {
110
+ this.listeners.delete(listener);
111
+ };
112
+ }
113
+
114
+ getState(): ChatState {
115
+ return this.state;
116
+ }
117
+
118
+ reset() {
119
+ this.sessionId = makeId("s");
120
+ this.patch({
121
+ sessionId: this.sessionId,
122
+ messages: [],
123
+ sending: false,
124
+ error: null,
125
+ pendingWorkflow: null,
126
+ autoTask: null,
127
+ });
128
+ }
129
+
130
+ isReady() {
131
+ return this.client !== null;
132
+ }
133
+
134
+ getSessionId() {
135
+ return this.sessionId;
136
+ }
137
+
138
+ async send(text: string): Promise<ChatResult> {
139
+ const trimmed = text.trim();
140
+ if (!trimmed) {
141
+ return { ok: false, reply: "", userMessage: null, botMessage: null, error: "Message is empty" };
142
+ }
143
+ if (!this.client) {
144
+ const error = "Chat is not configured. Wrap the app in <WebotMeProvider> or call configureChat() first.";
145
+ this.patch({ error });
146
+ return { ok: false, reply: "", userMessage: null, botMessage: null, error };
147
+ }
148
+ if (this.inflight) {
149
+ const error = "A message is already being sent.";
150
+ return { ok: false, reply: "", userMessage: null, botMessage: null, error };
151
+ }
152
+
153
+ const client = this.client;
154
+ const sessionId = this.sessionId;
155
+ const userId = this.userId;
156
+
157
+ const userMessage: WebotMeMessage = {
158
+ id: makeId("m"),
159
+ from: "user",
160
+ text: trimmed,
161
+ createdAt: Date.now(),
162
+ };
163
+ const pendingId = makeId("m");
164
+
165
+ this.inflight = true;
166
+ this.patch({
167
+ messages: [
168
+ ...this.state.messages,
169
+ userMessage,
170
+ { id: pendingId, from: "bot", text: "", createdAt: Date.now(), pending: true },
171
+ ],
172
+ sending: true,
173
+ error: null,
174
+ });
175
+
176
+ try {
177
+ const result = await client.send({
178
+ message: trimmed,
179
+ sessionId,
180
+ userId,
181
+ screenContext: this.screenContext,
182
+ autoTask: this.state.autoTask ?? undefined,
183
+ });
184
+
185
+ const botMessage: WebotMeMessage = {
186
+ id: pendingId,
187
+ from: "bot",
188
+ text: result.text || result.reply || "",
189
+ createdAt: Date.now(),
190
+ action: result.actions?.[0] ?? result.action,
191
+ actions: result.actions,
192
+ };
193
+
194
+ this.patch({
195
+ messages: this.state.messages.map((m) => (m.id === pendingId ? botMessage : m)),
196
+ sending: false,
197
+ error: null,
198
+ pendingWorkflow: result.pendingWorkflow ?? null,
199
+ autoTask: result.autoTask ?? null,
200
+ });
201
+
202
+ return {
203
+ ok: true,
204
+ reply: botMessage.text,
205
+ userMessage,
206
+ botMessage,
207
+ action: botMessage.action,
208
+ actions: result.actions,
209
+ pendingWorkflow: result.pendingWorkflow,
210
+ autoTask: result.autoTask,
211
+ };
212
+ } catch (err) {
213
+ const error =
214
+ err instanceof WebotMeError || err instanceof Error
215
+ ? err.message
216
+ : "Something went wrong";
217
+ this.patch({
218
+ messages: this.state.messages.filter((m) => m.id !== pendingId),
219
+ sending: false,
220
+ error,
221
+ });
222
+ return { ok: false, reply: "", userMessage, botMessage: null, error };
223
+ } finally {
224
+ this.inflight = false;
225
+ }
226
+ }
227
+
228
+ /** Push the current live screen snapshot to the autonomous AutoFlow loop. */
229
+ async sendStep(): Promise<ChatResult> {
230
+ if (!this.client) {
231
+ const error = "Chat is not configured. Wrap the app in <WebotMeProvider> or call configureChat() first.";
232
+ this.patch({ error });
233
+ return { ok: false, reply: "", userMessage: null, botMessage: null, error };
234
+ }
235
+ if (!this.state.autoTask || !this.state.autoTask.goal) {
236
+ return { ok: false, reply: "", userMessage: null, botMessage: null, error: "No AutoFlow task to advance." };
237
+ }
238
+ if (this.inflight) {
239
+ return { ok: false, reply: "", userMessage: null, botMessage: null, error: "A step is already in flight." };
240
+ }
241
+ const pendingId = makeId("m");
242
+ this.inflight = true;
243
+ this.patch({ sending: true, error: null });
244
+ try {
245
+ const result = await this.client.send({
246
+ message: "",
247
+ sessionId: this.sessionId,
248
+ userId: this.userId,
249
+ screenContext: this.screenContext,
250
+ autoTask: this.state.autoTask as any,
251
+ });
252
+ const botMessage: WebotMeMessage = {
253
+ id: pendingId,
254
+ from: "bot",
255
+ text: result.text || result.reply || "",
256
+ createdAt: Date.now(),
257
+ action: result.actions?.[0] ?? result.action,
258
+ actions: result.actions,
259
+ };
260
+ this.patch({
261
+ messages: [
262
+ ...this.state.messages,
263
+ botMessage,
264
+ ],
265
+ sending: false,
266
+ error: null,
267
+ pendingWorkflow: result.pendingWorkflow ?? null,
268
+ autoTask: result.autoTask ?? this.state.autoTask,
269
+ });
270
+ return {
271
+ ok: true,
272
+ reply: botMessage.text,
273
+ userMessage: null,
274
+ botMessage,
275
+ action: botMessage.action,
276
+ actions: result.actions,
277
+ pendingWorkflow: result.pendingWorkflow,
278
+ autoTask: result.autoTask,
279
+ };
280
+ } catch (err) {
281
+ const error =
282
+ err instanceof WebotMeError || err instanceof Error ? err.message : "Something went wrong";
283
+ this.patch({ sending: false, error });
284
+ return { ok: false, reply: "", userMessage: null, botMessage: null, error };
285
+ } finally {
286
+ this.inflight = false;
287
+ }
288
+ }
289
+ }
290
+
291
+ const engine = new ChatEngine();
292
+
293
+ /**
294
+ * Point the one-function chat at a bot. Only needed when the app does not use
295
+ * `WebotMeProvider`, which configures the engine automatically.
296
+ */
297
+ export function configureChat(config: WebotMeConfig) {
298
+ engine.configure(config);
299
+ }
300
+
301
+ /**
302
+ * Keeps `chatWithUs` sending an up-to-date screen snapshot. The provider calls
303
+ * this for you; only touch it when driving the engine manually.
304
+ */
305
+ export function updateChatScreenContext(screenContext: WebotMeScreenContext | undefined) {
306
+ engine.updateScreenContext(screenContext);
307
+ }
308
+
309
+ /** The id the next message will be sent under. */
310
+ export function getChatSessionId() {
311
+ return engine.getSessionId();
312
+ }
313
+
314
+ /** Subscribe to chat engine state (used by executor/observer utilities). */
315
+ export function subscribeChat(listener: () => void): () => void {
316
+ return engine.subscribe(listener);
317
+ }
318
+
319
+ /** Current chat state snapshot. */
320
+ export function getChatState() {
321
+ return engine.getState();
322
+ }
323
+
324
+ /**
325
+ * Send the next AutoFlow step (empty message) to advance the server's autonomous
326
+ * task loop. Provider/executor wires this up internally.
327
+ */
328
+ export async function nextAutoFlowStep(): Promise<ChatResult> {
329
+ return engine.sendStep();
330
+ }
331
+
332
+ /**
333
+ * Send one message to the bot and wait for its reply.
334
+ *
335
+ * ```tsx
336
+ * <TouchableOpacity
337
+ * onPress={async () => {
338
+ * const res = await chatWithUs(text);
339
+ * if (res.ok) setText("");
340
+ * }}
341
+ * />
342
+ * ```
343
+ */
344
+ export async function chatWithUs(text: string): Promise<ChatResult> {
345
+ return engine.send(text);
346
+ }
347
+
348
+ /** Clear the conversation and start a fresh session. */
349
+ export function resetChat() {
350
+ engine.reset();
351
+ }
352
+
353
+ /**
354
+ * Live chat state for rendering. Optional: use it to show the transcript, and
355
+ * keep `chatWithUs` for sending.
356
+ */
357
+ export function useChat() {
358
+ const [state, setState] = useState<ChatState>(() => engine.getState());
359
+
360
+ useEffect(() => {
361
+ setState(engine.getState());
362
+ return engine.subscribe(() => setState(engine.getState()));
363
+ }, []);
364
+
365
+ return {
366
+ ...state,
367
+ send: chatWithUs,
368
+ reset: resetChat,
369
+ ready: engine.isReady(),
370
+ };
371
+ }
package/src/client.ts CHANGED
@@ -31,7 +31,8 @@ export type SendResult = {
31
31
  */
32
32
  export class WebotMeClient {
33
33
  private apiBaseUrl: string;
34
- private botId: string;
34
+ /** Public so a host app can tell which bot an instance is bound to. */
35
+ readonly botId: string;
35
36
  private timeoutMs: number;
36
37
 
37
38
  constructor(config: Pick<WebotMeConfig, "botId" | "apiBaseUrl" | "timeoutMs">) {
@@ -112,6 +113,7 @@ export class WebotMeClient {
112
113
  sessionId: string;
113
114
  userId: string;
114
115
  screenContext?: WebotMeScreenContext;
116
+ autoTask?: WebotMeAutoTask;
115
117
  }): Promise<SendResult> {
116
118
  const body = await this.request<Record<string, unknown>>("/native-chat/v1", {
117
119
  method: "POST",
@@ -120,6 +122,7 @@ export class WebotMeClient {
120
122
  sessionId: params.sessionId,
121
123
  userId: params.userId,
122
124
  ...(params.screenContext ? { screenContext: params.screenContext } : {}),
125
+ ...(params.autoTask ? { autoTask: params.autoTask } : {}),
123
126
  }),
124
127
  });
125
128