@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.
package/dist/index.js ADDED
@@ -0,0 +1,1296 @@
1
+ import { ProtocolError, SmoothAgentClient } from "@smooai/smooth-operator";
2
+ //#region src/config.ts
3
+ /** Resolve a partial config against the built-in defaults. */
4
+ function resolveConfig(config) {
5
+ const theme = config.theme ?? {};
6
+ const primary = theme.primary ?? "#00a6a6";
7
+ const primaryText = theme.primaryText ?? "#f8fafc";
8
+ const assistantBubble = theme.chatBubbleInbound ?? theme.assistantBubble ?? "#06134b";
9
+ const assistantBubbleText = theme.chatBubbleInboundText ?? theme.assistantBubbleText ?? "#f8fafc";
10
+ const userBubble = theme.chatBubbleOutbound ?? theme.userBubble ?? primary;
11
+ const userBubbleText = theme.chatBubbleOutboundText ?? theme.userBubbleText ?? primaryText;
12
+ return {
13
+ endpoint: config.endpoint,
14
+ mode: config.mode ?? "popover",
15
+ agentId: config.agentId,
16
+ agentName: config.agentName ?? "Assistant",
17
+ userName: config.userName,
18
+ userEmail: config.userEmail,
19
+ userPhone: config.userPhone,
20
+ placeholder: config.placeholder ?? "Type a message…",
21
+ greeting: config.greeting ?? "Hi! How can I help you today?",
22
+ connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
23
+ startOpen: config.startOpen ?? false,
24
+ examplePrompts: (config.examplePrompts ?? []).filter((p) => p.trim().length > 0).slice(0, 5),
25
+ requireName: config.requireName ?? false,
26
+ requireEmail: config.requireEmail ?? false,
27
+ requirePhone: config.requirePhone ?? false,
28
+ allowAnonymous: config.allowAnonymous ?? false,
29
+ theme: {
30
+ text: theme.text ?? "#f8fafc",
31
+ background: theme.background ?? "#040d30",
32
+ primary,
33
+ primaryText,
34
+ secondary: theme.secondary ?? "#ff6b6c",
35
+ assistantBubble,
36
+ assistantBubbleText,
37
+ userBubble,
38
+ userBubbleText,
39
+ border: theme.border ?? "rgba(255, 255, 255, 0.1)"
40
+ }
41
+ };
42
+ }
43
+ /**
44
+ * Whether the pre-chat identity form should gate the conversation: at least one
45
+ * field is required and anonymous chat is not allowed.
46
+ */
47
+ function needsUserInfo(resolved) {
48
+ return !resolved.allowAnonymous && (resolved.requireName || resolved.requireEmail || resolved.requirePhone);
49
+ }
50
+ //#endregion
51
+ //#region src/conversation.ts
52
+ /**
53
+ * ConversationController — the bridge between the widget UI and the
54
+ * `@smooai/smooth-operator` protocol client.
55
+ *
56
+ * This is the piece that was rewired: the original smooai widget spoke to
57
+ * `@smooai/realtime`; here every protocol action goes through {@link SmoothAgentClient}.
58
+ * The wire shapes are identical (the protocol was lifted from `@smooai/realtime`),
59
+ * so the swap is purely at the client-library boundary.
60
+ *
61
+ * Flow:
62
+ * 1. `connect()` → opens the WebSocket transport and `create_conversation_session`.
63
+ * 2. `send(text)` → `send_message`, streaming `stream_token` deltas into the
64
+ * in-progress assistant message, then the terminal
65
+ * `eventual_response`.
66
+ *
67
+ * The controller is UI-agnostic: it emits typed events and the view renders them.
68
+ */
69
+ /** Pull the final assistant text out of an `eventual_response` data payload. */
70
+ function extractFinalText(response) {
71
+ if (!response || typeof response !== "object") return null;
72
+ const r = response;
73
+ if (Array.isArray(r.responseParts)) return r.responseParts.filter((p) => typeof p === "string").join("\n\n");
74
+ return null;
75
+ }
76
+ /**
77
+ * Pull the grounding {@link Citation}s out of a terminal `eventual_response`.
78
+ *
79
+ * The protocol client types these (`eventual_response.data.data.citations`),
80
+ * but they're optional and back-compatible — absent when the turn used no
81
+ * knowledge sources. We read them defensively (tolerating their total absence,
82
+ * non-array shapes, and missing fields) so a server that doesn't emit them, or
83
+ * an older client, can't break rendering. Each citation always carries
84
+ * `id`/`title`/`snippet`/`score`; `url` is present only for web-sourced docs.
85
+ */
86
+ function extractCitations(inner) {
87
+ if (!inner || typeof inner !== "object") return [];
88
+ const raw = inner.citations;
89
+ if (!Array.isArray(raw)) return [];
90
+ const out = [];
91
+ for (const c of raw) {
92
+ if (!c || typeof c !== "object") continue;
93
+ const obj = c;
94
+ const id = typeof obj.id === "string" ? obj.id : "";
95
+ const title = typeof obj.title === "string" ? obj.title : id || "Source";
96
+ const snippet = typeof obj.snippet === "string" ? obj.snippet : "";
97
+ const url = typeof obj.url === "string" && obj.url ? obj.url : void 0;
98
+ const score = typeof obj.score === "number" ? obj.score : 0;
99
+ out.push({
100
+ id,
101
+ title,
102
+ snippet,
103
+ score,
104
+ url
105
+ });
106
+ }
107
+ return out;
108
+ }
109
+ var ConversationController = class {
110
+ config;
111
+ events;
112
+ client = null;
113
+ sessionId = null;
114
+ messages = [];
115
+ status = "idle";
116
+ seq = 0;
117
+ /** Visitor identity, seeded from config and updated by the pre-chat form. */
118
+ identity;
119
+ /** requestId of the in-flight turn — used to resume OTP / tool confirmations. */
120
+ activeRequestId = null;
121
+ interrupt = null;
122
+ constructor(config, events) {
123
+ this.config = config;
124
+ this.events = events;
125
+ this.identity = {
126
+ name: config.userName,
127
+ email: config.userEmail,
128
+ phone: config.userPhone
129
+ };
130
+ }
131
+ get connectionStatus() {
132
+ return this.status;
133
+ }
134
+ /** Merge in visitor identity (from the pre-chat form). Applied on next connect. */
135
+ setUserInfo(info) {
136
+ this.identity = {
137
+ ...this.identity,
138
+ ...info
139
+ };
140
+ }
141
+ setInterrupt(interrupt) {
142
+ this.interrupt = interrupt;
143
+ this.events.onInterrupt?.(interrupt);
144
+ }
145
+ /** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */
146
+ verifyOtp(code) {
147
+ if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "otp") return;
148
+ this.client.verifyOtp({
149
+ sessionId: this.sessionId,
150
+ requestId: this.activeRequestId,
151
+ code
152
+ });
153
+ }
154
+ /** Approve or reject a pending tool write to resume the paused turn. */
155
+ confirmTool(approved) {
156
+ if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "confirm") return;
157
+ this.client.confirmToolAction({
158
+ sessionId: this.sessionId,
159
+ requestId: this.activeRequestId,
160
+ approved
161
+ });
162
+ this.setInterrupt(null);
163
+ }
164
+ nextId(prefix) {
165
+ this.seq += 1;
166
+ return `${prefix}-${this.seq}-${Date.now().toString(36)}`;
167
+ }
168
+ setStatus(status, detail) {
169
+ this.status = status;
170
+ this.events.onStatus(status, detail);
171
+ }
172
+ emitMessages() {
173
+ this.events.onMessages(this.messages.map((m) => ({ ...m })));
174
+ }
175
+ /** Open the transport and create a conversation session. Idempotent. */
176
+ async connect() {
177
+ if (this.status === "connecting" || this.status === "ready") return;
178
+ this.setStatus("connecting");
179
+ try {
180
+ this.client = new SmoothAgentClient({ url: this.config.endpoint });
181
+ await this.client.connect();
182
+ const session = await this.client.createConversationSession({
183
+ agentId: this.config.agentId,
184
+ userName: this.identity.name,
185
+ userEmail: this.identity.email,
186
+ ...this.identity.phone ? { metadata: { userPhone: this.identity.phone } } : {}
187
+ });
188
+ this.sessionId = session.sessionId;
189
+ this.setStatus("ready");
190
+ } catch (err) {
191
+ this.setStatus("error", err instanceof Error ? err.message : String(err));
192
+ throw err;
193
+ }
194
+ }
195
+ /**
196
+ * Submit a user message. Appends the user bubble immediately, then streams the
197
+ * assistant reply token-by-token, finalizing on `eventual_response`.
198
+ */
199
+ async send(text) {
200
+ const trimmed = text.trim();
201
+ if (!trimmed) return;
202
+ if (!this.client || !this.sessionId || this.status !== "ready") await this.connect();
203
+ if (!this.client || !this.sessionId) throw new Error("Conversation is not connected");
204
+ this.messages.push({
205
+ id: this.nextId("u"),
206
+ role: "user",
207
+ text: trimmed,
208
+ streaming: false
209
+ });
210
+ const assistant = {
211
+ id: this.nextId("a"),
212
+ role: "assistant",
213
+ text: "",
214
+ streaming: true
215
+ };
216
+ this.messages.push(assistant);
217
+ this.emitMessages();
218
+ try {
219
+ const turn = this.client.sendMessage({
220
+ sessionId: this.sessionId,
221
+ message: trimmed,
222
+ stream: true
223
+ });
224
+ this.activeRequestId = turn.requestId;
225
+ for await (const event of turn) if (event.type === "stream_token") {
226
+ const token = event.token ?? event.data?.token ?? "";
227
+ if (token) {
228
+ assistant.text += token;
229
+ this.emitMessages();
230
+ }
231
+ } else this.handleTurnEvent(event);
232
+ const inner = (await turn).data?.data;
233
+ const finalText = extractFinalText(inner?.response);
234
+ if (finalText && finalText.length > assistant.text.length) assistant.text = finalText;
235
+ if (!assistant.text) assistant.text = "(no response)";
236
+ const citations = extractCitations(inner);
237
+ if (citations.length > 0) assistant.citations = citations;
238
+ assistant.streaming = false;
239
+ this.emitMessages();
240
+ } catch (err) {
241
+ assistant.streaming = false;
242
+ const message = err instanceof ProtocolError ? `Error: ${err.message}` : this.config.connectionErrorMessage ?? "We couldn't reach the chat.";
243
+ assistant.text = assistant.text ? `${assistant.text}\n\n${message}` : message;
244
+ this.emitMessages();
245
+ this.setStatus("error", err instanceof Error ? err.message : String(err));
246
+ } finally {
247
+ this.activeRequestId = null;
248
+ this.setInterrupt(null);
249
+ }
250
+ }
251
+ /** Map a non-token turn event (OTP / tool-confirmation lifecycle) to interrupt state. */
252
+ handleTurnEvent(event) {
253
+ const inner = event.data?.data ?? {};
254
+ const str = (v) => typeof v === "string" ? v : void 0;
255
+ const num = (v) => typeof v === "number" ? v : void 0;
256
+ switch (event.type) {
257
+ case "otp_verification_required": {
258
+ const channels = Array.isArray(inner.availableChannels) ? inner.availableChannels.filter((c) => c === "email" || c === "sms") : ["email"];
259
+ this.setInterrupt({
260
+ kind: "otp",
261
+ toolId: str(inner.toolId),
262
+ actionDescription: str(inner.actionDescription),
263
+ availableChannels: channels.length > 0 ? channels : ["email"]
264
+ });
265
+ break;
266
+ }
267
+ case "otp_sent":
268
+ if (this.interrupt?.kind === "otp") this.setInterrupt({
269
+ ...this.interrupt,
270
+ sent: {
271
+ channel: str(inner.channel),
272
+ maskedDestination: str(inner.maskedDestination)
273
+ },
274
+ error: void 0
275
+ });
276
+ break;
277
+ case "otp_verified":
278
+ if (this.interrupt?.kind === "otp") this.setInterrupt(null);
279
+ break;
280
+ case "otp_invalid":
281
+ if (this.interrupt?.kind === "otp") this.setInterrupt({
282
+ ...this.interrupt,
283
+ error: str(inner.message) ?? "That code was incorrect.",
284
+ attemptsRemaining: num(inner.attemptsRemaining)
285
+ });
286
+ break;
287
+ case "write_confirmation_required":
288
+ this.setInterrupt({
289
+ kind: "confirm",
290
+ toolId: str(inner.toolId),
291
+ actionDescription: str(inner.actionDescription)
292
+ });
293
+ break;
294
+ default: break;
295
+ }
296
+ }
297
+ /** Tear down the underlying client. */
298
+ disconnect() {
299
+ this.client?.disconnect("widget closed");
300
+ this.client = null;
301
+ this.sessionId = null;
302
+ this.activeRequestId = null;
303
+ this.setInterrupt(null);
304
+ this.setStatus("closed");
305
+ }
306
+ };
307
+ //#endregion
308
+ //#region src/logo.ts
309
+ /**
310
+ * The Smooth logo, inlined as an SVG string so the full-page header can render
311
+ * it without a separate network fetch (the IIFE bundle is self-contained).
312
+ *
313
+ * GENERATED from `assets/smooth-logo.svg` — do not edit by hand. Regenerate with:
314
+ * node -e ... (see the commit that added this file)
315
+ */
316
+ 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>";
317
+ //#endregion
318
+ //#region src/styles.ts
319
+ /**
320
+ * Render the widget's scoped stylesheet — the "Aurora Glass" design system.
321
+ *
322
+ * Every brand value is injected as a CSS custom property on `:host` so a host
323
+ * page can override colors per-instance and the rules below stay static. Two
324
+ * extra tokens are *derived in CSS* from the brand vars so they adapt to any
325
+ * theme (light or dark) without the caller supplying them:
326
+ *
327
+ * --sac-primary-2 a darker shade of `primary`, used as the second stop of the
328
+ * launcher / send / user-bubble gradients (depth without a
329
+ * second brand input).
330
+ * --sac-surface-2 a faint wash derived from `text`, used for inset chrome
331
+ * (composer field, close button, source cards). On a dark
332
+ * panel it reads as a light overlay; on a light panel, dark.
333
+ *
334
+ * Deliberately framework-light: no Tailwind, no runtime CSS-in-JS — just a string
335
+ * the web component drops into its shadow root. Modern color features
336
+ * (`color-mix`) are used intentionally; the widget targets evergreen browsers.
337
+ *
338
+ * `mode` switches host positioning + panel sizing between the floating popover
339
+ * (default) and the full-page layout (fills its container/viewport).
340
+ */
341
+ function buildStyles(theme, mode = "popover") {
342
+ return `
343
+ :host {
344
+ --sac-text: ${theme.text};
345
+ --sac-bg: ${theme.background};
346
+ --sac-primary: ${theme.primary};
347
+ --sac-primary-text: ${theme.primaryText};
348
+ --sac-assistant-bubble: ${theme.assistantBubble};
349
+ --sac-assistant-bubble-text: ${theme.assistantBubbleText};
350
+ --sac-user-bubble: ${theme.userBubble};
351
+ --sac-user-bubble-text: ${theme.userBubbleText};
352
+ --sac-border: ${theme.border};
353
+
354
+ /* Derived tokens — adapt to any brand color without a second input. */
355
+ --sac-primary-2: color-mix(in srgb, var(--sac-primary) 78%, #000 22%);
356
+ --sac-surface-2: color-mix(in srgb, var(--sac-text) 5%, transparent);
357
+ --sac-radius: 22px;
358
+ --sac-ease: cubic-bezier(.16, 1, .3, 1);
359
+
360
+ ${mode === "fullpage" ? `/* Full-page: fill the host's box (sized by its container, else the viewport). */
361
+ display: block;
362
+ position: relative;
363
+ width: 100%;
364
+ height: 100%;
365
+ min-height: 100vh;` : `/* Popover: float in the bottom-right corner. */
366
+ position: fixed;
367
+ bottom: 24px;
368
+ right: 24px;
369
+ z-index: 2147483000;`}
370
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
371
+ -webkit-font-smoothing: antialiased;
372
+ }
373
+
374
+ * { box-sizing: border-box; }
375
+
376
+ /* ───────────────────────────── Launcher ───────────────────────────── */
377
+ .launcher {
378
+ position: relative;
379
+ width: 62px;
380
+ height: 62px;
381
+ border-radius: 50%;
382
+ border: none;
383
+ cursor: pointer;
384
+ padding: 0;
385
+ background: radial-gradient(120% 120% at 30% 20%,
386
+ color-mix(in srgb, var(--sac-primary) 78%, #fff 22%) 0%,
387
+ var(--sac-primary) 42%,
388
+ var(--sac-primary-2) 130%);
389
+ color: var(--sac-primary-text);
390
+ display: flex;
391
+ align-items: center;
392
+ justify-content: center;
393
+ box-shadow:
394
+ 0 1px 0 rgba(255, 255, 255, .25) inset,
395
+ 0 10px 24px -6px color-mix(in srgb, var(--sac-primary) 55%, transparent),
396
+ 0 18px 50px -12px rgba(0, 0, 0, .6);
397
+ transition: transform .45s var(--sac-ease), box-shadow .45s var(--sac-ease), opacity .3s ease;
398
+ isolation: isolate;
399
+ }
400
+ /* Breathing presence ring. */
401
+ .launcher::before {
402
+ content: '';
403
+ position: absolute;
404
+ inset: -6px;
405
+ border-radius: 50%;
406
+ z-index: -1;
407
+ background: radial-gradient(closest-side, color-mix(in srgb, var(--sac-primary) 45%, transparent), transparent 75%);
408
+ animation: sac-breathe 3.4s ease-in-out infinite;
409
+ }
410
+ @keyframes sac-breathe { 0%, 100% { transform: scale(1); opacity: .55 } 50% { transform: scale(1.28); opacity: 0 } }
411
+ .launcher:hover {
412
+ transform: translateY(-3px) scale(1.06);
413
+ box-shadow:
414
+ 0 1px 0 rgba(255, 255, 255, .3) inset,
415
+ 0 16px 30px -6px color-mix(in srgb, var(--sac-primary) 60%, transparent),
416
+ 0 26px 60px -14px rgba(0, 0, 0, .7);
417
+ }
418
+ .launcher:active { transform: translateY(-1px) scale(.98); }
419
+ .launcher .ico { width: 27px; height: 27px; display: block; transition: transform .4s var(--sac-ease); }
420
+ .launcher:hover .ico { transform: rotate(-6deg) scale(1.04); }
421
+ .launcher.hidden { opacity: 0; transform: scale(.4) translateY(10px); pointer-events: none; }
422
+
423
+ /* ─────────────────────────────── Panel ────────────────────────────── */
424
+ .panel {
425
+ width: 390px;
426
+ max-width: calc(100vw - 40px);
427
+ height: 600px;
428
+ max-height: calc(100vh - 56px);
429
+ display: flex;
430
+ flex-direction: column;
431
+ overflow: hidden;
432
+ border-radius: var(--sac-radius);
433
+ background: linear-gradient(180deg, color-mix(in srgb, var(--sac-bg) 92%, #fff 8%) 0%, var(--sac-bg) 22%);
434
+ color: var(--sac-text);
435
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
436
+ box-shadow:
437
+ 0 0 0 1px rgba(255, 255, 255, .03) inset,
438
+ 0 40px 80px -24px rgba(0, 0, 0, .65),
439
+ 0 16px 40px -20px rgba(0, 0, 0, .5);
440
+ transform-origin: bottom right;
441
+ animation: sac-panel-in .5s var(--sac-ease) both;
442
+ position: relative;
443
+ }
444
+ @keyframes sac-panel-in { from { opacity: 0; transform: translateY(16px) scale(.92) } to { opacity: 1; transform: none } }
445
+ .panel.hidden { display: none; }
446
+ /* Ambient brand glow bleeding from the top of the panel. */
447
+ .panel::before {
448
+ content: '';
449
+ position: absolute;
450
+ left: 0; right: 0; top: 0;
451
+ height: 140px;
452
+ pointer-events: none;
453
+ background: radial-gradient(120% 100% at 50% 0%, color-mix(in srgb, var(--sac-primary) 22%, transparent), transparent 70%);
454
+ }
455
+ /* Full-page: the panel becomes the whole surface. */
456
+ .panel.fullpage {
457
+ width: 100%;
458
+ height: 100%;
459
+ min-height: 100vh;
460
+ max-width: none;
461
+ max-height: none;
462
+ border: none;
463
+ border-radius: 0;
464
+ box-shadow: none;
465
+ animation: none;
466
+ }
467
+
468
+ /* ─────────────────────────────── Header ───────────────────────────── */
469
+ .header {
470
+ position: relative;
471
+ display: flex;
472
+ align-items: center;
473
+ gap: 12px;
474
+ padding: 16px 16px 14px;
475
+ }
476
+ .avatar {
477
+ width: 40px;
478
+ height: 40px;
479
+ border-radius: 13px;
480
+ flex: none;
481
+ background: linear-gradient(140deg, var(--sac-primary), var(--sac-primary-2));
482
+ display: flex;
483
+ align-items: center;
484
+ justify-content: center;
485
+ color: var(--sac-primary-text);
486
+ box-shadow:
487
+ 0 6px 16px -6px color-mix(in srgb, var(--sac-primary) 60%, transparent),
488
+ 0 1px 0 rgba(255, 255, 255, .25) inset;
489
+ }
490
+ .avatar svg { width: 22px; height: 22px; }
491
+ .avatar .logo-wrap { display: flex; }
492
+ .avatar .logo { height: 22px; width: auto; display: block; }
493
+ .meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
494
+ .title { font-weight: 650; font-size: 15.5px; letter-spacing: -.01em; line-height: 1.1; }
495
+ .status {
496
+ display: flex;
497
+ align-items: center;
498
+ gap: 6px;
499
+ font-size: 12px;
500
+ color: color-mix(in srgb, var(--sac-text) 62%, transparent);
501
+ }
502
+ .dot {
503
+ width: 7px; height: 7px;
504
+ border-radius: 50%;
505
+ flex: none;
506
+ background: #34d399;
507
+ color: #34d399;
508
+ box-shadow: 0 0 0 0 rgba(52, 211, 153, .6);
509
+ animation: sac-pulse 2.4s ease-out infinite;
510
+ }
511
+ .dot.connecting { background: #fbbf24; color: #fbbf24; animation: sac-pulse 1.1s ease-out infinite; }
512
+ .dot.error { background: #f87171; color: #f87171; animation: none; }
513
+ .dot.off { background: #94a3b8; color: #94a3b8; animation: none; }
514
+ @keyframes sac-pulse {
515
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, currentColor 55%, transparent) }
516
+ 70% { box-shadow: 0 0 0 6px transparent }
517
+ 100% { box-shadow: 0 0 0 0 transparent }
518
+ }
519
+ .close {
520
+ margin-left: auto;
521
+ width: 32px; height: 32px;
522
+ border-radius: 10px;
523
+ border: none;
524
+ cursor: pointer;
525
+ background: var(--sac-surface-2);
526
+ color: inherit;
527
+ display: flex;
528
+ align-items: center;
529
+ justify-content: center;
530
+ transition: background .2s ease, transform .2s ease;
531
+ }
532
+ .close:hover { background: color-mix(in srgb, var(--sac-text) 12%, transparent); transform: translateY(1px); }
533
+ .close svg { width: 16px; height: 16px; opacity: .8; }
534
+ .powered { margin-left: auto; font-size: 10.5px; letter-spacing: .02em; opacity: .6; }
535
+ .header-sep { height: 1px; margin: 0 16px; background: linear-gradient(90deg, transparent, var(--sac-border), transparent); }
536
+
537
+ /* Full-page header: taller, logo-led, no close. */
538
+ .panel.fullpage .header { padding: 18px 22px; }
539
+ .panel.fullpage .avatar { width: 44px; height: 44px; }
540
+ .panel.fullpage .avatar .logo { height: 26px; }
541
+
542
+ /* ────────────────────────────── Messages ──────────────────────────── */
543
+ .messages {
544
+ flex: 1;
545
+ overflow-y: auto;
546
+ padding: 18px 16px 8px;
547
+ display: flex;
548
+ flex-direction: column;
549
+ gap: 12px;
550
+ scroll-behavior: smooth;
551
+ }
552
+ .messages::-webkit-scrollbar { width: 8px; }
553
+ .messages::-webkit-scrollbar-thumb {
554
+ background: color-mix(in srgb, var(--sac-text) 14%, transparent);
555
+ border-radius: 99px;
556
+ border: 2px solid transparent;
557
+ background-clip: padding-box;
558
+ }
559
+ .messages::-webkit-scrollbar-thumb:hover {
560
+ background: color-mix(in srgb, var(--sac-text) 24%, transparent);
561
+ background-clip: padding-box;
562
+ }
563
+
564
+ .row {
565
+ display: flex;
566
+ gap: 9px;
567
+ max-width: 88%;
568
+ animation: sac-msg-in .42s var(--sac-ease) both;
569
+ }
570
+ @keyframes sac-msg-in { from { opacity: 0; transform: translateY(8px) } to { opacity: 1; transform: none } }
571
+ .row.user { align-self: flex-end; flex-direction: row-reverse; }
572
+ .row.assistant { align-self: flex-start; }
573
+ .mini {
574
+ width: 26px; height: 26px;
575
+ border-radius: 9px;
576
+ flex: none;
577
+ align-self: flex-end;
578
+ background: linear-gradient(140deg, var(--sac-primary), var(--sac-primary-2));
579
+ display: flex;
580
+ align-items: center;
581
+ justify-content: center;
582
+ color: var(--sac-primary-text);
583
+ }
584
+ .mini svg { width: 15px; height: 15px; }
585
+
586
+ .bubble {
587
+ padding: 11px 14px;
588
+ border-radius: 16px;
589
+ font-size: 14px;
590
+ line-height: 1.5;
591
+ white-space: pre-wrap;
592
+ word-break: break-word;
593
+ position: relative;
594
+ }
595
+ .bubble.assistant {
596
+ background: linear-gradient(180deg, color-mix(in srgb, var(--sac-assistant-bubble) 86%, #fff 5%), var(--sac-assistant-bubble));
597
+ color: var(--sac-assistant-bubble-text);
598
+ border: 1px solid color-mix(in srgb, var(--sac-text) 8%, transparent);
599
+ border-bottom-left-radius: 5px;
600
+ box-shadow: 0 2px 8px -4px rgba(0, 0, 0, .4);
601
+ }
602
+ .bubble.user {
603
+ background: linear-gradient(165deg,
604
+ color-mix(in srgb, var(--sac-user-bubble) 88%, #fff 12%),
605
+ var(--sac-user-bubble) 60%,
606
+ color-mix(in srgb, var(--sac-user-bubble) 80%, var(--sac-primary-2) 20%));
607
+ color: var(--sac-user-bubble-text);
608
+ border-bottom-right-radius: 5px;
609
+ box-shadow: 0 6px 16px -8px color-mix(in srgb, var(--sac-primary) 50%, transparent);
610
+ }
611
+ .bubble.greeting {
612
+ background: transparent;
613
+ border: 1px dashed color-mix(in srgb, var(--sac-text) 14%, transparent);
614
+ color: color-mix(in srgb, var(--sac-text) 80%, transparent);
615
+ box-shadow: none;
616
+ }
617
+
618
+ /* Typing indicator (assistant bubble with no text yet). */
619
+ .bubble.typing { display: flex; gap: 4px; padding: 14px 15px; }
620
+ .bubble.typing i {
621
+ width: 7px; height: 7px;
622
+ border-radius: 50%;
623
+ background: color-mix(in srgb, var(--sac-assistant-bubble-text) 55%, transparent);
624
+ animation: sac-typing 1.3s ease-in-out infinite;
625
+ }
626
+ .bubble.typing i:nth-child(2) { animation-delay: .18s; }
627
+ .bubble.typing i:nth-child(3) { animation-delay: .36s; }
628
+ @keyframes sac-typing { 0%, 60%, 100% { transform: translateY(0); opacity: .4 } 30% { transform: translateY(-5px); opacity: 1 } }
629
+
630
+ .cursor::after {
631
+ content: '';
632
+ display: inline-block;
633
+ width: 2px; height: 1.05em;
634
+ margin-left: 2px;
635
+ vertical-align: -2px;
636
+ border-radius: 2px;
637
+ background: currentColor;
638
+ animation: sac-blink 1s steps(2, start) infinite;
639
+ }
640
+ @keyframes sac-blink { to { opacity: 0 } }
641
+
642
+ /* Full-page: center the conversation in a readable column. */
643
+ .panel.fullpage .messages { padding: 26px 20px; }
644
+ .panel.fullpage .row { max-width: 760px; width: 100%; margin-left: auto; margin-right: auto; }
645
+ .panel.fullpage .row.user { max-width: 80%; margin-right: 0; }
646
+
647
+ /* ───────────────── Sources (grounding citations) ──────────────────── */
648
+ .sources {
649
+ align-self: flex-start;
650
+ max-width: 88%;
651
+ margin: -4px 0 0 35px;
652
+ }
653
+ .panel.fullpage .sources { max-width: 760px; width: 100%; margin-left: auto; margin-right: auto; }
654
+ .sources summary {
655
+ cursor: pointer;
656
+ list-style: none;
657
+ display: inline-flex;
658
+ align-items: center;
659
+ gap: 7px;
660
+ font-size: 12px;
661
+ font-weight: 600;
662
+ color: color-mix(in srgb, var(--sac-text) 70%, transparent);
663
+ padding: 5px 0;
664
+ user-select: none;
665
+ }
666
+ .sources summary::-webkit-details-marker { display: none; }
667
+ .sources .chev { transition: transform .2s var(--sac-ease); flex: none; }
668
+ .sources details[open] .chev { transform: rotate(90deg); }
669
+ .sources .count {
670
+ background: color-mix(in srgb, var(--sac-primary) 18%, transparent);
671
+ color: color-mix(in srgb, var(--sac-primary) 92%, #fff);
672
+ font-size: 10.5px;
673
+ font-weight: 700;
674
+ padding: 1px 7px;
675
+ border-radius: 99px;
676
+ }
677
+ .sources ol { list-style: none; margin: 6px 0 2px; padding: 0; display: flex; flex-direction: column; gap: 7px; }
678
+ .sources li {
679
+ background: var(--sac-surface-2);
680
+ border: 1px solid color-mix(in srgb, var(--sac-border) 70%, transparent);
681
+ border-left: 2px solid var(--sac-primary);
682
+ border-radius: 9px;
683
+ padding: 8px 10px;
684
+ }
685
+ .sources .src-title {
686
+ color: color-mix(in srgb, var(--sac-primary) 92%, #fff);
687
+ font-weight: 600;
688
+ font-size: 12.5px;
689
+ text-decoration: none;
690
+ word-break: break-word;
691
+ }
692
+ .sources a.src-title:hover { text-decoration: underline; }
693
+ .sources span.src-title { color: var(--sac-text); opacity: .95; }
694
+ .sources .src-snippet {
695
+ display: block;
696
+ margin-top: 3px;
697
+ font-size: 11.5px;
698
+ line-height: 1.45;
699
+ color: color-mix(in srgb, var(--sac-text) 55%, transparent);
700
+ white-space: normal;
701
+ }
702
+
703
+ /* ────────────────────────────── Composer ──────────────────────────── */
704
+ .composer-wrap { padding: 12px 14px calc(12px + env(safe-area-inset-bottom)); }
705
+ .composer {
706
+ display: flex;
707
+ align-items: flex-end;
708
+ gap: 8px;
709
+ padding: 7px 7px 7px 14px;
710
+ border-radius: 18px;
711
+ background: var(--sac-surface-2);
712
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
713
+ transition: border-color .25s ease, box-shadow .25s ease, background .25s ease;
714
+ }
715
+ .composer:focus-within {
716
+ border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent);
717
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent);
718
+ }
719
+ .composer textarea {
720
+ flex: 1;
721
+ resize: none;
722
+ border: none;
723
+ background: transparent;
724
+ color: var(--sac-text);
725
+ font-family: inherit;
726
+ font-size: 14px;
727
+ line-height: 1.45;
728
+ max-height: 120px;
729
+ padding: 6px 0;
730
+ outline: none;
731
+ }
732
+ .composer textarea::placeholder { color: color-mix(in srgb, var(--sac-text) 42%, transparent); }
733
+ .send {
734
+ width: 38px; height: 38px;
735
+ flex: none;
736
+ border: none;
737
+ border-radius: 13px;
738
+ cursor: pointer;
739
+ display: flex;
740
+ align-items: center;
741
+ justify-content: center;
742
+ background: linear-gradient(150deg, var(--sac-primary), var(--sac-primary-2));
743
+ color: var(--sac-primary-text);
744
+ box-shadow:
745
+ 0 6px 14px -6px color-mix(in srgb, var(--sac-primary) 65%, transparent),
746
+ 0 1px 0 rgba(255, 255, 255, .25) inset;
747
+ transition: transform .2s var(--sac-ease), box-shadow .2s var(--sac-ease), opacity .2s ease;
748
+ }
749
+ .send svg { width: 18px; height: 18px; }
750
+ .send:hover { transform: translateY(-1px) scale(1.05); }
751
+ .send:active { transform: scale(.94); }
752
+ .send:disabled { opacity: .4; cursor: default; transform: none; box-shadow: none; }
753
+ .footer {
754
+ text-align: center;
755
+ margin-top: 9px;
756
+ font-size: 10.5px;
757
+ letter-spacing: .04em;
758
+ color: color-mix(in srgb, var(--sac-text) 38%, transparent);
759
+ }
760
+ .footer b { font-weight: 600; color: color-mix(in srgb, var(--sac-text) 55%, transparent); }
761
+
762
+ /* ─────────────────── Pre-chat identity form ───────────────────────── */
763
+ .prechat { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 18px; padding: 22px 20px; }
764
+ .pc-head { text-align: center; }
765
+ .pc-title { font-size: 17px; font-weight: 650; letter-spacing: -.01em; }
766
+ .pc-sub { margin-top: 4px; font-size: 13px; color: color-mix(in srgb, var(--sac-text) 60%, transparent); }
767
+ .pc-form { display: flex; flex-direction: column; gap: 12px; }
768
+ .pc-field { display: flex; flex-direction: column; gap: 5px; }
769
+ .pc-field span { font-size: 12px; font-weight: 600; color: color-mix(in srgb, var(--sac-text) 70%, transparent); }
770
+ .pc-field input {
771
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
772
+ background: var(--sac-surface-2);
773
+ color: var(--sac-text);
774
+ border-radius: 12px;
775
+ padding: 11px 13px;
776
+ font-family: inherit;
777
+ font-size: 14px;
778
+ outline: none;
779
+ transition: border-color .2s ease, box-shadow .2s ease;
780
+ }
781
+ .pc-field input::placeholder { color: color-mix(in srgb, var(--sac-text) 42%, transparent); }
782
+ .pc-field input:focus {
783
+ border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent);
784
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent);
785
+ }
786
+ .pc-submit {
787
+ margin-top: 4px;
788
+ border: none;
789
+ border-radius: 13px;
790
+ padding: 12px;
791
+ cursor: pointer;
792
+ background: linear-gradient(150deg, var(--sac-primary), var(--sac-primary-2));
793
+ color: var(--sac-primary-text);
794
+ font-weight: 650;
795
+ font-size: 14px;
796
+ box-shadow: 0 6px 14px -6px color-mix(in srgb, var(--sac-primary) 65%, transparent), 0 1px 0 rgba(255, 255, 255, .25) inset;
797
+ transition: transform .2s var(--sac-ease);
798
+ }
799
+ .pc-submit:hover { transform: translateY(-1px); }
800
+ .pc-submit:active { transform: scale(.98); }
801
+
802
+ /* ─────────────────── Starter-prompt chips ─────────────────────────── */
803
+ .prompts { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 2px 35px; }
804
+ .panel.fullpage .prompts { margin-left: auto; margin-right: auto; max-width: 760px; width: 100%; }
805
+ .chip {
806
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
807
+ background: var(--sac-surface-2);
808
+ color: var(--sac-text);
809
+ border-radius: 999px;
810
+ padding: 8px 13px;
811
+ font-family: inherit;
812
+ font-size: 12.5px;
813
+ cursor: pointer;
814
+ text-align: left;
815
+ transition: border-color .2s ease, background .2s ease, transform .2s ease;
816
+ }
817
+ .chip:hover {
818
+ border-color: color-mix(in srgb, var(--sac-primary) 50%, transparent);
819
+ background: color-mix(in srgb, var(--sac-primary) 10%, var(--sac-surface-2));
820
+ transform: translateY(-1px);
821
+ }
822
+
823
+ .hidden { display: none !important; }
824
+
825
+ @media (prefers-reduced-motion: reduce) {
826
+ .launcher::before, .dot, .bubble.typing i { animation: none !important; }
827
+ .panel, .row, .launcher, .send, .close { animation: none !important; transition: none !important; }
828
+ }
829
+ `;
830
+ }
831
+ //#endregion
832
+ //#region src/element.ts
833
+ const ELEMENT_TAG = "smooth-agent-chat";
834
+ const OBSERVED = [
835
+ "endpoint",
836
+ "agent-id",
837
+ "agent-name",
838
+ "placeholder",
839
+ "greeting",
840
+ "start-open",
841
+ "mode"
842
+ ];
843
+ /**
844
+ * Inline SVG icons (static, trusted strings — never interpolated with user data).
845
+ * Kept here so the IIFE bundle is self-contained: no icon-font or network fetch.
846
+ */
847
+ const ICON = {
848
+ /** Launcher — a speech bubble carrying a spark (chat + AI). */
849
+ 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>`,
850
+ /** Small assistant avatar used beside each assistant message. */
851
+ 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>`,
852
+ /** Close (collapse panel) — a downward chevron. */
853
+ 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>`,
854
+ /** Send — an upward arrow. */
855
+ 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>`,
856
+ /** Sources disclosure caret. */
857
+ 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>`
858
+ };
859
+ /**
860
+ * Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
861
+ *
862
+ * SECURITY: citation URLs originate from indexed content (web / GitHub
863
+ * connectors), which can be attacker-influenceable. Assigning an arbitrary
864
+ * string to `<a>.href` allows `javascript:`/`data:`/`vbscript:` URLs that
865
+ * execute on click — a stored-XSS vector. Only http(s) links are rendered as
866
+ * anchors; anything else falls back to plain text.
867
+ */
868
+ function safeHttpUrl(url) {
869
+ if (!url) return null;
870
+ try {
871
+ const parsed = new URL(url);
872
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
873
+ } catch {
874
+ return null;
875
+ }
876
+ }
877
+ var SmoothAgentChatElement = class extends HTMLElement {
878
+ static get observedAttributes() {
879
+ return OBSERVED;
880
+ }
881
+ root;
882
+ controller = null;
883
+ overrides = {};
884
+ open = false;
885
+ messages = [];
886
+ status = "idle";
887
+ mounted = false;
888
+ /** True once the visitor has cleared the pre-chat identity gate (or it's not needed). */
889
+ userInfoSatisfied = false;
890
+ /** True after the visitor has sent their first message (hides starter chips). */
891
+ hasSent = false;
892
+ /** Starter prompts shown as chips in the empty state. */
893
+ examplePrompts = [];
894
+ panelEl = null;
895
+ launcherEl = null;
896
+ messagesEl = null;
897
+ statusEl = null;
898
+ dotEl = null;
899
+ inputEl = null;
900
+ sendBtn = null;
901
+ constructor() {
902
+ super();
903
+ this.root = this.attachShadow({ mode: "open" });
904
+ }
905
+ connectedCallback() {
906
+ this.mounted = true;
907
+ this.render();
908
+ }
909
+ disconnectedCallback() {
910
+ this.mounted = false;
911
+ this.controller?.disconnect();
912
+ this.controller = null;
913
+ }
914
+ attributeChangedCallback() {
915
+ if (this.mounted) this.render();
916
+ }
917
+ /**
918
+ * Programmatically merge config overrides (endpoint, agentId, theme, …). Values
919
+ * set here take precedence over HTML attributes. Re-renders the widget.
920
+ */
921
+ configure(config) {
922
+ this.overrides = {
923
+ ...this.overrides,
924
+ ...config
925
+ };
926
+ if (config.theme) this.overrides.theme = {
927
+ ...this.overrides.theme ?? {},
928
+ ...config.theme
929
+ };
930
+ if (this.mounted) this.render();
931
+ }
932
+ /** Open the chat panel. */
933
+ openChat() {
934
+ this.open = true;
935
+ this.syncOpenState();
936
+ this.controller?.connect().catch(() => {});
937
+ }
938
+ /** Collapse the chat panel back to the launcher. */
939
+ closeChat() {
940
+ this.open = false;
941
+ this.syncOpenState();
942
+ }
943
+ readConfig() {
944
+ const endpoint = this.overrides.endpoint ?? this.getAttribute("endpoint") ?? "";
945
+ const agentId = this.overrides.agentId ?? this.getAttribute("agent-id") ?? "";
946
+ if (!endpoint || !agentId) return null;
947
+ const theme = this.overrides.theme;
948
+ const modeAttr = this.getAttribute("mode");
949
+ return {
950
+ endpoint,
951
+ mode: this.overrides.mode ?? (modeAttr === "fullpage" ? "fullpage" : modeAttr === "popover" ? "popover" : void 0) ?? "popover",
952
+ agentId,
953
+ agentName: this.overrides.agentName ?? this.getAttribute("agent-name") ?? void 0,
954
+ userName: this.overrides.userName,
955
+ userEmail: this.overrides.userEmail,
956
+ userPhone: this.overrides.userPhone,
957
+ placeholder: this.overrides.placeholder ?? this.getAttribute("placeholder") ?? void 0,
958
+ greeting: this.overrides.greeting ?? this.getAttribute("greeting") ?? void 0,
959
+ connectionErrorMessage: this.overrides.connectionErrorMessage,
960
+ startOpen: this.overrides.startOpen ?? this.hasAttribute("start-open"),
961
+ examplePrompts: this.overrides.examplePrompts,
962
+ requireName: this.overrides.requireName,
963
+ requireEmail: this.overrides.requireEmail,
964
+ requirePhone: this.overrides.requirePhone,
965
+ allowAnonymous: this.overrides.allowAnonymous,
966
+ theme
967
+ };
968
+ }
969
+ render() {
970
+ const config = this.readConfig();
971
+ if (!config) {
972
+ this.root.innerHTML = "";
973
+ return;
974
+ }
975
+ const resolved = resolveConfig(config);
976
+ if (!this.controller) {
977
+ this.controller = new ConversationController(config, {
978
+ onMessages: (messages) => {
979
+ this.messages = messages;
980
+ this.renderMessages(resolved.greeting);
981
+ },
982
+ onStatus: (status) => {
983
+ this.status = status;
984
+ this.renderStatus();
985
+ this.renderComposerState();
986
+ }
987
+ });
988
+ if (resolved.startOpen) this.open = true;
989
+ }
990
+ const fullpage = resolved.mode === "fullpage";
991
+ if (fullpage) this.open = true;
992
+ const style = document.createElement("style");
993
+ style.textContent = buildStyles(resolved.theme, resolved.mode);
994
+ const monogram = escapeHtml((resolved.agentName.trim().charAt(0) || "A").toUpperCase());
995
+ const header = fullpage ? `<div class="header">
996
+ <div class="avatar"><span class="logo-wrap">${SMOOTH_LOGO_SVG}</span></div>
997
+ <div class="meta">
998
+ <span class="title">${escapeHtml(resolved.agentName)}</span>
999
+ <span class="status"><span class="dot off"></span><span class="status-text"></span></span>
1000
+ </div>
1001
+ <span class="powered">powered by smooth-operator</span>
1002
+ </div>` : `<div class="header">
1003
+ <div class="avatar">${monogram}</div>
1004
+ <div class="meta">
1005
+ <span class="title">${escapeHtml(resolved.agentName)}</span>
1006
+ <span class="status"><span class="dot off"></span><span class="status-text"></span></span>
1007
+ </div>
1008
+ <button class="close" aria-label="Close chat">${ICON.close}</button>
1009
+ </div>`;
1010
+ this.examplePrompts = resolved.examplePrompts;
1011
+ const gating = needsUserInfo(resolved) && !this.userInfoSatisfied;
1012
+ const field = (name, type, label, autocomplete) => `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}" required /></label>`;
1013
+ const prechatHtml = `
1014
+ <div class="prechat">
1015
+ <div class="pc-head">
1016
+ <div class="pc-title">Before we chat</div>
1017
+ <div class="pc-sub">A couple details so ${escapeHtml(resolved.agentName)} can help.</div>
1018
+ </div>
1019
+ <form class="pc-form" novalidate>
1020
+ ${resolved.requireName ? field("name", "text", "Name", "name") : ""}
1021
+ ${resolved.requireEmail ? field("email", "email", "Email", "email") : ""}
1022
+ ${resolved.requirePhone ? field("phone", "tel", "Phone", "tel") : ""}
1023
+ <button type="submit" class="pc-submit">Start chat</button>
1024
+ </form>
1025
+ </div>`;
1026
+ const chatHtml = `
1027
+ <div class="messages"></div>
1028
+ <div class="composer-wrap">
1029
+ <div class="composer">
1030
+ <textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
1031
+ <button class="send" type="button" aria-label="Send message">${ICON.send}</button>
1032
+ </div>
1033
+ <div class="footer">powered by <b>smooth&#8209;operator</b></div>
1034
+ </div>`;
1035
+ const container = document.createElement("div");
1036
+ container.innerHTML = `
1037
+ ${fullpage ? "" : `<button class="launcher" part="launcher" aria-label="Open chat">${ICON.spark}</button>`}
1038
+ <div class="panel${fullpage ? " fullpage" : " hidden"}" part="panel" role="${fullpage ? "region" : "dialog"}" aria-label="${escapeHtml(resolved.agentName)} chat">
1039
+ ${header}
1040
+ <div class="header-sep"></div>
1041
+ ${gating ? prechatHtml : chatHtml}
1042
+ </div>
1043
+ `;
1044
+ const logoSvg = container.querySelector(".logo-wrap svg");
1045
+ if (logoSvg) logoSvg.setAttribute("class", "logo");
1046
+ this.root.replaceChildren(style, container);
1047
+ this.launcherEl = container.querySelector(".launcher");
1048
+ this.panelEl = container.querySelector(".panel");
1049
+ this.messagesEl = container.querySelector(".messages");
1050
+ this.statusEl = container.querySelector(".status-text");
1051
+ this.dotEl = container.querySelector(".dot");
1052
+ this.inputEl = container.querySelector("textarea");
1053
+ this.sendBtn = container.querySelector(".send");
1054
+ this.launcherEl?.addEventListener("click", () => this.openChat());
1055
+ container.querySelector(".close")?.addEventListener("click", () => this.closeChat());
1056
+ this.sendBtn?.addEventListener("click", () => this.submit());
1057
+ this.inputEl?.addEventListener("input", () => this.autosize());
1058
+ this.inputEl?.addEventListener("keydown", (ev) => {
1059
+ if (ev.key === "Enter" && !ev.shiftKey) {
1060
+ ev.preventDefault();
1061
+ this.submit();
1062
+ }
1063
+ });
1064
+ const pcForm = container.querySelector(".pc-form");
1065
+ pcForm?.addEventListener("submit", (ev) => {
1066
+ ev.preventDefault();
1067
+ this.handlePrechatSubmit(pcForm);
1068
+ });
1069
+ if (fullpage && !gating) this.controller?.connect().catch(() => {});
1070
+ this.syncOpenState();
1071
+ if (!gating) this.renderMessages(resolved.greeting);
1072
+ this.renderStatus();
1073
+ this.renderComposerState();
1074
+ }
1075
+ /** Collect identity from the pre-chat form, then drop into the chat view. */
1076
+ handlePrechatSubmit(form) {
1077
+ if (!form.reportValidity()) return;
1078
+ const data = new FormData(form);
1079
+ const val = (k) => data.get(k)?.trim() || void 0;
1080
+ this.controller?.setUserInfo({
1081
+ name: val("name"),
1082
+ email: val("email"),
1083
+ phone: val("phone")
1084
+ });
1085
+ this.userInfoSatisfied = true;
1086
+ this.render();
1087
+ this.controller?.connect().catch(() => {});
1088
+ }
1089
+ /** Send a starter prompt (from a chip click). */
1090
+ submitPrompt(text) {
1091
+ if (!this.inputEl) return;
1092
+ this.inputEl.value = text;
1093
+ this.submit();
1094
+ }
1095
+ syncOpenState() {
1096
+ if (this.panelEl?.classList.contains("fullpage")) {
1097
+ this.inputEl?.focus();
1098
+ return;
1099
+ }
1100
+ this.panelEl?.classList.toggle("hidden", !this.open);
1101
+ this.launcherEl?.classList.toggle("hidden", this.open);
1102
+ if (this.open) this.inputEl?.focus();
1103
+ }
1104
+ /** Grow the textarea with its content, up to the CSS max-height. */
1105
+ autosize() {
1106
+ const ta = this.inputEl;
1107
+ if (!ta) return;
1108
+ ta.style.height = "auto";
1109
+ ta.style.height = `${ta.scrollHeight}px`;
1110
+ }
1111
+ renderMessages(greeting) {
1112
+ if (!this.messagesEl) return;
1113
+ this.messagesEl.replaceChildren();
1114
+ if (this.messages.length === 0 && greeting) this.messagesEl.appendChild(this.buildRow("assistant", this.greetingBubble(greeting)));
1115
+ if (!this.hasSent && this.messages.length === 0 && this.examplePrompts.length > 0) {
1116
+ const chips = document.createElement("div");
1117
+ chips.className = "prompts";
1118
+ for (const prompt of this.examplePrompts) {
1119
+ const chip = document.createElement("button");
1120
+ chip.type = "button";
1121
+ chip.className = "chip";
1122
+ chip.textContent = prompt;
1123
+ chip.addEventListener("click", () => this.submitPrompt(prompt));
1124
+ chips.appendChild(chip);
1125
+ }
1126
+ this.messagesEl.appendChild(chips);
1127
+ }
1128
+ for (const msg of this.messages) {
1129
+ const bubble = document.createElement("div");
1130
+ bubble.className = `bubble ${msg.role}`;
1131
+ if (msg.role === "assistant" && msg.streaming && !msg.text) {
1132
+ bubble.classList.add("typing");
1133
+ bubble.append(this.typingDot(), this.typingDot(), this.typingDot());
1134
+ } else if (msg.streaming) {
1135
+ bubble.classList.add("cursor");
1136
+ bubble.textContent = msg.text;
1137
+ } else bubble.textContent = msg.text;
1138
+ this.messagesEl.appendChild(this.buildRow(msg.role, bubble));
1139
+ if (msg.role === "assistant" && !msg.streaming && msg.citations && msg.citations.length > 0) this.messagesEl.appendChild(this.renderSources(msg.citations));
1140
+ }
1141
+ this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
1142
+ }
1143
+ /** Wrap a bubble in a `.row`, prefixing assistant rows with a mini avatar. */
1144
+ buildRow(role, bubble) {
1145
+ const row = document.createElement("div");
1146
+ row.className = `row ${role}`;
1147
+ if (role === "assistant") {
1148
+ const mini = document.createElement("div");
1149
+ mini.className = "mini";
1150
+ mini.innerHTML = ICON.bot;
1151
+ row.appendChild(mini);
1152
+ }
1153
+ row.appendChild(bubble);
1154
+ return row;
1155
+ }
1156
+ greetingBubble(greeting) {
1157
+ const b = document.createElement("div");
1158
+ b.className = "bubble assistant greeting";
1159
+ b.textContent = greeting;
1160
+ return b;
1161
+ }
1162
+ typingDot() {
1163
+ return document.createElement("i");
1164
+ }
1165
+ /**
1166
+ * Build the collapsible "Sources (N)" block for an assistant message's
1167
+ * citations. Title/snippet are set via `textContent` (never innerHTML) so
1168
+ * citation text can't inject markup; only the static chevron + numeric count
1169
+ * use innerHTML.
1170
+ */
1171
+ renderSources(citations) {
1172
+ const wrap = document.createElement("div");
1173
+ wrap.className = "sources";
1174
+ wrap.setAttribute("part", "sources");
1175
+ const details = document.createElement("details");
1176
+ details.open = true;
1177
+ const summary = document.createElement("summary");
1178
+ const chev = document.createElement("span");
1179
+ chev.className = "chev";
1180
+ chev.innerHTML = ICON.chev;
1181
+ const label = document.createElement("span");
1182
+ label.textContent = "Sources";
1183
+ const count = document.createElement("span");
1184
+ count.className = "count";
1185
+ count.textContent = String(citations.length);
1186
+ summary.append(chev, label, count);
1187
+ details.appendChild(summary);
1188
+ const list = document.createElement("ol");
1189
+ for (const c of citations) {
1190
+ const li = document.createElement("li");
1191
+ let titleEl;
1192
+ const safeUrl = safeHttpUrl(c.url);
1193
+ if (safeUrl) {
1194
+ const a = document.createElement("a");
1195
+ a.className = "src-title";
1196
+ a.href = safeUrl;
1197
+ a.target = "_blank";
1198
+ a.rel = "noopener noreferrer";
1199
+ titleEl = a;
1200
+ } else {
1201
+ titleEl = document.createElement("span");
1202
+ titleEl.className = "src-title";
1203
+ }
1204
+ titleEl.textContent = c.title || c.id || "Source";
1205
+ li.appendChild(titleEl);
1206
+ if (c.snippet) {
1207
+ const snip = document.createElement("span");
1208
+ snip.className = "src-snippet";
1209
+ snip.textContent = c.snippet;
1210
+ li.appendChild(snip);
1211
+ }
1212
+ list.appendChild(li);
1213
+ }
1214
+ details.appendChild(list);
1215
+ wrap.appendChild(details);
1216
+ return wrap;
1217
+ }
1218
+ renderStatus() {
1219
+ const label = {
1220
+ idle: "",
1221
+ connecting: "Connecting…",
1222
+ ready: "Online",
1223
+ error: "Connection issue",
1224
+ closed: "Disconnected"
1225
+ };
1226
+ if (this.statusEl) this.statusEl.textContent = label[this.status];
1227
+ if (this.dotEl) {
1228
+ const mod = this.status === "ready" ? "" : this.status === "connecting" ? " connecting" : this.status === "error" ? " error" : " off";
1229
+ this.dotEl.className = `dot${mod}`;
1230
+ }
1231
+ }
1232
+ renderComposerState() {
1233
+ const busy = this.status === "connecting";
1234
+ if (this.sendBtn) this.sendBtn.disabled = busy;
1235
+ if (this.inputEl) this.inputEl.disabled = busy;
1236
+ }
1237
+ submit() {
1238
+ if (!this.inputEl || !this.controller) return;
1239
+ const text = this.inputEl.value;
1240
+ if (!text.trim()) return;
1241
+ this.inputEl.value = "";
1242
+ this.hasSent = true;
1243
+ this.autosize();
1244
+ this.controller.send(text);
1245
+ }
1246
+ };
1247
+ function escapeHtml(value) {
1248
+ return value.replace(/[&<>"']/g, (c) => {
1249
+ switch (c) {
1250
+ case "&": return "&amp;";
1251
+ case "<": return "&lt;";
1252
+ case ">": return "&gt;";
1253
+ case "\"": return "&quot;";
1254
+ default: return "&#39;";
1255
+ }
1256
+ });
1257
+ }
1258
+ /** Register the custom element once. Safe to call multiple times. */
1259
+ function defineChatWidget() {
1260
+ if (typeof customElements !== "undefined" && !customElements.get("smooth-agent-chat")) customElements.define(ELEMENT_TAG, SmoothAgentChatElement);
1261
+ }
1262
+ /**
1263
+ * Programmatically create, configure, and append a widget to the page.
1264
+ * Returns the element so the host can drive `openChat()` / `closeChat()`.
1265
+ */
1266
+ function mountChatWidget(config, target = document.body) {
1267
+ defineChatWidget();
1268
+ const el = document.createElement(ELEMENT_TAG);
1269
+ el.configure(config);
1270
+ target.appendChild(el);
1271
+ return el;
1272
+ }
1273
+ /**
1274
+ * Ergonomic helper for the full-page layout: mounts a `<smooth-agent-chat>` in
1275
+ * `mode: "fullpage"` (no launcher — the chat fills its container/viewport with a
1276
+ * Smooth-branded header, a scrollable message list, and an input bar) and
1277
+ * returns the element.
1278
+ *
1279
+ * `target` defaults to `document.body`; pass a sized container to embed the
1280
+ * full-page chat inside a layout region (e.g. a `/chat` route shell or an
1281
+ * iframe). The `mode` is forced to `"fullpage"` regardless of the passed config.
1282
+ *
1283
+ * ```ts
1284
+ * mountFullPageChat({ endpoint: 'wss://…/ws', agentId: '…', agentName: 'Support' });
1285
+ * ```
1286
+ */
1287
+ function mountFullPageChat(config, target = document.body) {
1288
+ return mountChatWidget({
1289
+ ...config,
1290
+ mode: "fullpage"
1291
+ }, target);
1292
+ }
1293
+ //#endregion
1294
+ export { ConversationController, ELEMENT_TAG, SmoothAgentChatElement, defineChatWidget, mountChatWidget, mountFullPageChat };
1295
+
1296
+ //# sourceMappingURL=index.js.map