ss-support-widget 1.0.9 → 1.0.10
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/x-bot-widget.js +168 -0
- package/index.html +15 -17
- package/package.json +31 -29
- package/readme.md +7 -7
- package/src/api/service.ts +80 -0
- package/src/{sse.ts → api/sse.ts} +58 -55
- package/src/authentification/token.ts +57 -0
- package/src/components/ChatWidget.tsx +488 -0
- package/src/components/chatbot-element.tsx +53 -0
- package/src/constants.ts +3 -0
- package/src/containers/App.tsx +187 -0
- package/src/{hooks.ts → hooks/hooks.ts} +31 -31
- package/src/hooks/useSignalR.ts +222 -0
- package/src/main.ts +38 -0
- package/src/services/chatConfiguration.ts +11 -0
- package/src/storage/session-storage.ts +53 -0
- package/src/types.ts +29 -0
- package/src/utils/deviceInfo.ts +13 -0
- package/src/utils/script-utils.ts +11 -0
- package/ss-support-widget-1.0.4.tgz +0 -0
- package/tsconfig.json +13 -13
- package/vite.config.ts +22 -22
- package/dist/chat-bot-widget.js +0 -162
- package/src/App.tsx +0 -106
- package/src/ChatWidget.tsx +0 -428
- package/src/MsgDelta.tsx +0 -6
- package/src/element.tsx +0 -100
- package/src/service.ts +0 -68
- package/src/session-storage.ts +0 -17
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// App.tsx
|
|
2
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { streamChat } from "../api/sse";
|
|
4
|
+
import ReactMarkdown from "react-markdown";
|
|
5
|
+
import { getConversationId, writeConversation } from "../storage/session-storage";
|
|
6
|
+
import ChatWidget from "../components/ChatWidget";
|
|
7
|
+
import { useSignalR } from "../hooks/useSignalR";
|
|
8
|
+
import { UUID } from "bson";
|
|
9
|
+
import { chatBasePath } from "../constants";
|
|
10
|
+
import { Config, ConversationStatus, Msg, MsgDelta } from "../types";
|
|
11
|
+
import { getConversationStatus, getHistoryMessages } from "../api/service";
|
|
12
|
+
|
|
13
|
+
export default function App({ config }: { config: Config }) {
|
|
14
|
+
const { connected, error, start, stop, on, off, invoke } = useSignalR({
|
|
15
|
+
// accessToken: config.sessionToken,
|
|
16
|
+
url: config.apiBaseUrl + `/ws?sessionId=${getConversationId()}`
|
|
17
|
+
+ `&clientId=${config.clientId}`,
|
|
18
|
+
autoStart: false
|
|
19
|
+
});
|
|
20
|
+
const [open, setOpen] = useState(false);
|
|
21
|
+
const [input, setInput] = useState("");
|
|
22
|
+
const [conversationId, setConversationId] = useState<string | null>(getConversationId());
|
|
23
|
+
const [conversationStatus, setConversationStatus] = useState<ConversationStatus | null>()
|
|
24
|
+
const [msgs, setMsgs] = useState<Msg[]>([]);
|
|
25
|
+
const [sending, setSending] = useState(false);
|
|
26
|
+
const [hideChat, setHideChat] = useState(false);
|
|
27
|
+
|
|
28
|
+
const anchorSx = useMemo(
|
|
29
|
+
() => ({
|
|
30
|
+
position: "fixed",
|
|
31
|
+
right: 16,
|
|
32
|
+
bottom: 16,
|
|
33
|
+
zIndex: 2147483647,
|
|
34
|
+
fontFamily: "system-ui",
|
|
35
|
+
}),
|
|
36
|
+
[]
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const path = window.location.pathname;
|
|
41
|
+
if (config.hideChatForUrls?.some(u => path.startsWith(u))) {
|
|
42
|
+
setHideChat(true);
|
|
43
|
+
} else {
|
|
44
|
+
setHideChat(false);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (open && msgs.length == 0) {
|
|
50
|
+
getHistoryMessages(config, getConversationId() || config.conversationId || "",
|
|
51
|
+
(delta) => {
|
|
52
|
+
const msgs = delta.map((m: MsgDelta) => ({ id: m.id, role: m.role, text: m.text, sentAt: formatDateLabel(m.sentAt), seen: m.seen }));
|
|
53
|
+
setMsgs((prev) => [...msgs]);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (open && conversationId) {
|
|
58
|
+
const fetchData = async () => {
|
|
59
|
+
const result = await getConversationStatus(config, conversationId);
|
|
60
|
+
setConversationStatus(result);
|
|
61
|
+
}
|
|
62
|
+
fetchData();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
}, [open]);
|
|
67
|
+
|
|
68
|
+
function upsertAssistantDelta(delta: MsgDelta) {
|
|
69
|
+
setMsgs((prev) => {
|
|
70
|
+
const last = prev[prev.length - 1];
|
|
71
|
+
if (!last || last.role !== "Assistant" || last.id !== delta.id) {
|
|
72
|
+
return [...prev, { id: delta.id, role: delta.role, text: delta.text, sentAt: formatDateLabel(delta.sentAt), seen: delta.seen }];
|
|
73
|
+
}
|
|
74
|
+
const updated = prev.slice();
|
|
75
|
+
updated[updated.length - 1] = { ...last, text: last.text + delta.text };
|
|
76
|
+
return updated;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function send() {
|
|
81
|
+
const text = input.trim();
|
|
82
|
+
if (!text || sending) return;
|
|
83
|
+
|
|
84
|
+
setSending(true);
|
|
85
|
+
setInput("");
|
|
86
|
+
const newMsg = { id: new UUID().toString(), role: "User", text, seen: false, sentAt: formatDateLabel(new Date().toISOString()) }
|
|
87
|
+
setMsgs((m) => [...m, newMsg]);
|
|
88
|
+
|
|
89
|
+
///add an if here to check if should talk to llm or human agent
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await streamChat({
|
|
93
|
+
url: config.apiBaseUrl + chatBasePath + "/" + config.clientId + '/send-message/',
|
|
94
|
+
// url: config.apiBaseUrl + "api/message-operator",
|
|
95
|
+
token: config.sessionToken,
|
|
96
|
+
clientId: config.clientId,
|
|
97
|
+
conversationId: conversationId,
|
|
98
|
+
messageId: newMsg.id,
|
|
99
|
+
body: text,
|
|
100
|
+
onDelta: (delta) => {
|
|
101
|
+
upsertAssistantDelta(delta)
|
|
102
|
+
setConversationId(delta.conversationId);
|
|
103
|
+
writeConversation(delta.conversationId);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
} catch {
|
|
107
|
+
setMsgs((m) => [...m, { id: crypto.randomUUID(), role: "Assistant", text: "Eroare la trimitere.", seen: false }]);
|
|
108
|
+
} finally {
|
|
109
|
+
setSending(false);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
const messageRecieveHandler = useCallback((conversationId: string, message: any) => {
|
|
115
|
+
upsertAssistantDelta({ id: message.messageId, role: "Assistant", text: message.text, conversationId: conversationId, sentAt: new Date().toISOString(), seen: message.seen });
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
const updateSeenAtHandler = useCallback((messageId: string) => {
|
|
119
|
+
setMsgs((prev) => prev.map(m => m.id === messageId ? { ...m, seen: true } : m));
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
const conversationModeChangedHandler = useCallback((message: ConversationStatus) => {
|
|
123
|
+
setConversationStatus(message);
|
|
124
|
+
console.log(message)
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (conversationId == ""
|
|
130
|
+
|| conversationId == null
|
|
131
|
+
) return
|
|
132
|
+
|
|
133
|
+
start();
|
|
134
|
+
}, [conversationId]);
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
on("ReceivedOperatorMessage", messageRecieveHandler);
|
|
138
|
+
on("ReceivedSeenAtByOperator", updateSeenAtHandler);
|
|
139
|
+
on("RecievedConversationModeChanged", conversationModeChangedHandler);
|
|
140
|
+
|
|
141
|
+
return () => {
|
|
142
|
+
off("ReceivedOperatorMessage", messageRecieveHandler);
|
|
143
|
+
off("ReceivedSeenAtByOperator", updateSeenAtHandler)
|
|
144
|
+
off("RecievedConversationModeChanged", conversationModeChangedHandler);
|
|
145
|
+
};
|
|
146
|
+
}, [on, connected, off]);
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
const getConversationMode = useCallback((conversationStatus?: ConversationStatus | null): ConversationMode => {
|
|
150
|
+
if (!conversationId)
|
|
151
|
+
return "New"
|
|
152
|
+
|
|
153
|
+
if (!connected) {
|
|
154
|
+
return "Disconnected"
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (conversationStatus?.mode == "Bot")
|
|
158
|
+
return "AI"
|
|
159
|
+
|
|
160
|
+
if (conversationStatus?.mode == "Human")
|
|
161
|
+
return "Human"
|
|
162
|
+
|
|
163
|
+
return "AI"
|
|
164
|
+
}, [conversationId, connected])
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
hideChat
|
|
168
|
+
? <></>
|
|
169
|
+
: <ChatWidget
|
|
170
|
+
anchorSx={anchorSx}
|
|
171
|
+
open={open}
|
|
172
|
+
setOpen={setOpen}
|
|
173
|
+
msgs={msgs}
|
|
174
|
+
input={input}
|
|
175
|
+
setInput={setInput}
|
|
176
|
+
send={send}
|
|
177
|
+
sending={sending}
|
|
178
|
+
conversationMode={getConversationMode(conversationStatus)} />
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export type ConversationMode = "New" | "AI" | "Human" | "Disconnected" | "Ended"
|
|
183
|
+
|
|
184
|
+
function formatDateLabel(dateString: string | undefined): string {
|
|
185
|
+
if (!dateString) return "";
|
|
186
|
+
return new Date(dateString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
187
|
+
}
|
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
import { useEffect } from "react";
|
|
2
|
-
|
|
3
|
-
export function ensureViewportMeta() {
|
|
4
|
-
if (typeof document === "undefined") return;
|
|
5
|
-
|
|
6
|
-
const existing = document.querySelector('meta[name="viewport"]');
|
|
7
|
-
if (existing) return;
|
|
8
|
-
|
|
9
|
-
const meta = document.createElement("meta");
|
|
10
|
-
meta.name = "viewport";
|
|
11
|
-
meta.content = "width=device-width, initial-scale=1";
|
|
12
|
-
document.head.appendChild(meta);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function useViewportHeightVar() {
|
|
16
|
-
useEffect(() => {
|
|
17
|
-
const setVh = () => {
|
|
18
|
-
const vh = window.innerHeight * 0.01;
|
|
19
|
-
document.documentElement.style.setProperty("--vh", `${vh}px`);
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
setVh();
|
|
23
|
-
|
|
24
|
-
window.addEventListener("resize", setVh);
|
|
25
|
-
window.addEventListener("orientationchange", setVh);
|
|
26
|
-
|
|
27
|
-
return () => {
|
|
28
|
-
window.removeEventListener("resize", setVh);
|
|
29
|
-
window.removeEventListener("orientationchange", setVh);
|
|
30
|
-
};
|
|
31
|
-
}, []);
|
|
1
|
+
import { useEffect, useRef, useState, useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
export function ensureViewportMeta() {
|
|
4
|
+
if (typeof document === "undefined") return;
|
|
5
|
+
|
|
6
|
+
const existing = document.querySelector('meta[name="viewport"]');
|
|
7
|
+
if (existing) return;
|
|
8
|
+
|
|
9
|
+
const meta = document.createElement("meta");
|
|
10
|
+
meta.name = "viewport";
|
|
11
|
+
meta.content = "width=device-width, initial-scale=1";
|
|
12
|
+
document.head.appendChild(meta);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useViewportHeightVar() {
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const setVh = () => {
|
|
18
|
+
const vh = window.innerHeight * 0.01;
|
|
19
|
+
document.documentElement.style.setProperty("--vh", `${vh}px`);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
setVh();
|
|
23
|
+
|
|
24
|
+
window.addEventListener("resize", setVh);
|
|
25
|
+
window.addEventListener("orientationchange", setVh);
|
|
26
|
+
|
|
27
|
+
return () => {
|
|
28
|
+
window.removeEventListener("resize", setVh);
|
|
29
|
+
window.removeEventListener("orientationchange", setVh);
|
|
30
|
+
};
|
|
31
|
+
}, []);
|
|
32
32
|
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useCallback } from "react";
|
|
2
|
+
import * as signalR from "@microsoft/signalr";
|
|
3
|
+
import { getSessionAccessToken } from "../authentification/token";
|
|
4
|
+
|
|
5
|
+
type SignalRStatus =
|
|
6
|
+
| "disconnected"
|
|
7
|
+
| "connecting"
|
|
8
|
+
| "connected"
|
|
9
|
+
| "reconnecting";
|
|
10
|
+
|
|
11
|
+
type UseSignalROptions = {
|
|
12
|
+
url: string;
|
|
13
|
+
accessToken?: string;
|
|
14
|
+
autoStart?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Handler<T = any> = (payload: T, ...args: any[]) => void;
|
|
18
|
+
|
|
19
|
+
export function useSignalR({ url, accessToken, autoStart = true }: UseSignalROptions) {
|
|
20
|
+
const connectionRef = useRef<signalR.HubConnection | null>(null);
|
|
21
|
+
const handlersRef = useRef<Map<string, Set<Handler>>>(new Map());
|
|
22
|
+
|
|
23
|
+
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
24
|
+
const retryAttemptRef = useRef<number>(0);
|
|
25
|
+
const stopRequestedRef = useRef<boolean>(false);
|
|
26
|
+
|
|
27
|
+
const [status, setStatus] = useState<SignalRStatus>("disconnected");
|
|
28
|
+
const [error, setError] = useState<Error | null>(null);
|
|
29
|
+
|
|
30
|
+
const clearRetry = useCallback(() => {
|
|
31
|
+
if (retryTimeoutRef.current) {
|
|
32
|
+
clearTimeout(retryTimeoutRef.current);
|
|
33
|
+
retryTimeoutRef.current = null;
|
|
34
|
+
}
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const scheduleRetry = useCallback(() => {
|
|
38
|
+
if (stopRequestedRef.current) return;
|
|
39
|
+
if (retryTimeoutRef.current) return;
|
|
40
|
+
|
|
41
|
+
retryAttemptRef.current += 1;
|
|
42
|
+
|
|
43
|
+
const attempt = retryAttemptRef.current;
|
|
44
|
+
const base = Math.min(30000, 1000 * Math.pow(2, attempt - 1)); // 1s,2s,4s,...,30s cap
|
|
45
|
+
const jitter = Math.floor(Math.random() * 500);
|
|
46
|
+
const delay = base + jitter;
|
|
47
|
+
|
|
48
|
+
retryTimeoutRef.current = setTimeout(() => {
|
|
49
|
+
retryTimeoutRef.current = null;
|
|
50
|
+
void start();
|
|
51
|
+
}, delay);
|
|
52
|
+
}, [clearRetry]);
|
|
53
|
+
|
|
54
|
+
const attachHandlers = useCallback((connection: signalR.HubConnection) => {
|
|
55
|
+
handlersRef.current.forEach((set, event) => {
|
|
56
|
+
connection.off(event);
|
|
57
|
+
set.forEach(handler => {
|
|
58
|
+
connection.on(event, handler);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const getAccessToken = useCallback(async (): Promise<string> => {
|
|
64
|
+
return getSessionAccessToken();
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
const buildConnection = useCallback(() => {
|
|
68
|
+
const connection = new signalR.HubConnectionBuilder()
|
|
69
|
+
.withUrl(url, {
|
|
70
|
+
accessTokenFactory: getAccessToken
|
|
71
|
+
})
|
|
72
|
+
.withAutomaticReconnect({
|
|
73
|
+
nextRetryDelayInMilliseconds: retryContext => {
|
|
74
|
+
if (retryContext.elapsedMilliseconds < 60000) {
|
|
75
|
+
return Math.random() * 10000;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
.build();
|
|
81
|
+
|
|
82
|
+
connection.onreconnecting(() => {
|
|
83
|
+
clearRetry();
|
|
84
|
+
setStatus("reconnecting");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
connection.onreconnected(() => {
|
|
88
|
+
clearRetry();
|
|
89
|
+
retryAttemptRef.current = 0;
|
|
90
|
+
setStatus("connected");
|
|
91
|
+
attachHandlers(connection);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
connection.onclose(err => {
|
|
95
|
+
setStatus("disconnected");
|
|
96
|
+
if (err) setError(err);
|
|
97
|
+
|
|
98
|
+
if (!stopRequestedRef.current) {
|
|
99
|
+
scheduleRetry();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return connection;
|
|
104
|
+
}, [url, getAccessToken, attachHandlers, clearRetry, scheduleRetry]);
|
|
105
|
+
|
|
106
|
+
const start = useCallback(async () => {
|
|
107
|
+
stopRequestedRef.current = false;
|
|
108
|
+
clearRetry();
|
|
109
|
+
|
|
110
|
+
if (!connectionRef.current) {
|
|
111
|
+
connectionRef.current = buildConnection();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const connection = connectionRef.current;
|
|
115
|
+
|
|
116
|
+
if (connection.state === signalR.HubConnectionState.Connected) {
|
|
117
|
+
setStatus("connected");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (connection.state === signalR.HubConnectionState.Connecting) {
|
|
122
|
+
setStatus("connecting");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (connection.state === signalR.HubConnectionState.Reconnecting) {
|
|
127
|
+
setStatus("reconnecting");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
setStatus("connecting");
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await connection.start();
|
|
135
|
+
attachHandlers(connection);
|
|
136
|
+
retryAttemptRef.current = 0;
|
|
137
|
+
setStatus("connected");
|
|
138
|
+
setError(null);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
setError(err as Error);
|
|
141
|
+
setStatus("disconnected");
|
|
142
|
+
scheduleRetry();
|
|
143
|
+
}
|
|
144
|
+
}, [attachHandlers, buildConnection, clearRetry, scheduleRetry]);
|
|
145
|
+
|
|
146
|
+
const stop = useCallback(async () => {
|
|
147
|
+
stopRequestedRef.current = true;
|
|
148
|
+
clearRetry();
|
|
149
|
+
retryAttemptRef.current = 0;
|
|
150
|
+
|
|
151
|
+
const connection = connectionRef.current;
|
|
152
|
+
connectionRef.current = null;
|
|
153
|
+
|
|
154
|
+
if (!connection) {
|
|
155
|
+
setStatus("disconnected");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
await connection.stop();
|
|
161
|
+
} finally {
|
|
162
|
+
setStatus("disconnected");
|
|
163
|
+
}
|
|
164
|
+
}, [clearRetry]);
|
|
165
|
+
|
|
166
|
+
const on = useCallback(<T = any>(eventName: string, handler: Handler<T>) => {
|
|
167
|
+
let set = handlersRef.current.get(eventName);
|
|
168
|
+
if (!set) {
|
|
169
|
+
set = new Set();
|
|
170
|
+
handlersRef.current.set(eventName, set);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
set.add(handler as Handler);
|
|
174
|
+
|
|
175
|
+
const connection = connectionRef.current;
|
|
176
|
+
if (connection) {
|
|
177
|
+
connection.on(eventName, handler as Handler);
|
|
178
|
+
}
|
|
179
|
+
}, []);
|
|
180
|
+
|
|
181
|
+
const off = useCallback(<T = any>(eventName: string, handler: Handler<T>) => {
|
|
182
|
+
const set = handlersRef.current.get(eventName);
|
|
183
|
+
if (!set) return;
|
|
184
|
+
|
|
185
|
+
set.delete(handler as Handler);
|
|
186
|
+
if (set.size === 0) {
|
|
187
|
+
handlersRef.current.delete(eventName);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const connection = connectionRef.current;
|
|
191
|
+
if (connection) {
|
|
192
|
+
connection.off(eventName, handler as Handler);
|
|
193
|
+
}
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
const invoke = useCallback(async <T = any>(methodName: string, ...args: any[]): Promise<T> => {
|
|
197
|
+
const connection = connectionRef.current;
|
|
198
|
+
if (!connection || connection.state !== signalR.HubConnectionState.Connected) {
|
|
199
|
+
throw new Error("SignalR not connected");
|
|
200
|
+
}
|
|
201
|
+
return connection.invoke<T>(methodName, ...args);
|
|
202
|
+
}, []);
|
|
203
|
+
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (autoStart) void start();
|
|
206
|
+
|
|
207
|
+
return () => {
|
|
208
|
+
void stop();
|
|
209
|
+
};
|
|
210
|
+
}, [autoStart, start, stop]);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
status,
|
|
214
|
+
connected: status === "connected",
|
|
215
|
+
error,
|
|
216
|
+
start,
|
|
217
|
+
stop,
|
|
218
|
+
on,
|
|
219
|
+
off,
|
|
220
|
+
invoke
|
|
221
|
+
};
|
|
222
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ChatBotElement } from "./components/chatbot-element";
|
|
2
|
+
import { getApiStatus, getClientActivityStatus } from "./api/service";
|
|
3
|
+
import { getConversationId } from "./storage/session-storage";
|
|
4
|
+
import { getThisScript } from "./utils/script-utils";
|
|
5
|
+
import { getChatBotConfig, setChatBotConfig } from "./services/chatConfiguration";
|
|
6
|
+
|
|
7
|
+
const apiBaseUrl = "https://localhost:7352"
|
|
8
|
+
|
|
9
|
+
const script = getThisScript();
|
|
10
|
+
const clientId = script?.getAttribute("data-client-id") || "";
|
|
11
|
+
const chatBotComponentName = `chat-bot-${clientId}`;
|
|
12
|
+
|
|
13
|
+
if (!customElements.get(chatBotComponentName)) {
|
|
14
|
+
customElements.define(chatBotComponentName, ChatBotElement);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
(async function autoMount() {
|
|
18
|
+
var chatConfiguration = getChatBotConfig()
|
|
19
|
+
if (!chatConfiguration) {
|
|
20
|
+
chatConfiguration = {
|
|
21
|
+
clientId: clientId, apiBaseUrl: apiBaseUrl, conversationId: undefined, userId: undefined, hideChatForUrls: undefined,
|
|
22
|
+
sessionToken: null
|
|
23
|
+
}
|
|
24
|
+
setChatBotConfig(chatConfiguration)
|
|
25
|
+
}
|
|
26
|
+
if (document.querySelector(chatBotComponentName)) return;
|
|
27
|
+
|
|
28
|
+
var isHealty = await getApiStatus(chatConfiguration!)
|
|
29
|
+
if (!isHealty) return;
|
|
30
|
+
|
|
31
|
+
var clientActivityStatus = await getClientActivityStatus(chatConfiguration!, getConversationId())
|
|
32
|
+
if (!clientActivityStatus.isActive) return;
|
|
33
|
+
|
|
34
|
+
chatConfiguration!.hideChatForUrls = clientActivityStatus.hideForUrls;
|
|
35
|
+
|
|
36
|
+
const el = document.createElement(chatBotComponentName);
|
|
37
|
+
document.body.appendChild(el);
|
|
38
|
+
})();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const STORAGE_KEY = "chat_session_v1";
|
|
2
|
+
const INACTIVITY_MS = 30 * 60 * 1000;
|
|
3
|
+
|
|
4
|
+
type ChatSession = {
|
|
5
|
+
conversationId: string;
|
|
6
|
+
lastActivityAt: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function readSession(): ChatSession | null {
|
|
10
|
+
try {
|
|
11
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
12
|
+
if (!raw) return null;
|
|
13
|
+
return JSON.parse(raw) as ChatSession;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function writeSession(session: ChatSession): void {
|
|
19
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getConversationId(): string | null {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const session = readSession();
|
|
25
|
+
|
|
26
|
+
if (!session) return null
|
|
27
|
+
|
|
28
|
+
const expired = now - session.lastActivityAt > INACTIVITY_MS;
|
|
29
|
+
|
|
30
|
+
if (expired) {
|
|
31
|
+
resetConversation()
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const next: ChatSession = {
|
|
36
|
+
...session,
|
|
37
|
+
lastActivityAt: now,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
writeSession(next);
|
|
41
|
+
return next.conversationId;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function writeConversation(conversationId: string): void {
|
|
45
|
+
const next: ChatSession = {
|
|
46
|
+
conversationId: conversationId,
|
|
47
|
+
lastActivityAt: Date.now(),
|
|
48
|
+
};
|
|
49
|
+
writeSession(next);
|
|
50
|
+
}
|
|
51
|
+
export function resetConversation(): void {
|
|
52
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
53
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type Config = {
|
|
2
|
+
clientId: string;
|
|
3
|
+
apiBaseUrl: string;
|
|
4
|
+
conversationId?: string;
|
|
5
|
+
userId?: string;
|
|
6
|
+
hideChatForUrls?: string[];
|
|
7
|
+
sessionToken?: string | null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type Msg = {
|
|
11
|
+
id: string; role: string | "User" | "Assistant"; text: string, sentAt?: string;
|
|
12
|
+
seen: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type MsgDelta = {
|
|
16
|
+
id: string;
|
|
17
|
+
conversationId: string;
|
|
18
|
+
text: string;
|
|
19
|
+
role: string | "User" | "Assistant";
|
|
20
|
+
sentAt?: string;
|
|
21
|
+
seen: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ConversationState = "AI_ACTIVE" | "AI_NEEDS_HELP" | "HANDOFF_PENDING" | "HUMAN_ACTIVE" | "WRAP_UP" | "CLOSED"
|
|
25
|
+
export type ControlModeType = "Bot" | "Human"
|
|
26
|
+
export type ConversationStatus = {
|
|
27
|
+
state: ConversationState,
|
|
28
|
+
mode: ControlModeType
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function getThisScript(): HTMLScriptElement | null {
|
|
2
|
+
const cs = document.currentScript as HTMLScriptElement | null;
|
|
3
|
+
if (cs) return cs;
|
|
4
|
+
|
|
5
|
+
const scripts = Array.from(document.getElementsByTagName("script"));
|
|
6
|
+
const matches = scripts.filter(s =>
|
|
7
|
+
(s.src || "").startsWith("http://localhost:5173/src/main.ts") //for development only
|
|
8
|
+
|| (s.src || "").includes("https://cdn.jsdelivr.net/npm/ss-support-widget")
|
|
9
|
+
);
|
|
10
|
+
return matches.length ? matches[matches.length - 1] : null;
|
|
11
|
+
}
|
|
Binary file
|
package/tsconfig.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2019",
|
|
4
|
-
"lib": ["DOM", "ES2019"],
|
|
5
|
-
"jsx": "react-jsx",
|
|
6
|
-
"module": "ESNext",
|
|
7
|
-
"moduleResolution": "Bundler",
|
|
8
|
-
"strict": true,
|
|
9
|
-
"skipLibCheck": true,
|
|
10
|
-
"noEmit": true
|
|
11
|
-
},
|
|
12
|
-
"include": ["src"]
|
|
13
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2019",
|
|
4
|
+
"lib": ["DOM", "ES2019"],
|
|
5
|
+
"jsx": "react-jsx",
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleResolution": "Bundler",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"noEmit": true
|
|
11
|
+
},
|
|
12
|
+
"include": ["src"]
|
|
13
|
+
}
|