@oshara/voice-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +198 -0
  2. package/dist/appearance-CNWT8x1G.cjs +2 -0
  3. package/dist/appearance-CNWT8x1G.cjs.map +1 -0
  4. package/dist/appearance-i6QBkpCk.js +650 -0
  5. package/dist/appearance-i6QBkpCk.js.map +1 -0
  6. package/dist/consent-CK9VXNPa.js +54 -0
  7. package/dist/consent-CK9VXNPa.js.map +1 -0
  8. package/dist/consent-D7QNSkQD.cjs +2 -0
  9. package/dist/consent-D7QNSkQD.cjs.map +1 -0
  10. package/dist/core/analytics.d.ts +30 -0
  11. package/dist/core/appearance.d.ts +113 -0
  12. package/dist/core/audioSettings.d.ts +69 -0
  13. package/dist/core/consent.d.ts +17 -0
  14. package/dist/core/createVoiceAgent.d.ts +79 -0
  15. package/dist/core/events.d.ts +103 -0
  16. package/dist/core/formController.d.ts +28 -0
  17. package/dist/core/forms.d.ts +235 -0
  18. package/dist/core/index.d.ts +29 -0
  19. package/dist/core/prevContext.d.ts +26 -0
  20. package/dist/core/transport.d.ts +30 -0
  21. package/dist/core/types.d.ts +49 -0
  22. package/dist/core/voice.d.ts +79 -0
  23. package/dist/createVoiceAgent-BM3HODS6.js +1058 -0
  24. package/dist/createVoiceAgent-BM3HODS6.js.map +1 -0
  25. package/dist/createVoiceAgent-CJWxWzz6.cjs +4 -0
  26. package/dist/createVoiceAgent-CJWxWzz6.cjs.map +1 -0
  27. package/dist/index.cjs +2 -0
  28. package/dist/index.cjs.map +1 -0
  29. package/dist/index.js +44 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/react/index.d.ts +60 -0
  32. package/dist/react.cjs +2 -0
  33. package/dist/react.cjs.map +1 -0
  34. package/dist/react.js +115 -0
  35. package/dist/react.js.map +1 -0
  36. package/dist/styles.css +1838 -0
  37. package/dist/ui/index.d.ts +21 -0
  38. package/dist/ui/ui.d.ts +165 -0
  39. package/dist/ui.cjs +284 -0
  40. package/dist/ui.cjs.map +1 -0
  41. package/dist/ui.js +1153 -0
  42. package/dist/ui.js.map +1 -0
  43. package/package.json +67 -0
  44. package/src/core/analytics.ts +111 -0
  45. package/src/core/appearance.ts +464 -0
  46. package/src/core/audioSettings.ts +180 -0
  47. package/src/core/consent.ts +78 -0
  48. package/src/core/createVoiceAgent.ts +280 -0
  49. package/src/core/events.ts +120 -0
  50. package/src/core/formController.ts +317 -0
  51. package/src/core/forms.ts +861 -0
  52. package/src/core/index.ts +121 -0
  53. package/src/core/prevContext.ts +153 -0
  54. package/src/core/transport.ts +118 -0
  55. package/src/core/types.ts +66 -0
  56. package/src/core/voice.ts +1179 -0
  57. package/src/react/index.ts +238 -0
  58. package/src/ui/index.ts +507 -0
  59. package/src/ui/styles.css +1838 -0
  60. package/src/ui/ui.ts +1672 -0
  61. package/src/vite-env.d.ts +10 -0
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Headless form controller.
3
+ *
4
+ * Lifted from the widget's `createFormController`, with every DOM touch
5
+ * (`renderForm`, `readFormValues`, `setFieldErrors`, `setScreen`) replaced by
6
+ * typed events. The controller now owns the form `values` model; consumers
7
+ * (custom or prebuilt UI) push on-screen edits in via `updateValues()` — this
8
+ * replaces the old `readFormValues(refs)` reads.
9
+ *
10
+ * The agent round-trip (form.state publishing, form_submit_failed and
11
+ * confirmation messages) is preserved exactly.
12
+ */
13
+
14
+ import type { Emit } from "./events";
15
+ import {
16
+ FormDefinition,
17
+ FormStateSnapshot,
18
+ buildFieldSchema,
19
+ buildSubmissionText,
20
+ collectInputFields,
21
+ fieldsForStep,
22
+ initialFormValues,
23
+ mergeFormDraft,
24
+ submitForm,
25
+ totalSteps,
26
+ validateFields,
27
+ } from "./forms";
28
+ import type { VoiceController } from "./voice";
29
+
30
+ export interface FormControllerOptions {
31
+ emit: Emit;
32
+ voice: VoiceController;
33
+ apiUrl: string;
34
+ agentSlug: string;
35
+ apiKey?: string;
36
+ fetch?: typeof fetch;
37
+ }
38
+
39
+ export interface FormController {
40
+ open: (definition: FormDefinition, draft?: Record<string, string>) => void;
41
+ merge: (draft: Record<string, string>) => void;
42
+ close: () => void;
43
+ current: () => string | null;
44
+ step: (direction: "next" | "back" | number) => void;
45
+ submit: () => void;
46
+ /** Push on-screen edits into the values model (replaces readFormValues). */
47
+ updateValues: (values: Record<string, string>) => void;
48
+ /** Snapshot of the active form, or null. */
49
+ getActive: () => {
50
+ definition: FormDefinition;
51
+ values: Record<string, string>;
52
+ stepIndex: number;
53
+ } | null;
54
+ }
55
+
56
+ /** Index of the first stepper step containing one of the named (invalid)
57
+ * fields, or -1 if none match (or the form isn't a stepper). */
58
+ function firstStepWithError(
59
+ definition: FormDefinition,
60
+ errorNames: Set<string>,
61
+ ): number {
62
+ const steps = definition.steps;
63
+ if (!steps?.length) return -1;
64
+ for (let i = 0; i < steps.length; i++) {
65
+ const hit = steps[i].fields.some((f) => f.name && errorNames.has(f.name));
66
+ if (hit) return i;
67
+ }
68
+ return -1;
69
+ }
70
+
71
+ export function createFormController(
72
+ opts: FormControllerOptions,
73
+ ): FormController {
74
+ const { emit, voice, apiUrl, agentSlug, apiKey, fetch: fetchImpl } = opts;
75
+ let active: FormDefinition | null = null;
76
+ let values: Record<string, string> = {};
77
+ let stepIndex = 0;
78
+ let stateTimer: ReturnType<typeof setTimeout> | null = null;
79
+
80
+ const buildSnapshot = (isOpen: boolean): FormStateSnapshot | null => {
81
+ if (!active) return null;
82
+ const definition = active;
83
+ return {
84
+ type: "form_state",
85
+ form_id: definition.id,
86
+ is_open: isOpen,
87
+ step_index: stepIndex,
88
+ total_steps: totalSteps(definition),
89
+ values: { ...values },
90
+ // Ship the field schema (incl. select/radio/checkbox options) so the
91
+ // agent can register enum-constrained voice-fill tools even when the
92
+ // session token has no appearance.forms entry for this form.
93
+ fields: buildFieldSchema(definition),
94
+ };
95
+ };
96
+
97
+ const flushState = (isOpen: boolean) => {
98
+ if (stateTimer !== null) {
99
+ clearTimeout(stateTimer);
100
+ stateTimer = null;
101
+ }
102
+ if (!voice.isActive()) return;
103
+ const snapshot = isOpen
104
+ ? buildSnapshot(true)
105
+ : ({
106
+ type: "form_state",
107
+ form_id: "",
108
+ is_open: false,
109
+ step_index: 0,
110
+ total_steps: 0,
111
+ values: {},
112
+ fields: [],
113
+ } satisfies FormStateSnapshot);
114
+ if (!snapshot) return;
115
+ void voice.publishData(snapshot, "form.state");
116
+ };
117
+
118
+ const scheduleStatePublish = () => {
119
+ if (stateTimer !== null) clearTimeout(stateTimer);
120
+ stateTimer = setTimeout(() => {
121
+ stateTimer = null;
122
+ flushState(active !== null);
123
+ }, 250);
124
+ };
125
+
126
+ const emitUpdate = () => {
127
+ emit("form:update", { values: { ...values }, stepIndex });
128
+ };
129
+
130
+ const close = () => {
131
+ if (stateTimer !== null) {
132
+ clearTimeout(stateTimer);
133
+ stateTimer = null;
134
+ }
135
+ const wasOpen = active !== null;
136
+ active = null;
137
+ values = {};
138
+ stepIndex = 0;
139
+ emit("form:close", {});
140
+ if (wasOpen) flushState(false);
141
+ };
142
+
143
+ const open = (definition: FormDefinition, draft?: Record<string, string>) => {
144
+ active = definition;
145
+ stepIndex = 0;
146
+ values = mergeFormDraft(initialFormValues(definition), draft ?? null);
147
+ emit("form:show", {
148
+ definition,
149
+ draft: { ...values },
150
+ stepIndex,
151
+ inCall: voice.isActive(),
152
+ transcriptionEnabled:
153
+ voice.isActive() && voice.getAudioState().prefs.transcriptionEnabled,
154
+ });
155
+ flushState(true);
156
+ };
157
+
158
+ const merge = (draft: Record<string, string>) => {
159
+ if (!active) return;
160
+ // `values` is kept current by updateValues() on every keystroke, so the
161
+ // agent's draft overlays only the fields it set without wiping manual edits.
162
+ values = mergeFormDraft(values, draft);
163
+ emitUpdate();
164
+ flushState(true);
165
+ };
166
+
167
+ const updateValues = (next: Record<string, string>) => {
168
+ if (!active) return;
169
+ Object.assign(values, next);
170
+ scheduleStatePublish();
171
+ };
172
+
173
+ const goToStep = (target: number) => {
174
+ if (!active) return;
175
+ const steps = totalSteps(active);
176
+ const clamped = Math.max(0, Math.min(target, steps - 1));
177
+ if (clamped === stepIndex) return;
178
+ stepIndex = clamped;
179
+ emitUpdate();
180
+ flushState(true);
181
+ };
182
+
183
+ const stepBack = () => {
184
+ if (!active || stepIndex === 0) return;
185
+ goToStep(stepIndex - 1);
186
+ };
187
+
188
+ const attemptSubmit = async () => {
189
+ if (!active) return;
190
+ const definition = active;
191
+ const steps = totalSteps(definition);
192
+ const isFinalStep = stepIndex >= steps - 1;
193
+
194
+ // While navigating, validate just the current step. On the final step
195
+ // (actual submit) validate every required field across all steps so a
196
+ // skipped or agent-jumped step can't slip an empty required field past.
197
+ const fieldsToCheck = isFinalStep
198
+ ? collectInputFields(definition)
199
+ : fieldsForStep(definition, stepIndex);
200
+ const errors = validateFields(fieldsToCheck, values);
201
+ if (errors.length) {
202
+ // If an offending field lives on an earlier step, jump there so the
203
+ // visitor can actually see and fix it.
204
+ if (isFinalStep && totalSteps(definition) > 1) {
205
+ const targetStep = firstStepWithError(
206
+ definition,
207
+ new Set(errors.map((e) => e.name)),
208
+ );
209
+ if (targetStep >= 0 && targetStep !== stepIndex) {
210
+ stepIndex = targetStep;
211
+ emitUpdate();
212
+ }
213
+ }
214
+ emit("form:validation", { errors });
215
+ // Tell the agent the submit was rejected so it doesn't claim success.
216
+ if (voice.isActive()) {
217
+ void voice.publishData(
218
+ {
219
+ type: "form_submit_failed",
220
+ form_id: definition.id,
221
+ text:
222
+ `(System: the "${definition.title}" form was NOT submitted — ` +
223
+ `these fields need attention: ` +
224
+ `${errors.map((e) => `${e.label} (${e.message})`).join(", ")}. ` +
225
+ `Ask the visitor to correct them and do not say it was submitted.)`,
226
+ },
227
+ "voice.user_text",
228
+ );
229
+ }
230
+ return;
231
+ }
232
+
233
+ if (!isFinalStep) {
234
+ stepIndex += 1;
235
+ emitUpdate();
236
+ flushState(true);
237
+ return;
238
+ }
239
+
240
+ // Final step — submit.
241
+ emit("form:submitting", {});
242
+ try {
243
+ await submitForm({
244
+ definition,
245
+ values,
246
+ apiUrl,
247
+ slug: agentSlug,
248
+ sessionId: voice.sessionId(),
249
+ apiKey,
250
+ fetch: fetchImpl,
251
+ });
252
+ const successMessage =
253
+ definition.success_message ?? "Submitted. We'll be in touch shortly.";
254
+ emit("form:submitted", {
255
+ formId: definition.id,
256
+ values: { ...values },
257
+ successMessage,
258
+ });
259
+
260
+ if (voice.isActive()) {
261
+ void voice.publishData(
262
+ {
263
+ type: definition.confirmation_type ?? `${definition.id}_submitted`,
264
+ form_id: definition.id,
265
+ text: buildSubmissionText(definition, values),
266
+ form: { ...values },
267
+ },
268
+ definition.confirmation_topic ?? "voice.user_text",
269
+ );
270
+ }
271
+
272
+ // Let the success message breathe, then close the form so the user is
273
+ // dropped back on the call (or welcome if the call has ended).
274
+ setTimeout(() => {
275
+ if (active === definition) close();
276
+ }, 1500);
277
+ } catch (err) {
278
+ const message =
279
+ err instanceof Error
280
+ ? err.message
281
+ : "We couldn't send your request right now. Please try again.";
282
+ emit("form:error", { message });
283
+ if (voice.isActive()) {
284
+ void voice.publishData(
285
+ {
286
+ type: "form_submit_failed",
287
+ form_id: definition.id,
288
+ text:
289
+ `(System: the "${definition.title}" form submission failed: ` +
290
+ `${message} Tell the visitor it didn't go through and offer ` +
291
+ `to try again.)`,
292
+ },
293
+ "voice.user_text",
294
+ );
295
+ }
296
+ }
297
+ };
298
+
299
+ return {
300
+ open,
301
+ merge,
302
+ close,
303
+ updateValues,
304
+ current: () => active?.id ?? null,
305
+ getActive: () =>
306
+ active ? { definition: active, values: { ...values }, stepIndex } : null,
307
+ step: (direction) => {
308
+ if (!active) return;
309
+ if (direction === "next") goToStep(stepIndex + 1);
310
+ else if (direction === "back") stepBack();
311
+ else if (typeof direction === "number") goToStep(direction);
312
+ },
313
+ submit: () => {
314
+ void attemptSubmit();
315
+ },
316
+ };
317
+ }