@foisit/angular-wrapper 2.4.655 → 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,906 +1,998 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import { CommonModule } from "@angular/common";
|
|
5
|
-
|
|
6
|
-
// dist/libs/core/src/lib/command/command-handler.js
|
|
7
|
-
import { __awaiter as __awaiter2 } from "tslib";
|
|
8
|
-
|
|
9
|
-
// dist/libs/core/src/lib/ai/openai.service.js
|
|
10
|
-
import { __awaiter } from "tslib";
|
|
11
|
-
var OpenAIService = class {
|
|
12
|
-
constructor(endpoint) {
|
|
13
|
-
this.endpoint = endpoint || "https://foisit-ninja.netlify.app/.netlify/functions/intent";
|
|
14
|
-
}
|
|
15
|
-
determineIntent(userInput, availableCommands, context) {
|
|
16
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
17
|
-
try {
|
|
18
|
-
const payload = {
|
|
19
|
-
userInput,
|
|
20
|
-
commands: availableCommands.map((cmd) => ({
|
|
21
|
-
id: cmd.id,
|
|
22
|
-
command: cmd.command,
|
|
23
|
-
description: cmd.description,
|
|
24
|
-
parameters: cmd.parameters
|
|
25
|
-
// Send param schemas to AI
|
|
26
|
-
})),
|
|
27
|
-
context
|
|
28
|
-
};
|
|
29
|
-
const response = yield fetch(this.endpoint, {
|
|
30
|
-
method: "POST",
|
|
31
|
-
headers: {
|
|
32
|
-
"Content-Type": "application/json"
|
|
33
|
-
},
|
|
34
|
-
body: JSON.stringify(payload)
|
|
35
|
-
});
|
|
36
|
-
if (!response.ok) {
|
|
37
|
-
throw new Error(`Proxy API Error: ${response.statusText}`);
|
|
38
|
-
}
|
|
39
|
-
const result = yield response.json();
|
|
40
|
-
return result;
|
|
41
|
-
} catch (error) {
|
|
42
|
-
console.error("OpenAIService Error:", error);
|
|
43
|
-
return { type: "unknown" };
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
};
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { Component, Inject, Injectable, NgModule } from '@angular/core';
|
|
3
|
+
import { CommonModule } from '@angular/common';
|
|
48
4
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
this.enableSmartIntent = (_a = arg.enableSmartIntent) !== null && _a !== void 0 ? _a : true;
|
|
67
|
-
if (this.enableSmartIntent) {
|
|
68
|
-
this.openAIService = new OpenAIService(arg.intentEndpoint);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
/** Add a new command (string or object) */
|
|
72
|
-
addCommand(commandOrObj, action) {
|
|
73
|
-
let cmd;
|
|
74
|
-
if (typeof commandOrObj === "string") {
|
|
75
|
-
if (!action) {
|
|
76
|
-
throw new Error("Action required when adding command by string.");
|
|
77
|
-
}
|
|
78
|
-
cmd = {
|
|
79
|
-
id: commandOrObj.toLowerCase().replace(/\s+/g, "_"),
|
|
80
|
-
command: commandOrObj.toLowerCase(),
|
|
81
|
-
action
|
|
82
|
-
};
|
|
83
|
-
} else {
|
|
84
|
-
cmd = Object.assign({}, commandOrObj);
|
|
85
|
-
if (!cmd.id) {
|
|
86
|
-
cmd.id = cmd.command.toLowerCase().replace(/\s+/g, "_");
|
|
87
|
-
}
|
|
5
|
+
class AngularWrapperComponent {
|
|
6
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.7", ngImport: i0, type: AngularWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
7
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.0.7", type: AngularWrapperComponent, isStandalone: true, selector: "lib-angular-wrapper", ngImport: i0, template: "<p>AngularWrapper works!</p>\n", styles: [""], dependencies: [{ kind: "ngmodule", type: CommonModule }] });
|
|
8
|
+
}
|
|
9
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.7", ngImport: i0, type: AngularWrapperComponent, decorators: [{
|
|
10
|
+
type: Component,
|
|
11
|
+
args: [{ selector: 'lib-angular-wrapper', imports: [CommonModule], template: "<p>AngularWrapper works!</p>\n" }]
|
|
12
|
+
}] });
|
|
13
|
+
|
|
14
|
+
class OpenAIService {
|
|
15
|
+
endpoint;
|
|
16
|
+
constructor(endpoint) {
|
|
17
|
+
this.endpoint =
|
|
18
|
+
endpoint || 'https://foisit-ninja.netlify.app/.netlify/functions/intent';
|
|
88
19
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (typeof input === "object" && input !== null) {
|
|
102
|
-
if (this.isStructured(input)) {
|
|
103
|
-
const cmdId = String(input.commandId);
|
|
104
|
-
const rawParams = (_a = input.params) !== null && _a !== void 0 ? _a : {};
|
|
105
|
-
const cmd2 = this.getCommandById(cmdId);
|
|
106
|
-
if (!cmd2)
|
|
107
|
-
return { message: "That command is not available.", type: "error" };
|
|
108
|
-
const params = this.sanitizeParamsForCommand(cmd2, rawParams);
|
|
109
|
-
const requiredParams = ((_b = cmd2.parameters) !== null && _b !== void 0 ? _b : []).filter((p) => p.required);
|
|
110
|
-
const missing = requiredParams.filter((p) => params[p.name] == null || params[p.name] === "");
|
|
111
|
-
if (missing.length > 0) {
|
|
112
|
-
this.context = { commandId: this.getCommandIdentifier(cmd2), params };
|
|
113
|
-
return {
|
|
114
|
-
message: `Please provide the required details for "${cmd2.command}".`,
|
|
115
|
-
type: "form",
|
|
116
|
-
fields: missing
|
|
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,
|
|
117
32
|
};
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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;
|
|
124
45
|
}
|
|
125
|
-
|
|
126
|
-
|
|
46
|
+
catch (error) {
|
|
47
|
+
console.error('OpenAIService Error:', error);
|
|
48
|
+
return { type: 'unknown' };
|
|
127
49
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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;
|
|
132
67
|
}
|
|
133
|
-
|
|
134
|
-
|
|
68
|
+
this.enableSmartIntent = arg.enableSmartIntent ?? true;
|
|
69
|
+
if (this.enableSmartIntent) {
|
|
70
|
+
this.openAIService = new OpenAIService(arg.intentEndpoint);
|
|
135
71
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const trimmedInput = input.trim().toLowerCase();
|
|
150
|
-
if (this.pendingConfirmation) {
|
|
151
|
-
const normalized = trimmedInput;
|
|
152
|
-
if (["yes", "y", "confirm", "ok", "okay"].includes(normalized)) {
|
|
153
|
-
const { commandId, params } = this.pendingConfirmation;
|
|
154
|
-
this.pendingConfirmation = null;
|
|
155
|
-
const cmd = this.getCommandById(commandId);
|
|
156
|
-
if (!cmd) {
|
|
157
|
-
return { message: "That action is no longer available.", type: "error" };
|
|
158
|
-
}
|
|
159
|
-
return this.safeRunAction(cmd, params);
|
|
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
|
+
};
|
|
160
85
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
86
|
+
else {
|
|
87
|
+
cmd = { ...commandOrObj };
|
|
88
|
+
if (!cmd.id) {
|
|
89
|
+
cmd.id = cmd.command.toLowerCase().replace(/\s+/g, '_');
|
|
90
|
+
}
|
|
164
91
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
]
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
const exactCommand = this.commands.get(trimmedInput);
|
|
175
|
-
if (exactCommand) {
|
|
176
|
-
const cmd = exactCommand;
|
|
177
|
-
const requiredParams = ((_c = cmd.parameters) !== null && _c !== void 0 ? _c : []).filter((p) => p.required);
|
|
178
|
-
if (requiredParams.length > 0) {
|
|
179
|
-
this.context = { commandId: this.getCommandIdentifier(cmd), params: {} };
|
|
180
|
-
return {
|
|
181
|
-
message: `Please provide the required details for "${cmd.command}".`,
|
|
182
|
-
type: "form",
|
|
183
|
-
fields: requiredParams
|
|
184
|
-
};
|
|
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.
|
|
185
98
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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);
|
|
189
161
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
return __awaiter2(this, void 0, void 0, function* () {
|
|
208
|
-
var _a, _b;
|
|
209
|
-
if (result.type === "match" && result.match) {
|
|
210
|
-
const cmd = this.getCommandById(result.match);
|
|
211
|
-
if (!cmd) {
|
|
212
|
-
return { message: "I'm not sure what you mean.", type: "error" };
|
|
213
|
-
}
|
|
214
|
-
const rawParams = (_a = result.params) !== null && _a !== void 0 ? _a : {};
|
|
215
|
-
const sanitizedParams = this.sanitizeParamsForCommand(cmd, rawParams);
|
|
216
|
-
const params = cmd.allowAiParamExtraction === false ? {} : sanitizedParams;
|
|
217
|
-
const requiredParams = ((_b = cmd.parameters) !== null && _b !== void 0 ? _b : []).filter((p) => p.required);
|
|
218
|
-
const missingRequired = requiredParams.filter((p) => params[p.name] == null || params[p.name] === "");
|
|
219
|
-
if (result.incomplete || missingRequired.length > 0) {
|
|
220
|
-
this.context = { commandId: this.getCommandIdentifier(cmd), params };
|
|
221
|
-
const mustUseForm = cmd.collectRequiredViaForm !== false;
|
|
222
|
-
const askSingle = !mustUseForm && this.shouldAskSingleQuestion(missingRequired);
|
|
223
|
-
if (askSingle) {
|
|
224
|
-
const names = missingRequired.map((p) => p.name).join(" and ");
|
|
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
|
+
}
|
|
225
179
|
return {
|
|
226
|
-
|
|
227
|
-
|
|
180
|
+
message: 'Please confirm: Yes or No.',
|
|
181
|
+
type: 'confirm',
|
|
182
|
+
options: [
|
|
183
|
+
{ label: 'Yes', value: 'yes' },
|
|
184
|
+
{ label: 'No', value: 'no' },
|
|
185
|
+
],
|
|
228
186
|
};
|
|
229
|
-
}
|
|
230
|
-
return {
|
|
231
|
-
message: result.message || `Please fill in the missing details for "${cmd.command}".`,
|
|
232
|
-
type: "form",
|
|
233
|
-
fields: missingRequired
|
|
234
|
-
};
|
|
235
187
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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.
|
|
252
279
|
return {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
+
})),
|
|
256
287
|
};
|
|
257
|
-
|
|
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
|
+
],
|
|
258
398
|
};
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (p.type === "number") {
|
|
281
|
-
const numeric = typeof value === "number" ? value : Number(value === null || value === void 0 ? void 0 : value.toString().trim());
|
|
282
|
-
if (Number.isNaN(numeric)) {
|
|
283
|
-
delete sanitized[p.name];
|
|
284
|
-
continue;
|
|
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 });
|
|
285
420
|
}
|
|
286
|
-
if (
|
|
287
|
-
|
|
288
|
-
|
|
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
|
+
};
|
|
289
437
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
+
};
|
|
293
448
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const v = sanitized[p.name];
|
|
298
|
-
if (typeof v === "string") {
|
|
299
|
-
const s = v.trim();
|
|
300
|
-
if (!this.isIsoDateString(s)) {
|
|
301
|
-
delete sanitized[p.name];
|
|
302
|
-
} else {
|
|
303
|
-
sanitized[p.name] = s;
|
|
304
|
-
}
|
|
305
|
-
} else {
|
|
306
|
-
delete sanitized[p.name];
|
|
449
|
+
if (cmd.critical) {
|
|
450
|
+
this.pendingConfirmation = { commandId: this.getCommandIdentifier(cmd), params: {} };
|
|
451
|
+
return this.buildConfirmResponse(cmd);
|
|
307
452
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
}
|
|
315
|
-
if (Array.isArray(p.options) && p.options.length > 0) {
|
|
316
|
-
const allowed = p.options.some((opt) => String(opt.value) === String(v));
|
|
317
|
-
if (!allowed) {
|
|
318
|
-
delete sanitized[p.name];
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
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);
|
|
321
459
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if (p.type === "file") {
|
|
325
|
-
const val = sanitized[p.name];
|
|
326
|
-
const isFileLike = val && typeof val === "object" && typeof val.name === "string" && typeof val.size === "number";
|
|
327
|
-
const isDataUrl = typeof val === "string" && /^data:[^;]+;base64,/.test(val);
|
|
328
|
-
const delivery = (_b = p.delivery) !== null && _b !== void 0 ? _b : "file";
|
|
329
|
-
if (delivery === "base64") {
|
|
330
|
-
if (!isDataUrl && !isFileLike) {
|
|
331
|
-
delete sanitized[p.name];
|
|
332
|
-
}
|
|
333
|
-
} else {
|
|
334
|
-
if (!isFileLike) {
|
|
335
|
-
delete sanitized[p.name];
|
|
336
|
-
}
|
|
460
|
+
catch {
|
|
461
|
+
return { message: 'Something went wrong while running that command.', type: 'error' };
|
|
337
462
|
}
|
|
338
|
-
}
|
|
339
463
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
for (const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
if (candidates.length === 0)
|
|
387
|
-
return null;
|
|
388
|
-
candidates.sort((a, b) => b.score - a.score);
|
|
389
|
-
const topScore = candidates[0].score;
|
|
390
|
-
const top = candidates.filter((c) => c.score === topScore).slice(0, 3);
|
|
391
|
-
if (top.length > 1) {
|
|
392
|
-
return {
|
|
393
|
-
message: "I think you mean one of these. Which one should I run?",
|
|
394
|
-
type: "ambiguous",
|
|
395
|
-
options: top.map((c) => ({
|
|
396
|
-
label: c.cmd.command,
|
|
397
|
-
value: c.cmd.command,
|
|
398
|
-
commandId: c.cmd.id
|
|
399
|
-
}))
|
|
400
|
-
};
|
|
401
|
-
}
|
|
402
|
-
const cmd = top[0].cmd;
|
|
403
|
-
const requiredParams = ((_b = cmd.parameters) !== null && _b !== void 0 ? _b : []).filter((p) => p.required);
|
|
404
|
-
if (requiredParams.length > 0) {
|
|
405
|
-
this.context = { commandId: this.getCommandIdentifier(cmd), params: {} };
|
|
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
|
+
}));
|
|
406
510
|
return {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
511
|
+
message: 'Here are the available commands:',
|
|
512
|
+
type: 'ambiguous',
|
|
513
|
+
options,
|
|
410
514
|
};
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
if (cached && now - cached.ts < 6e4) {
|
|
443
|
-
p.options = cached.options;
|
|
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.');
|
|
444
546
|
return;
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
type: "ambiguous",
|
|
476
|
-
options
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
normalizeResponse(result) {
|
|
480
|
-
if (typeof result === "string") {
|
|
481
|
-
return { message: result, type: "success" };
|
|
482
|
-
}
|
|
483
|
-
if (result && typeof result === "object") {
|
|
484
|
-
return result;
|
|
485
|
-
}
|
|
486
|
-
return { message: "Done", type: "success" };
|
|
487
|
-
}
|
|
488
|
-
isStructured(input) {
|
|
489
|
-
return typeof input["commandId"] === "string";
|
|
490
|
-
}
|
|
491
|
-
getCommandIdentifier(cmd) {
|
|
492
|
-
if (!cmd.id) {
|
|
493
|
-
cmd.id = cmd.command.toLowerCase().replace(/\s+/g, "_");
|
|
494
|
-
}
|
|
495
|
-
return cmd.id;
|
|
496
|
-
}
|
|
497
|
-
/** List all registered commands */
|
|
498
|
-
getCommands() {
|
|
499
|
-
return Array.from(this.commands.keys());
|
|
500
|
-
}
|
|
501
|
-
};
|
|
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
|
+
}
|
|
502
577
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
}
|
|
508
|
-
speak(text, options) {
|
|
509
|
-
if (!this.synth) {
|
|
510
|
-
console.error("SpeechSynthesis API is not supported in this environment.");
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
const utterance = new SpeechSynthesisUtterance(text);
|
|
514
|
-
if (options) {
|
|
515
|
-
utterance.pitch = options.pitch || 1;
|
|
516
|
-
utterance.rate = options.rate || 1;
|
|
517
|
-
utterance.volume = options.volume || 1;
|
|
518
|
-
}
|
|
519
|
-
if (typeof window !== "undefined") {
|
|
520
|
-
utterance.onstart = () => {
|
|
521
|
-
window.dispatchEvent(new CustomEvent("foisit:tts-start"));
|
|
522
|
-
};
|
|
523
|
-
utterance.onend = () => {
|
|
524
|
-
console.log("Speech finished.");
|
|
525
|
-
window.dispatchEvent(new CustomEvent("foisit:tts-end"));
|
|
526
|
-
};
|
|
527
|
-
}
|
|
528
|
-
utterance.onerror = (event) => {
|
|
529
|
-
console.error("Error during speech synthesis:", event.error);
|
|
530
|
-
};
|
|
531
|
-
this.synth.speak(utterance);
|
|
532
|
-
}
|
|
533
|
-
stopSpeaking() {
|
|
534
|
-
if (this.synth) {
|
|
535
|
-
this.synth.cancel();
|
|
578
|
+
class FallbackHandler {
|
|
579
|
+
fallbackMessage = 'Sorry, I didn’t understand that.';
|
|
580
|
+
setFallbackMessage(message) {
|
|
581
|
+
this.fallbackMessage = message;
|
|
536
582
|
}
|
|
537
|
-
|
|
538
|
-
|
|
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
|
+
}
|
|
539
594
|
|
|
540
|
-
|
|
541
|
-
var FallbackHandler = class {
|
|
542
|
-
constructor() {
|
|
543
|
-
this.fallbackMessage = "Sorry, I didn\u2019t understand that.";
|
|
544
|
-
}
|
|
545
|
-
setFallbackMessage(message) {
|
|
546
|
-
this.fallbackMessage = message;
|
|
547
|
-
}
|
|
548
|
-
handleFallback(transcript) {
|
|
549
|
-
if (transcript)
|
|
550
|
-
console.log(`Fallback triggered for: "${transcript}"`);
|
|
551
|
-
console.log(this.fallbackMessage);
|
|
552
|
-
new TextToSpeech().speak(this.fallbackMessage);
|
|
553
|
-
}
|
|
554
|
-
getFallbackMessage() {
|
|
555
|
-
return this.fallbackMessage;
|
|
556
|
-
}
|
|
557
|
-
};
|
|
595
|
+
/* eslint-disable no-unused-vars */
|
|
558
596
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
return null;
|
|
565
|
-
const w = window;
|
|
566
|
-
return (_b = (_a = w.SpeechRecognition) !== null && _a !== void 0 ? _a : w.webkitSpeechRecognition) !== null && _b !== void 0 ? _b : null;
|
|
597
|
+
const getRecognitionCtor = () => {
|
|
598
|
+
if (typeof window === 'undefined')
|
|
599
|
+
return null;
|
|
600
|
+
const w = window;
|
|
601
|
+
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
|
567
602
|
};
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
this.ttsSpeaking = false;
|
|
597
|
-
this.debugEnabled = true;
|
|
598
|
-
this.restartTimer = null;
|
|
599
|
-
this.prewarmed = false;
|
|
600
|
-
this.hadResultThisSession = false;
|
|
601
|
-
this.onTTSStart = () => {
|
|
602
|
-
var _a2;
|
|
603
|
-
this.ttsSpeaking = true;
|
|
604
|
-
try {
|
|
605
|
-
(_a2 = this.recognition) === null || _a2 === void 0 ? void 0 : _a2.stop();
|
|
606
|
-
} catch (_b2) {
|
|
607
|
-
}
|
|
608
|
-
if (this.isListening) {
|
|
609
|
-
this.emitStatus("speaking");
|
|
610
|
-
}
|
|
611
|
-
};
|
|
612
|
-
this.onTTSEnd = () => {
|
|
613
|
-
this.ttsSpeaking = false;
|
|
614
|
-
if (this.isListening && this.restartAllowed) {
|
|
615
|
-
this.safeRestart();
|
|
616
|
-
} else {
|
|
617
|
-
this.emitStatus(this.isListening ? "listening" : "idle");
|
|
618
|
-
}
|
|
619
|
-
};
|
|
620
|
-
const Ctor = getRecognitionCtor();
|
|
621
|
-
if (Ctor) {
|
|
622
|
-
this.recognition = new Ctor();
|
|
623
|
-
this.recognition.lang = language;
|
|
624
|
-
this.recognition.interimResults = (_a = options.interimResults) !== null && _a !== void 0 ? _a : true;
|
|
625
|
-
this.recognition.continuous = (_b = options.continuous) !== null && _b !== void 0 ? _b : true;
|
|
626
|
-
this.recognition.onresult = (event) => this.handleResult(event, options);
|
|
627
|
-
this.recognition.onend = () => this.handleEnd();
|
|
628
|
-
this.recognition.onstart = () => {
|
|
629
|
-
this.log("recognition onstart");
|
|
630
|
-
this.engineActive = true;
|
|
631
|
-
this.hadResultThisSession = false;
|
|
632
|
-
if (this.restartTimer) {
|
|
633
|
-
clearTimeout(this.restartTimer);
|
|
634
|
-
this.restartTimer = null;
|
|
635
|
-
}
|
|
636
|
-
this.backoffMs = 250;
|
|
637
|
-
if (this.isListening && !this.ttsSpeaking) {
|
|
638
|
-
this.emitStatus("listening");
|
|
639
|
-
}
|
|
640
|
-
};
|
|
641
|
-
const vrec = this.recognition;
|
|
642
|
-
vrec.onaudiostart = () => this.log("onaudiostart");
|
|
643
|
-
vrec.onsoundstart = () => this.log("onsoundstart");
|
|
644
|
-
vrec.onspeechstart = () => this.log("onspeechstart");
|
|
645
|
-
vrec.onspeechend = () => this.log("onspeechend");
|
|
646
|
-
vrec.onsoundend = () => this.log("onsoundend");
|
|
647
|
-
vrec.onaudioend = () => this.log("onaudioend");
|
|
648
|
-
this.recognition.onerror = (event) => this.handleError(event);
|
|
649
|
-
} else {
|
|
650
|
-
this.recognition = null;
|
|
651
|
-
this.emitStatus("unsupported");
|
|
652
|
-
}
|
|
653
|
-
if (typeof window !== "undefined") {
|
|
654
|
-
window.addEventListener("foisit:tts-start", this.onTTSStart);
|
|
655
|
-
window.addEventListener("foisit:tts-end", this.onTTSEnd);
|
|
656
|
-
this.visibilityHandler = () => {
|
|
657
|
-
var _a2;
|
|
658
|
-
if (typeof document !== "undefined" && document.hidden) {
|
|
659
|
-
try {
|
|
660
|
-
(_a2 = this.recognition) === null || _a2 === void 0 ? void 0 : _a2.stop();
|
|
661
|
-
} catch (_b2) {
|
|
662
|
-
}
|
|
663
|
-
this.emitStatus(this.ttsSpeaking ? "speaking" : "idle");
|
|
664
|
-
} else if (this.isListening && !this.ttsSpeaking) {
|
|
665
|
-
this.safeRestart();
|
|
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);
|
|
666
631
|
}
|
|
667
|
-
};
|
|
668
|
-
if (typeof document !== "undefined") {
|
|
669
|
-
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
670
|
-
}
|
|
671
|
-
} else {
|
|
672
|
-
this.visibilityHandler = void 0;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
/** Check if SpeechRecognition is available */
|
|
676
|
-
isSupported() {
|
|
677
|
-
return getRecognitionCtor() !== null;
|
|
678
|
-
}
|
|
679
|
-
/** Allow consumers (wrappers) to observe status changes */
|
|
680
|
-
onStatusChange(callback) {
|
|
681
|
-
this.statusCallback = callback;
|
|
682
|
-
}
|
|
683
|
-
/** Start listening for speech input */
|
|
684
|
-
startListening(callback) {
|
|
685
|
-
if (!this.isSupported() || !this.recognition) {
|
|
686
|
-
this.warn("VoiceProcessor: SpeechRecognition is not supported in this browser.");
|
|
687
|
-
this.emitStatus("unsupported");
|
|
688
|
-
return;
|
|
689
|
-
}
|
|
690
|
-
if (this.isListening) {
|
|
691
|
-
this.warn("VoiceProcessor: Already listening.");
|
|
692
|
-
this.resultCallback = callback;
|
|
693
|
-
return;
|
|
694
|
-
}
|
|
695
|
-
this.resultCallback = callback;
|
|
696
|
-
this.intentionallyStopped = false;
|
|
697
|
-
this.restartAllowed = true;
|
|
698
|
-
this.isListening = true;
|
|
699
|
-
this.emitStatus("listening");
|
|
700
|
-
this.prewarmAudio().finally(() => {
|
|
701
|
-
this.safeRestart();
|
|
702
|
-
});
|
|
703
|
-
}
|
|
704
|
-
/** Stop listening for speech input */
|
|
705
|
-
stopListening() {
|
|
706
|
-
var _a;
|
|
707
|
-
this.intentionallyStopped = true;
|
|
708
|
-
this.restartAllowed = false;
|
|
709
|
-
this.isListening = false;
|
|
710
|
-
this.emitStatus(this.ttsSpeaking ? "speaking" : "idle");
|
|
711
|
-
try {
|
|
712
|
-
(_a = this.recognition) === null || _a === void 0 ? void 0 : _a.stop();
|
|
713
|
-
} catch (_b) {
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
/** Clean up listeners */
|
|
717
|
-
destroy() {
|
|
718
|
-
this.destroyed = true;
|
|
719
|
-
this.stopListening();
|
|
720
|
-
this.resultCallback = null;
|
|
721
|
-
window.removeEventListener("foisit:tts-start", this.onTTSStart);
|
|
722
|
-
window.removeEventListener("foisit:tts-end", this.onTTSEnd);
|
|
723
|
-
if (this.visibilityHandler) {
|
|
724
|
-
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
725
|
-
this.visibilityHandler = void 0;
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
/** Handle recognized speech results */
|
|
729
|
-
handleResult(event, options) {
|
|
730
|
-
var _a, _b, _c, _d;
|
|
731
|
-
if (!this.resultCallback)
|
|
732
|
-
return;
|
|
733
|
-
const threshold = (_a = options.confidenceThreshold) !== null && _a !== void 0 ? _a : 0.6;
|
|
734
|
-
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
735
|
-
const res = event.results[i];
|
|
736
|
-
const alt = res && res[0];
|
|
737
|
-
const transcript = ((_c = (_b = alt === null || alt === void 0 ? void 0 : alt.transcript) === null || _b === void 0 ? void 0 : _b.trim) === null || _c === void 0 ? void 0 : _c.call(_b)) || "";
|
|
738
|
-
const confidence = (_d = alt === null || alt === void 0 ? void 0 : alt.confidence) !== null && _d !== void 0 ? _d : 0;
|
|
739
|
-
if (!transcript)
|
|
740
|
-
continue;
|
|
741
|
-
if (!res.isFinal && options.interimResults === false)
|
|
742
|
-
continue;
|
|
743
|
-
if (res.isFinal && confidence < threshold)
|
|
744
|
-
continue;
|
|
745
|
-
try {
|
|
746
|
-
this.hadResultThisSession = true;
|
|
747
|
-
this.resultCallback(transcript, !!res.isFinal);
|
|
748
|
-
} catch (_e) {
|
|
749
|
-
this.error("VoiceProcessor: result callback error");
|
|
750
|
-
}
|
|
751
632
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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;
|
|
759
737
|
this.isListening = false;
|
|
760
|
-
this.emitStatus(
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
+
}
|
|
872
893
|
};
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
+
}
|
|
881
902
|
};
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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
|
+
}
|
|
895
985
|
function injectStyles() {
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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 = `
|
|
904
996
|
/* Rounded shape with gradient animation */
|
|
905
997
|
.gradient-indicator {
|
|
906
998
|
position: fixed;
|
|
@@ -934,644 +1026,947 @@ function injectStyles() {
|
|
|
934
1026
|
}
|
|
935
1027
|
}
|
|
936
1028
|
`;
|
|
937
|
-
|
|
938
|
-
|
|
1029
|
+
document.head.appendChild(style);
|
|
1030
|
+
console.log('Gradient styles injected');
|
|
939
1031
|
}
|
|
940
1032
|
function addGradientAnimation() {
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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');
|
|
950
1047
|
}
|
|
951
1048
|
function removeGradientAnimation() {
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1049
|
+
const gradientDiv = document.querySelector('#gradient-indicator');
|
|
1050
|
+
if (gradientDiv) {
|
|
1051
|
+
gradientDiv.remove();
|
|
1052
|
+
console.log('Gradient indicator removed from the DOM');
|
|
1053
|
+
}
|
|
957
1054
|
}
|
|
958
1055
|
|
|
959
|
-
// dist/libs/core/src/lib/utils/is-browser.js
|
|
960
1056
|
function isBrowser() {
|
|
961
|
-
|
|
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
|
+
}
|
|
962
1086
|
}
|
|
963
1087
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
+
}
|
|
1022
1232
|
this.renderChatWindow();
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
if (e.key === "Enter" && ((_a2 = this.input) === null || _a2 === void 0 ? void 0 : _a2.value.trim())) {
|
|
1078
|
-
const text = this.input.value.trim();
|
|
1079
|
-
this.input.value = "";
|
|
1080
|
-
if (this.onSubmit)
|
|
1081
|
-
this.onSubmit(text);
|
|
1082
|
-
}
|
|
1083
|
-
});
|
|
1084
|
-
inputArea.appendChild(this.input);
|
|
1085
|
-
this.chatWindow.appendChild(header);
|
|
1086
|
-
this.chatWindow.appendChild(this.messagesContainer);
|
|
1087
|
-
this.chatWindow.appendChild(inputArea);
|
|
1088
|
-
(_a = this.container) === null || _a === void 0 ? void 0 : _a.appendChild(this.chatWindow);
|
|
1089
|
-
}
|
|
1090
|
-
registerCallbacks(onSubmit, onClose) {
|
|
1091
|
-
if (!this.active)
|
|
1092
|
-
return;
|
|
1093
|
-
this.onSubmit = onSubmit;
|
|
1094
|
-
this.onClose = onClose;
|
|
1095
|
-
}
|
|
1096
|
-
toggle(onSubmit, onClose) {
|
|
1097
|
-
if (!this.active)
|
|
1098
|
-
return;
|
|
1099
|
-
if (onSubmit)
|
|
1100
|
-
this.onSubmit = onSubmit;
|
|
1101
|
-
if (onClose)
|
|
1102
|
-
this.onClose = onClose;
|
|
1103
|
-
this.isOpen = !this.isOpen;
|
|
1104
|
-
if (this.chatWindow) {
|
|
1105
|
-
if (this.isOpen) {
|
|
1106
|
-
this.chatWindow.style.display = "flex";
|
|
1107
|
-
requestAnimationFrame(() => {
|
|
1108
|
-
if (this.chatWindow) {
|
|
1109
|
-
this.chatWindow.style.opacity = "1";
|
|
1110
|
-
this.chatWindow.style.transform = "translateY(0) scale(1)";
|
|
1111
|
-
}
|
|
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 = '×';
|
|
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
|
+
}
|
|
1112
1287
|
});
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
this.chatWindow.style.opacity = "0";
|
|
1119
|
-
this.chatWindow.style.transform = "translateY(20px) scale(0.95)";
|
|
1120
|
-
setTimeout(() => {
|
|
1121
|
-
if (this.chatWindow && !this.isOpen) {
|
|
1122
|
-
this.chatWindow.style.display = "none";
|
|
1123
|
-
}
|
|
1124
|
-
}, 200);
|
|
1125
|
-
if (this.onClose)
|
|
1126
|
-
this.onClose();
|
|
1127
|
-
}
|
|
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);
|
|
1128
1293
|
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
const createLabel = (text, required) => {
|
|
1180
|
-
const label = document.createElement("div");
|
|
1181
|
-
label.className = "foisit-form-label";
|
|
1182
|
-
label.innerHTML = text + (required ? ' <span class="foisit-req-star">*</span>' : "");
|
|
1183
|
-
return label;
|
|
1184
|
-
};
|
|
1185
|
-
const createError = () => {
|
|
1186
|
-
const error = document.createElement("div");
|
|
1187
|
-
error.className = "foisit-form-error";
|
|
1188
|
-
error.style.display = "none";
|
|
1189
|
-
return error;
|
|
1190
|
-
};
|
|
1191
|
-
(fields !== null && fields !== void 0 ? fields : []).forEach((field) => {
|
|
1192
|
-
const wrapper = document.createElement("div");
|
|
1193
|
-
wrapper.className = "foisit-form-group";
|
|
1194
|
-
const labelText = field.description || field.name;
|
|
1195
|
-
wrapper.appendChild(createLabel(labelText, field.required));
|
|
1196
|
-
let inputEl;
|
|
1197
|
-
if (field.type === "select") {
|
|
1198
|
-
const select = document.createElement("select");
|
|
1199
|
-
select.className = "foisit-form-input";
|
|
1200
|
-
const placeholderOpt = document.createElement("option");
|
|
1201
|
-
placeholderOpt.value = "";
|
|
1202
|
-
placeholderOpt.textContent = "Select...";
|
|
1203
|
-
select.appendChild(placeholderOpt);
|
|
1204
|
-
const populate = (options) => {
|
|
1205
|
-
(options !== null && options !== void 0 ? options : []).forEach((opt) => {
|
|
1206
|
-
var _a, _b, _c, _d;
|
|
1207
|
-
const o = document.createElement("option");
|
|
1208
|
-
o.value = String((_b = (_a = opt.value) !== null && _a !== void 0 ? _a : opt.label) !== null && _b !== void 0 ? _b : "");
|
|
1209
|
-
o.textContent = String((_d = (_c = opt.label) !== null && _c !== void 0 ? _c : opt.value) !== null && _d !== void 0 ? _d : "");
|
|
1210
|
-
select.appendChild(o);
|
|
1211
|
-
});
|
|
1212
|
-
};
|
|
1213
|
-
if (Array.isArray(field.options) && field.options.length) {
|
|
1214
|
-
populate(field.options);
|
|
1215
|
-
} else if (typeof field.getOptions === "function") {
|
|
1216
|
-
const getOptions = field.getOptions;
|
|
1217
|
-
const loadingOpt = document.createElement("option");
|
|
1218
|
-
loadingOpt.value = "";
|
|
1219
|
-
loadingOpt.textContent = "Loading...";
|
|
1220
|
-
select.appendChild(loadingOpt);
|
|
1221
|
-
Promise.resolve().then(() => getOptions()).then((opts) => {
|
|
1222
|
-
while (select.options.length > 1)
|
|
1223
|
-
select.remove(1);
|
|
1224
|
-
populate(opts);
|
|
1225
|
-
}).catch(() => {
|
|
1226
|
-
while (select.options.length > 1)
|
|
1227
|
-
select.remove(1);
|
|
1228
|
-
const errOpt = document.createElement("option");
|
|
1229
|
-
errOpt.value = "";
|
|
1230
|
-
errOpt.textContent = "Error loading options";
|
|
1231
|
-
select.appendChild(errOpt);
|
|
1232
|
-
});
|
|
1233
|
-
}
|
|
1234
|
-
if (field.defaultValue != null) {
|
|
1235
|
-
select.value = String(field.defaultValue);
|
|
1236
|
-
}
|
|
1237
|
-
inputEl = select;
|
|
1238
|
-
} else if (field.type === "file") {
|
|
1239
|
-
const ffield = field;
|
|
1240
|
-
const input = document.createElement("input");
|
|
1241
|
-
input.className = "foisit-form-input";
|
|
1242
|
-
input.type = "file";
|
|
1243
|
-
if (ffield.accept && Array.isArray(ffield.accept)) {
|
|
1244
|
-
input.accept = ffield.accept.join(",");
|
|
1245
|
-
}
|
|
1246
|
-
if (ffield.multiple)
|
|
1247
|
-
input.multiple = true;
|
|
1248
|
-
if (ffield.capture) {
|
|
1249
|
-
if (ffield.capture === true)
|
|
1250
|
-
input.setAttribute("capture", "");
|
|
1251
|
-
else
|
|
1252
|
-
input.setAttribute("capture", String(ffield.capture));
|
|
1253
|
-
}
|
|
1254
|
-
input.addEventListener("change", () => __awaiter4(this, void 0, void 0, function* () {
|
|
1255
|
-
var _a, _b, _c;
|
|
1256
|
-
const files = Array.from(input.files || []);
|
|
1257
|
-
const errEl = errorEl;
|
|
1258
|
-
errEl.style.display = "none";
|
|
1259
|
-
errEl.textContent = "";
|
|
1260
|
-
if (files.length === 0)
|
|
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)
|
|
1261
1344
|
return;
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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)
|
|
1266
1352
|
return;
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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)) {
|
|
1273
1359
|
return;
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
errEl.textContent = `Total selected files exceed the maximum of ${Math.round(maxTotal / 1024)} KB.`;
|
|
1278
|
-
errEl.style.display = "block";
|
|
1360
|
+
}
|
|
1361
|
+
// Don't close if clicking on the floating button
|
|
1362
|
+
if (target.closest('.foisit-floating-btn')) {
|
|
1279
1363
|
return;
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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;
|
|
1292
1499
|
}
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
el.style.display = "none";
|
|
1354
|
-
el.textContent = "";
|
|
1355
|
-
});
|
|
1356
|
-
form.querySelectorAll(".foisit-form-input").forEach((el) => {
|
|
1357
|
-
el.classList.remove("foisit-error-border");
|
|
1358
|
-
});
|
|
1359
|
-
for (const c of controls) {
|
|
1360
|
-
if (c.type === "file") {
|
|
1361
|
-
const fileWrapper = c.el.parentElement;
|
|
1362
|
-
const fileErrorEl = fileWrapper === null || fileWrapper === void 0 ? void 0 : fileWrapper.querySelector(".foisit-form-error");
|
|
1363
|
-
const input = c.el;
|
|
1364
|
-
const files = Array.from(input.files || []);
|
|
1365
|
-
if (c.required && files.length === 0) {
|
|
1366
|
-
hasError = true;
|
|
1367
|
-
input.classList.add("foisit-error-border");
|
|
1368
|
-
if (fileErrorEl) {
|
|
1369
|
-
fileErrorEl.textContent = "This file is required";
|
|
1370
|
-
fileErrorEl.style.display = "block";
|
|
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;
|
|
1371
1560
|
}
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
const delivery = (_a = fieldDef === null || fieldDef === void 0 ? void 0 : fieldDef.delivery) !== null && _a !== void 0 ? _a : "file";
|
|
1378
|
-
if ((fieldDef === null || fieldDef === void 0 ? void 0 : fieldDef.maxWidth) || (fieldDef === null || fieldDef === void 0 ? void 0 : fieldDef.maxHeight)) {
|
|
1379
|
-
try {
|
|
1380
|
-
const dims = yield this.getImageDimensions(files[0]);
|
|
1381
|
-
if (fieldDef.maxWidth && dims.width > fieldDef.maxWidth) {
|
|
1382
|
-
hasError = true;
|
|
1383
|
-
if (fileErrorEl) {
|
|
1384
|
-
fileErrorEl.textContent = `Image width must be \u2264 ${fieldDef.maxWidth}px`;
|
|
1385
|
-
fileErrorEl.style.display = "block";
|
|
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...';
|
|
1386
1566
|
}
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
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);
|
|
1394
1577
|
}
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
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;
|
|
1398
1593
|
}
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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;
|
|
1408
1731
|
}
|
|
1409
|
-
continue;
|
|
1410
|
-
}
|
|
1411
|
-
} catch (_d) {
|
|
1412
1732
|
}
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
const encoded = yield Promise.all(files.map((file) => this.readFileAsDataURL(file)));
|
|
1419
|
-
data[c.name] = (fieldDef === null || fieldDef === void 0 ? void 0 : fieldDef.multiple) ? encoded : encoded[0];
|
|
1420
|
-
} catch (_e) {
|
|
1421
|
-
hasError = true;
|
|
1422
|
-
if (fileErrorEl) {
|
|
1423
|
-
fileErrorEl.textContent = "Failed to encode file(s) to base64.";
|
|
1424
|
-
fileErrorEl.style.display = "block";
|
|
1425
|
-
}
|
|
1426
|
-
continue;
|
|
1733
|
+
if (hasError) {
|
|
1734
|
+
// Shake animation
|
|
1735
|
+
form.classList.add('foisit-shake');
|
|
1736
|
+
setTimeout(() => form.classList.remove('foisit-shake'), 400);
|
|
1737
|
+
return;
|
|
1427
1738
|
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
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);
|
|
1442
1762
|
}
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
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;
|
|
1451
1773
|
}
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
return;
|
|
1457
|
-
}
|
|
1458
|
-
submitBtn.disabled = true;
|
|
1459
|
-
submitBtn.style.opacity = "0.6";
|
|
1460
|
-
controls.forEach((c) => {
|
|
1461
|
-
c.el.disabled = true;
|
|
1462
|
-
});
|
|
1463
|
-
onSubmit(data);
|
|
1464
|
-
});
|
|
1465
|
-
this.messagesContainer.appendChild(form);
|
|
1466
|
-
this.scrollToBottom();
|
|
1467
|
-
}
|
|
1468
|
-
showLoading() {
|
|
1469
|
-
if (!this.messagesContainer)
|
|
1470
|
-
return;
|
|
1471
|
-
if (this.loadingEl)
|
|
1472
|
-
return;
|
|
1473
|
-
this.loadingEl = document.createElement("div");
|
|
1474
|
-
this.loadingEl.className = "foisit-loading-dots foisit-bubble system";
|
|
1475
|
-
for (let i = 0; i < 3; i++) {
|
|
1476
|
-
const dot = document.createElement("div");
|
|
1477
|
-
dot.className = "foisit-dot";
|
|
1478
|
-
dot.style.animation = `foisitPulse 1.4s infinite ease-in-out ${i * 0.2}s`;
|
|
1479
|
-
this.loadingEl.appendChild(dot);
|
|
1480
|
-
}
|
|
1481
|
-
this.messagesContainer.appendChild(this.loadingEl);
|
|
1482
|
-
this.scrollToBottom();
|
|
1483
|
-
}
|
|
1484
|
-
hideLoading() {
|
|
1485
|
-
var _a;
|
|
1486
|
-
(_a = this.loadingEl) === null || _a === void 0 ? void 0 : _a.remove();
|
|
1487
|
-
this.loadingEl = null;
|
|
1488
|
-
}
|
|
1489
|
-
scrollToBottom() {
|
|
1490
|
-
if (this.messagesContainer) {
|
|
1491
|
-
this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
destroy() {
|
|
1495
|
-
var _a;
|
|
1496
|
-
(_a = this.container) === null || _a === void 0 ? void 0 : _a.remove();
|
|
1497
|
-
this.container = null;
|
|
1498
|
-
this.chatWindow = null;
|
|
1499
|
-
this.messagesContainer = null;
|
|
1500
|
-
this.input = null;
|
|
1501
|
-
this.isOpen = false;
|
|
1502
|
-
}
|
|
1503
|
-
readFileAsDataURL(file) {
|
|
1504
|
-
return new Promise((resolve, reject) => {
|
|
1505
|
-
const fr = new FileReader();
|
|
1506
|
-
fr.onerror = () => reject(new Error("Failed to read file"));
|
|
1507
|
-
fr.onload = () => resolve(String(fr.result));
|
|
1508
|
-
fr.readAsDataURL(file);
|
|
1509
|
-
});
|
|
1510
|
-
}
|
|
1511
|
-
getImageDimensions(file) {
|
|
1512
|
-
return new Promise((resolve) => {
|
|
1513
|
-
try {
|
|
1514
|
-
const url = URL.createObjectURL(file);
|
|
1515
|
-
const img = new Image();
|
|
1516
|
-
img.onload = () => {
|
|
1517
|
-
const dims = { width: img.naturalWidth || img.width, height: img.naturalHeight || img.height };
|
|
1518
|
-
URL.revokeObjectURL(url);
|
|
1519
|
-
resolve(dims);
|
|
1520
|
-
};
|
|
1521
|
-
img.onerror = () => {
|
|
1522
|
-
URL.revokeObjectURL(url);
|
|
1523
|
-
resolve({ width: 0, height: 0 });
|
|
1524
|
-
};
|
|
1525
|
-
img.src = url;
|
|
1526
|
-
} catch (_a) {
|
|
1527
|
-
resolve({ width: 0, height: 0 });
|
|
1528
|
-
}
|
|
1529
|
-
});
|
|
1530
|
-
}
|
|
1531
|
-
getMediaDuration(file) {
|
|
1532
|
-
return new Promise((resolve) => {
|
|
1533
|
-
try {
|
|
1534
|
-
const url = URL.createObjectURL(file);
|
|
1535
|
-
const el = file.type.startsWith("audio") ? document.createElement("audio") : document.createElement("video");
|
|
1536
|
-
let settled = false;
|
|
1537
|
-
const timeout = setTimeout(() => {
|
|
1538
|
-
if (!settled) {
|
|
1539
|
-
settled = true;
|
|
1540
|
-
URL.revokeObjectURL(url);
|
|
1541
|
-
resolve(0);
|
|
1542
|
-
}
|
|
1543
|
-
}, 5e3);
|
|
1544
|
-
el.preload = "metadata";
|
|
1545
|
-
el.onloadedmetadata = () => {
|
|
1546
|
-
if (settled)
|
|
1774
|
+
}
|
|
1775
|
+
/** Subtle entrance animation for new messages */
|
|
1776
|
+
animateMessageEntrance(el, duration) {
|
|
1777
|
+
if (!el)
|
|
1547
1778
|
return;
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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);
|
|
1554
1796
|
};
|
|
1555
|
-
el.
|
|
1556
|
-
|
|
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;
|
|
1557
1808
|
return;
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
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);
|
|
1562
1819
|
};
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
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
|
+
'&': '&',
|
|
1835
|
+
'<': '<',
|
|
1836
|
+
'>': '>',
|
|
1837
|
+
'"': '"',
|
|
1838
|
+
"'": ''',
|
|
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(/^> (.+)$/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 = `
|
|
1575
1970
|
:root {
|
|
1576
1971
|
/* LIGHT MODE (Default) - Smoother gradient */
|
|
1577
1972
|
/* Changed: Softer, right-focused radial highlight to avoid a heavy white bottom */
|
|
@@ -1892,247 +2287,411 @@ var OverlayManager = class {
|
|
|
1892
2287
|
transition: transform 0.2s;
|
|
1893
2288
|
}
|
|
1894
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
|
+
}
|
|
1895
2375
|
`;
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
}
|
|
2376
|
+
document.head.appendChild(style);
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
1899
2379
|
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
2380
|
+
class AssistantService {
|
|
2381
|
+
config;
|
|
2382
|
+
commandHandler;
|
|
2383
|
+
fallbackHandler;
|
|
2384
|
+
voiceProcessor;
|
|
2385
|
+
textToSpeech;
|
|
2386
|
+
stateManager;
|
|
2387
|
+
lastProcessedInput = '';
|
|
2388
|
+
processingLock = false;
|
|
2389
|
+
isActivated = false; // Tracks activation status
|
|
2390
|
+
gestureHandler;
|
|
2391
|
+
overlayManager;
|
|
2392
|
+
defaultIntroMessage = 'How can I help you?';
|
|
2393
|
+
constructor(config) {
|
|
2394
|
+
this.config = config;
|
|
2395
|
+
// Pass enableSmartIntent (default true) and potential apiKey (if we add it to config later)
|
|
2396
|
+
this.commandHandler = new CommandHandler({
|
|
2397
|
+
enableSmartIntent: this.config.enableSmartIntent !== false,
|
|
2398
|
+
intentEndpoint: this.config.intentEndpoint,
|
|
2399
|
+
});
|
|
2400
|
+
this.fallbackHandler = new FallbackHandler();
|
|
2401
|
+
// Browser-only features are lazily created to remain SSR-safe
|
|
2402
|
+
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
2403
|
+
this.voiceProcessor = new VoiceProcessor();
|
|
2404
|
+
this.textToSpeech = new TextToSpeech();
|
|
2405
|
+
this.gestureHandler = new GestureHandler();
|
|
2406
|
+
this.overlayManager = new OverlayManager({
|
|
2407
|
+
floatingButton: this.config.floatingButton,
|
|
2408
|
+
inputPlaceholder: this.config.inputPlaceholder,
|
|
2409
|
+
enableGestureActivation: this.config.enableGestureActivation,
|
|
2410
|
+
});
|
|
2411
|
+
// Let the overlay delegate command execution to our CommandHandler when
|
|
2412
|
+
// a programmatic handler isn't registered on the overlay.
|
|
2413
|
+
this.overlayManager.setExternalCommandExecutor(async (payload) => {
|
|
2414
|
+
return this.commandHandler.executeCommand(payload);
|
|
2415
|
+
});
|
|
2416
|
+
// Register global callbacks for floating button when overlay exists
|
|
2417
|
+
this.overlayManager.registerCallbacks(async (input) => {
|
|
2418
|
+
if (typeof input === 'string') {
|
|
2419
|
+
this.overlayManager.addMessage(input, 'user');
|
|
2420
|
+
}
|
|
2421
|
+
else if (input && typeof input === 'object') {
|
|
2422
|
+
const label = this.extractUserLabel(input);
|
|
2423
|
+
if (label) {
|
|
2424
|
+
this.overlayManager.addMessage(label, 'user');
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
await this.handleCommand(input);
|
|
2428
|
+
}, () => console.log('AssistantService: Overlay closed.'));
|
|
2429
|
+
this.stateManager = new StateManager();
|
|
2430
|
+
}
|
|
2431
|
+
else {
|
|
2432
|
+
// Server environment: keep browser-specific properties null
|
|
2433
|
+
this.stateManager = undefined;
|
|
2434
|
+
this.voiceProcessor = undefined;
|
|
2435
|
+
this.textToSpeech = undefined;
|
|
2436
|
+
this.gestureHandler = undefined;
|
|
2437
|
+
this.overlayManager = undefined;
|
|
2438
|
+
}
|
|
2439
|
+
// Setup commands from config
|
|
2440
|
+
this.config.commands.forEach((cmd) => this.commandHandler.addCommand(cmd));
|
|
2441
|
+
// Setup fallback response
|
|
2442
|
+
if (this.config.fallbackResponse) {
|
|
2443
|
+
this.fallbackHandler.setFallbackMessage(this.config.fallbackResponse);
|
|
2444
|
+
}
|
|
2445
|
+
// Voice disabled for text-first pivot
|
|
2446
|
+
// this.startListening();
|
|
2447
|
+
// (moved into the browser-only block)
|
|
2448
|
+
}
|
|
2449
|
+
/** Start listening for activation and commands */
|
|
2450
|
+
startListening() {
|
|
2451
|
+
// No-op on server or when voice features are unavailable
|
|
2452
|
+
if (typeof window === 'undefined' || !this.voiceProcessor) {
|
|
2453
|
+
console.log('AssistantService: Voice is disabled or unavailable; startListening() is a no-op.');
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
/*
|
|
2457
|
+
this.voiceProcessor.startListening(async (transcript: string) => {
|
|
2458
|
+
if (this.processingLock) return;
|
|
2459
|
+
|
|
2460
|
+
const normalizedTranscript = transcript.toLowerCase().trim();
|
|
2461
|
+
|
|
2462
|
+
// Skip repeated or irrelevant inputs
|
|
2463
|
+
if (
|
|
2464
|
+
!normalizedTranscript ||
|
|
2465
|
+
normalizedTranscript.length < 3 ||
|
|
2466
|
+
normalizedTranscript === this.lastProcessedInput
|
|
2467
|
+
) {
|
|
2468
|
+
console.log('AssistantService: Ignoring irrelevant input.');
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
this.lastProcessedInput = normalizedTranscript;
|
|
2473
|
+
|
|
2474
|
+
if (!this.isActivated) {
|
|
2475
|
+
await this.processActivation(normalizedTranscript);
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
const isFallbackOrIntroMessage =
|
|
2480
|
+
normalizedTranscript === this.config.fallbackResponse?.toLowerCase() ||
|
|
2481
|
+
normalizedTranscript === this.config.introMessage?.toLowerCase() ||
|
|
2482
|
+
normalizedTranscript === this.defaultIntroMessage.toLowerCase();
|
|
2483
|
+
|
|
2484
|
+
if (!isFallbackOrIntroMessage) {
|
|
2485
|
+
await this.handleCommand(normalizedTranscript);
|
|
2486
|
+
} else {
|
|
2487
|
+
console.log('AssistantService: Ignoring fallback or intro message.');
|
|
1945
2488
|
}
|
|
2489
|
+
|
|
2490
|
+
// Throttle input processing to prevent rapid feedback
|
|
2491
|
+
this.processingLock = true;
|
|
2492
|
+
setTimeout(() => (this.processingLock = false), 1000);
|
|
2493
|
+
});
|
|
2494
|
+
*/
|
|
2495
|
+
}
|
|
2496
|
+
/** Stop listening for voice input */
|
|
2497
|
+
stopListening() {
|
|
2498
|
+
if (typeof window === 'undefined' || !this.voiceProcessor) {
|
|
2499
|
+
console.log('AssistantService: Voice unavailable; stopListening() is a no-op.');
|
|
2500
|
+
this.isActivated = false;
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
this.voiceProcessor.stopListening();
|
|
2504
|
+
this.isActivated = false;
|
|
2505
|
+
}
|
|
2506
|
+
/** Reset activation state so the next activation flow can occur. */
|
|
2507
|
+
reactivate() {
|
|
2508
|
+
console.log('AssistantService: Reactivating assistant...');
|
|
2509
|
+
this.isActivated = false;
|
|
2510
|
+
try {
|
|
2511
|
+
this.startListening();
|
|
2512
|
+
}
|
|
2513
|
+
catch {
|
|
2514
|
+
// no-op
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
/** Process activation command */
|
|
2518
|
+
async processActivation(transcript) {
|
|
2519
|
+
const activationCmd = this.config.activationCommand?.toLowerCase();
|
|
2520
|
+
// If no activation command is set, we can't activate via voice
|
|
2521
|
+
if (!activationCmd)
|
|
2522
|
+
return;
|
|
2523
|
+
if (transcript === activationCmd) {
|
|
2524
|
+
console.log('AssistantService: Activation matched.');
|
|
2525
|
+
this.isActivated = true;
|
|
2526
|
+
this.textToSpeech.speak(this.config.introMessage || this.defaultIntroMessage);
|
|
2527
|
+
// this.stateManager.setState('listening'); // DISABLED - no gradient animation
|
|
2528
|
+
}
|
|
2529
|
+
else {
|
|
2530
|
+
console.log('AssistantService: Activation command not recognized.');
|
|
1946
2531
|
}
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
this.gestureHandler.setupDoubleTapListener(() => this.toggle());
|
|
1951
|
-
} else {
|
|
1952
|
-
this.stateManager = void 0;
|
|
1953
|
-
this.voiceProcessor = void 0;
|
|
1954
|
-
this.textToSpeech = void 0;
|
|
1955
|
-
this.gestureHandler = void 0;
|
|
1956
|
-
this.overlayManager = void 0;
|
|
1957
|
-
}
|
|
1958
|
-
this.config.commands.forEach((cmd) => this.commandHandler.addCommand(cmd));
|
|
1959
|
-
if (this.config.fallbackResponse) {
|
|
1960
|
-
this.fallbackHandler.setFallbackMessage(this.config.fallbackResponse);
|
|
1961
|
-
}
|
|
1962
|
-
}
|
|
1963
|
-
/** Start listening for activation and commands */
|
|
1964
|
-
startListening() {
|
|
1965
|
-
if (typeof window === "undefined" || !this.voiceProcessor) {
|
|
1966
|
-
console.log("AssistantService: Voice is disabled or unavailable; startListening() is a no-op.");
|
|
1967
|
-
return;
|
|
1968
|
-
}
|
|
1969
|
-
}
|
|
1970
|
-
/** Stop listening for voice input */
|
|
1971
|
-
stopListening() {
|
|
1972
|
-
if (typeof window === "undefined" || !this.voiceProcessor) {
|
|
1973
|
-
console.log("AssistantService: Voice unavailable; stopListening() is a no-op.");
|
|
1974
|
-
this.isActivated = false;
|
|
1975
|
-
return;
|
|
1976
|
-
}
|
|
1977
|
-
this.voiceProcessor.stopListening();
|
|
1978
|
-
this.isActivated = false;
|
|
1979
|
-
}
|
|
1980
|
-
/** Reset activation state so the next activation flow can occur. */
|
|
1981
|
-
reactivate() {
|
|
1982
|
-
console.log("AssistantService: Reactivating assistant...");
|
|
1983
|
-
this.isActivated = false;
|
|
1984
|
-
try {
|
|
1985
|
-
this.startListening();
|
|
1986
|
-
} catch {
|
|
1987
|
-
}
|
|
1988
|
-
}
|
|
1989
|
-
/** Process activation command */
|
|
1990
|
-
async processActivation(transcript) {
|
|
1991
|
-
const activationCmd = this.config.activationCommand?.toLowerCase();
|
|
1992
|
-
if (!activationCmd)
|
|
1993
|
-
return;
|
|
1994
|
-
if (transcript === activationCmd) {
|
|
1995
|
-
console.log("AssistantService: Activation matched.");
|
|
1996
|
-
this.isActivated = true;
|
|
1997
|
-
this.textToSpeech.speak(this.config.introMessage || this.defaultIntroMessage);
|
|
1998
|
-
} else {
|
|
1999
|
-
console.log("AssistantService: Activation command not recognized.");
|
|
2000
|
-
}
|
|
2001
|
-
}
|
|
2002
|
-
/** Handle recognized commands */
|
|
2003
|
-
async handleCommand(input) {
|
|
2004
|
-
this.overlayManager.showLoading();
|
|
2005
|
-
let response;
|
|
2006
|
-
try {
|
|
2007
|
-
response = await this.commandHandler.executeCommand(input);
|
|
2008
|
-
} finally {
|
|
2009
|
-
this.overlayManager.hideLoading();
|
|
2010
|
-
}
|
|
2011
|
-
const originalText = typeof input === "string" ? input : void 0;
|
|
2012
|
-
this.processResponse(response, originalText);
|
|
2013
|
-
}
|
|
2014
|
-
/**
|
|
2015
|
-
* Cleanup resources
|
|
2016
|
-
*/
|
|
2017
|
-
destroy() {
|
|
2018
|
-
this.voiceProcessor?.stopListening();
|
|
2019
|
-
this.gestureHandler?.destroy();
|
|
2020
|
-
this.overlayManager?.destroy();
|
|
2021
|
-
}
|
|
2022
|
-
/** Unified response processing */
|
|
2023
|
-
processResponse(response, originalText) {
|
|
2024
|
-
if (response.type === "error" && originalText) {
|
|
2025
|
-
this.fallbackHandler.handleFallback(originalText);
|
|
2026
|
-
this.overlayManager.addMessage(this.fallbackHandler.getFallbackMessage(), "system");
|
|
2027
|
-
return;
|
|
2028
|
-
}
|
|
2029
|
-
if (response.type === "form" && response.fields) {
|
|
2030
|
-
this.overlayManager.addForm(response.message, response.fields, async (data) => {
|
|
2532
|
+
}
|
|
2533
|
+
/** Handle recognized commands */
|
|
2534
|
+
async handleCommand(input) {
|
|
2031
2535
|
this.overlayManager.showLoading();
|
|
2032
|
-
let
|
|
2536
|
+
let response;
|
|
2033
2537
|
try {
|
|
2034
|
-
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2538
|
+
response = await this.commandHandler.executeCommand(input);
|
|
2539
|
+
}
|
|
2540
|
+
finally {
|
|
2541
|
+
this.overlayManager.hideLoading();
|
|
2037
2542
|
}
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
return;
|
|
2543
|
+
const originalText = typeof input === 'string' ? input : undefined;
|
|
2544
|
+
this.processResponse(response, originalText);
|
|
2041
2545
|
}
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
console.log(`AssistantService: Adding command "${commandOrObj}".`);
|
|
2057
|
-
} else {
|
|
2058
|
-
console.log(`AssistantService: Adding rich command "${commandOrObj.command}".`);
|
|
2059
|
-
}
|
|
2060
|
-
this.commandHandler.addCommand(commandOrObj, action);
|
|
2061
|
-
}
|
|
2062
|
-
/** Remove a command dynamically */
|
|
2063
|
-
removeCommand(command) {
|
|
2064
|
-
console.log(`AssistantService: Removing command "${command}".`);
|
|
2065
|
-
this.commandHandler.removeCommand(command);
|
|
2066
|
-
}
|
|
2067
|
-
/** Get all registered commands */
|
|
2068
|
-
getCommands() {
|
|
2069
|
-
return this.commandHandler.getCommands();
|
|
2070
|
-
}
|
|
2071
|
-
/** Toggle the assistant overlay */
|
|
2072
|
-
toggle(onSubmit, onClose) {
|
|
2073
|
-
console.log("AssistantService: Toggling overlay...");
|
|
2074
|
-
this.overlayManager.toggle(async (input) => {
|
|
2075
|
-
if (typeof input === "string") {
|
|
2076
|
-
this.overlayManager.addMessage(input, "user");
|
|
2077
|
-
} else if (input && typeof input === "object") {
|
|
2078
|
-
const label = this.extractUserLabel(input);
|
|
2079
|
-
if (label) {
|
|
2080
|
-
this.overlayManager.addMessage(label, "user");
|
|
2546
|
+
/**
|
|
2547
|
+
* Cleanup resources
|
|
2548
|
+
*/
|
|
2549
|
+
destroy() {
|
|
2550
|
+
this.voiceProcessor?.stopListening();
|
|
2551
|
+
this.gestureHandler?.destroy();
|
|
2552
|
+
this.overlayManager?.destroy();
|
|
2553
|
+
}
|
|
2554
|
+
/** Unified response processing */
|
|
2555
|
+
processResponse(response, originalText) {
|
|
2556
|
+
if (response.type === 'error' && originalText) {
|
|
2557
|
+
this.fallbackHandler.handleFallback(originalText);
|
|
2558
|
+
this.overlayManager.addMessage(this.fallbackHandler.getFallbackMessage(), 'system');
|
|
2559
|
+
return;
|
|
2081
2560
|
}
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
}
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
}
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2561
|
+
if (response.type === 'form' && response.fields) {
|
|
2562
|
+
this.overlayManager.addForm(response.message, response.fields, async (data) => {
|
|
2563
|
+
this.overlayManager.showLoading();
|
|
2564
|
+
let nextReponse;
|
|
2565
|
+
try {
|
|
2566
|
+
nextReponse = await this.commandHandler.executeCommand(data);
|
|
2567
|
+
}
|
|
2568
|
+
finally {
|
|
2569
|
+
this.overlayManager.hideLoading();
|
|
2570
|
+
}
|
|
2571
|
+
this.processResponse(nextReponse);
|
|
2572
|
+
});
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
if ((response.type === 'ambiguous' || response.type === 'confirm') && response.options) {
|
|
2576
|
+
if (response.message) {
|
|
2577
|
+
this.overlayManager.addMessage(response.message, 'system');
|
|
2578
|
+
}
|
|
2579
|
+
this.overlayManager.addOptions(response.options);
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
if (response.message) {
|
|
2583
|
+
this.overlayManager.addMessage(response.message, 'system');
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
/** Expose programmatic command handler registration to host apps */
|
|
2587
|
+
registerCommandHandler(commandId, handler) {
|
|
2588
|
+
if (this.overlayManager)
|
|
2589
|
+
this.overlayManager.registerCommandHandler(commandId, handler);
|
|
2590
|
+
}
|
|
2591
|
+
unregisterCommandHandler(commandId) {
|
|
2592
|
+
if (this.overlayManager)
|
|
2593
|
+
this.overlayManager.unregisterCommandHandler(commandId);
|
|
2594
|
+
}
|
|
2595
|
+
/** Programmatically run a registered command (proxies to OverlayManager) */
|
|
2596
|
+
async runCommand(options) {
|
|
2597
|
+
if (!this.overlayManager)
|
|
2598
|
+
throw new Error('Overlay manager not available.');
|
|
2599
|
+
const res = await this.overlayManager.runCommand(options);
|
|
2600
|
+
// If the overlay delegated to the CommandHandler, it returns an
|
|
2601
|
+
// InteractiveResponse object that we should process to render forms/options.
|
|
2602
|
+
if (res && typeof res === 'object' && 'type' in res) {
|
|
2603
|
+
// Let the existing response processing pipeline handle rendering.
|
|
2604
|
+
this.processResponse(res);
|
|
2605
|
+
}
|
|
2606
|
+
return res;
|
|
2607
|
+
}
|
|
2608
|
+
/** Add a command dynamically (supports string or rich object) */
|
|
2609
|
+
addCommand(commandOrObj, action) {
|
|
2610
|
+
if (typeof commandOrObj === 'string') {
|
|
2611
|
+
console.log(`AssistantService: Adding command "${commandOrObj}".`);
|
|
2612
|
+
}
|
|
2613
|
+
else {
|
|
2614
|
+
console.log(`AssistantService: Adding rich command "${commandOrObj.command}".`);
|
|
2615
|
+
}
|
|
2616
|
+
this.commandHandler.addCommand(commandOrObj, action);
|
|
2617
|
+
}
|
|
2618
|
+
/** Remove a command dynamically */
|
|
2619
|
+
removeCommand(command) {
|
|
2620
|
+
console.log(`AssistantService: Removing command "${command}".`);
|
|
2621
|
+
this.commandHandler.removeCommand(command);
|
|
2622
|
+
}
|
|
2623
|
+
/** Get all registered commands */
|
|
2624
|
+
getCommands() {
|
|
2625
|
+
return this.commandHandler.getCommands();
|
|
2626
|
+
}
|
|
2627
|
+
/** Toggle the assistant overlay */
|
|
2628
|
+
toggle(onSubmit, onClose) {
|
|
2629
|
+
console.log('AssistantService: Toggling overlay...');
|
|
2630
|
+
this.overlayManager.toggle(async (input) => {
|
|
2631
|
+
if (typeof input === 'string') {
|
|
2632
|
+
this.overlayManager.addMessage(input, 'user');
|
|
2633
|
+
}
|
|
2634
|
+
else if (input && typeof input === 'object') {
|
|
2635
|
+
const label = this.extractUserLabel(input);
|
|
2636
|
+
if (label) {
|
|
2637
|
+
this.overlayManager.addMessage(label, 'user');
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
if (onSubmit)
|
|
2641
|
+
onSubmit(input);
|
|
2642
|
+
await this.handleCommand(input);
|
|
2643
|
+
}, () => {
|
|
2644
|
+
console.log('AssistantService: Overlay closed.');
|
|
2645
|
+
if (onClose)
|
|
2646
|
+
onClose();
|
|
2647
|
+
});
|
|
2648
|
+
}
|
|
2649
|
+
extractUserLabel(payload) {
|
|
2650
|
+
const label = payload['label'];
|
|
2651
|
+
if (typeof label === 'string' && label.trim()) {
|
|
2652
|
+
return label.trim();
|
|
2653
|
+
}
|
|
2654
|
+
const commandId = payload['commandId'];
|
|
2655
|
+
if (typeof commandId === 'string' && commandId.trim()) {
|
|
2656
|
+
return commandId.trim();
|
|
2657
|
+
}
|
|
2658
|
+
return null;
|
|
2659
|
+
}
|
|
2660
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.7", ngImport: i0, type: AssistantService, deps: [{ token: 'ASSISTANT_CONFIG' }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
2661
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.0.7", ngImport: i0, type: AssistantService, providedIn: 'root' });
|
|
2662
|
+
}
|
|
2663
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.7", ngImport: i0, type: AssistantService, decorators: [{
|
|
2664
|
+
type: Injectable,
|
|
2665
|
+
args: [{
|
|
2666
|
+
providedIn: 'root',
|
|
2667
|
+
}]
|
|
2668
|
+
}], ctorParameters: () => [{ type: undefined, decorators: [{
|
|
2669
|
+
type: Inject,
|
|
2670
|
+
args: ['ASSISTANT_CONFIG']
|
|
2671
|
+
}] }] });
|
|
2672
|
+
|
|
2673
|
+
class AssistantModule {
|
|
2674
|
+
static forRoot(config) {
|
|
2675
|
+
return {
|
|
2676
|
+
ngModule: AssistantModule,
|
|
2677
|
+
providers: [
|
|
2678
|
+
{ provide: 'ASSISTANT_CONFIG', useValue: config },
|
|
2679
|
+
AssistantService,
|
|
2680
|
+
],
|
|
2681
|
+
};
|
|
2682
|
+
}
|
|
2683
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.7", ngImport: i0, type: AssistantModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
|
|
2684
|
+
static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "19.0.7", ngImport: i0, type: AssistantModule });
|
|
2685
|
+
static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "19.0.7", ngImport: i0, type: AssistantModule });
|
|
2686
|
+
}
|
|
2687
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.7", ngImport: i0, type: AssistantModule, decorators: [{
|
|
2688
|
+
type: NgModule,
|
|
2689
|
+
args: [{}]
|
|
2690
|
+
}] });
|
|
2691
|
+
|
|
2692
|
+
/**
|
|
2693
|
+
* Generated bundle index. Do not edit.
|
|
2694
|
+
*/
|
|
2695
|
+
|
|
2696
|
+
export { AngularWrapperComponent, AssistantModule, AssistantService };
|
|
2138
2697
|
//# sourceMappingURL=foisit-angular-wrapper.mjs.map
|