@myscheme/voice-navigation-sdk 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/README.md +359 -0
- package/dist/actions.d.ts +8 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +478 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +156 -0
- package/dist/microphone-handler.d.ts +47 -0
- package/dist/microphone-handler.d.ts.map +1 -0
- package/dist/microphone-handler.js +341 -0
- package/dist/navigation-controller.d.ts +50 -0
- package/dist/navigation-controller.d.ts.map +1 -0
- package/dist/navigation-controller.js +782 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/opensearch-handler.d.ts +52 -0
- package/dist/server/opensearch-handler.d.ts.map +1 -0
- package/dist/server/opensearch-handler.js +279 -0
- package/dist/services/azure-speech.d.ts +13 -0
- package/dist/services/azure-speech.d.ts.map +1 -0
- package/dist/services/azure-speech.js +33 -0
- package/dist/services/bedrock.d.ts +18 -0
- package/dist/services/bedrock.d.ts.map +1 -0
- package/dist/services/bedrock.js +132 -0
- package/dist/services/schemes.d.ts +2 -0
- package/dist/services/schemes.d.ts.map +1 -0
- package/dist/services/schemes.js +1 -0
- package/dist/services/vector-search.d.ts +21 -0
- package/dist/services/vector-search.d.ts.map +1 -0
- package/dist/services/vector-search.js +181 -0
- package/dist/types.d.ts +107 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/ui.d.ts +10 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +225 -0
- package/package.json +55 -0
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
import { MicrophoneHandler } from "./microphone-handler.js";
|
|
2
|
+
import { AzureSpeechService } from "./services/azure-speech.js";
|
|
3
|
+
import { BedrockService } from "./services/bedrock.js";
|
|
4
|
+
import { VectorSearchService, } from "./services/vector-search.js";
|
|
5
|
+
import { performAgentAction, formatActionLabel, extractAgentAction, } from "./actions.js";
|
|
6
|
+
import { createFloatingControl, setState, updateStatus, updateTranscript, updateActionIndicator, showError, emitEvent, } from "./ui.js";
|
|
7
|
+
const AUTO_START_STORAGE_KEY = "__navigate_auto_start";
|
|
8
|
+
const RESUME_STATE_STORAGE_KEY = "__navigate_resume_state";
|
|
9
|
+
const flowLog = (...args) => {
|
|
10
|
+
if (typeof console !== "undefined" && typeof console.info === "function") {
|
|
11
|
+
console.info("[VoiceNavigation]", ...args);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const clipText = (value, limit = 120) => {
|
|
15
|
+
const trimmed = value.trim();
|
|
16
|
+
if (trimmed.length <= limit) {
|
|
17
|
+
return trimmed;
|
|
18
|
+
}
|
|
19
|
+
return `${trimmed.slice(0, limit)}...`;
|
|
20
|
+
};
|
|
21
|
+
const resolveOpenSearchConfig = (config) => {
|
|
22
|
+
const aliasConfig = config.openSearch;
|
|
23
|
+
const resolved = config.opensearch ?? aliasConfig;
|
|
24
|
+
if (!resolved || typeof resolved !== "object") {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return resolved;
|
|
28
|
+
};
|
|
29
|
+
export class VoiceNavigationController {
|
|
30
|
+
constructor(config) {
|
|
31
|
+
this.lastTranscript = "";
|
|
32
|
+
this.isProcessing = false;
|
|
33
|
+
this.resumeAfterNavigation = false;
|
|
34
|
+
this.lastStartReason = "user";
|
|
35
|
+
this.autoStartPendingUserInteraction = false;
|
|
36
|
+
this.removeAutoStartListeners = null;
|
|
37
|
+
this.vectorSearchService = null;
|
|
38
|
+
const openSearchConfig = resolveOpenSearchConfig(config);
|
|
39
|
+
this.config = {
|
|
40
|
+
...config,
|
|
41
|
+
opensearch: openSearchConfig,
|
|
42
|
+
};
|
|
43
|
+
if (!this.config.azure?.subscriptionKey || !this.config.azure?.region) {
|
|
44
|
+
throw new Error("Azure Speech configuration is required");
|
|
45
|
+
}
|
|
46
|
+
if (!this.config.aws?.accessKeyId ||
|
|
47
|
+
!this.config.aws?.secretAccessKey ||
|
|
48
|
+
!this.config.aws?.modelId) {
|
|
49
|
+
throw new Error("AWS Bedrock configuration is required");
|
|
50
|
+
}
|
|
51
|
+
this.azureSpeechService = new AzureSpeechService({
|
|
52
|
+
subscriptionKey: this.config.azure.subscriptionKey,
|
|
53
|
+
region: this.config.azure.region,
|
|
54
|
+
});
|
|
55
|
+
this.bedrockService = new BedrockService({
|
|
56
|
+
region: this.config.aws.region || "ap-south-1",
|
|
57
|
+
accessKeyId: this.config.aws.accessKeyId,
|
|
58
|
+
secretAccessKey: this.config.aws.secretAccessKey,
|
|
59
|
+
modelId: this.config.aws.modelId,
|
|
60
|
+
embeddingModelId: this.config.aws.embeddingModelId === undefined
|
|
61
|
+
? undefined
|
|
62
|
+
: this.config.aws.embeddingModelId,
|
|
63
|
+
});
|
|
64
|
+
this.microphoneHandler = new MicrophoneHandler({
|
|
65
|
+
azureSpeechService: this.azureSpeechService,
|
|
66
|
+
bedrockService: this.bedrockService,
|
|
67
|
+
language: this.config.language || "en-IN",
|
|
68
|
+
});
|
|
69
|
+
if (openSearchConfig) {
|
|
70
|
+
try {
|
|
71
|
+
this.vectorSearchService = new VectorSearchService(openSearchConfig);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
console.error("Failed to initialize OpenSearch client:", error);
|
|
75
|
+
this.vectorSearchService = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
this.resumeAfterNavigation = this.consumeResumeState();
|
|
79
|
+
this.ui = createFloatingControl();
|
|
80
|
+
this.setupEventHandlers();
|
|
81
|
+
if (this.resumeAfterNavigation) {
|
|
82
|
+
void this.start({ reason: "auto" });
|
|
83
|
+
}
|
|
84
|
+
else if (this.shouldAutoStart()) {
|
|
85
|
+
void this.start({ reason: "auto" });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
shouldAutoStart() {
|
|
89
|
+
if (this.config.autoStart === true) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (typeof localStorage === "undefined") {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const stored = localStorage.getItem(AUTO_START_STORAGE_KEY);
|
|
97
|
+
return stored === "true";
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
setAutoStart(enabled) {
|
|
104
|
+
if (typeof localStorage === "undefined") {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
localStorage.setItem(AUTO_START_STORAGE_KEY, String(enabled));
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
console.error("Failed to save auto-start preference:", error);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
prepareForNavigation(options = {}) {
|
|
115
|
+
this.persistResumeState(options.target);
|
|
116
|
+
}
|
|
117
|
+
setupEventHandlers() {
|
|
118
|
+
this.ui.button.addEventListener("click", () => {
|
|
119
|
+
this.toggleRecording();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async toggleRecording() {
|
|
123
|
+
const currentState = this.ui.root.dataset.state;
|
|
124
|
+
if (currentState === "listening" || currentState === "thinking") {
|
|
125
|
+
await this.stop();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (currentState === "loading") {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
await this.start({ reason: "user" });
|
|
132
|
+
}
|
|
133
|
+
async start(options = {}) {
|
|
134
|
+
const reason = options.reason ?? "user";
|
|
135
|
+
this.lastStartReason = reason;
|
|
136
|
+
if (this.ui.root.dataset.state === "listening") {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (reason === "auto") {
|
|
140
|
+
try {
|
|
141
|
+
const canAutoStart = await this.microphoneHandler.canStartAutomatically();
|
|
142
|
+
if (!canAutoStart) {
|
|
143
|
+
this.handleAutoStartFallback("auto");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
this.handleAutoStartFallback("auto");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (reason === "user") {
|
|
153
|
+
this.clearAutoStartFallback();
|
|
154
|
+
try {
|
|
155
|
+
await this.microphoneHandler.unlockAudioContext(true);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
setState(this.ui, "idle");
|
|
159
|
+
updateStatus(this.ui, "Tap to enable voice navigation");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
this.isProcessing = false;
|
|
164
|
+
setState(this.ui, "loading");
|
|
165
|
+
emitEvent("state-change", { state: "loading" });
|
|
166
|
+
try {
|
|
167
|
+
const started = await this.microphoneHandler.startRecording({
|
|
168
|
+
onPartial: (text) => this.handlePartial(text),
|
|
169
|
+
onSegment: (text, info) => this.handleSegment(text, info),
|
|
170
|
+
onSilence: () => this.handleSilence(),
|
|
171
|
+
onError: (error) => this.handleError(error),
|
|
172
|
+
});
|
|
173
|
+
if (started) {
|
|
174
|
+
this.clearAutoStartFallback();
|
|
175
|
+
setState(this.ui, "listening");
|
|
176
|
+
emitEvent("state-change", { state: "listening" });
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
setState(this.ui, "idle");
|
|
180
|
+
emitEvent("state-change", { state: "idle" });
|
|
181
|
+
this.handleAutoStartFallback(reason);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
console.error("Failed to start recording:", error);
|
|
186
|
+
showError(this.ui, error);
|
|
187
|
+
setState(this.ui, "error");
|
|
188
|
+
emitEvent("error", { error });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async stop() {
|
|
192
|
+
this.isProcessing = false;
|
|
193
|
+
setState(this.ui, "thinking");
|
|
194
|
+
emitEvent("state-change", { state: "thinking" });
|
|
195
|
+
try {
|
|
196
|
+
const finalText = await this.microphoneHandler.stopRecording();
|
|
197
|
+
if (finalText && finalText.trim()) {
|
|
198
|
+
await this.processTranscript(finalText);
|
|
199
|
+
}
|
|
200
|
+
this.microphoneHandler.resetTranscriptionBuffer();
|
|
201
|
+
setState(this.ui, "idle");
|
|
202
|
+
emitEvent("state-change", { state: "idle" });
|
|
203
|
+
updateTranscript(this.ui, "");
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
console.error("Failed to stop recording:", error);
|
|
207
|
+
showError(this.ui, error);
|
|
208
|
+
setState(this.ui, "error");
|
|
209
|
+
emitEvent("error", { error });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
handlePartial(text) {
|
|
213
|
+
if (this.isProcessing) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
updateTranscript(this.ui, text);
|
|
217
|
+
}
|
|
218
|
+
handleSegment(_text, _info) {
|
|
219
|
+
if (this.isProcessing) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
this.lastTranscript = this.microphoneHandler.getAggregatedText();
|
|
223
|
+
updateTranscript(this.ui, this.lastTranscript);
|
|
224
|
+
}
|
|
225
|
+
async handleSilence() {
|
|
226
|
+
await this.processPendingTranscript();
|
|
227
|
+
}
|
|
228
|
+
async processPendingTranscript() {
|
|
229
|
+
if (this.isProcessing) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const aggregatedText = this.microphoneHandler.getAggregatedText();
|
|
233
|
+
const transcript = aggregatedText.trim();
|
|
234
|
+
if (!transcript) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
this.isProcessing = true;
|
|
238
|
+
this.lastTranscript = transcript;
|
|
239
|
+
this.microphoneHandler.clearSilenceTimer();
|
|
240
|
+
this.microphoneHandler.resetTranscriptionBuffer();
|
|
241
|
+
setState(this.ui, "thinking");
|
|
242
|
+
emitEvent("state-change", { state: "thinking" });
|
|
243
|
+
try {
|
|
244
|
+
await this.processTranscript(transcript);
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
const statusMessage = this.ui.status.textContent || "";
|
|
248
|
+
if (this.ui.root.dataset.state !== "idle") {
|
|
249
|
+
setState(this.ui, "listening");
|
|
250
|
+
emitEvent("state-change", { state: "listening" });
|
|
251
|
+
if (statusMessage) {
|
|
252
|
+
updateStatus(this.ui, statusMessage);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
updateTranscript(this.ui, "");
|
|
256
|
+
this.isProcessing = false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
handleError(error) {
|
|
260
|
+
console.error("Microphone error:", error);
|
|
261
|
+
if (this.lastStartReason === "auto" && this.isPermissionRelatedError(error)) {
|
|
262
|
+
setState(this.ui, "idle");
|
|
263
|
+
this.handleAutoStartFallback("auto");
|
|
264
|
+
updateStatus(this.ui, "Tap to start voice control");
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
showError(this.ui, error);
|
|
268
|
+
setState(this.ui, "error");
|
|
269
|
+
emitEvent("error", { error });
|
|
270
|
+
}
|
|
271
|
+
async processTranscript(text) {
|
|
272
|
+
if (!text || !text.trim()) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const transcriptSnippet = clipText(text);
|
|
277
|
+
flowLog("Transcript received", { transcript: transcriptSnippet });
|
|
278
|
+
updateStatus(this.ui, "Processing...");
|
|
279
|
+
flowLog("Sending transcript for action extraction", {
|
|
280
|
+
transcript: transcriptSnippet,
|
|
281
|
+
});
|
|
282
|
+
const result = await this.microphoneHandler.sendToBedrockAgent(text);
|
|
283
|
+
await this.handleAgentResult(text, result);
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
console.error("Failed to process transcript:", error);
|
|
287
|
+
showError(this.ui, error);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async handleAgentResult(originalText, result) {
|
|
291
|
+
const transcriptSnippet = clipText(originalText);
|
|
292
|
+
const actionData = extractAgentAction(result);
|
|
293
|
+
if (!actionData) {
|
|
294
|
+
console.error("Could not extract action fromm result", originalText, result);
|
|
295
|
+
console.error("Extracted action data:", originalText, result);
|
|
296
|
+
updateActionIndicator(this.ui, "unknown", false);
|
|
297
|
+
updateStatus(this.ui, "Could not understand the request");
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const { action, query } = actionData;
|
|
301
|
+
flowLog("Action extraction completed", {
|
|
302
|
+
transcript: transcriptSnippet,
|
|
303
|
+
action,
|
|
304
|
+
query: query ?? null,
|
|
305
|
+
});
|
|
306
|
+
emitEvent("action-detected", { action, originalText });
|
|
307
|
+
if (action === "search_content") {
|
|
308
|
+
flowLog("search_content action detected", {
|
|
309
|
+
transcript: transcriptSnippet,
|
|
310
|
+
query: query ?? null,
|
|
311
|
+
});
|
|
312
|
+
const actionResult = await this.executeVectorSearchAction({
|
|
313
|
+
query,
|
|
314
|
+
originalText,
|
|
315
|
+
});
|
|
316
|
+
if (typeof this.config.actionHandlers?.[action] === "function") {
|
|
317
|
+
try {
|
|
318
|
+
this.config.actionHandlers[action]({
|
|
319
|
+
controller: this,
|
|
320
|
+
transcript: originalText,
|
|
321
|
+
actionResult,
|
|
322
|
+
timestamp: Date.now(),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
catch (handlerError) {
|
|
326
|
+
console.error("Custom action handler failed:", handlerError);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
updateActionIndicator(this.ui, formatActionLabel(action), actionResult.performed, actionResult.info);
|
|
330
|
+
const statusMessage = typeof actionResult.info.message === "string"
|
|
331
|
+
? actionResult.info.message
|
|
332
|
+
: actionResult.performed
|
|
333
|
+
? "Opening the best matching result."
|
|
334
|
+
: "Could not open a relevant result.";
|
|
335
|
+
updateStatus(this.ui, statusMessage);
|
|
336
|
+
emitEvent("action-performed", {
|
|
337
|
+
action,
|
|
338
|
+
performed: actionResult.performed,
|
|
339
|
+
info: actionResult.info,
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const actionResult = performAgentAction(action, {
|
|
344
|
+
handler: this,
|
|
345
|
+
onStop: () => this.stop(),
|
|
346
|
+
transcript: originalText,
|
|
347
|
+
});
|
|
348
|
+
if (typeof this.config.actionHandlers?.[action] === "function") {
|
|
349
|
+
try {
|
|
350
|
+
this.config.actionHandlers[action]({
|
|
351
|
+
controller: this,
|
|
352
|
+
transcript: originalText,
|
|
353
|
+
actionResult,
|
|
354
|
+
timestamp: Date.now(),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
catch (handlerError) {
|
|
358
|
+
console.error("Custom action handler failed:", handlerError);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
updateActionIndicator(this.ui, formatActionLabel(action), actionResult.performed, actionResult.info);
|
|
362
|
+
if (action === "unknown" || !actionResult.performed) {
|
|
363
|
+
updateStatus(this.ui, "Say that again? I didn't catch the action.");
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
updateStatus(this.ui, `Action completed: ${formatActionLabel(action).toLowerCase()}`);
|
|
367
|
+
}
|
|
368
|
+
emitEvent("action-performed", {
|
|
369
|
+
action,
|
|
370
|
+
performed: actionResult.performed,
|
|
371
|
+
info: actionResult.info,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
setLanguage(language) {
|
|
375
|
+
this.microphoneHandler.setLanguage(language);
|
|
376
|
+
}
|
|
377
|
+
async executeVectorSearchAction(options) {
|
|
378
|
+
const vectorService = this.vectorSearchService;
|
|
379
|
+
const normalizedQuery = this.normalizeSearchQuery(options.query, options.originalText);
|
|
380
|
+
const transcriptSnippet = clipText(options.originalText);
|
|
381
|
+
flowLog("Vector search invoked", {
|
|
382
|
+
transcript: transcriptSnippet,
|
|
383
|
+
providedQuery: options.query ?? null,
|
|
384
|
+
normalizedQuery,
|
|
385
|
+
});
|
|
386
|
+
if (!vectorService) {
|
|
387
|
+
flowLog("Vector search unavailable", {
|
|
388
|
+
reason: "not_configured",
|
|
389
|
+
});
|
|
390
|
+
return {
|
|
391
|
+
performed: false,
|
|
392
|
+
info: {
|
|
393
|
+
query: normalizedQuery,
|
|
394
|
+
message: "Search is not configured.",
|
|
395
|
+
reason: "vector_search_unavailable",
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
if (!normalizedQuery) {
|
|
400
|
+
flowLog("Vector search missing query", {
|
|
401
|
+
reason: "empty_query",
|
|
402
|
+
});
|
|
403
|
+
return {
|
|
404
|
+
performed: false,
|
|
405
|
+
info: {
|
|
406
|
+
query: normalizedQuery,
|
|
407
|
+
message: "Please mention what you would like to find.",
|
|
408
|
+
reason: "missing_query",
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
updateStatus(this.ui, `Searching for “${normalizedQuery}”...`);
|
|
413
|
+
flowLog("Generating embeddings", { query: normalizedQuery });
|
|
414
|
+
let embedding = [];
|
|
415
|
+
try {
|
|
416
|
+
embedding = await this.bedrockService.embedText(normalizedQuery);
|
|
417
|
+
flowLog("Embeddings generated", {
|
|
418
|
+
query: normalizedQuery,
|
|
419
|
+
dimensions: embedding.length,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
console.error("Failed to generate embeddings:", error);
|
|
424
|
+
flowLog("Embedding generation failed", {
|
|
425
|
+
query: normalizedQuery,
|
|
426
|
+
});
|
|
427
|
+
showError(this.ui, new Error("Search is currently unavailable."));
|
|
428
|
+
return {
|
|
429
|
+
performed: false,
|
|
430
|
+
info: {
|
|
431
|
+
query: normalizedQuery,
|
|
432
|
+
message: "Search is currently unavailable.",
|
|
433
|
+
reason: "embedding_failed",
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
if (!embedding.length) {
|
|
438
|
+
flowLog("Embedding result empty", { query: normalizedQuery });
|
|
439
|
+
return {
|
|
440
|
+
performed: false,
|
|
441
|
+
info: {
|
|
442
|
+
query: normalizedQuery,
|
|
443
|
+
message: "Search is currently unavailable.",
|
|
444
|
+
reason: "empty_embedding",
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
let hits;
|
|
449
|
+
try {
|
|
450
|
+
flowLog("Calling vector search service", { query: normalizedQuery });
|
|
451
|
+
hits = await vectorService.search(embedding, normalizedQuery);
|
|
452
|
+
flowLog("Vector search completed", {
|
|
453
|
+
query: normalizedQuery,
|
|
454
|
+
hitCount: hits.length,
|
|
455
|
+
hits: hits,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
console.error("OpenSearch query failed:", error);
|
|
460
|
+
flowLog("Vector search call failed", { query: normalizedQuery });
|
|
461
|
+
showError(this.ui, new Error("Unable to reach the search service."));
|
|
462
|
+
return {
|
|
463
|
+
performed: false,
|
|
464
|
+
info: {
|
|
465
|
+
query: normalizedQuery,
|
|
466
|
+
message: "Unable to reach the search service.",
|
|
467
|
+
reason: "opensearch_error",
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
const best = vectorService.findBestMatchWithUrl(hits);
|
|
472
|
+
if (!best) {
|
|
473
|
+
flowLog("Vector search returned no matching URL", {
|
|
474
|
+
query: normalizedQuery,
|
|
475
|
+
});
|
|
476
|
+
return {
|
|
477
|
+
performed: false,
|
|
478
|
+
info: {
|
|
479
|
+
query: normalizedQuery,
|
|
480
|
+
message: "I could not find a relevant result.",
|
|
481
|
+
reason: "no_match",
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
const resolvedUrl = this.resolveUrl(best.url);
|
|
486
|
+
const sameDomain = this.isSameDomain(resolvedUrl);
|
|
487
|
+
const baseInfo = {
|
|
488
|
+
url: resolvedUrl,
|
|
489
|
+
query: normalizedQuery,
|
|
490
|
+
score: best.hit.score,
|
|
491
|
+
external: !sameDomain,
|
|
492
|
+
};
|
|
493
|
+
if (sameDomain) {
|
|
494
|
+
flowLog("Navigating to internal result", {
|
|
495
|
+
query: normalizedQuery,
|
|
496
|
+
url: resolvedUrl,
|
|
497
|
+
score: best.hit.score,
|
|
498
|
+
});
|
|
499
|
+
this.prepareForNavigation({ target: resolvedUrl });
|
|
500
|
+
baseInfo.message = "Opening the best matching result.";
|
|
501
|
+
window.location.href = resolvedUrl;
|
|
502
|
+
return {
|
|
503
|
+
performed: true,
|
|
504
|
+
info: baseInfo,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
const hostname = this.safeHostname(resolvedUrl);
|
|
508
|
+
const prompt = hostname
|
|
509
|
+
? `This will open an external page on ${hostname}. Continue?`
|
|
510
|
+
: "This will open an external page. Continue?";
|
|
511
|
+
flowLog("Prompting for external navigation", {
|
|
512
|
+
query: normalizedQuery,
|
|
513
|
+
url: resolvedUrl,
|
|
514
|
+
score: best.hit.score,
|
|
515
|
+
});
|
|
516
|
+
const confirmed = window.confirm(prompt);
|
|
517
|
+
if (!confirmed) {
|
|
518
|
+
flowLog("External navigation declined", {
|
|
519
|
+
query: normalizedQuery,
|
|
520
|
+
url: resolvedUrl,
|
|
521
|
+
});
|
|
522
|
+
baseInfo.reason = "user_declined";
|
|
523
|
+
baseInfo.message = "External navigation cancelled.";
|
|
524
|
+
return {
|
|
525
|
+
performed: false,
|
|
526
|
+
info: baseInfo,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
this.prepareForNavigation({ target: resolvedUrl });
|
|
530
|
+
window.open(resolvedUrl, "_blank", "noopener");
|
|
531
|
+
this.pauseAfterExternalNavigation();
|
|
532
|
+
baseInfo.message = hostname
|
|
533
|
+
? `Opened ${hostname} in a new tab.`
|
|
534
|
+
: "Opened external result in a new tab.";
|
|
535
|
+
flowLog("External navigation approved", {
|
|
536
|
+
query: normalizedQuery,
|
|
537
|
+
url: resolvedUrl,
|
|
538
|
+
score: best.hit.score,
|
|
539
|
+
});
|
|
540
|
+
return {
|
|
541
|
+
performed: true,
|
|
542
|
+
info: baseInfo,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
normalizeSearchQuery(primary, fallback) {
|
|
546
|
+
const candidates = [primary, fallback];
|
|
547
|
+
for (const candidate of candidates) {
|
|
548
|
+
if (!candidate || typeof candidate !== "string") {
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
let working = candidate.trim();
|
|
552
|
+
if (!working) {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
working = working.replace(/^["'](.+)["']$/u, "$1").trim();
|
|
556
|
+
const prefacePatterns = [
|
|
557
|
+
/^(please\s+)?(can|could|would)\s+you\s+/i,
|
|
558
|
+
/^i\s+(want|would\s+like)\s+to\s+/i,
|
|
559
|
+
];
|
|
560
|
+
for (const pattern of prefacePatterns) {
|
|
561
|
+
working = working.replace(pattern, "").trim();
|
|
562
|
+
}
|
|
563
|
+
const searchPatterns = [
|
|
564
|
+
/^(search|find|look\s*up|show\s+me|tell\s+me\s+about)\s+(for\s+)?/i,
|
|
565
|
+
];
|
|
566
|
+
for (const pattern of searchPatterns) {
|
|
567
|
+
const replaced = working.replace(pattern, "").trim();
|
|
568
|
+
if (replaced !== working) {
|
|
569
|
+
working = replaced;
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
working = this.normalizeIndianSchemeName(working);
|
|
574
|
+
if (working) {
|
|
575
|
+
return working;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return "";
|
|
579
|
+
}
|
|
580
|
+
normalizeIndianSchemeName(query) {
|
|
581
|
+
let normalized = query.trim();
|
|
582
|
+
const replacements = [
|
|
583
|
+
[/\bpm\s+/gi, "Pradhan Mantri "],
|
|
584
|
+
[/\bpradhanmantri\b/gi, "Pradhan Mantri"],
|
|
585
|
+
[/\bpradhanmanthri\b/gi, "Pradhan Mantri"],
|
|
586
|
+
[/\bpradhaan\s+mantri\b/gi, "Pradhan Mantri"],
|
|
587
|
+
[/\baawas\b/gi, "Awas"],
|
|
588
|
+
[/\bavas\b/gi, "Awas"],
|
|
589
|
+
[/\baavas\b/gi, "Awas"],
|
|
590
|
+
[/\byojna\b/gi, "Yojana"],
|
|
591
|
+
[/\byojana\b/gi, "Yojana"],
|
|
592
|
+
[/\byojanaa\b/gi, "Yojana"],
|
|
593
|
+
[/\bpmay\b/gi, "Pradhan Mantri Awas Yojana"],
|
|
594
|
+
[/\bukiran\b/gi, "Ujjwala"],
|
|
595
|
+
[/\bjan\s*dhan\b/gi, "Jan Dhan"],
|
|
596
|
+
[/\bjandhan\b/gi, "Jan Dhan"],
|
|
597
|
+
[/\s+/g, " "],
|
|
598
|
+
];
|
|
599
|
+
for (const [pattern, replacement] of replacements) {
|
|
600
|
+
normalized = normalized.replace(pattern, replacement);
|
|
601
|
+
}
|
|
602
|
+
return normalized.trim();
|
|
603
|
+
}
|
|
604
|
+
resolveUrl(url) {
|
|
605
|
+
try {
|
|
606
|
+
return new URL(url, window.location.href).toString();
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
return url;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
isSameDomain(url) {
|
|
613
|
+
try {
|
|
614
|
+
const current = new URL(window.location.href);
|
|
615
|
+
const target = new URL(url, current.href);
|
|
616
|
+
return current.host === target.host;
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
safeHostname(url) {
|
|
623
|
+
try {
|
|
624
|
+
const target = new URL(url, window.location.href);
|
|
625
|
+
return target.hostname || null;
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
pauseAfterExternalNavigation() {
|
|
632
|
+
try {
|
|
633
|
+
this.microphoneHandler.disposeRecognizer();
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
}
|
|
637
|
+
this.isProcessing = false;
|
|
638
|
+
setState(this.ui, "idle");
|
|
639
|
+
emitEvent("state-change", { state: "idle" });
|
|
640
|
+
this.microphoneHandler.resetTranscriptionBuffer();
|
|
641
|
+
updateTranscript(this.ui, "");
|
|
642
|
+
}
|
|
643
|
+
destroy() {
|
|
644
|
+
this.clearAutoStartFallback();
|
|
645
|
+
if (this.ui.root.parentElement) {
|
|
646
|
+
this.ui.root.parentElement.removeChild(this.ui.root);
|
|
647
|
+
}
|
|
648
|
+
const styles = document.getElementById("navigate-control-styles");
|
|
649
|
+
if (styles) {
|
|
650
|
+
styles.remove();
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
persistResumeState(target) {
|
|
654
|
+
if (typeof window === "undefined") {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (typeof sessionStorage === "undefined") {
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const shouldResume = this.isActiveState();
|
|
661
|
+
try {
|
|
662
|
+
if (!shouldResume) {
|
|
663
|
+
sessionStorage.removeItem(RESUME_STATE_STORAGE_KEY);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const payload = {
|
|
667
|
+
shouldResume: true,
|
|
668
|
+
timestamp: Date.now(),
|
|
669
|
+
target: target ?? null,
|
|
670
|
+
};
|
|
671
|
+
sessionStorage.setItem(RESUME_STATE_STORAGE_KEY, JSON.stringify(payload));
|
|
672
|
+
}
|
|
673
|
+
catch (error) {
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
consumeResumeState() {
|
|
677
|
+
if (typeof window === "undefined") {
|
|
678
|
+
return false;
|
|
679
|
+
}
|
|
680
|
+
if (typeof sessionStorage === "undefined") {
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
try {
|
|
684
|
+
const raw = sessionStorage.getItem(RESUME_STATE_STORAGE_KEY);
|
|
685
|
+
if (!raw) {
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
sessionStorage.removeItem(RESUME_STATE_STORAGE_KEY);
|
|
689
|
+
const parsed = JSON.parse(raw);
|
|
690
|
+
if (!parsed?.shouldResume) {
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
const isRecent = typeof parsed.timestamp === "number"
|
|
694
|
+
? Date.now() - parsed.timestamp < 60000
|
|
695
|
+
: true;
|
|
696
|
+
if (!isRecent) {
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
if (parsed.target) {
|
|
700
|
+
try {
|
|
701
|
+
const targetUrl = new URL(parsed.target, window.location.href);
|
|
702
|
+
if (targetUrl.origin !== window.location.origin) {
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return true;
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
isActiveState() {
|
|
717
|
+
if (!this.ui?.root?.dataset?.state) {
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
const state = this.ui.root.dataset.state;
|
|
721
|
+
return state === "listening" || state === "thinking" || state === "loading";
|
|
722
|
+
}
|
|
723
|
+
handleAutoStartFallback(reason) {
|
|
724
|
+
if (reason !== "auto") {
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
if (typeof window === "undefined") {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (this.microphoneHandler.hasMicrophonePermission()) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (this.autoStartPendingUserInteraction) {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
this.autoStartPendingUserInteraction = true;
|
|
737
|
+
setState(this.ui, "idle");
|
|
738
|
+
const resume = () => {
|
|
739
|
+
this.clearAutoStartFallback();
|
|
740
|
+
void this.microphoneHandler
|
|
741
|
+
.unlockAudioContext(true)
|
|
742
|
+
.finally(() => {
|
|
743
|
+
void this.start({ reason: "user" });
|
|
744
|
+
});
|
|
745
|
+
};
|
|
746
|
+
const events = [
|
|
747
|
+
"pointerdown",
|
|
748
|
+
"keydown",
|
|
749
|
+
"touchstart",
|
|
750
|
+
"click",
|
|
751
|
+
];
|
|
752
|
+
events.forEach((event) => {
|
|
753
|
+
window.addEventListener(event, resume, { once: false, passive: true });
|
|
754
|
+
});
|
|
755
|
+
this.removeAutoStartListeners = () => {
|
|
756
|
+
events.forEach((event) => {
|
|
757
|
+
window.removeEventListener(event, resume);
|
|
758
|
+
});
|
|
759
|
+
};
|
|
760
|
+
updateStatus(this.ui, "Tap to enable voice navigation");
|
|
761
|
+
}
|
|
762
|
+
clearAutoStartFallback() {
|
|
763
|
+
if (this.removeAutoStartListeners) {
|
|
764
|
+
this.removeAutoStartListeners();
|
|
765
|
+
this.removeAutoStartListeners = null;
|
|
766
|
+
}
|
|
767
|
+
this.autoStartPendingUserInteraction = false;
|
|
768
|
+
}
|
|
769
|
+
isPermissionRelatedError(error) {
|
|
770
|
+
const message = (error?.message || "").toLowerCase();
|
|
771
|
+
if (message.includes("permission") || message.includes("denied")) {
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
if (message.includes("audiocontext") && message.includes("not allowed")) {
|
|
775
|
+
return true;
|
|
776
|
+
}
|
|
777
|
+
if (message.includes("must be resumed") && message.includes("user gesture")) {
|
|
778
|
+
return true;
|
|
779
|
+
}
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
}
|