@smooai/chat-widget 0.10.1 → 0.11.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.10.1",
3
+ "version": "0.11.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",
package/src/config.ts CHANGED
@@ -95,11 +95,23 @@ export interface ChatWidgetConfig {
95
95
  connectionErrorMessage?: string;
96
96
  /** Start the panel open instead of collapsed to the launcher. */
97
97
  startOpen?: boolean;
98
+ /**
99
+ * Hide the "powered by smooth-operator" branding in the header tag and the
100
+ * composer footer. Defaults to `false` (branding shown). The `hide-branding`
101
+ * HTML attribute maps to this.
102
+ */
103
+ hideBranding?: boolean;
98
104
  /**
99
105
  * Suggested starter prompts shown as clickable chips before the first message.
100
106
  * Clicking one sends it. Capped at 5 for layout.
101
107
  */
102
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;
103
115
  /** Require the visitor's name before chatting. */
104
116
  requireName?: boolean;
105
117
  /** Require the visitor's email before chatting. */
@@ -172,7 +184,9 @@ export function resolveConfig(config: ChatWidgetConfig): ResolvedConfig {
172
184
  greeting: config.greeting ?? 'Hi! How can I help you today?',
173
185
  connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
174
186
  startOpen: config.startOpen ?? false,
187
+ hideBranding: config.hideBranding ?? false,
175
188
  examplePrompts: (config.examplePrompts ?? []).filter((p) => p.trim().length > 0).slice(0, 5),
189
+ showSuggestedReplies: config.showSuggestedReplies ?? true,
176
190
  requireName: config.requireName ?? false,
177
191
  requireEmail: config.requireEmail ?? false,
178
192
  requirePhone: config.requirePhone ?? false,
@@ -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';
@@ -183,6 +190,20 @@ function extractCitations(inner: unknown): Citation[] {
183
190
  return out;
184
191
  }
185
192
 
193
+ /**
194
+ * Pull the suggested follow-up replies out of a terminal `eventual_response`'s
195
+ * `response` object (`response.suggestedNextActions`). Optional + back-compatible
196
+ * like citations — read defensively (tolerating absence, non-array shapes, and
197
+ * non-string / blank entries) and capped at 4 so a chatty agent can't overflow
198
+ * the composer chip row.
199
+ */
200
+ function extractSuggestions(response: unknown): string[] {
201
+ if (!response || typeof response !== 'object') return [];
202
+ const raw = (response as { suggestedNextActions?: unknown }).suggestedNextActions;
203
+ if (!Array.isArray(raw)) return [];
204
+ return raw.filter((s): s is string => typeof s === 'string' && s.trim().length > 0).slice(0, 4);
205
+ }
206
+
186
207
  /** A `get_conversation_messages` row, narrowed defensively off the wire. */
187
208
  interface WireMessage {
188
209
  id?: string;
@@ -623,6 +644,11 @@ export class ConversationController {
623
644
  if (citations.length > 0) {
624
645
  assistant.citations = citations;
625
646
  }
647
+ // Suggested follow-up replies from the terminal event, when present.
648
+ const suggestions = extractSuggestions(inner?.response);
649
+ if (suggestions.length > 0) {
650
+ assistant.suggestions = suggestions;
651
+ }
626
652
  assistant.streaming = false;
627
653
  this.emitMessages();
628
654
  } catch (err) {
package/src/element.ts CHANGED
@@ -49,7 +49,10 @@ function phoneToE164(value: string): string | null {
49
49
  }
50
50
  }
51
51
 
52
- const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode'] as const;
52
+ /** Public smooth-operator repo the "powered by" header tag + footer link here. */
53
+ const SMOOTH_OPERATOR_URL = 'https://github.com/SmooAI/smooth-operator';
54
+
55
+ const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode', 'hide-branding'] as const;
53
56
 
54
57
  /**
55
58
  * Inline SVG icons (static, trusted strings — never interpolated with user data).
@@ -94,6 +97,8 @@ export class SmoothAgentChatElement extends HTMLElement {
94
97
  private hasSent = false;
95
98
  /** Starter prompts shown as chips in the empty state. */
96
99
  private examplePrompts: string[] = [];
100
+ /** Whether mid-conversation suggested-reply chips are shown (config). */
101
+ private showSuggestedReplies = true;
97
102
  /** Resolved greeting text, cached so async (rAF) renders can reuse it. */
98
103
  private greeting = '';
99
104
  /** Current mid-turn interrupt (OTP / tool-confirmation), or null. */
@@ -114,6 +119,7 @@ export class SmoothAgentChatElement extends HTMLElement {
114
119
  private dotEl: HTMLElement | null = null;
115
120
  private inputEl: HTMLTextAreaElement | null = null;
116
121
  private sendBtn: HTMLButtonElement | null = null;
122
+ private suggestionsEl: HTMLElement | null = null;
117
123
 
118
124
  // ── Smooth streaming reveal ──
119
125
  // Tokens arrive in variable-size bursts at uneven rates, so revealing text in
@@ -204,7 +210,9 @@ export class SmoothAgentChatElement extends HTMLElement {
204
210
  greeting: this.overrides.greeting ?? this.getAttribute('greeting') ?? undefined,
205
211
  connectionErrorMessage: this.overrides.connectionErrorMessage,
206
212
  startOpen: this.overrides.startOpen ?? this.hasAttribute('start-open'),
213
+ hideBranding: this.overrides.hideBranding ?? this.hasAttribute('hide-branding'),
207
214
  examplePrompts: this.overrides.examplePrompts,
215
+ showSuggestedReplies: this.overrides.showSuggestedReplies,
208
216
  requireName: this.overrides.requireName,
209
217
  requireEmail: this.overrides.requireEmail,
210
218
  requirePhone: this.overrides.requirePhone,
@@ -243,10 +251,14 @@ export class SmoothAgentChatElement extends HTMLElement {
243
251
  onInterrupt: (interrupt) => {
244
252
  this.interrupt = interrupt;
245
253
  this.renderInterrupt();
254
+ // Suggestion chips are hidden while an interrupt overlay is up.
255
+ this.renderSuggestions();
246
256
  },
247
257
  onIdentityRestore: (state) => {
248
258
  this.identityRestore = state;
249
259
  this.renderInterrupt();
260
+ // The restore overlay reuses the interrupt slot — hide chips too.
261
+ this.renderSuggestions();
250
262
  },
251
263
  });
252
264
  if (resolved.startOpen) this.open = true;
@@ -282,7 +294,11 @@ export class SmoothAgentChatElement extends HTMLElement {
282
294
  <span class="title">${escapeHtml(resolved.agentName)}</span>
283
295
  <span class="status"><span class="dot off"></span><span class="status-text"></span></span>
284
296
  </div>
285
- <span class="powered">powered by smooth-operator</span>
297
+ ${
298
+ resolved.hideBranding
299
+ ? ''
300
+ : `<a class="powered" href="${SMOOTH_OPERATOR_URL}" target="_blank" rel="noopener noreferrer">powered by smooth-operator</a>`
301
+ }
286
302
  </div>`
287
303
  : `<div class="header">
288
304
  <div class="avatar">${monogram}</div>
@@ -295,6 +311,7 @@ export class SmoothAgentChatElement extends HTMLElement {
295
311
 
296
312
  // Remember starter prompts + greeting for the empty-state chips / async renders.
297
313
  this.examplePrompts = resolved.examplePrompts;
314
+ this.showSuggestedReplies = resolved.showSuggestedReplies;
298
315
  this.greeting = resolved.greeting;
299
316
 
300
317
  // Gate the conversation behind a pre-chat identity form when required.
@@ -329,16 +346,25 @@ export class SmoothAgentChatElement extends HTMLElement {
329
346
  <button type="submit" class="pc-submit">Start chat</button>
330
347
  </form>
331
348
  </div>`;
332
- const restoreLink = this.allowChatRestore ? ` · <button type="button" class="restore-link">Restore my chats</button>` : '';
349
+ // Footer: optional "powered by" branding (hidden by hide-branding) and an
350
+ // optional "Restore my chats" affordance. The " · " separator only appears
351
+ // when both are present, and the footer is omitted entirely when neither is.
352
+ const brandingHtml = resolved.hideBranding
353
+ ? ''
354
+ : `<a href="${SMOOTH_OPERATOR_URL}" target="_blank" rel="noopener noreferrer">powered by <b>smooth&#8209;operator</b></a>`;
355
+ const restoreBtn = this.allowChatRestore ? `<button type="button" class="restore-link">Restore my chats</button>` : '';
356
+ const footerInner = [brandingHtml, restoreBtn].filter(Boolean).join(' · ');
357
+ const footerHtml = footerInner ? `<div class="footer">${footerInner}</div>` : '';
333
358
  const chatHtml = `
334
359
  <div class="messages"></div>
360
+ <div class="reply-suggestions"></div>
335
361
  <div class="interrupt hidden"></div>
336
362
  <div class="composer-wrap">
337
363
  <div class="composer">
338
364
  <textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
339
365
  <button class="send" type="button" aria-label="Send message">${ICON.send}</button>
340
366
  </div>
341
- <div class="footer">powered by <b>smooth&#8209;operator</b>${restoreLink}</div>
367
+ ${footerHtml}
342
368
  </div>`;
343
369
 
344
370
  const container = document.createElement('div');
@@ -369,6 +395,7 @@ export class SmoothAgentChatElement extends HTMLElement {
369
395
  this.inputEl = container.querySelector('textarea');
370
396
  this.sendBtn = container.querySelector('.send');
371
397
  this.interruptEl = container.querySelector('.interrupt');
398
+ this.suggestionsEl = container.querySelector('.reply-suggestions');
372
399
 
373
400
  this.launcherEl?.addEventListener('click', () => this.openChat());
374
401
  container.querySelector('.close')?.addEventListener('click', () => this.closeChat());
@@ -920,6 +947,39 @@ export class SmoothAgentChatElement extends HTMLElement {
920
947
  }
921
948
  }
922
949
  this.scrollToBottom(true);
950
+ this.renderSuggestions();
951
+ }
952
+
953
+ /**
954
+ * Render (or clear) the mid-conversation suggested-reply chips under the
955
+ * composer. Shown ONLY when: the feature is enabled, no interrupt / restore
956
+ * overlay is active (those reuse the slot above the composer), and the LATEST
957
+ * message is a finalized assistant turn that carried suggestions. A new turn
958
+ * (agent or the visitor typing) appends messages, so the latest is no longer
959
+ * that assistant message and the chips clear on the next render. Chips reuse
960
+ * the empty-state `.prompts`/`.chip` styling and send via the same path.
961
+ */
962
+ private renderSuggestions(): void {
963
+ const el = this.suggestionsEl;
964
+ if (!el) return;
965
+ el.replaceChildren();
966
+ if (!this.showSuggestedReplies) return;
967
+ // Hidden while an OTP / tool-confirmation / restore overlay is active.
968
+ if (this.interrupt || this.identityRestore.phase !== 'idle') return;
969
+ const last = this.messages[this.messages.length - 1];
970
+ if (!last || last.role !== 'assistant' || last.streaming || !last.suggestions || last.suggestions.length === 0) return;
971
+
972
+ const chips = document.createElement('div');
973
+ chips.className = 'prompts';
974
+ for (const suggestion of last.suggestions) {
975
+ const chip = document.createElement('button');
976
+ chip.type = 'button';
977
+ chip.className = 'chip';
978
+ chip.textContent = suggestion;
979
+ chip.addEventListener('click', () => this.submitPrompt(suggestion));
980
+ chips.appendChild(chip);
981
+ }
982
+ el.appendChild(chips);
923
983
  }
924
984
 
925
985
  // ─────────────────────── Smooth streaming reveal ───────────────────────────
package/src/styles.ts CHANGED
@@ -239,7 +239,8 @@ ${
239
239
  }
240
240
  .close:hover { background: color-mix(in srgb, var(--sac-text) 12%, transparent); transform: translateY(1px); }
241
241
  .close svg { width: 16px; height: 16px; opacity: .8; }
242
- .powered { margin-left: auto; font-size: 10.5px; letter-spacing: .02em; opacity: .6; }
242
+ .powered { margin-left: auto; font-size: 10.5px; letter-spacing: .02em; opacity: .6; color: inherit; text-decoration: none; }
243
+ .powered:hover { opacity: .85; text-decoration: underline; }
243
244
  .header-sep { height: 1px; margin: 0 16px; background: linear-gradient(90deg, transparent, var(--sac-border), transparent); }
244
245
 
245
246
  /* Full-page header: taller, logo-led, no close. */
@@ -511,6 +512,8 @@ ${
511
512
  color: color-mix(in srgb, var(--sac-text) 38%, transparent);
512
513
  }
513
514
  .footer b { font-weight: 600; color: color-mix(in srgb, var(--sac-text) 55%, transparent); }
515
+ .footer a { color: inherit; text-decoration: none; }
516
+ .footer a:hover { text-decoration: underline; }
514
517
 
515
518
  /* ─────────────────── Pre-chat identity form ───────────────────────── */
516
519
  .prechat { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 18px; padding: 22px 20px; }
@@ -602,6 +605,10 @@ ${
602
605
  transform: translateY(-1px);
603
606
  }
604
607
 
608
+ /* ───────────── Mid-conversation suggested-reply chips ─────────────── */
609
+ .reply-suggestions { padding: 0 14px 4px; }
610
+ .reply-suggestions:empty { display: none; }
611
+
605
612
  /* ─────────────── OTP / tool-confirmation interrupt ────────────────── */
606
613
  .interrupt { padding: 0 14px; }
607
614
  .int-card {