@smooai/chat-widget 0.10.2 → 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/README.md +3 -0
- package/dist/chat-widget.global.js +326 -9
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +275 -223
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +307 -6
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/config.ts +7 -0
- package/src/conversation.ts +138 -2
- package/src/element.ts +222 -5
- package/src/styles.ts +15 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smooai/chat-widget",
|
|
3
|
-
"version": "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.
|
|
61
|
+
"@smooai/smooth-operator": "^1.21.1",
|
|
62
62
|
"libphonenumber-js": "^1.13.7",
|
|
63
63
|
"zustand": "^5.0.14"
|
|
64
64
|
},
|
package/src/config.ts
CHANGED
|
@@ -106,6 +106,12 @@ export interface ChatWidgetConfig {
|
|
|
106
106
|
* Clicking one sends it. Capped at 5 for layout.
|
|
107
107
|
*/
|
|
108
108
|
examplePrompts?: string[];
|
|
109
|
+
/**
|
|
110
|
+
* Show mid-conversation suggested-reply chips ("quick replies") under the
|
|
111
|
+
* latest assistant message when the agent returns follow-up suggestions.
|
|
112
|
+
* Clicking one sends it. Defaults to `true` (shown unless explicitly `false`).
|
|
113
|
+
*/
|
|
114
|
+
showSuggestedReplies?: boolean;
|
|
109
115
|
/** Require the visitor's name before chatting. */
|
|
110
116
|
requireName?: boolean;
|
|
111
117
|
/** Require the visitor's email before chatting. */
|
|
@@ -180,6 +186,7 @@ export function resolveConfig(config: ChatWidgetConfig): ResolvedConfig {
|
|
|
180
186
|
startOpen: config.startOpen ?? false,
|
|
181
187
|
hideBranding: config.hideBranding ?? false,
|
|
182
188
|
examplePrompts: (config.examplePrompts ?? []).filter((p) => p.trim().length > 0).slice(0, 5),
|
|
189
|
+
showSuggestedReplies: config.showSuggestedReplies ?? true,
|
|
183
190
|
requireName: config.requireName ?? false,
|
|
184
191
|
requireEmail: config.requireEmail ?? false,
|
|
185
192
|
requirePhone: config.requirePhone ?? false,
|
package/src/conversation.ts
CHANGED
|
@@ -75,6 +75,13 @@ export interface ChatMessage {
|
|
|
75
75
|
* defensively off the terminal event — see {@link extractCitations}.
|
|
76
76
|
*/
|
|
77
77
|
citations?: Citation[];
|
|
78
|
+
/**
|
|
79
|
+
* Suggested follow-up replies ("quick replies") the agent offered on the
|
|
80
|
+
* terminal `eventual_response`. Set ONLY on the finalized assistant message —
|
|
81
|
+
* never mid-stream. Read defensively (see {@link extractSuggestions}); capped
|
|
82
|
+
* at 4 for layout. Absent when the turn offered none.
|
|
83
|
+
*/
|
|
84
|
+
suggestions?: string[];
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
export type ConnectionStatus = 'idle' | 'connecting' | 'ready' | 'error' | 'closed';
|
|
@@ -86,6 +93,9 @@ export type ConnectionStatus = 'idle' | 'connecting' | 'ready' | 'error' | 'clos
|
|
|
86
93
|
* Resume with {@link ConversationController.verifyOtp}.
|
|
87
94
|
* - `confirm` — the agent wants to run a state-mutating tool and needs approval.
|
|
88
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}.
|
|
89
99
|
*/
|
|
90
100
|
export type Interrupt =
|
|
91
101
|
| {
|
|
@@ -99,7 +109,28 @@ export type Interrupt =
|
|
|
99
109
|
error?: string;
|
|
100
110
|
attemptsRemaining?: number;
|
|
101
111
|
}
|
|
102
|
-
| { 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'];
|
|
103
134
|
|
|
104
135
|
export interface UserInfo {
|
|
105
136
|
name?: string;
|
|
@@ -183,6 +214,20 @@ function extractCitations(inner: unknown): Citation[] {
|
|
|
183
214
|
return out;
|
|
184
215
|
}
|
|
185
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Pull the suggested follow-up replies out of a terminal `eventual_response`'s
|
|
219
|
+
* `response` object (`response.suggestedNextActions`). Optional + back-compatible
|
|
220
|
+
* like citations — read defensively (tolerating absence, non-array shapes, and
|
|
221
|
+
* non-string / blank entries) and capped at 4 so a chatty agent can't overflow
|
|
222
|
+
* the composer chip row.
|
|
223
|
+
*/
|
|
224
|
+
function extractSuggestions(response: unknown): string[] {
|
|
225
|
+
if (!response || typeof response !== 'object') return [];
|
|
226
|
+
const raw = (response as { suggestedNextActions?: unknown }).suggestedNextActions;
|
|
227
|
+
if (!Array.isArray(raw)) return [];
|
|
228
|
+
return raw.filter((s): s is string => typeof s === 'string' && s.trim().length > 0).slice(0, 4);
|
|
229
|
+
}
|
|
230
|
+
|
|
186
231
|
/** A `get_conversation_messages` row, narrowed defensively off the wire. */
|
|
187
232
|
interface WireMessage {
|
|
188
233
|
id?: string;
|
|
@@ -211,6 +256,9 @@ export class ConversationController {
|
|
|
211
256
|
/** requestId of the in-flight turn — used to resume OTP / tool confirmations. */
|
|
212
257
|
private activeRequestId: string | null = null;
|
|
213
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;
|
|
214
262
|
private identityRestore: IdentityRestore = { phase: 'idle' };
|
|
215
263
|
/**
|
|
216
264
|
* True once the resume probe (persisted-pointer get_session OR the
|
|
@@ -316,6 +364,41 @@ export class ConversationController {
|
|
|
316
364
|
this.setInterrupt(null);
|
|
317
365
|
}
|
|
318
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
|
+
|
|
319
402
|
private nextId(prefix: string): string {
|
|
320
403
|
this.seq += 1;
|
|
321
404
|
return `${prefix}-${this.seq}-${Date.now().toString(36)}`;
|
|
@@ -522,6 +605,10 @@ export class ConversationController {
|
|
|
522
605
|
userName: state.identity.name,
|
|
523
606
|
userEmail: state.identity.email,
|
|
524
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],
|
|
525
612
|
...(metadata ? { metadata } : {}),
|
|
526
613
|
});
|
|
527
614
|
this.sessionId = session.sessionId;
|
|
@@ -623,6 +710,11 @@ export class ConversationController {
|
|
|
623
710
|
if (citations.length > 0) {
|
|
624
711
|
assistant.citations = citations;
|
|
625
712
|
}
|
|
713
|
+
// Suggested follow-up replies from the terminal event, when present.
|
|
714
|
+
const suggestions = extractSuggestions(inner?.response);
|
|
715
|
+
if (suggestions.length > 0) {
|
|
716
|
+
assistant.suggestions = suggestions;
|
|
717
|
+
}
|
|
626
718
|
assistant.streaming = false;
|
|
627
719
|
this.emitMessages();
|
|
628
720
|
} catch (err) {
|
|
@@ -674,7 +766,51 @@ export class ConversationController {
|
|
|
674
766
|
case 'write_confirmation_required':
|
|
675
767
|
this.setInterrupt({ kind: 'confirm', toolId: str(inner.toolId), actionDescription: str(inner.actionDescription) });
|
|
676
768
|
break;
|
|
677
|
-
|
|
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:
|
|
678
814
|
break;
|
|
679
815
|
}
|
|
680
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';
|
|
@@ -97,6 +253,8 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
97
253
|
private hasSent = false;
|
|
98
254
|
/** Starter prompts shown as chips in the empty state. */
|
|
99
255
|
private examplePrompts: string[] = [];
|
|
256
|
+
/** Whether mid-conversation suggested-reply chips are shown (config). */
|
|
257
|
+
private showSuggestedReplies = true;
|
|
100
258
|
/** Resolved greeting text, cached so async (rAF) renders can reuse it. */
|
|
101
259
|
private greeting = '';
|
|
102
260
|
/** Current mid-turn interrupt (OTP / tool-confirmation), or null. */
|
|
@@ -117,6 +275,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
117
275
|
private dotEl: HTMLElement | null = null;
|
|
118
276
|
private inputEl: HTMLTextAreaElement | null = null;
|
|
119
277
|
private sendBtn: HTMLButtonElement | null = null;
|
|
278
|
+
private suggestionsEl: HTMLElement | null = null;
|
|
120
279
|
|
|
121
280
|
// ── Smooth streaming reveal ──
|
|
122
281
|
// Tokens arrive in variable-size bursts at uneven rates, so revealing text in
|
|
@@ -209,6 +368,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
209
368
|
startOpen: this.overrides.startOpen ?? this.hasAttribute('start-open'),
|
|
210
369
|
hideBranding: this.overrides.hideBranding ?? this.hasAttribute('hide-branding'),
|
|
211
370
|
examplePrompts: this.overrides.examplePrompts,
|
|
371
|
+
showSuggestedReplies: this.overrides.showSuggestedReplies,
|
|
212
372
|
requireName: this.overrides.requireName,
|
|
213
373
|
requireEmail: this.overrides.requireEmail,
|
|
214
374
|
requirePhone: this.overrides.requirePhone,
|
|
@@ -247,10 +407,14 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
247
407
|
onInterrupt: (interrupt) => {
|
|
248
408
|
this.interrupt = interrupt;
|
|
249
409
|
this.renderInterrupt();
|
|
410
|
+
// Suggestion chips are hidden while an interrupt overlay is up.
|
|
411
|
+
this.renderSuggestions();
|
|
250
412
|
},
|
|
251
413
|
onIdentityRestore: (state) => {
|
|
252
414
|
this.identityRestore = state;
|
|
253
415
|
this.renderInterrupt();
|
|
416
|
+
// The restore overlay reuses the interrupt slot — hide chips too.
|
|
417
|
+
this.renderSuggestions();
|
|
254
418
|
},
|
|
255
419
|
});
|
|
256
420
|
if (resolved.startOpen) this.open = true;
|
|
@@ -303,6 +467,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
303
467
|
|
|
304
468
|
// Remember starter prompts + greeting for the empty-state chips / async renders.
|
|
305
469
|
this.examplePrompts = resolved.examplePrompts;
|
|
470
|
+
this.showSuggestedReplies = resolved.showSuggestedReplies;
|
|
306
471
|
this.greeting = resolved.greeting;
|
|
307
472
|
|
|
308
473
|
// Gate the conversation behind a pre-chat identity form when required.
|
|
@@ -348,6 +513,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
348
513
|
const footerHtml = footerInner ? `<div class="footer">${footerInner}</div>` : '';
|
|
349
514
|
const chatHtml = `
|
|
350
515
|
<div class="messages"></div>
|
|
516
|
+
<div class="reply-suggestions"></div>
|
|
351
517
|
<div class="interrupt hidden"></div>
|
|
352
518
|
<div class="composer-wrap">
|
|
353
519
|
<div class="composer">
|
|
@@ -385,6 +551,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
385
551
|
this.inputEl = container.querySelector('textarea');
|
|
386
552
|
this.sendBtn = container.querySelector('.send');
|
|
387
553
|
this.interruptEl = container.querySelector('.interrupt');
|
|
554
|
+
this.suggestionsEl = container.querySelector('.reply-suggestions');
|
|
388
555
|
|
|
389
556
|
this.launcherEl?.addEventListener('click', () => this.openChat());
|
|
390
557
|
container.querySelector('.close')?.addEventListener('click', () => this.closeChat());
|
|
@@ -469,21 +636,38 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
469
636
|
head.className = 'int-head';
|
|
470
637
|
const ico = document.createElement('span');
|
|
471
638
|
ico.className = 'int-ico';
|
|
472
|
-
|
|
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
|
|
473
648
|
const title = document.createElement('span');
|
|
474
649
|
title.className = 'int-title';
|
|
475
|
-
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';
|
|
476
651
|
head.append(ico, title);
|
|
477
652
|
card.appendChild(head);
|
|
478
653
|
|
|
479
|
-
if (it.actionDescription) {
|
|
654
|
+
if (it.kind !== 'interaction' && it.actionDescription) {
|
|
480
655
|
const desc = document.createElement('div');
|
|
481
656
|
desc.className = 'int-desc';
|
|
482
657
|
desc.textContent = it.actionDescription;
|
|
483
658
|
card.appendChild(desc);
|
|
484
659
|
}
|
|
485
660
|
|
|
486
|
-
if (it.kind === '
|
|
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') {
|
|
487
671
|
if (it.sent?.maskedDestination) {
|
|
488
672
|
const sent = document.createElement('div');
|
|
489
673
|
sent.className = 'int-sent';
|
|
@@ -936,6 +1120,39 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
936
1120
|
}
|
|
937
1121
|
}
|
|
938
1122
|
this.scrollToBottom(true);
|
|
1123
|
+
this.renderSuggestions();
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Render (or clear) the mid-conversation suggested-reply chips under the
|
|
1128
|
+
* composer. Shown ONLY when: the feature is enabled, no interrupt / restore
|
|
1129
|
+
* overlay is active (those reuse the slot above the composer), and the LATEST
|
|
1130
|
+
* message is a finalized assistant turn that carried suggestions. A new turn
|
|
1131
|
+
* (agent or the visitor typing) appends messages, so the latest is no longer
|
|
1132
|
+
* that assistant message and the chips clear on the next render. Chips reuse
|
|
1133
|
+
* the empty-state `.prompts`/`.chip` styling and send via the same path.
|
|
1134
|
+
*/
|
|
1135
|
+
private renderSuggestions(): void {
|
|
1136
|
+
const el = this.suggestionsEl;
|
|
1137
|
+
if (!el) return;
|
|
1138
|
+
el.replaceChildren();
|
|
1139
|
+
if (!this.showSuggestedReplies) return;
|
|
1140
|
+
// Hidden while an OTP / tool-confirmation / restore overlay is active.
|
|
1141
|
+
if (this.interrupt || this.identityRestore.phase !== 'idle') return;
|
|
1142
|
+
const last = this.messages[this.messages.length - 1];
|
|
1143
|
+
if (!last || last.role !== 'assistant' || last.streaming || !last.suggestions || last.suggestions.length === 0) return;
|
|
1144
|
+
|
|
1145
|
+
const chips = document.createElement('div');
|
|
1146
|
+
chips.className = 'prompts';
|
|
1147
|
+
for (const suggestion of last.suggestions) {
|
|
1148
|
+
const chip = document.createElement('button');
|
|
1149
|
+
chip.type = 'button';
|
|
1150
|
+
chip.className = 'chip';
|
|
1151
|
+
chip.textContent = suggestion;
|
|
1152
|
+
chip.addEventListener('click', () => this.submitPrompt(suggestion));
|
|
1153
|
+
chips.appendChild(chip);
|
|
1154
|
+
}
|
|
1155
|
+
el.appendChild(chips);
|
|
939
1156
|
}
|
|
940
1157
|
|
|
941
1158
|
// ─────────────────────── Smooth streaming reveal ───────────────────────────
|
package/src/styles.ts
CHANGED
|
@@ -605,6 +605,10 @@ ${
|
|
|
605
605
|
transform: translateY(-1px);
|
|
606
606
|
}
|
|
607
607
|
|
|
608
|
+
/* ───────────── Mid-conversation suggested-reply chips ─────────────── */
|
|
609
|
+
.reply-suggestions { padding: 0 14px 4px; }
|
|
610
|
+
.reply-suggestions:empty { display: none; }
|
|
611
|
+
|
|
608
612
|
/* ─────────────── OTP / tool-confirmation interrupt ────────────────── */
|
|
609
613
|
.interrupt { padding: 0 14px; }
|
|
610
614
|
.int-card {
|
|
@@ -639,6 +643,9 @@ ${
|
|
|
639
643
|
border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent);
|
|
640
644
|
box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent);
|
|
641
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; }
|
|
642
649
|
.int-btn {
|
|
643
650
|
border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
|
|
644
651
|
background: var(--sac-surface-2);
|
|
@@ -658,8 +665,14 @@ ${
|
|
|
658
665
|
color: var(--sac-primary-text);
|
|
659
666
|
box-shadow: 0 6px 14px -6px color-mix(in srgb, var(--sac-primary) 65%, transparent);
|
|
660
667
|
}
|
|
661
|
-
.int-row .int-
|
|
662
|
-
.int-
|
|
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; }
|
|
663
676
|
.int-error { margin-top: 8px; font-size: 12px; color: #f87171; }
|
|
664
677
|
.int-card { position: relative; }
|
|
665
678
|
.int-close {
|