@smooai/smooth-operator 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -4
- package/dist/react/components/SmoothChat.d.ts +35 -0
- package/dist/react/components/SmoothChat.d.ts.map +1 -0
- package/dist/react/components/SmoothChat.js +26 -0
- package/dist/react/components/SmoothChat.js.map +1 -0
- package/dist/react/components/parts.d.ts +30 -0
- package/dist/react/components/parts.d.ts.map +1 -0
- package/dist/react/components/parts.js +67 -0
- package/dist/react/components/parts.js.map +1 -0
- package/dist/react/index.d.ts +27 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +26 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/provider.d.ts +27 -0
- package/dist/react/provider.d.ts.map +1 -0
- package/dist/react/provider.js +25 -0
- package/dist/react/provider.js.map +1 -0
- package/dist/react/response.d.ts +19 -0
- package/dist/react/response.d.ts.map +1 -0
- package/dist/react/response.js +53 -0
- package/dist/react/response.js.map +1 -0
- package/dist/react/theme.d.ts +64 -0
- package/dist/react/theme.d.ts.map +1 -0
- package/dist/react/theme.js +38 -0
- package/dist/react/theme.js.map +1 -0
- package/dist/react/types.d.ts +24 -0
- package/dist/react/types.d.ts.map +1 -0
- package/dist/react/types.js +2 -0
- package/dist/react/types.js.map +1 -0
- package/dist/react/use-conversation.d.ts +59 -0
- package/dist/react/use-conversation.d.ts.map +1 -0
- package/dist/react/use-conversation.js +154 -0
- package/dist/react/use-conversation.js.map +1 -0
- package/dist/widget/chat-widget.iife.js +1301 -0
- package/dist/widget/chat-widget.iife.js.map +1 -0
- package/dist/widget/config.d.ts +73 -0
- package/dist/widget/config.d.ts.map +1 -0
- package/dist/widget/config.js +30 -0
- package/dist/widget/config.js.map +1 -0
- package/dist/widget/conversation.d.ts +50 -0
- package/dist/widget/conversation.d.ts.map +1 -0
- package/dist/widget/conversation.js +173 -0
- package/dist/widget/conversation.js.map +1 -0
- package/dist/widget/element.d.ts +94 -0
- package/dist/widget/element.d.ts.map +1 -0
- package/dist/widget/element.js +377 -0
- package/dist/widget/element.js.map +1 -0
- package/dist/widget/index.d.ts +25 -0
- package/dist/widget/index.d.ts.map +1 -0
- package/dist/widget/index.js +24 -0
- package/dist/widget/index.js.map +1 -0
- package/dist/widget/logo.d.ts +9 -0
- package/dist/widget/logo.d.ts.map +1 -0
- package/dist/widget/logo.js +10 -0
- package/dist/widget/logo.js.map +1 -0
- package/dist/widget/standalone.d.ts +25 -0
- package/dist/widget/standalone.d.ts.map +1 -0
- package/dist/widget/standalone.js +15 -0
- package/dist/widget/standalone.js.map +1 -0
- package/dist/widget/styles.d.ts +12 -0
- package/dist/widget/styles.d.ts.map +1 -0
- package/dist/widget/styles.js +262 -0
- package/dist/widget/styles.js.map +1 -0
- package/package.json +44 -6
- package/src/react/components/SmoothChat.tsx +57 -0
- package/src/react/components/parts.tsx +141 -0
- package/src/react/index.ts +30 -0
- package/src/react/provider.tsx +40 -0
- package/src/react/response.ts +57 -0
- package/src/react/styles.css +231 -0
- package/src/react/theme.ts +92 -0
- package/src/react/types.ts +27 -0
- package/src/react/use-conversation.ts +203 -0
- package/src/widget/config.ts +102 -0
- package/src/widget/conversation.ts +212 -0
- package/src/widget/element.ts +420 -0
- package/src/widget/index.ts +37 -0
- package/src/widget/logo.ts +9 -0
- package/src/widget/smooth-logo.svg +32 -0
- package/src/widget/standalone.ts +33 -0
- package/src/widget/styles.ts +265 -0
|
@@ -0,0 +1,1301 @@
|
|
|
1
|
+
var SmoothAgentChat = (function(exports) {
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
//#region src/widget/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
|
+
return {
|
|
10
|
+
endpoint: config.endpoint,
|
|
11
|
+
mode: config.mode ?? "popover",
|
|
12
|
+
agentId: config.agentId,
|
|
13
|
+
agentName: config.agentName ?? "Assistant",
|
|
14
|
+
userName: config.userName,
|
|
15
|
+
userEmail: config.userEmail,
|
|
16
|
+
placeholder: config.placeholder ?? "Type a message…",
|
|
17
|
+
greeting: config.greeting ?? "Hi! How can I help you today?",
|
|
18
|
+
connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
|
|
19
|
+
startOpen: config.startOpen ?? false,
|
|
20
|
+
theme: {
|
|
21
|
+
text: theme.text ?? "#f8fafc",
|
|
22
|
+
background: theme.background ?? "#040d30",
|
|
23
|
+
primary,
|
|
24
|
+
primaryText,
|
|
25
|
+
assistantBubble: theme.assistantBubble ?? "#06134b",
|
|
26
|
+
assistantBubbleText: theme.assistantBubbleText ?? "#f8fafc",
|
|
27
|
+
userBubble: theme.userBubble ?? primary,
|
|
28
|
+
userBubbleText: theme.userBubbleText ?? primaryText,
|
|
29
|
+
border: theme.border ?? "#0a1f7a"
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/transport.ts
|
|
35
|
+
const WS_CONNECTING = 0;
|
|
36
|
+
const WS_OPEN = 1;
|
|
37
|
+
const WS_CLOSING = 2;
|
|
38
|
+
/** Default connect timeout (ms) for the WebSocket transport. */
|
|
39
|
+
const DEFAULT_CONNECT_TIMEOUT = 3e4;
|
|
40
|
+
/**
|
|
41
|
+
* Default transport backed by a `WebSocket`-like object. By default it uses the
|
|
42
|
+
* global `WebSocket`; pass a `factory` to inject one (e.g. the `ws` package on
|
|
43
|
+
* Node, or a mock in tests).
|
|
44
|
+
*/
|
|
45
|
+
var WebSocketTransport = class {
|
|
46
|
+
socket = null;
|
|
47
|
+
url;
|
|
48
|
+
factory;
|
|
49
|
+
connectTimeout;
|
|
50
|
+
messageHandlers = /* @__PURE__ */ new Set();
|
|
51
|
+
closeHandlers = /* @__PURE__ */ new Set();
|
|
52
|
+
errorHandlers = /* @__PURE__ */ new Set();
|
|
53
|
+
constructor(url, factory, connectTimeout = DEFAULT_CONNECT_TIMEOUT) {
|
|
54
|
+
this.url = url;
|
|
55
|
+
this.connectTimeout = connectTimeout;
|
|
56
|
+
if (factory) this.factory = factory;
|
|
57
|
+
else {
|
|
58
|
+
const G = globalThis;
|
|
59
|
+
if (!G.WebSocket) throw new Error("No global WebSocket available; pass a WebSocketFactory to WebSocketTransport.");
|
|
60
|
+
const Ctor = G.WebSocket;
|
|
61
|
+
this.factory = (u) => new Ctor(u);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
get state() {
|
|
65
|
+
if (!this.socket) return "closed";
|
|
66
|
+
switch (this.socket.readyState) {
|
|
67
|
+
case WS_CONNECTING: return "connecting";
|
|
68
|
+
case WS_OPEN: return "open";
|
|
69
|
+
case WS_CLOSING: return "closing";
|
|
70
|
+
default: return "closed";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
connect() {
|
|
74
|
+
if (this.socket && this.socket.readyState === WS_OPEN) return Promise.resolve();
|
|
75
|
+
if (this.socket && this.socket.readyState !== WS_OPEN) {
|
|
76
|
+
const stale = this.socket;
|
|
77
|
+
this.socket = null;
|
|
78
|
+
try {
|
|
79
|
+
stale.close();
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const socket = this.factory(this.url);
|
|
84
|
+
this.socket = socket;
|
|
85
|
+
let settled = false;
|
|
86
|
+
const timer = this.connectTimeout > 0 ? setTimeout(() => {
|
|
87
|
+
if (settled) return;
|
|
88
|
+
settled = true;
|
|
89
|
+
if (this.socket === socket) this.socket = null;
|
|
90
|
+
try {
|
|
91
|
+
socket.close();
|
|
92
|
+
} catch {}
|
|
93
|
+
reject(/* @__PURE__ */ new Error(`WebSocket connect to ${this.url} timed out after ${this.connectTimeout}ms`));
|
|
94
|
+
}, this.connectTimeout) : void 0;
|
|
95
|
+
socket.addEventListener("open", () => {
|
|
96
|
+
if (this.socket !== socket) return;
|
|
97
|
+
if (settled) return;
|
|
98
|
+
settled = true;
|
|
99
|
+
if (timer) clearTimeout(timer);
|
|
100
|
+
resolve();
|
|
101
|
+
});
|
|
102
|
+
socket.addEventListener("error", (ev) => {
|
|
103
|
+
if (this.socket !== socket) return;
|
|
104
|
+
for (const h of this.errorHandlers) h(ev);
|
|
105
|
+
if (!settled && this.state !== "open") {
|
|
106
|
+
settled = true;
|
|
107
|
+
if (timer) clearTimeout(timer);
|
|
108
|
+
if (this.socket === socket) this.socket = null;
|
|
109
|
+
try {
|
|
110
|
+
socket.close();
|
|
111
|
+
} catch {}
|
|
112
|
+
reject(ev instanceof Error ? ev : /* @__PURE__ */ new Error("WebSocket connection error"));
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
socket.addEventListener("close", (ev) => {
|
|
116
|
+
if (this.socket !== socket) return;
|
|
117
|
+
if (timer) clearTimeout(timer);
|
|
118
|
+
for (const h of this.closeHandlers) h({
|
|
119
|
+
code: ev.code,
|
|
120
|
+
reason: ev.reason
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
socket.addEventListener("message", (ev) => {
|
|
124
|
+
if (this.socket !== socket) return;
|
|
125
|
+
const data = typeof ev.data === "string" ? ev.data : String(ev.data);
|
|
126
|
+
for (const h of this.messageHandlers) h(data);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
send(data) {
|
|
131
|
+
if (!this.socket || this.socket.readyState !== WS_OPEN) throw new Error(`Cannot send: transport is "${this.state}"`);
|
|
132
|
+
this.socket.send(data);
|
|
133
|
+
}
|
|
134
|
+
close(code, reason) {
|
|
135
|
+
this.socket?.close(code, reason);
|
|
136
|
+
}
|
|
137
|
+
onMessage(handler) {
|
|
138
|
+
this.messageHandlers.add(handler);
|
|
139
|
+
return () => this.messageHandlers.delete(handler);
|
|
140
|
+
}
|
|
141
|
+
onClose(handler) {
|
|
142
|
+
this.closeHandlers.add(handler);
|
|
143
|
+
return () => this.closeHandlers.delete(handler);
|
|
144
|
+
}
|
|
145
|
+
onError(handler) {
|
|
146
|
+
this.errorHandlers.add(handler);
|
|
147
|
+
return () => this.errorHandlers.delete(handler);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/types.ts
|
|
152
|
+
/** Every server→client `type` discriminator value. */
|
|
153
|
+
const EVENT_TYPES = [
|
|
154
|
+
"immediate_response",
|
|
155
|
+
"eventual_response",
|
|
156
|
+
"stream_chunk",
|
|
157
|
+
"stream_token",
|
|
158
|
+
"keepalive",
|
|
159
|
+
"write_confirmation_required",
|
|
160
|
+
"otp_verification_required",
|
|
161
|
+
"otp_sent",
|
|
162
|
+
"otp_verified",
|
|
163
|
+
"otp_invalid",
|
|
164
|
+
"error",
|
|
165
|
+
"pong"
|
|
166
|
+
];
|
|
167
|
+
/** True if `frame` looks like any server event (has a known `type` discriminator). */
|
|
168
|
+
function isServerEvent(frame) {
|
|
169
|
+
return typeof frame === "object" && frame !== null && "type" in frame && typeof frame.type === "string" && EVENT_TYPES.includes(frame.type);
|
|
170
|
+
}
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/client.ts
|
|
173
|
+
/**
|
|
174
|
+
* SmoothAgentClient — a minimal, idiomatic, transport-agnostic client for the
|
|
175
|
+
* smooth-operator WebSocket protocol.
|
|
176
|
+
*
|
|
177
|
+
* Design goals
|
|
178
|
+
* ------------
|
|
179
|
+
* - **Transport-agnostic.** The client never touches a real socket directly; it
|
|
180
|
+
* talks to an injectable {@link Transport}. The default ({@link WebSocketTransport})
|
|
181
|
+
* uses the global `WebSocket`, but tests inject a mock and Node can inject `ws`.
|
|
182
|
+
* - **Request/response correlation by `requestId`.** Every action gets a generated
|
|
183
|
+
* `requestId`; the client routes incoming events back to the originating call.
|
|
184
|
+
* - **Streaming as an async iterator.** `sendMessage` returns a {@link MessageTurn}
|
|
185
|
+
* that is both awaitable (resolves with the terminal `eventual_response`) and
|
|
186
|
+
* async-iterable (yields each `stream_token` / `stream_chunk` / HITL event in
|
|
187
|
+
* order). This models the `stream_token`/`stream_chunk` → `eventual_response`
|
|
188
|
+
* flow without forcing a callback style on the caller.
|
|
189
|
+
* - **No live server required.** Correctness is fully unit-testable with a mock
|
|
190
|
+
* transport (see `test/client.test.ts`).
|
|
191
|
+
*/
|
|
192
|
+
/** A timeout that yields no terminal event. */
|
|
193
|
+
var RequestTimeoutError = class extends Error {
|
|
194
|
+
constructor(requestId, ms) {
|
|
195
|
+
super(`Request ${requestId} timed out after ${ms}ms`);
|
|
196
|
+
this.name = "RequestTimeoutError";
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* A streaming turn that received no terminal `eventual_response` / `error` within the
|
|
201
|
+
* configured {@link SmoothAgentClientOptions.turnTimeout}. The turn rejects with this
|
|
202
|
+
* and its async iteration throws it, so a stuck server can never hang the caller.
|
|
203
|
+
*/
|
|
204
|
+
var TurnTimeoutError = class extends Error {
|
|
205
|
+
requestId;
|
|
206
|
+
constructor(requestId, ms) {
|
|
207
|
+
super(`Turn ${requestId} timed out after ${ms}ms without a terminal response`);
|
|
208
|
+
this.name = "TurnTimeoutError";
|
|
209
|
+
this.requestId = requestId;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
/** A protocol-level error event surfaced as a throwable. */
|
|
213
|
+
var ProtocolError = class extends Error {
|
|
214
|
+
code;
|
|
215
|
+
requestId;
|
|
216
|
+
constructor(code, message, requestId) {
|
|
217
|
+
super(message);
|
|
218
|
+
this.name = "ProtocolError";
|
|
219
|
+
this.code = code;
|
|
220
|
+
this.requestId = requestId;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
/**
|
|
224
|
+
* A streaming message turn. Await it for the terminal {@link EventualResponse},
|
|
225
|
+
* or async-iterate it to receive every intermediate event in arrival order.
|
|
226
|
+
*
|
|
227
|
+
* ```ts
|
|
228
|
+
* const turn = client.sendMessage({ sessionId, message: 'hi' });
|
|
229
|
+
* for await (const ev of turn) {
|
|
230
|
+
* if (ev.type === 'stream_token') process.stdout.write(ev.token ?? '');
|
|
231
|
+
* }
|
|
232
|
+
* const final = await turn; // EventualResponse
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
var MessageTurn = class {
|
|
236
|
+
/** The requestId this turn is correlated on. */
|
|
237
|
+
requestId;
|
|
238
|
+
queue = [];
|
|
239
|
+
waiter = null;
|
|
240
|
+
done = false;
|
|
241
|
+
finalEvent = null;
|
|
242
|
+
error = null;
|
|
243
|
+
settled;
|
|
244
|
+
settle;
|
|
245
|
+
fail;
|
|
246
|
+
onClose;
|
|
247
|
+
timeoutTimer;
|
|
248
|
+
constructor(requestId, onClose, turnTimeout = 0) {
|
|
249
|
+
this.requestId = requestId;
|
|
250
|
+
this.onClose = onClose;
|
|
251
|
+
this.settled = new Promise((resolve, reject) => {
|
|
252
|
+
this.settle = resolve;
|
|
253
|
+
this.fail = reject;
|
|
254
|
+
});
|
|
255
|
+
this.settled.catch(() => {});
|
|
256
|
+
if (turnTimeout > 0) this.timeoutTimer = setTimeout(() => {
|
|
257
|
+
this.finish(null, new TurnTimeoutError(this.requestId, turnTimeout));
|
|
258
|
+
}, turnTimeout);
|
|
259
|
+
}
|
|
260
|
+
/** Feed an event into the turn (called by the client's dispatcher). */
|
|
261
|
+
push(event) {
|
|
262
|
+
if (this.done) return;
|
|
263
|
+
if (event.type === "error") {
|
|
264
|
+
const code = event.data?.error?.code ?? "INTERNAL_ERROR";
|
|
265
|
+
const message = event.data?.error?.message ?? "Unknown protocol error";
|
|
266
|
+
this.deliver(event);
|
|
267
|
+
this.finish(null, new ProtocolError(code, message, this.requestId));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
this.deliver(event);
|
|
271
|
+
if (event.type === "eventual_response") this.finish(event, null);
|
|
272
|
+
}
|
|
273
|
+
/** Force-close the turn (e.g. on disconnect) with an error. */
|
|
274
|
+
abort(err) {
|
|
275
|
+
if (this.done) return;
|
|
276
|
+
this.finish(null, err);
|
|
277
|
+
}
|
|
278
|
+
deliver(event) {
|
|
279
|
+
if (this.waiter) {
|
|
280
|
+
const w = this.waiter;
|
|
281
|
+
this.waiter = null;
|
|
282
|
+
w.resolve({
|
|
283
|
+
value: event,
|
|
284
|
+
done: false
|
|
285
|
+
});
|
|
286
|
+
} else this.queue.push(event);
|
|
287
|
+
}
|
|
288
|
+
finish(final, err) {
|
|
289
|
+
if (this.done) return;
|
|
290
|
+
this.done = true;
|
|
291
|
+
this.finalEvent = final;
|
|
292
|
+
this.error = err;
|
|
293
|
+
if (this.timeoutTimer) {
|
|
294
|
+
clearTimeout(this.timeoutTimer);
|
|
295
|
+
this.timeoutTimer = void 0;
|
|
296
|
+
}
|
|
297
|
+
this.onClose();
|
|
298
|
+
if (err) this.fail(err);
|
|
299
|
+
else if (final) this.settle(final);
|
|
300
|
+
if (this.waiter) {
|
|
301
|
+
const w = this.waiter;
|
|
302
|
+
this.waiter = null;
|
|
303
|
+
if (err) w.reject(err);
|
|
304
|
+
else w.resolve({
|
|
305
|
+
value: void 0,
|
|
306
|
+
done: true
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
[Symbol.asyncIterator]() {
|
|
311
|
+
return { next: () => {
|
|
312
|
+
if (this.queue.length > 0) return Promise.resolve({
|
|
313
|
+
value: this.queue.shift(),
|
|
314
|
+
done: false
|
|
315
|
+
});
|
|
316
|
+
if (this.done) {
|
|
317
|
+
if (this.error) return Promise.reject(this.error);
|
|
318
|
+
return Promise.resolve({
|
|
319
|
+
value: void 0,
|
|
320
|
+
done: true
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return new Promise((resolve, reject) => {
|
|
324
|
+
this.waiter = {
|
|
325
|
+
resolve,
|
|
326
|
+
reject
|
|
327
|
+
};
|
|
328
|
+
});
|
|
329
|
+
} };
|
|
330
|
+
}
|
|
331
|
+
then(onfulfilled, onrejected) {
|
|
332
|
+
return this.settled.then(onfulfilled, onrejected);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
var SmoothAgentClient = class {
|
|
336
|
+
transport;
|
|
337
|
+
generateRequestId;
|
|
338
|
+
requestTimeout;
|
|
339
|
+
turnTimeout;
|
|
340
|
+
/** requestId → single-response waiter (create_session, get_session, ping, …). */
|
|
341
|
+
pending = /* @__PURE__ */ new Map();
|
|
342
|
+
/** requestId → active streaming turn (send_message, and HITL resumes). */
|
|
343
|
+
turns = /* @__PURE__ */ new Map();
|
|
344
|
+
/** Unsolicited-event listeners (keepalive, server-push). */
|
|
345
|
+
listeners = /* @__PURE__ */ new Set();
|
|
346
|
+
unsubscribe = [];
|
|
347
|
+
constructor(options) {
|
|
348
|
+
this.transport = options.transport ?? new WebSocketTransport(options.url, options.webSocketFactory);
|
|
349
|
+
this.requestTimeout = options.requestTimeout ?? 3e4;
|
|
350
|
+
this.turnTimeout = options.turnTimeout ?? 12e4;
|
|
351
|
+
this.generateRequestId = options.generateRequestId ?? (() => `req-${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`);
|
|
352
|
+
this.unsubscribe.push(this.transport.onMessage((data) => this.handleFrame(data)));
|
|
353
|
+
this.unsubscribe.push(this.transport.onClose(() => this.failAll(/* @__PURE__ */ new Error("Transport closed"))));
|
|
354
|
+
}
|
|
355
|
+
/** Open the underlying transport. */
|
|
356
|
+
async connect() {
|
|
357
|
+
await this.transport.connect();
|
|
358
|
+
}
|
|
359
|
+
/** Close the transport and reject all in-flight work. */
|
|
360
|
+
disconnect(reason = "client disconnect") {
|
|
361
|
+
this.failAll(new Error(reason));
|
|
362
|
+
for (const u of this.unsubscribe) u();
|
|
363
|
+
this.unsubscribe = [];
|
|
364
|
+
this.transport.close(1e3, reason);
|
|
365
|
+
}
|
|
366
|
+
/** Subscribe to unsolicited / uncorrelated server events (e.g. keepalive). */
|
|
367
|
+
onEvent(listener) {
|
|
368
|
+
this.listeners.add(listener);
|
|
369
|
+
return () => this.listeners.delete(listener);
|
|
370
|
+
}
|
|
371
|
+
/** Start a new conversation session. Resolves with the session descriptor. */
|
|
372
|
+
async createConversationSession(req) {
|
|
373
|
+
return extractImmediateData(await this.request({
|
|
374
|
+
action: "create_conversation_session",
|
|
375
|
+
...req
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
/** Fetch a session snapshot by ID. */
|
|
379
|
+
async getSession(req) {
|
|
380
|
+
return extractImmediateData(await this.request({
|
|
381
|
+
action: "get_session",
|
|
382
|
+
...req
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
/** Fetch a page of conversation messages. */
|
|
386
|
+
async getMessages(req) {
|
|
387
|
+
return extractImmediateData(await this.request({
|
|
388
|
+
action: "get_conversation_messages",
|
|
389
|
+
...req
|
|
390
|
+
}));
|
|
391
|
+
}
|
|
392
|
+
/** Keepalive ping. Resolves with the server timestamp from the `pong` event. */
|
|
393
|
+
async ping() {
|
|
394
|
+
const event = await this.request({ action: "ping" });
|
|
395
|
+
if (event.type === "pong") return event.timestamp ?? event.data?.timestamp ?? Date.now();
|
|
396
|
+
return Date.now();
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Submit a user message and return a {@link MessageTurn}: await it for the
|
|
400
|
+
* terminal `eventual_response`, or async-iterate it for the streaming events.
|
|
401
|
+
*/
|
|
402
|
+
sendMessage(req) {
|
|
403
|
+
const requestId = this.generateRequestId();
|
|
404
|
+
const turn = new MessageTurn(requestId, () => this.turns.delete(requestId), this.turnTimeout);
|
|
405
|
+
this.turns.set(requestId, turn);
|
|
406
|
+
try {
|
|
407
|
+
this.transport.send(JSON.stringify({
|
|
408
|
+
action: "send_message",
|
|
409
|
+
requestId,
|
|
410
|
+
...req
|
|
411
|
+
}));
|
|
412
|
+
} catch (err) {
|
|
413
|
+
this.turns.delete(requestId);
|
|
414
|
+
turn.abort(err);
|
|
415
|
+
}
|
|
416
|
+
return turn;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Approve or reject a pending tool write, resuming the paused turn identified
|
|
420
|
+
* by `requestId`. The resumed streaming events flow back into the original
|
|
421
|
+
* {@link MessageTurn} for that `requestId`.
|
|
422
|
+
*/
|
|
423
|
+
confirmToolAction(req) {
|
|
424
|
+
this.transport.send(JSON.stringify({
|
|
425
|
+
action: "confirm_tool_action",
|
|
426
|
+
...req
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Submit an OTP code, resuming the paused turn identified by `requestId`.
|
|
431
|
+
* The resumed streaming events flow back into the original {@link MessageTurn}.
|
|
432
|
+
*/
|
|
433
|
+
verifyOtp(req) {
|
|
434
|
+
this.transport.send(JSON.stringify({
|
|
435
|
+
action: "verify_otp",
|
|
436
|
+
...req
|
|
437
|
+
}));
|
|
438
|
+
}
|
|
439
|
+
/** Send an action that expects a single correlated response event. */
|
|
440
|
+
request(action) {
|
|
441
|
+
const requestId = action.requestId ?? this.generateRequestId();
|
|
442
|
+
const frame = {
|
|
443
|
+
...action,
|
|
444
|
+
requestId
|
|
445
|
+
};
|
|
446
|
+
return new Promise((resolve, reject) => {
|
|
447
|
+
const timer = this.requestTimeout > 0 ? setTimeout(() => {
|
|
448
|
+
this.pending.delete(requestId);
|
|
449
|
+
reject(new RequestTimeoutError(requestId, this.requestTimeout));
|
|
450
|
+
}, this.requestTimeout) : void 0;
|
|
451
|
+
this.pending.set(requestId, {
|
|
452
|
+
resolve,
|
|
453
|
+
reject,
|
|
454
|
+
timer
|
|
455
|
+
});
|
|
456
|
+
try {
|
|
457
|
+
this.transport.send(JSON.stringify(frame));
|
|
458
|
+
} catch (err) {
|
|
459
|
+
if (timer) clearTimeout(timer);
|
|
460
|
+
this.pending.delete(requestId);
|
|
461
|
+
reject(err);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
/** Parse and route an incoming frame to the right consumer. */
|
|
466
|
+
handleFrame(data) {
|
|
467
|
+
let frame;
|
|
468
|
+
try {
|
|
469
|
+
frame = JSON.parse(data);
|
|
470
|
+
} catch {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (!isServerEvent(frame)) return;
|
|
474
|
+
const event = frame;
|
|
475
|
+
const requestId = event.requestId;
|
|
476
|
+
if (requestId && this.turns.has(requestId)) {
|
|
477
|
+
this.turns.get(requestId).push(event);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (requestId && this.pending.has(requestId)) {
|
|
481
|
+
const pending = this.pending.get(requestId);
|
|
482
|
+
this.pending.delete(requestId);
|
|
483
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
484
|
+
if (event.type === "error") {
|
|
485
|
+
const code = event.data?.error?.code ?? "INTERNAL_ERROR";
|
|
486
|
+
const message = event.data?.error?.message ?? "Unknown protocol error";
|
|
487
|
+
pending.reject(new ProtocolError(code, message, requestId));
|
|
488
|
+
} else pending.resolve(event);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
for (const l of this.listeners) l(event);
|
|
492
|
+
}
|
|
493
|
+
failAll(err) {
|
|
494
|
+
for (const [, p] of this.pending) {
|
|
495
|
+
if (p.timer) clearTimeout(p.timer);
|
|
496
|
+
p.reject(err);
|
|
497
|
+
}
|
|
498
|
+
this.pending.clear();
|
|
499
|
+
for (const [, turn] of this.turns) turn.abort(err);
|
|
500
|
+
this.turns.clear();
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
/** Pull the typed `data` payload out of an `immediate_response` event. */
|
|
504
|
+
function extractImmediateData(event) {
|
|
505
|
+
if (event.type === "immediate_response") return event.data;
|
|
506
|
+
if ("data" in event && event.data && typeof event.data === "object") return event.data;
|
|
507
|
+
throw new ProtocolError("UNEXPECTED_EVENT", `Expected immediate_response, got "${event.type}"`, event.requestId);
|
|
508
|
+
}
|
|
509
|
+
//#endregion
|
|
510
|
+
//#region src/widget/conversation.ts
|
|
511
|
+
/**
|
|
512
|
+
* ConversationController — the bridge between the widget UI and the
|
|
513
|
+
* `@smooai/smooth-operator` protocol client.
|
|
514
|
+
*
|
|
515
|
+
* This is the piece that was rewired: the original smooai widget spoke to
|
|
516
|
+
* `@smooai/realtime`; here every protocol action goes through {@link SmoothAgentClient}.
|
|
517
|
+
* The wire shapes are identical (the protocol was lifted from `@smooai/realtime`),
|
|
518
|
+
* so the swap is purely at the client-library boundary.
|
|
519
|
+
*
|
|
520
|
+
* Flow:
|
|
521
|
+
* 1. `connect()` → opens the WebSocket transport and `create_conversation_session`.
|
|
522
|
+
* 2. `send(text)` → `send_message`, streaming `stream_token` deltas into the
|
|
523
|
+
* in-progress assistant message, then the terminal
|
|
524
|
+
* `eventual_response`.
|
|
525
|
+
*
|
|
526
|
+
* The controller is UI-agnostic: it emits typed events and the view renders them.
|
|
527
|
+
*/
|
|
528
|
+
/** Pull the final assistant text out of an `eventual_response` data payload. */
|
|
529
|
+
function extractFinalText(response) {
|
|
530
|
+
if (!response || typeof response !== "object") return null;
|
|
531
|
+
const r = response;
|
|
532
|
+
if (Array.isArray(r.responseParts)) return r.responseParts.filter((p) => typeof p === "string").join("\n\n");
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Pull the grounding {@link Citation}s out of a terminal `eventual_response`.
|
|
537
|
+
*
|
|
538
|
+
* The protocol client types these (`eventual_response.data.data.citations`),
|
|
539
|
+
* but they're optional and back-compatible — absent when the turn used no
|
|
540
|
+
* knowledge sources. We read them defensively (tolerating their total absence,
|
|
541
|
+
* non-array shapes, and missing fields) so a server that doesn't emit them, or
|
|
542
|
+
* an older client, can't break rendering. Each citation always carries
|
|
543
|
+
* `id`/`title`/`snippet`/`score`; `url` is present only for web-sourced docs.
|
|
544
|
+
*/
|
|
545
|
+
function extractCitations(inner) {
|
|
546
|
+
if (!inner || typeof inner !== "object") return [];
|
|
547
|
+
const raw = inner.citations;
|
|
548
|
+
if (!Array.isArray(raw)) return [];
|
|
549
|
+
const out = [];
|
|
550
|
+
for (const c of raw) {
|
|
551
|
+
if (!c || typeof c !== "object") continue;
|
|
552
|
+
const obj = c;
|
|
553
|
+
const id = typeof obj.id === "string" ? obj.id : "";
|
|
554
|
+
const title = typeof obj.title === "string" ? obj.title : id || "Source";
|
|
555
|
+
const snippet = typeof obj.snippet === "string" ? obj.snippet : "";
|
|
556
|
+
const url = typeof obj.url === "string" && obj.url ? obj.url : void 0;
|
|
557
|
+
const score = typeof obj.score === "number" ? obj.score : 0;
|
|
558
|
+
out.push({
|
|
559
|
+
id,
|
|
560
|
+
title,
|
|
561
|
+
snippet,
|
|
562
|
+
score,
|
|
563
|
+
url
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
return out;
|
|
567
|
+
}
|
|
568
|
+
var ConversationController = class {
|
|
569
|
+
config;
|
|
570
|
+
events;
|
|
571
|
+
client = null;
|
|
572
|
+
sessionId = null;
|
|
573
|
+
messages = [];
|
|
574
|
+
status = "idle";
|
|
575
|
+
seq = 0;
|
|
576
|
+
constructor(config, events) {
|
|
577
|
+
this.config = config;
|
|
578
|
+
this.events = events;
|
|
579
|
+
}
|
|
580
|
+
get connectionStatus() {
|
|
581
|
+
return this.status;
|
|
582
|
+
}
|
|
583
|
+
nextId(prefix) {
|
|
584
|
+
this.seq += 1;
|
|
585
|
+
return `${prefix}-${this.seq}-${Date.now().toString(36)}`;
|
|
586
|
+
}
|
|
587
|
+
setStatus(status, detail) {
|
|
588
|
+
this.status = status;
|
|
589
|
+
this.events.onStatus(status, detail);
|
|
590
|
+
}
|
|
591
|
+
emitMessages() {
|
|
592
|
+
this.events.onMessages(this.messages.map((m) => ({ ...m })));
|
|
593
|
+
}
|
|
594
|
+
/** Open the transport and create a conversation session. Idempotent. */
|
|
595
|
+
async connect() {
|
|
596
|
+
if (this.status === "connecting" || this.status === "ready") return;
|
|
597
|
+
this.setStatus("connecting");
|
|
598
|
+
try {
|
|
599
|
+
this.client = new SmoothAgentClient({ url: this.config.endpoint });
|
|
600
|
+
await this.client.connect();
|
|
601
|
+
const session = await this.client.createConversationSession({
|
|
602
|
+
agentId: this.config.agentId,
|
|
603
|
+
userName: this.config.userName,
|
|
604
|
+
userEmail: this.config.userEmail
|
|
605
|
+
});
|
|
606
|
+
this.sessionId = session.sessionId;
|
|
607
|
+
this.setStatus("ready");
|
|
608
|
+
} catch (err) {
|
|
609
|
+
this.setStatus("error", err instanceof Error ? err.message : String(err));
|
|
610
|
+
throw err;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Submit a user message. Appends the user bubble immediately, then streams the
|
|
615
|
+
* assistant reply token-by-token, finalizing on `eventual_response`.
|
|
616
|
+
*/
|
|
617
|
+
async send(text) {
|
|
618
|
+
const trimmed = text.trim();
|
|
619
|
+
if (!trimmed) return;
|
|
620
|
+
if (!this.client || !this.sessionId || this.status !== "ready") await this.connect();
|
|
621
|
+
if (!this.client || !this.sessionId) throw new Error("Conversation is not connected");
|
|
622
|
+
this.messages.push({
|
|
623
|
+
id: this.nextId("u"),
|
|
624
|
+
role: "user",
|
|
625
|
+
text: trimmed,
|
|
626
|
+
streaming: false
|
|
627
|
+
});
|
|
628
|
+
const assistant = {
|
|
629
|
+
id: this.nextId("a"),
|
|
630
|
+
role: "assistant",
|
|
631
|
+
text: "",
|
|
632
|
+
streaming: true
|
|
633
|
+
};
|
|
634
|
+
this.messages.push(assistant);
|
|
635
|
+
this.emitMessages();
|
|
636
|
+
try {
|
|
637
|
+
const turn = this.client.sendMessage({
|
|
638
|
+
sessionId: this.sessionId,
|
|
639
|
+
message: trimmed,
|
|
640
|
+
stream: true
|
|
641
|
+
});
|
|
642
|
+
for await (const event of turn) if (event.type === "stream_token") {
|
|
643
|
+
const token = event.token ?? event.data?.token ?? "";
|
|
644
|
+
if (token) {
|
|
645
|
+
assistant.text += token;
|
|
646
|
+
this.emitMessages();
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const inner = (await turn).data?.data;
|
|
650
|
+
const finalText = extractFinalText(inner?.response);
|
|
651
|
+
if (finalText && finalText.length > assistant.text.length) assistant.text = finalText;
|
|
652
|
+
if (!assistant.text) assistant.text = "(no response)";
|
|
653
|
+
const citations = extractCitations(inner);
|
|
654
|
+
if (citations.length > 0) assistant.citations = citations;
|
|
655
|
+
assistant.streaming = false;
|
|
656
|
+
this.emitMessages();
|
|
657
|
+
} catch (err) {
|
|
658
|
+
assistant.streaming = false;
|
|
659
|
+
const message = err instanceof ProtocolError ? `Error: ${err.message}` : this.config.connectionErrorMessage ?? "We couldn't reach the chat.";
|
|
660
|
+
assistant.text = assistant.text ? `${assistant.text}\n\n${message}` : message;
|
|
661
|
+
this.emitMessages();
|
|
662
|
+
this.setStatus("error", err instanceof Error ? err.message : String(err));
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
/** Tear down the underlying client. */
|
|
666
|
+
disconnect() {
|
|
667
|
+
this.client?.disconnect("widget closed");
|
|
668
|
+
this.client = null;
|
|
669
|
+
this.sessionId = null;
|
|
670
|
+
this.setStatus("closed");
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
//#endregion
|
|
674
|
+
//#region src/widget/logo.ts
|
|
675
|
+
/**
|
|
676
|
+
* The Smooth logo, inlined as an SVG string so the full-page header can render
|
|
677
|
+
* it without a separate network fetch (the IIFE bundle is self-contained).
|
|
678
|
+
*
|
|
679
|
+
* GENERATED from `assets/smooth-logo.svg` — do not edit by hand. Regenerate with:
|
|
680
|
+
* node -e ... (see the commit that added this file)
|
|
681
|
+
*/
|
|
682
|
+
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>";
|
|
683
|
+
//#endregion
|
|
684
|
+
//#region src/widget/styles.ts
|
|
685
|
+
/**
|
|
686
|
+
* Render the widget's scoped stylesheet. All theme values are injected as CSS
|
|
687
|
+
* custom properties on `:host` so they can be overridden per-instance and so the
|
|
688
|
+
* styles below stay static. Kept deliberately framework-light — no Tailwind, no
|
|
689
|
+
* runtime CSS-in-JS; just a string the web component drops into its shadow root.
|
|
690
|
+
*
|
|
691
|
+
* `mode` switches the host positioning + panel sizing between the floating
|
|
692
|
+
* popover (default) and the full-page layout (fills its container/viewport).
|
|
693
|
+
*/
|
|
694
|
+
function buildStyles(theme, mode = "popover") {
|
|
695
|
+
return `
|
|
696
|
+
:host {
|
|
697
|
+
--sac-text: ${theme.text};
|
|
698
|
+
--sac-bg: ${theme.background};
|
|
699
|
+
--sac-primary: ${theme.primary};
|
|
700
|
+
--sac-primary-text: ${theme.primaryText};
|
|
701
|
+
--sac-assistant-bubble: ${theme.assistantBubble};
|
|
702
|
+
--sac-assistant-bubble-text: ${theme.assistantBubbleText};
|
|
703
|
+
--sac-user-bubble: ${theme.userBubble};
|
|
704
|
+
--sac-user-bubble-text: ${theme.userBubbleText};
|
|
705
|
+
--sac-border: ${theme.border};
|
|
706
|
+
|
|
707
|
+
${mode === "fullpage" ? `/* Full-page: fill the host's box (the element should be sized by its
|
|
708
|
+
container, or it falls back to filling the viewport). */
|
|
709
|
+
display: block;
|
|
710
|
+
position: relative;
|
|
711
|
+
width: 100%;
|
|
712
|
+
height: 100%;
|
|
713
|
+
min-height: 100vh;` : `/* Popover: float in the bottom-right corner. */
|
|
714
|
+
position: fixed;
|
|
715
|
+
bottom: 20px;
|
|
716
|
+
right: 20px;
|
|
717
|
+
z-index: 2147483000;`}
|
|
718
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
* { box-sizing: border-box; }
|
|
722
|
+
|
|
723
|
+
.launcher {
|
|
724
|
+
width: 56px;
|
|
725
|
+
height: 56px;
|
|
726
|
+
border-radius: 50%;
|
|
727
|
+
border: none;
|
|
728
|
+
cursor: pointer;
|
|
729
|
+
background: var(--sac-primary);
|
|
730
|
+
color: var(--sac-primary-text);
|
|
731
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
|
732
|
+
display: flex;
|
|
733
|
+
align-items: center;
|
|
734
|
+
justify-content: center;
|
|
735
|
+
font-size: 24px;
|
|
736
|
+
transition: transform 0.15s ease;
|
|
737
|
+
}
|
|
738
|
+
.launcher:hover { transform: scale(1.05); }
|
|
739
|
+
|
|
740
|
+
.panel {
|
|
741
|
+
width: 360px;
|
|
742
|
+
max-width: calc(100vw - 40px);
|
|
743
|
+
height: 520px;
|
|
744
|
+
max-height: calc(100vh - 40px);
|
|
745
|
+
display: flex;
|
|
746
|
+
flex-direction: column;
|
|
747
|
+
background: var(--sac-bg);
|
|
748
|
+
color: var(--sac-text);
|
|
749
|
+
border: 1px solid var(--sac-border);
|
|
750
|
+
border-radius: 14px;
|
|
751
|
+
overflow: hidden;
|
|
752
|
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/* Full-page: the panel becomes the whole surface — no floating box, no shadow,
|
|
756
|
+
no rounded corners; it fills the host. */
|
|
757
|
+
.panel.fullpage {
|
|
758
|
+
width: 100%;
|
|
759
|
+
height: 100%;
|
|
760
|
+
min-height: 100vh;
|
|
761
|
+
max-width: none;
|
|
762
|
+
max-height: none;
|
|
763
|
+
border: none;
|
|
764
|
+
border-radius: 0;
|
|
765
|
+
box-shadow: none;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
.header {
|
|
769
|
+
display: flex;
|
|
770
|
+
align-items: center;
|
|
771
|
+
justify-content: space-between;
|
|
772
|
+
padding: 12px 14px;
|
|
773
|
+
background: var(--sac-primary);
|
|
774
|
+
color: var(--sac-primary-text);
|
|
775
|
+
}
|
|
776
|
+
.header .brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
777
|
+
.header .logo { height: 24px; width: auto; display: block; }
|
|
778
|
+
.header .title { font-weight: 600; font-size: 15px; }
|
|
779
|
+
.header .status { font-size: 11px; opacity: 0.85; }
|
|
780
|
+
.header .powered {
|
|
781
|
+
font-size: 10px;
|
|
782
|
+
opacity: 0.7;
|
|
783
|
+
letter-spacing: 0.02em;
|
|
784
|
+
}
|
|
785
|
+
.header .close {
|
|
786
|
+
background: transparent;
|
|
787
|
+
border: none;
|
|
788
|
+
color: inherit;
|
|
789
|
+
cursor: pointer;
|
|
790
|
+
font-size: 18px;
|
|
791
|
+
line-height: 1;
|
|
792
|
+
padding: 4px;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/* Full-page header: taller, logo-led, centered max-width content row. */
|
|
796
|
+
.panel.fullpage .header { padding: 14px 20px; }
|
|
797
|
+
.panel.fullpage .logo { height: 30px; }
|
|
798
|
+
|
|
799
|
+
.messages {
|
|
800
|
+
flex: 1;
|
|
801
|
+
overflow-y: auto;
|
|
802
|
+
padding: 14px;
|
|
803
|
+
display: flex;
|
|
804
|
+
flex-direction: column;
|
|
805
|
+
gap: 10px;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
.bubble {
|
|
809
|
+
max-width: 80%;
|
|
810
|
+
padding: 9px 12px;
|
|
811
|
+
border-radius: 12px;
|
|
812
|
+
font-size: 14px;
|
|
813
|
+
line-height: 1.4;
|
|
814
|
+
white-space: pre-wrap;
|
|
815
|
+
word-break: break-word;
|
|
816
|
+
}
|
|
817
|
+
.bubble.assistant {
|
|
818
|
+
align-self: flex-start;
|
|
819
|
+
background: var(--sac-assistant-bubble);
|
|
820
|
+
color: var(--sac-assistant-bubble-text);
|
|
821
|
+
border-bottom-left-radius: 4px;
|
|
822
|
+
}
|
|
823
|
+
.bubble.user {
|
|
824
|
+
align-self: flex-end;
|
|
825
|
+
background: var(--sac-user-bubble);
|
|
826
|
+
color: var(--sac-user-bubble-text);
|
|
827
|
+
border-bottom-right-radius: 4px;
|
|
828
|
+
}
|
|
829
|
+
.bubble.greeting { opacity: 0.85; font-style: italic; }
|
|
830
|
+
|
|
831
|
+
/* Full-page: center the conversation in a readable column and let bubbles
|
|
832
|
+
breathe a little wider. */
|
|
833
|
+
.panel.fullpage .messages {
|
|
834
|
+
padding: 24px 20px;
|
|
835
|
+
align-items: stretch;
|
|
836
|
+
}
|
|
837
|
+
.panel.fullpage .messages > * {
|
|
838
|
+
width: 100%;
|
|
839
|
+
max-width: 760px;
|
|
840
|
+
margin-left: auto;
|
|
841
|
+
margin-right: auto;
|
|
842
|
+
}
|
|
843
|
+
.panel.fullpage .bubble { max-width: 100%; }
|
|
844
|
+
.panel.fullpage .bubble.user { align-self: flex-end; max-width: 80%; margin-right: auto; }
|
|
845
|
+
.panel.fullpage .bubble.assistant { align-self: flex-start; max-width: 100%; }
|
|
846
|
+
|
|
847
|
+
/* Sources panel — rendered under an assistant bubble whose terminal
|
|
848
|
+
eventual_response carried citations. */
|
|
849
|
+
.sources {
|
|
850
|
+
align-self: flex-start;
|
|
851
|
+
max-width: 80%;
|
|
852
|
+
margin-top: -4px;
|
|
853
|
+
font-size: 12.5px;
|
|
854
|
+
color: var(--sac-text);
|
|
855
|
+
}
|
|
856
|
+
.panel.fullpage .sources { max-width: 100%; }
|
|
857
|
+
.sources details { background: transparent; }
|
|
858
|
+
.sources summary {
|
|
859
|
+
cursor: pointer;
|
|
860
|
+
font-weight: 600;
|
|
861
|
+
opacity: 0.85;
|
|
862
|
+
list-style: none;
|
|
863
|
+
user-select: none;
|
|
864
|
+
padding: 2px 0;
|
|
865
|
+
}
|
|
866
|
+
.sources summary::-webkit-details-marker { display: none; }
|
|
867
|
+
.sources summary::before {
|
|
868
|
+
content: '▸';
|
|
869
|
+
display: inline-block;
|
|
870
|
+
margin-right: 6px;
|
|
871
|
+
transition: transform 0.15s ease;
|
|
872
|
+
}
|
|
873
|
+
.sources details[open] summary::before { transform: rotate(90deg); }
|
|
874
|
+
.sources ol {
|
|
875
|
+
margin: 6px 0 0;
|
|
876
|
+
padding-left: 0;
|
|
877
|
+
list-style: none;
|
|
878
|
+
display: flex;
|
|
879
|
+
flex-direction: column;
|
|
880
|
+
gap: 8px;
|
|
881
|
+
}
|
|
882
|
+
.sources li {
|
|
883
|
+
border-left: 2px solid var(--sac-primary);
|
|
884
|
+
padding-left: 10px;
|
|
885
|
+
}
|
|
886
|
+
.sources .src-title {
|
|
887
|
+
color: var(--sac-primary);
|
|
888
|
+
text-decoration: none;
|
|
889
|
+
font-weight: 600;
|
|
890
|
+
word-break: break-word;
|
|
891
|
+
}
|
|
892
|
+
.sources a.src-title:hover { text-decoration: underline; }
|
|
893
|
+
.sources span.src-title { color: var(--sac-text); opacity: 0.95; }
|
|
894
|
+
.sources .src-snippet {
|
|
895
|
+
display: block;
|
|
896
|
+
margin-top: 2px;
|
|
897
|
+
opacity: 0.7;
|
|
898
|
+
line-height: 1.4;
|
|
899
|
+
white-space: normal;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
.cursor::after {
|
|
903
|
+
content: '▋';
|
|
904
|
+
margin-left: 1px;
|
|
905
|
+
animation: sac-blink 1s steps(2, start) infinite;
|
|
906
|
+
}
|
|
907
|
+
@keyframes sac-blink { to { visibility: hidden; } }
|
|
908
|
+
|
|
909
|
+
.composer {
|
|
910
|
+
display: flex;
|
|
911
|
+
gap: 8px;
|
|
912
|
+
padding: 10px;
|
|
913
|
+
border-top: 1px solid var(--sac-border);
|
|
914
|
+
}
|
|
915
|
+
.composer textarea {
|
|
916
|
+
flex: 1;
|
|
917
|
+
resize: none;
|
|
918
|
+
border: 1px solid var(--sac-border);
|
|
919
|
+
border-radius: 8px;
|
|
920
|
+
padding: 8px 10px;
|
|
921
|
+
font-family: inherit;
|
|
922
|
+
font-size: 14px;
|
|
923
|
+
background: transparent;
|
|
924
|
+
color: var(--sac-text);
|
|
925
|
+
max-height: 96px;
|
|
926
|
+
line-height: 1.4;
|
|
927
|
+
}
|
|
928
|
+
.composer textarea:focus { outline: 1px solid var(--sac-primary); }
|
|
929
|
+
.composer button {
|
|
930
|
+
border: none;
|
|
931
|
+
border-radius: 8px;
|
|
932
|
+
padding: 0 14px;
|
|
933
|
+
cursor: pointer;
|
|
934
|
+
background: var(--sac-primary);
|
|
935
|
+
color: var(--sac-primary-text);
|
|
936
|
+
font-weight: 600;
|
|
937
|
+
font-size: 14px;
|
|
938
|
+
}
|
|
939
|
+
.composer button:disabled { opacity: 0.5; cursor: default; }
|
|
940
|
+
|
|
941
|
+
.hidden { display: none !important; }
|
|
942
|
+
`;
|
|
943
|
+
}
|
|
944
|
+
//#endregion
|
|
945
|
+
//#region src/widget/element.ts
|
|
946
|
+
const ELEMENT_TAG = "smooth-agent-chat";
|
|
947
|
+
const OBSERVED = [
|
|
948
|
+
"endpoint",
|
|
949
|
+
"agent-id",
|
|
950
|
+
"agent-name",
|
|
951
|
+
"placeholder",
|
|
952
|
+
"greeting",
|
|
953
|
+
"start-open",
|
|
954
|
+
"mode"
|
|
955
|
+
];
|
|
956
|
+
/**
|
|
957
|
+
* Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
|
|
958
|
+
*
|
|
959
|
+
* SECURITY: citation URLs originate from indexed content (web / GitHub
|
|
960
|
+
* connectors), which can be attacker-influenceable. Assigning an arbitrary
|
|
961
|
+
* string to `<a>.href` allows `javascript:`/`data:`/`vbscript:` URLs that
|
|
962
|
+
* execute on click — a stored-XSS vector. Only http(s) links are rendered as
|
|
963
|
+
* anchors; anything else falls back to plain text.
|
|
964
|
+
*/
|
|
965
|
+
function safeHttpUrl(url) {
|
|
966
|
+
if (!url) return null;
|
|
967
|
+
try {
|
|
968
|
+
const parsed = new URL(url);
|
|
969
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
|
|
970
|
+
} catch {
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
var SmoothAgentChatElement = class extends HTMLElement {
|
|
975
|
+
static get observedAttributes() {
|
|
976
|
+
return OBSERVED;
|
|
977
|
+
}
|
|
978
|
+
root;
|
|
979
|
+
controller = null;
|
|
980
|
+
overrides = {};
|
|
981
|
+
open = false;
|
|
982
|
+
messages = [];
|
|
983
|
+
status = "idle";
|
|
984
|
+
mounted = false;
|
|
985
|
+
panelEl = null;
|
|
986
|
+
launcherEl = null;
|
|
987
|
+
messagesEl = null;
|
|
988
|
+
statusEl = null;
|
|
989
|
+
inputEl = null;
|
|
990
|
+
sendBtn = null;
|
|
991
|
+
constructor() {
|
|
992
|
+
super();
|
|
993
|
+
this.root = this.attachShadow({ mode: "open" });
|
|
994
|
+
}
|
|
995
|
+
connectedCallback() {
|
|
996
|
+
this.mounted = true;
|
|
997
|
+
this.render();
|
|
998
|
+
}
|
|
999
|
+
disconnectedCallback() {
|
|
1000
|
+
this.mounted = false;
|
|
1001
|
+
this.controller?.disconnect();
|
|
1002
|
+
this.controller = null;
|
|
1003
|
+
}
|
|
1004
|
+
attributeChangedCallback() {
|
|
1005
|
+
if (this.mounted) this.render();
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Programmatically merge config overrides (endpoint, agentId, theme, …). Values
|
|
1009
|
+
* set here take precedence over HTML attributes. Re-renders the widget.
|
|
1010
|
+
*/
|
|
1011
|
+
configure(config) {
|
|
1012
|
+
this.overrides = {
|
|
1013
|
+
...this.overrides,
|
|
1014
|
+
...config
|
|
1015
|
+
};
|
|
1016
|
+
if (config.theme) this.overrides.theme = {
|
|
1017
|
+
...this.overrides.theme ?? {},
|
|
1018
|
+
...config.theme
|
|
1019
|
+
};
|
|
1020
|
+
if (this.mounted) this.render();
|
|
1021
|
+
}
|
|
1022
|
+
/** Open the chat panel. */
|
|
1023
|
+
openChat() {
|
|
1024
|
+
this.open = true;
|
|
1025
|
+
this.syncOpenState();
|
|
1026
|
+
this.controller?.connect().catch(() => {});
|
|
1027
|
+
}
|
|
1028
|
+
/** Collapse the chat panel back to the launcher. */
|
|
1029
|
+
closeChat() {
|
|
1030
|
+
this.open = false;
|
|
1031
|
+
this.syncOpenState();
|
|
1032
|
+
}
|
|
1033
|
+
readConfig() {
|
|
1034
|
+
const endpoint = this.overrides.endpoint ?? this.getAttribute("endpoint") ?? "";
|
|
1035
|
+
const agentId = this.overrides.agentId ?? this.getAttribute("agent-id") ?? "";
|
|
1036
|
+
if (!endpoint || !agentId) return null;
|
|
1037
|
+
const theme = this.overrides.theme;
|
|
1038
|
+
const modeAttr = this.getAttribute("mode");
|
|
1039
|
+
return {
|
|
1040
|
+
endpoint,
|
|
1041
|
+
mode: this.overrides.mode ?? (modeAttr === "fullpage" ? "fullpage" : modeAttr === "popover" ? "popover" : void 0) ?? "popover",
|
|
1042
|
+
agentId,
|
|
1043
|
+
agentName: this.overrides.agentName ?? this.getAttribute("agent-name") ?? void 0,
|
|
1044
|
+
userName: this.overrides.userName,
|
|
1045
|
+
userEmail: this.overrides.userEmail,
|
|
1046
|
+
placeholder: this.overrides.placeholder ?? this.getAttribute("placeholder") ?? void 0,
|
|
1047
|
+
greeting: this.overrides.greeting ?? this.getAttribute("greeting") ?? void 0,
|
|
1048
|
+
connectionErrorMessage: this.overrides.connectionErrorMessage,
|
|
1049
|
+
startOpen: this.overrides.startOpen ?? this.hasAttribute("start-open"),
|
|
1050
|
+
theme
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
render() {
|
|
1054
|
+
const config = this.readConfig();
|
|
1055
|
+
if (!config) {
|
|
1056
|
+
this.root.innerHTML = "";
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
const resolved = resolveConfig(config);
|
|
1060
|
+
if (!this.controller) {
|
|
1061
|
+
this.controller = new ConversationController(config, {
|
|
1062
|
+
onMessages: (messages) => {
|
|
1063
|
+
this.messages = messages;
|
|
1064
|
+
this.renderMessages(resolved.greeting);
|
|
1065
|
+
},
|
|
1066
|
+
onStatus: (status) => {
|
|
1067
|
+
this.status = status;
|
|
1068
|
+
this.renderStatus();
|
|
1069
|
+
this.renderComposerState();
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
if (resolved.startOpen) this.open = true;
|
|
1073
|
+
}
|
|
1074
|
+
const fullpage = resolved.mode === "fullpage";
|
|
1075
|
+
if (fullpage) this.open = true;
|
|
1076
|
+
const style = document.createElement("style");
|
|
1077
|
+
style.textContent = buildStyles(resolved.theme, resolved.mode);
|
|
1078
|
+
const headerBrand = fullpage ? `<div class="brand">
|
|
1079
|
+
<span class="logo-wrap">${SMOOTH_LOGO_SVG}</span>
|
|
1080
|
+
<div>
|
|
1081
|
+
<div class="title">${escapeHtml(resolved.agentName)}</div>
|
|
1082
|
+
<div class="status"></div>
|
|
1083
|
+
</div>
|
|
1084
|
+
</div>
|
|
1085
|
+
<div class="powered">powered by smooth-operator</div>` : `<div class="brand">
|
|
1086
|
+
<div>
|
|
1087
|
+
<div class="title">${escapeHtml(resolved.agentName)}</div>
|
|
1088
|
+
<div class="status"></div>
|
|
1089
|
+
</div>
|
|
1090
|
+
</div>
|
|
1091
|
+
<button class="close" aria-label="Close chat">×</button>`;
|
|
1092
|
+
const container = document.createElement("div");
|
|
1093
|
+
container.innerHTML = `
|
|
1094
|
+
${fullpage ? "" : "<button class=\"launcher\" part=\"launcher\" aria-label=\"Open chat\">💬</button>"}
|
|
1095
|
+
<div class="panel${fullpage ? " fullpage" : " hidden"}" part="panel" role="${fullpage ? "region" : "dialog"}" aria-label="${escapeHtml(resolved.agentName)} chat">
|
|
1096
|
+
<div class="header">
|
|
1097
|
+
${headerBrand}
|
|
1098
|
+
</div>
|
|
1099
|
+
<div class="messages"></div>
|
|
1100
|
+
<div class="composer">
|
|
1101
|
+
<textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
|
|
1102
|
+
<button class="send" type="button">Send</button>
|
|
1103
|
+
</div>
|
|
1104
|
+
</div>
|
|
1105
|
+
`;
|
|
1106
|
+
const logoSvg = container.querySelector(".logo-wrap svg");
|
|
1107
|
+
if (logoSvg) logoSvg.setAttribute("class", "logo");
|
|
1108
|
+
this.root.replaceChildren(style, container);
|
|
1109
|
+
this.launcherEl = container.querySelector(".launcher");
|
|
1110
|
+
this.panelEl = container.querySelector(".panel");
|
|
1111
|
+
this.messagesEl = container.querySelector(".messages");
|
|
1112
|
+
this.statusEl = container.querySelector(".status");
|
|
1113
|
+
this.inputEl = container.querySelector("textarea");
|
|
1114
|
+
this.sendBtn = container.querySelector(".send");
|
|
1115
|
+
this.launcherEl?.addEventListener("click", () => this.openChat());
|
|
1116
|
+
container.querySelector(".close")?.addEventListener("click", () => this.closeChat());
|
|
1117
|
+
this.sendBtn?.addEventListener("click", () => this.submit());
|
|
1118
|
+
this.inputEl?.addEventListener("keydown", (ev) => {
|
|
1119
|
+
if (ev.key === "Enter" && !ev.shiftKey) {
|
|
1120
|
+
ev.preventDefault();
|
|
1121
|
+
this.submit();
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
if (fullpage) this.controller?.connect().catch(() => {});
|
|
1125
|
+
this.syncOpenState();
|
|
1126
|
+
this.renderMessages(resolved.greeting);
|
|
1127
|
+
this.renderStatus();
|
|
1128
|
+
this.renderComposerState();
|
|
1129
|
+
}
|
|
1130
|
+
syncOpenState() {
|
|
1131
|
+
if (this.panelEl?.classList.contains("fullpage")) {
|
|
1132
|
+
this.inputEl?.focus();
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
this.panelEl?.classList.toggle("hidden", !this.open);
|
|
1136
|
+
this.launcherEl?.classList.toggle("hidden", this.open);
|
|
1137
|
+
if (this.open) this.inputEl?.focus();
|
|
1138
|
+
}
|
|
1139
|
+
renderMessages(greeting) {
|
|
1140
|
+
if (!this.messagesEl) return;
|
|
1141
|
+
this.messagesEl.replaceChildren();
|
|
1142
|
+
if (this.messages.length === 0 && greeting) {
|
|
1143
|
+
const g = document.createElement("div");
|
|
1144
|
+
g.className = "bubble assistant greeting";
|
|
1145
|
+
g.textContent = greeting;
|
|
1146
|
+
this.messagesEl.appendChild(g);
|
|
1147
|
+
}
|
|
1148
|
+
for (const msg of this.messages) {
|
|
1149
|
+
const el = document.createElement("div");
|
|
1150
|
+
el.className = `bubble ${msg.role}`;
|
|
1151
|
+
if (msg.streaming && !msg.text) el.classList.add("cursor");
|
|
1152
|
+
else if (msg.streaming) {
|
|
1153
|
+
el.classList.add("cursor");
|
|
1154
|
+
el.textContent = msg.text;
|
|
1155
|
+
} else el.textContent = msg.text;
|
|
1156
|
+
this.messagesEl.appendChild(el);
|
|
1157
|
+
if (msg.role === "assistant" && !msg.streaming && msg.citations && msg.citations.length > 0) this.messagesEl.appendChild(this.renderSources(msg.citations));
|
|
1158
|
+
}
|
|
1159
|
+
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Build the collapsible "Sources (N)" block for an assistant message's
|
|
1163
|
+
* citations. Each source renders its `title` (linked to `citation.url` when
|
|
1164
|
+
* present — `target=_blank rel=noopener` — plain text otherwise) plus the
|
|
1165
|
+
* grounding `snippet`. Built with DOM APIs (not innerHTML) so citation text
|
|
1166
|
+
* can't inject markup.
|
|
1167
|
+
*/
|
|
1168
|
+
renderSources(citations) {
|
|
1169
|
+
const wrap = document.createElement("div");
|
|
1170
|
+
wrap.className = "sources";
|
|
1171
|
+
wrap.setAttribute("part", "sources");
|
|
1172
|
+
const details = document.createElement("details");
|
|
1173
|
+
details.open = true;
|
|
1174
|
+
const summary = document.createElement("summary");
|
|
1175
|
+
summary.textContent = `Sources (${citations.length})`;
|
|
1176
|
+
details.appendChild(summary);
|
|
1177
|
+
const list = document.createElement("ol");
|
|
1178
|
+
for (const c of citations) {
|
|
1179
|
+
const li = document.createElement("li");
|
|
1180
|
+
let titleEl;
|
|
1181
|
+
const safeUrl = safeHttpUrl(c.url);
|
|
1182
|
+
if (safeUrl) {
|
|
1183
|
+
const a = document.createElement("a");
|
|
1184
|
+
a.className = "src-title";
|
|
1185
|
+
a.href = safeUrl;
|
|
1186
|
+
a.target = "_blank";
|
|
1187
|
+
a.rel = "noopener noreferrer";
|
|
1188
|
+
titleEl = a;
|
|
1189
|
+
} else {
|
|
1190
|
+
titleEl = document.createElement("span");
|
|
1191
|
+
titleEl.className = "src-title";
|
|
1192
|
+
}
|
|
1193
|
+
titleEl.textContent = c.title || c.id || "Source";
|
|
1194
|
+
li.appendChild(titleEl);
|
|
1195
|
+
if (c.snippet) {
|
|
1196
|
+
const snip = document.createElement("span");
|
|
1197
|
+
snip.className = "src-snippet";
|
|
1198
|
+
snip.textContent = c.snippet;
|
|
1199
|
+
li.appendChild(snip);
|
|
1200
|
+
}
|
|
1201
|
+
list.appendChild(li);
|
|
1202
|
+
}
|
|
1203
|
+
details.appendChild(list);
|
|
1204
|
+
wrap.appendChild(details);
|
|
1205
|
+
return wrap;
|
|
1206
|
+
}
|
|
1207
|
+
renderStatus() {
|
|
1208
|
+
if (!this.statusEl) return;
|
|
1209
|
+
const label = {
|
|
1210
|
+
idle: "",
|
|
1211
|
+
connecting: "Connecting…",
|
|
1212
|
+
ready: "Online",
|
|
1213
|
+
error: "Connection issue",
|
|
1214
|
+
closed: "Disconnected"
|
|
1215
|
+
};
|
|
1216
|
+
this.statusEl.textContent = label[this.status];
|
|
1217
|
+
}
|
|
1218
|
+
renderComposerState() {
|
|
1219
|
+
const busy = this.status === "connecting";
|
|
1220
|
+
if (this.sendBtn) this.sendBtn.disabled = busy;
|
|
1221
|
+
if (this.inputEl) this.inputEl.disabled = busy;
|
|
1222
|
+
}
|
|
1223
|
+
submit() {
|
|
1224
|
+
if (!this.inputEl || !this.controller) return;
|
|
1225
|
+
const text = this.inputEl.value;
|
|
1226
|
+
if (!text.trim()) return;
|
|
1227
|
+
this.inputEl.value = "";
|
|
1228
|
+
this.controller.send(text);
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
function escapeHtml(value) {
|
|
1232
|
+
return value.replace(/[&<>"']/g, (c) => {
|
|
1233
|
+
switch (c) {
|
|
1234
|
+
case "&": return "&";
|
|
1235
|
+
case "<": return "<";
|
|
1236
|
+
case ">": return ">";
|
|
1237
|
+
case "\"": return """;
|
|
1238
|
+
default: return "'";
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
/** Register the custom element once. Safe to call multiple times. */
|
|
1243
|
+
function defineChatWidget() {
|
|
1244
|
+
if (typeof customElements !== "undefined" && !customElements.get("smooth-agent-chat")) customElements.define(ELEMENT_TAG, SmoothAgentChatElement);
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Programmatically create, configure, and append a widget to the page.
|
|
1248
|
+
* Returns the element so the host can drive `openChat()` / `closeChat()`.
|
|
1249
|
+
*/
|
|
1250
|
+
function mountChatWidget(config, target = document.body) {
|
|
1251
|
+
defineChatWidget();
|
|
1252
|
+
const el = document.createElement(ELEMENT_TAG);
|
|
1253
|
+
el.configure(config);
|
|
1254
|
+
target.appendChild(el);
|
|
1255
|
+
return el;
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Ergonomic helper for the full-page layout: mounts a `<smooth-agent-chat>` in
|
|
1259
|
+
* `mode: "fullpage"` (no launcher — the chat fills its container/viewport with a
|
|
1260
|
+
* Smooth-branded header, a scrollable message list, and an input bar) and
|
|
1261
|
+
* returns the element.
|
|
1262
|
+
*
|
|
1263
|
+
* `target` defaults to `document.body`; pass a sized container to embed the
|
|
1264
|
+
* full-page chat inside a layout region (e.g. a `/chat` route shell or an
|
|
1265
|
+
* iframe). The `mode` is forced to `"fullpage"` regardless of the passed config.
|
|
1266
|
+
*
|
|
1267
|
+
* ```ts
|
|
1268
|
+
* mountFullPageChat({ endpoint: 'wss://…/ws', agentId: '…', agentName: 'Support' });
|
|
1269
|
+
* ```
|
|
1270
|
+
*/
|
|
1271
|
+
function mountFullPageChat(config, target = document.body) {
|
|
1272
|
+
return mountChatWidget({
|
|
1273
|
+
...config,
|
|
1274
|
+
mode: "fullpage"
|
|
1275
|
+
}, target);
|
|
1276
|
+
}
|
|
1277
|
+
//#endregion
|
|
1278
|
+
//#region src/widget/standalone.ts
|
|
1279
|
+
defineChatWidget();
|
|
1280
|
+
/** Convenience alias matching the global API surface (`SmoothAgentChat.mount`). */
|
|
1281
|
+
function mount(config, target) {
|
|
1282
|
+
return mountChatWidget(config, target);
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Full-page convenience alias (`SmoothAgentChat.mountFullPage`): mounts the chat
|
|
1286
|
+
* in `mode: "fullpage"` so it fills its container/viewport with no launcher.
|
|
1287
|
+
*/
|
|
1288
|
+
function mountFullPage(config, target) {
|
|
1289
|
+
return mountFullPageChat(config, target);
|
|
1290
|
+
}
|
|
1291
|
+
//#endregion
|
|
1292
|
+
exports.SmoothAgentChatElement = SmoothAgentChatElement;
|
|
1293
|
+
exports.defineChatWidget = defineChatWidget;
|
|
1294
|
+
exports.mount = mount;
|
|
1295
|
+
exports.mountChatWidget = mountChatWidget;
|
|
1296
|
+
exports.mountFullPage = mountFullPage;
|
|
1297
|
+
exports.mountFullPageChat = mountFullPageChat;
|
|
1298
|
+
return exports;
|
|
1299
|
+
})({});
|
|
1300
|
+
|
|
1301
|
+
//# sourceMappingURL=chat-widget.iife.js.map
|