@smooai/chat-widget 0.3.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.
@@ -0,0 +1,1802 @@
1
+ var SmoothAgentChat = (function(exports) {
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ //#region src/config.ts
4
+ /** Resolve a partial config against the built-in defaults. */
5
+ function resolveConfig(config) {
6
+ const theme = config.theme ?? {};
7
+ const primary = theme.primary ?? "#00a6a6";
8
+ const primaryText = theme.primaryText ?? "#f8fafc";
9
+ const assistantBubble = theme.chatBubbleInbound ?? theme.assistantBubble ?? "#06134b";
10
+ const assistantBubbleText = theme.chatBubbleInboundText ?? theme.assistantBubbleText ?? "#f8fafc";
11
+ const userBubble = theme.chatBubbleOutbound ?? theme.userBubble ?? primary;
12
+ const userBubbleText = theme.chatBubbleOutboundText ?? theme.userBubbleText ?? primaryText;
13
+ return {
14
+ endpoint: config.endpoint,
15
+ mode: config.mode ?? "popover",
16
+ agentId: config.agentId,
17
+ agentName: config.agentName ?? "Assistant",
18
+ userName: config.userName,
19
+ userEmail: config.userEmail,
20
+ userPhone: config.userPhone,
21
+ placeholder: config.placeholder ?? "Type a message…",
22
+ greeting: config.greeting ?? "Hi! How can I help you today?",
23
+ connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
24
+ startOpen: config.startOpen ?? false,
25
+ examplePrompts: (config.examplePrompts ?? []).filter((p) => p.trim().length > 0).slice(0, 5),
26
+ requireName: config.requireName ?? false,
27
+ requireEmail: config.requireEmail ?? false,
28
+ requirePhone: config.requirePhone ?? false,
29
+ allowAnonymous: config.allowAnonymous ?? false,
30
+ theme: {
31
+ text: theme.text ?? "#f8fafc",
32
+ background: theme.background ?? "#040d30",
33
+ primary,
34
+ primaryText,
35
+ secondary: theme.secondary ?? "#ff6b6c",
36
+ assistantBubble,
37
+ assistantBubbleText,
38
+ userBubble,
39
+ userBubbleText,
40
+ border: theme.border ?? "rgba(255, 255, 255, 0.1)"
41
+ }
42
+ };
43
+ }
44
+ /**
45
+ * Whether the pre-chat identity form should gate the conversation: at least one
46
+ * field is required and anonymous chat is not allowed.
47
+ */
48
+ function needsUserInfo(resolved) {
49
+ return !resolved.allowAnonymous && (resolved.requireName || resolved.requireEmail || resolved.requirePhone);
50
+ }
51
+ //#endregion
52
+ //#region node_modules/.pnpm/@smooai+smooth-operator@0.2.0/node_modules/@smooai/smooth-operator/dist/transport.js
53
+ /**
54
+ * Transport abstraction for the client.
55
+ *
56
+ * The client is deliberately decoupled from any concrete WebSocket implementation
57
+ * so it can be unit-tested with a mock and run on Node, the browser, or a custom
58
+ * socket. A transport is anything that can send a string frame and surface
59
+ * incoming string frames + lifecycle events.
60
+ */
61
+ const WS_CONNECTING = 0;
62
+ const WS_OPEN = 1;
63
+ const WS_CLOSING = 2;
64
+ /** Default connect timeout (ms) for the WebSocket transport. */
65
+ const DEFAULT_CONNECT_TIMEOUT = 3e4;
66
+ /**
67
+ * Default transport backed by a `WebSocket`-like object. By default it uses the
68
+ * global `WebSocket`; pass a `factory` to inject one (e.g. the `ws` package on
69
+ * Node, or a mock in tests).
70
+ */
71
+ var WebSocketTransport = class {
72
+ socket = null;
73
+ url;
74
+ factory;
75
+ connectTimeout;
76
+ messageHandlers = /* @__PURE__ */ new Set();
77
+ closeHandlers = /* @__PURE__ */ new Set();
78
+ errorHandlers = /* @__PURE__ */ new Set();
79
+ constructor(url, factory, connectTimeout = DEFAULT_CONNECT_TIMEOUT) {
80
+ this.url = url;
81
+ this.connectTimeout = connectTimeout;
82
+ if (factory) this.factory = factory;
83
+ else {
84
+ const G = globalThis;
85
+ if (!G.WebSocket) throw new Error("No global WebSocket available; pass a WebSocketFactory to WebSocketTransport.");
86
+ const Ctor = G.WebSocket;
87
+ this.factory = (u) => new Ctor(u);
88
+ }
89
+ }
90
+ get state() {
91
+ if (!this.socket) return "closed";
92
+ switch (this.socket.readyState) {
93
+ case WS_CONNECTING: return "connecting";
94
+ case WS_OPEN: return "open";
95
+ case WS_CLOSING: return "closing";
96
+ default: return "closed";
97
+ }
98
+ }
99
+ connect() {
100
+ if (this.socket && this.socket.readyState === WS_OPEN) return Promise.resolve();
101
+ if (this.socket && this.socket.readyState !== WS_OPEN) {
102
+ const stale = this.socket;
103
+ this.socket = null;
104
+ try {
105
+ stale.close();
106
+ } catch {}
107
+ }
108
+ return new Promise((resolve, reject) => {
109
+ const socket = this.factory(this.url);
110
+ this.socket = socket;
111
+ let settled = false;
112
+ const timer = this.connectTimeout > 0 ? setTimeout(() => {
113
+ if (settled) return;
114
+ settled = true;
115
+ if (this.socket === socket) this.socket = null;
116
+ try {
117
+ socket.close();
118
+ } catch {}
119
+ reject(/* @__PURE__ */ new Error(`WebSocket connect to ${this.url} timed out after ${this.connectTimeout}ms`));
120
+ }, this.connectTimeout) : void 0;
121
+ socket.addEventListener("open", () => {
122
+ if (this.socket !== socket) return;
123
+ if (settled) return;
124
+ settled = true;
125
+ if (timer) clearTimeout(timer);
126
+ resolve();
127
+ });
128
+ socket.addEventListener("error", (ev) => {
129
+ if (this.socket !== socket) return;
130
+ for (const h of this.errorHandlers) h(ev);
131
+ if (!settled && this.state !== "open") {
132
+ settled = true;
133
+ if (timer) clearTimeout(timer);
134
+ if (this.socket === socket) this.socket = null;
135
+ try {
136
+ socket.close();
137
+ } catch {}
138
+ reject(ev instanceof Error ? ev : /* @__PURE__ */ new Error("WebSocket connection error"));
139
+ }
140
+ });
141
+ socket.addEventListener("close", (ev) => {
142
+ if (this.socket !== socket) return;
143
+ if (timer) clearTimeout(timer);
144
+ for (const h of this.closeHandlers) h({
145
+ code: ev.code,
146
+ reason: ev.reason
147
+ });
148
+ });
149
+ socket.addEventListener("message", (ev) => {
150
+ if (this.socket !== socket) return;
151
+ const data = typeof ev.data === "string" ? ev.data : String(ev.data);
152
+ for (const h of this.messageHandlers) h(data);
153
+ });
154
+ });
155
+ }
156
+ send(data) {
157
+ if (!this.socket || this.socket.readyState !== WS_OPEN) throw new Error(`Cannot send: transport is "${this.state}"`);
158
+ this.socket.send(data);
159
+ }
160
+ close(code, reason) {
161
+ this.socket?.close(code, reason);
162
+ }
163
+ onMessage(handler) {
164
+ this.messageHandlers.add(handler);
165
+ return () => this.messageHandlers.delete(handler);
166
+ }
167
+ onClose(handler) {
168
+ this.closeHandlers.add(handler);
169
+ return () => this.closeHandlers.delete(handler);
170
+ }
171
+ onError(handler) {
172
+ this.errorHandlers.add(handler);
173
+ return () => this.errorHandlers.delete(handler);
174
+ }
175
+ };
176
+ //#endregion
177
+ //#region node_modules/.pnpm/@smooai+smooth-operator@0.2.0/node_modules/@smooai/smooth-operator/dist/types.js
178
+ /** Every server→client `type` discriminator value. */
179
+ const EVENT_TYPES = [
180
+ "immediate_response",
181
+ "eventual_response",
182
+ "stream_chunk",
183
+ "stream_token",
184
+ "keepalive",
185
+ "write_confirmation_required",
186
+ "otp_verification_required",
187
+ "otp_sent",
188
+ "otp_verified",
189
+ "otp_invalid",
190
+ "error",
191
+ "pong"
192
+ ];
193
+ /** True if `frame` looks like any server event (has a known `type` discriminator). */
194
+ function isServerEvent(frame) {
195
+ return typeof frame === "object" && frame !== null && "type" in frame && typeof frame.type === "string" && EVENT_TYPES.includes(frame.type);
196
+ }
197
+ //#endregion
198
+ //#region node_modules/.pnpm/@smooai+smooth-operator@0.2.0/node_modules/@smooai/smooth-operator/dist/client.js
199
+ /**
200
+ * SmoothAgentClient — a minimal, idiomatic, transport-agnostic client for the
201
+ * smooth-operator WebSocket protocol.
202
+ *
203
+ * Design goals
204
+ * ------------
205
+ * - **Transport-agnostic.** The client never touches a real socket directly; it
206
+ * talks to an injectable {@link Transport}. The default ({@link WebSocketTransport})
207
+ * uses the global `WebSocket`, but tests inject a mock and Node can inject `ws`.
208
+ * - **Request/response correlation by `requestId`.** Every action gets a generated
209
+ * `requestId`; the client routes incoming events back to the originating call.
210
+ * - **Streaming as an async iterator.** `sendMessage` returns a {@link MessageTurn}
211
+ * that is both awaitable (resolves with the terminal `eventual_response`) and
212
+ * async-iterable (yields each `stream_token` / `stream_chunk` / HITL event in
213
+ * order). This models the `stream_token`/`stream_chunk` → `eventual_response`
214
+ * flow without forcing a callback style on the caller.
215
+ * - **No live server required.** Correctness is fully unit-testable with a mock
216
+ * transport (see `test/client.test.ts`).
217
+ */
218
+ /** A timeout that yields no terminal event. */
219
+ var RequestTimeoutError = class extends Error {
220
+ constructor(requestId, ms) {
221
+ super(`Request ${requestId} timed out after ${ms}ms`);
222
+ this.name = "RequestTimeoutError";
223
+ }
224
+ };
225
+ /**
226
+ * A streaming turn that received no terminal `eventual_response` / `error` within the
227
+ * configured {@link SmoothAgentClientOptions.turnTimeout}. The turn rejects with this
228
+ * and its async iteration throws it, so a stuck server can never hang the caller.
229
+ */
230
+ var TurnTimeoutError = class extends Error {
231
+ requestId;
232
+ constructor(requestId, ms) {
233
+ super(`Turn ${requestId} timed out after ${ms}ms without a terminal response`);
234
+ this.name = "TurnTimeoutError";
235
+ this.requestId = requestId;
236
+ }
237
+ };
238
+ /** A protocol-level error event surfaced as a throwable. */
239
+ var ProtocolError = class extends Error {
240
+ code;
241
+ requestId;
242
+ constructor(code, message, requestId) {
243
+ super(message);
244
+ this.name = "ProtocolError";
245
+ this.code = code;
246
+ this.requestId = requestId;
247
+ }
248
+ };
249
+ /**
250
+ * A streaming message turn. Await it for the terminal {@link EventualResponse},
251
+ * or async-iterate it to receive every intermediate event in arrival order.
252
+ *
253
+ * ```ts
254
+ * const turn = client.sendMessage({ sessionId, message: 'hi' });
255
+ * for await (const ev of turn) {
256
+ * if (ev.type === 'stream_token') process.stdout.write(ev.token ?? '');
257
+ * }
258
+ * const final = await turn; // EventualResponse
259
+ * ```
260
+ */
261
+ var MessageTurn = class {
262
+ /** The requestId this turn is correlated on. */
263
+ requestId;
264
+ queue = [];
265
+ waiter = null;
266
+ done = false;
267
+ finalEvent = null;
268
+ error = null;
269
+ settled;
270
+ settle;
271
+ fail;
272
+ onClose;
273
+ timeoutTimer;
274
+ constructor(requestId, onClose, turnTimeout = 0) {
275
+ this.requestId = requestId;
276
+ this.onClose = onClose;
277
+ this.settled = new Promise((resolve, reject) => {
278
+ this.settle = resolve;
279
+ this.fail = reject;
280
+ });
281
+ this.settled.catch(() => {});
282
+ if (turnTimeout > 0) this.timeoutTimer = setTimeout(() => {
283
+ this.finish(null, new TurnTimeoutError(this.requestId, turnTimeout));
284
+ }, turnTimeout);
285
+ }
286
+ /** Feed an event into the turn (called by the client's dispatcher). */
287
+ push(event) {
288
+ if (this.done) return;
289
+ if (event.type === "error") {
290
+ const code = event.data?.error?.code ?? "INTERNAL_ERROR";
291
+ const message = event.data?.error?.message ?? "Unknown protocol error";
292
+ this.deliver(event);
293
+ this.finish(null, new ProtocolError(code, message, this.requestId));
294
+ return;
295
+ }
296
+ this.deliver(event);
297
+ if (event.type === "eventual_response") this.finish(event, null);
298
+ }
299
+ /** Force-close the turn (e.g. on disconnect) with an error. */
300
+ abort(err) {
301
+ if (this.done) return;
302
+ this.finish(null, err);
303
+ }
304
+ deliver(event) {
305
+ if (this.waiter) {
306
+ const w = this.waiter;
307
+ this.waiter = null;
308
+ w.resolve({
309
+ value: event,
310
+ done: false
311
+ });
312
+ } else this.queue.push(event);
313
+ }
314
+ finish(final, err) {
315
+ if (this.done) return;
316
+ this.done = true;
317
+ this.finalEvent = final;
318
+ this.error = err;
319
+ if (this.timeoutTimer) {
320
+ clearTimeout(this.timeoutTimer);
321
+ this.timeoutTimer = void 0;
322
+ }
323
+ this.onClose();
324
+ if (err) this.fail(err);
325
+ else if (final) this.settle(final);
326
+ if (this.waiter) {
327
+ const w = this.waiter;
328
+ this.waiter = null;
329
+ if (err) w.reject(err);
330
+ else w.resolve({
331
+ value: void 0,
332
+ done: true
333
+ });
334
+ }
335
+ }
336
+ [Symbol.asyncIterator]() {
337
+ return { next: () => {
338
+ if (this.queue.length > 0) return Promise.resolve({
339
+ value: this.queue.shift(),
340
+ done: false
341
+ });
342
+ if (this.done) {
343
+ if (this.error) return Promise.reject(this.error);
344
+ return Promise.resolve({
345
+ value: void 0,
346
+ done: true
347
+ });
348
+ }
349
+ return new Promise((resolve, reject) => {
350
+ this.waiter = {
351
+ resolve,
352
+ reject
353
+ };
354
+ });
355
+ } };
356
+ }
357
+ then(onfulfilled, onrejected) {
358
+ return this.settled.then(onfulfilled, onrejected);
359
+ }
360
+ };
361
+ var SmoothAgentClient = class {
362
+ transport;
363
+ generateRequestId;
364
+ requestTimeout;
365
+ turnTimeout;
366
+ /** requestId → single-response waiter (create_session, get_session, ping, …). */
367
+ pending = /* @__PURE__ */ new Map();
368
+ /** requestId → active streaming turn (send_message, and HITL resumes). */
369
+ turns = /* @__PURE__ */ new Map();
370
+ /** Unsolicited-event listeners (keepalive, server-push). */
371
+ listeners = /* @__PURE__ */ new Set();
372
+ unsubscribe = [];
373
+ constructor(options) {
374
+ this.transport = options.transport ?? new WebSocketTransport(options.url, options.webSocketFactory);
375
+ this.requestTimeout = options.requestTimeout ?? 3e4;
376
+ this.turnTimeout = options.turnTimeout ?? 12e4;
377
+ this.generateRequestId = options.generateRequestId ?? (() => `req-${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`);
378
+ this.unsubscribe.push(this.transport.onMessage((data) => this.handleFrame(data)));
379
+ this.unsubscribe.push(this.transport.onClose(() => this.failAll(/* @__PURE__ */ new Error("Transport closed"))));
380
+ }
381
+ /** Open the underlying transport. */
382
+ async connect() {
383
+ await this.transport.connect();
384
+ }
385
+ /** Close the transport and reject all in-flight work. */
386
+ disconnect(reason = "client disconnect") {
387
+ this.failAll(new Error(reason));
388
+ for (const u of this.unsubscribe) u();
389
+ this.unsubscribe = [];
390
+ this.transport.close(1e3, reason);
391
+ }
392
+ /** Subscribe to unsolicited / uncorrelated server events (e.g. keepalive). */
393
+ onEvent(listener) {
394
+ this.listeners.add(listener);
395
+ return () => this.listeners.delete(listener);
396
+ }
397
+ /** Start a new conversation session. Resolves with the session descriptor. */
398
+ async createConversationSession(req) {
399
+ return extractImmediateData(await this.request({
400
+ action: "create_conversation_session",
401
+ ...req
402
+ }));
403
+ }
404
+ /** Fetch a session snapshot by ID. */
405
+ async getSession(req) {
406
+ return extractImmediateData(await this.request({
407
+ action: "get_session",
408
+ ...req
409
+ }));
410
+ }
411
+ /** Fetch a page of conversation messages. */
412
+ async getMessages(req) {
413
+ return extractImmediateData(await this.request({
414
+ action: "get_conversation_messages",
415
+ ...req
416
+ }));
417
+ }
418
+ /** Keepalive ping. Resolves with the server timestamp from the `pong` event. */
419
+ async ping() {
420
+ const event = await this.request({ action: "ping" });
421
+ if (event.type === "pong") return event.timestamp ?? event.data?.timestamp ?? Date.now();
422
+ return Date.now();
423
+ }
424
+ /**
425
+ * Submit a user message and return a {@link MessageTurn}: await it for the
426
+ * terminal `eventual_response`, or async-iterate it for the streaming events.
427
+ */
428
+ sendMessage(req) {
429
+ const requestId = this.generateRequestId();
430
+ const turn = new MessageTurn(requestId, () => this.turns.delete(requestId), this.turnTimeout);
431
+ this.turns.set(requestId, turn);
432
+ try {
433
+ this.transport.send(JSON.stringify({
434
+ action: "send_message",
435
+ requestId,
436
+ ...req
437
+ }));
438
+ } catch (err) {
439
+ this.turns.delete(requestId);
440
+ turn.abort(err);
441
+ }
442
+ return turn;
443
+ }
444
+ /**
445
+ * Approve or reject a pending tool write, resuming the paused turn identified
446
+ * by `requestId`. The resumed streaming events flow back into the original
447
+ * {@link MessageTurn} for that `requestId`.
448
+ */
449
+ confirmToolAction(req) {
450
+ this.transport.send(JSON.stringify({
451
+ action: "confirm_tool_action",
452
+ ...req
453
+ }));
454
+ }
455
+ /**
456
+ * Submit an OTP code, resuming the paused turn identified by `requestId`.
457
+ * The resumed streaming events flow back into the original {@link MessageTurn}.
458
+ */
459
+ verifyOtp(req) {
460
+ this.transport.send(JSON.stringify({
461
+ action: "verify_otp",
462
+ ...req
463
+ }));
464
+ }
465
+ /** Send an action that expects a single correlated response event. */
466
+ request(action) {
467
+ const requestId = action.requestId ?? this.generateRequestId();
468
+ const frame = {
469
+ ...action,
470
+ requestId
471
+ };
472
+ return new Promise((resolve, reject) => {
473
+ const timer = this.requestTimeout > 0 ? setTimeout(() => {
474
+ this.pending.delete(requestId);
475
+ reject(new RequestTimeoutError(requestId, this.requestTimeout));
476
+ }, this.requestTimeout) : void 0;
477
+ this.pending.set(requestId, {
478
+ resolve,
479
+ reject,
480
+ timer
481
+ });
482
+ try {
483
+ this.transport.send(JSON.stringify(frame));
484
+ } catch (err) {
485
+ if (timer) clearTimeout(timer);
486
+ this.pending.delete(requestId);
487
+ reject(err);
488
+ }
489
+ });
490
+ }
491
+ /** Parse and route an incoming frame to the right consumer. */
492
+ handleFrame(data) {
493
+ let frame;
494
+ try {
495
+ frame = JSON.parse(data);
496
+ } catch {
497
+ return;
498
+ }
499
+ if (!isServerEvent(frame)) return;
500
+ const event = frame;
501
+ const requestId = event.requestId;
502
+ if (requestId && this.turns.has(requestId)) {
503
+ this.turns.get(requestId).push(event);
504
+ return;
505
+ }
506
+ if (requestId && this.pending.has(requestId)) {
507
+ const pending = this.pending.get(requestId);
508
+ this.pending.delete(requestId);
509
+ if (pending.timer) clearTimeout(pending.timer);
510
+ if (event.type === "error") {
511
+ const code = event.data?.error?.code ?? "INTERNAL_ERROR";
512
+ const message = event.data?.error?.message ?? "Unknown protocol error";
513
+ pending.reject(new ProtocolError(code, message, requestId));
514
+ } else pending.resolve(event);
515
+ return;
516
+ }
517
+ for (const l of this.listeners) l(event);
518
+ }
519
+ failAll(err) {
520
+ for (const [, p] of this.pending) {
521
+ if (p.timer) clearTimeout(p.timer);
522
+ p.reject(err);
523
+ }
524
+ this.pending.clear();
525
+ for (const [, turn] of this.turns) turn.abort(err);
526
+ this.turns.clear();
527
+ }
528
+ };
529
+ /** Pull the typed `data` payload out of an `immediate_response` event. */
530
+ function extractImmediateData(event) {
531
+ if (event.type === "immediate_response") return event.data;
532
+ if ("data" in event && event.data && typeof event.data === "object") return event.data;
533
+ throw new ProtocolError("UNEXPECTED_EVENT", `Expected immediate_response, got "${event.type}"`, event.requestId);
534
+ }
535
+ //#endregion
536
+ //#region src/conversation.ts
537
+ /**
538
+ * ConversationController — the bridge between the widget UI and the
539
+ * `@smooai/smooth-operator` protocol client.
540
+ *
541
+ * This is the piece that was rewired: the original smooai widget spoke to
542
+ * `@smooai/realtime`; here every protocol action goes through {@link SmoothAgentClient}.
543
+ * The wire shapes are identical (the protocol was lifted from `@smooai/realtime`),
544
+ * so the swap is purely at the client-library boundary.
545
+ *
546
+ * Flow:
547
+ * 1. `connect()` → opens the WebSocket transport and `create_conversation_session`.
548
+ * 2. `send(text)` → `send_message`, streaming `stream_token` deltas into the
549
+ * in-progress assistant message, then the terminal
550
+ * `eventual_response`.
551
+ *
552
+ * The controller is UI-agnostic: it emits typed events and the view renders them.
553
+ */
554
+ /** Pull the final assistant text out of an `eventual_response` data payload. */
555
+ function extractFinalText(response) {
556
+ if (!response || typeof response !== "object") return null;
557
+ const r = response;
558
+ if (Array.isArray(r.responseParts)) return r.responseParts.filter((p) => typeof p === "string").join("\n\n");
559
+ return null;
560
+ }
561
+ /**
562
+ * Pull the grounding {@link Citation}s out of a terminal `eventual_response`.
563
+ *
564
+ * The protocol client types these (`eventual_response.data.data.citations`),
565
+ * but they're optional and back-compatible — absent when the turn used no
566
+ * knowledge sources. We read them defensively (tolerating their total absence,
567
+ * non-array shapes, and missing fields) so a server that doesn't emit them, or
568
+ * an older client, can't break rendering. Each citation always carries
569
+ * `id`/`title`/`snippet`/`score`; `url` is present only for web-sourced docs.
570
+ */
571
+ function extractCitations(inner) {
572
+ if (!inner || typeof inner !== "object") return [];
573
+ const raw = inner.citations;
574
+ if (!Array.isArray(raw)) return [];
575
+ const out = [];
576
+ for (const c of raw) {
577
+ if (!c || typeof c !== "object") continue;
578
+ const obj = c;
579
+ const id = typeof obj.id === "string" ? obj.id : "";
580
+ const title = typeof obj.title === "string" ? obj.title : id || "Source";
581
+ const snippet = typeof obj.snippet === "string" ? obj.snippet : "";
582
+ const url = typeof obj.url === "string" && obj.url ? obj.url : void 0;
583
+ const score = typeof obj.score === "number" ? obj.score : 0;
584
+ out.push({
585
+ id,
586
+ title,
587
+ snippet,
588
+ score,
589
+ url
590
+ });
591
+ }
592
+ return out;
593
+ }
594
+ var ConversationController = class {
595
+ config;
596
+ events;
597
+ client = null;
598
+ sessionId = null;
599
+ messages = [];
600
+ status = "idle";
601
+ seq = 0;
602
+ /** Visitor identity, seeded from config and updated by the pre-chat form. */
603
+ identity;
604
+ /** requestId of the in-flight turn — used to resume OTP / tool confirmations. */
605
+ activeRequestId = null;
606
+ interrupt = null;
607
+ constructor(config, events) {
608
+ this.config = config;
609
+ this.events = events;
610
+ this.identity = {
611
+ name: config.userName,
612
+ email: config.userEmail,
613
+ phone: config.userPhone
614
+ };
615
+ }
616
+ get connectionStatus() {
617
+ return this.status;
618
+ }
619
+ /** Merge in visitor identity (from the pre-chat form). Applied on next connect. */
620
+ setUserInfo(info) {
621
+ this.identity = {
622
+ ...this.identity,
623
+ ...info
624
+ };
625
+ }
626
+ setInterrupt(interrupt) {
627
+ this.interrupt = interrupt;
628
+ this.events.onInterrupt?.(interrupt);
629
+ }
630
+ /** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */
631
+ verifyOtp(code) {
632
+ if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "otp") return;
633
+ this.client.verifyOtp({
634
+ sessionId: this.sessionId,
635
+ requestId: this.activeRequestId,
636
+ code
637
+ });
638
+ }
639
+ /** Approve or reject a pending tool write to resume the paused turn. */
640
+ confirmTool(approved) {
641
+ if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "confirm") return;
642
+ this.client.confirmToolAction({
643
+ sessionId: this.sessionId,
644
+ requestId: this.activeRequestId,
645
+ approved
646
+ });
647
+ this.setInterrupt(null);
648
+ }
649
+ nextId(prefix) {
650
+ this.seq += 1;
651
+ return `${prefix}-${this.seq}-${Date.now().toString(36)}`;
652
+ }
653
+ setStatus(status, detail) {
654
+ this.status = status;
655
+ this.events.onStatus(status, detail);
656
+ }
657
+ emitMessages() {
658
+ this.events.onMessages(this.messages.map((m) => ({ ...m })));
659
+ }
660
+ /** Open the transport and create a conversation session. Idempotent. */
661
+ async connect() {
662
+ if (this.status === "connecting" || this.status === "ready") return;
663
+ this.setStatus("connecting");
664
+ try {
665
+ this.client = new SmoothAgentClient({ url: this.config.endpoint });
666
+ await this.client.connect();
667
+ const session = await this.client.createConversationSession({
668
+ agentId: this.config.agentId,
669
+ userName: this.identity.name,
670
+ userEmail: this.identity.email,
671
+ ...this.identity.phone ? { metadata: { userPhone: this.identity.phone } } : {}
672
+ });
673
+ this.sessionId = session.sessionId;
674
+ this.setStatus("ready");
675
+ } catch (err) {
676
+ this.setStatus("error", err instanceof Error ? err.message : String(err));
677
+ throw err;
678
+ }
679
+ }
680
+ /**
681
+ * Submit a user message. Appends the user bubble immediately, then streams the
682
+ * assistant reply token-by-token, finalizing on `eventual_response`.
683
+ */
684
+ async send(text) {
685
+ const trimmed = text.trim();
686
+ if (!trimmed) return;
687
+ if (!this.client || !this.sessionId || this.status !== "ready") await this.connect();
688
+ if (!this.client || !this.sessionId) throw new Error("Conversation is not connected");
689
+ this.messages.push({
690
+ id: this.nextId("u"),
691
+ role: "user",
692
+ text: trimmed,
693
+ streaming: false
694
+ });
695
+ const assistant = {
696
+ id: this.nextId("a"),
697
+ role: "assistant",
698
+ text: "",
699
+ streaming: true
700
+ };
701
+ this.messages.push(assistant);
702
+ this.emitMessages();
703
+ try {
704
+ const turn = this.client.sendMessage({
705
+ sessionId: this.sessionId,
706
+ message: trimmed,
707
+ stream: true
708
+ });
709
+ this.activeRequestId = turn.requestId;
710
+ for await (const event of turn) if (event.type === "stream_token") {
711
+ const token = event.token ?? event.data?.token ?? "";
712
+ if (token) {
713
+ assistant.text += token;
714
+ this.emitMessages();
715
+ }
716
+ } else this.handleTurnEvent(event);
717
+ const inner = (await turn).data?.data;
718
+ const finalText = extractFinalText(inner?.response);
719
+ if (finalText && finalText.length > assistant.text.length) assistant.text = finalText;
720
+ if (!assistant.text) assistant.text = "(no response)";
721
+ const citations = extractCitations(inner);
722
+ if (citations.length > 0) assistant.citations = citations;
723
+ assistant.streaming = false;
724
+ this.emitMessages();
725
+ } catch (err) {
726
+ assistant.streaming = false;
727
+ const message = err instanceof ProtocolError ? `Error: ${err.message}` : this.config.connectionErrorMessage ?? "We couldn't reach the chat.";
728
+ assistant.text = assistant.text ? `${assistant.text}\n\n${message}` : message;
729
+ this.emitMessages();
730
+ this.setStatus("error", err instanceof Error ? err.message : String(err));
731
+ } finally {
732
+ this.activeRequestId = null;
733
+ this.setInterrupt(null);
734
+ }
735
+ }
736
+ /** Map a non-token turn event (OTP / tool-confirmation lifecycle) to interrupt state. */
737
+ handleTurnEvent(event) {
738
+ const inner = event.data?.data ?? {};
739
+ const str = (v) => typeof v === "string" ? v : void 0;
740
+ const num = (v) => typeof v === "number" ? v : void 0;
741
+ switch (event.type) {
742
+ case "otp_verification_required": {
743
+ const channels = Array.isArray(inner.availableChannels) ? inner.availableChannels.filter((c) => c === "email" || c === "sms") : ["email"];
744
+ this.setInterrupt({
745
+ kind: "otp",
746
+ toolId: str(inner.toolId),
747
+ actionDescription: str(inner.actionDescription),
748
+ availableChannels: channels.length > 0 ? channels : ["email"]
749
+ });
750
+ break;
751
+ }
752
+ case "otp_sent":
753
+ if (this.interrupt?.kind === "otp") this.setInterrupt({
754
+ ...this.interrupt,
755
+ sent: {
756
+ channel: str(inner.channel),
757
+ maskedDestination: str(inner.maskedDestination)
758
+ },
759
+ error: void 0
760
+ });
761
+ break;
762
+ case "otp_verified":
763
+ if (this.interrupt?.kind === "otp") this.setInterrupt(null);
764
+ break;
765
+ case "otp_invalid":
766
+ if (this.interrupt?.kind === "otp") this.setInterrupt({
767
+ ...this.interrupt,
768
+ error: str(inner.message) ?? "That code was incorrect.",
769
+ attemptsRemaining: num(inner.attemptsRemaining)
770
+ });
771
+ break;
772
+ case "write_confirmation_required":
773
+ this.setInterrupt({
774
+ kind: "confirm",
775
+ toolId: str(inner.toolId),
776
+ actionDescription: str(inner.actionDescription)
777
+ });
778
+ break;
779
+ default: break;
780
+ }
781
+ }
782
+ /** Tear down the underlying client. */
783
+ disconnect() {
784
+ this.client?.disconnect("widget closed");
785
+ this.client = null;
786
+ this.sessionId = null;
787
+ this.activeRequestId = null;
788
+ this.setInterrupt(null);
789
+ this.setStatus("closed");
790
+ }
791
+ };
792
+ //#endregion
793
+ //#region src/logo.ts
794
+ /**
795
+ * The Smooth logo, inlined as an SVG string so the full-page header can render
796
+ * it without a separate network fetch (the IIFE bundle is self-contained).
797
+ *
798
+ * GENERATED from `assets/smooth-logo.svg` — do not edit by hand. Regenerate with:
799
+ * node -e ... (see the commit that added this file)
800
+ */
801
+ const SMOOTH_LOGO_SVG = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg id=\"Layer_1\" data-name=\"Layer 1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 550 135\">\n <defs>\n <style>\n .cls-1 {\n fill: url(#linear-gradient-3);\n }\n\n .cls-2 {\n fill: url(#linear-gradient-2);\n }\n\n .cls-3 {\n fill: url(#linear-gradient);\n fill-rule: evenodd;\n }\n </style>\n <linearGradient id=\"linear-gradient\" x1=\"115.59\" y1=\"112.81\" x2=\"25.08\" y2=\"22.3\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\".3\" stop-color=\"#f49f0a\"/>\n <stop offset=\".79\" stop-color=\"#fb7a4d\"/>\n <stop offset=\"1\" stop-color=\"#ff6b6c\"/>\n </linearGradient>\n <linearGradient id=\"linear-gradient-2\" x1=\"360.91\" y1=\"152.01\" x2=\"202.32\" y2=\"-6.59\" xlink:href=\"#linear-gradient\"/>\n <linearGradient id=\"linear-gradient-3\" x1=\"443.91\" y1=\"30.15\" x2=\"531.36\" y2=\"117.59\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\".43\" stop-color=\"#00a6a6\"/>\n <stop offset=\"1\" stop-color=\"#1238dd\"/>\n </linearGradient>\n </defs>\n <path class=\"cls-3\" d=\"M48.28,14.96c-12.39,5.21-22.54,14.64-28.65,26.61-6.12,11.97-7.8,25.72-4.77,38.81,3.04,13.09,10.6,24.69,21.36,32.75,10.76,8.06,24.02,12.05,37.44,11.28,13.42-.77,26.13-6.26,35.9-15.5,9.76-9.24,15.95-21.63,17.46-34.99,1.51-13.36-1.74-26.82-9.19-38.01-1.07-1.61-.64-3.78.97-4.85,1.61-1.07,3.78-.64,4.85.97,8.36,12.56,12.02,27.68,10.32,42.67-1.7,15-8.64,28.91-19.61,39.28-10.96,10.37-25.24,16.54-40.31,17.4-15.07.87-29.96-3.62-42.04-12.66-12.08-9.05-20.58-22.07-23.99-36.77-3.41-14.7-1.51-30.14,5.35-43.58,6.87-13.44,18.26-24.02,32.17-29.87,13.91-5.85,29.44-6.6,43.85-2.11,1.85.57,2.88,2.54,2.3,4.38-.57,1.85-2.54,2.88-4.38,2.3-12.83-4-26.67-3.33-39.06,1.88ZM111.39,19.75c0,2.07-1.68,3.75-3.75,3.75s-3.75-1.68-3.75-3.75,1.68-3.75,3.75-3.75,3.75,1.68,3.75,3.75ZM64.64,59.93c0,1.91,2.39,2.56,7.69,3.88,3.89.97,6.6,2.18,8.15,3.63,1.53,1.45,2.29,3.53,2.29,6.25,0,3.57-1.03,6.26-3.11,8.08-2.07,1.82-5.09,2.73-9.09,2.73h-9.6c-1.97,0-3.57-1.6-3.59-3.57-.01-1.99,1.6-3.61,3.59-3.61h9.41c3.15-.12,4.79-.95,4.91-2.47,0-1.3-1.03-2.21-3.07-2.73-6.91-1.72-11.11-3.44-12.6-5.15-1.48-1.71-2.23-3.77-2.23-6.19,0-6.59,3.2-9.85,9.59-9.8h10.77c1.99,0,3.6,1.61,3.6,3.59s-1.61,3.59-3.6,3.59h-9.69c-1.83,0-3.43.06-3.43,1.77Z\"/>\n <path class=\"cls-2\" d=\"M205.52,48.44h-8.86c-.44-3.75-2.23-6.65-5.38-8.72-3.16-2.07-7.03-3.1-11.6-3.1h0c-3.35,0-6.27.54-8.78,1.62-2.49,1.09-4.44,2.59-5.84,4.48-1.39,1.89-2.08,4.05-2.08,6.46h0c0,2.01.49,3.75,1.46,5.2.97,1.44,2.22,2.63,3.74,3.58,1.53.95,3.13,1.72,4.8,2.32,1.68.6,3.22,1.09,4.62,1.46h0l7.68,2.06c1.97.52,4.17,1.23,6.6,2.14,2.43.92,4.75,2.16,6.98,3.72,2.23,1.56,4.07,3.56,5.52,6,1.45,2.44,2.18,5.43,2.18,8.98h0c0,4.08-1.07,7.77-3.2,11.08-2.12,3.29-5.22,5.91-9.3,7.86-4.08,1.95-9.02,2.92-14.82,2.92h0c-5.43,0-10.11-.87-14.06-2.62-3.95-1.75-7.05-4.19-9.3-7.32-2.25-3.12-3.53-6.75-3.84-10.88h9.46c.25,2.85,1.22,5.21,2.9,7.06,1.69,1.87,3.83,3.25,6.42,4.14,2.6.89,5.41,1.34,8.42,1.34h0c3.49,0,6.63-.57,9.4-1.72,2.79-1.13,4.99-2.73,6.62-4.8,1.63-2.05,2.44-4.46,2.44-7.22h0c0-2.51-.7-4.55-2.1-6.12-1.41-1.57-3.26-2.85-5.54-3.84-2.29-.99-4.77-1.85-7.44-2.58h0l-9.3-2.66c-5.91-1.71-10.59-4.13-14.04-7.28-3.44-3.16-5.16-7.29-5.16-12.38h0c0-4.23,1.15-7.93,3.46-11.1,2.29-3.16,5.39-5.62,9.3-7.38,3.91-1.76,8.27-2.64,13.08-2.64h0c4.88,0,9.21.87,13,2.6,3.8,1.73,6.81,4.11,9.04,7.12,2.23,3,3.4,6.41,3.52,10.22h0ZM229.16,105.18h-8.72v-56.74h8.42v8.86h.74c1.19-3.03,3.1-5.38,5.74-7.06,2.63-1.69,5.79-2.54,9.48-2.54h0c3.75,0,6.87.85,9.36,2.54,2.51,1.68,4.46,4.03,5.86,7.06h.58c1.45-2.92,3.63-5.25,6.54-7,2.91-1.73,6.39-2.6,10.46-2.6h0c5.07,0,9.21,1.58,12.44,4.74,3.23,3.17,4.84,8.09,4.84,14.76h0v37.98h-8.72v-37.98c0-4.19-1.14-7.18-3.42-8.98-2.29-1.79-4.99-2.68-8.1-2.68h0c-3.99,0-7.07,1.2-9.26,3.6-2.2,2.4-3.3,5.43-3.3,9.1h0v36.94h-8.86v-38.86c0-3.23-1.05-5.83-3.14-7.82-2.09-1.97-4.79-2.96-8.08-2.96h0c-2.27,0-4.38.6-6.34,1.8-1.96,1.21-3.53,2.88-4.72,5-1.2,2.13-1.8,4.59-1.8,7.38h0v35.46ZM333.9,106.36h0c-5.12,0-9.61-1.22-13.46-3.66-3.85-2.44-6.86-5.85-9.02-10.24-2.15-4.37-3.22-9.49-3.22-15.36h0c0-5.91,1.07-11.07,3.22-15.48,2.16-4.4,5.17-7.82,9.02-10.26,3.85-2.44,8.34-3.66,13.46-3.66h0c5.12,0,9.61,1.22,13.46,3.66,3.85,2.44,6.86,5.86,9.02,10.26,2.15,4.41,3.22,9.57,3.22,15.48h0c0,5.87-1.07,10.99-3.22,15.36-2.16,4.39-5.17,7.8-9.02,10.24-3.85,2.44-8.34,3.66-13.46,3.66ZM333.9,98.52h0c3.89,0,7.09-.99,9.6-2.98,2.52-2,4.38-4.63,5.58-7.88,1.21-3.25,1.82-6.77,1.82-10.56h0c0-3.79-.61-7.32-1.82-10.6-1.2-3.27-3.06-5.91-5.58-7.94-2.51-2.01-5.71-3.02-9.6-3.02h0c-3.89,0-7.09,1.01-9.6,3.02-2.51,2.03-4.37,4.67-5.58,7.94-1.2,3.28-1.8,6.81-1.8,10.6h0c0,3.79.6,7.31,1.8,10.56,1.21,3.25,3.07,5.88,5.58,7.88,2.51,1.99,5.71,2.98,9.6,2.98ZM395.94,106.36h0c-5.12,0-9.61-1.22-13.46-3.66-3.85-2.44-6.85-5.85-9-10.24-2.16-4.37-3.24-9.49-3.24-15.36h0c0-5.91,1.08-11.07,3.24-15.48,2.15-4.4,5.15-7.82,9-10.26,3.85-2.44,8.34-3.66,13.46-3.66h0c5.12,0,9.61,1.22,13.46,3.66,3.85,2.44,6.86,5.86,9.02,10.26,2.16,4.41,3.24,9.57,3.24,15.48h0c0,5.87-1.08,10.99-3.24,15.36-2.16,4.39-5.17,7.8-9.02,10.24-3.85,2.44-8.34,3.66-13.46,3.66ZM395.94,98.52h0c3.89,0,7.09-.99,9.6-2.98,2.52-2,4.38-4.63,5.58-7.88,1.21-3.25,1.82-6.77,1.82-10.56h0c0-3.79-.61-7.32-1.82-10.6-1.2-3.27-3.06-5.91-5.58-7.94-2.51-2.01-5.71-3.02-9.6-3.02h0c-3.88,0-7.08,1.01-9.6,3.02-2.51,2.03-4.37,4.67-5.58,7.94-1.2,3.28-1.8,6.81-1.8,10.6h0c0,3.79.6,7.31,1.8,10.56,1.21,3.25,3.07,5.88,5.58,7.88,2.52,1.99,5.72,2.98,9.6,2.98Z\"/>\n <path class=\"cls-1\" d=\"M467.88,48.02v13.28h-35.79v-13.28h35.79ZM439.68,34.38h17.89v53.42c0,1.5.36,2.62,1.08,3.36.72.74,1.88,1.1,3.49,1.1.62,0,1.48-.07,2.59-.21,1.11-.14,1.91-.27,2.38-.41l2.31,13.02c-2.02.58-3.97.97-5.84,1.18-1.88.21-3.66.31-5.33.31-6.08,0-10.7-1.43-13.84-4.28-3.15-2.85-4.72-7.01-4.72-12.48v-55.01ZM506.59,72.63v32.71h-17.89V28.95h17.53v33.53h-1.13c1.4-4.55,3.6-8.21,6.59-11,2.99-2.79,7.01-4.18,12.07-4.18,4,0,7.48.89,10.46,2.67,2.97,1.78,5.28,4.29,6.92,7.54,1.64,3.25,2.46,7.02,2.46,11.33v36.5h-17.89v-33.02c0-3.21-.82-5.73-2.46-7.56-1.64-1.83-3.93-2.74-6.87-2.74-1.92,0-3.62.42-5.1,1.26-1.49.84-2.64,2.04-3.46,3.61-.82,1.57-1.23,3.49-1.23,5.74Z\"/>\n</svg>";
802
+ //#endregion
803
+ //#region src/styles.ts
804
+ /**
805
+ * Render the widget's scoped stylesheet — the "Aurora Glass" design system.
806
+ *
807
+ * Every brand value is injected as a CSS custom property on `:host` so a host
808
+ * page can override colors per-instance and the rules below stay static. Two
809
+ * extra tokens are *derived in CSS* from the brand vars so they adapt to any
810
+ * theme (light or dark) without the caller supplying them:
811
+ *
812
+ * --sac-primary-2 a darker shade of `primary`, used as the second stop of the
813
+ * launcher / send / user-bubble gradients (depth without a
814
+ * second brand input).
815
+ * --sac-surface-2 a faint wash derived from `text`, used for inset chrome
816
+ * (composer field, close button, source cards). On a dark
817
+ * panel it reads as a light overlay; on a light panel, dark.
818
+ *
819
+ * Deliberately framework-light: no Tailwind, no runtime CSS-in-JS — just a string
820
+ * the web component drops into its shadow root. Modern color features
821
+ * (`color-mix`) are used intentionally; the widget targets evergreen browsers.
822
+ *
823
+ * `mode` switches host positioning + panel sizing between the floating popover
824
+ * (default) and the full-page layout (fills its container/viewport).
825
+ */
826
+ function buildStyles(theme, mode = "popover") {
827
+ return `
828
+ :host {
829
+ --sac-text: ${theme.text};
830
+ --sac-bg: ${theme.background};
831
+ --sac-primary: ${theme.primary};
832
+ --sac-primary-text: ${theme.primaryText};
833
+ --sac-assistant-bubble: ${theme.assistantBubble};
834
+ --sac-assistant-bubble-text: ${theme.assistantBubbleText};
835
+ --sac-user-bubble: ${theme.userBubble};
836
+ --sac-user-bubble-text: ${theme.userBubbleText};
837
+ --sac-border: ${theme.border};
838
+
839
+ /* Derived tokens — adapt to any brand color without a second input. */
840
+ --sac-primary-2: color-mix(in srgb, var(--sac-primary) 78%, #000 22%);
841
+ --sac-surface-2: color-mix(in srgb, var(--sac-text) 5%, transparent);
842
+ --sac-radius: 22px;
843
+ --sac-ease: cubic-bezier(.16, 1, .3, 1);
844
+
845
+ ${mode === "fullpage" ? `/* Full-page: fill the host's box (sized by its container, else the viewport). */
846
+ display: block;
847
+ position: relative;
848
+ width: 100%;
849
+ height: 100%;
850
+ min-height: 100vh;` : `/* Popover: float in the bottom-right corner. */
851
+ position: fixed;
852
+ bottom: 24px;
853
+ right: 24px;
854
+ z-index: 2147483000;`}
855
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
856
+ -webkit-font-smoothing: antialiased;
857
+ }
858
+
859
+ * { box-sizing: border-box; }
860
+
861
+ /* ───────────────────────────── Launcher ───────────────────────────── */
862
+ .launcher {
863
+ position: relative;
864
+ width: 62px;
865
+ height: 62px;
866
+ border-radius: 50%;
867
+ border: none;
868
+ cursor: pointer;
869
+ padding: 0;
870
+ background: radial-gradient(120% 120% at 30% 20%,
871
+ color-mix(in srgb, var(--sac-primary) 78%, #fff 22%) 0%,
872
+ var(--sac-primary) 42%,
873
+ var(--sac-primary-2) 130%);
874
+ color: var(--sac-primary-text);
875
+ display: flex;
876
+ align-items: center;
877
+ justify-content: center;
878
+ box-shadow:
879
+ 0 1px 0 rgba(255, 255, 255, .25) inset,
880
+ 0 10px 24px -6px color-mix(in srgb, var(--sac-primary) 55%, transparent),
881
+ 0 18px 50px -12px rgba(0, 0, 0, .6);
882
+ transition: transform .45s var(--sac-ease), box-shadow .45s var(--sac-ease), opacity .3s ease;
883
+ isolation: isolate;
884
+ }
885
+ /* Breathing presence ring. */
886
+ .launcher::before {
887
+ content: '';
888
+ position: absolute;
889
+ inset: -6px;
890
+ border-radius: 50%;
891
+ z-index: -1;
892
+ background: radial-gradient(closest-side, color-mix(in srgb, var(--sac-primary) 45%, transparent), transparent 75%);
893
+ animation: sac-breathe 3.4s ease-in-out infinite;
894
+ }
895
+ @keyframes sac-breathe { 0%, 100% { transform: scale(1); opacity: .55 } 50% { transform: scale(1.28); opacity: 0 } }
896
+ .launcher:hover {
897
+ transform: translateY(-3px) scale(1.06);
898
+ box-shadow:
899
+ 0 1px 0 rgba(255, 255, 255, .3) inset,
900
+ 0 16px 30px -6px color-mix(in srgb, var(--sac-primary) 60%, transparent),
901
+ 0 26px 60px -14px rgba(0, 0, 0, .7);
902
+ }
903
+ .launcher:active { transform: translateY(-1px) scale(.98); }
904
+ .launcher .ico { width: 27px; height: 27px; display: block; transition: transform .4s var(--sac-ease); }
905
+ .launcher:hover .ico { transform: rotate(-6deg) scale(1.04); }
906
+ .launcher.hidden { opacity: 0; transform: scale(.4) translateY(10px); pointer-events: none; }
907
+
908
+ /* ─────────────────────────────── Panel ────────────────────────────── */
909
+ .panel {
910
+ width: 390px;
911
+ max-width: calc(100vw - 40px);
912
+ height: 600px;
913
+ max-height: calc(100vh - 56px);
914
+ display: flex;
915
+ flex-direction: column;
916
+ overflow: hidden;
917
+ border-radius: var(--sac-radius);
918
+ background: linear-gradient(180deg, color-mix(in srgb, var(--sac-bg) 92%, #fff 8%) 0%, var(--sac-bg) 22%);
919
+ color: var(--sac-text);
920
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
921
+ box-shadow:
922
+ 0 0 0 1px rgba(255, 255, 255, .03) inset,
923
+ 0 40px 80px -24px rgba(0, 0, 0, .65),
924
+ 0 16px 40px -20px rgba(0, 0, 0, .5);
925
+ transform-origin: bottom right;
926
+ animation: sac-panel-in .5s var(--sac-ease) both;
927
+ position: relative;
928
+ }
929
+ @keyframes sac-panel-in { from { opacity: 0; transform: translateY(16px) scale(.92) } to { opacity: 1; transform: none } }
930
+ .panel.hidden { display: none; }
931
+ /* Ambient brand glow bleeding from the top of the panel. */
932
+ .panel::before {
933
+ content: '';
934
+ position: absolute;
935
+ left: 0; right: 0; top: 0;
936
+ height: 140px;
937
+ pointer-events: none;
938
+ background: radial-gradient(120% 100% at 50% 0%, color-mix(in srgb, var(--sac-primary) 22%, transparent), transparent 70%);
939
+ }
940
+ /* Full-page: the panel becomes the whole surface. */
941
+ .panel.fullpage {
942
+ width: 100%;
943
+ height: 100%;
944
+ min-height: 100vh;
945
+ max-width: none;
946
+ max-height: none;
947
+ border: none;
948
+ border-radius: 0;
949
+ box-shadow: none;
950
+ animation: none;
951
+ }
952
+
953
+ /* ─────────────────────────────── Header ───────────────────────────── */
954
+ .header {
955
+ position: relative;
956
+ display: flex;
957
+ align-items: center;
958
+ gap: 12px;
959
+ padding: 16px 16px 14px;
960
+ }
961
+ .avatar {
962
+ width: 40px;
963
+ height: 40px;
964
+ border-radius: 13px;
965
+ flex: none;
966
+ background: linear-gradient(140deg, var(--sac-primary), var(--sac-primary-2));
967
+ display: flex;
968
+ align-items: center;
969
+ justify-content: center;
970
+ color: var(--sac-primary-text);
971
+ box-shadow:
972
+ 0 6px 16px -6px color-mix(in srgb, var(--sac-primary) 60%, transparent),
973
+ 0 1px 0 rgba(255, 255, 255, .25) inset;
974
+ }
975
+ .avatar svg { width: 22px; height: 22px; }
976
+ .avatar .logo-wrap { display: flex; }
977
+ .avatar .logo { height: 22px; width: auto; display: block; }
978
+ .meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
979
+ .title { font-weight: 650; font-size: 15.5px; letter-spacing: -.01em; line-height: 1.1; }
980
+ .status {
981
+ display: flex;
982
+ align-items: center;
983
+ gap: 6px;
984
+ font-size: 12px;
985
+ color: color-mix(in srgb, var(--sac-text) 62%, transparent);
986
+ }
987
+ .dot {
988
+ width: 7px; height: 7px;
989
+ border-radius: 50%;
990
+ flex: none;
991
+ background: #34d399;
992
+ color: #34d399;
993
+ box-shadow: 0 0 0 0 rgba(52, 211, 153, .6);
994
+ animation: sac-pulse 2.4s ease-out infinite;
995
+ }
996
+ .dot.connecting { background: #fbbf24; color: #fbbf24; animation: sac-pulse 1.1s ease-out infinite; }
997
+ .dot.error { background: #f87171; color: #f87171; animation: none; }
998
+ .dot.off { background: #94a3b8; color: #94a3b8; animation: none; }
999
+ @keyframes sac-pulse {
1000
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, currentColor 55%, transparent) }
1001
+ 70% { box-shadow: 0 0 0 6px transparent }
1002
+ 100% { box-shadow: 0 0 0 0 transparent }
1003
+ }
1004
+ .close {
1005
+ margin-left: auto;
1006
+ width: 32px; height: 32px;
1007
+ border-radius: 10px;
1008
+ border: none;
1009
+ cursor: pointer;
1010
+ background: var(--sac-surface-2);
1011
+ color: inherit;
1012
+ display: flex;
1013
+ align-items: center;
1014
+ justify-content: center;
1015
+ transition: background .2s ease, transform .2s ease;
1016
+ }
1017
+ .close:hover { background: color-mix(in srgb, var(--sac-text) 12%, transparent); transform: translateY(1px); }
1018
+ .close svg { width: 16px; height: 16px; opacity: .8; }
1019
+ .powered { margin-left: auto; font-size: 10.5px; letter-spacing: .02em; opacity: .6; }
1020
+ .header-sep { height: 1px; margin: 0 16px; background: linear-gradient(90deg, transparent, var(--sac-border), transparent); }
1021
+
1022
+ /* Full-page header: taller, logo-led, no close. */
1023
+ .panel.fullpage .header { padding: 18px 22px; }
1024
+ .panel.fullpage .avatar { width: 44px; height: 44px; }
1025
+ .panel.fullpage .avatar .logo { height: 26px; }
1026
+
1027
+ /* ────────────────────────────── Messages ──────────────────────────── */
1028
+ .messages {
1029
+ flex: 1;
1030
+ overflow-y: auto;
1031
+ padding: 18px 16px 8px;
1032
+ display: flex;
1033
+ flex-direction: column;
1034
+ gap: 12px;
1035
+ scroll-behavior: smooth;
1036
+ }
1037
+ .messages::-webkit-scrollbar { width: 8px; }
1038
+ .messages::-webkit-scrollbar-thumb {
1039
+ background: color-mix(in srgb, var(--sac-text) 14%, transparent);
1040
+ border-radius: 99px;
1041
+ border: 2px solid transparent;
1042
+ background-clip: padding-box;
1043
+ }
1044
+ .messages::-webkit-scrollbar-thumb:hover {
1045
+ background: color-mix(in srgb, var(--sac-text) 24%, transparent);
1046
+ background-clip: padding-box;
1047
+ }
1048
+
1049
+ .row {
1050
+ display: flex;
1051
+ gap: 9px;
1052
+ max-width: 88%;
1053
+ animation: sac-msg-in .42s var(--sac-ease) both;
1054
+ }
1055
+ @keyframes sac-msg-in { from { opacity: 0; transform: translateY(8px) } to { opacity: 1; transform: none } }
1056
+ .row.user { align-self: flex-end; flex-direction: row-reverse; }
1057
+ .row.assistant { align-self: flex-start; }
1058
+ .mini {
1059
+ width: 26px; height: 26px;
1060
+ border-radius: 9px;
1061
+ flex: none;
1062
+ align-self: flex-end;
1063
+ background: linear-gradient(140deg, var(--sac-primary), var(--sac-primary-2));
1064
+ display: flex;
1065
+ align-items: center;
1066
+ justify-content: center;
1067
+ color: var(--sac-primary-text);
1068
+ }
1069
+ .mini svg { width: 15px; height: 15px; }
1070
+
1071
+ .bubble {
1072
+ padding: 11px 14px;
1073
+ border-radius: 16px;
1074
+ font-size: 14px;
1075
+ line-height: 1.5;
1076
+ white-space: pre-wrap;
1077
+ word-break: break-word;
1078
+ position: relative;
1079
+ }
1080
+ .bubble.assistant {
1081
+ background: linear-gradient(180deg, color-mix(in srgb, var(--sac-assistant-bubble) 86%, #fff 5%), var(--sac-assistant-bubble));
1082
+ color: var(--sac-assistant-bubble-text);
1083
+ border: 1px solid color-mix(in srgb, var(--sac-text) 8%, transparent);
1084
+ border-bottom-left-radius: 5px;
1085
+ box-shadow: 0 2px 8px -4px rgba(0, 0, 0, .4);
1086
+ }
1087
+ .bubble.user {
1088
+ background: linear-gradient(165deg,
1089
+ color-mix(in srgb, var(--sac-user-bubble) 88%, #fff 12%),
1090
+ var(--sac-user-bubble) 60%,
1091
+ color-mix(in srgb, var(--sac-user-bubble) 80%, var(--sac-primary-2) 20%));
1092
+ color: var(--sac-user-bubble-text);
1093
+ border-bottom-right-radius: 5px;
1094
+ box-shadow: 0 6px 16px -8px color-mix(in srgb, var(--sac-primary) 50%, transparent);
1095
+ }
1096
+ .bubble.greeting {
1097
+ background: transparent;
1098
+ border: 1px dashed color-mix(in srgb, var(--sac-text) 14%, transparent);
1099
+ color: color-mix(in srgb, var(--sac-text) 80%, transparent);
1100
+ box-shadow: none;
1101
+ }
1102
+
1103
+ /* Typing indicator (assistant bubble with no text yet). */
1104
+ .bubble.typing { display: flex; gap: 4px; padding: 14px 15px; }
1105
+ .bubble.typing i {
1106
+ width: 7px; height: 7px;
1107
+ border-radius: 50%;
1108
+ background: color-mix(in srgb, var(--sac-assistant-bubble-text) 55%, transparent);
1109
+ animation: sac-typing 1.3s ease-in-out infinite;
1110
+ }
1111
+ .bubble.typing i:nth-child(2) { animation-delay: .18s; }
1112
+ .bubble.typing i:nth-child(3) { animation-delay: .36s; }
1113
+ @keyframes sac-typing { 0%, 60%, 100% { transform: translateY(0); opacity: .4 } 30% { transform: translateY(-5px); opacity: 1 } }
1114
+
1115
+ .cursor::after {
1116
+ content: '';
1117
+ display: inline-block;
1118
+ width: 2px; height: 1.05em;
1119
+ margin-left: 2px;
1120
+ vertical-align: -2px;
1121
+ border-radius: 2px;
1122
+ background: currentColor;
1123
+ animation: sac-blink 1s steps(2, start) infinite;
1124
+ }
1125
+ @keyframes sac-blink { to { opacity: 0 } }
1126
+
1127
+ /* Full-page: center the conversation in a readable column. */
1128
+ .panel.fullpage .messages { padding: 26px 20px; }
1129
+ .panel.fullpage .row { max-width: 760px; width: 100%; margin-left: auto; margin-right: auto; }
1130
+ .panel.fullpage .row.user { max-width: 80%; margin-right: 0; }
1131
+
1132
+ /* ───────────────── Sources (grounding citations) ──────────────────── */
1133
+ .sources {
1134
+ align-self: flex-start;
1135
+ max-width: 88%;
1136
+ margin: -4px 0 0 35px;
1137
+ }
1138
+ .panel.fullpage .sources { max-width: 760px; width: 100%; margin-left: auto; margin-right: auto; }
1139
+ .sources summary {
1140
+ cursor: pointer;
1141
+ list-style: none;
1142
+ display: inline-flex;
1143
+ align-items: center;
1144
+ gap: 7px;
1145
+ font-size: 12px;
1146
+ font-weight: 600;
1147
+ color: color-mix(in srgb, var(--sac-text) 70%, transparent);
1148
+ padding: 5px 0;
1149
+ user-select: none;
1150
+ }
1151
+ .sources summary::-webkit-details-marker { display: none; }
1152
+ .sources .chev { transition: transform .2s var(--sac-ease); flex: none; }
1153
+ .sources details[open] .chev { transform: rotate(90deg); }
1154
+ .sources .count {
1155
+ background: color-mix(in srgb, var(--sac-primary) 18%, transparent);
1156
+ color: color-mix(in srgb, var(--sac-primary) 92%, #fff);
1157
+ font-size: 10.5px;
1158
+ font-weight: 700;
1159
+ padding: 1px 7px;
1160
+ border-radius: 99px;
1161
+ }
1162
+ .sources ol { list-style: none; margin: 6px 0 2px; padding: 0; display: flex; flex-direction: column; gap: 7px; }
1163
+ .sources li {
1164
+ background: var(--sac-surface-2);
1165
+ border: 1px solid color-mix(in srgb, var(--sac-border) 70%, transparent);
1166
+ border-left: 2px solid var(--sac-primary);
1167
+ border-radius: 9px;
1168
+ padding: 8px 10px;
1169
+ }
1170
+ .sources .src-title {
1171
+ color: color-mix(in srgb, var(--sac-primary) 92%, #fff);
1172
+ font-weight: 600;
1173
+ font-size: 12.5px;
1174
+ text-decoration: none;
1175
+ word-break: break-word;
1176
+ }
1177
+ .sources a.src-title:hover { text-decoration: underline; }
1178
+ .sources span.src-title { color: var(--sac-text); opacity: .95; }
1179
+ .sources .src-snippet {
1180
+ display: block;
1181
+ margin-top: 3px;
1182
+ font-size: 11.5px;
1183
+ line-height: 1.45;
1184
+ color: color-mix(in srgb, var(--sac-text) 55%, transparent);
1185
+ white-space: normal;
1186
+ }
1187
+
1188
+ /* ────────────────────────────── Composer ──────────────────────────── */
1189
+ .composer-wrap { padding: 12px 14px calc(12px + env(safe-area-inset-bottom)); }
1190
+ .composer {
1191
+ display: flex;
1192
+ align-items: flex-end;
1193
+ gap: 8px;
1194
+ padding: 7px 7px 7px 14px;
1195
+ border-radius: 18px;
1196
+ background: var(--sac-surface-2);
1197
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
1198
+ transition: border-color .25s ease, box-shadow .25s ease, background .25s ease;
1199
+ }
1200
+ .composer:focus-within {
1201
+ border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent);
1202
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent);
1203
+ }
1204
+ .composer textarea {
1205
+ flex: 1;
1206
+ resize: none;
1207
+ border: none;
1208
+ background: transparent;
1209
+ color: var(--sac-text);
1210
+ font-family: inherit;
1211
+ font-size: 14px;
1212
+ line-height: 1.45;
1213
+ max-height: 120px;
1214
+ padding: 6px 0;
1215
+ outline: none;
1216
+ }
1217
+ .composer textarea::placeholder { color: color-mix(in srgb, var(--sac-text) 42%, transparent); }
1218
+ .send {
1219
+ width: 38px; height: 38px;
1220
+ flex: none;
1221
+ border: none;
1222
+ border-radius: 13px;
1223
+ cursor: pointer;
1224
+ display: flex;
1225
+ align-items: center;
1226
+ justify-content: center;
1227
+ background: linear-gradient(150deg, var(--sac-primary), var(--sac-primary-2));
1228
+ color: var(--sac-primary-text);
1229
+ box-shadow:
1230
+ 0 6px 14px -6px color-mix(in srgb, var(--sac-primary) 65%, transparent),
1231
+ 0 1px 0 rgba(255, 255, 255, .25) inset;
1232
+ transition: transform .2s var(--sac-ease), box-shadow .2s var(--sac-ease), opacity .2s ease;
1233
+ }
1234
+ .send svg { width: 18px; height: 18px; }
1235
+ .send:hover { transform: translateY(-1px) scale(1.05); }
1236
+ .send:active { transform: scale(.94); }
1237
+ .send:disabled { opacity: .4; cursor: default; transform: none; box-shadow: none; }
1238
+ .footer {
1239
+ text-align: center;
1240
+ margin-top: 9px;
1241
+ font-size: 10.5px;
1242
+ letter-spacing: .04em;
1243
+ color: color-mix(in srgb, var(--sac-text) 38%, transparent);
1244
+ }
1245
+ .footer b { font-weight: 600; color: color-mix(in srgb, var(--sac-text) 55%, transparent); }
1246
+
1247
+ /* ─────────────────── Pre-chat identity form ───────────────────────── */
1248
+ .prechat { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 18px; padding: 22px 20px; }
1249
+ .pc-head { text-align: center; }
1250
+ .pc-title { font-size: 17px; font-weight: 650; letter-spacing: -.01em; }
1251
+ .pc-sub { margin-top: 4px; font-size: 13px; color: color-mix(in srgb, var(--sac-text) 60%, transparent); }
1252
+ .pc-form { display: flex; flex-direction: column; gap: 12px; }
1253
+ .pc-field { display: flex; flex-direction: column; gap: 5px; }
1254
+ .pc-field span { font-size: 12px; font-weight: 600; color: color-mix(in srgb, var(--sac-text) 70%, transparent); }
1255
+ .pc-field input {
1256
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
1257
+ background: var(--sac-surface-2);
1258
+ color: var(--sac-text);
1259
+ border-radius: 12px;
1260
+ padding: 11px 13px;
1261
+ font-family: inherit;
1262
+ font-size: 14px;
1263
+ outline: none;
1264
+ transition: border-color .2s ease, box-shadow .2s ease;
1265
+ }
1266
+ .pc-field input::placeholder { color: color-mix(in srgb, var(--sac-text) 42%, transparent); }
1267
+ .pc-field input:focus {
1268
+ border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent);
1269
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent);
1270
+ }
1271
+ .pc-submit {
1272
+ margin-top: 4px;
1273
+ border: none;
1274
+ border-radius: 13px;
1275
+ padding: 12px;
1276
+ cursor: pointer;
1277
+ background: linear-gradient(150deg, var(--sac-primary), var(--sac-primary-2));
1278
+ color: var(--sac-primary-text);
1279
+ font-weight: 650;
1280
+ font-size: 14px;
1281
+ box-shadow: 0 6px 14px -6px color-mix(in srgb, var(--sac-primary) 65%, transparent), 0 1px 0 rgba(255, 255, 255, .25) inset;
1282
+ transition: transform .2s var(--sac-ease);
1283
+ }
1284
+ .pc-submit:hover { transform: translateY(-1px); }
1285
+ .pc-submit:active { transform: scale(.98); }
1286
+
1287
+ /* ─────────────────── Starter-prompt chips ─────────────────────────── */
1288
+ .prompts { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 2px 35px; }
1289
+ .panel.fullpage .prompts { margin-left: auto; margin-right: auto; max-width: 760px; width: 100%; }
1290
+ .chip {
1291
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
1292
+ background: var(--sac-surface-2);
1293
+ color: var(--sac-text);
1294
+ border-radius: 999px;
1295
+ padding: 8px 13px;
1296
+ font-family: inherit;
1297
+ font-size: 12.5px;
1298
+ cursor: pointer;
1299
+ text-align: left;
1300
+ transition: border-color .2s ease, background .2s ease, transform .2s ease;
1301
+ }
1302
+ .chip:hover {
1303
+ border-color: color-mix(in srgb, var(--sac-primary) 50%, transparent);
1304
+ background: color-mix(in srgb, var(--sac-primary) 10%, var(--sac-surface-2));
1305
+ transform: translateY(-1px);
1306
+ }
1307
+
1308
+ .hidden { display: none !important; }
1309
+
1310
+ @media (prefers-reduced-motion: reduce) {
1311
+ .launcher::before, .dot, .bubble.typing i { animation: none !important; }
1312
+ .panel, .row, .launcher, .send, .close { animation: none !important; transition: none !important; }
1313
+ }
1314
+ `;
1315
+ }
1316
+ //#endregion
1317
+ //#region src/element.ts
1318
+ const ELEMENT_TAG = "smooth-agent-chat";
1319
+ const OBSERVED = [
1320
+ "endpoint",
1321
+ "agent-id",
1322
+ "agent-name",
1323
+ "placeholder",
1324
+ "greeting",
1325
+ "start-open",
1326
+ "mode"
1327
+ ];
1328
+ /**
1329
+ * Inline SVG icons (static, trusted strings — never interpolated with user data).
1330
+ * Kept here so the IIFE bundle is self-contained: no icon-font or network fetch.
1331
+ */
1332
+ const ICON = {
1333
+ /** Launcher — a speech bubble carrying a spark (chat + AI). */
1334
+ spark: `<svg class="ico" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 3.5c-4.7 0-8.5 3.2-8.5 7.2 0 2.2 1.2 4.2 3 5.5v3.3l3.2-1.7c.7.1 1.5.2 2.3.2 4.7 0 8.5-3.2 8.5-7.3S16.7 3.5 12 3.5Z" fill="currentColor" opacity=".22"/><path d="M13.4 7.2 9 12.6h2.6l-1 4.2 4.4-5.4h-2.6l1-4.2Z" fill="currentColor"/></svg>`,
1335
+ /** Small assistant avatar used beside each assistant message. */
1336
+ bot: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="4.5" y="7.5" width="15" height="11" rx="3.5" stroke="currentColor" stroke-width="1.6"/><path d="M12 4.5v3M8.5 12.2h.01M15.5 12.2h.01" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M9.5 15.4c.7.6 1.5.9 2.5.9s1.8-.3 2.5-.9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`,
1337
+ /** Close (collapse panel) — a downward chevron. */
1338
+ close: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="m7 10 5 5 5-5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
1339
+ /** Send — an upward arrow. */
1340
+ send: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 19V6M12 6l-5.5 5.5M12 6l5.5 5.5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
1341
+ /** Sources disclosure caret. */
1342
+ 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>`
1343
+ };
1344
+ /**
1345
+ * Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
1346
+ *
1347
+ * SECURITY: citation URLs originate from indexed content (web / GitHub
1348
+ * connectors), which can be attacker-influenceable. Assigning an arbitrary
1349
+ * string to `<a>.href` allows `javascript:`/`data:`/`vbscript:` URLs that
1350
+ * execute on click — a stored-XSS vector. Only http(s) links are rendered as
1351
+ * anchors; anything else falls back to plain text.
1352
+ */
1353
+ function safeHttpUrl(url) {
1354
+ if (!url) return null;
1355
+ try {
1356
+ const parsed = new URL(url);
1357
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
1358
+ } catch {
1359
+ return null;
1360
+ }
1361
+ }
1362
+ var SmoothAgentChatElement = class extends HTMLElement {
1363
+ static get observedAttributes() {
1364
+ return OBSERVED;
1365
+ }
1366
+ root;
1367
+ controller = null;
1368
+ overrides = {};
1369
+ open = false;
1370
+ messages = [];
1371
+ status = "idle";
1372
+ mounted = false;
1373
+ /** True once the visitor has cleared the pre-chat identity gate (or it's not needed). */
1374
+ userInfoSatisfied = false;
1375
+ /** True after the visitor has sent their first message (hides starter chips). */
1376
+ hasSent = false;
1377
+ /** Starter prompts shown as chips in the empty state. */
1378
+ examplePrompts = [];
1379
+ panelEl = null;
1380
+ launcherEl = null;
1381
+ messagesEl = null;
1382
+ statusEl = null;
1383
+ dotEl = null;
1384
+ inputEl = null;
1385
+ sendBtn = null;
1386
+ constructor() {
1387
+ super();
1388
+ this.root = this.attachShadow({ mode: "open" });
1389
+ }
1390
+ connectedCallback() {
1391
+ this.mounted = true;
1392
+ this.render();
1393
+ }
1394
+ disconnectedCallback() {
1395
+ this.mounted = false;
1396
+ this.controller?.disconnect();
1397
+ this.controller = null;
1398
+ }
1399
+ attributeChangedCallback() {
1400
+ if (this.mounted) this.render();
1401
+ }
1402
+ /**
1403
+ * Programmatically merge config overrides (endpoint, agentId, theme, …). Values
1404
+ * set here take precedence over HTML attributes. Re-renders the widget.
1405
+ */
1406
+ configure(config) {
1407
+ this.overrides = {
1408
+ ...this.overrides,
1409
+ ...config
1410
+ };
1411
+ if (config.theme) this.overrides.theme = {
1412
+ ...this.overrides.theme ?? {},
1413
+ ...config.theme
1414
+ };
1415
+ if (this.mounted) this.render();
1416
+ }
1417
+ /** Open the chat panel. */
1418
+ openChat() {
1419
+ this.open = true;
1420
+ this.syncOpenState();
1421
+ this.controller?.connect().catch(() => {});
1422
+ }
1423
+ /** Collapse the chat panel back to the launcher. */
1424
+ closeChat() {
1425
+ this.open = false;
1426
+ this.syncOpenState();
1427
+ }
1428
+ readConfig() {
1429
+ const endpoint = this.overrides.endpoint ?? this.getAttribute("endpoint") ?? "";
1430
+ const agentId = this.overrides.agentId ?? this.getAttribute("agent-id") ?? "";
1431
+ if (!endpoint || !agentId) return null;
1432
+ const theme = this.overrides.theme;
1433
+ const modeAttr = this.getAttribute("mode");
1434
+ return {
1435
+ endpoint,
1436
+ mode: this.overrides.mode ?? (modeAttr === "fullpage" ? "fullpage" : modeAttr === "popover" ? "popover" : void 0) ?? "popover",
1437
+ agentId,
1438
+ agentName: this.overrides.agentName ?? this.getAttribute("agent-name") ?? void 0,
1439
+ userName: this.overrides.userName,
1440
+ userEmail: this.overrides.userEmail,
1441
+ userPhone: this.overrides.userPhone,
1442
+ placeholder: this.overrides.placeholder ?? this.getAttribute("placeholder") ?? void 0,
1443
+ greeting: this.overrides.greeting ?? this.getAttribute("greeting") ?? void 0,
1444
+ connectionErrorMessage: this.overrides.connectionErrorMessage,
1445
+ startOpen: this.overrides.startOpen ?? this.hasAttribute("start-open"),
1446
+ examplePrompts: this.overrides.examplePrompts,
1447
+ requireName: this.overrides.requireName,
1448
+ requireEmail: this.overrides.requireEmail,
1449
+ requirePhone: this.overrides.requirePhone,
1450
+ allowAnonymous: this.overrides.allowAnonymous,
1451
+ theme
1452
+ };
1453
+ }
1454
+ render() {
1455
+ const config = this.readConfig();
1456
+ if (!config) {
1457
+ this.root.innerHTML = "";
1458
+ return;
1459
+ }
1460
+ const resolved = resolveConfig(config);
1461
+ if (!this.controller) {
1462
+ this.controller = new ConversationController(config, {
1463
+ onMessages: (messages) => {
1464
+ this.messages = messages;
1465
+ this.renderMessages(resolved.greeting);
1466
+ },
1467
+ onStatus: (status) => {
1468
+ this.status = status;
1469
+ this.renderStatus();
1470
+ this.renderComposerState();
1471
+ }
1472
+ });
1473
+ if (resolved.startOpen) this.open = true;
1474
+ }
1475
+ const fullpage = resolved.mode === "fullpage";
1476
+ if (fullpage) this.open = true;
1477
+ const style = document.createElement("style");
1478
+ style.textContent = buildStyles(resolved.theme, resolved.mode);
1479
+ const monogram = escapeHtml((resolved.agentName.trim().charAt(0) || "A").toUpperCase());
1480
+ const header = fullpage ? `<div class="header">
1481
+ <div class="avatar"><span class="logo-wrap">${SMOOTH_LOGO_SVG}</span></div>
1482
+ <div class="meta">
1483
+ <span class="title">${escapeHtml(resolved.agentName)}</span>
1484
+ <span class="status"><span class="dot off"></span><span class="status-text"></span></span>
1485
+ </div>
1486
+ <span class="powered">powered by smooth-operator</span>
1487
+ </div>` : `<div class="header">
1488
+ <div class="avatar">${monogram}</div>
1489
+ <div class="meta">
1490
+ <span class="title">${escapeHtml(resolved.agentName)}</span>
1491
+ <span class="status"><span class="dot off"></span><span class="status-text"></span></span>
1492
+ </div>
1493
+ <button class="close" aria-label="Close chat">${ICON.close}</button>
1494
+ </div>`;
1495
+ this.examplePrompts = resolved.examplePrompts;
1496
+ const gating = needsUserInfo(resolved) && !this.userInfoSatisfied;
1497
+ const field = (name, type, label, autocomplete) => `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}" required /></label>`;
1498
+ const prechatHtml = `
1499
+ <div class="prechat">
1500
+ <div class="pc-head">
1501
+ <div class="pc-title">Before we chat</div>
1502
+ <div class="pc-sub">A couple details so ${escapeHtml(resolved.agentName)} can help.</div>
1503
+ </div>
1504
+ <form class="pc-form" novalidate>
1505
+ ${resolved.requireName ? field("name", "text", "Name", "name") : ""}
1506
+ ${resolved.requireEmail ? field("email", "email", "Email", "email") : ""}
1507
+ ${resolved.requirePhone ? field("phone", "tel", "Phone", "tel") : ""}
1508
+ <button type="submit" class="pc-submit">Start chat</button>
1509
+ </form>
1510
+ </div>`;
1511
+ const chatHtml = `
1512
+ <div class="messages"></div>
1513
+ <div class="composer-wrap">
1514
+ <div class="composer">
1515
+ <textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
1516
+ <button class="send" type="button" aria-label="Send message">${ICON.send}</button>
1517
+ </div>
1518
+ <div class="footer">powered by <b>smooth&#8209;operator</b></div>
1519
+ </div>`;
1520
+ const container = document.createElement("div");
1521
+ container.innerHTML = `
1522
+ ${fullpage ? "" : `<button class="launcher" part="launcher" aria-label="Open chat">${ICON.spark}</button>`}
1523
+ <div class="panel${fullpage ? " fullpage" : " hidden"}" part="panel" role="${fullpage ? "region" : "dialog"}" aria-label="${escapeHtml(resolved.agentName)} chat">
1524
+ ${header}
1525
+ <div class="header-sep"></div>
1526
+ ${gating ? prechatHtml : chatHtml}
1527
+ </div>
1528
+ `;
1529
+ const logoSvg = container.querySelector(".logo-wrap svg");
1530
+ if (logoSvg) logoSvg.setAttribute("class", "logo");
1531
+ this.root.replaceChildren(style, container);
1532
+ this.launcherEl = container.querySelector(".launcher");
1533
+ this.panelEl = container.querySelector(".panel");
1534
+ this.messagesEl = container.querySelector(".messages");
1535
+ this.statusEl = container.querySelector(".status-text");
1536
+ this.dotEl = container.querySelector(".dot");
1537
+ this.inputEl = container.querySelector("textarea");
1538
+ this.sendBtn = container.querySelector(".send");
1539
+ this.launcherEl?.addEventListener("click", () => this.openChat());
1540
+ container.querySelector(".close")?.addEventListener("click", () => this.closeChat());
1541
+ this.sendBtn?.addEventListener("click", () => this.submit());
1542
+ this.inputEl?.addEventListener("input", () => this.autosize());
1543
+ this.inputEl?.addEventListener("keydown", (ev) => {
1544
+ if (ev.key === "Enter" && !ev.shiftKey) {
1545
+ ev.preventDefault();
1546
+ this.submit();
1547
+ }
1548
+ });
1549
+ const pcForm = container.querySelector(".pc-form");
1550
+ pcForm?.addEventListener("submit", (ev) => {
1551
+ ev.preventDefault();
1552
+ this.handlePrechatSubmit(pcForm);
1553
+ });
1554
+ if (fullpage && !gating) this.controller?.connect().catch(() => {});
1555
+ this.syncOpenState();
1556
+ if (!gating) this.renderMessages(resolved.greeting);
1557
+ this.renderStatus();
1558
+ this.renderComposerState();
1559
+ }
1560
+ /** Collect identity from the pre-chat form, then drop into the chat view. */
1561
+ handlePrechatSubmit(form) {
1562
+ if (!form.reportValidity()) return;
1563
+ const data = new FormData(form);
1564
+ const val = (k) => data.get(k)?.trim() || void 0;
1565
+ this.controller?.setUserInfo({
1566
+ name: val("name"),
1567
+ email: val("email"),
1568
+ phone: val("phone")
1569
+ });
1570
+ this.userInfoSatisfied = true;
1571
+ this.render();
1572
+ this.controller?.connect().catch(() => {});
1573
+ }
1574
+ /** Send a starter prompt (from a chip click). */
1575
+ submitPrompt(text) {
1576
+ if (!this.inputEl) return;
1577
+ this.inputEl.value = text;
1578
+ this.submit();
1579
+ }
1580
+ syncOpenState() {
1581
+ if (this.panelEl?.classList.contains("fullpage")) {
1582
+ this.inputEl?.focus();
1583
+ return;
1584
+ }
1585
+ this.panelEl?.classList.toggle("hidden", !this.open);
1586
+ this.launcherEl?.classList.toggle("hidden", this.open);
1587
+ if (this.open) this.inputEl?.focus();
1588
+ }
1589
+ /** Grow the textarea with its content, up to the CSS max-height. */
1590
+ autosize() {
1591
+ const ta = this.inputEl;
1592
+ if (!ta) return;
1593
+ ta.style.height = "auto";
1594
+ ta.style.height = `${ta.scrollHeight}px`;
1595
+ }
1596
+ renderMessages(greeting) {
1597
+ if (!this.messagesEl) return;
1598
+ this.messagesEl.replaceChildren();
1599
+ if (this.messages.length === 0 && greeting) this.messagesEl.appendChild(this.buildRow("assistant", this.greetingBubble(greeting)));
1600
+ if (!this.hasSent && this.messages.length === 0 && this.examplePrompts.length > 0) {
1601
+ const chips = document.createElement("div");
1602
+ chips.className = "prompts";
1603
+ for (const prompt of this.examplePrompts) {
1604
+ const chip = document.createElement("button");
1605
+ chip.type = "button";
1606
+ chip.className = "chip";
1607
+ chip.textContent = prompt;
1608
+ chip.addEventListener("click", () => this.submitPrompt(prompt));
1609
+ chips.appendChild(chip);
1610
+ }
1611
+ this.messagesEl.appendChild(chips);
1612
+ }
1613
+ for (const msg of this.messages) {
1614
+ const bubble = document.createElement("div");
1615
+ bubble.className = `bubble ${msg.role}`;
1616
+ if (msg.role === "assistant" && msg.streaming && !msg.text) {
1617
+ bubble.classList.add("typing");
1618
+ bubble.append(this.typingDot(), this.typingDot(), this.typingDot());
1619
+ } else if (msg.streaming) {
1620
+ bubble.classList.add("cursor");
1621
+ bubble.textContent = msg.text;
1622
+ } else bubble.textContent = msg.text;
1623
+ this.messagesEl.appendChild(this.buildRow(msg.role, bubble));
1624
+ if (msg.role === "assistant" && !msg.streaming && msg.citations && msg.citations.length > 0) this.messagesEl.appendChild(this.renderSources(msg.citations));
1625
+ }
1626
+ this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
1627
+ }
1628
+ /** Wrap a bubble in a `.row`, prefixing assistant rows with a mini avatar. */
1629
+ buildRow(role, bubble) {
1630
+ const row = document.createElement("div");
1631
+ row.className = `row ${role}`;
1632
+ if (role === "assistant") {
1633
+ const mini = document.createElement("div");
1634
+ mini.className = "mini";
1635
+ mini.innerHTML = ICON.bot;
1636
+ row.appendChild(mini);
1637
+ }
1638
+ row.appendChild(bubble);
1639
+ return row;
1640
+ }
1641
+ greetingBubble(greeting) {
1642
+ const b = document.createElement("div");
1643
+ b.className = "bubble assistant greeting";
1644
+ b.textContent = greeting;
1645
+ return b;
1646
+ }
1647
+ typingDot() {
1648
+ return document.createElement("i");
1649
+ }
1650
+ /**
1651
+ * Build the collapsible "Sources (N)" block for an assistant message's
1652
+ * citations. Title/snippet are set via `textContent` (never innerHTML) so
1653
+ * citation text can't inject markup; only the static chevron + numeric count
1654
+ * use innerHTML.
1655
+ */
1656
+ renderSources(citations) {
1657
+ const wrap = document.createElement("div");
1658
+ wrap.className = "sources";
1659
+ wrap.setAttribute("part", "sources");
1660
+ const details = document.createElement("details");
1661
+ details.open = true;
1662
+ const summary = document.createElement("summary");
1663
+ const chev = document.createElement("span");
1664
+ chev.className = "chev";
1665
+ chev.innerHTML = ICON.chev;
1666
+ const label = document.createElement("span");
1667
+ label.textContent = "Sources";
1668
+ const count = document.createElement("span");
1669
+ count.className = "count";
1670
+ count.textContent = String(citations.length);
1671
+ summary.append(chev, label, count);
1672
+ details.appendChild(summary);
1673
+ const list = document.createElement("ol");
1674
+ for (const c of citations) {
1675
+ const li = document.createElement("li");
1676
+ let titleEl;
1677
+ const safeUrl = safeHttpUrl(c.url);
1678
+ if (safeUrl) {
1679
+ const a = document.createElement("a");
1680
+ a.className = "src-title";
1681
+ a.href = safeUrl;
1682
+ a.target = "_blank";
1683
+ a.rel = "noopener noreferrer";
1684
+ titleEl = a;
1685
+ } else {
1686
+ titleEl = document.createElement("span");
1687
+ titleEl.className = "src-title";
1688
+ }
1689
+ titleEl.textContent = c.title || c.id || "Source";
1690
+ li.appendChild(titleEl);
1691
+ if (c.snippet) {
1692
+ const snip = document.createElement("span");
1693
+ snip.className = "src-snippet";
1694
+ snip.textContent = c.snippet;
1695
+ li.appendChild(snip);
1696
+ }
1697
+ list.appendChild(li);
1698
+ }
1699
+ details.appendChild(list);
1700
+ wrap.appendChild(details);
1701
+ return wrap;
1702
+ }
1703
+ renderStatus() {
1704
+ const label = {
1705
+ idle: "",
1706
+ connecting: "Connecting…",
1707
+ ready: "Online",
1708
+ error: "Connection issue",
1709
+ closed: "Disconnected"
1710
+ };
1711
+ if (this.statusEl) this.statusEl.textContent = label[this.status];
1712
+ if (this.dotEl) {
1713
+ const mod = this.status === "ready" ? "" : this.status === "connecting" ? " connecting" : this.status === "error" ? " error" : " off";
1714
+ this.dotEl.className = `dot${mod}`;
1715
+ }
1716
+ }
1717
+ renderComposerState() {
1718
+ const busy = this.status === "connecting";
1719
+ if (this.sendBtn) this.sendBtn.disabled = busy;
1720
+ if (this.inputEl) this.inputEl.disabled = busy;
1721
+ }
1722
+ submit() {
1723
+ if (!this.inputEl || !this.controller) return;
1724
+ const text = this.inputEl.value;
1725
+ if (!text.trim()) return;
1726
+ this.inputEl.value = "";
1727
+ this.hasSent = true;
1728
+ this.autosize();
1729
+ this.controller.send(text);
1730
+ }
1731
+ };
1732
+ function escapeHtml(value) {
1733
+ return value.replace(/[&<>"']/g, (c) => {
1734
+ switch (c) {
1735
+ case "&": return "&amp;";
1736
+ case "<": return "&lt;";
1737
+ case ">": return "&gt;";
1738
+ case "\"": return "&quot;";
1739
+ default: return "&#39;";
1740
+ }
1741
+ });
1742
+ }
1743
+ /** Register the custom element once. Safe to call multiple times. */
1744
+ function defineChatWidget() {
1745
+ if (typeof customElements !== "undefined" && !customElements.get("smooth-agent-chat")) customElements.define(ELEMENT_TAG, SmoothAgentChatElement);
1746
+ }
1747
+ /**
1748
+ * Programmatically create, configure, and append a widget to the page.
1749
+ * Returns the element so the host can drive `openChat()` / `closeChat()`.
1750
+ */
1751
+ function mountChatWidget(config, target = document.body) {
1752
+ defineChatWidget();
1753
+ const el = document.createElement(ELEMENT_TAG);
1754
+ el.configure(config);
1755
+ target.appendChild(el);
1756
+ return el;
1757
+ }
1758
+ /**
1759
+ * Ergonomic helper for the full-page layout: mounts a `<smooth-agent-chat>` in
1760
+ * `mode: "fullpage"` (no launcher — the chat fills its container/viewport with a
1761
+ * Smooth-branded header, a scrollable message list, and an input bar) and
1762
+ * returns the element.
1763
+ *
1764
+ * `target` defaults to `document.body`; pass a sized container to embed the
1765
+ * full-page chat inside a layout region (e.g. a `/chat` route shell or an
1766
+ * iframe). The `mode` is forced to `"fullpage"` regardless of the passed config.
1767
+ *
1768
+ * ```ts
1769
+ * mountFullPageChat({ endpoint: 'wss://…/ws', agentId: '…', agentName: 'Support' });
1770
+ * ```
1771
+ */
1772
+ function mountFullPageChat(config, target = document.body) {
1773
+ return mountChatWidget({
1774
+ ...config,
1775
+ mode: "fullpage"
1776
+ }, target);
1777
+ }
1778
+ //#endregion
1779
+ //#region src/standalone.ts
1780
+ defineChatWidget();
1781
+ /** Convenience alias matching the global API surface (`SmoothAgentChat.mount`). */
1782
+ function mount(config, target) {
1783
+ return mountChatWidget(config, target);
1784
+ }
1785
+ /**
1786
+ * Full-page convenience alias (`SmoothAgentChat.mountFullPage`): mounts the chat
1787
+ * in `mode: "fullpage"` so it fills its container/viewport with no launcher.
1788
+ */
1789
+ function mountFullPage(config, target) {
1790
+ return mountFullPageChat(config, target);
1791
+ }
1792
+ //#endregion
1793
+ exports.SmoothAgentChatElement = SmoothAgentChatElement;
1794
+ exports.defineChatWidget = defineChatWidget;
1795
+ exports.mount = mount;
1796
+ exports.mountChatWidget = mountChatWidget;
1797
+ exports.mountFullPage = mountFullPage;
1798
+ exports.mountFullPageChat = mountFullPageChat;
1799
+ return exports;
1800
+ })({});
1801
+
1802
+ //# sourceMappingURL=chat-widget.global.js.map