@clivly/chat-widget 0.13.0 → 0.15.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 +41 -1
- package/dist/browser-mount.cjs +283 -23
- package/dist/browser-mount.d.cts +48 -1
- package/dist/browser-mount.d.mts +48 -1
- package/dist/browser-mount.mjs +283 -23
- package/dist/browser.cjs +283 -23
- package/dist/browser.d.cts +48 -1
- package/dist/browser.d.mts +48 -1
- package/dist/browser.mjs +283 -23
- package/dist/index.cjs +283 -23
- package/dist/index.d.cts +54 -3
- package/dist/index.d.mts +54 -3
- package/dist/index.mjs +283 -23
- package/dist/widget.global.js +66 -298
- package/package.json +1 -1
package/dist/browser.cjs
CHANGED
|
@@ -163,6 +163,13 @@ const WIDGET_STYLES = `
|
|
|
163
163
|
.clivly-chat-widget textarea {
|
|
164
164
|
font: inherit;
|
|
165
165
|
}
|
|
166
|
+
/* The standalone embed does not load the app's shared stylesheet. */
|
|
167
|
+
@media (width < 48rem), (any-pointer: coarse) {
|
|
168
|
+
.clivly-chat-widget input,
|
|
169
|
+
.clivly-chat-widget textarea {
|
|
170
|
+
font-size: max(16px, 1em);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
166
173
|
.clivly-chat-widget__launcher {
|
|
167
174
|
align-items: center;
|
|
168
175
|
background: var(--clivly-chat-accent);
|
|
@@ -364,6 +371,55 @@ const WIDGET_STYLES = `
|
|
|
364
371
|
font-size: 0.875rem;
|
|
365
372
|
padding: 0.75rem;
|
|
366
373
|
}
|
|
374
|
+
.clivly-chat-widget__notice {
|
|
375
|
+
background: var(--clivly-chat-operator);
|
|
376
|
+
border-radius: 0.8rem;
|
|
377
|
+
color: var(--clivly-chat-muted);
|
|
378
|
+
font-size: 0.8125rem;
|
|
379
|
+
margin: 0;
|
|
380
|
+
padding: 0.625rem 0.75rem;
|
|
381
|
+
}
|
|
382
|
+
/* Automated and AI messages are marked as such, never styled as a teammate. */
|
|
383
|
+
.clivly-chat-widget__message[data-automation] {
|
|
384
|
+
background: transparent;
|
|
385
|
+
border: 1px dashed var(--clivly-chat-border);
|
|
386
|
+
}
|
|
387
|
+
.clivly-chat-widget__badge {
|
|
388
|
+
border: 1px solid var(--clivly-chat-border);
|
|
389
|
+
border-radius: 999px;
|
|
390
|
+
font-size: 0.6875rem;
|
|
391
|
+
padding: 0 0.4rem;
|
|
392
|
+
}
|
|
393
|
+
.clivly-chat-widget__topics {
|
|
394
|
+
display: flex;
|
|
395
|
+
flex-wrap: wrap;
|
|
396
|
+
gap: 0.5rem;
|
|
397
|
+
margin: 0;
|
|
398
|
+
padding: 0;
|
|
399
|
+
list-style: none;
|
|
400
|
+
}
|
|
401
|
+
.clivly-chat-widget__topic {
|
|
402
|
+
background: var(--clivly-chat-panel);
|
|
403
|
+
border: 1px solid var(--clivly-chat-accent);
|
|
404
|
+
border-radius: 999px;
|
|
405
|
+
color: var(--clivly-chat-text);
|
|
406
|
+
cursor: pointer;
|
|
407
|
+
font-size: 0.875rem;
|
|
408
|
+
min-height: 2.25rem;
|
|
409
|
+
padding: 0.4rem 0.85rem;
|
|
410
|
+
}
|
|
411
|
+
.clivly-chat-widget__topic:hover,
|
|
412
|
+
.clivly-chat-widget__topic:focus-visible {
|
|
413
|
+
background: var(--clivly-chat-visitor);
|
|
414
|
+
}
|
|
415
|
+
.clivly-chat-widget__topic:focus-visible {
|
|
416
|
+
outline: 2px solid var(--clivly-chat-accent);
|
|
417
|
+
outline-offset: 2px;
|
|
418
|
+
}
|
|
419
|
+
.clivly-chat-widget__topic[disabled] {
|
|
420
|
+
cursor: not-allowed;
|
|
421
|
+
opacity: 0.55;
|
|
422
|
+
}
|
|
367
423
|
.clivly-chat-widget__empty {
|
|
368
424
|
color: var(--clivly-chat-muted);
|
|
369
425
|
font-size: 0.875rem;
|
|
@@ -460,8 +516,15 @@ function useResolvedTheme(theme) {
|
|
|
460
516
|
* protocol reads as a single unit, and so `ChatWidget` stays inside the
|
|
461
517
|
* cognitive-complexity budget.
|
|
462
518
|
*/
|
|
463
|
-
function applyServerEvent(event,
|
|
519
|
+
function applyServerEvent(event, actions) {
|
|
520
|
+
const { setError, setIsReady, setMessages } = actions;
|
|
464
521
|
switch (event.type) {
|
|
522
|
+
case "assistant.status":
|
|
523
|
+
actions.setAssistantThinking(event.status === "thinking");
|
|
524
|
+
return;
|
|
525
|
+
case "support.state":
|
|
526
|
+
actions.setSupportState(event.state);
|
|
527
|
+
return;
|
|
465
528
|
case "conversation.snapshot":
|
|
466
529
|
setMessages(sortMessages(event.messages.map((message) => createLocalMessage(message))));
|
|
467
530
|
return;
|
|
@@ -482,14 +545,41 @@ function applyServerEvent(event, { setError, setIsReady, setMessages }) {
|
|
|
482
545
|
default: return;
|
|
483
546
|
}
|
|
484
547
|
}
|
|
548
|
+
/**
|
|
549
|
+
* What a message from the support assistant is called. Automated
|
|
550
|
+
* acknowledgements, AI answers and people are never presented alike, so a
|
|
551
|
+
* visitor is never led to think a person has read their message.
|
|
552
|
+
*/
|
|
553
|
+
const AUTOMATION_BADGES = {
|
|
554
|
+
ai: "AI assistant",
|
|
555
|
+
ai_clarify: "AI assistant",
|
|
556
|
+
greeting: "Automated",
|
|
557
|
+
handoff: "Automated",
|
|
558
|
+
topic: "Automated"
|
|
559
|
+
};
|
|
560
|
+
const AUTHOR_LABEL_SUFFIX = / · (?:Automated|AI assistant)$/;
|
|
561
|
+
function automationBadge(message) {
|
|
562
|
+
if (message.senderType !== "system" || !message.automation) return null;
|
|
563
|
+
return AUTOMATION_BADGES[message.automation] ?? "Automated";
|
|
564
|
+
}
|
|
485
565
|
function MessageBubble({ message }) {
|
|
566
|
+
const badge = automationBadge(message);
|
|
567
|
+
const author = badge ? (message.author ?? "").replace(AUTHOR_LABEL_SUFFIX, "") : message.author;
|
|
486
568
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("article", {
|
|
487
569
|
className: "clivly-chat-widget__message",
|
|
570
|
+
"data-automation": badge ? message.automation : void 0,
|
|
488
571
|
"data-sender": message.senderType,
|
|
489
572
|
children: [
|
|
490
573
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
491
574
|
className: "clivly-chat-widget__message-meta",
|
|
492
|
-
children: [
|
|
575
|
+
children: [
|
|
576
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: author || "Clivly" }),
|
|
577
|
+
badge ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
578
|
+
className: "clivly-chat-widget__badge",
|
|
579
|
+
children: badge
|
|
580
|
+
}) : null,
|
|
581
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatTimestamp(message.createdAt) })
|
|
582
|
+
]
|
|
493
583
|
}),
|
|
494
584
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: message.body }),
|
|
495
585
|
message.delivery === "sent" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -516,6 +606,57 @@ function MessageList({ greeting, hasSession, messages }) {
|
|
|
516
606
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: messages.map((message) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MessageBubble, { message }, message.id)) });
|
|
517
607
|
}
|
|
518
608
|
/**
|
|
609
|
+
* How often an open, visible widget re-sends its read receipt. The room drops
|
|
610
|
+
* a receipt that arrives within 5 s of the last one, so a reply read in that
|
|
611
|
+
* window would otherwise stay "unread" and be emailed to the visitor.
|
|
612
|
+
*/
|
|
613
|
+
const READ_RECEIPT_REFRESH_MS = 15e3;
|
|
614
|
+
/** How long a visitor waits unanswered before the widget reassures them (D20a). */
|
|
615
|
+
const WAITING_NOTICE_AFTER_MS = 12e4;
|
|
616
|
+
/**
|
|
617
|
+
* When the visitor's latest message was sent, if nobody has answered it yet;
|
|
618
|
+
* null when the latest message is a reply or there is nothing waiting. Failed
|
|
619
|
+
* sends don't count: nobody was notified about them.
|
|
620
|
+
*/
|
|
621
|
+
function awaitingReplySince(messages) {
|
|
622
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
623
|
+
const message = messages[index];
|
|
624
|
+
if (!message || message.delivery === "failed") continue;
|
|
625
|
+
if (message.senderType === "system" || message.automation === "topic_choice") continue;
|
|
626
|
+
if (message.senderType !== "visitor") return null;
|
|
627
|
+
return new Date(message.createdAt).getTime();
|
|
628
|
+
}
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
/** True once the visitor has waited `WAITING_NOTICE_AFTER_MS` unanswered. */
|
|
632
|
+
function useWaitingNotice(messages) {
|
|
633
|
+
const [due, setDue] = (0, react.useState)(false);
|
|
634
|
+
(0, react.useEffect)(() => {
|
|
635
|
+
const since = awaitingReplySince(messages);
|
|
636
|
+
if (since === null) {
|
|
637
|
+
setDue(false);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const remaining = since + WAITING_NOTICE_AFTER_MS - Date.now();
|
|
641
|
+
if (remaining <= 0) {
|
|
642
|
+
setDue(true);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
setDue(false);
|
|
646
|
+
const timer = setTimeout(() => {
|
|
647
|
+
setDue(true);
|
|
648
|
+
}, remaining);
|
|
649
|
+
return () => {
|
|
650
|
+
clearTimeout(timer);
|
|
651
|
+
};
|
|
652
|
+
}, [messages]);
|
|
653
|
+
return due;
|
|
654
|
+
}
|
|
655
|
+
function waitingNoticeText(email) {
|
|
656
|
+
const address = email?.trim();
|
|
657
|
+
return address ? `We've notified the team — we'll reply here or by email at ${address}.` : "We've notified the team — we'll reply here as soon as we can.";
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
519
660
|
* Whether a seeded visitor is complete enough to skip the form.
|
|
520
661
|
*
|
|
521
662
|
* Both fields have to be present and non-blank: the server requires each, so
|
|
@@ -535,6 +676,70 @@ function composerStatusText(hasTransport, isReady) {
|
|
|
535
676
|
if (!hasTransport) return "Chat is unavailable";
|
|
536
677
|
return isReady ? "Conversation ready" : "Connecting…";
|
|
537
678
|
}
|
|
679
|
+
/**
|
|
680
|
+
* Guided options under the greeting. Native buttons in a labelled group, so
|
|
681
|
+
* they are reachable with Tab and operable with Enter/Space; free text stays
|
|
682
|
+
* available beside them the whole time.
|
|
683
|
+
*/
|
|
684
|
+
function SupportTopics({ disabled, onSelect, topics }) {
|
|
685
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
686
|
+
"aria-label": "Suggested topics",
|
|
687
|
+
className: "clivly-chat-widget__topics",
|
|
688
|
+
children: topics.map((topic) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
689
|
+
className: "clivly-chat-widget__topic",
|
|
690
|
+
disabled,
|
|
691
|
+
onClick: () => {
|
|
692
|
+
onSelect(topic);
|
|
693
|
+
},
|
|
694
|
+
type: "button",
|
|
695
|
+
children: topic.label
|
|
696
|
+
}) }, topic.id))
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
/** Topics are offered only while automation is answering this conversation. */
|
|
700
|
+
function visibleTopics(session, state) {
|
|
701
|
+
return state === "automated" ? session?.support?.topics ?? [] : [];
|
|
702
|
+
}
|
|
703
|
+
function composerPlaceholder(hasTopics) {
|
|
704
|
+
return hasTopics ? "Or type your question" : "Write your message";
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Hand one visitor message to the transport: a topic click as `topic.select`
|
|
708
|
+
* when the transport supports it (the server resolves the answer), otherwise
|
|
709
|
+
* as the label, typed. Throws what the transport throws.
|
|
710
|
+
*/
|
|
711
|
+
function dispatchVisitorMessage(transport, clientMessageId, body, topic) {
|
|
712
|
+
if (topic && transport.selectTopic) {
|
|
713
|
+
transport.selectTopic({
|
|
714
|
+
id: clientMessageId,
|
|
715
|
+
topicId: topic.id,
|
|
716
|
+
type: "topic.select"
|
|
717
|
+
});
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
transport.sendMessage({
|
|
721
|
+
body,
|
|
722
|
+
id: clientMessageId,
|
|
723
|
+
type: "message.create"
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* One honest status line: the AI is working on an answer, or — once the
|
|
728
|
+
* visitor has waited — that the team has been told. Never both.
|
|
729
|
+
*/
|
|
730
|
+
function StatusNotice({ assistantThinking, email, showWaitingNotice }) {
|
|
731
|
+
if (assistantThinking) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
732
|
+
className: "clivly-chat-widget__notice",
|
|
733
|
+
role: "status",
|
|
734
|
+
children: "The AI assistant is looking for an answer…"
|
|
735
|
+
});
|
|
736
|
+
if (!showWaitingNotice) return null;
|
|
737
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
738
|
+
className: "clivly-chat-widget__notice",
|
|
739
|
+
role: "status",
|
|
740
|
+
children: waitingNoticeText(email)
|
|
741
|
+
});
|
|
742
|
+
}
|
|
538
743
|
function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
|
|
539
744
|
const [isOpen, setIsOpen] = (0, react.useState)(initiallyOpen);
|
|
540
745
|
const [error, setError] = (0, react.useState)(null);
|
|
@@ -543,6 +748,8 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
543
748
|
const [messageDraft, setMessageDraft] = (0, react.useState)("");
|
|
544
749
|
const [messages, setMessages] = (0, react.useState)([]);
|
|
545
750
|
const [session, setSession] = (0, react.useState)(null);
|
|
751
|
+
const [supportState, setSupportState] = (0, react.useState)(null);
|
|
752
|
+
const [assistantThinking, setAssistantThinking] = (0, react.useState)(false);
|
|
546
753
|
const [visitor, setVisitor] = (0, react.useState)({
|
|
547
754
|
email: defaultVisitor?.email ?? "",
|
|
548
755
|
name: defaultVisitor?.name ?? ""
|
|
@@ -553,6 +760,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
553
760
|
if (createTransport) return;
|
|
554
761
|
console.warn("[clivly] <ChatWidget> was given no createTransport, so messages cannot be sent. Pass createTransport={createClivlyTransport()} — see https://www.npmjs.com/package/@clivly/chat-widget#five-minute-quick-start");
|
|
555
762
|
}, []);
|
|
763
|
+
const showWaitingNotice = useWaitingNotice(messages);
|
|
556
764
|
const themeStyle = useThemeStyle(session, style);
|
|
557
765
|
const resolvedTheme = useResolvedTheme(theme);
|
|
558
766
|
(0, react.useEffect)(() => () => {
|
|
@@ -572,15 +780,18 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
572
780
|
handlers: {
|
|
573
781
|
onClose: () => {
|
|
574
782
|
setIsReady(false);
|
|
783
|
+
setAssistantThinking(false);
|
|
575
784
|
},
|
|
576
785
|
onError: (transportError) => {
|
|
577
786
|
setError(transportError.message);
|
|
578
787
|
},
|
|
579
788
|
onEvent: (event) => {
|
|
580
789
|
applyServerEvent(event, {
|
|
790
|
+
setAssistantThinking,
|
|
581
791
|
setError,
|
|
582
792
|
setIsReady,
|
|
583
|
-
setMessages
|
|
793
|
+
setMessages,
|
|
794
|
+
setSupportState
|
|
584
795
|
});
|
|
585
796
|
},
|
|
586
797
|
onOpen: () => {
|
|
@@ -595,6 +806,23 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
595
806
|
if (transportRef.current === connection) transportRef.current = null;
|
|
596
807
|
};
|
|
597
808
|
}, [createTransport, session]);
|
|
809
|
+
(0, react.useEffect)(() => {
|
|
810
|
+
if (!(isOpen && isReady)) return;
|
|
811
|
+
const markReadIfVisible = () => {
|
|
812
|
+
if (document.visibilityState === "visible") transportRef.current?.markRead?.();
|
|
813
|
+
};
|
|
814
|
+
markReadIfVisible();
|
|
815
|
+
const interval = window.setInterval(markReadIfVisible, READ_RECEIPT_REFRESH_MS);
|
|
816
|
+
document.addEventListener("visibilitychange", markReadIfVisible);
|
|
817
|
+
return () => {
|
|
818
|
+
window.clearInterval(interval);
|
|
819
|
+
document.removeEventListener("visibilitychange", markReadIfVisible);
|
|
820
|
+
};
|
|
821
|
+
}, [
|
|
822
|
+
isOpen,
|
|
823
|
+
isReady,
|
|
824
|
+
messages
|
|
825
|
+
]);
|
|
598
826
|
const startSession = (0, react.useCallback)(async (identity) => {
|
|
599
827
|
setError(null);
|
|
600
828
|
setIsConnecting(true);
|
|
@@ -605,6 +833,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
605
833
|
widgetId
|
|
606
834
|
});
|
|
607
835
|
storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
|
|
836
|
+
setSupportState(nextSession.support?.state ?? null);
|
|
608
837
|
setSession(nextSession);
|
|
609
838
|
setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
|
|
610
839
|
} catch (sessionError) {
|
|
@@ -637,41 +866,48 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
637
866
|
session,
|
|
638
867
|
startSession
|
|
639
868
|
]);
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
869
|
+
/**
|
|
870
|
+
* Show the visitor's message at once and hand it to the transport. A topic
|
|
871
|
+
* click goes as `topic.select` when the transport supports it, so the
|
|
872
|
+
* server resolves the answer; otherwise as the label, typed.
|
|
873
|
+
*/
|
|
874
|
+
const sendVisitorMessage = (body, topic) => {
|
|
875
|
+
const transport = transportRef.current;
|
|
876
|
+
if (!(session && transport)) {
|
|
643
877
|
setError("Chat is not connected yet.");
|
|
644
878
|
return;
|
|
645
879
|
}
|
|
646
|
-
const body = messageDraft.trim();
|
|
647
|
-
if (!body) return;
|
|
648
880
|
const clientMessageId = generateId();
|
|
649
881
|
const optimisticMessage = {
|
|
650
882
|
author: sessionDisplayName(session),
|
|
883
|
+
automation: topic && transport.selectTopic ? "topic_choice" : null,
|
|
651
884
|
body,
|
|
652
885
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
653
886
|
id: clientMessageId,
|
|
654
887
|
senderType: "visitor"
|
|
655
888
|
};
|
|
656
889
|
setMessages((currentMessages) => sortMessages([...currentMessages, createLocalMessage(optimisticMessage, "pending")]));
|
|
657
|
-
setMessageDraft("");
|
|
658
890
|
setError(null);
|
|
659
891
|
try {
|
|
660
|
-
|
|
661
|
-
body,
|
|
662
|
-
id: clientMessageId,
|
|
663
|
-
type: "message.create"
|
|
664
|
-
});
|
|
892
|
+
dispatchVisitorMessage(transport, clientMessageId, body, topic);
|
|
665
893
|
} catch (transportError) {
|
|
666
894
|
const resolved = transportError instanceof Error ? transportError : /* @__PURE__ */ new Error("Failed to send your message.");
|
|
667
895
|
setMessages((currentMessages) => markMessageFailed(currentMessages, clientMessageId));
|
|
668
896
|
setError(resolved.message);
|
|
669
897
|
}
|
|
670
898
|
};
|
|
899
|
+
const submitMessage = (event) => {
|
|
900
|
+
event?.preventDefault();
|
|
901
|
+
const body = messageDraft.trim();
|
|
902
|
+
if (!body) return;
|
|
903
|
+
setMessageDraft("");
|
|
904
|
+
sendVisitorMessage(body);
|
|
905
|
+
};
|
|
671
906
|
const canSend = Boolean(createTransport) && isReady;
|
|
672
907
|
const greeting = session?.widget.config.greeting ?? "Tell us a little about yourself and we’ll pick up the conversation from there.";
|
|
673
908
|
const panelTitle = session?.widget.name ?? "Start a conversation";
|
|
674
909
|
const sessionSummary = sessionSummaryText(session, greeting);
|
|
910
|
+
const topics = visibleTopics(session, supportState);
|
|
675
911
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
676
912
|
className: `clivly-chat-widget ${className ?? ""}`.trim(),
|
|
677
913
|
"data-clivly-theme": resolvedTheme,
|
|
@@ -709,16 +945,33 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
709
945
|
})]
|
|
710
946
|
}),
|
|
711
947
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
948
|
+
"aria-label": "Conversation",
|
|
712
949
|
className: "clivly-chat-widget__body",
|
|
713
950
|
ref: listRef,
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
951
|
+
role: "log",
|
|
952
|
+
children: [
|
|
953
|
+
error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
954
|
+
className: "clivly-chat-widget__error",
|
|
955
|
+
children: error
|
|
956
|
+
}) : null,
|
|
957
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MessageList, {
|
|
958
|
+
greeting,
|
|
959
|
+
hasSession: Boolean(session),
|
|
960
|
+
messages
|
|
961
|
+
}),
|
|
962
|
+
topics.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SupportTopics, {
|
|
963
|
+
disabled: !canSend,
|
|
964
|
+
onSelect: (topic) => {
|
|
965
|
+
sendVisitorMessage(topic.label, topic);
|
|
966
|
+
},
|
|
967
|
+
topics
|
|
968
|
+
}) : null,
|
|
969
|
+
session ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatusNotice, {
|
|
970
|
+
assistantThinking,
|
|
971
|
+
email: session.contact?.email,
|
|
972
|
+
showWaitingNotice
|
|
973
|
+
}) : null
|
|
974
|
+
]
|
|
722
975
|
}),
|
|
723
976
|
session ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
|
|
724
977
|
className: "clivly-chat-widget__composer",
|
|
@@ -738,7 +991,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
738
991
|
event.preventDefault();
|
|
739
992
|
if (messageDraft.trim() && canSend) submitMessage();
|
|
740
993
|
},
|
|
741
|
-
placeholder:
|
|
994
|
+
placeholder: composerPlaceholder(topics.length > 0),
|
|
742
995
|
value: messageDraft
|
|
743
996
|
})]
|
|
744
997
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -861,6 +1114,13 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
|
|
|
861
1114
|
close: () => {
|
|
862
1115
|
socket.close();
|
|
863
1116
|
},
|
|
1117
|
+
markRead: () => {
|
|
1118
|
+
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "conversation.read" }));
|
|
1119
|
+
},
|
|
1120
|
+
selectTopic: (event) => {
|
|
1121
|
+
if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
|
|
1122
|
+
socket.send(JSON.stringify(event));
|
|
1123
|
+
},
|
|
864
1124
|
sendMessage: (message) => {
|
|
865
1125
|
if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
|
|
866
1126
|
socket.send(JSON.stringify(message));
|
package/dist/browser.d.cts
CHANGED
|
@@ -34,11 +34,30 @@ interface WidgetSummary {
|
|
|
34
34
|
}
|
|
35
35
|
interface WidgetMessage {
|
|
36
36
|
author: string | null;
|
|
37
|
+
/**
|
|
38
|
+
* Set on messages the support assistant wrote (`greeting`, `topic`,
|
|
39
|
+
* `handoff`, `ai`, `ai_clarify`) and on a visitor's topic click
|
|
40
|
+
* (`topic_choice`). Absent or null on everything a person typed, and on
|
|
41
|
+
* every message from a server that predates the assistant.
|
|
42
|
+
*/
|
|
43
|
+
automation?: string | null;
|
|
37
44
|
body: string;
|
|
38
45
|
createdAt: string;
|
|
39
46
|
id: string;
|
|
40
47
|
senderType: WidgetSenderType;
|
|
41
48
|
}
|
|
49
|
+
type WidgetSupportState = "automated" | "handoff" | "human";
|
|
50
|
+
interface WidgetSupportTopic {
|
|
51
|
+
action: "answer" | "ask" | "link" | "human";
|
|
52
|
+
id: string;
|
|
53
|
+
label: string;
|
|
54
|
+
}
|
|
55
|
+
/** Guided support for one conversation. Answers stay on the server. */
|
|
56
|
+
interface WidgetSupport {
|
|
57
|
+
mode: "human" | "guided" | "ai";
|
|
58
|
+
state: WidgetSupportState | null;
|
|
59
|
+
topics: WidgetSupportTopic[];
|
|
60
|
+
}
|
|
42
61
|
interface WidgetSessionPayload {
|
|
43
62
|
contact: WidgetContact | null;
|
|
44
63
|
conversation: WidgetConversation;
|
|
@@ -46,6 +65,8 @@ interface WidgetSessionPayload {
|
|
|
46
65
|
messages: WidgetMessage[];
|
|
47
66
|
/** Absolute socket URL returned by Clivly's bootstrap. */
|
|
48
67
|
socketUrl?: string;
|
|
68
|
+
/** Absent or null when the widget has no guided support configured. */
|
|
69
|
+
support?: WidgetSupport | null;
|
|
49
70
|
token: string;
|
|
50
71
|
visitorToken: string;
|
|
51
72
|
widget: WidgetSummary;
|
|
@@ -65,6 +86,12 @@ interface WidgetClientCreateMessageEvent {
|
|
|
65
86
|
id: string;
|
|
66
87
|
type: "message.create";
|
|
67
88
|
}
|
|
89
|
+
/** The visitor picked a guided topic (see `ChatTransportConnection.selectTopic`). */
|
|
90
|
+
interface WidgetClientTopicEvent {
|
|
91
|
+
id: string;
|
|
92
|
+
topicId: string;
|
|
93
|
+
type: "topic.select";
|
|
94
|
+
}
|
|
68
95
|
interface WidgetSessionReadyEvent {
|
|
69
96
|
conversationId: string;
|
|
70
97
|
type: "session.ready";
|
|
@@ -83,11 +110,20 @@ interface WidgetMessageAckEvent {
|
|
|
83
110
|
message: WidgetMessage;
|
|
84
111
|
type: "message.ack";
|
|
85
112
|
}
|
|
113
|
+
interface WidgetSupportStateEvent {
|
|
114
|
+
state: WidgetSupportState;
|
|
115
|
+
type: "support.state";
|
|
116
|
+
}
|
|
117
|
+
/** The assistant is preparing an answer (`thinking`) or has finished (`idle`). */
|
|
118
|
+
interface WidgetAssistantStatusEvent {
|
|
119
|
+
status: "thinking" | "idle";
|
|
120
|
+
type: "assistant.status";
|
|
121
|
+
}
|
|
86
122
|
interface WidgetErrorEvent {
|
|
87
123
|
message: string;
|
|
88
124
|
type: "error";
|
|
89
125
|
}
|
|
90
|
-
type WidgetServerEvent = WidgetConversationSnapshotEvent | WidgetErrorEvent | WidgetMessageAckEvent | WidgetMessageCreatedEvent | WidgetSessionReadyEvent;
|
|
126
|
+
type WidgetServerEvent = WidgetAssistantStatusEvent | WidgetConversationSnapshotEvent | WidgetErrorEvent | WidgetMessageAckEvent | WidgetMessageCreatedEvent | WidgetSessionReadyEvent | WidgetSupportStateEvent;
|
|
91
127
|
//#endregion
|
|
92
128
|
//#region src/transport.d.ts
|
|
93
129
|
interface ChatTransportHandlers {
|
|
@@ -98,6 +134,17 @@ interface ChatTransportHandlers {
|
|
|
98
134
|
}
|
|
99
135
|
interface ChatTransportConnection {
|
|
100
136
|
close: () => void;
|
|
137
|
+
/**
|
|
138
|
+
* Tell the server the visitor has seen the thread, so a teammate's reply
|
|
139
|
+
* they've read isn't also emailed to them. Optional: a custom transport
|
|
140
|
+
* without it still works, its visitors are just emailed replies they saw.
|
|
141
|
+
*/
|
|
142
|
+
markRead?: () => void;
|
|
143
|
+
/**
|
|
144
|
+
* Send a guided-topic click. Optional: without it the widget sends the
|
|
145
|
+
* topic's label as an ordinary message, so a custom transport still works.
|
|
146
|
+
*/
|
|
147
|
+
selectTopic?: (event: WidgetClientTopicEvent) => void;
|
|
101
148
|
sendMessage: (event: WidgetClientCreateMessageEvent) => void;
|
|
102
149
|
}
|
|
103
150
|
interface CreateChatTransportOptions {
|
package/dist/browser.d.mts
CHANGED
|
@@ -34,11 +34,30 @@ interface WidgetSummary {
|
|
|
34
34
|
}
|
|
35
35
|
interface WidgetMessage {
|
|
36
36
|
author: string | null;
|
|
37
|
+
/**
|
|
38
|
+
* Set on messages the support assistant wrote (`greeting`, `topic`,
|
|
39
|
+
* `handoff`, `ai`, `ai_clarify`) and on a visitor's topic click
|
|
40
|
+
* (`topic_choice`). Absent or null on everything a person typed, and on
|
|
41
|
+
* every message from a server that predates the assistant.
|
|
42
|
+
*/
|
|
43
|
+
automation?: string | null;
|
|
37
44
|
body: string;
|
|
38
45
|
createdAt: string;
|
|
39
46
|
id: string;
|
|
40
47
|
senderType: WidgetSenderType;
|
|
41
48
|
}
|
|
49
|
+
type WidgetSupportState = "automated" | "handoff" | "human";
|
|
50
|
+
interface WidgetSupportTopic {
|
|
51
|
+
action: "answer" | "ask" | "link" | "human";
|
|
52
|
+
id: string;
|
|
53
|
+
label: string;
|
|
54
|
+
}
|
|
55
|
+
/** Guided support for one conversation. Answers stay on the server. */
|
|
56
|
+
interface WidgetSupport {
|
|
57
|
+
mode: "human" | "guided" | "ai";
|
|
58
|
+
state: WidgetSupportState | null;
|
|
59
|
+
topics: WidgetSupportTopic[];
|
|
60
|
+
}
|
|
42
61
|
interface WidgetSessionPayload {
|
|
43
62
|
contact: WidgetContact | null;
|
|
44
63
|
conversation: WidgetConversation;
|
|
@@ -46,6 +65,8 @@ interface WidgetSessionPayload {
|
|
|
46
65
|
messages: WidgetMessage[];
|
|
47
66
|
/** Absolute socket URL returned by Clivly's bootstrap. */
|
|
48
67
|
socketUrl?: string;
|
|
68
|
+
/** Absent or null when the widget has no guided support configured. */
|
|
69
|
+
support?: WidgetSupport | null;
|
|
49
70
|
token: string;
|
|
50
71
|
visitorToken: string;
|
|
51
72
|
widget: WidgetSummary;
|
|
@@ -65,6 +86,12 @@ interface WidgetClientCreateMessageEvent {
|
|
|
65
86
|
id: string;
|
|
66
87
|
type: "message.create";
|
|
67
88
|
}
|
|
89
|
+
/** The visitor picked a guided topic (see `ChatTransportConnection.selectTopic`). */
|
|
90
|
+
interface WidgetClientTopicEvent {
|
|
91
|
+
id: string;
|
|
92
|
+
topicId: string;
|
|
93
|
+
type: "topic.select";
|
|
94
|
+
}
|
|
68
95
|
interface WidgetSessionReadyEvent {
|
|
69
96
|
conversationId: string;
|
|
70
97
|
type: "session.ready";
|
|
@@ -83,11 +110,20 @@ interface WidgetMessageAckEvent {
|
|
|
83
110
|
message: WidgetMessage;
|
|
84
111
|
type: "message.ack";
|
|
85
112
|
}
|
|
113
|
+
interface WidgetSupportStateEvent {
|
|
114
|
+
state: WidgetSupportState;
|
|
115
|
+
type: "support.state";
|
|
116
|
+
}
|
|
117
|
+
/** The assistant is preparing an answer (`thinking`) or has finished (`idle`). */
|
|
118
|
+
interface WidgetAssistantStatusEvent {
|
|
119
|
+
status: "thinking" | "idle";
|
|
120
|
+
type: "assistant.status";
|
|
121
|
+
}
|
|
86
122
|
interface WidgetErrorEvent {
|
|
87
123
|
message: string;
|
|
88
124
|
type: "error";
|
|
89
125
|
}
|
|
90
|
-
type WidgetServerEvent = WidgetConversationSnapshotEvent | WidgetErrorEvent | WidgetMessageAckEvent | WidgetMessageCreatedEvent | WidgetSessionReadyEvent;
|
|
126
|
+
type WidgetServerEvent = WidgetAssistantStatusEvent | WidgetConversationSnapshotEvent | WidgetErrorEvent | WidgetMessageAckEvent | WidgetMessageCreatedEvent | WidgetSessionReadyEvent | WidgetSupportStateEvent;
|
|
91
127
|
//#endregion
|
|
92
128
|
//#region src/transport.d.ts
|
|
93
129
|
interface ChatTransportHandlers {
|
|
@@ -98,6 +134,17 @@ interface ChatTransportHandlers {
|
|
|
98
134
|
}
|
|
99
135
|
interface ChatTransportConnection {
|
|
100
136
|
close: () => void;
|
|
137
|
+
/**
|
|
138
|
+
* Tell the server the visitor has seen the thread, so a teammate's reply
|
|
139
|
+
* they've read isn't also emailed to them. Optional: a custom transport
|
|
140
|
+
* without it still works, its visitors are just emailed replies they saw.
|
|
141
|
+
*/
|
|
142
|
+
markRead?: () => void;
|
|
143
|
+
/**
|
|
144
|
+
* Send a guided-topic click. Optional: without it the widget sends the
|
|
145
|
+
* topic's label as an ordinary message, so a custom transport still works.
|
|
146
|
+
*/
|
|
147
|
+
selectTopic?: (event: WidgetClientTopicEvent) => void;
|
|
101
148
|
sendMessage: (event: WidgetClientCreateMessageEvent) => void;
|
|
102
149
|
}
|
|
103
150
|
interface CreateChatTransportOptions {
|