@pixygon/chatbot-react 0.1.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,1086 @@
1
+ // src/context.ts
2
+ function resolveConfig(cfg) {
3
+ return {
4
+ ...cfg,
5
+ brand: {
6
+ name: cfg.brand?.name || "Assistant",
7
+ tagline: cfg.brand?.tagline || `Powered by ${cfg.brand?.name || "Pixygon"}`,
8
+ primaryColor: cfg.brand?.primaryColor || "#8FB7C9"
9
+ }
10
+ };
11
+ }
12
+
13
+ // src/endpoints.ts
14
+ function injectChatbotEndpoints(baseApi, opts = { pathPrefix: "/tenants" }) {
15
+ const p = opts.pathPrefix;
16
+ return baseApi.injectEndpoints({
17
+ endpoints: (b) => ({
18
+ // Knowledge
19
+ listDocuments: b.query({
20
+ query: (tenantId) => `${p}/${tenantId}/knowledge`,
21
+ providesTags: ["Knowledge"]
22
+ }),
23
+ getDocument: b.query({
24
+ query: ({ tenantId, documentId }) => `${p}/${tenantId}/knowledge/${documentId}`,
25
+ providesTags: (_r, _e, { documentId }) => [{ type: "Knowledge", id: documentId }]
26
+ }),
27
+ createDocument: b.mutation({
28
+ query: ({ tenantId, data }) => ({ url: `${p}/${tenantId}/knowledge`, method: "POST", body: data }),
29
+ invalidatesTags: ["Knowledge"]
30
+ }),
31
+ updateDocument: b.mutation({
32
+ query: ({ tenantId, documentId, patch }) => ({
33
+ url: `${p}/${tenantId}/knowledge/${documentId}`,
34
+ method: "PUT",
35
+ body: patch
36
+ }),
37
+ invalidatesTags: ["Knowledge"]
38
+ }),
39
+ deleteDocument: b.mutation({
40
+ query: ({ tenantId, documentId }) => ({
41
+ url: `${p}/${tenantId}/knowledge/${documentId}`,
42
+ method: "DELETE"
43
+ }),
44
+ invalidatesTags: ["Knowledge"]
45
+ }),
46
+ // Chat
47
+ sendMessage: b.mutation({
48
+ query: ({ tenantId, sessionId, message }) => ({
49
+ url: `${p}/${tenantId}/chat`,
50
+ method: "POST",
51
+ body: { sessionId, message }
52
+ }),
53
+ invalidatesTags: (_r, _e, { sessionId }) => [
54
+ { type: "ChatConversation", id: sessionId || "new" },
55
+ "ChatConversation"
56
+ ]
57
+ }),
58
+ getConversation: b.query({
59
+ query: ({ tenantId, sessionId }) => `${p}/${tenantId}/chat/${sessionId}`,
60
+ providesTags: (_r, _e, { sessionId }) => [{ type: "ChatConversation", id: sessionId }]
61
+ }),
62
+ listConversations: b.query({
63
+ query: ({ tenantId, limit = 50 }) => `${p}/${tenantId}/conversations?limit=${limit}`,
64
+ providesTags: ["ChatConversation"]
65
+ }),
66
+ rateMessage: b.mutation({
67
+ query: ({ tenantId, ...body }) => ({ url: `${p}/${tenantId}/chat/rate`, method: "POST", body }),
68
+ invalidatesTags: ["ChatConversation"]
69
+ }),
70
+ // Analytics
71
+ getChatOverview: b.query({
72
+ query: (tenantId) => `${p}/${tenantId}/chat-analytics/overview`,
73
+ providesTags: ["ChatConversation"]
74
+ }),
75
+ getTopQuestions: b.query({
76
+ query: ({ tenantId, limit = 20 }) => `${p}/${tenantId}/chat-analytics/top-questions?limit=${limit}`,
77
+ providesTags: ["ChatConversation"]
78
+ }),
79
+ getKeywords: b.query({
80
+ query: ({ tenantId, limit = 30 }) => `${p}/${tenantId}/chat-analytics/keywords?limit=${limit}`,
81
+ providesTags: ["ChatConversation"]
82
+ }),
83
+ getCostTimeseries: b.query({
84
+ query: ({ tenantId, days = 30 }) => `${p}/${tenantId}/chat-analytics/cost-timeseries?days=${days}`,
85
+ providesTags: ["ChatConversation"]
86
+ }),
87
+ getKnowledgeGaps: b.query({
88
+ query: ({ tenantId, limit = 15 }) => `${p}/${tenantId}/chat-analytics/knowledge-gaps?limit=${limit}`,
89
+ providesTags: ["ChatConversation"]
90
+ }),
91
+ getDocumentUsage: b.query({
92
+ query: (tenantId) => `${p}/${tenantId}/chat-analytics/document-usage`,
93
+ providesTags: ["ChatConversation", "Knowledge"]
94
+ }),
95
+ getConversationsForQuestion: b.query({
96
+ query: ({ tenantId, normalized, limit = 50 }) => `${p}/${tenantId}/chat-analytics/conversations?normalized=${encodeURIComponent(normalized)}&limit=${limit}`
97
+ }),
98
+ getSemanticClusters: b.query({
99
+ query: ({ tenantId, limit = 15 }) => `${p}/${tenantId}/chat-analytics/semantic-clusters?limit=${limit}`,
100
+ providesTags: ["ChatConversation"]
101
+ })
102
+ })
103
+ });
104
+ }
105
+
106
+ // src/pages/KnowledgePage.tsx
107
+ import { useState } from "react";
108
+ import {
109
+ Box,
110
+ Typography,
111
+ Card,
112
+ CardContent,
113
+ Stack,
114
+ Button,
115
+ Chip,
116
+ Table,
117
+ TableHead,
118
+ TableRow,
119
+ TableCell,
120
+ TableBody,
121
+ Alert,
122
+ Skeleton,
123
+ Dialog,
124
+ DialogTitle,
125
+ DialogContent,
126
+ DialogActions,
127
+ TextField,
128
+ IconButton,
129
+ Tooltip
130
+ } from "@mui/material";
131
+ import { Add, Delete, Refresh } from "@mui/icons-material";
132
+ import { jsx, jsxs } from "react/jsx-runtime";
133
+ var STATUS_COLOR = {
134
+ pending: "default",
135
+ processing: "info",
136
+ ready: "success",
137
+ failed: "error"
138
+ };
139
+ function createKnowledgePage(_cfg, hooks) {
140
+ return function KnowledgePage(props) {
141
+ const tenantId = props.tenantId;
142
+ const { data: documents = [], isLoading, refetch } = hooks.useListDocumentsQuery(tenantId, { skip: !tenantId });
143
+ const [createDocument, { isLoading: creating }] = hooks.useCreateDocumentMutation();
144
+ const [deleteDocument] = hooks.useDeleteDocumentMutation();
145
+ const [open, setOpen] = useState(false);
146
+ const [form, setForm] = useState({ title: "", content: "", source: "" });
147
+ const [error, setError] = useState(null);
148
+ if (!tenantId) return /* @__PURE__ */ jsx(Alert, { severity: "info", children: "Select a tenant first." });
149
+ const handleCreate = async () => {
150
+ setError(null);
151
+ try {
152
+ await createDocument({
153
+ tenantId,
154
+ data: { title: form.title, content: form.content, source: form.source || void 0 }
155
+ }).unwrap();
156
+ setForm({ title: "", content: "", source: "" });
157
+ setOpen(false);
158
+ } catch (e) {
159
+ setError(e?.data?.error || "Failed to upload");
160
+ }
161
+ };
162
+ const handleDelete = async (documentId, title) => {
163
+ if (!confirm(`Delete "${title}"? Its chunks are removed from the chatbot immediately.`)) return;
164
+ await deleteDocument({ tenantId, documentId });
165
+ };
166
+ const processing = documents.some((d) => d.status === "processing" || d.status === "pending");
167
+ return /* @__PURE__ */ jsxs(Box, { children: [
168
+ /* @__PURE__ */ jsxs(Stack, { direction: "row", alignItems: "center", justifyContent: "space-between", sx: { mb: 3 }, children: [
169
+ /* @__PURE__ */ jsxs(Box, { children: [
170
+ /* @__PURE__ */ jsx(Typography, { variant: "h4", children: "Knowledge base" }),
171
+ /* @__PURE__ */ jsx(Typography, { variant: "body2", color: "text.secondary", children: "Material the chatbot draws from. Each document is chunked and embedded so the bot can cite relevant passages." })
172
+ ] }),
173
+ /* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 1, children: [
174
+ /* @__PURE__ */ jsx(Tooltip, { title: "Refresh status", children: /* @__PURE__ */ jsx(IconButton, { onClick: () => refetch(), children: /* @__PURE__ */ jsx(Refresh, {}) }) }),
175
+ /* @__PURE__ */ jsx(Button, { variant: "contained", startIcon: /* @__PURE__ */ jsx(Add, {}), onClick: () => setOpen(true), children: "Add document" })
176
+ ] })
177
+ ] }),
178
+ processing && /* @__PURE__ */ jsx(Alert, { severity: "info", sx: { mb: 2 }, children: "Processing in progress \u2014 embeddings can take 30s to a few minutes per document. Refresh to see status updates." }),
179
+ /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, { children: isLoading ? /* @__PURE__ */ jsx(Skeleton, { height: 200 }) : documents.length === 0 ? /* @__PURE__ */ jsx(Alert, { severity: "info", children: "No documents yet. Add one to get the chatbot started." }) : /* @__PURE__ */ jsxs(Table, { size: "small", children: [
180
+ /* @__PURE__ */ jsx(TableHead, { children: /* @__PURE__ */ jsxs(TableRow, { children: [
181
+ /* @__PURE__ */ jsx(TableCell, { children: "Title" }),
182
+ /* @__PURE__ */ jsx(TableCell, { children: "Source" }),
183
+ /* @__PURE__ */ jsx(TableCell, { children: "Status" }),
184
+ /* @__PURE__ */ jsx(TableCell, { children: "Chunks" }),
185
+ /* @__PURE__ */ jsx(TableCell, { children: "Updated" }),
186
+ /* @__PURE__ */ jsx(TableCell, { align: "right" })
187
+ ] }) }),
188
+ /* @__PURE__ */ jsx(TableBody, { children: documents.map((d) => /* @__PURE__ */ jsxs(TableRow, { hover: true, children: [
189
+ /* @__PURE__ */ jsxs(TableCell, { children: [
190
+ /* @__PURE__ */ jsx(Typography, { variant: "body2", fontWeight: 600, children: d.title }),
191
+ d.lastError && /* @__PURE__ */ jsx(Typography, { variant: "caption", color: "error.main", sx: { display: "block" }, children: d.lastError })
192
+ ] }),
193
+ /* @__PURE__ */ jsx(TableCell, { children: /* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 0.5, alignItems: "center", children: [
194
+ /* @__PURE__ */ jsx(Chip, { label: d.sourceType, size: "small", variant: "outlined" }),
195
+ d.source && /* @__PURE__ */ jsx(Typography, { variant: "caption", color: "text.secondary", noWrap: true, sx: { maxWidth: 240 }, children: d.source })
196
+ ] }) }),
197
+ /* @__PURE__ */ jsx(TableCell, { children: /* @__PURE__ */ jsx(Chip, { label: d.status, size: "small", color: STATUS_COLOR[d.status] }) }),
198
+ /* @__PURE__ */ jsx(TableCell, { children: d.chunkCount }),
199
+ /* @__PURE__ */ jsx(TableCell, { children: new Date(d.updatedAt).toLocaleString() }),
200
+ /* @__PURE__ */ jsx(TableCell, { align: "right", children: /* @__PURE__ */ jsx(IconButton, { size: "small", onClick: () => handleDelete(d._id, d.title), children: /* @__PURE__ */ jsx(Delete, { fontSize: "small" }) }) })
201
+ ] }, d._id)) })
202
+ ] }) }) }),
203
+ /* @__PURE__ */ jsxs(Dialog, { open, onClose: () => setOpen(false), fullWidth: true, maxWidth: "md", children: [
204
+ /* @__PURE__ */ jsx(DialogTitle, { children: "Add knowledge document" }),
205
+ /* @__PURE__ */ jsx(DialogContent, { children: /* @__PURE__ */ jsxs(Stack, { spacing: 2, sx: { mt: 1 }, children: [
206
+ /* @__PURE__ */ jsx(
207
+ TextField,
208
+ {
209
+ label: "Title",
210
+ required: true,
211
+ autoFocus: true,
212
+ value: form.title,
213
+ onChange: (e) => setForm({ ...form, title: e.target.value })
214
+ }
215
+ ),
216
+ /* @__PURE__ */ jsx(
217
+ TextField,
218
+ {
219
+ label: "Source (optional)",
220
+ value: form.source,
221
+ onChange: (e) => setForm({ ...form, source: e.target.value }),
222
+ helperText: "A URL, filename or note \u2014 shown next to citations."
223
+ }
224
+ ),
225
+ /* @__PURE__ */ jsx(
226
+ TextField,
227
+ {
228
+ label: "Content",
229
+ required: true,
230
+ multiline: true,
231
+ minRows: 10,
232
+ maxRows: 20,
233
+ value: form.content,
234
+ onChange: (e) => setForm({ ...form, content: e.target.value }),
235
+ helperText: "Paste the full document text. It gets chunked and embedded once you save."
236
+ }
237
+ ),
238
+ error && /* @__PURE__ */ jsx(Alert, { severity: "error", children: error })
239
+ ] }) }),
240
+ /* @__PURE__ */ jsxs(DialogActions, { children: [
241
+ /* @__PURE__ */ jsx(Button, { onClick: () => setOpen(false), children: "Cancel" }),
242
+ /* @__PURE__ */ jsx(
243
+ Button,
244
+ {
245
+ variant: "contained",
246
+ onClick: handleCreate,
247
+ disabled: creating || !form.title || form.content.length < 10,
248
+ children: "Save & process"
249
+ }
250
+ )
251
+ ] })
252
+ ] })
253
+ ] });
254
+ };
255
+ }
256
+
257
+ // src/pages/ChatPage.tsx
258
+ import { useEffect, useRef, useState as useState2 } from "react";
259
+ import {
260
+ Box as Box2,
261
+ Typography as Typography2,
262
+ Card as Card2,
263
+ CardContent as CardContent2,
264
+ Stack as Stack2,
265
+ TextField as TextField2,
266
+ Button as Button2,
267
+ Alert as Alert2,
268
+ Chip as Chip2,
269
+ IconButton as IconButton2,
270
+ Tooltip as Tooltip2,
271
+ Divider
272
+ } from "@mui/material";
273
+ import { Send, ThumbUp, ThumbDown, ChatBubbleOutline, RestartAlt } from "@mui/icons-material";
274
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
275
+ function createChatPage(_cfg, hooks) {
276
+ return function ChatPage(props) {
277
+ const tenantId = props.tenantId;
278
+ const [sessionId, setSessionId] = useState2(null);
279
+ const [draft, setDraft] = useState2("");
280
+ const [optimistic, setOptimistic] = useState2([]);
281
+ const [error, setError] = useState2(null);
282
+ const scrollRef = useRef(null);
283
+ const [sendMessage, { isLoading: sending }] = hooks.useSendMessageMutation();
284
+ const [rateMessage] = hooks.useRateMessageMutation();
285
+ const { data: conversation } = hooks.useGetConversationQuery(
286
+ { tenantId, sessionId },
287
+ { skip: !tenantId || !sessionId }
288
+ );
289
+ const display = [
290
+ ...conversation?.messages || [],
291
+ ...optimistic.filter((o) => !(conversation?.messages || []).some(
292
+ (m) => m.content === o.content && m.role === o.role
293
+ ))
294
+ ];
295
+ useEffect(() => {
296
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
297
+ }, [display.length]);
298
+ if (!tenantId) return /* @__PURE__ */ jsx2(Alert2, { severity: "info", children: "Select a tenant first." });
299
+ const handleSend = async () => {
300
+ if (!draft.trim() || sending) return;
301
+ setError(null);
302
+ const localUser = { _localId: `local-${Date.now()}`, role: "user", content: draft.trim() };
303
+ const localPending = { _localId: `pending-${Date.now()}`, role: "assistant", content: "Thinking\u2026", pending: true };
304
+ setOptimistic((prev) => [...prev, localUser, localPending]);
305
+ setDraft("");
306
+ try {
307
+ const res = await sendMessage({
308
+ tenantId,
309
+ sessionId: sessionId || void 0,
310
+ message: localUser.content
311
+ }).unwrap();
312
+ if (!sessionId) setSessionId(res.sessionId);
313
+ setOptimistic((prev) => prev.filter((p) => p._localId !== localPending._localId));
314
+ } catch (e) {
315
+ setError(e?.data?.error || "Send failed");
316
+ setOptimistic((prev) => prev.filter((p) => !p.pending));
317
+ }
318
+ };
319
+ const handleRate = async (messageId, rating) => {
320
+ if (!conversation) return;
321
+ await rateMessage({
322
+ tenantId,
323
+ conversationId: conversation._id,
324
+ messageId,
325
+ rating
326
+ });
327
+ };
328
+ const handleNewChat = () => {
329
+ setSessionId(null);
330
+ setOptimistic([]);
331
+ setError(null);
332
+ };
333
+ return /* @__PURE__ */ jsxs2(Box2, { sx: { display: "flex", flexDirection: "column", height: "calc(100vh - 96px)" }, children: [
334
+ /* @__PURE__ */ jsxs2(Stack2, { direction: "row", alignItems: "center", justifyContent: "space-between", sx: { mb: 2 }, children: [
335
+ /* @__PURE__ */ jsxs2(Box2, { children: [
336
+ /* @__PURE__ */ jsx2(Typography2, { variant: "h4", children: "Assistant" }),
337
+ /* @__PURE__ */ jsx2(Typography2, { variant: "body2", color: "text.secondary", children: "Test the chatbot. End-users get the same experience via the public widget." })
338
+ ] }),
339
+ /* @__PURE__ */ jsxs2(Stack2, { direction: "row", spacing: 1, alignItems: "center", children: [
340
+ conversation && /* @__PURE__ */ jsx2(
341
+ Chip2,
342
+ {
343
+ label: `${conversation.messages.length} turns \xB7 $${conversation.totalCostUsd.toFixed(4)}`,
344
+ size: "small",
345
+ variant: "outlined"
346
+ }
347
+ ),
348
+ /* @__PURE__ */ jsx2(Button2, { startIcon: /* @__PURE__ */ jsx2(RestartAlt, {}), onClick: handleNewChat, children: "New chat" })
349
+ ] })
350
+ ] }),
351
+ /* @__PURE__ */ jsxs2(Card2, { sx: { flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }, children: [
352
+ /* @__PURE__ */ jsx2(CardContent2, { ref: scrollRef, sx: { flex: 1, overflowY: "auto", "&:last-child": { pb: 2 } }, children: display.length === 0 ? /* @__PURE__ */ jsxs2(Stack2, { alignItems: "center", sx: { py: 8 }, spacing: 2, children: [
353
+ /* @__PURE__ */ jsx2(ChatBubbleOutline, { sx: { fontSize: 48, color: "text.secondary" } }),
354
+ /* @__PURE__ */ jsx2(Typography2, { variant: "body2", color: "text.secondary", children: "Ask anything \u2014 the bot answers from your knowledge base only." })
355
+ ] }) : /* @__PURE__ */ jsx2(Stack2, { spacing: 2, children: display.map((turn, i) => /* @__PURE__ */ jsx2(
356
+ TurnBubble,
357
+ {
358
+ turn,
359
+ onRate: turn._id ? (r) => handleRate(turn._id, r) : void 0
360
+ },
361
+ turn._id || turn._localId || String(i)
362
+ )) }) }),
363
+ /* @__PURE__ */ jsx2(Divider, {}),
364
+ /* @__PURE__ */ jsxs2(Box2, { sx: { p: 1.5, bgcolor: "background.default" }, children: [
365
+ error && /* @__PURE__ */ jsx2(Alert2, { severity: "error", sx: { mb: 1 }, children: error }),
366
+ /* @__PURE__ */ jsxs2(Stack2, { direction: "row", spacing: 1, children: [
367
+ /* @__PURE__ */ jsx2(
368
+ TextField2,
369
+ {
370
+ fullWidth: true,
371
+ multiline: true,
372
+ maxRows: 6,
373
+ placeholder: "Ask the assistant\u2026",
374
+ value: draft,
375
+ onChange: (e) => setDraft(e.target.value),
376
+ onKeyDown: (e) => {
377
+ if (e.key === "Enter" && !e.shiftKey) {
378
+ e.preventDefault();
379
+ handleSend();
380
+ }
381
+ },
382
+ disabled: sending
383
+ }
384
+ ),
385
+ /* @__PURE__ */ jsx2(
386
+ Button2,
387
+ {
388
+ variant: "contained",
389
+ endIcon: /* @__PURE__ */ jsx2(Send, {}),
390
+ onClick: handleSend,
391
+ disabled: !draft.trim() || sending,
392
+ children: "Send"
393
+ }
394
+ )
395
+ ] })
396
+ ] })
397
+ ] })
398
+ ] });
399
+ };
400
+ }
401
+ function TurnBubble({ turn, onRate }) {
402
+ const isUser = turn.role === "user";
403
+ const bg = isUser ? "primary.main" : "background.paper";
404
+ const color = isUser ? "primary.contrastText" : "text.primary";
405
+ return /* @__PURE__ */ jsx2(Box2, { sx: { display: "flex", justifyContent: isUser ? "flex-end" : "flex-start" }, children: /* @__PURE__ */ jsxs2(Box2, { sx: { maxWidth: "75%" }, children: [
406
+ /* @__PURE__ */ jsx2(Box2, { sx: {
407
+ bgcolor: bg,
408
+ color,
409
+ px: 2,
410
+ py: 1.5,
411
+ borderRadius: 2,
412
+ border: "1px solid",
413
+ borderColor: isUser ? "primary.main" : "divider",
414
+ whiteSpace: "pre-wrap",
415
+ wordBreak: "break-word",
416
+ opacity: turn.pending ? 0.6 : 1
417
+ }, children: /* @__PURE__ */ jsx2(Typography2, { variant: "body2", children: turn.content }) }),
418
+ !isUser && turn.citations?.length > 0 && /* @__PURE__ */ jsx2(Stack2, { direction: "row", spacing: 0.5, sx: { mt: 1, flexWrap: "wrap", gap: 0.5 }, children: turn.citations.map((c, i) => /* @__PURE__ */ jsx2(Tooltip2, { title: c.snippet, children: /* @__PURE__ */ jsx2(Chip2, { label: `[${i + 1}] ${c.documentTitle}`, size: "small", variant: "outlined" }) }, c.chunkId || i)) }),
419
+ !isUser && onRate && /* @__PURE__ */ jsxs2(Stack2, { direction: "row", spacing: 0.5, sx: { mt: 0.5 }, children: [
420
+ /* @__PURE__ */ jsx2(
421
+ IconButton2,
422
+ {
423
+ size: "small",
424
+ onClick: () => onRate(1),
425
+ color: turn.rating === 1 ? "primary" : "default",
426
+ children: /* @__PURE__ */ jsx2(ThumbUp, { fontSize: "small" })
427
+ }
428
+ ),
429
+ /* @__PURE__ */ jsx2(
430
+ IconButton2,
431
+ {
432
+ size: "small",
433
+ onClick: () => onRate(-1),
434
+ color: turn.rating === -1 ? "error" : "default",
435
+ children: /* @__PURE__ */ jsx2(ThumbDown, { fontSize: "small" })
436
+ }
437
+ )
438
+ ] })
439
+ ] }) });
440
+ }
441
+
442
+ // src/pages/ChatAnalyticsPage.tsx
443
+ import { useState as useState3 } from "react";
444
+ import {
445
+ Box as Box3,
446
+ Typography as Typography3,
447
+ Card as Card3,
448
+ CardContent as CardContent3,
449
+ Stack as Stack3,
450
+ Alert as Alert3,
451
+ Skeleton as Skeleton2,
452
+ Table as Table2,
453
+ TableHead as TableHead2,
454
+ TableRow as TableRow2,
455
+ TableCell as TableCell2,
456
+ TableBody as TableBody2,
457
+ Chip as Chip3,
458
+ LinearProgress,
459
+ Divider as Divider2,
460
+ Tooltip as Tooltip3,
461
+ IconButton as IconButton3,
462
+ Dialog as Dialog2,
463
+ DialogTitle as DialogTitle2,
464
+ DialogContent as DialogContent2,
465
+ DialogActions as DialogActions2,
466
+ Button as Button3
467
+ } from "@mui/material";
468
+ import { TipsAndUpdates, Close, ThumbDown as ThumbDown2, ThumbUp as ThumbUp2 } from "@mui/icons-material";
469
+ import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
470
+ function createChatAnalyticsPage(_cfg, hooks) {
471
+ return function ChatAnalyticsPage(props) {
472
+ const tenantId = props.tenantId;
473
+ const { data: overview, isLoading: loadingOverview } = hooks.useGetChatOverviewQuery(
474
+ tenantId,
475
+ { skip: !tenantId }
476
+ );
477
+ const { data: topQ = [] } = hooks.useGetTopQuestionsQuery(
478
+ { tenantId, limit: 20 },
479
+ { skip: !tenantId }
480
+ );
481
+ const { data: keywords = [] } = hooks.useGetKeywordsQuery(
482
+ { tenantId, limit: 30 },
483
+ { skip: !tenantId }
484
+ );
485
+ const { data: costSeries = [] } = hooks.useGetCostTimeseriesQuery(
486
+ { tenantId, days: 30 },
487
+ { skip: !tenantId }
488
+ );
489
+ const { data: gaps = [] } = hooks.useGetKnowledgeGapsQuery(
490
+ { tenantId, limit: 15 },
491
+ { skip: !tenantId }
492
+ );
493
+ const { data: docUsage = [] } = hooks.useGetDocumentUsageQuery(
494
+ tenantId,
495
+ { skip: !tenantId }
496
+ );
497
+ const { data: clusters = [] } = hooks.useGetSemanticClustersQuery(
498
+ { tenantId, limit: 15 },
499
+ { skip: !tenantId }
500
+ );
501
+ const [drillQuestion, setDrillQuestion] = useState3(null);
502
+ if (!tenantId) return /* @__PURE__ */ jsx3(Alert3, { severity: "info", children: "Select a tenant first." });
503
+ const totalRated = (overview?.ratingsHelpful || 0) + (overview?.ratingsUnhelpful || 0);
504
+ const helpfulPct = totalRated > 0 ? Math.round(overview.ratingsHelpful / totalRated * 100) : null;
505
+ const maxKeyword = keywords[0]?.count || 1;
506
+ const maxCostDay = Math.max(1, ...costSeries.map((d) => d.costUsd));
507
+ return /* @__PURE__ */ jsxs3(Box3, { children: [
508
+ /* @__PURE__ */ jsx3(Typography3, { variant: "h4", sx: { mb: 0.5 }, children: "Assistant analytics" }),
509
+ /* @__PURE__ */ jsx3(Typography3, { variant: "body2", color: "text.secondary", sx: { mb: 3 }, children: "What users ask, what words come up most, what it's costing you, and whether they thought it helped." }),
510
+ /* @__PURE__ */ jsxs3(Stack3, { direction: { xs: "column", md: "row" }, spacing: 2, sx: { mb: 3 }, children: [
511
+ /* @__PURE__ */ jsx3(Tile, { label: "Conversations", value: overview?.conversations ?? "\u2014", loading: loadingOverview }),
512
+ /* @__PURE__ */ jsx3(Tile, { label: "Messages", value: overview?.messages ?? "\u2014", loading: loadingOverview }),
513
+ /* @__PURE__ */ jsx3(
514
+ Tile,
515
+ {
516
+ label: "Total cost",
517
+ value: overview ? `$${overview.totalCostUsd.toFixed(4)}` : "\u2014",
518
+ loading: loadingOverview
519
+ }
520
+ ),
521
+ /* @__PURE__ */ jsx3(
522
+ Tile,
523
+ {
524
+ label: "Helpful rating",
525
+ value: helpfulPct === null ? "n/a" : `${helpfulPct}%`,
526
+ subtitle: overview ? `${overview.ratingsHelpful}\u{1F44D} \xB7 ${overview.ratingsUnhelpful}\u{1F44E} \xB7 ${overview.ratingsUnrated} unrated` : "",
527
+ loading: loadingOverview
528
+ }
529
+ )
530
+ ] }),
531
+ /* @__PURE__ */ jsx3(Card3, { sx: { mb: 3, borderLeft: "4px solid", borderColor: "warning.main" }, children: /* @__PURE__ */ jsxs3(CardContent3, { children: [
532
+ /* @__PURE__ */ jsxs3(Stack3, { direction: "row", alignItems: "center", spacing: 1, sx: { mb: 1 }, children: [
533
+ /* @__PURE__ */ jsx3(TipsAndUpdates, { color: "warning" }),
534
+ /* @__PURE__ */ jsx3(Typography3, { variant: "h6", children: "Knowledge gaps" })
535
+ ] }),
536
+ /* @__PURE__ */ jsx3(Typography3, { variant: "caption", color: "text.secondary", children: "Questions where the bot's answer was weak \u2014 low retrieval similarity, no chunks matched, or got a thumbs-down. Rank shows what'd help most to write KB material about." }),
537
+ gaps.length === 0 ? /* @__PURE__ */ jsx3(Alert3, { severity: "success", sx: { mt: 2 }, children: "No clear gaps yet \u2014 either no chats or the KB covers everything asked so far." }) : /* @__PURE__ */ jsxs3(Table2, { size: "small", sx: { mt: 1 }, children: [
538
+ /* @__PURE__ */ jsx3(TableHead2, { children: /* @__PURE__ */ jsxs3(TableRow2, { children: [
539
+ /* @__PURE__ */ jsx3(TableCell2, { children: "Question" }),
540
+ /* @__PURE__ */ jsx3(TableCell2, { align: "right", children: "Asked" }),
541
+ /* @__PURE__ */ jsx3(TableCell2, { align: "right", children: "Avg match" }),
542
+ /* @__PURE__ */ jsx3(TableCell2, { align: "right", children: "Signals" })
543
+ ] }) }),
544
+ /* @__PURE__ */ jsx3(TableBody2, { children: gaps.map((g) => /* @__PURE__ */ jsxs3(
545
+ TableRow2,
546
+ {
547
+ hover: true,
548
+ sx: { cursor: "pointer" },
549
+ onClick: () => setDrillQuestion({ normalized: g.normalized, question: g.question }),
550
+ children: [
551
+ /* @__PURE__ */ jsx3(TableCell2, { children: g.question }),
552
+ /* @__PURE__ */ jsx3(TableCell2, { align: "right", children: g.count }),
553
+ /* @__PURE__ */ jsx3(TableCell2, { align: "right", children: /* @__PURE__ */ jsx3(
554
+ Chip3,
555
+ {
556
+ label: `${Math.round(g.avgSimilarity * 100)}%`,
557
+ size: "small",
558
+ color: g.avgSimilarity < 0.3 ? "error" : g.avgSimilarity < 0.5 ? "warning" : "default"
559
+ }
560
+ ) }),
561
+ /* @__PURE__ */ jsx3(TableCell2, { align: "right", children: /* @__PURE__ */ jsxs3(Stack3, { direction: "row", spacing: 0.5, justifyContent: "flex-end", children: [
562
+ g.negativeRatings > 0 && /* @__PURE__ */ jsx3(Tooltip3, { title: `${g.negativeRatings} thumbs-down`, children: /* @__PURE__ */ jsx3(Chip3, { icon: /* @__PURE__ */ jsx3(ThumbDown2, { sx: { fontSize: 14 } }), label: g.negativeRatings, size: "small", color: "error" }) }),
563
+ g.noRetrievalCount > 0 && /* @__PURE__ */ jsx3(Tooltip3, { title: `${g.noRetrievalCount} answers with no source citations`, children: /* @__PURE__ */ jsx3(Chip3, { label: `${g.noRetrievalCount} no-source`, size: "small", color: "warning", variant: "outlined" }) })
564
+ ] }) })
565
+ ]
566
+ },
567
+ g.normalized
568
+ )) })
569
+ ] })
570
+ ] }) }),
571
+ /* @__PURE__ */ jsxs3(Stack3, { direction: { xs: "column", md: "row" }, spacing: 2, sx: { mb: 3 }, children: [
572
+ /* @__PURE__ */ jsx3(Card3, { sx: { flex: 2 }, children: /* @__PURE__ */ jsxs3(CardContent3, { children: [
573
+ /* @__PURE__ */ jsx3(Typography3, { variant: "h6", gutterBottom: true, children: "Top questions" }),
574
+ /* @__PURE__ */ jsx3(Typography3, { variant: "caption", color: "text.secondary", children: "Grouped by normalized form. Click a row to drill into matching conversations." }),
575
+ topQ.length === 0 ? /* @__PURE__ */ jsx3(Alert3, { severity: "info", sx: { mt: 2 }, children: "No questions yet." }) : /* @__PURE__ */ jsxs3(Table2, { size: "small", sx: { mt: 1 }, children: [
576
+ /* @__PURE__ */ jsx3(TableHead2, { children: /* @__PURE__ */ jsxs3(TableRow2, { children: [
577
+ /* @__PURE__ */ jsx3(TableCell2, { children: "Question" }),
578
+ /* @__PURE__ */ jsx3(TableCell2, { align: "right", children: "Count" }),
579
+ /* @__PURE__ */ jsx3(TableCell2, { children: "Last asked" })
580
+ ] }) }),
581
+ /* @__PURE__ */ jsx3(TableBody2, { children: topQ.map((q) => /* @__PURE__ */ jsxs3(
582
+ TableRow2,
583
+ {
584
+ hover: true,
585
+ sx: { cursor: "pointer" },
586
+ onClick: () => setDrillQuestion({ normalized: q.normalized, question: q.question }),
587
+ children: [
588
+ /* @__PURE__ */ jsx3(TableCell2, { children: q.question }),
589
+ /* @__PURE__ */ jsx3(TableCell2, { align: "right", children: /* @__PURE__ */ jsx3(Chip3, { label: q.count, size: "small", color: q.count > 5 ? "primary" : "default" }) }),
590
+ /* @__PURE__ */ jsx3(TableCell2, { children: new Date(q.lastAskedAt).toLocaleDateString() })
591
+ ]
592
+ },
593
+ q.normalized
594
+ )) })
595
+ ] })
596
+ ] }) }),
597
+ /* @__PURE__ */ jsx3(Card3, { sx: { flex: 1 }, children: /* @__PURE__ */ jsxs3(CardContent3, { children: [
598
+ /* @__PURE__ */ jsx3(Typography3, { variant: "h6", gutterBottom: true, children: "Keywords" }),
599
+ /* @__PURE__ */ jsx3(Typography3, { variant: "caption", color: "text.secondary", children: "Most frequent words in user questions. Stopwords + short tokens filtered." }),
600
+ keywords.length === 0 ? /* @__PURE__ */ jsx3(Alert3, { severity: "info", sx: { mt: 2 }, children: "No data yet." }) : /* @__PURE__ */ jsx3(Stack3, { spacing: 0.5, sx: { mt: 2 }, children: keywords.map((k) => /* @__PURE__ */ jsxs3(Box3, { children: [
601
+ /* @__PURE__ */ jsxs3(Stack3, { direction: "row", justifyContent: "space-between", children: [
602
+ /* @__PURE__ */ jsx3(Typography3, { variant: "body2", children: k.term }),
603
+ /* @__PURE__ */ jsx3(Typography3, { variant: "caption", color: "text.secondary", children: k.count })
604
+ ] }),
605
+ /* @__PURE__ */ jsx3(
606
+ LinearProgress,
607
+ {
608
+ variant: "determinate",
609
+ value: k.count / maxKeyword * 100,
610
+ sx: { height: 4, borderRadius: 2 }
611
+ }
612
+ )
613
+ ] }, k.term)) })
614
+ ] }) })
615
+ ] }),
616
+ /* @__PURE__ */ jsx3(Card3, { sx: { mb: 3 }, children: /* @__PURE__ */ jsxs3(CardContent3, { children: [
617
+ /* @__PURE__ */ jsx3(Typography3, { variant: "h6", gutterBottom: true, children: "Semantic clusters" }),
618
+ /* @__PURE__ */ jsx3(Typography3, { variant: "caption", color: "text.secondary", children: "Questions grouped by meaning. Two questions cluster when their embeddings agree at \u226578% cosine similarity." }),
619
+ clusters.length === 0 ? /* @__PURE__ */ jsx3(Alert3, { severity: "info", sx: { mt: 2 }, children: "No clusters yet \u2014 user turns predate embedding capture." }) : /* @__PURE__ */ jsx3(Stack3, { spacing: 1, sx: { mt: 2 }, children: clusters.map((c, i) => /* @__PURE__ */ jsx3(Card3, { variant: "outlined", children: /* @__PURE__ */ jsxs3(CardContent3, { sx: { p: 1.5, "&:last-child": { pb: 1.5 } }, children: [
620
+ /* @__PURE__ */ jsxs3(Stack3, { direction: "row", alignItems: "center", justifyContent: "space-between", sx: { mb: 0.5 }, children: [
621
+ /* @__PURE__ */ jsx3(Typography3, { variant: "body2", fontWeight: 600, children: c.representative }),
622
+ /* @__PURE__ */ jsxs3(Stack3, { direction: "row", spacing: 0.5, children: [
623
+ /* @__PURE__ */ jsx3(Chip3, { label: `${c.count} asks`, size: "small", color: c.count > 5 ? "primary" : "default" }),
624
+ /* @__PURE__ */ jsx3(
625
+ Chip3,
626
+ {
627
+ label: `${Math.round(c.avgSimilarity * 100)}% cohesion`,
628
+ size: "small",
629
+ variant: "outlined",
630
+ color: c.avgSimilarity > 0.9 ? "success" : c.avgSimilarity > 0.8 ? "default" : "warning"
631
+ }
632
+ )
633
+ ] })
634
+ ] }),
635
+ c.members.length > 1 && /* @__PURE__ */ jsx3(Stack3, { spacing: 0.25, children: c.members.slice(1).map((m, j) => /* @__PURE__ */ jsxs3(Typography3, { variant: "caption", color: "text.secondary", sx: { pl: 1 }, children: [
636
+ "\xB7 ",
637
+ m
638
+ ] }, j)) })
639
+ ] }) }, i)) })
640
+ ] }) }),
641
+ /* @__PURE__ */ jsx3(Card3, { sx: { mb: 3 }, children: /* @__PURE__ */ jsxs3(CardContent3, { children: [
642
+ /* @__PURE__ */ jsx3(Typography3, { variant: "h6", gutterBottom: true, children: "Cost \u2014 last 30 days" }),
643
+ costSeries.length === 0 ? /* @__PURE__ */ jsx3(Alert3, { severity: "info", children: "No cost data yet." }) : /* @__PURE__ */ jsxs3(Fragment, { children: [
644
+ /* @__PURE__ */ jsx3(Stack3, { direction: "row", spacing: 0.5, alignItems: "flex-end", sx: { height: 120, mt: 1 }, children: costSeries.map((d) => {
645
+ const height = Math.max(2, d.costUsd / maxCostDay * 100);
646
+ return /* @__PURE__ */ jsx3(
647
+ Box3,
648
+ {
649
+ title: `${d.date} \u2014 $${d.costUsd.toFixed(4)} \xB7 ${d.messages} msgs`,
650
+ sx: { flex: 1, height: `${height}%`, minWidth: 6, bgcolor: "primary.main", borderRadius: 0.5 }
651
+ },
652
+ d.date
653
+ );
654
+ }) }),
655
+ /* @__PURE__ */ jsx3(Divider2, { sx: { my: 1 } }),
656
+ /* @__PURE__ */ jsxs3(Stack3, { direction: "row", justifyContent: "space-between", children: [
657
+ /* @__PURE__ */ jsx3(Typography3, { variant: "caption", color: "text.secondary", children: costSeries[0]?.date }),
658
+ /* @__PURE__ */ jsx3(Typography3, { variant: "caption", color: "text.secondary", children: costSeries[costSeries.length - 1]?.date })
659
+ ] })
660
+ ] })
661
+ ] }) }),
662
+ /* @__PURE__ */ jsx3(Card3, { children: /* @__PURE__ */ jsxs3(CardContent3, { children: [
663
+ /* @__PURE__ */ jsx3(Typography3, { variant: "h6", gutterBottom: true, children: "Source usage" }),
664
+ /* @__PURE__ */ jsx3(Typography3, { variant: "caption", color: "text.secondary", children: "How often each knowledge document appears as a citation. Unused docs are off-topic, redundant, or could use better titles." }),
665
+ docUsage.length === 0 ? /* @__PURE__ */ jsx3(Alert3, { severity: "info", sx: { mt: 2 }, children: "No documents in the KB yet." }) : /* @__PURE__ */ jsxs3(Table2, { size: "small", sx: { mt: 1 }, children: [
666
+ /* @__PURE__ */ jsx3(TableHead2, { children: /* @__PURE__ */ jsxs3(TableRow2, { children: [
667
+ /* @__PURE__ */ jsx3(TableCell2, { children: "Document" }),
668
+ /* @__PURE__ */ jsx3(TableCell2, { align: "right", children: "Citations" }),
669
+ /* @__PURE__ */ jsx3(TableCell2, { align: "right", children: "In conversations" }),
670
+ /* @__PURE__ */ jsx3(TableCell2, { align: "right", children: "Chunks used" }),
671
+ /* @__PURE__ */ jsx3(TableCell2, { children: "Status" })
672
+ ] }) }),
673
+ /* @__PURE__ */ jsx3(TableBody2, { children: docUsage.map((d) => /* @__PURE__ */ jsxs3(TableRow2, { children: [
674
+ /* @__PURE__ */ jsx3(TableCell2, { children: d.title }),
675
+ /* @__PURE__ */ jsx3(TableCell2, { align: "right", children: d.citationCount }),
676
+ /* @__PURE__ */ jsx3(TableCell2, { align: "right", children: d.uniqueConversations }),
677
+ /* @__PURE__ */ jsxs3(TableCell2, { align: "right", children: [
678
+ d.citedChunkCount,
679
+ " / ",
680
+ d.chunkCount
681
+ ] }),
682
+ /* @__PURE__ */ jsx3(TableCell2, { children: /* @__PURE__ */ jsx3(
683
+ Chip3,
684
+ {
685
+ label: d.status,
686
+ size: "small",
687
+ color: d.status === "active" ? "success" : d.status === "underused" ? "warning" : "default",
688
+ variant: d.status === "unused" ? "outlined" : "filled"
689
+ }
690
+ ) })
691
+ ] }, d.documentId)) })
692
+ ] })
693
+ ] }) }),
694
+ /* @__PURE__ */ jsx3(Dialog2, { open: !!drillQuestion, onClose: () => setDrillQuestion(null), fullWidth: true, maxWidth: "md", children: drillQuestion && /* @__PURE__ */ jsx3(
695
+ DrillContent,
696
+ {
697
+ hooks,
698
+ tenantId,
699
+ normalized: drillQuestion.normalized,
700
+ question: drillQuestion.question,
701
+ onClose: () => setDrillQuestion(null)
702
+ }
703
+ ) })
704
+ ] });
705
+ };
706
+ }
707
+ function Tile({ label, value, subtitle, loading }) {
708
+ return /* @__PURE__ */ jsx3(Card3, { sx: { flex: 1 }, children: /* @__PURE__ */ jsxs3(CardContent3, { children: [
709
+ /* @__PURE__ */ jsx3(Typography3, { variant: "overline", color: "text.secondary", children: label }),
710
+ loading ? /* @__PURE__ */ jsx3(Skeleton2, { width: 60, height: 48 }) : /* @__PURE__ */ jsx3(Typography3, { variant: "h4", sx: { fontWeight: 700 }, children: value }),
711
+ subtitle && /* @__PURE__ */ jsx3(Typography3, { variant: "caption", color: "text.secondary", sx: { display: "block", mt: 0.5 }, children: subtitle })
712
+ ] }) });
713
+ }
714
+ function DrillContent({
715
+ hooks,
716
+ tenantId,
717
+ normalized,
718
+ question,
719
+ onClose
720
+ }) {
721
+ const { data: matches = [], isLoading } = hooks.useGetConversationsForQuestionQuery(
722
+ { tenantId, normalized, limit: 50 }
723
+ );
724
+ return /* @__PURE__ */ jsxs3(Fragment, { children: [
725
+ /* @__PURE__ */ jsx3(DialogTitle2, { children: /* @__PURE__ */ jsxs3(Stack3, { direction: "row", justifyContent: "space-between", alignItems: "center", children: [
726
+ /* @__PURE__ */ jsxs3(Box3, { children: [
727
+ /* @__PURE__ */ jsx3(Typography3, { variant: "overline", color: "text.secondary", children: "Asked" }),
728
+ /* @__PURE__ */ jsx3(Typography3, { variant: "h6", children: question })
729
+ ] }),
730
+ /* @__PURE__ */ jsx3(IconButton3, { onClick: onClose, children: /* @__PURE__ */ jsx3(Close, {}) })
731
+ ] }) }),
732
+ /* @__PURE__ */ jsx3(DialogContent2, { dividers: true, children: isLoading ? /* @__PURE__ */ jsx3(Skeleton2, { height: 120 }) : matches.length === 0 ? /* @__PURE__ */ jsx3(Alert3, { severity: "info", children: "No matching conversations." }) : /* @__PURE__ */ jsx3(Stack3, { spacing: 1, children: matches.map((m) => /* @__PURE__ */ jsx3(Card3, { variant: "outlined", children: /* @__PURE__ */ jsxs3(CardContent3, { children: [
733
+ /* @__PURE__ */ jsxs3(Stack3, { direction: "row", alignItems: "center", spacing: 1, sx: { mb: 1 }, children: [
734
+ /* @__PURE__ */ jsx3(Typography3, { variant: "caption", color: "text.secondary", children: new Date(m.createdAt).toLocaleString() }),
735
+ typeof m.retrievalSimilarity === "number" && /* @__PURE__ */ jsx3(
736
+ Chip3,
737
+ {
738
+ label: `${Math.round(m.retrievalSimilarity * 100)}% match`,
739
+ size: "small",
740
+ color: m.retrievalSimilarity < 0.3 ? "error" : m.retrievalSimilarity < 0.5 ? "warning" : "default"
741
+ }
742
+ ),
743
+ m.rating === 1 && /* @__PURE__ */ jsx3(ThumbUp2, { fontSize: "small", color: "primary" }),
744
+ m.rating === -1 && /* @__PURE__ */ jsx3(ThumbDown2, { fontSize: "small", color: "error" })
745
+ ] }),
746
+ /* @__PURE__ */ jsx3(Typography3, { variant: "body2", sx: { whiteSpace: "pre-wrap" }, children: m.assistantReply || /* @__PURE__ */ jsx3("em", { style: { color: "#888" }, children: "(no assistant reply recorded)" }) })
747
+ ] }) }, m._id)) }) }),
748
+ /* @__PURE__ */ jsx3(DialogActions2, { children: /* @__PURE__ */ jsx3(Button3, { onClick: onClose, children: "Close" }) })
749
+ ] });
750
+ }
751
+
752
+ // src/pages/EmbedChatPage.tsx
753
+ import { useEffect as useEffect2, useRef as useRef2, useState as useState4 } from "react";
754
+ import {
755
+ Box as Box4,
756
+ Typography as Typography4,
757
+ TextField as TextField3,
758
+ Button as Button4,
759
+ Stack as Stack4,
760
+ Alert as Alert4,
761
+ Chip as Chip4,
762
+ IconButton as IconButton4,
763
+ Tooltip as Tooltip4,
764
+ Avatar
765
+ } from "@mui/material";
766
+ import { Send as Send2, ThumbUp as ThumbUp3, ThumbDown as ThumbDown3, Close as Close2, ChatBubbleOutline as ChatBubbleOutline2 } from "@mui/icons-material";
767
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
768
+ function createEmbedChatPage(cfg) {
769
+ const { apiBase, brand } = cfg;
770
+ const sessionKey = (slug) => `pixygon-chatbot.session.${slug}`;
771
+ return function EmbedChatPage(props) {
772
+ const tenantSlug = props.tenantSlug;
773
+ const [sessionId, setSessionId] = useState4(() => {
774
+ if (!tenantSlug) return null;
775
+ return localStorage.getItem(sessionKey(tenantSlug));
776
+ });
777
+ const [turns, setTurns] = useState4([]);
778
+ const [draft, setDraft] = useState4("");
779
+ const [sending, setSending] = useState4(false);
780
+ const [error, setError] = useState4(null);
781
+ const scrollRef = useRef2(null);
782
+ useEffect2(() => {
783
+ if (!tenantSlug || !sessionId) return;
784
+ fetch(`${apiBase}/public/chat/${tenantSlug}/${sessionId}`).then((res) => res.ok ? res.json() : null).then((data) => {
785
+ if (data?.messages) {
786
+ setTurns(data.messages.map((m) => ({
787
+ _id: m._id,
788
+ role: m.role,
789
+ content: m.content,
790
+ rating: m.rating
791
+ })));
792
+ }
793
+ }).catch(() => {
794
+ });
795
+ }, [tenantSlug, sessionId]);
796
+ useEffect2(() => {
797
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
798
+ }, [turns.length]);
799
+ const send = async () => {
800
+ if (!draft.trim() || sending || !tenantSlug) return;
801
+ const text = draft.trim();
802
+ setDraft("");
803
+ setError(null);
804
+ setTurns((prev) => [
805
+ ...prev,
806
+ { role: "user", content: text },
807
+ { role: "assistant", content: "Thinking\u2026", pending: true }
808
+ ]);
809
+ setSending(true);
810
+ try {
811
+ const res = await fetch(`${apiBase}/public/chat/${tenantSlug}`, {
812
+ method: "POST",
813
+ headers: { "Content-Type": "application/json" },
814
+ body: JSON.stringify({ sessionId: sessionId || void 0, message: text })
815
+ });
816
+ if (!res.ok) {
817
+ const body = await res.json().catch(() => ({}));
818
+ if (res.status === 429) setError(`Too many messages \u2014 wait ${body.retryAfterSeconds || 30}s.`);
819
+ else if (res.status === 503) setError("Assistant is over its monthly budget. Try again later.");
820
+ else setError(body.error || "Send failed");
821
+ setTurns((prev) => prev.filter((t) => !t.pending));
822
+ return;
823
+ }
824
+ const data = await res.json();
825
+ if (!sessionId) {
826
+ setSessionId(data.sessionId);
827
+ localStorage.setItem(sessionKey(tenantSlug), data.sessionId);
828
+ }
829
+ setTurns((prev) => {
830
+ const withoutPending = prev.filter((t) => !t.pending);
831
+ return [...withoutPending, {
832
+ _id: data.conversationId,
833
+ role: "assistant",
834
+ content: data.content,
835
+ citations: data.citations
836
+ }];
837
+ });
838
+ } catch (err) {
839
+ setError(err?.message || "Network error");
840
+ setTurns((prev) => prev.filter((t) => !t.pending));
841
+ } finally {
842
+ setSending(false);
843
+ }
844
+ };
845
+ const rate = async (idx, rating) => {
846
+ if (!tenantSlug || !sessionId) return;
847
+ const turn = turns[idx];
848
+ if (!turn?._id) return;
849
+ setTurns((prev) => prev.map((t, i) => i === idx ? { ...t, rating } : t));
850
+ await fetch(`${apiBase}/public/chat/${tenantSlug}/rate`, {
851
+ method: "POST",
852
+ headers: { "Content-Type": "application/json" },
853
+ body: JSON.stringify({ sessionId, messageId: turn._id, rating })
854
+ }).catch(() => {
855
+ });
856
+ };
857
+ const newChat = () => {
858
+ if (!tenantSlug) return;
859
+ localStorage.removeItem(sessionKey(tenantSlug));
860
+ setSessionId(null);
861
+ setTurns([]);
862
+ setError(null);
863
+ };
864
+ const closeWidget = () => {
865
+ if (window.parent !== window) {
866
+ window.parent.postMessage({ type: "pixygon-chatbot:close" }, "*");
867
+ }
868
+ };
869
+ return /* @__PURE__ */ jsxs4(Box4, { sx: {
870
+ display: "flex",
871
+ flexDirection: "column",
872
+ height: "100vh",
873
+ bgcolor: "background.paper",
874
+ overflow: "hidden"
875
+ }, children: [
876
+ /* @__PURE__ */ jsxs4(
877
+ Stack4,
878
+ {
879
+ direction: "row",
880
+ alignItems: "center",
881
+ spacing: 1.5,
882
+ sx: {
883
+ px: 2,
884
+ py: 1.5,
885
+ borderBottom: "1px solid",
886
+ borderColor: "divider",
887
+ bgcolor: brand.primaryColor,
888
+ color: "#fff"
889
+ },
890
+ children: [
891
+ /* @__PURE__ */ jsx4(Avatar, { sx: { bgcolor: "#fff", color: brand.primaryColor, width: 32, height: 32 }, children: /* @__PURE__ */ jsx4(ChatBubbleOutline2, { fontSize: "small" }) }),
892
+ /* @__PURE__ */ jsxs4(Box4, { sx: { flex: 1 }, children: [
893
+ /* @__PURE__ */ jsx4(Typography4, { variant: "body2", fontWeight: 600, children: "Assistant" }),
894
+ /* @__PURE__ */ jsx4(Typography4, { variant: "caption", sx: { opacity: 0.85 }, children: brand.tagline })
895
+ ] }),
896
+ /* @__PURE__ */ jsx4(Button4, { size: "small", variant: "text", sx: { color: "#fff", minWidth: "auto" }, onClick: newChat, children: "New chat" }),
897
+ /* @__PURE__ */ jsx4(IconButton4, { size: "small", sx: { color: "#fff" }, onClick: closeWidget, children: /* @__PURE__ */ jsx4(Close2, { fontSize: "small" }) })
898
+ ]
899
+ }
900
+ ),
901
+ /* @__PURE__ */ jsx4(Box4, { ref: scrollRef, sx: { flex: 1, p: 2, overflowY: "auto" }, children: turns.length === 0 ? /* @__PURE__ */ jsxs4(Stack4, { alignItems: "center", sx: { pt: 6 }, spacing: 1, children: [
902
+ /* @__PURE__ */ jsx4(ChatBubbleOutline2, { sx: { fontSize: 40, color: "text.secondary" } }),
903
+ /* @__PURE__ */ jsxs4(Typography4, { variant: "body2", color: "text.secondary", textAlign: "center", children: [
904
+ "Ask anything \u2014 the assistant answers from ",
905
+ brand.name,
906
+ "'s knowledge base."
907
+ ] })
908
+ ] }) : /* @__PURE__ */ jsx4(Stack4, { spacing: 1.5, children: turns.map((t, i) => /* @__PURE__ */ jsx4(Bubble, { turn: t, brand, onRate: (r) => rate(i, r) }, i)) }) }),
909
+ /* @__PURE__ */ jsxs4(Box4, { sx: { p: 1.5, borderTop: "1px solid", borderColor: "divider" }, children: [
910
+ error && /* @__PURE__ */ jsx4(Alert4, { severity: "error", sx: { mb: 1 }, children: error }),
911
+ /* @__PURE__ */ jsxs4(Stack4, { direction: "row", spacing: 1, children: [
912
+ /* @__PURE__ */ jsx4(
913
+ TextField3,
914
+ {
915
+ fullWidth: true,
916
+ size: "small",
917
+ multiline: true,
918
+ maxRows: 4,
919
+ placeholder: "Type a message\u2026",
920
+ value: draft,
921
+ onChange: (e) => setDraft(e.target.value),
922
+ onKeyDown: (e) => {
923
+ if (e.key === "Enter" && !e.shiftKey) {
924
+ e.preventDefault();
925
+ send();
926
+ }
927
+ },
928
+ disabled: sending
929
+ }
930
+ ),
931
+ /* @__PURE__ */ jsx4(Button4, { variant: "contained", onClick: send, disabled: !draft.trim() || sending, children: /* @__PURE__ */ jsx4(Send2, { fontSize: "small" }) })
932
+ ] })
933
+ ] })
934
+ ] });
935
+ };
936
+ }
937
+ function Bubble({ turn, brand, onRate }) {
938
+ const isUser = turn.role === "user";
939
+ return /* @__PURE__ */ jsx4(Box4, { sx: { display: "flex", justifyContent: isUser ? "flex-end" : "flex-start" }, children: /* @__PURE__ */ jsxs4(Box4, { sx: { maxWidth: "85%" }, children: [
940
+ /* @__PURE__ */ jsx4(Box4, { sx: {
941
+ bgcolor: isUser ? brand.primaryColor : "grey.100",
942
+ color: isUser ? "#fff" : "text.primary",
943
+ px: 1.5,
944
+ py: 1,
945
+ borderRadius: 2,
946
+ whiteSpace: "pre-wrap",
947
+ wordBreak: "break-word",
948
+ opacity: turn.pending ? 0.6 : 1
949
+ }, children: /* @__PURE__ */ jsx4(Typography4, { variant: "body2", children: turn.content }) }),
950
+ !isUser && turn.citations?.length ? /* @__PURE__ */ jsx4(Stack4, { direction: "row", spacing: 0.5, sx: { mt: 0.5, flexWrap: "wrap", gap: 0.5 }, children: turn.citations.map((c, i) => /* @__PURE__ */ jsx4(Tooltip4, { title: c.snippet, children: /* @__PURE__ */ jsx4(Chip4, { label: `[${i + 1}] ${c.documentTitle}`, size: "small", variant: "outlined" }) }, i)) }) : null,
951
+ !isUser && !turn.pending && turn._id && /* @__PURE__ */ jsxs4(Stack4, { direction: "row", spacing: 0.5, sx: { mt: 0.25 }, children: [
952
+ /* @__PURE__ */ jsx4(IconButton4, { size: "small", onClick: () => onRate(1), color: turn.rating === 1 ? "primary" : "default", children: /* @__PURE__ */ jsx4(ThumbUp3, { sx: { fontSize: 14 } }) }),
953
+ /* @__PURE__ */ jsx4(IconButton4, { size: "small", onClick: () => onRate(-1), color: turn.rating === -1 ? "error" : "default", children: /* @__PURE__ */ jsx4(ThumbDown3, { sx: { fontSize: 14 } }) })
954
+ ] })
955
+ ] }) });
956
+ }
957
+
958
+ // src/pages/ChatbotSettings.tsx
959
+ import { useState as useState5, useEffect as useEffect3 } from "react";
960
+ import {
961
+ Card as Card4,
962
+ CardContent as CardContent4,
963
+ Typography as Typography5,
964
+ Stack as Stack5,
965
+ Box as Box5,
966
+ TextField as TextField4,
967
+ Button as Button5,
968
+ Chip as Chip5
969
+ } from "@mui/material";
970
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
971
+ function createChatbotSettings(cfg) {
972
+ return function ChatbotSettings(props) {
973
+ const { tenant, onSave } = props;
974
+ const [capInput, setCapInput] = useState5("");
975
+ const [saving, setSaving] = useState5(false);
976
+ const [saved, setSaved] = useState5(false);
977
+ useEffect3(() => {
978
+ if (tenant?.chatCostCapUsdMonthly != null) setCapInput(String(tenant.chatCostCapUsdMonthly));
979
+ else setCapInput("");
980
+ }, [tenant?._id, tenant?.chatCostCapUsdMonthly]);
981
+ if (!tenant) return null;
982
+ const handleSave = async () => {
983
+ setSaved(false);
984
+ setSaving(true);
985
+ try {
986
+ const trimmed = capInput.trim();
987
+ const cap = trimmed === "" ? null : parseFloat(trimmed);
988
+ if (trimmed && (Number.isNaN(cap) || cap < 0)) return;
989
+ await onSave({ chatCostCapUsdMonthly: cap });
990
+ setSaved(true);
991
+ } finally {
992
+ setSaving(false);
993
+ }
994
+ };
995
+ const scriptUrl = props.embedScriptUrl || (typeof window !== "undefined" ? `${window.location.origin}/chatbot.js` : "/chatbot.js");
996
+ const embedSnippet = `<script src="${scriptUrl}" data-tenant-slug="${tenant.slug}" defer></script>`;
997
+ return /* @__PURE__ */ jsx5(Card4, { sx: { mt: 2 }, children: /* @__PURE__ */ jsxs5(CardContent4, { children: [
998
+ /* @__PURE__ */ jsx5(Typography5, { variant: "h6", gutterBottom: true, children: "Chatbot" }),
999
+ /* @__PURE__ */ jsxs5(Stack5, { spacing: 2, sx: { mt: 1 }, children: [
1000
+ /* @__PURE__ */ jsxs5(Box5, { children: [
1001
+ /* @__PURE__ */ jsx5(Typography5, { variant: "overline", color: "text.secondary", children: "Monthly cost cap" }),
1002
+ /* @__PURE__ */ jsxs5(Stack5, { direction: "row", spacing: 1, alignItems: "center", children: [
1003
+ /* @__PURE__ */ jsx5(
1004
+ TextField4,
1005
+ {
1006
+ size: "small",
1007
+ type: "number",
1008
+ placeholder: "No cap",
1009
+ value: capInput,
1010
+ onChange: (e) => {
1011
+ setCapInput(e.target.value);
1012
+ setSaved(false);
1013
+ },
1014
+ InputProps: { startAdornment: /* @__PURE__ */ jsx5(Box5, { sx: { pr: 0.5 }, children: "$" }) },
1015
+ sx: { maxWidth: 220 }
1016
+ }
1017
+ ),
1018
+ /* @__PURE__ */ jsx5(Button5, { variant: "contained", onClick: handleSave, disabled: saving, children: "Save" }),
1019
+ saved && /* @__PURE__ */ jsx5(Chip5, { label: "Saved", color: "success", size: "small" })
1020
+ ] }),
1021
+ /* @__PURE__ */ jsx5(Typography5, { variant: "caption", color: "text.secondary", sx: { display: "block", mt: 0.5 }, children: "When this month's chat spend hits the cap, new messages are refused with a friendly error. Spend resets at the start of every calendar month." })
1022
+ ] }),
1023
+ /* @__PURE__ */ jsxs5(Box5, { children: [
1024
+ /* @__PURE__ */ jsx5(Typography5, { variant: "overline", color: "text.secondary", children: "Embed snippet" }),
1025
+ /* @__PURE__ */ jsxs5(Typography5, { variant: "caption", color: "text.secondary", sx: { display: "block", mb: 1 }, children: [
1026
+ "Drop this in any HTML page to add a floating ",
1027
+ cfg.brand.name,
1028
+ " chat launcher."
1029
+ ] }),
1030
+ /* @__PURE__ */ jsx5(Box5, { component: "pre", sx: {
1031
+ m: 0,
1032
+ p: 1.5,
1033
+ bgcolor: "grey.100",
1034
+ borderRadius: 1,
1035
+ fontSize: 12,
1036
+ fontFamily: "monospace",
1037
+ overflowX: "auto",
1038
+ whiteSpace: "pre-wrap",
1039
+ wordBreak: "break-all"
1040
+ }, children: embedSnippet })
1041
+ ] })
1042
+ ] })
1043
+ ] }) });
1044
+ };
1045
+ }
1046
+
1047
+ // src/index.tsx
1048
+ import { jsx as jsx6 } from "react/jsx-runtime";
1049
+ function createChatbotComponents(cfg) {
1050
+ const resolved = resolveConfig(cfg);
1051
+ const hooks = injectChatbotEndpoints(cfg.apiSlice, { pathPrefix: cfg.pathPrefix });
1052
+ const KnowledgePageBase = createKnowledgePage(resolved, hooks);
1053
+ const ChatPageBase = createChatPage(resolved, hooks);
1054
+ const ChatAnalyticsPageBase = createChatAnalyticsPage(resolved, hooks);
1055
+ const EmbedChatPageBase = createEmbedChatPage(resolved);
1056
+ const ChatbotSettings = createChatbotSettings(resolved);
1057
+ const KnowledgePage = () => {
1058
+ const tenantId = cfg.useTenantId();
1059
+ return /* @__PURE__ */ jsx6(KnowledgePageBase, { tenantId });
1060
+ };
1061
+ const ChatPage = () => {
1062
+ const tenantId = cfg.useTenantId();
1063
+ return /* @__PURE__ */ jsx6(ChatPageBase, { tenantId });
1064
+ };
1065
+ const ChatAnalyticsPage = () => {
1066
+ const tenantId = cfg.useTenantId();
1067
+ return /* @__PURE__ */ jsx6(ChatAnalyticsPageBase, { tenantId });
1068
+ };
1069
+ return {
1070
+ hooks,
1071
+ KnowledgePage,
1072
+ ChatPage,
1073
+ ChatAnalyticsPage,
1074
+ EmbedChatPage: EmbedChatPageBase,
1075
+ ChatbotSettings,
1076
+ // Base variants if the host wants to pass tenantId explicitly.
1077
+ KnowledgePageBase,
1078
+ ChatPageBase,
1079
+ ChatAnalyticsPageBase,
1080
+ EmbedChatPageBase
1081
+ };
1082
+ }
1083
+ export {
1084
+ createChatbotComponents,
1085
+ injectChatbotEndpoints
1086
+ };