@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/README.md +1 -0
- package/dist/chat-widget.global.js +473 -21
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +333 -236
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +454 -18
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/config.ts +11 -0
- package/src/conversation.ts +205 -3
- package/src/element.ts +315 -18
- package/src/index.ts +2 -0
- package/src/styles.ts +53 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smooai/chat-widget",
|
|
3
|
-
"version": "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.
|
|
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',
|
package/src/conversation.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|