veform-js 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eric McElyea
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # veform-js
2
+
3
+ JS/TS Library for [Veform](https://veform.co)
4
+
5
+
6
+ For usage/examples see [Playground](https://veform.co/playground)
7
+
8
+ ## License
9
+
10
+ See [LICENSE](./LICENSE) file.
@@ -0,0 +1,2 @@
1
+ export * from "./veform-builder";
2
+ export * from "./veform";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./veform-builder";
2
+ export * from "./veform";
File without changes
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ "use strict";
@@ -0,0 +1,118 @@
1
+ declare enum FieldType {
2
+ TEXT = "text",
3
+ TEXTAREA = "textarea",
4
+ SELECT = "select",
5
+ MULTISELECT = "multiselect",
6
+ YESNO = "yesNo",
7
+ NUMBER = "number",
8
+ INFO = "info"
9
+ }
10
+ type EventHandlers = {
11
+ onFocus?: (previousName: string) => boolean;
12
+ onChange?: (answer: string | number | boolean) => void;
13
+ };
14
+ export declare abstract class Field {
15
+ name: string;
16
+ question: string;
17
+ type: FieldType;
18
+ eventConfig?: FieldEventConfig | undefined;
19
+ eventHandlers: EventHandlers;
20
+ constructor(name: string, question: string, type: FieldType, eventConfig?: FieldEventConfig | undefined, eventHandlers?: EventHandlers);
21
+ addBehavior(event: FieldEvents, behavior: FieldBehavior): void;
22
+ onFocus(callback: EventHandlers['onFocus']): void;
23
+ onChange(callback: EventHandlers['onChange']): void;
24
+ }
25
+ export type FieldEventConfig = {
26
+ [key in FieldEvents]?: FieldBehavior[];
27
+ };
28
+ declare enum FieldEvents {
29
+ VALID_ANSWER = "validAnswer",
30
+ INVALID_ANSWER = "invalidAnswer",
31
+ MOVE_REQUESTED = "moveRequested",
32
+ END_REQUESTED = "endRequested",
33
+ VALID_YES_ANSWER = "validYesAnswer",
34
+ VALID_NO_ANSWER = "validNoAnswer"
35
+ }
36
+ declare enum FieldBehaviorType {
37
+ MOVE_TO = "moveTo",
38
+ OUTPUT = "output"
39
+ }
40
+ type FieldBehavior = {
41
+ type: FieldBehaviorType;
42
+ moveToFieldName: string;
43
+ output?: string;
44
+ modifier?: string;
45
+ };
46
+ export declare class TextField extends Field {
47
+ textFieldValidation?: TextFieldValidation;
48
+ constructor(name: string, question: string, eventConfig?: FieldEventConfig);
49
+ addValidation(validation: TextFieldValidation): void;
50
+ }
51
+ type TextFieldPatterns = 'email' | 'phone' | 'url' | 'date' | 'name';
52
+ type TextFieldValidation = {
53
+ validate: boolean;
54
+ pattern?: TextFieldPatterns;
55
+ readback?: boolean;
56
+ };
57
+ export declare class NumberField extends Field {
58
+ numberFieldValidation?: NumberFieldValidation;
59
+ constructor(name: string, question: string, eventConfig?: FieldEventConfig);
60
+ addValidation(validation: NumberFieldValidation): void;
61
+ }
62
+ type NumberFieldValidation = {
63
+ validate: boolean;
64
+ minValue?: number;
65
+ maxValue?: number;
66
+ };
67
+ export declare class SelectField extends Field {
68
+ selectFieldValidation?: SelectFieldValidation;
69
+ constructor(name: string, question: string, eventConfig?: FieldEventConfig);
70
+ addSelectOption(option: SelectOption): void;
71
+ }
72
+ export type SelectOption = {
73
+ label: string;
74
+ value: string;
75
+ readAloud?: boolean;
76
+ behaviors?: FieldBehavior[];
77
+ };
78
+ type SelectFieldValidation = {
79
+ validate: boolean;
80
+ selectOptions?: SelectOption[];
81
+ };
82
+ export declare class MultiselectField extends Field {
83
+ multiselectFieldValidation?: SelectFieldValidation;
84
+ constructor(name: string, question: string, eventConfig?: FieldEventConfig);
85
+ addSelectOption(option: SelectOption): void;
86
+ }
87
+ export declare class YesNoField extends Field {
88
+ yesNoFieldValidation?: YesNoFieldValidation;
89
+ constructor(name: string, question: string, eventConfig?: FieldEventConfig);
90
+ addValidation(validation: YesNoFieldValidation): void;
91
+ }
92
+ type YesNoFieldValidation = {
93
+ validate: boolean;
94
+ requireYes?: boolean;
95
+ requireNo?: boolean;
96
+ };
97
+ export declare class TextAreaField extends Field {
98
+ textAreaFieldValidation?: TextAreaFieldValidation;
99
+ constructor(name: string, question: string, eventConfig?: FieldEventConfig);
100
+ addValidation(validation: TextAreaFieldValidation): void;
101
+ }
102
+ type TextAreaFieldValidation = {
103
+ validate: boolean;
104
+ maxCharacters?: number;
105
+ minCharacters?: number;
106
+ };
107
+ export declare class InfoField extends Field {
108
+ constructor(name: string, question: string, eventConfig?: FieldEventConfig);
109
+ }
110
+ export declare class VeformBuilder {
111
+ private fields;
112
+ addField({ name, question, type }: Field): Field | null;
113
+ getField(name: string): Field | undefined;
114
+ getFields(): Field[];
115
+ setField(name: string, field: Field): boolean;
116
+ removeField(name: string): boolean;
117
+ }
118
+ export {};
@@ -0,0 +1,184 @@
1
+ // TMRW MOVE THESE ALL TO CLASSES THAT CAN CONVERT TO THE JSON PAYLOAD WHEN NEEDED
2
+ // then test we can actuall parse this nonsense in GO
3
+ var FieldType;
4
+ (function (FieldType) {
5
+ FieldType["TEXT"] = "text";
6
+ FieldType["TEXTAREA"] = "textarea";
7
+ FieldType["SELECT"] = "select";
8
+ FieldType["MULTISELECT"] = "multiselect";
9
+ FieldType["YESNO"] = "yesNo";
10
+ FieldType["NUMBER"] = "number";
11
+ FieldType["INFO"] = "info";
12
+ })(FieldType || (FieldType = {}));
13
+ export class Field {
14
+ constructor(name, question, type, eventConfig, eventHandlers = {}) {
15
+ this.name = name;
16
+ this.question = question;
17
+ this.type = type;
18
+ this.eventConfig = eventConfig;
19
+ this.eventHandlers = eventHandlers;
20
+ }
21
+ addBehavior(event, behavior) {
22
+ if (!this.eventConfig) {
23
+ this.eventConfig = {};
24
+ }
25
+ if (!this.eventConfig[event]) {
26
+ this.eventConfig[event] = [];
27
+ }
28
+ if (behavior.type === FieldBehaviorType.MOVE_TO) {
29
+ const existingMoveToIndex = this.eventConfig[event].findIndex(b => b.type === FieldBehaviorType.MOVE_TO);
30
+ if (existingMoveToIndex !== -1) {
31
+ this.eventConfig[event][existingMoveToIndex] = behavior;
32
+ }
33
+ else {
34
+ this.eventConfig[event].push(behavior);
35
+ }
36
+ }
37
+ else {
38
+ this.eventConfig[event].push(behavior);
39
+ }
40
+ }
41
+ onFocus(callback) {
42
+ this.eventHandlers.onFocus = callback;
43
+ }
44
+ onChange(callback) {
45
+ this.eventHandlers.onChange = callback;
46
+ }
47
+ }
48
+ var FieldEvents;
49
+ (function (FieldEvents) {
50
+ FieldEvents["VALID_ANSWER"] = "validAnswer";
51
+ FieldEvents["INVALID_ANSWER"] = "invalidAnswer";
52
+ FieldEvents["MOVE_REQUESTED"] = "moveRequested";
53
+ FieldEvents["END_REQUESTED"] = "endRequested";
54
+ FieldEvents["VALID_YES_ANSWER"] = "validYesAnswer";
55
+ FieldEvents["VALID_NO_ANSWER"] = "validNoAnswer";
56
+ })(FieldEvents || (FieldEvents = {}));
57
+ var FieldBehaviorType;
58
+ (function (FieldBehaviorType) {
59
+ FieldBehaviorType["MOVE_TO"] = "moveTo";
60
+ FieldBehaviorType["OUTPUT"] = "output";
61
+ })(FieldBehaviorType || (FieldBehaviorType = {}));
62
+ export class TextField extends Field {
63
+ constructor(name, question, eventConfig) {
64
+ super(name, question, FieldType.TEXT, eventConfig);
65
+ }
66
+ addValidation(validation) {
67
+ this.textFieldValidation = validation;
68
+ }
69
+ }
70
+ export class NumberField extends Field {
71
+ constructor(name, question, eventConfig) {
72
+ super(name, question, FieldType.NUMBER, eventConfig);
73
+ }
74
+ addValidation(validation) {
75
+ this.numberFieldValidation = validation;
76
+ }
77
+ }
78
+ export class SelectField extends Field {
79
+ constructor(name, question, eventConfig) {
80
+ super(name, question, FieldType.SELECT, eventConfig);
81
+ this.selectFieldValidation = {
82
+ validate: true,
83
+ selectOptions: [],
84
+ };
85
+ }
86
+ addSelectOption(option) {
87
+ this.selectFieldValidation?.selectOptions?.push(option);
88
+ }
89
+ }
90
+ export class MultiselectField extends Field {
91
+ constructor(name, question, eventConfig) {
92
+ super(name, question, FieldType.MULTISELECT, eventConfig);
93
+ }
94
+ addSelectOption(option) {
95
+ this.multiselectFieldValidation?.selectOptions?.push(option);
96
+ }
97
+ }
98
+ export class YesNoField extends Field {
99
+ constructor(name, question, eventConfig) {
100
+ super(name, question, FieldType.YESNO, eventConfig);
101
+ }
102
+ addValidation(validation) {
103
+ this.yesNoFieldValidation = validation;
104
+ }
105
+ }
106
+ export class TextAreaField extends Field {
107
+ constructor(name, question, eventConfig) {
108
+ super(name, question, FieldType.TEXTAREA, eventConfig);
109
+ }
110
+ addValidation(validation) {
111
+ this.textAreaFieldValidation = validation;
112
+ }
113
+ }
114
+ export class InfoField extends Field {
115
+ constructor(name, question, eventConfig) {
116
+ super(name, question, FieldType.INFO, eventConfig);
117
+ }
118
+ }
119
+ export class VeformBuilder {
120
+ constructor() {
121
+ this.fields = [];
122
+ }
123
+ addField({ name, question, type }) {
124
+ if (this.getField(name)) {
125
+ console.error(`Field with name ${name} already exists`);
126
+ return null;
127
+ }
128
+ if (name.length === 0 || question.length === 0) {
129
+ console.error(`Field with name ${name} and question ${question} has invalid name or question`);
130
+ return null;
131
+ }
132
+ let field;
133
+ switch (type) {
134
+ case FieldType.TEXT:
135
+ field = new TextField(name, question);
136
+ break;
137
+ case FieldType.TEXTAREA:
138
+ field = new TextAreaField(name, question);
139
+ break;
140
+ case FieldType.SELECT:
141
+ field = new SelectField(name, question);
142
+ break;
143
+ case FieldType.YESNO:
144
+ field = new YesNoField(name, question);
145
+ break;
146
+ case FieldType.NUMBER:
147
+ field = new NumberField(name, question);
148
+ break;
149
+ case FieldType.INFO:
150
+ field = new InfoField(name, question);
151
+ break;
152
+ case FieldType.MULTISELECT:
153
+ field = new MultiselectField(name, question);
154
+ break;
155
+ default:
156
+ console.error(`Field with name ${name} has invalid type ${type}`);
157
+ return null;
158
+ }
159
+ this.fields.push(field);
160
+ return field;
161
+ }
162
+ getField(name) {
163
+ return this.fields.find(field => field.name === name);
164
+ }
165
+ getFields() {
166
+ return this.fields;
167
+ }
168
+ setField(name, field) {
169
+ const index = this.fields.findIndex(field => field.name === name);
170
+ if (index !== -1) {
171
+ this.fields[index] = field;
172
+ return true;
173
+ }
174
+ return false;
175
+ }
176
+ removeField(name) {
177
+ const index = this.fields.findIndex(field => field.name === name);
178
+ if (index !== -1) {
179
+ this.fields.splice(index, 1);
180
+ return true;
181
+ }
182
+ return false;
183
+ }
184
+ }
@@ -0,0 +1,514 @@
1
+ "use strict";
2
+ var Veform = (() => {
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+
21
+ // dist/index.js
22
+ var dist_exports = {};
23
+ __export(dist_exports, {
24
+ Field: () => Field,
25
+ InfoField: () => InfoField,
26
+ MultiselectField: () => MultiselectField,
27
+ NumberField: () => NumberField,
28
+ SelectField: () => SelectField,
29
+ TextAreaField: () => TextAreaField,
30
+ TextField: () => TextField,
31
+ Veform: () => Veform,
32
+ VeformBuilder: () => VeformBuilder,
33
+ YesNoField: () => YesNoField
34
+ });
35
+
36
+ // dist/veform-builder.js
37
+ var FieldType;
38
+ (function(FieldType2) {
39
+ FieldType2["TEXT"] = "text";
40
+ FieldType2["TEXTAREA"] = "textarea";
41
+ FieldType2["SELECT"] = "select";
42
+ FieldType2["MULTISELECT"] = "multiselect";
43
+ FieldType2["YESNO"] = "yesNo";
44
+ FieldType2["NUMBER"] = "number";
45
+ FieldType2["INFO"] = "info";
46
+ })(FieldType || (FieldType = {}));
47
+ var Field = class {
48
+ constructor(name, question, type, eventConfig, eventHandlers = {}) {
49
+ this.name = name;
50
+ this.question = question;
51
+ this.type = type;
52
+ this.eventConfig = eventConfig;
53
+ this.eventHandlers = eventHandlers;
54
+ }
55
+ addBehavior(event, behavior) {
56
+ if (!this.eventConfig) {
57
+ this.eventConfig = {};
58
+ }
59
+ if (!this.eventConfig[event]) {
60
+ this.eventConfig[event] = [];
61
+ }
62
+ if (behavior.type === FieldBehaviorType.MOVE_TO) {
63
+ const existingMoveToIndex = this.eventConfig[event].findIndex((b) => b.type === FieldBehaviorType.MOVE_TO);
64
+ if (existingMoveToIndex !== -1) {
65
+ this.eventConfig[event][existingMoveToIndex] = behavior;
66
+ } else {
67
+ this.eventConfig[event].push(behavior);
68
+ }
69
+ } else {
70
+ this.eventConfig[event].push(behavior);
71
+ }
72
+ }
73
+ onFocus(callback) {
74
+ this.eventHandlers.onFocus = callback;
75
+ }
76
+ onChange(callback) {
77
+ this.eventHandlers.onChange = callback;
78
+ }
79
+ };
80
+ var FieldEvents;
81
+ (function(FieldEvents2) {
82
+ FieldEvents2["VALID_ANSWER"] = "validAnswer";
83
+ FieldEvents2["INVALID_ANSWER"] = "invalidAnswer";
84
+ FieldEvents2["MOVE_REQUESTED"] = "moveRequested";
85
+ FieldEvents2["END_REQUESTED"] = "endRequested";
86
+ FieldEvents2["VALID_YES_ANSWER"] = "validYesAnswer";
87
+ FieldEvents2["VALID_NO_ANSWER"] = "validNoAnswer";
88
+ })(FieldEvents || (FieldEvents = {}));
89
+ var FieldBehaviorType;
90
+ (function(FieldBehaviorType2) {
91
+ FieldBehaviorType2["MOVE_TO"] = "moveTo";
92
+ FieldBehaviorType2["OUTPUT"] = "output";
93
+ })(FieldBehaviorType || (FieldBehaviorType = {}));
94
+ var TextField = class extends Field {
95
+ constructor(name, question, eventConfig) {
96
+ super(name, question, FieldType.TEXT, eventConfig);
97
+ }
98
+ addValidation(validation) {
99
+ this.textFieldValidation = validation;
100
+ }
101
+ };
102
+ var NumberField = class extends Field {
103
+ constructor(name, question, eventConfig) {
104
+ super(name, question, FieldType.NUMBER, eventConfig);
105
+ }
106
+ addValidation(validation) {
107
+ this.numberFieldValidation = validation;
108
+ }
109
+ };
110
+ var SelectField = class extends Field {
111
+ constructor(name, question, eventConfig) {
112
+ super(name, question, FieldType.SELECT, eventConfig);
113
+ this.selectFieldValidation = {
114
+ validate: true,
115
+ selectOptions: []
116
+ };
117
+ }
118
+ addSelectOption(option) {
119
+ this.selectFieldValidation?.selectOptions?.push(option);
120
+ }
121
+ };
122
+ var MultiselectField = class extends Field {
123
+ constructor(name, question, eventConfig) {
124
+ super(name, question, FieldType.MULTISELECT, eventConfig);
125
+ }
126
+ addSelectOption(option) {
127
+ this.multiselectFieldValidation?.selectOptions?.push(option);
128
+ }
129
+ };
130
+ var YesNoField = class extends Field {
131
+ constructor(name, question, eventConfig) {
132
+ super(name, question, FieldType.YESNO, eventConfig);
133
+ }
134
+ addValidation(validation) {
135
+ this.yesNoFieldValidation = validation;
136
+ }
137
+ };
138
+ var TextAreaField = class extends Field {
139
+ constructor(name, question, eventConfig) {
140
+ super(name, question, FieldType.TEXTAREA, eventConfig);
141
+ }
142
+ addValidation(validation) {
143
+ this.textAreaFieldValidation = validation;
144
+ }
145
+ };
146
+ var InfoField = class extends Field {
147
+ constructor(name, question, eventConfig) {
148
+ super(name, question, FieldType.INFO, eventConfig);
149
+ }
150
+ };
151
+ var VeformBuilder = class {
152
+ constructor() {
153
+ this.fields = [];
154
+ }
155
+ addField({ name, question, type }) {
156
+ if (this.getField(name)) {
157
+ console.error(`Field with name ${name} already exists`);
158
+ return null;
159
+ }
160
+ if (name.length === 0 || question.length === 0) {
161
+ console.error(`Field with name ${name} and question ${question} has invalid name or question`);
162
+ return null;
163
+ }
164
+ let field;
165
+ switch (type) {
166
+ case FieldType.TEXT:
167
+ field = new TextField(name, question);
168
+ break;
169
+ case FieldType.TEXTAREA:
170
+ field = new TextAreaField(name, question);
171
+ break;
172
+ case FieldType.SELECT:
173
+ field = new SelectField(name, question);
174
+ break;
175
+ case FieldType.YESNO:
176
+ field = new YesNoField(name, question);
177
+ break;
178
+ case FieldType.NUMBER:
179
+ field = new NumberField(name, question);
180
+ break;
181
+ case FieldType.INFO:
182
+ field = new InfoField(name, question);
183
+ break;
184
+ case FieldType.MULTISELECT:
185
+ field = new MultiselectField(name, question);
186
+ break;
187
+ default:
188
+ console.error(`Field with name ${name} has invalid type ${type}`);
189
+ return null;
190
+ }
191
+ this.fields.push(field);
192
+ return field;
193
+ }
194
+ getField(name) {
195
+ return this.fields.find((field) => field.name === name);
196
+ }
197
+ getFields() {
198
+ return this.fields;
199
+ }
200
+ setField(name, field) {
201
+ const index = this.fields.findIndex((field2) => field2.name === name);
202
+ if (index !== -1) {
203
+ this.fields[index] = field;
204
+ return true;
205
+ }
206
+ return false;
207
+ }
208
+ removeField(name) {
209
+ const index = this.fields.findIndex((field) => field.name === name);
210
+ if (index !== -1) {
211
+ this.fields.splice(index, 1);
212
+ return true;
213
+ }
214
+ return false;
215
+ }
216
+ };
217
+
218
+ // dist/veform.js
219
+ var DEFAULT_SERVER_URL = "ws://localhost:8080/ws";
220
+ var Veform = class {
221
+ constructor(fields) {
222
+ this.connected = false;
223
+ this.eventHandlers = {};
224
+ this.localStream = null;
225
+ this.peerConnection = null;
226
+ this.wsConnection = null;
227
+ this.audioElement = null;
228
+ if (fields instanceof VeformBuilder) {
229
+ this.form = { fields: fields.getFields() };
230
+ } else {
231
+ this.form = { fields };
232
+ }
233
+ }
234
+ onLoadingStarted(callback) {
235
+ this.eventHandlers.onLoadingStarted = callback;
236
+ }
237
+ onRunningStarted(callback) {
238
+ this.eventHandlers.onRunningStarted = callback;
239
+ }
240
+ onFinished(callback) {
241
+ this.eventHandlers.onFinished = callback;
242
+ }
243
+ onError(callback) {
244
+ this.eventHandlers.onError = callback;
245
+ }
246
+ onCriticalError(callback) {
247
+ this.eventHandlers.onCriticalError = callback;
248
+ }
249
+ onAudioInStart(callback) {
250
+ this.eventHandlers.onAudioInStart = callback;
251
+ }
252
+ onAudioInEnd(callback) {
253
+ this.eventHandlers.onAudioInEnd = callback;
254
+ }
255
+ onAudioOutStart(callback) {
256
+ this.eventHandlers.onAudioOutStart = callback;
257
+ }
258
+ onAudioOutEnd(callback) {
259
+ this.eventHandlers.onAudioOutEnd = callback;
260
+ }
261
+ onFieldValueChanged(callback) {
262
+ this.eventHandlers.onFieldValueChanged = callback;
263
+ }
264
+ onFocusChanged(callback) {
265
+ this.eventHandlers.onFocusChanged = callback;
266
+ }
267
+ /**
268
+ * Start the conversation
269
+ * This will connect the client the the veform server with the current set of fields
270
+ */
271
+ async start(token) {
272
+ if (!this.form?.fields || this.form?.fields.length === 0) {
273
+ console.error("No fields provided or token URL");
274
+ return false;
275
+ }
276
+ if (this.connected || this.wsConnection || this.peerConnection || this.localStream) {
277
+ console.error("Start already called, try running stop() or creating a new instance");
278
+ return false;
279
+ }
280
+ try {
281
+ if (token.startsWith("http")) {
282
+ console.log("Fetching token from URL", token);
283
+ token = await fetch(token, { method: "POST" }).then((response) => response.json()).then((data) => data.token);
284
+ }
285
+ if (!token) {
286
+ console.error("No token provided or returned from token URL");
287
+ return false;
288
+ }
289
+ this.audioElement = createAudioElement();
290
+ if (this.eventHandlers.onLoadingStarted) {
291
+ this.eventHandlers.onLoadingStarted();
292
+ }
293
+ this.localStream = await navigator.mediaDevices.getUserMedia({
294
+ audio: {
295
+ echoCancellation: true,
296
+ noiseSuppression: true,
297
+ sampleRate: 48e3,
298
+ channelCount: 1
299
+ },
300
+ video: false
301
+ });
302
+ this.peerConnection = new RTCPeerConnection();
303
+ this.localStream.getTracks().forEach((track) => {
304
+ if (!this.localStream || !this.peerConnection) {
305
+ console.error("Local stream or peer connection not established");
306
+ return;
307
+ }
308
+ this.peerConnection?.addTrack(track, this.localStream);
309
+ });
310
+ this.peerConnection.oniceconnectionstatechange = () => {
311
+ if (this.peerConnection?.iceConnectionState === "connected") {
312
+ this.connected = true;
313
+ console.log("Peer connection connected");
314
+ } else if (this.peerConnection?.iceConnectionState === "disconnected") {
315
+ console.log("Peer connection disconnected");
316
+ } else if (this.peerConnection?.iceConnectionState === "failed") {
317
+ if (this.eventHandlers.onError) {
318
+ this.eventHandlers.onError("Connection to server failed");
319
+ }
320
+ }
321
+ };
322
+ this.peerConnection.ontrack = (event) => {
323
+ if (!this.audioElement) {
324
+ console.error("Audio element not found");
325
+ return;
326
+ }
327
+ const stream = event.streams[0];
328
+ this.audioElement.srcObject = stream;
329
+ this.audioElement.play().catch((e) => console.error("Play error:", e));
330
+ };
331
+ this.wsConnection = new WebSocket(DEFAULT_SERVER_URL + "?token=" + token);
332
+ this.wsConnection.onmessage = (event) => {
333
+ const message = JSON.parse(event.data);
334
+ if (!this.peerConnection) {
335
+ console.error("WS response, Peer connection not established");
336
+ return;
337
+ }
338
+ if (message.type === "answer") {
339
+ const answer = new RTCSessionDescription(message.payload);
340
+ this.peerConnection.setRemoteDescription(answer);
341
+ } else if (message.type === "ice-candidate") {
342
+ const candidate = new RTCIceCandidate(message.payload);
343
+ this.peerConnection.addIceCandidate(candidate);
344
+ } else {
345
+ this.resolveWsMessage(message);
346
+ }
347
+ };
348
+ this.wsConnection.onopen = async () => {
349
+ if (!this.peerConnection) {
350
+ console.error("Peer connection not established");
351
+ return;
352
+ }
353
+ const offer = await this.peerConnection.createOffer();
354
+ await this.peerConnection.setLocalDescription(offer);
355
+ this.peerConnection.onicecandidate = (event) => {
356
+ if (event.candidate) {
357
+ this.wsConnection?.send(JSON.stringify({
358
+ type: "ice-candidate",
359
+ payload: event.candidate
360
+ }));
361
+ }
362
+ };
363
+ this.wsConnection?.send(JSON.stringify({
364
+ type: "offer",
365
+ payload: this.peerConnection?.localDescription
366
+ }));
367
+ this.wsConnection?.send(JSON.stringify({
368
+ type: "form",
369
+ payload: this.form
370
+ }));
371
+ };
372
+ return true;
373
+ } catch (error) {
374
+ console.error("Error starting conversation:", error);
375
+ this.connected = false;
376
+ this.wsConnection = null;
377
+ this.peerConnection = null;
378
+ this.localStream = null;
379
+ return false;
380
+ }
381
+ }
382
+ /**
383
+ * Stop the conversation, this will cut off audio mid output
384
+ */
385
+ stop() {
386
+ console.log("Stop called");
387
+ if (!this.connected) {
388
+ console.error("Not connected to veform server");
389
+ return;
390
+ }
391
+ this.connected = false;
392
+ this.wsConnection?.close();
393
+ this.wsConnection = null;
394
+ this.peerConnection?.close();
395
+ this.peerConnection = null;
396
+ this.localStream?.getTracks().forEach((track) => {
397
+ track.stop();
398
+ });
399
+ this.localStream = null;
400
+ }
401
+ /**
402
+ * Emit audio to client
403
+ * interrupt: if true, will interrupt the current output, otherwise will be queued behind current audio
404
+ */
405
+ emitAudio(audio, interrupt = false) {
406
+ console.log("Emit audio called", audio);
407
+ }
408
+ /**
409
+ * Change the current field
410
+ * This will change the current field to the one provided
411
+ */
412
+ changeField(fieldName, interrupt = false) {
413
+ console.log("Change field called", fieldName, interrupt);
414
+ }
415
+ /**
416
+ * Stop current audio output
417
+ */
418
+ interrupt() {
419
+ console.log("Interrupt called");
420
+ }
421
+ resolveWsMessage(message) {
422
+ console.log("RAW WS MESSAGE:", message);
423
+ switch (message.type) {
424
+ case "event-start":
425
+ if (this.eventHandlers.onRunningStarted) {
426
+ this.eventHandlers.onRunningStarted();
427
+ }
428
+ return;
429
+ case "event-end":
430
+ if (this.eventHandlers.onFinished) {
431
+ this.eventHandlers.onFinished();
432
+ }
433
+ return;
434
+ case "event-audio-out-start":
435
+ if (this.eventHandlers.onAudioOutStart) {
436
+ const interrupt = this.eventHandlers.onAudioOutStart(message.payload);
437
+ if (interrupt) {
438
+ console.log("Audio out start interrupted");
439
+ }
440
+ }
441
+ return;
442
+ case "event-audio-out-end":
443
+ if (this.eventHandlers.onAudioOutEnd) {
444
+ this.eventHandlers.onAudioOutEnd();
445
+ }
446
+ return;
447
+ case "event-input-start":
448
+ if (this.eventHandlers.onAudioInStart) {
449
+ this.eventHandlers.onAudioInStart();
450
+ }
451
+ return;
452
+ case "event-input-end":
453
+ if (this.eventHandlers.onAudioInEnd) {
454
+ const interrupt = this.eventHandlers.onAudioInEnd(message.payload);
455
+ if (interrupt) {
456
+ console.log("Audio in end interrupted");
457
+ }
458
+ }
459
+ return;
460
+ case "event-focus-changed":
461
+ if (this.eventHandlers.onFocusChanged) {
462
+ const interrupt = this.eventHandlers.onFocusChanged(message.payload.previousName, message.payload.nextName);
463
+ if (interrupt) {
464
+ console.log("Focus changed interrupted");
465
+ return;
466
+ }
467
+ }
468
+ const nextField = this.form.fields.find((field2) => field2.name === message.payload.nextName);
469
+ if (nextField?.eventHandlers?.onFocus) {
470
+ nextField.eventHandlers.onFocus(message.payload.previousName);
471
+ }
472
+ return;
473
+ case "event-field-value-changed":
474
+ console.log("CHECKING FOR FIELD:", message.payload);
475
+ const field = this.form.fields.find((field2) => field2.name === message.payload.fieldName);
476
+ console.log("FIELD:", field);
477
+ if (field?.eventHandlers?.onChange) {
478
+ field.eventHandlers.onChange(message.payload.value);
479
+ }
480
+ if (this.eventHandlers.onFieldValueChanged) {
481
+ this.eventHandlers.onFieldValueChanged(message.payload.fieldName, message.payload.value);
482
+ }
483
+ return;
484
+ case "event-error":
485
+ if (this.eventHandlers.onError) {
486
+ this.eventHandlers.onError(message.payload);
487
+ }
488
+ return;
489
+ case "event-critical-error":
490
+ if (this.eventHandlers.onCriticalError) {
491
+ this.eventHandlers.onCriticalError(message.payload);
492
+ this.stop();
493
+ }
494
+ return;
495
+ default:
496
+ console.error("Unknown event type:", message.type);
497
+ break;
498
+ }
499
+ }
500
+ };
501
+ function createAudioElement() {
502
+ const element = document.createElement("audio");
503
+ element.id = "veform-audio";
504
+ element.style.opacity = "0";
505
+ element.style.position = "absolute";
506
+ element.style.bottom = "0";
507
+ element.style.right = "0";
508
+ element.style.width = "100%";
509
+ element.style.height = "100%";
510
+ document.body.appendChild(element);
511
+ return element;
512
+ }
513
+ return __toCommonJS(dist_exports);
514
+ })();
@@ -0,0 +1 @@
1
+ "use strict";var Veform=(()=>{var C=Object.defineProperty;var y=Object.getOwnPropertyDescriptor;var H=Object.getOwnPropertyNames;var m=Object.prototype.hasOwnProperty;var w=(t,e)=>{for(var n in e)C(t,n,{get:e[n],enumerable:!0})},A=(t,e,n,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of H(e))!m.call(t,i)&&i!==n&&C(t,i,{get:()=>e[i],enumerable:!(o=y(e,i))||o.enumerable});return t};var O=t=>A(C({},"__esModule",{value:!0}),t);var R={};w(R,{Field:()=>s,InfoField:()=>v,MultiselectField:()=>u,NumberField:()=>c,SelectField:()=>h,TextAreaField:()=>p,TextField:()=>d,Veform:()=>E,VeformBuilder:()=>a,YesNoField:()=>f});var r;(function(t){t.TEXT="text",t.TEXTAREA="textarea",t.SELECT="select",t.MULTISELECT="multiselect",t.YESNO="yesNo",t.NUMBER="number",t.INFO="info"})(r||(r={}));var s=class{constructor(e,n,o,i,g={}){this.name=e,this.question=n,this.type=o,this.eventConfig=i,this.eventHandlers=g}addBehavior(e,n){if(this.eventConfig||(this.eventConfig={}),this.eventConfig[e]||(this.eventConfig[e]=[]),n.type===l.MOVE_TO){let o=this.eventConfig[e].findIndex(i=>i.type===l.MOVE_TO);o!==-1?this.eventConfig[e][o]=n:this.eventConfig[e].push(n)}else this.eventConfig[e].push(n)}onFocus(e){this.eventHandlers.onFocus=e}onChange(e){this.eventHandlers.onChange=e}},S;(function(t){t.VALID_ANSWER="validAnswer",t.INVALID_ANSWER="invalidAnswer",t.MOVE_REQUESTED="moveRequested",t.END_REQUESTED="endRequested",t.VALID_YES_ANSWER="validYesAnswer",t.VALID_NO_ANSWER="validNoAnswer"})(S||(S={}));var l;(function(t){t.MOVE_TO="moveTo",t.OUTPUT="output"})(l||(l={}));var d=class extends s{constructor(e,n,o){super(e,n,r.TEXT,o)}addValidation(e){this.textFieldValidation=e}},c=class extends s{constructor(e,n,o){super(e,n,r.NUMBER,o)}addValidation(e){this.numberFieldValidation=e}},h=class extends s{constructor(e,n,o){super(e,n,r.SELECT,o),this.selectFieldValidation={validate:!0,selectOptions:[]}}addSelectOption(e){this.selectFieldValidation?.selectOptions?.push(e)}},u=class extends s{constructor(e,n,o){super(e,n,r.MULTISELECT,o)}addSelectOption(e){this.multiselectFieldValidation?.selectOptions?.push(e)}},f=class extends s{constructor(e,n,o){super(e,n,r.YESNO,o)}addValidation(e){this.yesNoFieldValidation=e}},p=class extends s{constructor(e,n,o){super(e,n,r.TEXTAREA,o)}addValidation(e){this.textAreaFieldValidation=e}},v=class extends s{constructor(e,n,o){super(e,n,r.INFO,o)}},a=class{constructor(){this.fields=[]}addField({name:e,question:n,type:o}){if(this.getField(e))return console.error(`Field with name ${e} already exists`),null;if(e.length===0||n.length===0)return console.error(`Field with name ${e} and question ${n} has invalid name or question`),null;let i;switch(o){case r.TEXT:i=new d(e,n);break;case r.TEXTAREA:i=new p(e,n);break;case r.SELECT:i=new h(e,n);break;case r.YESNO:i=new f(e,n);break;case r.NUMBER:i=new c(e,n);break;case r.INFO:i=new v(e,n);break;case r.MULTISELECT:i=new u(e,n);break;default:return console.error(`Field with name ${e} has invalid type ${o}`),null}return this.fields.push(i),i}getField(e){return this.fields.find(n=>n.name===e)}getFields(){return this.fields}setField(e,n){let o=this.fields.findIndex(i=>i.name===e);return o!==-1?(this.fields[o]=n,!0):!1}removeField(e){let n=this.fields.findIndex(o=>o.name===e);return n!==-1?(this.fields.splice(n,1),!0):!1}};var x="ws://localhost:8080/ws",E=class{constructor(e){this.connected=!1,this.eventHandlers={},this.localStream=null,this.peerConnection=null,this.wsConnection=null,this.audioElement=null,e instanceof a?this.form={fields:e.getFields()}:this.form={fields:e}}onLoadingStarted(e){this.eventHandlers.onLoadingStarted=e}onRunningStarted(e){this.eventHandlers.onRunningStarted=e}onFinished(e){this.eventHandlers.onFinished=e}onError(e){this.eventHandlers.onError=e}onCriticalError(e){this.eventHandlers.onCriticalError=e}onAudioInStart(e){this.eventHandlers.onAudioInStart=e}onAudioInEnd(e){this.eventHandlers.onAudioInEnd=e}onAudioOutStart(e){this.eventHandlers.onAudioOutStart=e}onAudioOutEnd(e){this.eventHandlers.onAudioOutEnd=e}onFieldValueChanged(e){this.eventHandlers.onFieldValueChanged=e}onFocusChanged(e){this.eventHandlers.onFocusChanged=e}async start(e){if(!this.form?.fields||this.form?.fields.length===0)return console.error("No fields provided or token URL"),!1;if(this.connected||this.wsConnection||this.peerConnection||this.localStream)return console.error("Start already called, try running stop() or creating a new instance"),!1;try{return e.startsWith("http")&&(console.log("Fetching token from URL",e),e=await fetch(e,{method:"POST"}).then(n=>n.json()).then(n=>n.token)),e?(this.audioElement=N(),this.eventHandlers.onLoadingStarted&&this.eventHandlers.onLoadingStarted(),this.localStream=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:!0,noiseSuppression:!0,sampleRate:48e3,channelCount:1},video:!1}),this.peerConnection=new RTCPeerConnection,this.localStream.getTracks().forEach(n=>{if(!this.localStream||!this.peerConnection){console.error("Local stream or peer connection not established");return}this.peerConnection?.addTrack(n,this.localStream)}),this.peerConnection.oniceconnectionstatechange=()=>{this.peerConnection?.iceConnectionState==="connected"?(this.connected=!0,console.log("Peer connection connected")):this.peerConnection?.iceConnectionState==="disconnected"?console.log("Peer connection disconnected"):this.peerConnection?.iceConnectionState==="failed"&&this.eventHandlers.onError&&this.eventHandlers.onError("Connection to server failed")},this.peerConnection.ontrack=n=>{if(!this.audioElement){console.error("Audio element not found");return}let o=n.streams[0];this.audioElement.srcObject=o,this.audioElement.play().catch(i=>console.error("Play error:",i))},this.wsConnection=new WebSocket(x+"?token="+e),this.wsConnection.onmessage=n=>{let o=JSON.parse(n.data);if(!this.peerConnection){console.error("WS response, Peer connection not established");return}if(o.type==="answer"){let i=new RTCSessionDescription(o.payload);this.peerConnection.setRemoteDescription(i)}else if(o.type==="ice-candidate"){let i=new RTCIceCandidate(o.payload);this.peerConnection.addIceCandidate(i)}else this.resolveWsMessage(o)},this.wsConnection.onopen=async()=>{if(!this.peerConnection){console.error("Peer connection not established");return}let n=await this.peerConnection.createOffer();await this.peerConnection.setLocalDescription(n),this.peerConnection.onicecandidate=o=>{o.candidate&&this.wsConnection?.send(JSON.stringify({type:"ice-candidate",payload:o.candidate}))},this.wsConnection?.send(JSON.stringify({type:"offer",payload:this.peerConnection?.localDescription})),this.wsConnection?.send(JSON.stringify({type:"form",payload:this.form}))},!0):(console.error("No token provided or returned from token URL"),!1)}catch(n){return console.error("Error starting conversation:",n),this.connected=!1,this.wsConnection=null,this.peerConnection=null,this.localStream=null,!1}}stop(){if(console.log("Stop called"),!this.connected){console.error("Not connected to veform server");return}this.connected=!1,this.wsConnection?.close(),this.wsConnection=null,this.peerConnection?.close(),this.peerConnection=null,this.localStream?.getTracks().forEach(e=>{e.stop()}),this.localStream=null}emitAudio(e,n=!1){console.log("Emit audio called",e)}changeField(e,n=!1){console.log("Change field called",e,n)}interrupt(){console.log("Interrupt called")}resolveWsMessage(e){switch(console.log("RAW WS MESSAGE:",e),e.type){case"event-start":this.eventHandlers.onRunningStarted&&this.eventHandlers.onRunningStarted();return;case"event-end":this.eventHandlers.onFinished&&this.eventHandlers.onFinished();return;case"event-audio-out-start":this.eventHandlers.onAudioOutStart&&this.eventHandlers.onAudioOutStart(e.payload)&&console.log("Audio out start interrupted");return;case"event-audio-out-end":this.eventHandlers.onAudioOutEnd&&this.eventHandlers.onAudioOutEnd();return;case"event-input-start":this.eventHandlers.onAudioInStart&&this.eventHandlers.onAudioInStart();return;case"event-input-end":this.eventHandlers.onAudioInEnd&&this.eventHandlers.onAudioInEnd(e.payload)&&console.log("Audio in end interrupted");return;case"event-focus-changed":if(this.eventHandlers.onFocusChanged&&this.eventHandlers.onFocusChanged(e.payload.previousName,e.payload.nextName)){console.log("Focus changed interrupted");return}let n=this.form.fields.find(i=>i.name===e.payload.nextName);n?.eventHandlers?.onFocus&&n.eventHandlers.onFocus(e.payload.previousName);return;case"event-field-value-changed":console.log("CHECKING FOR FIELD:",e.payload);let o=this.form.fields.find(i=>i.name===e.payload.fieldName);console.log("FIELD:",o),o?.eventHandlers?.onChange&&o.eventHandlers.onChange(e.payload.value),this.eventHandlers.onFieldValueChanged&&this.eventHandlers.onFieldValueChanged(e.payload.fieldName,e.payload.value);return;case"event-error":this.eventHandlers.onError&&this.eventHandlers.onError(e.payload);return;case"event-critical-error":this.eventHandlers.onCriticalError&&(this.eventHandlers.onCriticalError(e.payload),this.stop());return;default:console.error("Unknown event type:",e.type);break}}};function N(){let t=document.createElement("audio");return t.id="veform-audio",t.style.opacity="0",t.style.position="absolute",t.style.bottom="0",t.style.right="0",t.style.width="100%",t.style.height="100%",document.body.appendChild(t),t}return O(R);})();
@@ -0,0 +1,102 @@
1
+ import { Field, VeformBuilder } from './veform-builder';
2
+ type EventHandlers = {
3
+ /**
4
+ * Called immediately after start() is called
5
+ */
6
+ onLoadingStarted?: () => void;
7
+ /**
8
+ * Called when connections established and conversation begins
9
+ * This can be treated as a loading finished event
10
+ */
11
+ onRunningStarted?: () => void;
12
+ /**
13
+ * Called when conversation is complete
14
+ */
15
+ onFinished?: () => void;
16
+ /**
17
+ * Called when an error occurs.
18
+ * Veform will attempt to recover from these errors.
19
+ */
20
+ onError?: (error: string) => void;
21
+ /**
22
+ * Called when a critical error occurs.
23
+ * veform audio will output a generic error and end the conversation when these occur.
24
+ */
25
+ onCriticalError?: (error: string) => void;
26
+ /**
27
+ * Called when user talking is detected
28
+ */
29
+ onAudioInStart?: () => void;
30
+ /**
31
+ * Called when user done talking is detected
32
+ Returning true blocks veform from processing this input and continuing the conversation.
33
+ * If you block this you must call `changeField` or`emitAudio` to keep the conversation going.
34
+ */
35
+ onAudioInEnd?: (input: string) => boolean;
36
+ /**
37
+ * Called before audio output starts.
38
+ * Returning true blocks veform from emitting this output and continuing the conversation.
39
+ * If you block this you must call `changeField` or`emitAudio` to keep the conversation going.
40
+ */
41
+ onAudioOutStart?: (chunk: string) => boolean;
42
+ /**
43
+ * Called when audio output ends
44
+ */
45
+ onAudioOutEnd?: () => void;
46
+ /**
47
+ * Called when the current field focus changes
48
+ * Returning true blocks veform from changing the field and continuing the conversation.
49
+ * If you block this you must call `changeField` or `emitAudio` to keep the conversation going.
50
+ */
51
+ onFocusChanged?: (previousName: string, nextName: string) => boolean;
52
+ /**
53
+ * Called when an answer is provided to a field
54
+ */
55
+ onFieldValueChanged?: (fieldName: string, answer: string | number | boolean) => void;
56
+ };
57
+ export declare class Veform {
58
+ private connected;
59
+ private form;
60
+ private eventHandlers;
61
+ private localStream;
62
+ private peerConnection;
63
+ private wsConnection;
64
+ private audioElement;
65
+ constructor(fields: Field[] | VeformBuilder);
66
+ onLoadingStarted(callback: EventHandlers['onLoadingStarted']): void;
67
+ onRunningStarted(callback: EventHandlers['onRunningStarted']): void;
68
+ onFinished(callback: EventHandlers['onFinished']): void;
69
+ onError(callback: EventHandlers['onError']): void;
70
+ onCriticalError(callback: EventHandlers['onCriticalError']): void;
71
+ onAudioInStart(callback: EventHandlers['onAudioInStart']): void;
72
+ onAudioInEnd(callback: EventHandlers['onAudioInEnd']): void;
73
+ onAudioOutStart(callback: EventHandlers['onAudioOutStart']): void;
74
+ onAudioOutEnd(callback: EventHandlers['onAudioOutEnd']): void;
75
+ onFieldValueChanged(callback: EventHandlers['onFieldValueChanged']): void;
76
+ onFocusChanged(callback: EventHandlers['onFocusChanged']): void;
77
+ /**
78
+ * Start the conversation
79
+ * This will connect the client the the veform server with the current set of fields
80
+ */
81
+ start(token: string): Promise<boolean>;
82
+ /**
83
+ * Stop the conversation, this will cut off audio mid output
84
+ */
85
+ stop(): void;
86
+ /**
87
+ * Emit audio to client
88
+ * interrupt: if true, will interrupt the current output, otherwise will be queued behind current audio
89
+ */
90
+ emitAudio(audio: string, interrupt?: boolean): void;
91
+ /**
92
+ * Change the current field
93
+ * This will change the current field to the one provided
94
+ */
95
+ changeField(fieldName: string, interrupt?: boolean): void;
96
+ /**
97
+ * Stop current audio output
98
+ */
99
+ interrupt(): void;
100
+ private resolveWsMessage;
101
+ }
102
+ export {};
package/dist/veform.js ADDED
@@ -0,0 +1,305 @@
1
+ import { VeformBuilder } from './veform-builder';
2
+ const DEFAULT_SERVER_URL = 'ws://localhost:8080/ws';
3
+ // this is what exposes all the cancelable events to user
4
+ export class Veform {
5
+ constructor(fields) {
6
+ this.connected = false;
7
+ this.eventHandlers = {};
8
+ this.localStream = null;
9
+ this.peerConnection = null;
10
+ this.wsConnection = null;
11
+ this.audioElement = null;
12
+ if (fields instanceof VeformBuilder) {
13
+ this.form = { fields: fields.getFields() };
14
+ }
15
+ else {
16
+ this.form = { fields: fields };
17
+ }
18
+ }
19
+ onLoadingStarted(callback) {
20
+ this.eventHandlers.onLoadingStarted = callback;
21
+ }
22
+ onRunningStarted(callback) {
23
+ this.eventHandlers.onRunningStarted = callback;
24
+ }
25
+ onFinished(callback) {
26
+ this.eventHandlers.onFinished = callback;
27
+ }
28
+ onError(callback) {
29
+ this.eventHandlers.onError = callback;
30
+ }
31
+ onCriticalError(callback) {
32
+ this.eventHandlers.onCriticalError = callback;
33
+ }
34
+ onAudioInStart(callback) {
35
+ this.eventHandlers.onAudioInStart = callback;
36
+ }
37
+ onAudioInEnd(callback) {
38
+ this.eventHandlers.onAudioInEnd = callback;
39
+ }
40
+ onAudioOutStart(callback) {
41
+ this.eventHandlers.onAudioOutStart = callback;
42
+ }
43
+ onAudioOutEnd(callback) {
44
+ this.eventHandlers.onAudioOutEnd = callback;
45
+ }
46
+ onFieldValueChanged(callback) {
47
+ this.eventHandlers.onFieldValueChanged = callback;
48
+ }
49
+ onFocusChanged(callback) {
50
+ this.eventHandlers.onFocusChanged = callback;
51
+ }
52
+ /**
53
+ * Start the conversation
54
+ * This will connect the client the the veform server with the current set of fields
55
+ */
56
+ async start(token) {
57
+ if (!this.form?.fields || this.form?.fields.length === 0) {
58
+ console.error('No fields provided or token URL');
59
+ return false;
60
+ }
61
+ if (this.connected || this.wsConnection || this.peerConnection || this.localStream) {
62
+ console.error('Start already called, try running stop() or creating a new instance');
63
+ return false;
64
+ }
65
+ try {
66
+ if (token.startsWith('http')) {
67
+ console.log('Fetching token from URL', token);
68
+ token = await fetch(token, { method: 'POST' }).then(response => response.json()).then(data => data.token);
69
+ }
70
+ if (!token) {
71
+ console.error('No token provided or returned from token URL');
72
+ return false;
73
+ }
74
+ this.audioElement = createAudioElement();
75
+ if (this.eventHandlers.onLoadingStarted) {
76
+ this.eventHandlers.onLoadingStarted();
77
+ }
78
+ // get local audio track
79
+ this.localStream = await navigator.mediaDevices.getUserMedia({
80
+ audio: {
81
+ echoCancellation: true,
82
+ noiseSuppression: true,
83
+ sampleRate: 48000,
84
+ channelCount: 1,
85
+ },
86
+ video: false,
87
+ });
88
+ // setup local peerconnection
89
+ this.peerConnection = new RTCPeerConnection();
90
+ this.localStream.getTracks().forEach((track) => {
91
+ if (!this.localStream || !this.peerConnection) {
92
+ console.error('Local stream or peer connection not established');
93
+ return;
94
+ }
95
+ this.peerConnection?.addTrack(track, this.localStream);
96
+ });
97
+ this.peerConnection.oniceconnectionstatechange = () => {
98
+ if (this.peerConnection?.iceConnectionState === 'connected') {
99
+ this.connected = true;
100
+ console.log('Peer connection connected');
101
+ }
102
+ else if (this.peerConnection?.iceConnectionState === 'disconnected') {
103
+ console.log('Peer connection disconnected');
104
+ }
105
+ else if (this.peerConnection?.iceConnectionState === 'failed') {
106
+ if (this.eventHandlers.onError) {
107
+ this.eventHandlers.onError('Connection to server failed');
108
+ }
109
+ }
110
+ };
111
+ this.peerConnection.ontrack = (event) => {
112
+ if (!this.audioElement) {
113
+ console.error('Audio element not found');
114
+ return;
115
+ }
116
+ const stream = event.streams[0];
117
+ this.audioElement.srcObject = stream;
118
+ this.audioElement.play().catch((e) => console.error("Play error:", e));
119
+ };
120
+ // create websocket connection
121
+ this.wsConnection = new WebSocket(DEFAULT_SERVER_URL + '?token=' + token);
122
+ this.wsConnection.onmessage = (event) => {
123
+ const message = JSON.parse(event.data);
124
+ if (!this.peerConnection) {
125
+ console.error('WS response, Peer connection not established');
126
+ return;
127
+ }
128
+ if (message.type === "answer") {
129
+ const answer = new RTCSessionDescription(message.payload);
130
+ this.peerConnection.setRemoteDescription(answer);
131
+ }
132
+ else if (message.type === "ice-candidate") {
133
+ const candidate = new RTCIceCandidate(message.payload);
134
+ this.peerConnection.addIceCandidate(candidate);
135
+ }
136
+ else {
137
+ this.resolveWsMessage(message);
138
+ }
139
+ };
140
+ this.wsConnection.onopen = async () => {
141
+ if (!this.peerConnection) {
142
+ console.error('Peer connection not established');
143
+ return;
144
+ }
145
+ const offer = await this.peerConnection.createOffer();
146
+ await this.peerConnection.setLocalDescription(offer);
147
+ this.peerConnection.onicecandidate = (event) => {
148
+ if (event.candidate) {
149
+ this.wsConnection?.send(JSON.stringify({
150
+ type: "ice-candidate",
151
+ payload: event.candidate,
152
+ }));
153
+ }
154
+ };
155
+ this.wsConnection?.send(JSON.stringify({
156
+ type: "offer",
157
+ payload: this.peerConnection?.localDescription,
158
+ }));
159
+ this.wsConnection?.send(JSON.stringify({
160
+ type: "form",
161
+ payload: this.form,
162
+ }));
163
+ };
164
+ return true;
165
+ }
166
+ catch (error) {
167
+ console.error('Error starting conversation:', error);
168
+ this.connected = false;
169
+ this.wsConnection = null;
170
+ this.peerConnection = null;
171
+ this.localStream = null;
172
+ return false;
173
+ }
174
+ }
175
+ /**
176
+ * Stop the conversation, this will cut off audio mid output
177
+ */
178
+ stop() {
179
+ console.log('Stop called');
180
+ if (!this.connected) {
181
+ console.error('Not connected to veform server');
182
+ return;
183
+ }
184
+ this.connected = false;
185
+ this.wsConnection?.close();
186
+ this.wsConnection = null;
187
+ this.peerConnection?.close();
188
+ this.peerConnection = null;
189
+ this.localStream?.getTracks().forEach((track) => {
190
+ track.stop();
191
+ });
192
+ this.localStream = null;
193
+ }
194
+ /**
195
+ * Emit audio to client
196
+ * interrupt: if true, will interrupt the current output, otherwise will be queued behind current audio
197
+ */
198
+ emitAudio(audio, interrupt = false) {
199
+ console.log('Emit audio called', audio);
200
+ }
201
+ /**
202
+ * Change the current field
203
+ * This will change the current field to the one provided
204
+ */
205
+ changeField(fieldName, interrupt = false) {
206
+ console.log('Change field called', fieldName, interrupt);
207
+ }
208
+ /**
209
+ * Stop current audio output
210
+ */
211
+ interrupt() {
212
+ console.log('Interrupt called');
213
+ }
214
+ resolveWsMessage(message) {
215
+ console.log('RAW WS MESSAGE:', message);
216
+ switch (message.type) {
217
+ case "event-start":
218
+ if (this.eventHandlers.onRunningStarted) {
219
+ this.eventHandlers.onRunningStarted();
220
+ }
221
+ return;
222
+ case "event-end":
223
+ if (this.eventHandlers.onFinished) {
224
+ this.eventHandlers.onFinished();
225
+ }
226
+ return;
227
+ case "event-audio-out-start":
228
+ if (this.eventHandlers.onAudioOutStart) {
229
+ const interrupt = this.eventHandlers.onAudioOutStart(message.payload);
230
+ if (interrupt) {
231
+ console.log('Audio out start interrupted');
232
+ }
233
+ }
234
+ return;
235
+ case "event-audio-out-end":
236
+ if (this.eventHandlers.onAudioOutEnd) {
237
+ this.eventHandlers.onAudioOutEnd();
238
+ }
239
+ return;
240
+ case "event-input-start":
241
+ if (this.eventHandlers.onAudioInStart) {
242
+ this.eventHandlers.onAudioInStart();
243
+ }
244
+ return;
245
+ case "event-input-end":
246
+ if (this.eventHandlers.onAudioInEnd) {
247
+ const interrupt = this.eventHandlers.onAudioInEnd(message.payload);
248
+ if (interrupt) {
249
+ console.log('Audio in end interrupted');
250
+ }
251
+ }
252
+ return;
253
+ case "event-focus-changed":
254
+ if (this.eventHandlers.onFocusChanged) {
255
+ const interrupt = this.eventHandlers.onFocusChanged(message.payload.previousName, message.payload.nextName);
256
+ if (interrupt) {
257
+ console.log('Focus changed interrupted');
258
+ return;
259
+ }
260
+ }
261
+ const nextField = this.form.fields.find((field) => field.name === message.payload.nextName);
262
+ if (nextField?.eventHandlers?.onFocus) {
263
+ nextField.eventHandlers.onFocus(message.payload.previousName);
264
+ }
265
+ return;
266
+ case "event-field-value-changed":
267
+ console.log('CHECKING FOR FIELD:', message.payload);
268
+ const field = this.form.fields.find((field) => field.name === message.payload.fieldName);
269
+ console.log('FIELD:', field);
270
+ if (field?.eventHandlers?.onChange) {
271
+ field.eventHandlers.onChange(message.payload.value);
272
+ }
273
+ if (this.eventHandlers.onFieldValueChanged) {
274
+ this.eventHandlers.onFieldValueChanged(message.payload.fieldName, message.payload.value);
275
+ }
276
+ return;
277
+ case "event-error":
278
+ if (this.eventHandlers.onError) {
279
+ this.eventHandlers.onError(message.payload);
280
+ }
281
+ return;
282
+ case "event-critical-error":
283
+ if (this.eventHandlers.onCriticalError) {
284
+ this.eventHandlers.onCriticalError(message.payload);
285
+ this.stop();
286
+ }
287
+ return;
288
+ default:
289
+ console.error('Unknown event type:', message.type);
290
+ break;
291
+ }
292
+ }
293
+ }
294
+ function createAudioElement() {
295
+ const element = document.createElement('audio');
296
+ element.id = 'veform-audio';
297
+ element.style.opacity = '0';
298
+ element.style.position = 'absolute';
299
+ element.style.bottom = '0';
300
+ element.style.right = '0';
301
+ element.style.width = '100%';
302
+ element.style.height = '100%';
303
+ document.body.appendChild(element);
304
+ return element;
305
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "veform-js",
3
+ "version": "0.1.0",
4
+ "description": "Veform JavaScript/TypeScript library",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "type": "module",
9
+ "scripts": {
10
+ "build": "tsc && node build.js",
11
+ "prepare": "yarn build",
12
+ "watch": "tsc --watch"
13
+ },
14
+ "keywords": [
15
+ "veform"
16
+ ],
17
+ "author": "",
18
+ "license": "ISC",
19
+ "devDependencies": {
20
+ "typescript": "^5.3.3",
21
+ "esbuild": "^0.19.0"
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ]
26
+ }