@smooai/chat-widget 0.11.0 → 0.13.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.13.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
  },
package/src/config.ts CHANGED
@@ -141,6 +141,16 @@ export interface ChatWidgetConfig {
141
141
  * `require*` flags are ignored and the pre-chat form is skipped.
142
142
  */
143
143
  allowAnonymous?: boolean;
144
+ /**
145
+ * Show the agent's tool activity (grep / read_file / bash / knowledge_search…)
146
+ * as inline chips interleaved with its prose, mirroring the smooth daemon SPA.
147
+ *
148
+ * Defaults to **`false`**: for a customer-facing support widget, surfacing raw
149
+ * tool calls to an end-user is usually undesirable, so tool activity is hidden
150
+ * and only the assistant's prose renders. Enable it for internal / power-user
151
+ * surfaces where seeing what the agent did mid-turn is valuable.
152
+ */
153
+ showToolActivity?: boolean;
144
154
  /** Theme overrides. */
145
155
  theme?: ChatWidgetTheme;
146
156
  }
@@ -194,6 +204,7 @@ export function resolveConfig(config: ChatWidgetConfig): ResolvedConfig {
194
204
  collectConsent: config.collectConsent ?? true,
195
205
  allowChatRestore: config.allowChatRestore ?? true,
196
206
  allowAnonymous: config.allowAnonymous ?? false,
207
+ showToolActivity: config.showToolActivity ?? false,
197
208
  theme: {
198
209
  text: theme.text ?? '#f8fafc',
199
210
  background: theme.background ?? '#040d30',
@@ -61,6 +61,32 @@ export type { Citation };
61
61
 
62
62
  export type Role = 'user' | 'assistant';
63
63
 
64
+ /**
65
+ * One tool invocation within an assistant turn. Mirrors the smooth daemon SPA's
66
+ * `ToolCall` (`crates/smooth-web/web/src/operator.ts`): opens `done: false` on the
67
+ * tool call and resolves on the tool result.
68
+ */
69
+ export interface ToolCall {
70
+ /** Stable id for keyed rendering (assigned when the call opens). */
71
+ id: string;
72
+ name: string;
73
+ /** Raw arguments, JSON-stringified. */
74
+ args: string;
75
+ /** Present once the tool resolves. */
76
+ result?: string;
77
+ isError?: boolean;
78
+ done: boolean;
79
+ }
80
+
81
+ /**
82
+ * One ordered segment of an assistant turn: a run of prose, or a tool call.
83
+ * Preserves the interleave order the model produced (say a bit → call a tool →
84
+ * say a bit → …) so the UI can render tool chips INLINE where the model called
85
+ * them. Mirrors the daemon SPA's `MessageBlock`. Only populated when the widget
86
+ * is configured with `showToolActivity: true`.
87
+ */
88
+ export type MessageBlock = { kind: 'text'; text: string } | { kind: 'tool'; tool: ToolCall };
89
+
64
90
  export interface ChatMessage {
65
91
  id: string;
66
92
  role: Role;
@@ -68,6 +94,12 @@ export interface ChatMessage {
68
94
  text: string;
69
95
  /** True while an assistant message is still streaming. */
70
96
  streaming: boolean;
97
+ /**
98
+ * Ordered text + tool segments, interleaved as the model produced them. Present
99
+ * only on assistant messages when `showToolActivity` is enabled (absent
100
+ * otherwise — the default popover renders `text` alone, byte-for-byte unchanged).
101
+ */
102
+ blocks?: MessageBlock[];
71
103
  /**
72
104
  * Sources that grounded an assistant answer, when the terminal
73
105
  * `eventual_response` carried any. Optional + back-compatible: absent when
@@ -93,6 +125,9 @@ export type ConnectionStatus = 'idle' | 'connecting' | 'ready' | 'error' | 'clos
93
125
  * Resume with {@link ConversationController.verifyOtp}.
94
126
  * - `confirm` — the agent wants to run a state-mutating tool and needs approval.
95
127
  * Resume with {@link ConversationController.confirmTool}.
128
+ * - `interaction` — the agent raised a Rich Interaction (structured card, e.g.
129
+ * identity intake). Resume with {@link ConversationController.submitInteraction}
130
+ * or {@link ConversationController.declineInteraction}.
96
131
  */
97
132
  export type Interrupt =
98
133
  | {
@@ -106,7 +141,28 @@ export type Interrupt =
106
141
  error?: string;
107
142
  attemptsRemaining?: number;
108
143
  }
109
- | { kind: 'confirm'; toolId?: string; actionDescription?: string };
144
+ | { kind: 'confirm'; toolId?: string; actionDescription?: string }
145
+ | {
146
+ kind: 'interaction';
147
+ /** Server-generated interaction instance id (echoed on submit). */
148
+ interactionId: string;
149
+ /** The Rich Interaction kind (e.g. `identity_intake`) — selects the card. */
150
+ interactionKind: string;
151
+ /** Kind-specific render spec (identity_intake: `{ fields: [...] }`). */
152
+ spec: Record<string, unknown>;
153
+ /** Why the agent raised it (card header copy). */
154
+ reason?: string;
155
+ /** Per-field server-side validation errors (from `interaction_invalid`). */
156
+ errors?: { field: string; message: string }[];
157
+ };
158
+
159
+ /**
160
+ * The Rich-Interaction render capabilities this widget declares at session
161
+ * create (`supports`). Must stay aligned with the card registry in
162
+ * `element.ts` (`INTERACTION_CARDS`) — registering a card IS declaring its
163
+ * capability; a test asserts the two match.
164
+ */
165
+ export const SUPPORTED_INTERACTION_CAPABILITIES: readonly string[] = ['identity_form'];
110
166
 
111
167
  export interface UserInfo {
112
168
  name?: string;
@@ -220,6 +276,52 @@ function wireMessageToChat(m: WireMessage, idx: number): ChatMessage | null {
220
276
  return { id: typeof m.id === 'string' ? m.id : `hist-${idx}`, role, text, streaming: false };
221
277
  }
222
278
 
279
+ let toolSeq = 0;
280
+ const nextToolId = (): string => `tool-${++toolSeq}`;
281
+
282
+ /** Grow the trailing text block, or open a new one if the last block was a tool. */
283
+ function growTextBlock(blocks: MessageBlock[], text: string): void {
284
+ if (!text) return;
285
+ const last = blocks[blocks.length - 1];
286
+ if (last && last.kind === 'text') last.text += text;
287
+ else blocks.push({ kind: 'text', text });
288
+ }
289
+
290
+ /**
291
+ * Fold a `stream_chunk` node-state into the ordered block list, returning `true`
292
+ * when the chunk carried tool activity.
293
+ *
294
+ * Tool activity rides `state.rawResponse.toolCall` / `state.rawResponse.toolResult`
295
+ * — **NOT** `state.toolResult`. Reading the wrong path leaves every chip stuck on
296
+ * "running…" forever (the exact bug the daemon SPA hit and this mirror avoids).
297
+ */
298
+ function applyToolChunk(blocks: MessageBlock[], state: unknown): boolean {
299
+ const raw = (state as { rawResponse?: unknown } | null | undefined)?.rawResponse;
300
+ if (!raw || typeof raw !== 'object') return false;
301
+ const call = (raw as { toolCall?: { name?: string; arguments?: unknown } }).toolCall;
302
+ const res = (raw as { toolResult?: { name?: string; isError?: boolean; result?: unknown } }).toolResult;
303
+ if (call) {
304
+ const args = typeof call.arguments === 'string' ? call.arguments : JSON.stringify(call.arguments ?? {});
305
+ blocks.push({ kind: 'tool', tool: { id: nextToolId(), name: call.name ?? '', args, done: false } });
306
+ return true;
307
+ }
308
+ if (res) {
309
+ const result = typeof res.result === 'string' ? res.result : JSON.stringify(res.result ?? '');
310
+ // Complete the most-recent still-open tool block matching this name.
311
+ for (let i = blocks.length - 1; i >= 0; i--) {
312
+ const b = blocks[i];
313
+ if (b && b.kind === 'tool' && b.tool.name === (res.name ?? '') && !b.tool.done) {
314
+ b.tool.done = true;
315
+ b.tool.isError = !!res.isError;
316
+ b.tool.result = result;
317
+ break;
318
+ }
319
+ }
320
+ return true;
321
+ }
322
+ return false;
323
+ }
324
+
223
325
  export class ConversationController {
224
326
  private readonly config: ChatWidgetConfig;
225
327
  private readonly events: ConversationEvents;
@@ -232,6 +334,9 @@ export class ConversationController {
232
334
  /** requestId of the in-flight turn — used to resume OTP / tool confirmations. */
233
335
  private activeRequestId: string | null = null;
234
336
  private interrupt: Interrupt | null = null;
337
+ /** Values from the last interaction submit, merged into the persisted
338
+ * identity (identity_intake only) once the server acks them. */
339
+ private pendingInteractionValues: { kind: string; values: Record<string, unknown> } | null = null;
235
340
  private identityRestore: IdentityRestore = { phase: 'idle' };
236
341
  /**
237
342
  * True once the resume probe (persisted-pointer get_session OR the
@@ -337,6 +442,41 @@ export class ConversationController {
337
442
  this.setInterrupt(null);
338
443
  }
339
444
 
445
+ /**
446
+ * Submit a Rich Interaction card to resume the parked turn. The server
447
+ * routes to the kind's validator: invalid values come back as an
448
+ * `interaction_invalid` event (the card re-renders with per-field errors —
449
+ * the turn stays parked); a valid submit is acked and the turn resumes.
450
+ * No-op if not awaiting an interaction.
451
+ */
452
+ submitInteraction(values: Record<string, unknown>): void {
453
+ if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== 'interaction') return;
454
+ // Stash the values so the ack (immediate_response) can merge accepted
455
+ // identity values into the persisted store.
456
+ this.pendingInteractionValues = { kind: this.interrupt.interactionKind, values };
457
+ this.client.submitInteraction({
458
+ sessionId: this.sessionId,
459
+ requestId: this.activeRequestId,
460
+ interactionId: this.interrupt.interactionId,
461
+ kind: this.interrupt.interactionKind,
462
+ values,
463
+ });
464
+ }
465
+
466
+ /** Decline the pending Rich Interaction; the agent continues without it. */
467
+ declineInteraction(): void {
468
+ if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== 'interaction') return;
469
+ this.client.submitInteraction({
470
+ sessionId: this.sessionId,
471
+ requestId: this.activeRequestId,
472
+ interactionId: this.interrupt.interactionId,
473
+ kind: this.interrupt.interactionKind,
474
+ declined: true,
475
+ });
476
+ this.pendingInteractionValues = null;
477
+ this.setInterrupt(null);
478
+ }
479
+
340
480
  private nextId(prefix: string): string {
341
481
  this.seq += 1;
342
482
  return `${prefix}-${this.seq}-${Date.now().toString(36)}`;
@@ -543,6 +683,10 @@ export class ConversationController {
543
683
  userName: state.identity.name,
544
684
  userEmail: state.identity.email,
545
685
  browserFingerprint: this.fingerprint(),
686
+ // Declare the Rich-Interaction cards this widget can render (derived
687
+ // from the card registry), so the server emits `interaction_required`
688
+ // for those kinds instead of the conversational fallback.
689
+ supports: [...SUPPORTED_INTERACTION_CAPABILITIES],
546
690
  ...(metadata ? { metadata } : {}),
547
691
  });
548
692
  this.sessionId = session.sessionId;
@@ -608,7 +752,8 @@ export class ConversationController {
608
752
  this.messages.push({ id: this.nextId('u'), role: 'user', text: trimmed, streaming: false });
609
753
 
610
754
  // 2. Placeholder assistant bubble we grow as tokens arrive.
611
- const assistant: ChatMessage = { id: this.nextId('a'), role: 'assistant', text: '', streaming: true };
755
+ const showTools = this.config.showToolActivity === true;
756
+ const assistant: ChatMessage = { id: this.nextId('a'), role: 'assistant', text: '', streaming: true, blocks: showTools ? [] : undefined };
612
757
  this.messages.push(assistant);
613
758
  this.emitMessages();
614
759
 
@@ -621,6 +766,14 @@ export class ConversationController {
621
766
  const token = event.token ?? event.data?.token ?? '';
622
767
  if (token) {
623
768
  assistant.text += token;
769
+ // Grow the trailing text block so prose interleaves with any
770
+ // tool chips in the order the model produced them.
771
+ if (showTools && assistant.blocks) growTextBlock(assistant.blocks, token);
772
+ this.emitMessages();
773
+ }
774
+ } else if (showTools && event.type === 'stream_chunk') {
775
+ // Tool activity (gated). Read state.rawResponse.toolCall/.toolResult.
776
+ if (assistant.blocks && applyToolChunk(assistant.blocks, event.data?.state)) {
624
777
  this.emitMessages();
625
778
  }
626
779
  } else {
@@ -649,6 +802,11 @@ export class ConversationController {
649
802
  if (suggestions.length > 0) {
650
803
  assistant.suggestions = suggestions;
651
804
  }
805
+ // Only keep blocks for turns that actually invoked a tool — a prose-only
806
+ // turn drops back to the normal markdown text path (with the final text).
807
+ if (assistant.blocks && !assistant.blocks.some((b) => b.kind === 'tool')) {
808
+ assistant.blocks = undefined;
809
+ }
652
810
  assistant.streaming = false;
653
811
  this.emitMessages();
654
812
  } catch (err) {
@@ -700,7 +858,51 @@ export class ConversationController {
700
858
  case 'write_confirmation_required':
701
859
  this.setInterrupt({ kind: 'confirm', toolId: str(inner.toolId), actionDescription: str(inner.actionDescription) });
702
860
  break;
703
- default:
861
+ case 'interaction_required': {
862
+ const interactionId = str(inner.interactionId);
863
+ const kind = str(inner.kind);
864
+ const spec = inner.spec && typeof inner.spec === 'object' ? (inner.spec as Record<string, unknown>) : {};
865
+ if (!interactionId || !kind) break; // not renderable — ignore
866
+ this.pendingInteractionValues = null;
867
+ this.setInterrupt({
868
+ kind: 'interaction',
869
+ interactionId,
870
+ interactionKind: kind,
871
+ spec,
872
+ reason: str(inner.reason),
873
+ });
874
+ break;
875
+ }
876
+ case 'interaction_invalid':
877
+ if (this.interrupt?.kind === 'interaction' && this.interrupt.interactionId === str(inner.interactionId)) {
878
+ const errors: { field: string; message: string }[] = [];
879
+ if (Array.isArray(inner.errors)) {
880
+ for (const e of inner.errors) {
881
+ if (!e || typeof e !== 'object') continue;
882
+ const o = e as Record<string, unknown>;
883
+ const field = str(o.field);
884
+ if (field) errors.push({ field, message: str(o.message) ?? 'Invalid value' });
885
+ }
886
+ }
887
+ this.pendingInteractionValues = null;
888
+ this.setInterrupt({ ...this.interrupt, errors });
889
+ }
890
+ break;
891
+ case 'immediate_response':
892
+ // Mid-turn immediate_response while an interaction card is showing
893
+ // is the submit/decline ack: the park resolved — clear the card
894
+ // and, for accepted identity values, persist them.
895
+ if (this.interrupt?.kind === 'interaction') {
896
+ const pending = this.pendingInteractionValues;
897
+ if (pending && pending.kind === 'identity_intake') {
898
+ const v = pending.values as { name?: string; email?: string; phone?: string };
899
+ this.store.getState().mergeIdentity({ name: v.name, email: v.email, phone: v.phone });
900
+ }
901
+ this.pendingInteractionValues = null;
902
+ this.setInterrupt(null);
903
+ }
904
+ break;
905
+ default:
704
906
  break;
705
907
  }
706
908
  }