@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/src/element.ts
CHANGED
|
@@ -17,7 +17,16 @@
|
|
|
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 {
|
|
20
|
+
import {
|
|
21
|
+
type ChatMessage,
|
|
22
|
+
type Citation,
|
|
23
|
+
type ConnectionStatus,
|
|
24
|
+
ConversationController,
|
|
25
|
+
type IdentityRestore,
|
|
26
|
+
type Interrupt,
|
|
27
|
+
SUPPORTED_INTERACTION_CAPABILITIES,
|
|
28
|
+
type ToolCall,
|
|
29
|
+
} from './conversation.js';
|
|
21
30
|
import { SMOOTH_ICON_SVG } from './logo.js';
|
|
22
31
|
import { cleanCitationSnippet, escapeHtml, renderMarkdown, safeHttpUrl } from './markdown.js';
|
|
23
32
|
import { buildStyles } from './styles.js';
|
|
@@ -52,7 +61,7 @@ function phoneToE164(value: string): string | null {
|
|
|
52
61
|
/** Public smooth-operator repo — the "powered by" header tag + footer link here. */
|
|
53
62
|
const SMOOTH_OPERATOR_URL = 'https://github.com/SmooAI/smooth-operator';
|
|
54
63
|
|
|
55
|
-
const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode', 'hide-branding'] as const;
|
|
64
|
+
const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode', 'hide-branding', 'show-tool-activity'] as const;
|
|
56
65
|
|
|
57
66
|
/**
|
|
58
67
|
* Inline SVG icons (static, trusted strings — never interpolated with user data).
|
|
@@ -71,10 +80,168 @@ const ICON = {
|
|
|
71
80
|
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
81
|
/** OTP interrupt — a padlock. */
|
|
73
82
|
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>`,
|
|
83
|
+
/** Identity-intake interrupt — a person. */
|
|
84
|
+
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
85
|
/** Tool-confirmation interrupt — a shield. */
|
|
75
86
|
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>`,
|
|
87
|
+
/** Tool-activity chip — a wrench. */
|
|
88
|
+
tool: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14.7 6.3a3.5 3.5 0 0 0-4.6 4.3l-5 5a1.6 1.6 0 0 0 2.3 2.3l5-5a3.5 3.5 0 0 0 4.3-4.6l-2 2-1.7-.3-.3-1.7 2-2Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>`,
|
|
76
89
|
} as const;
|
|
77
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The Rich Interactions **card registry**: interaction kind → overlay card.
|
|
93
|
+
* `interaction_required { kind }` looks its card up here; registering a card IS
|
|
94
|
+
* declaring the widget's render capability for that kind (see
|
|
95
|
+
* `SUPPORTED_INTERACTION_CAPABILITIES` in conversation.ts — a test keeps the
|
|
96
|
+
* two aligned). Adding a kind = one card builder + one entry.
|
|
97
|
+
*
|
|
98
|
+
* The existing OTP and tool-approval overlays are prior instances of this same
|
|
99
|
+
* shape and should retrofit onto this registry later (their wire events predate
|
|
100
|
+
* the pattern).
|
|
101
|
+
*/
|
|
102
|
+
export interface InteractionCardContext {
|
|
103
|
+
/** Submit kind-shaped values (resumes the parked turn; server validates). */
|
|
104
|
+
submit: (values: Record<string, unknown>) => void;
|
|
105
|
+
/** Decline the interaction (the agent continues without it). */
|
|
106
|
+
decline: () => void;
|
|
107
|
+
/** Persisted visitor identity, for pre-filling known fields. */
|
|
108
|
+
prefill: { name?: string; email?: string; phone?: string };
|
|
109
|
+
/** Wire live phone formatting + validity hint onto a form's phone input. */
|
|
110
|
+
wirePhoneField: (form: HTMLFormElement) => void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface InteractionCard {
|
|
114
|
+
/** The render capability this card provides (goes into `supports`). */
|
|
115
|
+
capability: string;
|
|
116
|
+
/** Card header title. */
|
|
117
|
+
title: string;
|
|
118
|
+
/** Static, trusted header icon SVG. */
|
|
119
|
+
icon: string;
|
|
120
|
+
/** Build the card body for an `interaction` interrupt. */
|
|
121
|
+
build: (it: Extract<Interrupt, { kind: 'interaction' }>, ctx: InteractionCardContext) => HTMLElement;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The identity_intake card: the fields the agent requested (pre-chat form field
|
|
126
|
+
* pattern — same classes, same phone formatting), per-field server errors, a
|
|
127
|
+
* submit and a decline affordance. Server-supplied text (reason, labels, error
|
|
128
|
+
* messages) is set via `textContent` — never innerHTML.
|
|
129
|
+
*/
|
|
130
|
+
function buildIdentityIntakeCard(it: Extract<Interrupt, { kind: 'interaction' }>, ctx: InteractionCardContext): HTMLElement {
|
|
131
|
+
const form = document.createElement('form');
|
|
132
|
+
form.className = 'int-form';
|
|
133
|
+
form.noValidate = true;
|
|
134
|
+
|
|
135
|
+
if (it.reason) {
|
|
136
|
+
const desc = document.createElement('div');
|
|
137
|
+
desc.className = 'int-desc';
|
|
138
|
+
desc.textContent = it.reason;
|
|
139
|
+
form.appendChild(desc);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const DEFAULTS: Record<string, { label: string; type: string; autocomplete: string }> = {
|
|
143
|
+
name: { label: 'Name', type: 'text', autocomplete: 'name' },
|
|
144
|
+
email: { label: 'Email', type: 'email', autocomplete: 'email' },
|
|
145
|
+
phone: { label: 'Phone', type: 'tel', autocomplete: 'tel' },
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Parse the kind's spec defensively: `{ fields: [{ key, required, label? }] }`.
|
|
149
|
+
const rawFields = Array.isArray(it.spec.fields) ? it.spec.fields : [];
|
|
150
|
+
const fields: { key: 'name' | 'email' | 'phone'; required: boolean; label?: string }[] = [];
|
|
151
|
+
for (const f of rawFields) {
|
|
152
|
+
if (!f || typeof f !== 'object') continue;
|
|
153
|
+
const o = f as Record<string, unknown>;
|
|
154
|
+
const key = typeof o.key === 'string' ? o.key : '';
|
|
155
|
+
if (key !== 'name' && key !== 'email' && key !== 'phone') continue;
|
|
156
|
+
fields.push({ key, required: o.required === true, label: typeof o.label === 'string' ? o.label : undefined });
|
|
157
|
+
}
|
|
158
|
+
if (fields.length === 0) fields.push({ key: 'email', required: true });
|
|
159
|
+
|
|
160
|
+
for (const f of fields) {
|
|
161
|
+
const d = DEFAULTS[f.key]!;
|
|
162
|
+
const label = document.createElement('label');
|
|
163
|
+
label.className = 'pc-field';
|
|
164
|
+
const caption = document.createElement('span');
|
|
165
|
+
caption.textContent = f.label ?? d.label;
|
|
166
|
+
const input = document.createElement('input');
|
|
167
|
+
input.name = f.key;
|
|
168
|
+
input.type = d.type;
|
|
169
|
+
input.setAttribute('autocomplete', d.autocomplete);
|
|
170
|
+
input.required = f.required;
|
|
171
|
+
const prefill = ctx.prefill[f.key];
|
|
172
|
+
if (prefill) input.value = prefill;
|
|
173
|
+
label.append(caption, input);
|
|
174
|
+
if (f.key === 'phone') {
|
|
175
|
+
const hint = document.createElement('span');
|
|
176
|
+
hint.className = 'pc-hint';
|
|
177
|
+
hint.setAttribute('aria-live', 'polite');
|
|
178
|
+
label.appendChild(hint);
|
|
179
|
+
}
|
|
180
|
+
// Per-field server-side validation error (interaction_invalid).
|
|
181
|
+
const serverError = it.errors?.find((e) => e.field === f.key);
|
|
182
|
+
if (serverError) {
|
|
183
|
+
label.classList.add('invalid');
|
|
184
|
+
const err = document.createElement('span');
|
|
185
|
+
err.className = 'int-error';
|
|
186
|
+
err.textContent = serverError.message;
|
|
187
|
+
label.appendChild(err);
|
|
188
|
+
}
|
|
189
|
+
form.appendChild(label);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const row = document.createElement('div');
|
|
193
|
+
row.className = 'int-row';
|
|
194
|
+
const decline = document.createElement('button');
|
|
195
|
+
decline.className = 'int-btn';
|
|
196
|
+
decline.type = 'button';
|
|
197
|
+
decline.textContent = 'No thanks';
|
|
198
|
+
decline.addEventListener('click', () => ctx.decline());
|
|
199
|
+
const share = document.createElement('button');
|
|
200
|
+
share.className = 'int-btn primary';
|
|
201
|
+
share.type = 'submit';
|
|
202
|
+
share.textContent = 'Share details';
|
|
203
|
+
row.append(decline, share);
|
|
204
|
+
form.appendChild(row);
|
|
205
|
+
|
|
206
|
+
form.addEventListener('submit', (ev) => {
|
|
207
|
+
ev.preventDefault();
|
|
208
|
+
if (!form.reportValidity()) return;
|
|
209
|
+
const data = new FormData(form);
|
|
210
|
+
const val = (k: string) => ((data.get(k) as string | null)?.trim() || undefined);
|
|
211
|
+
|
|
212
|
+
// Phone: mirror the pre-chat rules — block a required-but-invalid number,
|
|
213
|
+
// prefer canonical E.164 when it parses (the server re-validates anyway).
|
|
214
|
+
const rawPhone = val('phone');
|
|
215
|
+
const phoneInput = form.querySelector('input[name="phone"]') as HTMLInputElement | null;
|
|
216
|
+
if (rawPhone && phoneInput && !isValidPhoneNumber(rawPhone, PHONE_DEFAULT_REGION)) {
|
|
217
|
+
const field = phoneInput.closest('.pc-field');
|
|
218
|
+
field?.classList.add('invalid');
|
|
219
|
+
const hint = field?.querySelector('.pc-hint');
|
|
220
|
+
if (hint) hint.textContent = 'Enter a valid phone number';
|
|
221
|
+
if (phoneInput.hasAttribute('required')) {
|
|
222
|
+
phoneInput.focus();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const phone = rawPhone ? (phoneToE164(rawPhone) ?? rawPhone) : undefined;
|
|
227
|
+
ctx.submit({ name: val('name'), email: val('email'), phone });
|
|
228
|
+
});
|
|
229
|
+
// Same live phone formatting + validity hint as the pre-chat form.
|
|
230
|
+
ctx.wirePhoneField(form);
|
|
231
|
+
queueMicrotask(() => (form.querySelector('input') as HTMLInputElement | null)?.focus());
|
|
232
|
+
return form;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Kind → card. See the registry doc above. */
|
|
236
|
+
export const INTERACTION_CARDS: Record<string, InteractionCard> = {
|
|
237
|
+
identity_intake: {
|
|
238
|
+
capability: 'identity_form',
|
|
239
|
+
title: 'Share your details',
|
|
240
|
+
icon: ICON.user,
|
|
241
|
+
build: buildIdentityIntakeCard,
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
|
|
78
245
|
// `safeHttpUrl` / `escapeHtml` live in `./markdown.js` (the markdown renderer
|
|
79
246
|
// needs them too); re-exported here for back-compat with existing importers.
|
|
80
247
|
export { escapeHtml, safeHttpUrl } from './markdown.js';
|
|
@@ -136,6 +303,10 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
136
303
|
/** How many chars of {@link streamTarget} are currently shown. */
|
|
137
304
|
private displayedLength = 0;
|
|
138
305
|
private rafId = 0;
|
|
306
|
+
/** Block structure signature of the last-rendered streaming message (tool chips
|
|
307
|
+
* only — text growth doesn't change it), so a chip add/resolve forces a rebuild
|
|
308
|
+
* while plain trailing-text growth stays on the fast reveal path. */
|
|
309
|
+
private prevBlockSig = '';
|
|
139
310
|
|
|
140
311
|
constructor() {
|
|
141
312
|
super();
|
|
@@ -220,6 +391,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
220
391
|
collectConsent: this.overrides.collectConsent,
|
|
221
392
|
allowChatRestore: this.overrides.allowChatRestore,
|
|
222
393
|
allowAnonymous: this.overrides.allowAnonymous,
|
|
394
|
+
showToolActivity: this.overrides.showToolActivity ?? this.hasAttribute('show-tool-activity'),
|
|
223
395
|
theme,
|
|
224
396
|
};
|
|
225
397
|
}
|
|
@@ -480,21 +652,38 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
480
652
|
head.className = 'int-head';
|
|
481
653
|
const ico = document.createElement('span');
|
|
482
654
|
ico.className = 'int-ico';
|
|
483
|
-
|
|
655
|
+
const card_meta = it.kind === 'interaction' ? INTERACTION_CARDS[it.interactionKind] : undefined;
|
|
656
|
+
if (it.kind === 'interaction' && !card_meta) {
|
|
657
|
+
// A kind we have no card for (shouldn't happen — we only declare
|
|
658
|
+
// capabilities for registered cards). Decline so the turn never hangs.
|
|
659
|
+
this.controller?.declineInteraction();
|
|
660
|
+
el.classList.add('hidden');
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
ico.innerHTML = it.kind === 'otp' ? ICON.lock : it.kind === 'interaction' ? (card_meta?.icon ?? ICON.user) : ICON.shield; // static, trusted
|
|
484
664
|
const title = document.createElement('span');
|
|
485
665
|
title.className = 'int-title';
|
|
486
|
-
title.textContent = it.kind === 'otp' ? 'Verification required' : 'Confirm this action';
|
|
666
|
+
title.textContent = it.kind === 'otp' ? 'Verification required' : it.kind === 'interaction' ? (card_meta?.title ?? 'One more thing') : 'Confirm this action';
|
|
487
667
|
head.append(ico, title);
|
|
488
668
|
card.appendChild(head);
|
|
489
669
|
|
|
490
|
-
if (it.actionDescription) {
|
|
670
|
+
if (it.kind !== 'interaction' && it.actionDescription) {
|
|
491
671
|
const desc = document.createElement('div');
|
|
492
672
|
desc.className = 'int-desc';
|
|
493
673
|
desc.textContent = it.actionDescription;
|
|
494
674
|
card.appendChild(desc);
|
|
495
675
|
}
|
|
496
676
|
|
|
497
|
-
if (it.kind === '
|
|
677
|
+
if (it.kind === 'interaction') {
|
|
678
|
+
card.appendChild(
|
|
679
|
+
card_meta!.build(it, {
|
|
680
|
+
submit: (values) => this.controller?.submitInteraction(values),
|
|
681
|
+
decline: () => this.controller?.declineInteraction(),
|
|
682
|
+
prefill: this.controller?.getStore().getState().identity ?? {},
|
|
683
|
+
wirePhoneField: (form) => this.wirePhoneField(form),
|
|
684
|
+
}),
|
|
685
|
+
);
|
|
686
|
+
} else if (it.kind === 'otp') {
|
|
498
687
|
if (it.sent?.maskedDestination) {
|
|
499
688
|
const sent = document.createElement('div');
|
|
500
689
|
sent.className = 'int-sent';
|
|
@@ -873,14 +1062,19 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
873
1062
|
// finalize transition (streaming → done) needs the markdown render + sources
|
|
874
1063
|
last.streaming !== prevLast.streaming ||
|
|
875
1064
|
// first token after the typing indicator needs the bubble swapped in
|
|
876
|
-
(!!last.streaming && !prevLast.text && !!last.text)
|
|
1065
|
+
(!!last.streaming && !prevLast.text && !!last.text) ||
|
|
1066
|
+
// a tool chip was added or resolved (text growth alone doesn't change this)
|
|
1067
|
+
this.blockSig(last) !== this.prevBlockSig;
|
|
877
1068
|
|
|
878
1069
|
this.messages = messages;
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
//
|
|
883
|
-
|
|
1070
|
+
this.prevBlockSig = this.blockSig(last);
|
|
1071
|
+
|
|
1072
|
+
if (!structural && last && last.streaming && this.tailKey(last) === this.streamMsgId) {
|
|
1073
|
+
// Fast path: the streaming (trailing) text just grew. Bump the reveal
|
|
1074
|
+
// target and let the rAF loop catch up — no DOM rebuild, no per-token
|
|
1075
|
+
// reflow. `tailText` is the live trailing text block for a tool turn, or
|
|
1076
|
+
// the whole message for a plain-prose turn.
|
|
1077
|
+
this.streamTarget = this.tailText(last);
|
|
884
1078
|
this.ensureRevealLoop();
|
|
885
1079
|
return;
|
|
886
1080
|
}
|
|
@@ -888,6 +1082,32 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
888
1082
|
this.renderMessages();
|
|
889
1083
|
}
|
|
890
1084
|
|
|
1085
|
+
/** True when an assistant message's turn invoked at least one tool. */
|
|
1086
|
+
private hasToolBlocks(m?: ChatMessage): boolean {
|
|
1087
|
+
return !!m?.blocks?.some((b) => b.kind === 'tool');
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** Signature that changes only when a tool chip is added or resolved (text growth is 'x'). */
|
|
1091
|
+
private blockSig(m?: ChatMessage): string {
|
|
1092
|
+
if (!m?.blocks) return '';
|
|
1093
|
+
return m.blocks.map((b) => (b.kind === 'tool' ? `t:${b.tool.id}:${b.tool.done ? 1 : 0}` : 'x')).join('|');
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** The live (last) text block for a tool turn, else the whole message text. */
|
|
1097
|
+
private tailText(m: ChatMessage): string {
|
|
1098
|
+
if (this.hasToolBlocks(m) && m.blocks) {
|
|
1099
|
+
const last = m.blocks[m.blocks.length - 1];
|
|
1100
|
+
return last?.kind === 'text' ? last.text : '';
|
|
1101
|
+
}
|
|
1102
|
+
return m.text;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/** Reveal-binding key — composite for a tool-turn tail (so a new trailing block rebinds), else msg id. */
|
|
1106
|
+
private tailKey(m: ChatMessage): string {
|
|
1107
|
+
if (this.hasToolBlocks(m) && m.blocks) return `${m.id}#${m.blocks.length - 1}`;
|
|
1108
|
+
return m.id;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
891
1111
|
private renderMessages(): void {
|
|
892
1112
|
if (!this.messagesEl) return;
|
|
893
1113
|
this.resetReveal();
|
|
@@ -914,6 +1134,16 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
914
1134
|
}
|
|
915
1135
|
|
|
916
1136
|
for (const msg of this.messages) {
|
|
1137
|
+
// Tool-activity turns (gated by `showToolActivity`) render as an ordered
|
|
1138
|
+
// strip of prose bubbles + inline tool chips instead of one bubble.
|
|
1139
|
+
if (msg.role === 'assistant' && this.hasToolBlocks(msg)) {
|
|
1140
|
+
this.messagesEl.appendChild(this.buildRow('assistant', this.renderAssistantBlocks(msg)));
|
|
1141
|
+
if (!msg.streaming && msg.citations && msg.citations.length > 0) {
|
|
1142
|
+
this.messagesEl.appendChild(this.renderSources(msg.citations));
|
|
1143
|
+
}
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
917
1147
|
const bubble = document.createElement('div');
|
|
918
1148
|
bubble.className = `bubble ${msg.role}`;
|
|
919
1149
|
if (msg.role === 'assistant' && msg.streaming && !msg.text) {
|
|
@@ -995,22 +1225,89 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
995
1225
|
* doesn't restart the reveal from zero), then resumes the loop.
|
|
996
1226
|
*/
|
|
997
1227
|
private bindReveal(msg: ChatMessage, bubble: HTMLElement): void {
|
|
998
|
-
|
|
1228
|
+
// `tailKey`/`tailText` collapse to the message id + full text for a plain
|
|
1229
|
+
// prose turn, and to the live trailing text block for a tool turn — so both
|
|
1230
|
+
// the single streaming bubble and a tool turn's trailing prose share this path.
|
|
1231
|
+
const key = this.tailKey(msg);
|
|
1232
|
+
const target = this.tailText(msg);
|
|
1233
|
+
const carryOver = key === this.streamMsgId ? Math.min(this.displayedLength, target.length) : 0;
|
|
999
1234
|
this.streamBubbleEl = bubble;
|
|
1000
|
-
this.streamMsgId =
|
|
1001
|
-
this.streamTarget =
|
|
1235
|
+
this.streamMsgId = key;
|
|
1236
|
+
this.streamTarget = target;
|
|
1002
1237
|
this.displayedLength = carryOver;
|
|
1003
1238
|
|
|
1004
1239
|
if (this.prefersReducedMotion()) {
|
|
1005
1240
|
// No animation: show everything immediately.
|
|
1006
|
-
this.displayedLength =
|
|
1007
|
-
bubble.textContent =
|
|
1241
|
+
this.displayedLength = target.length;
|
|
1242
|
+
bubble.textContent = target;
|
|
1008
1243
|
return;
|
|
1009
1244
|
}
|
|
1010
|
-
bubble.textContent =
|
|
1245
|
+
bubble.textContent = target.slice(0, this.displayedLength);
|
|
1011
1246
|
this.ensureRevealLoop();
|
|
1012
1247
|
}
|
|
1013
1248
|
|
|
1249
|
+
/**
|
|
1250
|
+
* Render a tool-activity assistant turn as an ordered strip: prose bubbles and
|
|
1251
|
+
* inline tool chips in the order the model produced them (mirrors the daemon
|
|
1252
|
+
* SPA's `blocks`). The live trailing text block (while streaming) binds to the
|
|
1253
|
+
* rAF reveal; earlier/finalized text blocks render as sanitized markdown.
|
|
1254
|
+
*/
|
|
1255
|
+
private renderAssistantBlocks(msg: ChatMessage): HTMLElement {
|
|
1256
|
+
const wrap = document.createElement('div');
|
|
1257
|
+
wrap.className = 'blocks';
|
|
1258
|
+
const blocks = msg.blocks ?? [];
|
|
1259
|
+
const lastIdx = blocks.length - 1;
|
|
1260
|
+
blocks.forEach((block, i) => {
|
|
1261
|
+
if (block.kind === 'tool') {
|
|
1262
|
+
wrap.appendChild(this.buildToolChip(block.tool));
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
const bubble = document.createElement('div');
|
|
1266
|
+
bubble.className = 'bubble assistant';
|
|
1267
|
+
if (msg.streaming && i === lastIdx) {
|
|
1268
|
+
// Live trailing prose → plain text + cursor, driven by the reveal loop.
|
|
1269
|
+
bubble.classList.add('cursor');
|
|
1270
|
+
this.bindReveal(msg, bubble);
|
|
1271
|
+
} else {
|
|
1272
|
+
// Settled prose → sanitized markdown (same allowlisted renderer as bubbles).
|
|
1273
|
+
bubble.classList.add('md');
|
|
1274
|
+
bubble.innerHTML = renderMarkdown(block.text);
|
|
1275
|
+
}
|
|
1276
|
+
wrap.appendChild(bubble);
|
|
1277
|
+
});
|
|
1278
|
+
return wrap;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* A single tool-activity chip: icon + tool name + status (running… / done / error),
|
|
1283
|
+
* with a truncated args preview. Tool name/args are set via `textContent` so a
|
|
1284
|
+
* tool payload can never inject markup.
|
|
1285
|
+
*/
|
|
1286
|
+
private buildToolChip(tool: ToolCall): HTMLElement {
|
|
1287
|
+
const chip = document.createElement('div');
|
|
1288
|
+
chip.className = `toolchip ${tool.done ? (tool.isError ? 'error' : 'done') : 'running'}`;
|
|
1289
|
+
chip.setAttribute('part', 'tool-chip');
|
|
1290
|
+
|
|
1291
|
+
const icon = document.createElement('span');
|
|
1292
|
+
icon.className = 'ti';
|
|
1293
|
+
icon.innerHTML = ICON.tool; // static, trusted
|
|
1294
|
+
const name = document.createElement('span');
|
|
1295
|
+
name.className = 'tn';
|
|
1296
|
+
name.textContent = tool.name || 'tool';
|
|
1297
|
+
const status = document.createElement('span');
|
|
1298
|
+
status.className = 'ts';
|
|
1299
|
+
status.textContent = tool.done ? (tool.isError ? 'error' : 'done') : 'running…';
|
|
1300
|
+
chip.append(icon, name, status);
|
|
1301
|
+
|
|
1302
|
+
if (tool.args && tool.args !== '{}' && tool.args !== '""') {
|
|
1303
|
+
const args = document.createElement('span');
|
|
1304
|
+
args.className = 'ta';
|
|
1305
|
+
args.textContent = tool.args.length > 80 ? `${tool.args.slice(0, 80)}…` : tool.args;
|
|
1306
|
+
chip.appendChild(args);
|
|
1307
|
+
}
|
|
1308
|
+
return chip;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1014
1311
|
/** Start the rAF loop if it isn't already running. */
|
|
1015
1312
|
private ensureRevealLoop(): void {
|
|
1016
1313
|
if (this.prefersReducedMotion() || typeof requestAnimationFrame !== 'function') {
|
package/src/index.ts
CHANGED
package/src/styles.ts
CHANGED
|
@@ -29,6 +29,7 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove
|
|
|
29
29
|
--sac-bg: ${theme.background};
|
|
30
30
|
--sac-primary: ${theme.primary};
|
|
31
31
|
--sac-primary-text: ${theme.primaryText};
|
|
32
|
+
--sac-secondary: ${theme.secondary};
|
|
32
33
|
--sac-assistant-bubble: ${theme.assistantBubble};
|
|
33
34
|
--sac-assistant-bubble-text: ${theme.assistantBubbleText};
|
|
34
35
|
--sac-user-bubble: ${theme.userBubble};
|
|
@@ -349,6 +350,47 @@ ${
|
|
|
349
350
|
}
|
|
350
351
|
@keyframes sac-blink { to { opacity: 0 } }
|
|
351
352
|
|
|
353
|
+
/* Interleaved tool-activity strip (gated by showToolActivity): prose bubbles
|
|
354
|
+
and inline tool chips stacked in the order the model produced them. */
|
|
355
|
+
.blocks { display: flex; flex-direction: column; align-items: flex-start; gap: 7px; }
|
|
356
|
+
.blocks .bubble { align-self: flex-start; }
|
|
357
|
+
.toolchip {
|
|
358
|
+
display: inline-flex;
|
|
359
|
+
align-items: center;
|
|
360
|
+
gap: 6px;
|
|
361
|
+
max-width: 100%;
|
|
362
|
+
padding: 5px 10px;
|
|
363
|
+
border-radius: 99px;
|
|
364
|
+
font-size: 12px;
|
|
365
|
+
line-height: 1.3;
|
|
366
|
+
color: color-mix(in srgb, var(--sac-text) 78%, transparent);
|
|
367
|
+
background: color-mix(in srgb, var(--sac-text) 6%, transparent);
|
|
368
|
+
border: 1px solid color-mix(in srgb, var(--sac-text) 12%, transparent);
|
|
369
|
+
animation: sac-msg-in .3s var(--sac-ease) both;
|
|
370
|
+
}
|
|
371
|
+
.toolchip .ti { display: inline-flex; flex: none; opacity: .8; }
|
|
372
|
+
.toolchip .ti svg { width: 13px; height: 13px; }
|
|
373
|
+
.toolchip .tn { font-weight: 600; letter-spacing: .01em; }
|
|
374
|
+
.toolchip .ts { opacity: .7; }
|
|
375
|
+
.toolchip .ta {
|
|
376
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
377
|
+
font-size: 11px;
|
|
378
|
+
opacity: .6;
|
|
379
|
+
overflow: hidden;
|
|
380
|
+
text-overflow: ellipsis;
|
|
381
|
+
white-space: nowrap;
|
|
382
|
+
min-width: 0;
|
|
383
|
+
}
|
|
384
|
+
.toolchip.running { border-color: color-mix(in srgb, var(--sac-primary) 45%, transparent); }
|
|
385
|
+
.toolchip.running .ts { color: var(--sac-primary); opacity: 1; animation: sac-typing 1.3s ease-in-out infinite; }
|
|
386
|
+
.toolchip.done .ts::before { content: '✓ '; }
|
|
387
|
+
.toolchip.error {
|
|
388
|
+
color: var(--sac-secondary);
|
|
389
|
+
border-color: color-mix(in srgb, var(--sac-secondary) 50%, transparent);
|
|
390
|
+
background: color-mix(in srgb, var(--sac-secondary) 10%, transparent);
|
|
391
|
+
}
|
|
392
|
+
.toolchip.error .ts::before { content: '! '; }
|
|
393
|
+
|
|
352
394
|
/* ─────────────── Rendered markdown (assistant bubbles / snippets) ─────────── */
|
|
353
395
|
/* The renderer (markdown.ts) emits a small allowlisted set of tags; these rules
|
|
354
396
|
keep them legible inside the tight Aurora-Glass bubble + citation card. */
|
|
@@ -643,6 +685,9 @@ ${
|
|
|
643
685
|
border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent);
|
|
644
686
|
box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent);
|
|
645
687
|
}
|
|
688
|
+
.int-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
|
|
689
|
+
.int-form .pc-field input { width: 100%; box-sizing: border-box; }
|
|
690
|
+
.int-form .int-error { margin-top: 2px; }
|
|
646
691
|
.int-btn {
|
|
647
692
|
border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
|
|
648
693
|
background: var(--sac-surface-2);
|
|
@@ -662,8 +707,14 @@ ${
|
|
|
662
707
|
color: var(--sac-primary-text);
|
|
663
708
|
box-shadow: 0 6px 14px -6px color-mix(in srgb, var(--sac-primary) 65%, transparent);
|
|
664
709
|
}
|
|
665
|
-
.int-row .int-
|
|
666
|
-
.int-
|
|
710
|
+
.int-row .int-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
|
|
711
|
+
.int-form .pc-field input { width: 100%; box-sizing: border-box; }
|
|
712
|
+
.int-form .int-error { margin-top: 2px; }
|
|
713
|
+
.int-btn { flex: 1; }
|
|
714
|
+
.int-row .int-input + .int-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
|
|
715
|
+
.int-form .pc-field input { width: 100%; box-sizing: border-box; }
|
|
716
|
+
.int-form .int-error { margin-top: 2px; }
|
|
717
|
+
.int-btn { flex: 0 0 auto; }
|
|
667
718
|
.int-error { margin-top: 8px; font-size: 12px; color: #f87171; }
|
|
668
719
|
.int-card { position: relative; }
|
|
669
720
|
.int-close {
|