@smooai/chat-widget 0.11.0 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smooai/chat-widget",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Embeddable AI chat as a framework-light web component — the Aurora Glass design, streaming replies, grounded sources, and per-brand theming. Speaks the smooth-operator WebSocket protocol.",
5
5
  "license": "MIT",
6
6
  "author": "SmooAI",
@@ -58,7 +58,7 @@
58
58
  "access": "public"
59
59
  },
60
60
  "dependencies": {
61
- "@smooai/smooth-operator": "^1.8.0",
61
+ "@smooai/smooth-operator": "^1.21.1",
62
62
  "libphonenumber-js": "^1.13.7",
63
63
  "zustand": "^5.0.14"
64
64
  },
@@ -93,6 +93,9 @@ export type ConnectionStatus = 'idle' | 'connecting' | 'ready' | 'error' | 'clos
93
93
  * Resume with {@link ConversationController.verifyOtp}.
94
94
  * - `confirm` — the agent wants to run a state-mutating tool and needs approval.
95
95
  * Resume with {@link ConversationController.confirmTool}.
96
+ * - `interaction` — the agent raised a Rich Interaction (structured card, e.g.
97
+ * identity intake). Resume with {@link ConversationController.submitInteraction}
98
+ * or {@link ConversationController.declineInteraction}.
96
99
  */
97
100
  export type Interrupt =
98
101
  | {
@@ -106,7 +109,28 @@ export type Interrupt =
106
109
  error?: string;
107
110
  attemptsRemaining?: number;
108
111
  }
109
- | { kind: 'confirm'; toolId?: string; actionDescription?: string };
112
+ | { kind: 'confirm'; toolId?: string; actionDescription?: string }
113
+ | {
114
+ kind: 'interaction';
115
+ /** Server-generated interaction instance id (echoed on submit). */
116
+ interactionId: string;
117
+ /** The Rich Interaction kind (e.g. `identity_intake`) — selects the card. */
118
+ interactionKind: string;
119
+ /** Kind-specific render spec (identity_intake: `{ fields: [...] }`). */
120
+ spec: Record<string, unknown>;
121
+ /** Why the agent raised it (card header copy). */
122
+ reason?: string;
123
+ /** Per-field server-side validation errors (from `interaction_invalid`). */
124
+ errors?: { field: string; message: string }[];
125
+ };
126
+
127
+ /**
128
+ * The Rich-Interaction render capabilities this widget declares at session
129
+ * create (`supports`). Must stay aligned with the card registry in
130
+ * `element.ts` (`INTERACTION_CARDS`) — registering a card IS declaring its
131
+ * capability; a test asserts the two match.
132
+ */
133
+ export const SUPPORTED_INTERACTION_CAPABILITIES: readonly string[] = ['identity_form'];
110
134
 
111
135
  export interface UserInfo {
112
136
  name?: string;
@@ -232,6 +256,9 @@ export class ConversationController {
232
256
  /** requestId of the in-flight turn — used to resume OTP / tool confirmations. */
233
257
  private activeRequestId: string | null = null;
234
258
  private interrupt: Interrupt | null = null;
259
+ /** Values from the last interaction submit, merged into the persisted
260
+ * identity (identity_intake only) once the server acks them. */
261
+ private pendingInteractionValues: { kind: string; values: Record<string, unknown> } | null = null;
235
262
  private identityRestore: IdentityRestore = { phase: 'idle' };
236
263
  /**
237
264
  * True once the resume probe (persisted-pointer get_session OR the
@@ -337,6 +364,41 @@ export class ConversationController {
337
364
  this.setInterrupt(null);
338
365
  }
339
366
 
367
+ /**
368
+ * Submit a Rich Interaction card to resume the parked turn. The server
369
+ * routes to the kind's validator: invalid values come back as an
370
+ * `interaction_invalid` event (the card re-renders with per-field errors —
371
+ * the turn stays parked); a valid submit is acked and the turn resumes.
372
+ * No-op if not awaiting an interaction.
373
+ */
374
+ submitInteraction(values: Record<string, unknown>): void {
375
+ if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== 'interaction') return;
376
+ // Stash the values so the ack (immediate_response) can merge accepted
377
+ // identity values into the persisted store.
378
+ this.pendingInteractionValues = { kind: this.interrupt.interactionKind, values };
379
+ this.client.submitInteraction({
380
+ sessionId: this.sessionId,
381
+ requestId: this.activeRequestId,
382
+ interactionId: this.interrupt.interactionId,
383
+ kind: this.interrupt.interactionKind,
384
+ values,
385
+ });
386
+ }
387
+
388
+ /** Decline the pending Rich Interaction; the agent continues without it. */
389
+ declineInteraction(): void {
390
+ if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== 'interaction') return;
391
+ this.client.submitInteraction({
392
+ sessionId: this.sessionId,
393
+ requestId: this.activeRequestId,
394
+ interactionId: this.interrupt.interactionId,
395
+ kind: this.interrupt.interactionKind,
396
+ declined: true,
397
+ });
398
+ this.pendingInteractionValues = null;
399
+ this.setInterrupt(null);
400
+ }
401
+
340
402
  private nextId(prefix: string): string {
341
403
  this.seq += 1;
342
404
  return `${prefix}-${this.seq}-${Date.now().toString(36)}`;
@@ -543,6 +605,10 @@ export class ConversationController {
543
605
  userName: state.identity.name,
544
606
  userEmail: state.identity.email,
545
607
  browserFingerprint: this.fingerprint(),
608
+ // Declare the Rich-Interaction cards this widget can render (derived
609
+ // from the card registry), so the server emits `interaction_required`
610
+ // for those kinds instead of the conversational fallback.
611
+ supports: [...SUPPORTED_INTERACTION_CAPABILITIES],
546
612
  ...(metadata ? { metadata } : {}),
547
613
  });
548
614
  this.sessionId = session.sessionId;
@@ -700,7 +766,51 @@ export class ConversationController {
700
766
  case 'write_confirmation_required':
701
767
  this.setInterrupt({ kind: 'confirm', toolId: str(inner.toolId), actionDescription: str(inner.actionDescription) });
702
768
  break;
703
- default:
769
+ case 'interaction_required': {
770
+ const interactionId = str(inner.interactionId);
771
+ const kind = str(inner.kind);
772
+ const spec = inner.spec && typeof inner.spec === 'object' ? (inner.spec as Record<string, unknown>) : {};
773
+ if (!interactionId || !kind) break; // not renderable — ignore
774
+ this.pendingInteractionValues = null;
775
+ this.setInterrupt({
776
+ kind: 'interaction',
777
+ interactionId,
778
+ interactionKind: kind,
779
+ spec,
780
+ reason: str(inner.reason),
781
+ });
782
+ break;
783
+ }
784
+ case 'interaction_invalid':
785
+ if (this.interrupt?.kind === 'interaction' && this.interrupt.interactionId === str(inner.interactionId)) {
786
+ const errors: { field: string; message: string }[] = [];
787
+ if (Array.isArray(inner.errors)) {
788
+ for (const e of inner.errors) {
789
+ if (!e || typeof e !== 'object') continue;
790
+ const o = e as Record<string, unknown>;
791
+ const field = str(o.field);
792
+ if (field) errors.push({ field, message: str(o.message) ?? 'Invalid value' });
793
+ }
794
+ }
795
+ this.pendingInteractionValues = null;
796
+ this.setInterrupt({ ...this.interrupt, errors });
797
+ }
798
+ break;
799
+ case 'immediate_response':
800
+ // Mid-turn immediate_response while an interaction card is showing
801
+ // is the submit/decline ack: the park resolved — clear the card
802
+ // and, for accepted identity values, persist them.
803
+ if (this.interrupt?.kind === 'interaction') {
804
+ const pending = this.pendingInteractionValues;
805
+ if (pending && pending.kind === 'identity_intake') {
806
+ const v = pending.values as { name?: string; email?: string; phone?: string };
807
+ this.store.getState().mergeIdentity({ name: v.name, email: v.email, phone: v.phone });
808
+ }
809
+ this.pendingInteractionValues = null;
810
+ this.setInterrupt(null);
811
+ }
812
+ break;
813
+ default:
704
814
  break;
705
815
  }
706
816
  }
package/src/element.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  import { AsYouType, isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js/min';
18
18
  import type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js';
19
19
  import { needsUserInfo, resolveConfig } from './config.js';
20
- import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type IdentityRestore, type Interrupt } from './conversation.js';
20
+ import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type IdentityRestore, type Interrupt, SUPPORTED_INTERACTION_CAPABILITIES } from './conversation.js';
21
21
  import { SMOOTH_ICON_SVG } from './logo.js';
22
22
  import { cleanCitationSnippet, escapeHtml, renderMarkdown, safeHttpUrl } from './markdown.js';
23
23
  import { buildStyles } from './styles.js';
@@ -71,10 +71,166 @@ const ICON = {
71
71
  chev: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none"><path d="m9 6 6 6-6 6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
72
72
  /** OTP interrupt — a padlock. */
73
73
  lock: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="5" y="10.5" width="14" height="9.5" rx="2.2" stroke="currentColor" stroke-width="1.7"/><path d="M8 10.5V8a4 4 0 0 1 8 0v2.5" stroke="currentColor" stroke-width="1.7"/></svg>`,
74
+ /** Identity-intake interrupt — a person. */
75
+ user: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="8.2" r="3.4" stroke="currentColor" stroke-width="1.7"/><path d="M5.5 19.5c.8-3.1 3.4-4.8 6.5-4.8s5.7 1.7 6.5 4.8" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>`,
74
76
  /** Tool-confirmation interrupt — a shield. */
75
77
  shield: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 3 5 6v5c0 4.4 3 7.2 7 8.5 4-1.3 7-4.1 7-8.5V6l-7-3Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m9 11.5 2 2 4-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
76
78
  } as const;
77
79
 
80
+ /**
81
+ * The Rich Interactions **card registry**: interaction kind → overlay card.
82
+ * `interaction_required { kind }` looks its card up here; registering a card IS
83
+ * declaring the widget's render capability for that kind (see
84
+ * `SUPPORTED_INTERACTION_CAPABILITIES` in conversation.ts — a test keeps the
85
+ * two aligned). Adding a kind = one card builder + one entry.
86
+ *
87
+ * The existing OTP and tool-approval overlays are prior instances of this same
88
+ * shape and should retrofit onto this registry later (their wire events predate
89
+ * the pattern).
90
+ */
91
+ export interface InteractionCardContext {
92
+ /** Submit kind-shaped values (resumes the parked turn; server validates). */
93
+ submit: (values: Record<string, unknown>) => void;
94
+ /** Decline the interaction (the agent continues without it). */
95
+ decline: () => void;
96
+ /** Persisted visitor identity, for pre-filling known fields. */
97
+ prefill: { name?: string; email?: string; phone?: string };
98
+ /** Wire live phone formatting + validity hint onto a form's phone input. */
99
+ wirePhoneField: (form: HTMLFormElement) => void;
100
+ }
101
+
102
+ export interface InteractionCard {
103
+ /** The render capability this card provides (goes into `supports`). */
104
+ capability: string;
105
+ /** Card header title. */
106
+ title: string;
107
+ /** Static, trusted header icon SVG. */
108
+ icon: string;
109
+ /** Build the card body for an `interaction` interrupt. */
110
+ build: (it: Extract<Interrupt, { kind: 'interaction' }>, ctx: InteractionCardContext) => HTMLElement;
111
+ }
112
+
113
+ /**
114
+ * The identity_intake card: the fields the agent requested (pre-chat form field
115
+ * pattern — same classes, same phone formatting), per-field server errors, a
116
+ * submit and a decline affordance. Server-supplied text (reason, labels, error
117
+ * messages) is set via `textContent` — never innerHTML.
118
+ */
119
+ function buildIdentityIntakeCard(it: Extract<Interrupt, { kind: 'interaction' }>, ctx: InteractionCardContext): HTMLElement {
120
+ const form = document.createElement('form');
121
+ form.className = 'int-form';
122
+ form.noValidate = true;
123
+
124
+ if (it.reason) {
125
+ const desc = document.createElement('div');
126
+ desc.className = 'int-desc';
127
+ desc.textContent = it.reason;
128
+ form.appendChild(desc);
129
+ }
130
+
131
+ const DEFAULTS: Record<string, { label: string; type: string; autocomplete: string }> = {
132
+ name: { label: 'Name', type: 'text', autocomplete: 'name' },
133
+ email: { label: 'Email', type: 'email', autocomplete: 'email' },
134
+ phone: { label: 'Phone', type: 'tel', autocomplete: 'tel' },
135
+ };
136
+
137
+ // Parse the kind's spec defensively: `{ fields: [{ key, required, label? }] }`.
138
+ const rawFields = Array.isArray(it.spec.fields) ? it.spec.fields : [];
139
+ const fields: { key: 'name' | 'email' | 'phone'; required: boolean; label?: string }[] = [];
140
+ for (const f of rawFields) {
141
+ if (!f || typeof f !== 'object') continue;
142
+ const o = f as Record<string, unknown>;
143
+ const key = typeof o.key === 'string' ? o.key : '';
144
+ if (key !== 'name' && key !== 'email' && key !== 'phone') continue;
145
+ fields.push({ key, required: o.required === true, label: typeof o.label === 'string' ? o.label : undefined });
146
+ }
147
+ if (fields.length === 0) fields.push({ key: 'email', required: true });
148
+
149
+ for (const f of fields) {
150
+ const d = DEFAULTS[f.key]!;
151
+ const label = document.createElement('label');
152
+ label.className = 'pc-field';
153
+ const caption = document.createElement('span');
154
+ caption.textContent = f.label ?? d.label;
155
+ const input = document.createElement('input');
156
+ input.name = f.key;
157
+ input.type = d.type;
158
+ input.setAttribute('autocomplete', d.autocomplete);
159
+ input.required = f.required;
160
+ const prefill = ctx.prefill[f.key];
161
+ if (prefill) input.value = prefill;
162
+ label.append(caption, input);
163
+ if (f.key === 'phone') {
164
+ const hint = document.createElement('span');
165
+ hint.className = 'pc-hint';
166
+ hint.setAttribute('aria-live', 'polite');
167
+ label.appendChild(hint);
168
+ }
169
+ // Per-field server-side validation error (interaction_invalid).
170
+ const serverError = it.errors?.find((e) => e.field === f.key);
171
+ if (serverError) {
172
+ label.classList.add('invalid');
173
+ const err = document.createElement('span');
174
+ err.className = 'int-error';
175
+ err.textContent = serverError.message;
176
+ label.appendChild(err);
177
+ }
178
+ form.appendChild(label);
179
+ }
180
+
181
+ const row = document.createElement('div');
182
+ row.className = 'int-row';
183
+ const decline = document.createElement('button');
184
+ decline.className = 'int-btn';
185
+ decline.type = 'button';
186
+ decline.textContent = 'No thanks';
187
+ decline.addEventListener('click', () => ctx.decline());
188
+ const share = document.createElement('button');
189
+ share.className = 'int-btn primary';
190
+ share.type = 'submit';
191
+ share.textContent = 'Share details';
192
+ row.append(decline, share);
193
+ form.appendChild(row);
194
+
195
+ form.addEventListener('submit', (ev) => {
196
+ ev.preventDefault();
197
+ if (!form.reportValidity()) return;
198
+ const data = new FormData(form);
199
+ const val = (k: string) => ((data.get(k) as string | null)?.trim() || undefined);
200
+
201
+ // Phone: mirror the pre-chat rules — block a required-but-invalid number,
202
+ // prefer canonical E.164 when it parses (the server re-validates anyway).
203
+ const rawPhone = val('phone');
204
+ const phoneInput = form.querySelector('input[name="phone"]') as HTMLInputElement | null;
205
+ if (rawPhone && phoneInput && !isValidPhoneNumber(rawPhone, PHONE_DEFAULT_REGION)) {
206
+ const field = phoneInput.closest('.pc-field');
207
+ field?.classList.add('invalid');
208
+ const hint = field?.querySelector('.pc-hint');
209
+ if (hint) hint.textContent = 'Enter a valid phone number';
210
+ if (phoneInput.hasAttribute('required')) {
211
+ phoneInput.focus();
212
+ return;
213
+ }
214
+ }
215
+ const phone = rawPhone ? (phoneToE164(rawPhone) ?? rawPhone) : undefined;
216
+ ctx.submit({ name: val('name'), email: val('email'), phone });
217
+ });
218
+ // Same live phone formatting + validity hint as the pre-chat form.
219
+ ctx.wirePhoneField(form);
220
+ queueMicrotask(() => (form.querySelector('input') as HTMLInputElement | null)?.focus());
221
+ return form;
222
+ }
223
+
224
+ /** Kind → card. See the registry doc above. */
225
+ export const INTERACTION_CARDS: Record<string, InteractionCard> = {
226
+ identity_intake: {
227
+ capability: 'identity_form',
228
+ title: 'Share your details',
229
+ icon: ICON.user,
230
+ build: buildIdentityIntakeCard,
231
+ },
232
+ };
233
+
78
234
  // `safeHttpUrl` / `escapeHtml` live in `./markdown.js` (the markdown renderer
79
235
  // needs them too); re-exported here for back-compat with existing importers.
80
236
  export { escapeHtml, safeHttpUrl } from './markdown.js';
@@ -480,21 +636,38 @@ export class SmoothAgentChatElement extends HTMLElement {
480
636
  head.className = 'int-head';
481
637
  const ico = document.createElement('span');
482
638
  ico.className = 'int-ico';
483
- ico.innerHTML = it.kind === 'otp' ? ICON.lock : ICON.shield; // static, trusted
639
+ const card_meta = it.kind === 'interaction' ? INTERACTION_CARDS[it.interactionKind] : undefined;
640
+ if (it.kind === 'interaction' && !card_meta) {
641
+ // A kind we have no card for (shouldn't happen — we only declare
642
+ // capabilities for registered cards). Decline so the turn never hangs.
643
+ this.controller?.declineInteraction();
644
+ el.classList.add('hidden');
645
+ return;
646
+ }
647
+ ico.innerHTML = it.kind === 'otp' ? ICON.lock : it.kind === 'interaction' ? (card_meta?.icon ?? ICON.user) : ICON.shield; // static, trusted
484
648
  const title = document.createElement('span');
485
649
  title.className = 'int-title';
486
- title.textContent = it.kind === 'otp' ? 'Verification required' : 'Confirm this action';
650
+ title.textContent = it.kind === 'otp' ? 'Verification required' : it.kind === 'interaction' ? (card_meta?.title ?? 'One more thing') : 'Confirm this action';
487
651
  head.append(ico, title);
488
652
  card.appendChild(head);
489
653
 
490
- if (it.actionDescription) {
654
+ if (it.kind !== 'interaction' && it.actionDescription) {
491
655
  const desc = document.createElement('div');
492
656
  desc.className = 'int-desc';
493
657
  desc.textContent = it.actionDescription;
494
658
  card.appendChild(desc);
495
659
  }
496
660
 
497
- if (it.kind === 'otp') {
661
+ if (it.kind === 'interaction') {
662
+ card.appendChild(
663
+ card_meta!.build(it, {
664
+ submit: (values) => this.controller?.submitInteraction(values),
665
+ decline: () => this.controller?.declineInteraction(),
666
+ prefill: this.controller?.getStore().getState().identity ?? {},
667
+ wirePhoneField: (form) => this.wirePhoneField(form),
668
+ }),
669
+ );
670
+ } else if (it.kind === 'otp') {
498
671
  if (it.sent?.maskedDestination) {
499
672
  const sent = document.createElement('div');
500
673
  sent.className = 'int-sent';
package/src/styles.ts CHANGED
@@ -643,6 +643,9 @@ ${
643
643
  border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent);
644
644
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent);
645
645
  }
646
+ .int-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
647
+ .int-form .pc-field input { width: 100%; box-sizing: border-box; }
648
+ .int-form .int-error { margin-top: 2px; }
646
649
  .int-btn {
647
650
  border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
648
651
  background: var(--sac-surface-2);
@@ -662,8 +665,14 @@ ${
662
665
  color: var(--sac-primary-text);
663
666
  box-shadow: 0 6px 14px -6px color-mix(in srgb, var(--sac-primary) 65%, transparent);
664
667
  }
665
- .int-row .int-btn { flex: 1; }
666
- .int-row .int-input + .int-btn { flex: 0 0 auto; }
668
+ .int-row .int-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
669
+ .int-form .pc-field input { width: 100%; box-sizing: border-box; }
670
+ .int-form .int-error { margin-top: 2px; }
671
+ .int-btn { flex: 1; }
672
+ .int-row .int-input + .int-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
673
+ .int-form .pc-field input { width: 100%; box-sizing: border-box; }
674
+ .int-form .int-error { margin-top: 2px; }
675
+ .int-btn { flex: 0 0 auto; }
667
676
  .int-error { margin-top: 8px; font-size: 12px; color: #f87171; }
668
677
  .int-card { position: relative; }
669
678
  .int-close {