@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,861 @@
1
+ /**
2
+ * Agent-driven form support for the chat widget.
3
+ *
4
+ * The voice agent can prompt the visitor to fill a structured form (book a
5
+ * demo, table reservation, lead capture, etc.) by publishing a LiveKit data
6
+ * message. The widget listens on `RoomEvent.DataReceived`, matches the message
7
+ * against a registered form definition, prefills any draft fields, and opens
8
+ * a review screen so the user can confirm and submit.
9
+ *
10
+ * Form definitions are generic — `book-demo` ships as the default, but
11
+ * additional forms (reservation, support ticket, lead capture, etc.) can be
12
+ * added either by extending DEFAULT_FORM_DEFINITIONS below or by sending them
13
+ * from the backend through the appearance config (`appearance.forms`).
14
+ *
15
+ * ---------------------------------------------------------------------------
16
+ * Agent → widget protocol (LiveKit data message payload, UTF-8 JSON):
17
+ *
18
+ * 1. Topic-based match (preferred):
19
+ * topic: "form.book-demo" | "book-demo.form" | "book-demo.review"
20
+ * payload: { ...draft fields }
21
+ *
22
+ * 2. Form-id field:
23
+ * payload: { form_id: "reservation", ...draft fields }
24
+ *
25
+ * 3. Event-type field (back-compat with the help-desk-call payload shape):
26
+ * payload: { type: "book_demo_form", form: { ...draft fields } }
27
+ *
28
+ * Draft fields can live at the payload root or nested under `form`/`payload`/
29
+ * `data`/`fields`/`values` — extractFormDraft() looks in all of them.
30
+ */
31
+
32
+ export type FormFieldType =
33
+ | "text"
34
+ | "email"
35
+ | "tel"
36
+ | "textarea"
37
+ | "select"
38
+ | "number"
39
+ | "date"
40
+ | "time"
41
+ | "checkbox"
42
+ | "radio"
43
+ | "display";
44
+
45
+ /** Option for select/radio/checkbox-group. A plain string is shorthand for { value, label }. */
46
+ export type FormFieldOption = string | { value: string; label?: string };
47
+
48
+ export interface FormFieldDef {
49
+ /** Field key — sent in the POST body and matched against incoming drafts.
50
+ * Not required for `display` blocks (which render no input). */
51
+ name?: string;
52
+ label: string;
53
+ type: FormFieldType;
54
+ placeholder?: string;
55
+ required?: boolean;
56
+ /** Options for select/radio/checkbox-group. */
57
+ options?: FormFieldOption[];
58
+ /** Row count for `type: "textarea"`. */
59
+ rows?: number;
60
+ /** Optional default applied when the form is opened with no draft value. */
61
+ default_value?: string;
62
+ /** Helper text rendered beneath the input (or as the body of a `display` block). */
63
+ help_text?: string;
64
+ /** Optional regex (string form) to validate text-like fields on submit. */
65
+ pattern?: string;
66
+ /** Min/max for `type: "number"` (or min/max length for text — not enforced in v1). */
67
+ min?: number;
68
+ max?: number;
69
+ /** Layout width when the form's `field_layout: "grid"`. "full" (default) or "half". */
70
+ width?: "full" | "half";
71
+ }
72
+
73
+ export interface FormStep {
74
+ id: string;
75
+ title?: string;
76
+ subtitle?: string;
77
+ fields: FormFieldDef[];
78
+ next_label?: string;
79
+ back_label?: string;
80
+ }
81
+
82
+ export interface FormLayout {
83
+ field_layout?: "stack" | "grid";
84
+ density?: "comfortable" | "compact";
85
+ label_position?: "top" | "inline";
86
+ }
87
+
88
+ export interface FormDefinition {
89
+ /** Stable identifier — used to match topics, event types, and form_id. */
90
+ id: string;
91
+ title: string;
92
+ subtitle?: string;
93
+ /** Single-page fields. Ignored when `steps` is set. */
94
+ fields: FormFieldDef[];
95
+ /** When set, the form renders as a stepper wizard. */
96
+ steps?: FormStep[];
97
+ /** Form-level layout knobs. */
98
+ layout?: FormLayout;
99
+ /** When true, the auto-derived agent tool is skipped (form is hidden from the LLM). */
100
+ disabled?: boolean;
101
+ /**
102
+ * Where to POST the form on submit. Absolute URL (starts with http) is
103
+ * used as-is; otherwise treated as a path joined onto BootConfig.apiUrl.
104
+ * When null, the platform stores the response in managed storage.
105
+ */
106
+ submit_url: string | null;
107
+ submit_method?: "POST" | "PUT" | "PATCH";
108
+ submit_label?: string;
109
+ success_message?: string;
110
+ /** Extra LiveKit topics to match against (in addition to the id-based ones). */
111
+ topics?: string[];
112
+ /** Extra event-type aliases to match (e.g. legacy "book_demo_form"). */
113
+ event_types?: string[];
114
+ /**
115
+ * LiveKit topic used when echoing the confirmation back to the agent.
116
+ * Defaults to "voice.user_text".
117
+ */
118
+ confirmation_topic?: string;
119
+ /**
120
+ * `type` field on the confirmation payload sent back to the agent.
121
+ * Defaults to `<id>_submitted`.
122
+ */
123
+ confirmation_type?: string;
124
+ }
125
+
126
+ export interface FormSession {
127
+ definition: FormDefinition;
128
+ values: Record<string, string>;
129
+ }
130
+
131
+ /**
132
+ * Minimal surface a form controller must expose so the agent can drive it
133
+ * via data-channel messages (step / submit / close). The widget's
134
+ * `createFormController` returns an object that satisfies this shape; we
135
+ * keep the type narrow so the dispatcher below can't accidentally reach
136
+ * into internals.
137
+ */
138
+ export interface FormActionTarget {
139
+ current: () => string | null;
140
+ step: (direction: "next" | "back" | number) => void;
141
+ submit: () => void;
142
+ close: () => void;
143
+ }
144
+
145
+ /**
146
+ * Snapshot of the open form that the widget publishes back to the agent on
147
+ * every meaningful change (field edit, step move, open, close). Lives on
148
+ * the `form.state` LiveKit topic.
149
+ */
150
+ /** Compact field descriptor sent to the agent so it can build accurate,
151
+ * enum-constrained voice-fill tools for forms that aren't defined in the
152
+ * session token (appearance.forms). Mirrors the subset of FormFieldDef the
153
+ * agent's _field_to_json_schema needs. */
154
+ export interface FormFieldSchema {
155
+ name: string;
156
+ label: string;
157
+ type: FormFieldType;
158
+ required: boolean;
159
+ /** Zero-based step this field lives on (0 for single-page forms). Lets the
160
+ * agent guide the visitor through a stepper one step at a time. */
161
+ step: number;
162
+ options?: { value: string; label: string }[];
163
+ /** Validation rules mirrored so the agent can reject malformed input in its
164
+ * submit guard — before claiming success — exactly like the widget does. */
165
+ pattern?: string;
166
+ min?: number;
167
+ max?: number;
168
+ }
169
+
170
+ export interface FormStateSnapshot {
171
+ type: "form_state";
172
+ form_id: string;
173
+ is_open: boolean;
174
+ step_index: number;
175
+ total_steps: number;
176
+ values: Record<string, string>;
177
+ /** Schema of the form's input fields, so the agent can register
178
+ * enum-aware render tools even without appearance.forms in the token. */
179
+ fields: FormFieldSchema[];
180
+ }
181
+
182
+ /** Build the compact field schema the agent needs to construct voice-fill
183
+ * tools (enum values for select/radio/checkbox, types for the rest) and to
184
+ * guide the visitor through a multi-step form one step at a time. */
185
+ export function buildFieldSchema(
186
+ definition: FormDefinition,
187
+ ): FormFieldSchema[] {
188
+ const toSchema = (f: FormFieldDef, step: number): FormFieldSchema => {
189
+ const entry: FormFieldSchema = {
190
+ name: f.name!,
191
+ label: f.label,
192
+ type: f.type,
193
+ required: Boolean(f.required),
194
+ step,
195
+ };
196
+ if (f.options?.length) {
197
+ entry.options = f.options.map((opt) =>
198
+ typeof opt === "string"
199
+ ? { value: opt, label: opt }
200
+ : { value: opt.value, label: opt.label ?? opt.value },
201
+ );
202
+ }
203
+ if (f.pattern) entry.pattern = f.pattern;
204
+ if (f.min !== undefined) entry.min = f.min;
205
+ if (f.max !== undefined) entry.max = f.max;
206
+ return entry;
207
+ };
208
+
209
+ if (definition.steps?.length) {
210
+ const out: FormFieldSchema[] = [];
211
+ definition.steps.forEach((s, idx) => {
212
+ for (const f of s.fields) {
213
+ if (f.type !== "display" && f.name) out.push(toSchema(f, idx));
214
+ }
215
+ });
216
+ return out;
217
+ }
218
+ return collectInputFields(definition).map((f) => toSchema(f, 0));
219
+ }
220
+
221
+ /**
222
+ * Topic pattern the agent uses to drive form actions: `form.{id}.action`.
223
+ * Payload carries `{type: "form_step"|"form_submit"|"form_close", form_id,
224
+ * direction?, step_index?}`.
225
+ *
226
+ * Returns true if the message was a form-action and was dispatched (so the
227
+ * caller can short-circuit further matching), false otherwise.
228
+ */
229
+ export function handleFormAction(
230
+ topic: string | undefined,
231
+ value: unknown,
232
+ target: FormActionTarget,
233
+ ): boolean {
234
+ if (!topic) return false;
235
+ const normalized = topic.trim().toLowerCase();
236
+ // Expect "form.<id>.action".
237
+ if (!normalized.startsWith("form.") || !normalized.endsWith(".action")) {
238
+ return false;
239
+ }
240
+ const formId = normalized.slice("form.".length, -".action".length);
241
+ if (!formId) return false;
242
+ if (!value || typeof value !== "object") return false;
243
+ const payload = value as Record<string, unknown>;
244
+
245
+ // Guard: action must target the currently-open form. We don't want a
246
+ // stale event from a prior form to drive whatever the user opened next.
247
+ const openId = target.current();
248
+ if (!openId || openId.trim().toLowerCase() !== formId) return false;
249
+
250
+ // Match by payload.form_id when present (belt + suspenders).
251
+ const payloadFormId = stringField(payload.form_id);
252
+ if (payloadFormId && payloadFormId.trim().toLowerCase() !== formId) {
253
+ return false;
254
+ }
255
+
256
+ const eventType = (
257
+ stringField(payload.type) ?? stringField(payload.event) ?? ""
258
+ )
259
+ .trim()
260
+ .toLowerCase();
261
+
262
+ if (eventType === "form_step") {
263
+ const direction = stringField(payload.direction)?.trim().toLowerCase();
264
+ if (direction === "next" || direction === "back") {
265
+ target.step(direction);
266
+ return true;
267
+ }
268
+ const stepIndex = payload.step_index;
269
+ if (typeof stepIndex === "number" && Number.isFinite(stepIndex)) {
270
+ target.step(stepIndex);
271
+ return true;
272
+ }
273
+ // Malformed step event — swallow so it doesn't fall through to the
274
+ // generic form matcher and accidentally re-render.
275
+ return true;
276
+ }
277
+
278
+ if (eventType === "form_submit") {
279
+ target.submit();
280
+ return true;
281
+ }
282
+
283
+ if (eventType === "form_close") {
284
+ target.close();
285
+ return true;
286
+ }
287
+
288
+ return false;
289
+ }
290
+
291
+ /** Flatten a form to its full field list — single-page or stepper. Excludes display blocks. */
292
+ export function collectInputFields(definition: FormDefinition): FormFieldDef[] {
293
+ const all = definition.steps?.length
294
+ ? definition.steps.flatMap((s) => s.fields)
295
+ : definition.fields;
296
+ return all.filter((f) => f.type !== "display" && Boolean(f.name));
297
+ }
298
+
299
+ /** Fields for a specific step (or the whole form when no stepper). */
300
+ export function fieldsForStep(
301
+ definition: FormDefinition,
302
+ stepIndex: number,
303
+ ): FormFieldDef[] {
304
+ if (definition.steps?.length) {
305
+ const step = definition.steps[Math.max(0, Math.min(stepIndex, definition.steps.length - 1))];
306
+ return step?.fields ?? [];
307
+ }
308
+ return definition.fields;
309
+ }
310
+
311
+ export function totalSteps(definition: FormDefinition): number {
312
+ return definition.steps?.length || 1;
313
+ }
314
+
315
+ /** A single field that failed local validation. */
316
+ export interface FieldValidationError {
317
+ name: string;
318
+ label: string;
319
+ message: string;
320
+ }
321
+
322
+ // Loose, intentionally permissive formats — the goal is to catch obvious
323
+ // typos before submit, not to reject every unusual-but-valid value.
324
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
325
+ const TEL_ALLOWED_RE = /^[+()\-.\s\d]+$/;
326
+
327
+ /**
328
+ * Validate the given values against their field definitions, returning one
329
+ * error per offending field. Runs entirely in the browser so obvious mistakes
330
+ * (missing required fields, malformed email/phone, out-of-range numbers, custom
331
+ * pattern mismatches) are caught before anything is sent to the server/agent.
332
+ *
333
+ * Format checks are skipped for empty optional fields — only `required` flags
334
+ * empties. `display` blocks and unnamed fields are ignored.
335
+ */
336
+ export function validateFields(
337
+ fields: FormFieldDef[],
338
+ values: Record<string, string>,
339
+ ): FieldValidationError[] {
340
+ const errors: FieldValidationError[] = [];
341
+ for (const field of fields) {
342
+ if (field.type === "display" || !field.name) continue;
343
+ const raw = values[field.name];
344
+ const value = (raw ?? "").trim();
345
+
346
+ if (!value) {
347
+ if (field.required) {
348
+ errors.push({ name: field.name, label: field.label, message: `${field.label} is required.` });
349
+ }
350
+ // Nothing more to validate on an empty optional field.
351
+ continue;
352
+ }
353
+
354
+ if (field.type === "email" && !EMAIL_RE.test(value)) {
355
+ errors.push({ name: field.name, label: field.label, message: "Enter a valid email address." });
356
+ continue;
357
+ }
358
+
359
+ if (field.type === "tel") {
360
+ const digits = value.replace(/\D/g, "");
361
+ if (!TEL_ALLOWED_RE.test(value) || digits.length < 7) {
362
+ errors.push({ name: field.name, label: field.label, message: "Enter a valid phone number." });
363
+ continue;
364
+ }
365
+ }
366
+
367
+ if (field.type === "number") {
368
+ const num = Number(value);
369
+ if (!Number.isFinite(num)) {
370
+ errors.push({ name: field.name, label: field.label, message: "Enter a valid number." });
371
+ continue;
372
+ }
373
+ if (field.min !== undefined && num < field.min) {
374
+ errors.push({ name: field.name, label: field.label, message: `Must be at least ${field.min}.` });
375
+ continue;
376
+ }
377
+ if (field.max !== undefined && num > field.max) {
378
+ errors.push({ name: field.name, label: field.label, message: `Must be at most ${field.max}.` });
379
+ continue;
380
+ }
381
+ }
382
+
383
+ if (field.pattern) {
384
+ try {
385
+ if (!new RegExp(field.pattern).test(value)) {
386
+ errors.push({ name: field.name, label: field.label, message: `Please enter a valid ${field.label.toLowerCase()}.` });
387
+ continue;
388
+ }
389
+ } catch {
390
+ // A malformed pattern in the form config must never break submission.
391
+ }
392
+ }
393
+ }
394
+ return errors;
395
+ }
396
+
397
+ const BOOK_DEMO_INTEREST_OPTIONS = [
398
+ "Enterprise Voice Platform (OVIP)",
399
+ "Fine-Tuning Automation (OFTA)",
400
+ "Consumer / Kids Products",
401
+ "Investor Relations",
402
+ "Partnership / Integration",
403
+ ];
404
+
405
+ export const DEFAULT_FORM_DEFINITIONS: FormDefinition[] = [
406
+ {
407
+ id: "book-demo",
408
+ title: "Book a demo",
409
+ subtitle:
410
+ "The voice agent filled this from the call. Review and edit before sending.",
411
+ submit_url: "/api/book-demo/",
412
+ submit_label: "Confirm & send",
413
+ success_message:
414
+ "Thanks! Your demo request has been sent. We'll get back to you soon.",
415
+ topics: ["book-demo.form", "book-demo.review", "form.book-demo"],
416
+ event_types: ["book_demo_form", "book-demo-form"],
417
+ // Match the webapp's help-desk-call echo so the agent sees the same
418
+ // event type from either surface. Default would have been
419
+ // "book-demo_submitted" (hyphen).
420
+ confirmation_type: "book_demo_submitted",
421
+ fields: [
422
+ { name: "name", label: "Full name", type: "text", required: true, placeholder: "Your full name" },
423
+ { name: "organization", label: "Organization", type: "text", required: true, placeholder: "Your company or organization" },
424
+ { name: "email", label: "Email", type: "email", required: true, placeholder: "you@organization.com" },
425
+ {
426
+ name: "interested_in",
427
+ label: "I'm interested in...",
428
+ type: "select",
429
+ required: true,
430
+ options: BOOK_DEMO_INTEREST_OPTIONS,
431
+ default_value: BOOK_DEMO_INTEREST_OPTIONS[0],
432
+ },
433
+ {
434
+ name: "description",
435
+ label: "Why do you want to book a demo?",
436
+ type: "textarea",
437
+ required: true,
438
+ rows: 4,
439
+ placeholder: "Describe your use case, goals, or questions...",
440
+ },
441
+ ],
442
+ },
443
+ {
444
+ id: "reservation",
445
+ title: "Make a reservation",
446
+ subtitle:
447
+ "The voice agent filled this from the call. Review and edit before sending.",
448
+ submit_url: "/api/reservations/",
449
+ submit_label: "Confirm reservation",
450
+ success_message: "Reservation confirmed. We'll send a confirmation email shortly.",
451
+ topics: ["reservation.form", "form.reservation"],
452
+ event_types: ["reservation_form", "reservation-form"],
453
+ fields: [
454
+ { name: "name", label: "Name", type: "text", required: true, placeholder: "Your name" },
455
+ { name: "email", label: "Email", type: "email", required: true, placeholder: "you@example.com" },
456
+ { name: "phone", label: "Phone", type: "tel", placeholder: "+977 98XXXXXXXX" },
457
+ { name: "party_size", label: "Party size", type: "text", required: true, placeholder: "e.g. 4" },
458
+ { name: "date", label: "Date", type: "text", required: true, placeholder: "YYYY-MM-DD" },
459
+ { name: "time", label: "Time", type: "text", required: true, placeholder: "HH:MM" },
460
+ { name: "notes", label: "Notes", type: "textarea", rows: 3, placeholder: "Allergies, occasion, seating preference…" },
461
+ ],
462
+ },
463
+ ];
464
+
465
+ /**
466
+ * Try to identify which form definition an incoming LiveKit data message is
467
+ * asking the widget to open. Returns null if no definition matches.
468
+ */
469
+ export function matchForm(
470
+ topic: string | undefined,
471
+ value: unknown,
472
+ forms: FormDefinition[],
473
+ ): FormDefinition | null {
474
+ if (!value || typeof value !== "object") return null;
475
+
476
+ const normalize = (s: string) => s.trim().toLowerCase();
477
+
478
+ if (topic) {
479
+ const t = normalize(topic);
480
+ for (const form of forms) {
481
+ if (form.topics?.some((entry) => normalize(entry) === t)) return form;
482
+ const id = normalize(form.id);
483
+ if (
484
+ t === `${id}.form` ||
485
+ t === `${id}.review` ||
486
+ t === `form.${id}` ||
487
+ t === id
488
+ ) {
489
+ return form;
490
+ }
491
+ }
492
+ }
493
+
494
+ const candidate = value as Record<string, unknown>;
495
+
496
+ const explicitId = stringField(candidate.form_id) ?? stringField(candidate.formId);
497
+ if (explicitId) {
498
+ const id = normalize(explicitId);
499
+ const match = forms.find((f) => normalize(f.id) === id);
500
+ if (match) return match;
501
+ }
502
+
503
+ const eventType = [candidate.type, candidate.event, candidate.kind, candidate.intent]
504
+ .map(stringField)
505
+ .find((v): v is string => Boolean(v))
506
+ ?.toLowerCase();
507
+
508
+ if (eventType) {
509
+ for (const form of forms) {
510
+ if (form.event_types?.some((entry) => normalize(entry) === eventType)) return form;
511
+ const id = normalize(form.id);
512
+ if (
513
+ eventType === id ||
514
+ eventType === `${id}_form` ||
515
+ eventType === `${id}-form` ||
516
+ eventType === `${id.replace(/-/g, "_")}_form`
517
+ ) {
518
+ return form;
519
+ }
520
+ }
521
+ }
522
+
523
+ return null;
524
+ }
525
+
526
+ /**
527
+ * Pull a partial field/value draft out of an incoming data message. Looks at
528
+ * the payload root and common nested keys (`form`, `payload`, `data`,
529
+ * `fields`, `values`). Returns null when nothing matches the form's fields.
530
+ */
531
+ export function extractFormDraft(
532
+ value: unknown,
533
+ definition: FormDefinition,
534
+ ): Record<string, string> | null {
535
+ if (!value || typeof value !== "object") return null;
536
+ const candidate = value as Record<string, unknown>;
537
+
538
+ const nested = ["draft", "form", "payload", "data", "fields", "values"]
539
+ .map((key) => candidate[key])
540
+ .find((entry) => entry && typeof entry === "object") as
541
+ | Record<string, unknown>
542
+ | undefined;
543
+
544
+ const draft: Record<string, string> = {};
545
+
546
+ for (const field of collectInputFields(definition)) {
547
+ if (!field.name) continue;
548
+ const raw =
549
+ stringField(nested?.[field.name]) ?? stringField(candidate[field.name]);
550
+ if (raw) draft[field.name] = raw;
551
+ }
552
+
553
+ return Object.keys(draft).length > 0 ? draft : null;
554
+ }
555
+
556
+ export function initialFormValues(
557
+ definition: FormDefinition,
558
+ ): Record<string, string> {
559
+ const values: Record<string, string> = {};
560
+ for (const field of collectInputFields(definition)) {
561
+ if (!field.name) continue;
562
+ values[field.name] = field.default_value ?? "";
563
+ }
564
+ return values;
565
+ }
566
+
567
+ export function mergeFormDraft(
568
+ current: Record<string, string>,
569
+ draft: Record<string, string> | null | undefined,
570
+ ): Record<string, string> {
571
+ if (!draft) return current;
572
+ const next = { ...current };
573
+ for (const [k, v] of Object.entries(draft)) {
574
+ if (typeof v === "string" && v.trim()) next[k] = v.trim();
575
+ }
576
+ return next;
577
+ }
578
+
579
+ /** Plain-text summary of a submission — echoed back to the agent on confirm. */
580
+ export function buildSubmissionText(
581
+ definition: FormDefinition,
582
+ values: Record<string, string>,
583
+ ): string {
584
+ const parts = collectInputFields(definition)
585
+ .map((f) => `${f.label.toLowerCase()}: ${(values[f.name!] ?? "").trim()}`)
586
+ .filter((entry) => !entry.endsWith(": "));
587
+ return `I have confirmed and submitted the "${definition.title}" form: ${parts.join("; ")}.`;
588
+ }
589
+
590
+ export interface SubmitFormArgs {
591
+ definition: FormDefinition;
592
+ values: Record<string, string>;
593
+ apiUrl: string;
594
+ /** Agent slug — required when submit_url is null (managed storage path). */
595
+ slug: string;
596
+ /** Active session ID to link the form response to its billing session. */
597
+ sessionId?: string | null;
598
+ /** Secret key sent as `x-api-key` on the managed-storage POST. */
599
+ apiKey?: string;
600
+ /** Custom fetch (Node <18 / testing). Defaults to globalThis.fetch. */
601
+ fetch?: typeof fetch;
602
+ }
603
+
604
+ /** Collect all field definitions across steps and top-level fields. */
605
+ function allFields(definition: FormDefinition): FormFieldDef[] {
606
+ if (definition.steps?.length) {
607
+ return definition.steps.flatMap((s) => s.fields);
608
+ }
609
+ return definition.fields;
610
+ }
611
+
612
+ export async function submitForm({
613
+ definition,
614
+ values,
615
+ apiUrl,
616
+ slug,
617
+ sessionId,
618
+ apiKey,
619
+ fetch: fetchImpl,
620
+ }: SubmitFormArgs): Promise<unknown> {
621
+ const base = apiUrl.replace(/\/+$/, "");
622
+
623
+ let url: string;
624
+ let body: unknown;
625
+ let method: string;
626
+ let managed = false;
627
+
628
+ if (!definition.submit_url) {
629
+ // Managed storage path: POST to platform API with question+answer pairs.
630
+ managed = true;
631
+ url = `${base}/api/agents/${slug}/form-responses/`;
632
+ method = "POST";
633
+ const formData = allFields(definition)
634
+ .filter((f) => f.name && f.type !== "display")
635
+ .map((f) => ({
636
+ name: f.name!,
637
+ label: f.label,
638
+ value: values[f.name!] ?? "",
639
+ }));
640
+ body = {
641
+ form_id: definition.id,
642
+ form_data: formData,
643
+ ...(sessionId ? { session_id: sessionId } : {}),
644
+ };
645
+ } else {
646
+ // External submit_url path (existing behaviour).
647
+ url = /^https?:\/\//i.test(definition.submit_url)
648
+ ? definition.submit_url
649
+ : `${base}/${definition.submit_url.replace(/^\/+/, "")}`;
650
+ method = definition.submit_method ?? "POST";
651
+ body = values;
652
+ }
653
+
654
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
655
+ // Only attach the platform key on the managed-storage path; never leak it to
656
+ // a third-party submit_url.
657
+ if (managed && apiKey?.trim()) headers["x-api-key"] = apiKey.trim();
658
+ const doFetch =
659
+ fetchImpl ??
660
+ (typeof globalThis.fetch === "function"
661
+ ? globalThis.fetch.bind(globalThis)
662
+ : (() => {
663
+ throw new Error("[voice-agent] No fetch available; pass config.fetch.");
664
+ })());
665
+
666
+ const response = await doFetch(url, {
667
+ method,
668
+ headers,
669
+ body: JSON.stringify(body),
670
+ });
671
+
672
+ const data = await response.json().catch(() => null);
673
+
674
+ if (!response.ok) {
675
+ throw new Error(
676
+ data?.error ||
677
+ data?.detail ||
678
+ `We couldn't send your request right now (HTTP ${response.status}).`,
679
+ );
680
+ }
681
+
682
+ return data;
683
+ }
684
+
685
+ /**
686
+ * Accept a partial `forms` array from the appearance config (or anywhere
687
+ * else) and turn it into a clean array of FormDefinitions, dropping entries
688
+ * that don't have an id + at least one field.
689
+ */
690
+ export function normalizeFormDefinitions(value: unknown): FormDefinition[] {
691
+ if (!Array.isArray(value)) return [];
692
+ const out: FormDefinition[] = [];
693
+
694
+ for (const raw of value) {
695
+ if (!raw || typeof raw !== "object") continue;
696
+ const entry = raw as Record<string, unknown>;
697
+ const id = stringField(entry.id);
698
+ if (!id) continue;
699
+
700
+ const steps = normalizeSteps(entry.steps);
701
+ const fields = normalizeFields(entry.fields);
702
+ // A form must have either at least one step (with fields) or a fields array.
703
+ if (!steps.length && !fields.length) continue;
704
+
705
+ out.push({
706
+ id,
707
+ title: stringField(entry.title) ?? id,
708
+ subtitle: stringField(entry.subtitle),
709
+ fields,
710
+ steps: steps.length ? steps : undefined,
711
+ layout: normalizeLayout(entry.layout),
712
+ disabled: entry.disabled === true ? true : undefined,
713
+ submit_url: stringField(entry.submit_url) ?? null,
714
+ submit_method: enumField(
715
+ ["POST", "PUT", "PATCH"] as const,
716
+ entry.submit_method,
717
+ ),
718
+ submit_label: stringField(entry.submit_label),
719
+ success_message: stringField(entry.success_message),
720
+ topics: stringArray(entry.topics),
721
+ event_types: stringArray(entry.event_types),
722
+ confirmation_topic: stringField(entry.confirmation_topic),
723
+ confirmation_type: stringField(entry.confirmation_type),
724
+ });
725
+ }
726
+ return out;
727
+ }
728
+
729
+ function normalizeFields(value: unknown): FormFieldDef[] {
730
+ if (!Array.isArray(value)) return [];
731
+ const out: FormFieldDef[] = [];
732
+ for (const raw of value) {
733
+ if (!raw || typeof raw !== "object") continue;
734
+ const entry = raw as Record<string, unknown>;
735
+ const type =
736
+ enumField(
737
+ [
738
+ "text",
739
+ "email",
740
+ "tel",
741
+ "textarea",
742
+ "select",
743
+ "number",
744
+ "date",
745
+ "time",
746
+ "checkbox",
747
+ "radio",
748
+ "display",
749
+ ] as const,
750
+ entry.type,
751
+ ) ?? "text";
752
+ const name = stringField(entry.name);
753
+ // Inputs require a name; display blocks don't.
754
+ if (type !== "display" && !name) continue;
755
+
756
+ out.push({
757
+ name,
758
+ label: stringField(entry.label) ?? name ?? "",
759
+ type,
760
+ placeholder: stringField(entry.placeholder),
761
+ required: entry.required === true,
762
+ options: normalizeOptions(entry.options),
763
+ rows:
764
+ typeof entry.rows === "number" && Number.isFinite(entry.rows) && entry.rows > 0
765
+ ? entry.rows
766
+ : undefined,
767
+ default_value: stringField(entry.default_value),
768
+ help_text: stringField(entry.help_text),
769
+ pattern: stringField(entry.pattern),
770
+ min: finiteNumber(entry.min),
771
+ max: finiteNumber(entry.max),
772
+ width: enumField(["full", "half"] as const, entry.width),
773
+ });
774
+ }
775
+ return out;
776
+ }
777
+
778
+ function normalizeSteps(value: unknown): FormStep[] {
779
+ if (!Array.isArray(value)) return [];
780
+ const out: FormStep[] = [];
781
+ for (const [index, raw] of value.entries()) {
782
+ if (!raw || typeof raw !== "object") continue;
783
+ const entry = raw as Record<string, unknown>;
784
+ const fields = normalizeFields(entry.fields);
785
+ if (!fields.length) continue;
786
+ out.push({
787
+ id: stringField(entry.id) ?? `step-${index + 1}`,
788
+ title: stringField(entry.title),
789
+ subtitle: stringField(entry.subtitle),
790
+ fields,
791
+ next_label: stringField(entry.next_label),
792
+ back_label: stringField(entry.back_label),
793
+ });
794
+ }
795
+ return out;
796
+ }
797
+
798
+ function normalizeLayout(value: unknown): FormLayout | undefined {
799
+ if (!value || typeof value !== "object") return undefined;
800
+ const entry = value as Record<string, unknown>;
801
+ const layout: FormLayout = {};
802
+ const fieldLayout = enumField(["stack", "grid"] as const, entry.field_layout);
803
+ if (fieldLayout) layout.field_layout = fieldLayout;
804
+ const density = enumField(["comfortable", "compact"] as const, entry.density);
805
+ if (density) layout.density = density;
806
+ const labelPos = enumField(["top", "inline"] as const, entry.label_position);
807
+ if (labelPos) layout.label_position = labelPos;
808
+ return Object.keys(layout).length ? layout : undefined;
809
+ }
810
+
811
+ function normalizeOptions(value: unknown): FormFieldOption[] | undefined {
812
+ if (!Array.isArray(value)) return undefined;
813
+ const out: FormFieldOption[] = [];
814
+ for (const raw of value) {
815
+ if (typeof raw === "string") {
816
+ const trimmed = raw.trim();
817
+ if (trimmed) out.push(trimmed);
818
+ continue;
819
+ }
820
+ if (raw && typeof raw === "object") {
821
+ const entry = raw as Record<string, unknown>;
822
+ const v = stringField(entry.value);
823
+ if (!v) continue;
824
+ const label = stringField(entry.label);
825
+ out.push(label ? { value: v, label } : { value: v });
826
+ }
827
+ }
828
+ return out.length ? out : undefined;
829
+ }
830
+
831
+ function finiteNumber(value: unknown): number | undefined {
832
+ if (typeof value === "number" && Number.isFinite(value)) return value;
833
+ if (typeof value === "string") {
834
+ const parsed = Number(value.trim());
835
+ if (Number.isFinite(parsed)) return parsed;
836
+ }
837
+ return undefined;
838
+ }
839
+
840
+ function stringField(value: unknown): string | undefined {
841
+ if (typeof value !== "string") return undefined;
842
+ const trimmed = value.trim();
843
+ return trimmed ? trimmed : undefined;
844
+ }
845
+
846
+ function stringArray(value: unknown): string[] | undefined {
847
+ if (!Array.isArray(value)) return undefined;
848
+ const out = value
849
+ .map(stringField)
850
+ .filter((v): v is string => Boolean(v));
851
+ return out.length ? out : undefined;
852
+ }
853
+
854
+ function enumField<T extends string>(
855
+ allowed: readonly T[],
856
+ value: unknown,
857
+ ): T | undefined {
858
+ return typeof value === "string" && allowed.includes(value as T)
859
+ ? (value as T)
860
+ : undefined;
861
+ }