@foisit/angular-wrapper 2.5.0 → 2.5.1

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.
@@ -1,7 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { Component, Inject, Injectable, NgModule } from '@angular/core';
3
3
  import { CommonModule } from '@angular/common';
4
- import { CommandHandler, FallbackHandler, VoiceProcessor, TextToSpeech, GestureHandler, OverlayManager, StateManager } from '@foisit/core';
5
4
 
6
5
  class AngularWrapperComponent {
7
6
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.7", ngImport: i0, type: AngularWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
@@ -12,6 +11,2372 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.7", ngImpor
12
11
  args: [{ selector: 'lib-angular-wrapper', imports: [CommonModule], template: "<p>AngularWrapper works!</p>\n" }]
13
12
  }] });
14
13
 
14
+ class OpenAIService {
15
+ endpoint;
16
+ constructor(endpoint) {
17
+ this.endpoint =
18
+ endpoint || 'https://foisit-ninja.netlify.app/.netlify/functions/intent';
19
+ }
20
+ async determineIntent(userInput, availableCommands, context) {
21
+ try {
22
+ // Prepare the payload
23
+ const payload = {
24
+ userInput,
25
+ commands: availableCommands.map((cmd) => ({
26
+ id: cmd.id,
27
+ command: cmd.command,
28
+ description: cmd.description,
29
+ parameters: cmd.parameters, // Send param schemas to AI
30
+ })),
31
+ context,
32
+ };
33
+ const response = await fetch(this.endpoint, {
34
+ method: 'POST',
35
+ headers: {
36
+ 'Content-Type': 'application/json',
37
+ },
38
+ body: JSON.stringify(payload),
39
+ });
40
+ if (!response.ok) {
41
+ throw new Error(`Proxy API Error: ${response.statusText}`);
42
+ }
43
+ const result = await response.json();
44
+ return result;
45
+ }
46
+ catch (error) {
47
+ console.error('OpenAIService Error:', error);
48
+ return { type: 'unknown' };
49
+ }
50
+ }
51
+ }
52
+
53
+ class CommandHandler {
54
+ commands = new Map();
55
+ openAIService = null;
56
+ context = null;
57
+ pendingConfirmation = null;
58
+ enableSmartIntent = true;
59
+ selectOptionsCache = new Map();
60
+ constructor(arg = true) {
61
+ if (typeof arg === 'boolean') {
62
+ this.enableSmartIntent = arg;
63
+ if (this.enableSmartIntent) {
64
+ this.openAIService = new OpenAIService();
65
+ }
66
+ return;
67
+ }
68
+ this.enableSmartIntent = arg.enableSmartIntent ?? true;
69
+ if (this.enableSmartIntent) {
70
+ this.openAIService = new OpenAIService(arg.intentEndpoint);
71
+ }
72
+ }
73
+ /** Add a new command (string or object) */
74
+ addCommand(commandOrObj, action) {
75
+ let cmd;
76
+ if (typeof commandOrObj === 'string') {
77
+ if (!action) {
78
+ throw new Error('Action required when adding command by string.');
79
+ }
80
+ cmd = {
81
+ id: commandOrObj.toLowerCase().replace(/\s+/g, '_'),
82
+ command: commandOrObj.toLowerCase(),
83
+ action,
84
+ };
85
+ }
86
+ else {
87
+ cmd = { ...commandOrObj };
88
+ if (!cmd.id) {
89
+ cmd.id = cmd.command.toLowerCase().replace(/\s+/g, '_');
90
+ }
91
+ }
92
+ this.commands.set(cmd.command.toLowerCase(), cmd);
93
+ // Also index by ID for AI matching
94
+ if (cmd.id && cmd.id !== cmd.command) {
95
+ // We store it by ID in a separate map or just rely on iteration for AI
96
+ // For simplicity, let's keep the main map keyed by trigger phrase,
97
+ // but we'll lookup by ID in AI flow.
98
+ }
99
+ }
100
+ /** Remove an existing command */
101
+ removeCommand(command) {
102
+ this.commands.delete(command.toLowerCase());
103
+ }
104
+ /** Execute a command by matching input */
105
+ async executeCommand(input) {
106
+ // 0) Handle object input
107
+ if (typeof input === 'object' && input !== null) {
108
+ // Direct command trigger from UI: { commandId, params? }
109
+ if (this.isStructured(input)) {
110
+ const cmdId = String(input.commandId);
111
+ const rawParams = input.params ?? {};
112
+ const cmd = this.getCommandById(cmdId);
113
+ if (!cmd)
114
+ return { message: 'That command is not available.', type: 'error' };
115
+ const params = this.sanitizeParamsForCommand(cmd, rawParams);
116
+ const requiredParams = (cmd.parameters ?? []).filter((p) => p.required);
117
+ const missing = requiredParams.filter((p) => params[p.name] == null || params[p.name] === '');
118
+ if (missing.length > 0) {
119
+ // Ask for missing params via form and store context
120
+ this.context = { commandId: this.getCommandIdentifier(cmd), params };
121
+ return {
122
+ message: `Please provide the required details for "${cmd.command}".`,
123
+ type: 'form',
124
+ fields: missing,
125
+ };
126
+ }
127
+ if (cmd.critical) {
128
+ this.pendingConfirmation = { commandId: this.getCommandIdentifier(cmd), params };
129
+ return this.buildConfirmResponse(cmd);
130
+ }
131
+ return this.safeRunAction(cmd, params);
132
+ }
133
+ // Otherwise treat as continuation of a previously requested form
134
+ if (!this.context) {
135
+ return { message: 'Session expired or invalid context.', type: 'error' };
136
+ }
137
+ const cmd = this.getCommandById(this.context.commandId);
138
+ if (!cmd) {
139
+ this.context = null;
140
+ return { message: 'Session expired or invalid context.', type: 'error' };
141
+ }
142
+ if (Array.isArray(input)) {
143
+ return { message: 'Invalid form payload.', type: 'error' };
144
+ }
145
+ const mergedParams = {
146
+ ...this.context.params,
147
+ ...input,
148
+ };
149
+ // If this was a critical command, ask for confirmation before executing.
150
+ if (cmd.critical) {
151
+ this.context = null;
152
+ this.pendingConfirmation = {
153
+ commandId: this.getCommandIdentifier(cmd),
154
+ params: mergedParams,
155
+ };
156
+ return this.buildConfirmResponse(cmd);
157
+ }
158
+ const result = await this.safeRunAction(cmd, mergedParams);
159
+ this.context = null;
160
+ return this.normalizeResponse(result);
161
+ }
162
+ const trimmedInput = input.trim().toLowerCase();
163
+ // 1) Confirmation flow (Yes/No)
164
+ if (this.pendingConfirmation) {
165
+ const normalized = trimmedInput;
166
+ if (['yes', 'y', 'confirm', 'ok', 'okay'].includes(normalized)) {
167
+ const { commandId, params } = this.pendingConfirmation;
168
+ this.pendingConfirmation = null;
169
+ const cmd = this.getCommandById(commandId);
170
+ if (!cmd) {
171
+ return { message: 'That action is no longer available.', type: 'error' };
172
+ }
173
+ return this.safeRunAction(cmd, params);
174
+ }
175
+ if (['no', 'n', 'cancel', 'stop'].includes(normalized)) {
176
+ this.pendingConfirmation = null;
177
+ return { message: 'Cancelled.', type: 'success' };
178
+ }
179
+ return {
180
+ message: 'Please confirm: Yes or No.',
181
+ type: 'confirm',
182
+ options: [
183
+ { label: 'Yes', value: 'yes' },
184
+ { label: 'No', value: 'no' },
185
+ ],
186
+ };
187
+ }
188
+ // 2) Exact match
189
+ const exactCommand = this.commands.get(trimmedInput);
190
+ if (exactCommand) {
191
+ const cmd = exactCommand;
192
+ // Macro commands bypass all parameter collection and confirmation for instant execution
193
+ if (cmd.macro) {
194
+ return this.safeRunAction(cmd, {});
195
+ }
196
+ // If the command needs params, collect them (no AI needed)
197
+ const requiredParams = (cmd.parameters ?? []).filter((p) => p.required);
198
+ if (requiredParams.length > 0) {
199
+ this.context = { commandId: this.getCommandIdentifier(cmd), params: {} };
200
+ return {
201
+ message: `Please provide the required details for "${cmd.command}".`,
202
+ type: 'form',
203
+ fields: requiredParams,
204
+ };
205
+ }
206
+ // Critical commands require confirmation
207
+ if (cmd.critical) {
208
+ this.pendingConfirmation = { commandId: this.getCommandIdentifier(cmd), params: {} };
209
+ return this.buildConfirmResponse(cmd);
210
+ }
211
+ // Always pass an object for params so actions can safely read optional fields.
212
+ return this.safeRunAction(cmd, {});
213
+ }
214
+ // 3) Deterministic (non-AI) fuzzy match using keywords/substring
215
+ const deterministic = await this.tryDeterministicMatch(trimmedInput);
216
+ if (deterministic)
217
+ return deterministic;
218
+ // 4) Smart intent fallback (AI)
219
+ if (this.enableSmartIntent && this.openAIService) {
220
+ const availableCommands = await this.getCommandsForAI();
221
+ const aiResult = await this.openAIService.determineIntent(trimmedInput, availableCommands, this.context);
222
+ return this.handleAIResult(aiResult);
223
+ }
224
+ // No deterministic match available and AI is disabled (or no AI configured).
225
+ // Return an error so callers (wrappers) can trigger fallback handling.
226
+ if (!this.enableSmartIntent) {
227
+ return { message: "I'm not sure what you mean.", type: 'error' };
228
+ }
229
+ // As a last resort, list available commands to let the user pick.
230
+ return this.listAllCommands();
231
+ }
232
+ async handleAIResult(result) {
233
+ if (result.type === 'match' && result.match) {
234
+ const cmd = this.getCommandById(result.match);
235
+ if (!cmd) {
236
+ return { message: "I'm not sure what you mean.", type: 'error' };
237
+ }
238
+ // Macro commands bypass all parameter collection and confirmation for instant execution
239
+ if (cmd.macro) {
240
+ return this.safeRunAction(cmd, {});
241
+ }
242
+ const rawParams = (result.params ?? {});
243
+ const sanitizedParams = this.sanitizeParamsForCommand(cmd, rawParams);
244
+ const params = cmd.allowAiParamExtraction === false ? {} : sanitizedParams;
245
+ const requiredParams = (cmd.parameters ?? []).filter((p) => p.required);
246
+ const missingRequired = requiredParams.filter((p) => params[p.name] == null || params[p.name] === '');
247
+ if (result.incomplete || missingRequired.length > 0) {
248
+ // Store partial params for continuation
249
+ this.context = { commandId: this.getCommandIdentifier(cmd), params };
250
+ const mustUseForm = cmd.collectRequiredViaForm !== false;
251
+ const askSingle = !mustUseForm && this.shouldAskSingleQuestion(missingRequired);
252
+ if (askSingle) {
253
+ const names = missingRequired.map((p) => p.name).join(' and ');
254
+ return {
255
+ message: result.message || `Please provide ${names}.`,
256
+ type: 'question',
257
+ };
258
+ }
259
+ return {
260
+ message: result.message || `Please fill in the missing details for "${cmd.command}".`,
261
+ type: 'form',
262
+ fields: missingRequired,
263
+ };
264
+ }
265
+ // Critical commands require confirmation
266
+ if (cmd.critical) {
267
+ this.pendingConfirmation = {
268
+ commandId: this.getCommandIdentifier(cmd),
269
+ params,
270
+ };
271
+ return this.buildConfirmResponse(cmd);
272
+ }
273
+ const actionResult = await cmd.action(params);
274
+ return this.normalizeResponse(actionResult);
275
+ }
276
+ if (result.type === 'ambiguous' && result.options && result.options.length) {
277
+ // Prefer clickable phrases that map to exact matches. Use commandId as value to
278
+ // make UI actions deterministic when the user clicks an option.
279
+ return {
280
+ message: result.message || 'Did you mean one of these?',
281
+ type: 'ambiguous',
282
+ options: result.options.map((o) => ({
283
+ label: o.label,
284
+ value: o.commandId ?? o.label,
285
+ commandId: o.commandId,
286
+ })),
287
+ };
288
+ }
289
+ // Unknown / fallback: show all available commands
290
+ return this.listAllCommands();
291
+ }
292
+ sanitizeParamsForCommand(cmd, params) {
293
+ const sanitized = { ...(params ?? {}) };
294
+ for (const p of cmd.parameters ?? []) {
295
+ const value = sanitized[p.name];
296
+ if (p.type === 'string') {
297
+ if (typeof value !== 'string') {
298
+ delete sanitized[p.name];
299
+ continue;
300
+ }
301
+ const trimmed = value.trim();
302
+ if (!trimmed) {
303
+ delete sanitized[p.name];
304
+ continue;
305
+ }
306
+ sanitized[p.name] = trimmed;
307
+ }
308
+ if (p.type === 'number') {
309
+ const numeric = typeof value === 'number' ? value : Number(value?.toString().trim());
310
+ if (Number.isNaN(numeric)) {
311
+ delete sanitized[p.name];
312
+ continue;
313
+ }
314
+ if (typeof p.min === 'number' && numeric < p.min) {
315
+ delete sanitized[p.name];
316
+ continue;
317
+ }
318
+ if (typeof p.max === 'number' && numeric > p.max) {
319
+ delete sanitized[p.name];
320
+ continue;
321
+ }
322
+ sanitized[p.name] = numeric;
323
+ }
324
+ if (p.type === 'date') {
325
+ const v = sanitized[p.name];
326
+ if (typeof v === 'string') {
327
+ const s = v.trim();
328
+ if (!this.isIsoDateString(s)) {
329
+ delete sanitized[p.name];
330
+ }
331
+ else {
332
+ sanitized[p.name] = s;
333
+ }
334
+ }
335
+ else {
336
+ delete sanitized[p.name];
337
+ }
338
+ }
339
+ if (p.type === 'select') {
340
+ const v = typeof value === 'string' ? value : value?.toString();
341
+ if (!v) {
342
+ delete sanitized[p.name];
343
+ continue;
344
+ }
345
+ if (Array.isArray(p.options) && p.options.length > 0) {
346
+ const allowed = p.options.some((opt) => String(opt.value) === String(v));
347
+ if (!allowed) {
348
+ delete sanitized[p.name];
349
+ continue;
350
+ }
351
+ }
352
+ sanitized[p.name] = v;
353
+ }
354
+ // Defensive handling for file parameters: AI may return textual hints like "csv file".
355
+ // If we expect a File/Blob or a base64 data URL (when delivery='base64'), accept only
356
+ // those forms. Otherwise remove the param so the handler will ask the user via a form.
357
+ if (p.type === 'file') {
358
+ const val = sanitized[p.name];
359
+ const isFileLike = val && typeof val === 'object' && typeof val.name === 'string' && typeof val.size === 'number';
360
+ const isDataUrl = typeof val === 'string' && /^data:[^;]+;base64,/.test(val);
361
+ const delivery = p.delivery ?? 'file';
362
+ if (delivery === 'base64') {
363
+ // Accept either a data URL string or a File-like object
364
+ if (!isDataUrl && !isFileLike) {
365
+ delete sanitized[p.name];
366
+ }
367
+ }
368
+ else {
369
+ // delivery === 'file' -> expect a File/Blob object
370
+ if (!isFileLike) {
371
+ delete sanitized[p.name];
372
+ }
373
+ }
374
+ }
375
+ }
376
+ return sanitized;
377
+ }
378
+ isIsoDateString(value) {
379
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
380
+ return false;
381
+ const dt = new Date(`${value}T00:00:00Z`);
382
+ return !Number.isNaN(dt.getTime());
383
+ }
384
+ shouldAskSingleQuestion(missing) {
385
+ if (missing.length !== 1)
386
+ return false;
387
+ const t = missing[0].type;
388
+ return t === 'string' || t === 'number' || t === 'date';
389
+ }
390
+ buildConfirmResponse(cmd) {
391
+ return {
392
+ message: `Are you sure you want to run "${cmd.command}"?`,
393
+ type: 'confirm',
394
+ options: [
395
+ { label: 'Yes', value: 'yes' },
396
+ { label: 'No', value: 'no' },
397
+ ],
398
+ };
399
+ }
400
+ async tryDeterministicMatch(input) {
401
+ // Substring or keyword-based match before AI
402
+ const candidates = [];
403
+ for (const cmd of this.commands.values()) {
404
+ let score = 0;
405
+ const commandPhrase = cmd.command.toLowerCase();
406
+ if (input.includes(commandPhrase))
407
+ score += 5;
408
+ const keywords = cmd.keywords ?? [];
409
+ for (const kw of keywords) {
410
+ const k = kw.toLowerCase().trim();
411
+ if (!k)
412
+ continue;
413
+ if (input === k)
414
+ score += 4;
415
+ else if (input.includes(k))
416
+ score += 3;
417
+ }
418
+ if (score > 0)
419
+ candidates.push({ cmd, score });
420
+ }
421
+ if (candidates.length === 0)
422
+ return null;
423
+ candidates.sort((a, b) => b.score - a.score);
424
+ const topScore = candidates[0].score;
425
+ const top = candidates.filter((c) => c.score === topScore).slice(0, 3);
426
+ // If multiple equally-good matches, ask the user to choose.
427
+ if (top.length > 1) {
428
+ return {
429
+ message: 'I think you mean one of these. Which one should I run?',
430
+ type: 'ambiguous',
431
+ options: top.map((c) => ({
432
+ label: c.cmd.command,
433
+ value: c.cmd.command,
434
+ commandId: c.cmd.id,
435
+ })),
436
+ };
437
+ }
438
+ const cmd = top[0].cmd;
439
+ // If params are required, ask with a form
440
+ const requiredParams = (cmd.parameters ?? []).filter((p) => p.required);
441
+ if (requiredParams.length > 0) {
442
+ this.context = { commandId: this.getCommandIdentifier(cmd), params: {} };
443
+ return {
444
+ message: `Please provide the required details for "${cmd.command}".`,
445
+ type: 'form',
446
+ fields: requiredParams,
447
+ };
448
+ }
449
+ if (cmd.critical) {
450
+ this.pendingConfirmation = { commandId: this.getCommandIdentifier(cmd), params: {} };
451
+ return this.buildConfirmResponse(cmd);
452
+ }
453
+ return this.safeRunAction(cmd, {});
454
+ }
455
+ async safeRunAction(cmd, params) {
456
+ try {
457
+ const result = await cmd.action(params ?? {});
458
+ return this.normalizeResponse(result);
459
+ }
460
+ catch {
461
+ return { message: 'Something went wrong while running that command.', type: 'error' };
462
+ }
463
+ }
464
+ async getCommandsForAI() {
465
+ const commands = Array.from(this.commands.values()).map((cmd) => ({
466
+ ...cmd,
467
+ parameters: cmd.parameters
468
+ ? cmd.parameters.map((param) => ({ ...param }))
469
+ : undefined,
470
+ }));
471
+ // Resolve async select options (cached) to give the model enough context.
472
+ await Promise.all(commands.map(async (cmd) => {
473
+ if (!cmd.parameters)
474
+ return;
475
+ await Promise.all(cmd.parameters.map(async (p) => {
476
+ if (p.type !== 'select' || !p.getOptions || (p.options && p.options.length))
477
+ return;
478
+ const cacheKey = `${cmd.id ?? cmd.command}:${p.name}`;
479
+ const cached = this.selectOptionsCache.get(cacheKey);
480
+ const now = Date.now();
481
+ if (cached && now - cached.ts < 60_000) {
482
+ p.options = cached.options;
483
+ return;
484
+ }
485
+ try {
486
+ const opts = await p.getOptions();
487
+ this.selectOptionsCache.set(cacheKey, { options: opts, ts: now });
488
+ p.options = opts;
489
+ }
490
+ catch {
491
+ // If options fail to load, keep as-is.
492
+ }
493
+ }));
494
+ }));
495
+ return commands;
496
+ }
497
+ getCommandById(id) {
498
+ for (const cmd of this.commands.values()) {
499
+ if (cmd.id === id)
500
+ return cmd;
501
+ }
502
+ return undefined;
503
+ }
504
+ listAllCommands() {
505
+ const options = Array.from(this.commands.values()).map((cmd) => ({
506
+ label: cmd.command,
507
+ value: cmd.id ?? cmd.command,
508
+ commandId: cmd.id ?? cmd.command,
509
+ }));
510
+ return {
511
+ message: 'Here are the available commands:',
512
+ type: 'ambiguous',
513
+ options,
514
+ };
515
+ }
516
+ normalizeResponse(result) {
517
+ if (typeof result === 'string') {
518
+ return { message: result, type: 'success' };
519
+ }
520
+ if (result && typeof result === 'object') {
521
+ return result;
522
+ }
523
+ return { message: 'Done', type: 'success' };
524
+ }
525
+ isStructured(input) {
526
+ return typeof input['commandId'] === 'string';
527
+ }
528
+ getCommandIdentifier(cmd) {
529
+ if (!cmd.id) {
530
+ cmd.id = cmd.command.toLowerCase().replace(/\s+/g, '_');
531
+ }
532
+ return cmd.id;
533
+ }
534
+ /** List all registered commands */
535
+ getCommands() {
536
+ return Array.from(this.commands.keys());
537
+ }
538
+ }
539
+
540
+ class TextToSpeech {
541
+ synth = (typeof window !== 'undefined' ? window.speechSynthesis : null);
542
+ speak(text, options) {
543
+ if (!this.synth) {
544
+ // eslint-disable-next-line no-console
545
+ console.error('SpeechSynthesis API is not supported in this environment.');
546
+ return;
547
+ }
548
+ const utterance = new SpeechSynthesisUtterance(text);
549
+ if (options) {
550
+ utterance.pitch = options.pitch || 1;
551
+ utterance.rate = options.rate || 1;
552
+ utterance.volume = options.volume || 1;
553
+ }
554
+ // Notify listeners (e.g., VoiceProcessor) to pause recognition while speaking
555
+ if (typeof window !== 'undefined') {
556
+ utterance.onstart = () => {
557
+ window.dispatchEvent(new CustomEvent('foisit:tts-start'));
558
+ };
559
+ utterance.onend = () => {
560
+ // eslint-disable-next-line no-console
561
+ console.log('Speech finished.');
562
+ window.dispatchEvent(new CustomEvent('foisit:tts-end'));
563
+ };
564
+ }
565
+ utterance.onerror = (event) => {
566
+ // eslint-disable-next-line no-console
567
+ console.error('Error during speech synthesis:', event.error);
568
+ };
569
+ this.synth.speak(utterance);
570
+ }
571
+ stopSpeaking() {
572
+ if (this.synth) {
573
+ this.synth.cancel();
574
+ }
575
+ }
576
+ }
577
+
578
+ class FallbackHandler {
579
+ fallbackMessage = 'Sorry, I didn’t understand that.';
580
+ setFallbackMessage(message) {
581
+ this.fallbackMessage = message;
582
+ }
583
+ handleFallback(transcript) {
584
+ // eslint-disable-next-line no-console
585
+ if (transcript)
586
+ console.log(`Fallback triggered for: "${transcript}"`);
587
+ console.log(this.fallbackMessage);
588
+ new TextToSpeech().speak(this.fallbackMessage);
589
+ }
590
+ getFallbackMessage() {
591
+ return this.fallbackMessage;
592
+ }
593
+ }
594
+
595
+ /* eslint-disable no-unused-vars */
596
+
597
+ const getRecognitionCtor = () => {
598
+ if (typeof window === 'undefined')
599
+ return null;
600
+ const w = window;
601
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
602
+ };
603
+ class VoiceProcessor {
604
+ recognition = null;
605
+ isListening = false;
606
+ engineActive = false; // true after onstart, false after onend
607
+ intentionallyStopped = false;
608
+ restartAllowed = true;
609
+ lastStart = 0;
610
+ backoffMs = 250;
611
+ destroyed = false;
612
+ resultCallback = null;
613
+ ttsSpeaking = false;
614
+ visibilityHandler;
615
+ statusCallback;
616
+ debugEnabled = true; // enable debug logs to aid diagnosis
617
+ restartTimer = null;
618
+ prewarmed = false;
619
+ hadResultThisSession = false;
620
+ // Debug logger helpers
621
+ log(message) {
622
+ if (this.debugEnabled && message) {
623
+ // eslint-disable-next-line no-console
624
+ console.log('[VoiceProcessor]', message);
625
+ }
626
+ }
627
+ warn(message) {
628
+ if (this.debugEnabled && message) {
629
+ // eslint-disable-next-line no-console
630
+ console.warn('[VoiceProcessor]', message);
631
+ }
632
+ }
633
+ error(message) {
634
+ if (this.debugEnabled && message) {
635
+ // eslint-disable-next-line no-console
636
+ console.error('[VoiceProcessor]', message);
637
+ }
638
+ }
639
+ constructor(language = 'en-US', options = {}) {
640
+ const Ctor = getRecognitionCtor();
641
+ if (Ctor) {
642
+ this.recognition = new Ctor();
643
+ this.recognition.lang = language;
644
+ this.recognition.interimResults = options.interimResults ?? true;
645
+ this.recognition.continuous = options.continuous ?? true;
646
+ this.recognition.onresult = (event) => this.handleResult(event, options);
647
+ this.recognition.onend = () => this.handleEnd();
648
+ this.recognition.onstart = () => {
649
+ this.log('recognition onstart');
650
+ this.engineActive = true;
651
+ this.hadResultThisSession = false;
652
+ // Clear any pending restart attempts now that we are active
653
+ if (this.restartTimer) {
654
+ clearTimeout(this.restartTimer);
655
+ this.restartTimer = null;
656
+ }
657
+ this.backoffMs = 250;
658
+ if (this.isListening && !this.ttsSpeaking) {
659
+ this.emitStatus('listening');
660
+ }
661
+ };
662
+ const vrec = this.recognition;
663
+ vrec.onaudiostart = () => this.log('onaudiostart');
664
+ vrec.onsoundstart = () => this.log('onsoundstart');
665
+ vrec.onspeechstart = () => this.log('onspeechstart');
666
+ vrec.onspeechend = () => this.log('onspeechend');
667
+ vrec.onsoundend = () => this.log('onsoundend');
668
+ vrec.onaudioend = () => this.log('onaudioend');
669
+ this.recognition.onerror = (event) => this.handleError(event);
670
+ }
671
+ else {
672
+ // No native support; keep recognition null and let consumers feature-detect.
673
+ this.recognition = null;
674
+ // If unsupported, immediately report status so UI can show a clear fallback.
675
+ this.emitStatus('unsupported');
676
+ }
677
+ // Pause listening while TTS is speaking via CustomEvents dispatched by TextToSpeech
678
+ if (typeof window !== 'undefined') {
679
+ window.addEventListener('foisit:tts-start', this.onTTSStart);
680
+ window.addEventListener('foisit:tts-end', this.onTTSEnd);
681
+ // Pause on tab hide, resume on show
682
+ this.visibilityHandler = () => {
683
+ if (typeof document !== 'undefined' && document.hidden) {
684
+ try {
685
+ this.recognition?.stop();
686
+ }
687
+ catch { /* no-op */ }
688
+ this.emitStatus(this.ttsSpeaking ? 'speaking' : 'idle');
689
+ }
690
+ else if (this.isListening && !this.ttsSpeaking) {
691
+ this.safeRestart();
692
+ }
693
+ };
694
+ if (typeof document !== 'undefined') {
695
+ document.addEventListener('visibilitychange', this.visibilityHandler);
696
+ }
697
+ }
698
+ else {
699
+ // No window/document on server — do not register browser-only handlers
700
+ this.visibilityHandler = undefined;
701
+ }
702
+ }
703
+ /** Check if SpeechRecognition is available */
704
+ isSupported() {
705
+ return getRecognitionCtor() !== null;
706
+ }
707
+ /** Allow consumers (wrappers) to observe status changes */
708
+ onStatusChange(callback) {
709
+ this.statusCallback = callback;
710
+ }
711
+ /** Start listening for speech input */
712
+ startListening(callback) {
713
+ if (!this.isSupported() || !this.recognition) {
714
+ this.warn('VoiceProcessor: SpeechRecognition is not supported in this browser.');
715
+ this.emitStatus('unsupported');
716
+ return;
717
+ }
718
+ if (this.isListening) {
719
+ this.warn('VoiceProcessor: Already listening.');
720
+ this.resultCallback = callback; // update callback if needed
721
+ return;
722
+ }
723
+ this.resultCallback = callback;
724
+ this.intentionallyStopped = false;
725
+ this.restartAllowed = true;
726
+ this.isListening = true;
727
+ this.emitStatus('listening');
728
+ // Warm up mic to avoid immediate onend in some environments
729
+ this.prewarmAudio().finally(() => {
730
+ this.safeRestart();
731
+ });
732
+ }
733
+ /** Stop listening for speech input */
734
+ stopListening() {
735
+ this.intentionallyStopped = true;
736
+ this.restartAllowed = false;
737
+ this.isListening = false;
738
+ this.emitStatus(this.ttsSpeaking ? 'speaking' : 'idle');
739
+ try {
740
+ this.recognition?.stop();
741
+ }
742
+ catch { /* no-op */ }
743
+ }
744
+ /** Clean up listeners */
745
+ destroy() {
746
+ this.destroyed = true;
747
+ this.stopListening();
748
+ this.resultCallback = null;
749
+ window.removeEventListener('foisit:tts-start', this.onTTSStart);
750
+ window.removeEventListener('foisit:tts-end', this.onTTSEnd);
751
+ if (this.visibilityHandler) {
752
+ document.removeEventListener('visibilitychange', this.visibilityHandler);
753
+ this.visibilityHandler = undefined;
754
+ }
755
+ }
756
+ /** Handle recognized speech results */
757
+ handleResult(event, options) {
758
+ if (!this.resultCallback)
759
+ return;
760
+ const threshold = options.confidenceThreshold ?? 0.6;
761
+ // Emit each alternative result chunk; concatenate finals client-side if desired
762
+ for (let i = event.resultIndex; i < event.results.length; i++) {
763
+ const res = event.results[i];
764
+ const alt = res && res[0];
765
+ const transcript = alt?.transcript?.trim?.() || '';
766
+ const confidence = alt?.confidence ?? 0;
767
+ if (!transcript)
768
+ continue;
769
+ if (!res.isFinal && options.interimResults === false)
770
+ continue; // skip interim if disabled
771
+ if (res.isFinal && confidence < threshold)
772
+ continue; // ignore low-confidence finals
773
+ try {
774
+ this.hadResultThisSession = true;
775
+ this.resultCallback(transcript, !!res.isFinal);
776
+ }
777
+ catch {
778
+ // Swallow user callback exceptions to avoid killing recognition
779
+ this.error('VoiceProcessor: result callback error');
780
+ }
781
+ }
782
+ }
783
+ /** Handle session end */
784
+ handleEnd() {
785
+ this.log('recognition onend');
786
+ this.engineActive = false;
787
+ if (this.destroyed || this.intentionallyStopped || !this.restartAllowed || this.ttsSpeaking) {
788
+ if (!this.ttsSpeaking) {
789
+ this.isListening = false;
790
+ this.emitStatus('idle');
791
+ }
792
+ return;
793
+ }
794
+ // We are still in "listening" mode logically; recognition ended spuriously.
795
+ this.isListening = true;
796
+ // Best-effort restart (continuous can still end spuriously)
797
+ this.scheduleRestart();
798
+ }
799
+ /** Handle errors during speech recognition */
800
+ handleError(event) {
801
+ const err = event?.error;
802
+ this.warn(`Error occurred: ${err ?? 'unknown'}`);
803
+ // Fatal errors: don't spin
804
+ const fatal = ['not-allowed', 'service-not-allowed', 'bad-grammar', 'language-not-supported'];
805
+ if (err && fatal.includes(err)) {
806
+ this.intentionallyStopped = true;
807
+ this.restartAllowed = false;
808
+ this.isListening = false;
809
+ this.emitStatus('error', { error: err });
810
+ return;
811
+ }
812
+ // For transient errors, try restart
813
+ this.scheduleRestart();
814
+ }
815
+ safeRestart() {
816
+ if (!this.recognition)
817
+ return;
818
+ if (this.engineActive) {
819
+ this.log('safeRestart: engine already active, skipping start');
820
+ return;
821
+ }
822
+ const now = Date.now();
823
+ if (now - this.lastStart < 300) {
824
+ setTimeout(() => this.safeRestart(), 300);
825
+ return;
826
+ }
827
+ this.lastStart = now;
828
+ try {
829
+ this.log('calling recognition.start()');
830
+ this.recognition.start();
831
+ this.backoffMs = 250; // reset backoff on successful start
832
+ if (this.isListening && !this.ttsSpeaking) {
833
+ this.emitStatus('listening');
834
+ }
835
+ }
836
+ catch {
837
+ this.error('recognition.start() threw; scheduling restart');
838
+ this.scheduleRestart();
839
+ }
840
+ }
841
+ scheduleRestart() {
842
+ if (this.destroyed || this.intentionallyStopped || !this.restartAllowed || this.ttsSpeaking)
843
+ return;
844
+ if (this.engineActive) {
845
+ this.log('scheduleRestart: engine active, not scheduling');
846
+ return;
847
+ }
848
+ const delay = Math.min(this.backoffMs, 2000);
849
+ this.log(`scheduleRestart in ${delay}ms`);
850
+ if (this.restartTimer) {
851
+ // A restart is already scheduled; keep the earliest
852
+ this.log('scheduleRestart: restart already scheduled');
853
+ return;
854
+ }
855
+ this.restartTimer = setTimeout(() => {
856
+ this.restartTimer = null;
857
+ if (this.destroyed || this.intentionallyStopped || !this.restartAllowed || this.ttsSpeaking)
858
+ return;
859
+ this.safeRestart();
860
+ }, delay);
861
+ this.backoffMs = Math.min(this.backoffMs * 2, 2000);
862
+ }
863
+ async prewarmAudio() {
864
+ if (this.prewarmed)
865
+ return;
866
+ try {
867
+ if (typeof navigator === 'undefined' || !('mediaDevices' in navigator))
868
+ return;
869
+ const md = navigator.mediaDevices;
870
+ if (!md?.getUserMedia)
871
+ return;
872
+ this.log('prewarmAudio: requesting mic');
873
+ const stream = await md.getUserMedia({ audio: true });
874
+ for (const track of stream.getTracks())
875
+ track.stop();
876
+ this.prewarmed = true;
877
+ this.log('prewarmAudio: mic ready');
878
+ }
879
+ catch {
880
+ this.warn('prewarmAudio: failed to get mic');
881
+ }
882
+ }
883
+ onTTSStart = () => {
884
+ this.ttsSpeaking = true;
885
+ try {
886
+ this.recognition?.stop();
887
+ }
888
+ catch { /* no-op */ }
889
+ // If we were listening, switch to speaking state
890
+ if (this.isListening) {
891
+ this.emitStatus('speaking');
892
+ }
893
+ };
894
+ onTTSEnd = () => {
895
+ this.ttsSpeaking = false;
896
+ if (this.isListening && this.restartAllowed) {
897
+ this.safeRestart();
898
+ }
899
+ else {
900
+ this.emitStatus(this.isListening ? 'listening' : 'idle');
901
+ }
902
+ };
903
+ emitStatus(status, details) {
904
+ if (!this.statusCallback)
905
+ return;
906
+ try {
907
+ this.statusCallback(status, details);
908
+ }
909
+ catch {
910
+ // Never let consumer errors break recognition
911
+ this.error('VoiceProcessor: status callback error');
912
+ }
913
+ }
914
+ }
915
+
916
+ class GestureHandler {
917
+ lastTap = 0;
918
+ tapCount = 0;
919
+ tapTimeout;
920
+ dblClickListener;
921
+ touchEndListener;
922
+ clickListener;
923
+ /**
924
+ * Sets up triple-click and triple-tap listeners
925
+ * @param onTripleClickOrTap Callback to execute when a triple-click or triple-tap is detected
926
+ */
927
+ setupTripleTapListener(onTripleClickOrTap) {
928
+ // Ensure we never stack multiple listeners for the same instance
929
+ this.destroy();
930
+ // Handle triple-click (desktop) - implement manually since no built-in triple-click event
931
+ this.clickListener = () => {
932
+ this.tapCount++;
933
+ if (this.tapCount === 1) {
934
+ // Start timeout for first click
935
+ this.tapTimeout = window.setTimeout(() => {
936
+ this.tapCount = 0;
937
+ }, 500); // Reset after 500ms
938
+ }
939
+ else if (this.tapCount === 3) {
940
+ // Triple click detected
941
+ clearTimeout(this.tapTimeout);
942
+ this.tapCount = 0;
943
+ onTripleClickOrTap();
944
+ }
945
+ };
946
+ document.addEventListener('click', this.clickListener);
947
+ // Handle triple-tap (mobile)
948
+ this.touchEndListener = () => {
949
+ const currentTime = new Date().getTime();
950
+ const tapInterval = currentTime - this.lastTap;
951
+ if (tapInterval < 500 && tapInterval > 0) {
952
+ this.tapCount++;
953
+ if (this.tapCount === 3) {
954
+ this.tapCount = 0;
955
+ onTripleClickOrTap();
956
+ }
957
+ }
958
+ else {
959
+ this.tapCount = 1;
960
+ }
961
+ this.lastTap = currentTime;
962
+ };
963
+ document.addEventListener('touchend', this.touchEndListener);
964
+ }
965
+ destroy() {
966
+ if (this.dblClickListener) {
967
+ document.removeEventListener('dblclick', this.dblClickListener);
968
+ }
969
+ if (this.touchEndListener) {
970
+ document.removeEventListener('touchend', this.touchEndListener);
971
+ }
972
+ if (this.clickListener) {
973
+ document.removeEventListener('click', this.clickListener);
974
+ }
975
+ if (this.tapTimeout) {
976
+ clearTimeout(this.tapTimeout);
977
+ }
978
+ this.dblClickListener = undefined;
979
+ this.touchEndListener = undefined;
980
+ this.clickListener = undefined;
981
+ this.tapTimeout = undefined;
982
+ this.tapCount = 0;
983
+ }
984
+ }
985
+ function injectStyles() {
986
+ // Check if the styles are already injected
987
+ const existingStyle = document.querySelector('#assistant-styles');
988
+ if (existingStyle) {
989
+ console.log('Styles already injected');
990
+ return; // Avoid duplicate injection
991
+ }
992
+ // Create and inject the style element
993
+ const style = document.createElement('style');
994
+ style.id = 'assistant-styles';
995
+ style.innerHTML = `
996
+ /* Rounded shape with gradient animation */
997
+ .gradient-indicator {
998
+ position: fixed;
999
+ top: 20px;
1000
+ right: 20px;
1001
+ width: 60px;
1002
+ height: 60px;
1003
+ border-radius: 50%;
1004
+ background: linear-gradient(135deg, #ff6ec4, #7873f5, #5e8cff, #6ed0f6);
1005
+ box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
1006
+ animation: amoeba 5s infinite ease-in-out;
1007
+ z-index: 9999; /* Ensure it's above all other elements */
1008
+ }
1009
+
1010
+ /* Amoeba effect for the borders */
1011
+ @keyframes amoeba {
1012
+ 0% {
1013
+ border-radius: 50%;
1014
+ }
1015
+ 25% {
1016
+ border-radius: 40% 60% 60% 40%;
1017
+ }
1018
+ 50% {
1019
+ border-radius: 60% 40% 40% 60%;
1020
+ }
1021
+ 75% {
1022
+ border-radius: 40% 60% 60% 40%;
1023
+ }
1024
+ 100% {
1025
+ border-radius: 50%;
1026
+ }
1027
+ }
1028
+ `;
1029
+ document.head.appendChild(style);
1030
+ console.log('Gradient styles injected');
1031
+ }
1032
+ function addGradientAnimation() {
1033
+ // Check if the gradient indicator already exists
1034
+ if (document.querySelector('#gradient-indicator')) {
1035
+ return; // Avoid duplicate indicators
1036
+ }
1037
+ // Create a new div element
1038
+ const gradientDiv = document.createElement('div');
1039
+ gradientDiv.id = 'gradient-indicator';
1040
+ // Inject styles dynamically
1041
+ injectStyles();
1042
+ // Add the gradient-indicator class to the div
1043
+ gradientDiv.classList.add('gradient-indicator');
1044
+ // Append the div to the body
1045
+ document.body.appendChild(gradientDiv);
1046
+ console.log('Gradient indicator added to the DOM');
1047
+ }
1048
+ function removeGradientAnimation() {
1049
+ const gradientDiv = document.querySelector('#gradient-indicator');
1050
+ if (gradientDiv) {
1051
+ gradientDiv.remove();
1052
+ console.log('Gradient indicator removed from the DOM');
1053
+ }
1054
+ }
1055
+
1056
+ function isBrowser() {
1057
+ return typeof window !== 'undefined' && typeof document !== 'undefined';
1058
+ }
1059
+
1060
+ class StateManager {
1061
+ state = 'idle';
1062
+ // eslint-disable-next-line no-unused-vars
1063
+ subscribers = [];
1064
+ getState() {
1065
+ return this.state;
1066
+ }
1067
+ setState(state) {
1068
+ this.state = state;
1069
+ this.notifySubscribers();
1070
+ console.log('State updated:', state);
1071
+ // Dynamically update body class based on state
1072
+ if (state === 'listening') {
1073
+ addGradientAnimation();
1074
+ }
1075
+ else {
1076
+ removeGradientAnimation();
1077
+ }
1078
+ }
1079
+ // eslint-disable-next-line no-unused-vars
1080
+ subscribe(callback) {
1081
+ this.subscribers.push(callback);
1082
+ }
1083
+ notifySubscribers() {
1084
+ this.subscribers.forEach((callback) => callback(this.state));
1085
+ }
1086
+ }
1087
+
1088
+ /* eslint-disable no-unused-vars */
1089
+ class OverlayManager {
1090
+ container = null;
1091
+ chatWindow = null;
1092
+ messagesContainer = null;
1093
+ input = null;
1094
+ isOpen = false;
1095
+ onSubmit;
1096
+ onClose;
1097
+ loadingEl = null;
1098
+ config;
1099
+ gestureHandler;
1100
+ // Command handlers registered programmatically by the host app
1101
+ commandHandlers = new Map();
1102
+ // Optional external executor (e.g. CommandHandler from wrappers)
1103
+ externalCommandExecutor;
1104
+ active = isBrowser();
1105
+ constructor(config) {
1106
+ this.config = config;
1107
+ if (this.active)
1108
+ this.init();
1109
+ }
1110
+ /** Register a command handler that can be invoked programmatically via `runCommand` */
1111
+ registerCommandHandler(commandId, handler) {
1112
+ if (!commandId || typeof handler !== 'function')
1113
+ return;
1114
+ this.commandHandlers.set(commandId, handler);
1115
+ }
1116
+ /** Check whether a programmatic handler is registered locally */
1117
+ hasCommandHandler(commandId) {
1118
+ return this.commandHandlers.has(commandId);
1119
+ }
1120
+ /** Set an external executor (used to run commands registered via CommandHandler) */
1121
+ setExternalCommandExecutor(exec) {
1122
+ this.externalCommandExecutor = exec;
1123
+ }
1124
+ /** Unregister a previously registered command handler */
1125
+ unregisterCommandHandler(commandId) {
1126
+ if (!commandId)
1127
+ return;
1128
+ this.commandHandlers.delete(commandId);
1129
+ }
1130
+ /**
1131
+ * Run a registered command by id. Options:
1132
+ * - `params`: passed to the handler
1133
+ * - `openOverlay`: if true, open the overlay before running
1134
+ * - `showInvocation`: if true, show the invocation as a user message
1135
+ */
1136
+ async runCommand(options) {
1137
+ if (!options || !options.commandId)
1138
+ throw new Error('runCommand requires a commandId');
1139
+ const { commandId, params, openOverlay = true, showInvocation = true } = options;
1140
+ if (openOverlay && !this.isOpen)
1141
+ this.toggle();
1142
+ const handler = this.commandHandlers.get(commandId);
1143
+ // Only show a user invocation bubble when invoking a handler registered
1144
+ // directly on the overlay (programmatic handlers). If we're delegating
1145
+ // to an external executor (e.g. CommandHandler), suppress the user bubble
1146
+ // so the overlay starts with the system/AI response.
1147
+ if (handler && showInvocation && this.messagesContainer) {
1148
+ this.addMessage(`Command: ${commandId}`, 'user');
1149
+ }
1150
+ if (handler) {
1151
+ try {
1152
+ this.showLoading();
1153
+ const result = await handler(params);
1154
+ this.hideLoading();
1155
+ if (typeof result === 'string') {
1156
+ this.addMessage(result, 'system');
1157
+ }
1158
+ else if (result && typeof result === 'object') {
1159
+ try {
1160
+ this.addMessage(JSON.stringify(result, null, 2), 'system');
1161
+ }
1162
+ catch (_) {
1163
+ this.addMessage(String(result), 'system');
1164
+ }
1165
+ }
1166
+ else if (result === undefined || result === null) {
1167
+ // no-op
1168
+ }
1169
+ else {
1170
+ this.addMessage(String(result), 'system');
1171
+ }
1172
+ return result;
1173
+ }
1174
+ catch (err) {
1175
+ this.hideLoading();
1176
+ this.addMessage(`Command "${commandId}" failed: ${String(err)}`, 'system');
1177
+ throw err;
1178
+ }
1179
+ }
1180
+ // No local handler — delegate to external executor if available. The
1181
+ // external executor (CommandHandler) returns an InteractiveResponse which
1182
+ // the caller (AssistantService) should process and render via its
1183
+ // `processResponse` method. Here we just return that object.
1184
+ if (this.externalCommandExecutor) {
1185
+ try {
1186
+ this.showLoading();
1187
+ const resp = await this.externalCommandExecutor({ commandId, params });
1188
+ this.hideLoading();
1189
+ return resp;
1190
+ }
1191
+ catch (err) {
1192
+ this.hideLoading();
1193
+ this.addMessage(`Command "${commandId}" failed: ${String(err)}`, 'system');
1194
+ throw err;
1195
+ }
1196
+ }
1197
+ // No handler anywhere
1198
+ this.addMessage(`No handler registered for command "${commandId}".`, 'system');
1199
+ return undefined;
1200
+ }
1201
+ init() {
1202
+ if (this.container)
1203
+ return;
1204
+ this.injectOverlayStyles();
1205
+ // Reuse an existing overlay container if one already exists on the page.
1206
+ // This prevents duplicate overlays when multiple assistant instances are created (e.g., React StrictMode).
1207
+ const existing = document.getElementById('foisit-overlay-container');
1208
+ if (existing && existing instanceof HTMLElement) {
1209
+ this.container = existing;
1210
+ this.chatWindow = existing.querySelector('.foisit-chat');
1211
+ this.messagesContainer = existing.querySelector('.foisit-messages');
1212
+ this.input = existing.querySelector('input.foisit-input');
1213
+ if (this.config.floatingButton?.visible !== false && !existing.querySelector('.foisit-floating-btn')) {
1214
+ this.renderFloatingButton();
1215
+ }
1216
+ if (!this.chatWindow) {
1217
+ this.renderChatWindow();
1218
+ }
1219
+ if (this.config.enableGestureActivation) {
1220
+ this.gestureHandler = new GestureHandler();
1221
+ this.gestureHandler.setupTripleTapListener(() => this.toggle());
1222
+ }
1223
+ return;
1224
+ }
1225
+ this.container = document.createElement('div');
1226
+ this.container.id = 'foisit-overlay-container';
1227
+ this.container.className = 'foisit-overlay-container';
1228
+ document.body.appendChild(this.container);
1229
+ if (this.config.floatingButton?.visible !== false) {
1230
+ this.renderFloatingButton();
1231
+ }
1232
+ this.renderChatWindow();
1233
+ if (this.config.enableGestureActivation) {
1234
+ this.gestureHandler = new GestureHandler();
1235
+ this.gestureHandler.setupTripleTapListener(() => this.toggle());
1236
+ }
1237
+ }
1238
+ renderFloatingButton() {
1239
+ const btn = document.createElement('button');
1240
+ btn.innerHTML = this.config.floatingButton?.customHtml || '🎙️';
1241
+ const bottom = this.config.floatingButton?.position?.bottom || '20px';
1242
+ const right = this.config.floatingButton?.position?.right || '20px';
1243
+ btn.className = 'foisit-floating-btn';
1244
+ btn.style.bottom = bottom;
1245
+ btn.style.right = right;
1246
+ btn.onclick = () => this.toggle();
1247
+ btn.onmouseenter = () => (btn.style.transform = 'scale(1.05)');
1248
+ btn.onmouseleave = () => (btn.style.transform = 'scale(1)');
1249
+ this.container?.appendChild(btn);
1250
+ }
1251
+ renderChatWindow() {
1252
+ if (this.chatWindow)
1253
+ return;
1254
+ this.chatWindow = document.createElement('div');
1255
+ this.chatWindow.className = 'foisit-chat';
1256
+ // Header
1257
+ const header = document.createElement('div');
1258
+ header.className = 'foisit-header';
1259
+ const title = document.createElement('span');
1260
+ title.className = 'foisit-title';
1261
+ title.textContent = 'Foisit';
1262
+ const closeButton = document.createElement('button');
1263
+ closeButton.type = 'button';
1264
+ closeButton.className = 'foisit-close';
1265
+ closeButton.setAttribute('aria-label', 'Close');
1266
+ closeButton.innerHTML = '&times;';
1267
+ closeButton.addEventListener('click', () => this.toggle());
1268
+ header.appendChild(title);
1269
+ header.appendChild(closeButton);
1270
+ // Messages Area
1271
+ this.messagesContainer = document.createElement('div');
1272
+ this.messagesContainer.className = 'foisit-messages';
1273
+ // Input Area
1274
+ const inputArea = document.createElement('div');
1275
+ inputArea.className = 'foisit-input-area';
1276
+ this.input = document.createElement('input');
1277
+ this.input.placeholder =
1278
+ this.config.inputPlaceholder || 'Type a command...';
1279
+ this.input.className = 'foisit-input';
1280
+ this.input.addEventListener('keydown', (e) => {
1281
+ if (e.key === 'Enter' && this.input?.value.trim()) {
1282
+ const text = this.input.value.trim();
1283
+ this.input.value = '';
1284
+ if (this.onSubmit)
1285
+ this.onSubmit(text);
1286
+ }
1287
+ });
1288
+ inputArea.appendChild(this.input);
1289
+ this.chatWindow.appendChild(header);
1290
+ this.chatWindow.appendChild(this.messagesContainer);
1291
+ this.chatWindow.appendChild(inputArea);
1292
+ this.container?.appendChild(this.chatWindow);
1293
+ }
1294
+ registerCallbacks(onSubmit, onClose) {
1295
+ if (!this.active)
1296
+ return; // no-op on server
1297
+ this.onSubmit = onSubmit;
1298
+ this.onClose = onClose;
1299
+ }
1300
+ toggle(onSubmit, onClose) {
1301
+ if (!this.active)
1302
+ return; // no-op on server
1303
+ if (onSubmit)
1304
+ this.onSubmit = onSubmit;
1305
+ if (onClose)
1306
+ this.onClose = onClose;
1307
+ this.isOpen = !this.isOpen;
1308
+ if (this.chatWindow) {
1309
+ if (this.isOpen) {
1310
+ // Ensure the container can receive pointer events while open so
1311
+ // click-outside detection works (CSS defaults to pointer-events: none).
1312
+ if (this.container)
1313
+ this.container.style.pointerEvents = 'auto';
1314
+ this.chatWindow.style.display = 'flex';
1315
+ requestAnimationFrame(() => {
1316
+ if (this.chatWindow) {
1317
+ this.chatWindow.style.opacity = '1';
1318
+ this.chatWindow.style.transform = 'translateY(0) scale(1)';
1319
+ }
1320
+ });
1321
+ setTimeout(() => this.input?.focus(), 100);
1322
+ // Add click-outside-to-close listener
1323
+ this.addClickOutsideListener();
1324
+ }
1325
+ else {
1326
+ this.chatWindow.style.opacity = '0';
1327
+ this.chatWindow.style.transform = 'translateY(20px) scale(0.95)';
1328
+ setTimeout(() => {
1329
+ if (this.chatWindow && !this.isOpen) {
1330
+ this.chatWindow.style.display = 'none';
1331
+ }
1332
+ }, 200);
1333
+ if (this.onClose)
1334
+ this.onClose();
1335
+ // Remove click-outside-to-close listener and disable container pointer events
1336
+ this.removeClickOutsideListener();
1337
+ if (this.container)
1338
+ this.container.style.pointerEvents = 'none';
1339
+ }
1340
+ }
1341
+ }
1342
+ addClickOutsideListener() {
1343
+ if (!this.container)
1344
+ return;
1345
+ // Remove any existing listener first
1346
+ this.removeClickOutsideListener();
1347
+ // Add click listener to container
1348
+ this.container.addEventListener('click', this.handleClickOutside);
1349
+ }
1350
+ removeClickOutsideListener() {
1351
+ if (!this.container)
1352
+ return;
1353
+ this.container.removeEventListener('click', this.handleClickOutside);
1354
+ }
1355
+ handleClickOutside = (event) => {
1356
+ const target = event.target;
1357
+ // Don't close if clicking on the chat window or its contents
1358
+ if (this.chatWindow && this.chatWindow.contains(target)) {
1359
+ return;
1360
+ }
1361
+ // Don't close if clicking on the floating button
1362
+ if (target.closest('.foisit-floating-btn')) {
1363
+ return;
1364
+ }
1365
+ // Close the overlay
1366
+ this.toggle();
1367
+ };
1368
+ addMessage(text, type) {
1369
+ if (!this.messagesContainer)
1370
+ return;
1371
+ const msg = document.createElement('div');
1372
+ // Render markdown for system (AI) messages, keep user messages as plain text
1373
+ if (type === 'system') {
1374
+ msg.innerHTML = this.renderMarkdown(text);
1375
+ }
1376
+ else {
1377
+ msg.textContent = text;
1378
+ }
1379
+ msg.className = type === 'user' ? 'foisit-bubble user' : 'foisit-bubble system';
1380
+ // Entrance animation: fade + slight upward motion. Duration scales so
1381
+ // short messages animate a bit slower, long messages appear faster.
1382
+ const length = (text || '').length || 0;
1383
+ // Base duration (ms) reduced for longer content
1384
+ const duration = Math.max(120, 700 - Math.min(600, Math.floor(length * 6)));
1385
+ // Prepare initial state
1386
+ msg.style.opacity = '0';
1387
+ msg.style.transform = 'translateY(8px)';
1388
+ msg.style.transition = 'none';
1389
+ this.messagesContainer.appendChild(msg);
1390
+ // Animate entrance and scroll instantly to bottom
1391
+ this.animateMessageEntrance(msg, duration);
1392
+ this.scrollToBottom();
1393
+ }
1394
+ addOptions(options) {
1395
+ if (!this.messagesContainer)
1396
+ return;
1397
+ const container = document.createElement('div');
1398
+ container.className = 'foisit-options-container';
1399
+ options.forEach((opt) => {
1400
+ const btn = document.createElement('button');
1401
+ btn.textContent = opt.label;
1402
+ btn.className = 'foisit-option-chip';
1403
+ btn.setAttribute('type', 'button');
1404
+ btn.setAttribute('aria-label', opt.label);
1405
+ const clickPayload = () => {
1406
+ // If commandId is provided, submit a structured payload so the handler
1407
+ // can run it deterministically: { commandId, params? }
1408
+ if (opt.commandId) {
1409
+ if (this.onSubmit)
1410
+ this.onSubmit({ commandId: opt.commandId });
1411
+ return;
1412
+ }
1413
+ // Otherwise fall back to value or label string
1414
+ const value = (opt && typeof opt.value === 'string' && opt.value.trim()) ? opt.value : opt.label;
1415
+ if (this.onSubmit)
1416
+ this.onSubmit(value);
1417
+ };
1418
+ btn.onclick = clickPayload;
1419
+ btn.onkeydown = (e) => {
1420
+ if (e.key === 'Enter' || e.key === ' ') {
1421
+ e.preventDefault();
1422
+ clickPayload();
1423
+ }
1424
+ };
1425
+ container.appendChild(btn);
1426
+ });
1427
+ this.messagesContainer.appendChild(container);
1428
+ this.scrollToBottom();
1429
+ }
1430
+ addForm(message, fields, onSubmit) {
1431
+ if (!this.messagesContainer)
1432
+ return;
1433
+ this.addMessage(message, 'system');
1434
+ const form = document.createElement('form');
1435
+ form.className = 'foisit-form';
1436
+ const controls = [];
1437
+ const createLabel = (text, required) => {
1438
+ const label = document.createElement('div');
1439
+ label.className = 'foisit-form-label';
1440
+ label.innerHTML = text + (required ? ' <span class="foisit-req-star">*</span>' : '');
1441
+ return label;
1442
+ };
1443
+ const createError = () => {
1444
+ const error = document.createElement('div');
1445
+ error.className = 'foisit-form-error';
1446
+ error.style.display = 'none';
1447
+ return error;
1448
+ };
1449
+ (fields ?? []).forEach((field) => {
1450
+ const wrapper = document.createElement('div');
1451
+ wrapper.className = 'foisit-form-group';
1452
+ const labelText = field.description || field.name;
1453
+ wrapper.appendChild(createLabel(labelText, field.required));
1454
+ let inputEl;
1455
+ if (field.type === 'select') {
1456
+ const select = document.createElement('select');
1457
+ select.className = 'foisit-form-input';
1458
+ const placeholderOpt = document.createElement('option');
1459
+ placeholderOpt.value = '';
1460
+ placeholderOpt.textContent = 'Select...';
1461
+ select.appendChild(placeholderOpt);
1462
+ const populate = (options) => {
1463
+ (options ?? []).forEach((opt) => {
1464
+ const o = document.createElement('option');
1465
+ o.value = String(opt.value ?? opt.label ?? '');
1466
+ o.textContent = String(opt.label ?? opt.value ?? '');
1467
+ select.appendChild(o);
1468
+ });
1469
+ };
1470
+ if (Array.isArray(field.options) && field.options.length) {
1471
+ populate(field.options);
1472
+ }
1473
+ else if (typeof field.getOptions === 'function') {
1474
+ const getOptions = field.getOptions;
1475
+ const loadingOpt = document.createElement('option');
1476
+ loadingOpt.value = '';
1477
+ loadingOpt.textContent = 'Loading...';
1478
+ select.appendChild(loadingOpt);
1479
+ Promise.resolve()
1480
+ .then(() => getOptions())
1481
+ .then((opts) => {
1482
+ while (select.options.length > 1)
1483
+ select.remove(1);
1484
+ populate(opts);
1485
+ })
1486
+ .catch(() => {
1487
+ while (select.options.length > 1)
1488
+ select.remove(1);
1489
+ const errOpt = document.createElement('option');
1490
+ errOpt.value = '';
1491
+ errOpt.textContent = 'Error loading options';
1492
+ select.appendChild(errOpt);
1493
+ });
1494
+ }
1495
+ if (field.defaultValue != null) {
1496
+ select.value = String(field.defaultValue);
1497
+ }
1498
+ inputEl = select;
1499
+ }
1500
+ else if (field.type === 'file') {
1501
+ const ffield = field;
1502
+ const input = document.createElement('input');
1503
+ input.className = 'foisit-form-input';
1504
+ input.type = 'file';
1505
+ if (ffield.accept && Array.isArray(ffield.accept)) {
1506
+ input.accept = ffield.accept.join(',');
1507
+ }
1508
+ if (ffield.multiple)
1509
+ input.multiple = true;
1510
+ if (ffield.capture) {
1511
+ if (ffield.capture === true)
1512
+ input.setAttribute('capture', '');
1513
+ else
1514
+ input.setAttribute('capture', String(ffield.capture));
1515
+ }
1516
+ // Validation state stored on the element via dataset
1517
+ input.addEventListener('change', async () => {
1518
+ const files = Array.from(input.files || []);
1519
+ const errEl = errorEl;
1520
+ errEl.style.display = 'none';
1521
+ errEl.textContent = '';
1522
+ if (files.length === 0)
1523
+ return;
1524
+ // Basic validations: count and sizes
1525
+ const maxFiles = ffield.maxFiles ?? (ffield.multiple ? 10 : 1);
1526
+ if (files.length > maxFiles) {
1527
+ errEl.textContent = `Please select at most ${maxFiles} file(s).`;
1528
+ errEl.style.display = 'block';
1529
+ return;
1530
+ }
1531
+ const maxSize = ffield.maxSizeBytes ?? Infinity;
1532
+ const total = files.reduce((s, f) => s + f.size, 0);
1533
+ if (files.some(f => f.size > maxSize)) {
1534
+ errEl.textContent = `One or more files exceed the maximum size of ${Math.round(maxSize / 1024)} KB.`;
1535
+ errEl.style.display = 'block';
1536
+ return;
1537
+ }
1538
+ const maxTotal = ffield.maxTotalBytes ?? Infinity;
1539
+ if (total > maxTotal) {
1540
+ errEl.textContent = `Total selected files exceed the maximum of ${Math.round(maxTotal / 1024)} KB.`;
1541
+ errEl.style.display = 'block';
1542
+ return;
1543
+ }
1544
+ // Basic mime/extension check
1545
+ if (ffield.accept && Array.isArray(ffield.accept)) {
1546
+ const accepts = ffield.accept;
1547
+ const ok = files.every((file) => {
1548
+ if (!file.type)
1549
+ return true; // can't tell
1550
+ return accepts.some(a => a.startsWith('.') ? file.name.toLowerCase().endsWith(a.toLowerCase()) : file.type === a || file.type.startsWith(a.split('/')[0] + '/'));
1551
+ });
1552
+ if (!ok) {
1553
+ errEl.textContent = 'One or more files have an unsupported type.';
1554
+ errEl.style.display = 'block';
1555
+ return;
1556
+ }
1557
+ }
1558
+ });
1559
+ inputEl = input;
1560
+ }
1561
+ else {
1562
+ const input = document.createElement('input');
1563
+ input.className = 'foisit-form-input';
1564
+ if (field.type === 'string') {
1565
+ input.placeholder = field.placeholder || 'Type here...';
1566
+ }
1567
+ if (field.type === 'number') {
1568
+ input.type = 'number';
1569
+ if (typeof field.min === 'number')
1570
+ input.min = String(field.min);
1571
+ if (typeof field.max === 'number')
1572
+ input.max = String(field.max);
1573
+ if (typeof field.step === 'number')
1574
+ input.step = String(field.step);
1575
+ if (field.defaultValue != null)
1576
+ input.value = String(field.defaultValue);
1577
+ }
1578
+ else if (field.type === 'date') {
1579
+ input.type = 'date';
1580
+ if (typeof field.min === 'string')
1581
+ input.min = field.min;
1582
+ if (typeof field.max === 'string')
1583
+ input.max = field.max;
1584
+ if (field.defaultValue != null)
1585
+ input.value = String(field.defaultValue);
1586
+ }
1587
+ else {
1588
+ input.type = 'text';
1589
+ if (field.defaultValue != null)
1590
+ input.value = String(field.defaultValue);
1591
+ }
1592
+ inputEl = input;
1593
+ }
1594
+ // Add Error element
1595
+ const errorEl = createError();
1596
+ wrapper.appendChild(inputEl);
1597
+ wrapper.appendChild(errorEl); // Append error container
1598
+ controls.push({
1599
+ name: field.name,
1600
+ type: field.type,
1601
+ el: inputEl,
1602
+ required: field.required,
1603
+ });
1604
+ form.appendChild(wrapper);
1605
+ });
1606
+ const actions = document.createElement('div');
1607
+ actions.className = 'foisit-form-actions';
1608
+ const submitBtn = document.createElement('button');
1609
+ submitBtn.type = 'submit';
1610
+ submitBtn.textContent = 'Submit';
1611
+ submitBtn.className = 'foisit-option-chip';
1612
+ submitBtn.style.fontWeight = '600';
1613
+ actions.appendChild(submitBtn);
1614
+ form.appendChild(actions);
1615
+ form.onsubmit = async (e) => {
1616
+ e.preventDefault();
1617
+ const data = {};
1618
+ let hasError = false;
1619
+ // Clear previous errors
1620
+ form.querySelectorAll('.foisit-form-error').forEach(el => {
1621
+ el.style.display = 'none';
1622
+ el.textContent = '';
1623
+ });
1624
+ form.querySelectorAll('.foisit-form-input').forEach(el => {
1625
+ el.classList.remove('foisit-error-border');
1626
+ });
1627
+ for (const c of controls) {
1628
+ // FILE inputs need special handling
1629
+ if (c.type === 'file') {
1630
+ const fileWrapper = c.el.parentElement;
1631
+ const fileErrorEl = fileWrapper?.querySelector('.foisit-form-error');
1632
+ const input = c.el;
1633
+ const files = Array.from(input.files || []);
1634
+ if (c.required && files.length === 0) {
1635
+ hasError = true;
1636
+ input.classList.add('foisit-error-border');
1637
+ if (fileErrorEl) {
1638
+ fileErrorEl.textContent = 'This file is required';
1639
+ fileErrorEl.style.display = 'block';
1640
+ }
1641
+ continue;
1642
+ }
1643
+ if (files.length === 0)
1644
+ continue;
1645
+ // Find corresponding field definition
1646
+ const fieldDef = (fields ?? []).find((f) => f.name === c.name);
1647
+ const delivery = fieldDef?.delivery ?? 'file';
1648
+ // Run optional dimension/duration checks (best-effort)
1649
+ if (fieldDef?.maxWidth || fieldDef?.maxHeight) {
1650
+ // check first image only
1651
+ try {
1652
+ const dims = await this.getImageDimensions(files[0]);
1653
+ if (fieldDef.maxWidth && dims.width > fieldDef.maxWidth) {
1654
+ hasError = true;
1655
+ if (fileErrorEl) {
1656
+ fileErrorEl.textContent = `Image width must be ≤ ${fieldDef.maxWidth}px`;
1657
+ fileErrorEl.style.display = 'block';
1658
+ }
1659
+ continue;
1660
+ }
1661
+ if (fieldDef.maxHeight && dims.height > fieldDef.maxHeight) {
1662
+ hasError = true;
1663
+ if (fileErrorEl) {
1664
+ fileErrorEl.textContent = `Image height must be ≤ ${fieldDef.maxHeight}px`;
1665
+ fileErrorEl.style.display = 'block';
1666
+ }
1667
+ continue;
1668
+ }
1669
+ }
1670
+ catch {
1671
+ // ignore dimension check failures
1672
+ }
1673
+ }
1674
+ if (fieldDef?.maxDurationSec) {
1675
+ try {
1676
+ const dur = await this.getMediaDuration(files[0]);
1677
+ if (dur && dur > fieldDef.maxDurationSec) {
1678
+ hasError = true;
1679
+ if (fileErrorEl) {
1680
+ fileErrorEl.textContent = `Media duration must be ≤ ${fieldDef.maxDurationSec}s`;
1681
+ fileErrorEl.style.display = 'block';
1682
+ }
1683
+ continue;
1684
+ }
1685
+ }
1686
+ catch {
1687
+ // ignore
1688
+ }
1689
+ }
1690
+ // Prepare payload according to delivery
1691
+ if (delivery === 'file') {
1692
+ data[c.name] = fieldDef?.multiple ? files : files[0];
1693
+ }
1694
+ else if (delivery === 'base64') {
1695
+ try {
1696
+ const encoded = await Promise.all(files.map((file) => this.readFileAsDataURL(file)));
1697
+ data[c.name] = fieldDef?.multiple ? encoded : encoded[0];
1698
+ }
1699
+ catch {
1700
+ hasError = true;
1701
+ if (fileErrorEl) {
1702
+ fileErrorEl.textContent = 'Failed to encode file(s) to base64.';
1703
+ fileErrorEl.style.display = 'block';
1704
+ }
1705
+ continue;
1706
+ }
1707
+ }
1708
+ continue;
1709
+ }
1710
+ const val = (c.el.value ?? '').toString().trim();
1711
+ const valueWrapper = c.el.parentElement;
1712
+ const fieldErrorEl = valueWrapper?.querySelector('.foisit-form-error');
1713
+ if (c.required && (val == null || val === '')) {
1714
+ hasError = true;
1715
+ c.el.classList.add('foisit-error-border');
1716
+ if (fieldErrorEl) {
1717
+ fieldErrorEl.textContent = 'This field is required';
1718
+ fieldErrorEl.style.display = 'block';
1719
+ }
1720
+ continue;
1721
+ }
1722
+ if (val === '')
1723
+ continue;
1724
+ if (c.type === 'number') {
1725
+ const n = Number(val);
1726
+ if (!Number.isNaN(n))
1727
+ data[c.name] = n;
1728
+ }
1729
+ else {
1730
+ data[c.name] = val;
1731
+ }
1732
+ }
1733
+ if (hasError) {
1734
+ // Shake animation
1735
+ form.classList.add('foisit-shake');
1736
+ setTimeout(() => form.classList.remove('foisit-shake'), 400);
1737
+ return;
1738
+ }
1739
+ submitBtn.disabled = true;
1740
+ submitBtn.style.opacity = '0.6';
1741
+ controls.forEach((c) => {
1742
+ c.el.disabled = true;
1743
+ });
1744
+ onSubmit(data);
1745
+ };
1746
+ this.messagesContainer.appendChild(form);
1747
+ this.scrollToBottom();
1748
+ }
1749
+ showLoading() {
1750
+ if (!this.messagesContainer)
1751
+ return;
1752
+ if (this.loadingEl)
1753
+ return;
1754
+ this.loadingEl = document.createElement('div');
1755
+ this.loadingEl.className = 'foisit-loading-dots foisit-bubble system';
1756
+ // Create dots
1757
+ for (let i = 0; i < 3; i++) {
1758
+ const dot = document.createElement('div');
1759
+ dot.className = 'foisit-dot';
1760
+ dot.style.animation = `foisitPulse 1.4s infinite ease-in-out ${i * 0.2}s`;
1761
+ this.loadingEl.appendChild(dot);
1762
+ }
1763
+ this.messagesContainer.appendChild(this.loadingEl);
1764
+ this.scrollToBottom();
1765
+ }
1766
+ hideLoading() {
1767
+ this.loadingEl?.remove();
1768
+ this.loadingEl = null;
1769
+ }
1770
+ scrollToBottom() {
1771
+ if (this.messagesContainer) {
1772
+ this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
1773
+ }
1774
+ }
1775
+ /** Subtle entrance animation for new messages */
1776
+ animateMessageEntrance(el, duration) {
1777
+ if (!el)
1778
+ return;
1779
+ // Force a reflow so transition will apply
1780
+ // apply transition
1781
+ // Use ease-out cubic for a subtle feel
1782
+ el.style.transition = `opacity ${duration}ms cubic-bezier(0.22, 0.9, 0.32, 1), transform ${Math.max(120, duration)}ms cubic-bezier(0.22, 0.9, 0.32, 1)`;
1783
+ // trigger in next frame
1784
+ requestAnimationFrame(() => {
1785
+ el.style.opacity = '1';
1786
+ el.style.transform = 'translateY(0)';
1787
+ });
1788
+ // cleanup transition after done
1789
+ const cleanup = () => {
1790
+ try {
1791
+ el.style.transition = '';
1792
+ // eslint-disable-next-line no-empty, @typescript-eslint/no-unused-vars
1793
+ }
1794
+ catch (_) { }
1795
+ el.removeEventListener('transitionend', cleanup);
1796
+ };
1797
+ el.addEventListener('transitionend', cleanup);
1798
+ }
1799
+ /** Smoothly scroll messages container to bottom over duration (ms) */
1800
+ animateScrollToBottom(duration) {
1801
+ if (!this.messagesContainer)
1802
+ return;
1803
+ const el = this.messagesContainer;
1804
+ const start = el.scrollTop;
1805
+ const end = el.scrollHeight - el.clientHeight;
1806
+ if (end <= start || duration <= 0) {
1807
+ el.scrollTop = end;
1808
+ return;
1809
+ }
1810
+ const delta = end - start;
1811
+ const startTime = performance.now();
1812
+ const step = (now) => {
1813
+ const t = Math.min(1, (now - startTime) / duration);
1814
+ // easeOutCubic
1815
+ const eased = 1 - Math.pow(1 - t, 3);
1816
+ el.scrollTop = Math.round(start + delta * eased);
1817
+ if (t < 1)
1818
+ requestAnimationFrame(step);
1819
+ };
1820
+ requestAnimationFrame(step);
1821
+ }
1822
+ destroy() {
1823
+ this.removeClickOutsideListener();
1824
+ this.container?.remove();
1825
+ this.container = null;
1826
+ this.chatWindow = null;
1827
+ this.messagesContainer = null;
1828
+ this.input = null;
1829
+ this.isOpen = false;
1830
+ }
1831
+ /** Escape HTML special characters to prevent XSS */
1832
+ escapeHtml(text) {
1833
+ const map = {
1834
+ '&': '&amp;',
1835
+ '<': '&lt;',
1836
+ '>': '&gt;',
1837
+ '"': '&quot;',
1838
+ "'": '&#039;',
1839
+ };
1840
+ return text.replace(/[&<>"']/g, (char) => map[char]);
1841
+ }
1842
+ /** Simple markdown renderer for AI responses */
1843
+ renderMarkdown(text) {
1844
+ // First escape HTML to prevent XSS
1845
+ let html = this.escapeHtml(text);
1846
+ // Code blocks (```lang ... ```)
1847
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
1848
+ const langClass = lang ? ` class="language-${lang}"` : '';
1849
+ return `<pre class="foisit-code-block"><code${langClass}>${code.trim()}</code></pre>`;
1850
+ });
1851
+ // Inline code (`code`)
1852
+ html = html.replace(/`([^`]+)`/g, '<code class="foisit-inline-code">$1</code>');
1853
+ // Headings (# to ######)
1854
+ html = html.replace(/^###### (.+)$/gm, '<h6 class="foisit-md-h6">$1</h6>');
1855
+ html = html.replace(/^##### (.+)$/gm, '<h5 class="foisit-md-h5">$1</h5>');
1856
+ html = html.replace(/^#### (.+)$/gm, '<h4 class="foisit-md-h4">$1</h4>');
1857
+ html = html.replace(/^### (.+)$/gm, '<h3 class="foisit-md-h3">$1</h3>');
1858
+ html = html.replace(/^## (.+)$/gm, '<h2 class="foisit-md-h2">$1</h2>');
1859
+ html = html.replace(/^# (.+)$/gm, '<h1 class="foisit-md-h1">$1</h1>');
1860
+ // Bold (**text** or __text__)
1861
+ html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
1862
+ html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');
1863
+ // Italic (*text* or _text_)
1864
+ html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
1865
+ html = html.replace(/(?<!_)_([^_]+)_(?!_)/g, '<em>$1</em>');
1866
+ // Strikethrough (~~text~~)
1867
+ html = html.replace(/~~([^~]+)~~/g, '<del>$1</del>');
1868
+ // Links [text](url)
1869
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="foisit-md-link">$1</a>');
1870
+ // Unordered lists (- or *)
1871
+ // eslint-disable-next-line no-useless-escape
1872
+ html = html.replace(/^[\-\*] (.+)$/gm, '<li class="foisit-md-li">$1</li>');
1873
+ html = html.replace(/(<li class="foisit-md-li">.*<\/li>\n?)+/g, (match) => `<ul class="foisit-md-ul">${match}</ul>`);
1874
+ // Ordered lists (1. 2. etc)
1875
+ html = html.replace(/^\d+\. (.+)$/gm, '<li class="foisit-md-li">$1</li>');
1876
+ // Wrap consecutive <li> not already in <ul> into <ol>
1877
+ html = html.replace(/(?<!<\/ul>)(<li class="foisit-md-li">.*<\/li>\n?)+/g, (match) => {
1878
+ if (!match.includes('<ul')) {
1879
+ return `<ol class="foisit-md-ol">${match}</ol>`;
1880
+ }
1881
+ return match;
1882
+ });
1883
+ // Blockquotes (> text)
1884
+ html = html.replace(/^&gt; (.+)$/gm, '<blockquote class="foisit-md-blockquote">$1</blockquote>');
1885
+ // Horizontal rule (--- or *** or ___)
1886
+ html = html.replace(/^(---|___|\*\*\*)$/gm, '<hr class="foisit-md-hr">');
1887
+ // Line breaks: convert double newlines to paragraphs, single to <br>
1888
+ html = html.replace(/\n\n+/g, '</p><p class="foisit-md-p">');
1889
+ html = html.replace(/\n/g, '<br>');
1890
+ // Wrap in paragraph if not starting with block element
1891
+ if (!html.match(/^<(h[1-6]|ul|ol|pre|blockquote|hr|p)/)) {
1892
+ html = `<p class="foisit-md-p">${html}</p>`;
1893
+ }
1894
+ return html;
1895
+ }
1896
+ readFileAsDataURL(file) {
1897
+ return new Promise((resolve, reject) => {
1898
+ const fr = new FileReader();
1899
+ fr.onerror = () => reject(new Error('Failed to read file'));
1900
+ fr.onload = () => resolve(String(fr.result));
1901
+ fr.readAsDataURL(file);
1902
+ });
1903
+ }
1904
+ getImageDimensions(file) {
1905
+ return new Promise((resolve) => {
1906
+ try {
1907
+ const url = URL.createObjectURL(file);
1908
+ const img = new Image();
1909
+ img.onload = () => {
1910
+ const dims = { width: img.naturalWidth || img.width, height: img.naturalHeight || img.height };
1911
+ URL.revokeObjectURL(url);
1912
+ resolve(dims);
1913
+ };
1914
+ img.onerror = () => {
1915
+ URL.revokeObjectURL(url);
1916
+ resolve({ width: 0, height: 0 });
1917
+ };
1918
+ img.src = url;
1919
+ }
1920
+ catch {
1921
+ resolve({ width: 0, height: 0 });
1922
+ }
1923
+ });
1924
+ }
1925
+ getMediaDuration(file) {
1926
+ return new Promise((resolve) => {
1927
+ try {
1928
+ const url = URL.createObjectURL(file);
1929
+ const el = file.type.startsWith('audio') ? document.createElement('audio') : document.createElement('video');
1930
+ let settled = false;
1931
+ const timeout = setTimeout(() => {
1932
+ if (!settled) {
1933
+ settled = true;
1934
+ URL.revokeObjectURL(url);
1935
+ resolve(0);
1936
+ }
1937
+ }, 5000);
1938
+ el.preload = 'metadata';
1939
+ el.onloadedmetadata = () => {
1940
+ if (settled)
1941
+ return;
1942
+ settled = true;
1943
+ clearTimeout(timeout);
1944
+ const mediaEl = el;
1945
+ const d = mediaEl.duration || 0;
1946
+ URL.revokeObjectURL(url);
1947
+ resolve(d);
1948
+ };
1949
+ el.onerror = () => {
1950
+ if (settled)
1951
+ return;
1952
+ settled = true;
1953
+ clearTimeout(timeout);
1954
+ URL.revokeObjectURL(url);
1955
+ resolve(0);
1956
+ };
1957
+ el.src = url;
1958
+ }
1959
+ catch {
1960
+ resolve(0);
1961
+ }
1962
+ });
1963
+ }
1964
+ injectOverlayStyles() {
1965
+ if (document.getElementById('foisit-overlay-styles'))
1966
+ return;
1967
+ const style = document.createElement('style');
1968
+ style.id = 'foisit-overlay-styles';
1969
+ style.textContent = `
1970
+ :root {
1971
+ /* LIGHT MODE (Default) - Smoother gradient */
1972
+ /* Changed: Softer, right-focused radial highlight to avoid a heavy white bottom */
1973
+ --foisit-bg: radial-gradient(ellipse at 75% 30%, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.03));
1974
+ --foisit-border: 1px solid rgba(255, 255, 255, 0.25);
1975
+ --foisit-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
1976
+ --foisit-text: #333;
1977
+
1978
+ /* Input */
1979
+ --foisit-input-color: #333;
1980
+ --foisit-input-placeholder: rgba(60, 60, 67, 0.6);
1981
+
1982
+ /* Bubbles */
1983
+ --foisit-bubble-user-bg: rgba(0, 0, 0, 0.04);
1984
+ --foisit-bubble-user-text: #333;
1985
+
1986
+ --foisit-bubble-sys-bg: rgba(255, 255, 255, 0.45);
1987
+ --foisit-bubble-sys-text: #333;
1988
+
1989
+ /* Form Colors */
1990
+ --foisit-req-star: #ef4444; /* Red asterisk */
1991
+ --foisit-error-text: #dc2626;
1992
+ --foisit-error-border: #fca5a5;
1993
+ }
1994
+
1995
+ @media (prefers-color-scheme: dark) {
1996
+ :root {
1997
+ /* DARK MODE */
1998
+ --foisit-bg: linear-gradient(135deg, rgba(40, 40, 40, 0.65), rgba(40, 40, 40, 0.25));
1999
+ --foisit-border: 1px solid rgba(255, 255, 255, 0.1);
2000
+ --foisit-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
2001
+ --foisit-text: #fff;
2002
+
2003
+ /* Input */
2004
+ --foisit-input-color: white;
2005
+ --foisit-input-placeholder: rgba(235, 235, 245, 0.5);
2006
+
2007
+ /* Bubbles */
2008
+ --foisit-bubble-user-bg: rgba(255, 255, 255, 0.1);
2009
+ --foisit-bubble-user-text: white;
2010
+
2011
+ --foisit-bubble-sys-bg: rgba(255, 255, 255, 0.05);
2012
+ --foisit-bubble-sys-text: rgba(255, 255, 255, 0.9);
2013
+
2014
+ /* Form Colors */
2015
+ --foisit-req-star: #f87171;
2016
+ --foisit-error-text: #fca5a5;
2017
+ --foisit-error-border: #f87171;
2018
+ }
2019
+ }
2020
+
2021
+ @keyframes foisitPulse {
2022
+ 0%, 100% { transform: scale(0.8); opacity: 0.5; }
2023
+ 50% { transform: scale(1.2); opacity: 1; }
2024
+ }
2025
+
2026
+ @keyframes foisitShake {
2027
+ 0%, 100% { transform: translateX(0); }
2028
+ 25% { transform: translateX(-4px); }
2029
+ 75% { transform: translateX(4px); }
2030
+ }
2031
+ .foisit-shake { animation: foisitShake 0.4s ease-in-out; }
2032
+
2033
+ /* Container */
2034
+ .foisit-overlay-container {
2035
+ position: fixed;
2036
+ inset: 0;
2037
+ z-index: 2147483647;
2038
+ pointer-events: none;
2039
+ display: flex;
2040
+ flex-direction: column;
2041
+ justify-content: flex-end;
2042
+ align-items: flex-end;
2043
+ padding: 20px;
2044
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
2045
+ }
2046
+
2047
+ .foisit-overlay-container * {
2048
+ box-sizing: border-box;
2049
+ }
2050
+
2051
+ /* Chat Window - Dynamic Height */
2052
+ .foisit-chat {
2053
+ position: absolute;
2054
+ top: 20px;
2055
+ right: 20px;
2056
+ width: min(420px, 92vw);
2057
+
2058
+ /* FIX: Auto height to prevent empty space */
2059
+ height: auto;
2060
+ min-height: 120px;
2061
+ max-height: 80vh;
2062
+
2063
+ background: var(--foisit-bg);
2064
+ border: var(--foisit-border);
2065
+ box-shadow: var(--foisit-shadow);
2066
+
2067
+ backdrop-filter: blur(20px);
2068
+ -webkit-backdrop-filter: blur(20px);
2069
+
2070
+ border-radius: 18px;
2071
+ display: none;
2072
+ flex-direction: column;
2073
+ overflow: hidden;
2074
+ pointer-events: auto;
2075
+ transform-origin: top right;
2076
+ transition: opacity 0.2s, transform 0.2s cubic-bezier(0.2, 0.9, 0.2, 1);
2077
+ }
2078
+
2079
+ .foisit-header {
2080
+ display: flex;
2081
+ align-items: center;
2082
+ justify-content: space-between;
2083
+ padding: 12px 16px;
2084
+ font-weight: 600;
2085
+ font-size: 14px;
2086
+ color: var(--foisit-text);
2087
+ border-bottom: 1px solid rgba(127,127,127,0.08); /* Subtle separator */
2088
+ }
2089
+
2090
+ .foisit-close {
2091
+ background: transparent;
2092
+ border: none;
2093
+ color: var(--foisit-text);
2094
+ opacity: 0.5;
2095
+ font-size: 24px;
2096
+ line-height: 1;
2097
+ cursor: pointer;
2098
+ padding: 0;
2099
+ width: 28px;
2100
+ height: 28px;
2101
+ display: flex;
2102
+ align-items: center;
2103
+ justify-content: center;
2104
+ transition: opacity 0.2s;
2105
+ }
2106
+ .foisit-close:hover { opacity: 1; }
2107
+
2108
+ /* Message Area */
2109
+ .foisit-messages {
2110
+ flex: 1;
2111
+ overflow-y: auto;
2112
+ padding: 16px;
2113
+ display: flex;
2114
+ flex-direction: column;
2115
+ gap: 10px;
2116
+ /* Ensure it doesn't get too tall initially */
2117
+ min-height: 60px;
2118
+ }
2119
+
2120
+ /* Make sure empty state isn't huge */
2121
+ .foisit-messages:empty {
2122
+ display: none;
2123
+ }
2124
+
2125
+ /* Only show messages container if it has children */
2126
+ .foisit-messages:not(:empty) {
2127
+ display: flex;
2128
+ }
2129
+
2130
+ /* Bubbles */
2131
+ .foisit-bubble {
2132
+ max-width: 90%;
2133
+ padding: 8px 14px;
2134
+ border-radius: 14px;
2135
+ font-size: 14px;
2136
+ line-height: 1.4;
2137
+ word-wrap: break-word;
2138
+ }
2139
+
2140
+ .foisit-bubble.user {
2141
+ align-self: flex-end;
2142
+ background: var(--foisit-bubble-user-bg);
2143
+ color: var(--foisit-bubble-user-text);
2144
+ border-bottom-right-radius: 4px;
2145
+ }
2146
+
2147
+ .foisit-bubble.system {
2148
+ align-self: flex-start;
2149
+ background: var(--foisit-bubble-sys-bg);
2150
+ color: var(--foisit-bubble-sys-text);
2151
+ border-bottom-left-radius: 4px;
2152
+ border: 1px solid rgba(255,255,255,0.1);
2153
+ }
2154
+
2155
+ /* Input Area */
2156
+ .foisit-input-area {
2157
+ padding: 0;
2158
+ width: 100%;
2159
+ border-top: 1px solid rgba(127,127,127,0.08);
2160
+ }
2161
+
2162
+ .foisit-input {
2163
+ width: 100%;
2164
+ background: transparent;
2165
+ border: none;
2166
+ font-size: 16px;
2167
+ color: var(--foisit-input-color);
2168
+ padding: 16px 20px;
2169
+ outline: none;
2170
+ text-align: left;
2171
+ }
2172
+
2173
+ .foisit-input::placeholder {
2174
+ color: var(--foisit-input-placeholder);
2175
+ font-weight: 400;
2176
+ }
2177
+
2178
+ /* Options & Buttons */
2179
+ .foisit-options-container {
2180
+ display: flex;
2181
+ flex-wrap: wrap;
2182
+ gap: 8px;
2183
+ margin-left: 2px;
2184
+ margin-top: 4px;
2185
+ }
2186
+
2187
+ .foisit-option-chip {
2188
+ padding: 6px 14px;
2189
+ background: var(--foisit-bubble-sys-bg);
2190
+ border: 1px solid rgba(127,127,127,0.1);
2191
+ border-radius: 20px;
2192
+ font-size: 13px;
2193
+ color: var(--foisit-text);
2194
+ cursor: pointer;
2195
+ transition: all 0.2s;
2196
+ font-weight: 500;
2197
+ }
2198
+ .foisit-option-chip:hover {
2199
+ background: rgba(127,127,127,0.15);
2200
+ }
2201
+
2202
+ /* Form Styling */
2203
+ .foisit-form {
2204
+ background: var(--foisit-bubble-sys-bg);
2205
+ padding: 16px;
2206
+ border-radius: 14px;
2207
+ display: flex;
2208
+ flex-direction: column;
2209
+ gap: 12px;
2210
+ width: 100%;
2211
+ border: 1px solid rgba(127,127,127,0.1);
2212
+ }
2213
+
2214
+ .foisit-form-label {
2215
+ font-size: 12px;
2216
+ font-weight: 600;
2217
+ color: var(--foisit-text);
2218
+ opacity: 0.9;
2219
+ margin-bottom: 2px;
2220
+ }
2221
+
2222
+ .foisit-req-star {
2223
+ color: var(--foisit-req-star);
2224
+ font-weight: bold;
2225
+ }
2226
+
2227
+ .foisit-form-input {
2228
+ width: 100%;
2229
+ padding: 10px;
2230
+ border-radius: 8px;
2231
+ border: 1px solid rgba(127,127,127,0.2);
2232
+ background: rgba(255,255,255,0.05); /* Very subtle fill */
2233
+ color: var(--foisit-text);
2234
+ font-size: 14px;
2235
+ outline: none;
2236
+ transition: border 0.2s;
2237
+ }
2238
+ .foisit-form-input:focus {
2239
+ border-color: var(--foisit-text);
2240
+ background: rgba(255,255,255,0.1);
2241
+ }
2242
+
2243
+ .foisit-error-border {
2244
+ border-color: var(--foisit-error-border) !important;
2245
+ }
2246
+
2247
+ .foisit-form-error {
2248
+ font-size: 11px;
2249
+ color: var(--foisit-error-text);
2250
+ margin-top: 4px;
2251
+ }
2252
+
2253
+ /* Loading */
2254
+ .foisit-loading-dots {
2255
+ display: inline-flex;
2256
+ gap: 4px;
2257
+ padding: 10px 14px;
2258
+ align-self: flex-start;
2259
+ }
2260
+ .foisit-dot {
2261
+ width: 6px;
2262
+ height: 6px;
2263
+ border-radius: 50%;
2264
+ background: var(--foisit-text);
2265
+ opacity: 0.4;
2266
+ }
2267
+
2268
+ /* Floating Button */
2269
+ .foisit-floating-btn {
2270
+ position: absolute;
2271
+ width: 56px;
2272
+ height: 56px;
2273
+ border-radius: 50%;
2274
+ border: 1px solid rgba(255,255,255,0.2);
2275
+ background: var(--foisit-bg);
2276
+ color: var(--foisit-text);
2277
+ backdrop-filter: blur(10px);
2278
+ -webkit-backdrop-filter: blur(10px);
2279
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
2280
+ cursor: pointer;
2281
+ pointer-events: auto;
2282
+ display: flex;
2283
+ align-items: center;
2284
+ justify-content: center;
2285
+ font-size: 24px;
2286
+ z-index: 100000;
2287
+ transition: transform 0.2s;
2288
+ }
2289
+ .foisit-floating-btn:hover { transform: scale(1.05); }
2290
+
2291
+ /* Markdown Styles */
2292
+ .foisit-bubble.system .foisit-md-p { margin: 0 0 0.5em 0; }
2293
+ .foisit-bubble.system .foisit-md-p:last-child { margin-bottom: 0; }
2294
+
2295
+ .foisit-bubble.system .foisit-md-h1,
2296
+ .foisit-bubble.system .foisit-md-h2,
2297
+ .foisit-bubble.system .foisit-md-h3,
2298
+ .foisit-bubble.system .foisit-md-h4,
2299
+ .foisit-bubble.system .foisit-md-h5,
2300
+ .foisit-bubble.system .foisit-md-h6 {
2301
+ margin: 0.8em 0 0.4em 0;
2302
+ font-weight: 600;
2303
+ line-height: 1.3;
2304
+ }
2305
+ .foisit-bubble.system .foisit-md-h1:first-child,
2306
+ .foisit-bubble.system .foisit-md-h2:first-child,
2307
+ .foisit-bubble.system .foisit-md-h3:first-child { margin-top: 0; }
2308
+
2309
+ .foisit-bubble.system .foisit-md-h1 { font-size: 1.4em; }
2310
+ .foisit-bubble.system .foisit-md-h2 { font-size: 1.25em; }
2311
+ .foisit-bubble.system .foisit-md-h3 { font-size: 1.1em; }
2312
+ .foisit-bubble.system .foisit-md-h4 { font-size: 1em; }
2313
+ .foisit-bubble.system .foisit-md-h5 { font-size: 0.95em; }
2314
+ .foisit-bubble.system .foisit-md-h6 { font-size: 0.9em; opacity: 0.85; }
2315
+
2316
+ .foisit-bubble.system .foisit-md-ul,
2317
+ .foisit-bubble.system .foisit-md-ol {
2318
+ margin: 0.5em 0;
2319
+ padding-left: 1.5em;
2320
+ }
2321
+ .foisit-bubble.system .foisit-md-li { margin: 0.25em 0; }
2322
+
2323
+ .foisit-bubble.system .foisit-code-block {
2324
+ background: rgba(0,0,0,0.15);
2325
+ border-radius: 6px;
2326
+ padding: 10px 12px;
2327
+ margin: 0.5em 0;
2328
+ overflow-x: auto;
2329
+ font-family: 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
2330
+ font-size: 0.85em;
2331
+ line-height: 1.4;
2332
+ }
2333
+ .foisit-bubble.system .foisit-code-block code {
2334
+ background: transparent;
2335
+ padding: 0;
2336
+ }
2337
+
2338
+ .foisit-bubble.system .foisit-inline-code {
2339
+ background: rgba(0,0,0,0.1);
2340
+ padding: 2px 6px;
2341
+ border-radius: 4px;
2342
+ font-family: 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
2343
+ font-size: 0.9em;
2344
+ }
2345
+
2346
+ .foisit-bubble.system .foisit-md-blockquote {
2347
+ border-left: 3px solid rgba(127,127,127,0.4);
2348
+ margin: 0.5em 0;
2349
+ padding-left: 12px;
2350
+ opacity: 0.9;
2351
+ font-style: italic;
2352
+ }
2353
+
2354
+ .foisit-bubble.system .foisit-md-link {
2355
+ color: inherit;
2356
+ text-decoration: underline;
2357
+ opacity: 0.9;
2358
+ }
2359
+ .foisit-bubble.system .foisit-md-link:hover { opacity: 1; }
2360
+
2361
+ .foisit-bubble.system .foisit-md-hr {
2362
+ border: none;
2363
+ border-top: 1px solid rgba(127,127,127,0.3);
2364
+ margin: 0.8em 0;
2365
+ }
2366
+
2367
+ .foisit-bubble.system strong { font-weight: 600; }
2368
+ .foisit-bubble.system em { font-style: italic; }
2369
+ .foisit-bubble.system del { text-decoration: line-through; opacity: 0.7; }
2370
+
2371
+ @media (prefers-color-scheme: dark) {
2372
+ .foisit-bubble.system .foisit-code-block { background: rgba(255,255,255,0.08); }
2373
+ .foisit-bubble.system .foisit-inline-code { background: rgba(255,255,255,0.1); }
2374
+ }
2375
+ `;
2376
+ document.head.appendChild(style);
2377
+ }
2378
+ }
2379
+
15
2380
  class AssistantService {
16
2381
  config;
17
2382
  commandHandler;