@freewaretools/outercom 0.4.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/LICENSE +21 -0
- package/README.md +153 -0
- package/dist/ai.d.ts +31 -0
- package/dist/ai.js +42 -0
- package/dist/engine.d.ts +96 -0
- package/dist/engine.js +282 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/local.d.ts +38 -0
- package/dist/local.js +44 -0
- package/dist/next.d.ts +46 -0
- package/dist/next.js +155 -0
- package/dist/prompt.d.ts +24 -0
- package/dist/prompt.js +105 -0
- package/dist/react.d.ts +51 -0
- package/dist/react.js +618 -0
- package/dist/storage.d.ts +54 -0
- package/dist/storage.js +72 -0
- package/dist/telegram.d.ts +40 -0
- package/dist/telegram.js +91 -0
- package/dist/transcript.d.ts +22 -0
- package/dist/transcript.js +73 -0
- package/dist/transport.d.ts +23 -0
- package/dist/transport.js +1 -0
- package/dist/types.d.ts +75 -0
- package/dist/types.js +8 -0
- package/package.json +48 -0
- package/src/ai.ts +72 -0
- package/src/engine.ts +363 -0
- package/src/index.ts +24 -0
- package/src/local.ts +77 -0
- package/src/next.ts +189 -0
- package/src/prompt.ts +131 -0
- package/src/react.tsx +994 -0
- package/src/storage.ts +111 -0
- package/src/telegram.ts +135 -0
- package/src/transcript.ts +116 -0
- package/src/transport.ts +24 -0
- package/src/types.ts +83 -0
package/dist/react.js
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* chatkit widget — a self-contained floating chat box.
|
|
5
|
+
*
|
|
6
|
+
* No CSS framework dependency: styling is inline + a small injected <style> for
|
|
7
|
+
* the keyframes, themeable via the `accentColor` prop. Drop it into any React
|
|
8
|
+
* app and point `apiBase` at the routes created with `chatkit/next`.
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
11
|
+
export function ChatWidget({ apiBase = "/api/chat", title: titleProp = "Chat with us", subtitle: subtitleProp = "We typically reply in a few minutes", greeting: greetingProp, accentColor: accentColorProp = "#0f172a", launcherLabel: launcherLabelProp = "Live Chat", launcherVariant = "accent", aiLabel: aiLabelProp = "Assistant", position: positionProp = "bottom-right", pollIntervalMs = 4000, storageKey = "chatkit:session", configUrl, visitor: visitorProp, hideLauncher = false, open: controlledOpen, onOpenChange, onUnreadChange, }) {
|
|
12
|
+
// When configUrl is set, stay hidden until the runtime config says enabled.
|
|
13
|
+
const [enabled, setEnabled] = useState(!configUrl);
|
|
14
|
+
// Theming/config overrides fetched at runtime from configUrl (take precedence over props).
|
|
15
|
+
const [overrides, setOverrides] = useState({});
|
|
16
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
17
|
+
// Controlled when `open` prop is provided; otherwise self-managed.
|
|
18
|
+
const open = controlledOpen ?? internalOpen;
|
|
19
|
+
const setOpen = (v) => {
|
|
20
|
+
const next = typeof v === "function" ? v(open) : v;
|
|
21
|
+
onOpenChange?.(next);
|
|
22
|
+
if (controlledOpen === undefined)
|
|
23
|
+
setInternalOpen(next);
|
|
24
|
+
};
|
|
25
|
+
// Skip the pre-chat form when identity is already known.
|
|
26
|
+
const [phase, setPhase] = useState(visitorProp ? "chat" : "form");
|
|
27
|
+
const [name, setName] = useState("");
|
|
28
|
+
const [email, setEmail] = useState("");
|
|
29
|
+
const [topic, setTopic] = useState("");
|
|
30
|
+
const [sessionId, setSessionId] = useState(null);
|
|
31
|
+
const [messages, setMessages] = useState([]);
|
|
32
|
+
const [input, setInput] = useState("");
|
|
33
|
+
const [status, setStatus] = useState("ai");
|
|
34
|
+
const [busy, setBusy] = useState(false);
|
|
35
|
+
const [error, setError] = useState(null);
|
|
36
|
+
const [muted, setMuted] = useState(false);
|
|
37
|
+
const [unread, setUnread] = useState(false);
|
|
38
|
+
const [emailState, setEmailState] = useState("idle");
|
|
39
|
+
const afterRef = useRef(0);
|
|
40
|
+
const seenIds = useRef(new Set());
|
|
41
|
+
const scrollRef = useRef(null);
|
|
42
|
+
const audioCtxRef = useRef(null);
|
|
43
|
+
const armedRef = useRef(false); // suppress sound/unread on the first (history) sync
|
|
44
|
+
const startingRef = useRef(false); // guard against double auto-start
|
|
45
|
+
const openRef = useRef(open);
|
|
46
|
+
const mutedRef = useRef(muted);
|
|
47
|
+
const mutedKey = `${storageKey}:muted`;
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
openRef.current = open;
|
|
50
|
+
}, [open]);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
mutedRef.current = muted;
|
|
53
|
+
}, [muted]);
|
|
54
|
+
// Load mute preference + clear the unread dot when opened.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
try {
|
|
57
|
+
setMuted(localStorage.getItem(mutedKey) === "1");
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* ignore */
|
|
61
|
+
}
|
|
62
|
+
}, [mutedKey]);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (open)
|
|
65
|
+
setUnread(false);
|
|
66
|
+
}, [open]);
|
|
67
|
+
// Surface unread changes to the host (e.g. to badge a menu item).
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
onUnreadChange?.(unread);
|
|
70
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
71
|
+
}, [unread]);
|
|
72
|
+
function toggleMute() {
|
|
73
|
+
setMuted((m) => {
|
|
74
|
+
const next = !m;
|
|
75
|
+
try {
|
|
76
|
+
localStorage.setItem(mutedKey, next ? "1" : "0");
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* ignore */
|
|
80
|
+
}
|
|
81
|
+
return next;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// Soft two-tone "boop" via Web Audio — no asset to bundle.
|
|
85
|
+
function playBoop() {
|
|
86
|
+
try {
|
|
87
|
+
const Ctx = window.AudioContext ||
|
|
88
|
+
window.webkitAudioContext;
|
|
89
|
+
if (!Ctx)
|
|
90
|
+
return;
|
|
91
|
+
if (!audioCtxRef.current)
|
|
92
|
+
audioCtxRef.current = new Ctx();
|
|
93
|
+
const ctx = audioCtxRef.current;
|
|
94
|
+
if (ctx.state === "suspended")
|
|
95
|
+
void ctx.resume();
|
|
96
|
+
const now = ctx.currentTime;
|
|
97
|
+
[660, 880].forEach((freq, i) => {
|
|
98
|
+
const osc = ctx.createOscillator();
|
|
99
|
+
const gain = ctx.createGain();
|
|
100
|
+
osc.type = "sine";
|
|
101
|
+
osc.frequency.value = freq;
|
|
102
|
+
const t = now + i * 0.11;
|
|
103
|
+
gain.gain.setValueAtTime(0.0001, t);
|
|
104
|
+
gain.gain.exponentialRampToValueAtTime(0.14, t + 0.02);
|
|
105
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.11);
|
|
106
|
+
osc.connect(gain).connect(ctx.destination);
|
|
107
|
+
osc.start(t);
|
|
108
|
+
osc.stop(t + 0.12);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
/* audio unavailable — ignore */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Resume a prior session from localStorage.
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
try {
|
|
118
|
+
const raw = localStorage.getItem(storageKey);
|
|
119
|
+
if (raw) {
|
|
120
|
+
const s = JSON.parse(raw);
|
|
121
|
+
if (s?.sessionId) {
|
|
122
|
+
setSessionId(s.sessionId);
|
|
123
|
+
setName(s.visitor?.name ?? "");
|
|
124
|
+
setEmail(s.visitor?.email ?? "");
|
|
125
|
+
setPhase("chat");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* ignore */
|
|
131
|
+
}
|
|
132
|
+
}, [storageKey]);
|
|
133
|
+
// Resolve enabled + theming at request time (avoids baking into a build).
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!configUrl)
|
|
136
|
+
return;
|
|
137
|
+
let cancelled = false;
|
|
138
|
+
fetch(configUrl, { cache: "no-store" })
|
|
139
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
140
|
+
.then((cfg) => {
|
|
141
|
+
if (cancelled || !cfg)
|
|
142
|
+
return;
|
|
143
|
+
setEnabled(!!cfg.enabled);
|
|
144
|
+
const next = {};
|
|
145
|
+
if (cfg.greeting)
|
|
146
|
+
next.greeting = cfg.greeting;
|
|
147
|
+
if (cfg.accentColor)
|
|
148
|
+
next.accentColor = cfg.accentColor;
|
|
149
|
+
if (cfg.title)
|
|
150
|
+
next.title = cfg.title;
|
|
151
|
+
if (cfg.subtitle)
|
|
152
|
+
next.subtitle = cfg.subtitle;
|
|
153
|
+
if (cfg.launcherLabel)
|
|
154
|
+
next.launcherLabel = cfg.launcherLabel;
|
|
155
|
+
if (cfg.aiLabel)
|
|
156
|
+
next.aiLabel = cfg.aiLabel;
|
|
157
|
+
if (cfg.position === "bottom-left" || cfg.position === "bottom-right") {
|
|
158
|
+
next.position = cfg.position;
|
|
159
|
+
}
|
|
160
|
+
setOverrides(next);
|
|
161
|
+
})
|
|
162
|
+
.catch(() => {
|
|
163
|
+
/* leave hidden on error */
|
|
164
|
+
});
|
|
165
|
+
return () => {
|
|
166
|
+
cancelled = true;
|
|
167
|
+
};
|
|
168
|
+
}, [configUrl]);
|
|
169
|
+
const mergeMessages = useCallback((incoming, notify = false) => {
|
|
170
|
+
const fresh = incoming.filter((m) => !seenIds.current.has(m.id));
|
|
171
|
+
if (fresh.length === 0)
|
|
172
|
+
return;
|
|
173
|
+
let gotIncoming = false;
|
|
174
|
+
for (const m of fresh) {
|
|
175
|
+
seenIds.current.add(m.id);
|
|
176
|
+
if (m.createdAt > afterRef.current)
|
|
177
|
+
afterRef.current = m.createdAt;
|
|
178
|
+
if (m.role === "ai" || m.role === "agent")
|
|
179
|
+
gotIncoming = true;
|
|
180
|
+
}
|
|
181
|
+
setMessages((prev) => [...prev, ...fresh].sort((a, b) => a.createdAt - b.createdAt));
|
|
182
|
+
if (notify && gotIncoming) {
|
|
183
|
+
if (!mutedRef.current)
|
|
184
|
+
playBoop();
|
|
185
|
+
if (!openRef.current)
|
|
186
|
+
setUnread(true);
|
|
187
|
+
}
|
|
188
|
+
}, []);
|
|
189
|
+
const poll = useCallback(async () => {
|
|
190
|
+
if (!sessionId)
|
|
191
|
+
return;
|
|
192
|
+
try {
|
|
193
|
+
const res = await fetch(`${apiBase}/poll?sessionId=${encodeURIComponent(sessionId)}&after=${afterRef.current}`);
|
|
194
|
+
if (!res.ok)
|
|
195
|
+
return;
|
|
196
|
+
const data = (await res.json());
|
|
197
|
+
setStatus(data.status);
|
|
198
|
+
// Don't sound/badge the first sync (it loads history); arm afterwards.
|
|
199
|
+
mergeMessages(data.messages, armedRef.current);
|
|
200
|
+
armedRef.current = true;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
/* transient network error — next tick retries */
|
|
204
|
+
}
|
|
205
|
+
}, [apiBase, sessionId, mergeMessages]);
|
|
206
|
+
// Poll while a chat is active. Runs even when the bubble is closed (a slower
|
|
207
|
+
// heartbeat) so the server knows the visitor is still present (last-seen) and
|
|
208
|
+
// can show the unread dot / play a sound on incoming replies. When the tab is
|
|
209
|
+
// truly gone, polling stops → the server can email unseen replies after X min.
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (phase !== "chat" || !sessionId || status === "closed")
|
|
212
|
+
return;
|
|
213
|
+
poll();
|
|
214
|
+
const interval = open ? pollIntervalMs : Math.max(pollIntervalMs * 6, 20000);
|
|
215
|
+
const t = setInterval(poll, interval);
|
|
216
|
+
return () => clearInterval(t);
|
|
217
|
+
}, [open, phase, sessionId, status, pollIntervalMs, poll]);
|
|
218
|
+
// Keep scrolled to the latest message.
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
|
221
|
+
}, [messages, open, phase]);
|
|
222
|
+
async function doStart(v) {
|
|
223
|
+
if (startingRef.current || sessionId)
|
|
224
|
+
return;
|
|
225
|
+
startingRef.current = true;
|
|
226
|
+
setBusy(true);
|
|
227
|
+
try {
|
|
228
|
+
const res = await fetch(apiBase, {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: { "Content-Type": "application/json" },
|
|
231
|
+
body: JSON.stringify({ action: "start", visitor: v }),
|
|
232
|
+
});
|
|
233
|
+
const data = (await res.json());
|
|
234
|
+
if (!res.ok || !data.sessionId) {
|
|
235
|
+
setError(data.error ?? "Could not start the chat. Please try again.");
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
setSessionId(data.sessionId);
|
|
239
|
+
setStatus("ai");
|
|
240
|
+
try {
|
|
241
|
+
localStorage.setItem(storageKey, JSON.stringify({ sessionId: data.sessionId, visitor: { name: v.name, email: v.email } }));
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
/* ignore */
|
|
245
|
+
}
|
|
246
|
+
// Show the visitor's opening question locally; the AI reply arrives via poll.
|
|
247
|
+
if (v.topic) {
|
|
248
|
+
mergeMessages([localMessage(data.sessionId, "visitor", v.topic)]);
|
|
249
|
+
}
|
|
250
|
+
setPhase("chat");
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
setError("Could not start the chat. Please try again.");
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
setBusy(false);
|
|
257
|
+
startingRef.current = false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async function startChat(e) {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
setError(null);
|
|
263
|
+
if (!name.trim() || !isEmail(email)) {
|
|
264
|
+
setError("Please enter your name and a valid email.");
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
await doStart({ name: name.trim(), email: email.trim(), topic: topic.trim() || undefined });
|
|
268
|
+
}
|
|
269
|
+
// Auto-start once the panel opens and identity is already known (logged-in).
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
if (visitorProp && open && !sessionId && !startingRef.current) {
|
|
272
|
+
void doStart({ name: visitorProp.name, email: visitorProp.email });
|
|
273
|
+
}
|
|
274
|
+
// doStart is stable enough for this guard-driven effect.
|
|
275
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
276
|
+
}, [visitorProp, open, sessionId]);
|
|
277
|
+
async function sendMessage(e) {
|
|
278
|
+
e.preventDefault();
|
|
279
|
+
const text = input.trim();
|
|
280
|
+
if (!text || !sessionId || status === "closed")
|
|
281
|
+
return;
|
|
282
|
+
setInput("");
|
|
283
|
+
mergeMessages([localMessage(sessionId, "visitor", text)]);
|
|
284
|
+
setBusy(true);
|
|
285
|
+
try {
|
|
286
|
+
const res = await fetch(apiBase, {
|
|
287
|
+
method: "POST",
|
|
288
|
+
headers: { "Content-Type": "application/json" },
|
|
289
|
+
body: JSON.stringify({ action: "send", sessionId, text }),
|
|
290
|
+
});
|
|
291
|
+
if (res.ok) {
|
|
292
|
+
const data = (await res.json());
|
|
293
|
+
setStatus(data.status);
|
|
294
|
+
mergeMessages(data.messages, true);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
/* poll will reconcile */
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
setBusy(false);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
async function emailTranscript() {
|
|
305
|
+
if (!sessionId || emailState === "sending")
|
|
306
|
+
return;
|
|
307
|
+
setEmailState("sending");
|
|
308
|
+
try {
|
|
309
|
+
const res = await fetch(apiBase, {
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: { "Content-Type": "application/json" },
|
|
312
|
+
body: JSON.stringify({ action: "email", sessionId }),
|
|
313
|
+
});
|
|
314
|
+
const data = (await res.json().catch(() => ({})));
|
|
315
|
+
setEmailState(res.ok && data.ok ? "sent" : "error");
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
setEmailState("error");
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function resetChat() {
|
|
322
|
+
// Tell the server the visitor ended this chat so agents in Telegram know
|
|
323
|
+
// their replies won't land (and the topic gets closed). Fire-and-forget;
|
|
324
|
+
// keepalive lets it finish even as we tear down local state.
|
|
325
|
+
if (sessionId) {
|
|
326
|
+
fetch(apiBase, {
|
|
327
|
+
method: "POST",
|
|
328
|
+
headers: { "Content-Type": "application/json" },
|
|
329
|
+
body: JSON.stringify({ action: "end", sessionId }),
|
|
330
|
+
keepalive: true,
|
|
331
|
+
}).catch(() => { });
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
localStorage.removeItem(storageKey);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
/* ignore */
|
|
338
|
+
}
|
|
339
|
+
seenIds.current = new Set();
|
|
340
|
+
afterRef.current = 0;
|
|
341
|
+
armedRef.current = false;
|
|
342
|
+
setMessages([]);
|
|
343
|
+
setSessionId(null);
|
|
344
|
+
setStatus("ai");
|
|
345
|
+
setTopic("");
|
|
346
|
+
setInput("");
|
|
347
|
+
setEmailState("idle");
|
|
348
|
+
setUnread(false);
|
|
349
|
+
setPhase("form");
|
|
350
|
+
}
|
|
351
|
+
function handleReset() {
|
|
352
|
+
// Confirm only if there's a conversation to lose.
|
|
353
|
+
if (messages.length > 0 && !window.confirm("Start over? This clears the current conversation.")) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
resetChat();
|
|
357
|
+
}
|
|
358
|
+
// Gated off (or config not yet resolved) — render nothing.
|
|
359
|
+
if (!enabled)
|
|
360
|
+
return null;
|
|
361
|
+
// Effective values: runtime config (overrides) wins over props.
|
|
362
|
+
const accentColor = overrides.accentColor ?? accentColorProp;
|
|
363
|
+
const title = overrides.title ?? titleProp;
|
|
364
|
+
const subtitle = overrides.subtitle ?? subtitleProp;
|
|
365
|
+
const resolvedGreeting = overrides.greeting ?? greetingProp;
|
|
366
|
+
const launcherLabel = overrides.launcherLabel ?? launcherLabelProp;
|
|
367
|
+
const aiLabel = overrides.aiLabel ?? aiLabelProp;
|
|
368
|
+
const position = overrides.position ?? positionProp;
|
|
369
|
+
const side = position === "bottom-left" ? { left: 20 } : { right: 20 };
|
|
370
|
+
const headerSub = status === "live"
|
|
371
|
+
? "You're chatting with our team"
|
|
372
|
+
: status === "closed"
|
|
373
|
+
? "This chat has ended"
|
|
374
|
+
: subtitle;
|
|
375
|
+
return (_jsxs("div", { style: { position: "fixed", bottom: 20, zIndex: 2147483000, ...side }, children: [_jsx("style", { children: KEYFRAMES }), open && (_jsxs("div", { style: { ...panelStyle(accentColor), bottom: hideLauncher ? 0 : 72 }, role: "dialog", "aria-label": title, children: [_jsxs("header", { style: headerStyle(accentColor), children: [_jsxs("div", { children: [_jsx("div", { style: { fontWeight: 600, fontSize: 15 }, children: title }), _jsx("div", { style: { fontSize: 12, opacity: 0.8 }, children: headerSub })] }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 6, marginRight: -6 }, children: [phase === "chat" && (_jsxs(_Fragment, { children: [_jsx("button", { "aria-label": muted ? "Unmute notifications" : "Mute notifications", title: muted ? "Unmute" : "Mute", onClick: toggleMute, style: iconBtn, children: muted ? _jsx(MuteIcon, {}) : _jsx(SoundIcon, {}) }), _jsx("button", { "aria-label": "Start over", title: "Start over", onClick: handleReset, style: iconBtn, children: _jsx(RefreshIcon, {}) })] })), _jsx("button", { "aria-label": "Close chat", onClick: () => setOpen(false), style: iconBtn, children: _jsx(CloseIcon, {}) })] })] }), phase === "form" ? (_jsxs("form", { onSubmit: startChat, style: formStyle, children: [_jsx("p", { style: { margin: "0 0 4px", fontSize: 13, color: "#475569" }, children: "Tell us who you are and we'll get started." }), _jsx("input", { className: "ck-field", style: inputStyle, placeholder: "Your name", value: name, onChange: (e) => setName(e.target.value), autoComplete: "name", required: true }), _jsx("input", { className: "ck-field", style: inputStyle, type: "email", placeholder: "Your email", value: email, onChange: (e) => setEmail(e.target.value), autoComplete: "email", required: true }), _jsx("textarea", { className: "ck-field", style: { ...inputStyle, minHeight: 64, resize: "vertical" }, placeholder: "What can we help with? (optional)", value: topic, onChange: (e) => setTopic(e.target.value) }), error && _jsx("div", { style: errorStyle, children: error }), _jsx("button", { type: "submit", disabled: busy, style: primaryBtn(accentColor), children: busy ? "Starting…" : "Start chat" })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { ref: scrollRef, style: messagesStyle, children: [resolvedGreeting && messages.length === 0 && (_jsx(Bubble, { role: "ai", text: resolvedGreeting, aiLabel: aiLabel, accentColor: accentColor })), messages.map((m) => (_jsx(Bubble, { role: m.role, text: m.text, authorName: m.authorName, aiLabel: aiLabel, accentColor: accentColor }, m.id)))] }), status === "closed" ? (_jsxs("div", { style: {
|
|
376
|
+
padding: 12,
|
|
377
|
+
borderTop: "1px solid #e2e8f0",
|
|
378
|
+
display: "flex",
|
|
379
|
+
flexDirection: "column",
|
|
380
|
+
gap: 8,
|
|
381
|
+
}, children: [emailState === "sent" ? (_jsxs("span", { style: { textAlign: "center", fontSize: 13, color: "#16a34a" }, children: ["\u2713 Transcript sent to ", email] })) : (_jsx("button", { onClick: emailTranscript, disabled: emailState === "sending", style: secondaryBtn(accentColor), children: emailState === "sending" ? "Sending…" : "✉ Email me a copy" })), _jsx("button", { onClick: resetChat, style: primaryBtn(accentColor), children: "Start a new chat" })] })) : (_jsxs(_Fragment, { children: [messages.length > 0 && (_jsx("div", { style: transcriptRowStyle, children: emailState === "sent" ? (_jsxs("span", { style: { fontSize: 12.5, color: "#16a34a" }, children: ["\u2713 Transcript sent to ", email] })) : (_jsx("button", { type: "button", onClick: emailTranscript, disabled: emailState === "sending", style: linkBtn, children: emailState === "sending"
|
|
382
|
+
? "Sending…"
|
|
383
|
+
: emailState === "error"
|
|
384
|
+
? "Couldn't send — retry"
|
|
385
|
+
: "✉ Email me a copy" })) })), _jsxs("form", { onSubmit: sendMessage, style: composerStyle, children: [_jsx("input", { className: "ck-field", style: { ...inputStyle, margin: 0, flex: 1 }, placeholder: "Type a message\u2026", value: input, onChange: (e) => setInput(e.target.value), "aria-label": "Message" }), _jsx("button", { type: "submit", disabled: busy || !input.trim() || !sessionId, style: sendBtn(accentColor), children: "\u27A4" })] })] }))] }))] })), !hideLauncher && (_jsxs("button", { "aria-label": open ? "Close live chat" : "Open live chat", onClick: () => setOpen((v) => !v), style: launcherStyle(accentColor, launcherVariant), children: [open ? _jsx(CloseIcon, {}) : _jsx(ChatIcon, {}), _jsx("span", { children: open ? "Close" : launcherLabel }), unread && !open && _jsx("span", { style: unreadDotStyle })] }))] }));
|
|
386
|
+
}
|
|
387
|
+
function Bubble({ role, text, authorName, aiLabel, accentColor, }) {
|
|
388
|
+
if (role === "system") {
|
|
389
|
+
return (_jsx("div", { style: { textAlign: "center", fontSize: 12, color: "#64748b", margin: "6px 0" }, children: text }));
|
|
390
|
+
}
|
|
391
|
+
const mine = role === "visitor";
|
|
392
|
+
const label = role === "agent"
|
|
393
|
+
? authorName || "Support"
|
|
394
|
+
: role === "ai"
|
|
395
|
+
? aiLabel
|
|
396
|
+
: "";
|
|
397
|
+
return (_jsx("div", { style: { display: "flex", justifyContent: mine ? "flex-end" : "flex-start" }, children: _jsxs("div", { style: { maxWidth: "80%" }, children: [label && (_jsx("div", { style: { fontSize: 11, color: "#94a3b8", margin: "0 4px 2px" }, children: label })), _jsx("div", { style: bubbleStyle(mine, accentColor), children: text })] }) }));
|
|
398
|
+
}
|
|
399
|
+
// --- helpers -----------------------------------------------------------------
|
|
400
|
+
let localCounter = 0;
|
|
401
|
+
function localMessage(sessionId, role, text) {
|
|
402
|
+
return {
|
|
403
|
+
id: `local-${++localCounter}`,
|
|
404
|
+
sessionId,
|
|
405
|
+
role,
|
|
406
|
+
text,
|
|
407
|
+
createdAt: Date.now(),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function isEmail(s) {
|
|
411
|
+
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s.trim());
|
|
412
|
+
}
|
|
413
|
+
// --- styles ------------------------------------------------------------------
|
|
414
|
+
const KEYFRAMES = `
|
|
415
|
+
@keyframes chatkit-pop{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}
|
|
416
|
+
.ck-field{color:#0f172a !important;-webkit-text-fill-color:#0f172a !important;background:#fff !important;font-size:14px;}
|
|
417
|
+
.ck-field::placeholder{color:#94a3b8 !important;opacity:1 !important;-webkit-text-fill-color:#94a3b8 !important;}
|
|
418
|
+
`;
|
|
419
|
+
const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
|
|
420
|
+
function launcherStyle(accent, variant) {
|
|
421
|
+
const light = variant === "light";
|
|
422
|
+
return {
|
|
423
|
+
position: "relative",
|
|
424
|
+
display: "inline-flex",
|
|
425
|
+
alignItems: "center",
|
|
426
|
+
gap: 8,
|
|
427
|
+
padding: light ? "11px 18px" : "13px 20px",
|
|
428
|
+
// "light" — white pill, black rim + text — for visibility on busy/dark pages.
|
|
429
|
+
border: light ? "2px solid #000" : "none",
|
|
430
|
+
borderRadius: 999,
|
|
431
|
+
background: light ? "#fff" : accent,
|
|
432
|
+
color: light ? "#000" : "#fff",
|
|
433
|
+
fontSize: 15,
|
|
434
|
+
fontWeight: 600,
|
|
435
|
+
fontFamily: FONT,
|
|
436
|
+
lineHeight: 1,
|
|
437
|
+
cursor: "pointer",
|
|
438
|
+
boxShadow: "0 6px 24px rgba(0,0,0,0.25)",
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
const unreadDotStyle = {
|
|
442
|
+
position: "absolute",
|
|
443
|
+
top: -3,
|
|
444
|
+
right: -3,
|
|
445
|
+
width: 12,
|
|
446
|
+
height: 12,
|
|
447
|
+
borderRadius: "50%",
|
|
448
|
+
background: "#ef4444",
|
|
449
|
+
border: "2px solid #fff",
|
|
450
|
+
};
|
|
451
|
+
const transcriptRowStyle = {
|
|
452
|
+
padding: "6px 12px",
|
|
453
|
+
borderTop: "1px solid #e2e8f0",
|
|
454
|
+
background: "#fff",
|
|
455
|
+
textAlign: "center",
|
|
456
|
+
};
|
|
457
|
+
const linkBtn = {
|
|
458
|
+
background: "transparent",
|
|
459
|
+
border: "none",
|
|
460
|
+
color: "#64748b",
|
|
461
|
+
fontSize: 12.5,
|
|
462
|
+
fontFamily: FONT,
|
|
463
|
+
textDecoration: "underline",
|
|
464
|
+
cursor: "pointer",
|
|
465
|
+
padding: 0,
|
|
466
|
+
};
|
|
467
|
+
function secondaryBtn(accent) {
|
|
468
|
+
return {
|
|
469
|
+
width: "100%",
|
|
470
|
+
padding: "10px 14px",
|
|
471
|
+
background: "#fff",
|
|
472
|
+
color: accent,
|
|
473
|
+
border: `1px solid ${accent}`,
|
|
474
|
+
borderRadius: 10,
|
|
475
|
+
fontSize: 14,
|
|
476
|
+
fontWeight: 600,
|
|
477
|
+
cursor: "pointer",
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function ChatIcon() {
|
|
481
|
+
return (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" }) }));
|
|
482
|
+
}
|
|
483
|
+
function CloseIcon() {
|
|
484
|
+
return (_jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), _jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" })] }));
|
|
485
|
+
}
|
|
486
|
+
function HeaderSvg({ children }) {
|
|
487
|
+
return (_jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: children }));
|
|
488
|
+
}
|
|
489
|
+
function SoundIcon() {
|
|
490
|
+
return (_jsxs(HeaderSvg, { children: [_jsx("path", { d: "M11 5 6 9H2v6h4l5 4z" }), _jsx("path", { d: "M15.5 8.5a5 5 0 0 1 0 7" }), _jsx("path", { d: "M19 5a9 9 0 0 1 0 14" })] }));
|
|
491
|
+
}
|
|
492
|
+
function MuteIcon() {
|
|
493
|
+
return (_jsxs(HeaderSvg, { children: [_jsx("path", { d: "M11 5 6 9H2v6h4l5 4z" }), _jsx("line", { x1: "23", y1: "9", x2: "17", y2: "15" }), _jsx("line", { x1: "17", y1: "9", x2: "23", y2: "15" })] }));
|
|
494
|
+
}
|
|
495
|
+
function RefreshIcon() {
|
|
496
|
+
return (_jsxs(HeaderSvg, { children: [_jsx("polyline", { points: "1 4 1 10 7 10" }), _jsx("path", { d: "M3.51 15a9 9 0 1 0 2.13-9.36L1 10" })] }));
|
|
497
|
+
}
|
|
498
|
+
function panelStyle(_accent) {
|
|
499
|
+
return {
|
|
500
|
+
position: "absolute",
|
|
501
|
+
bottom: 72,
|
|
502
|
+
right: 0,
|
|
503
|
+
width: 360,
|
|
504
|
+
maxWidth: "calc(100vw - 40px)",
|
|
505
|
+
height: 520,
|
|
506
|
+
maxHeight: "calc(100vh - 120px)",
|
|
507
|
+
background: "#fff",
|
|
508
|
+
borderRadius: 16,
|
|
509
|
+
boxShadow: "0 12px 48px rgba(0,0,0,0.28)",
|
|
510
|
+
display: "flex",
|
|
511
|
+
flexDirection: "column",
|
|
512
|
+
overflow: "hidden",
|
|
513
|
+
fontFamily: FONT,
|
|
514
|
+
animation: "chatkit-pop .18s ease-out",
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
function headerStyle(accent) {
|
|
518
|
+
return {
|
|
519
|
+
background: accent,
|
|
520
|
+
color: "#fff",
|
|
521
|
+
padding: "14px 16px",
|
|
522
|
+
display: "flex",
|
|
523
|
+
alignItems: "center",
|
|
524
|
+
justifyContent: "space-between",
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
const iconBtn = {
|
|
528
|
+
display: "inline-flex",
|
|
529
|
+
alignItems: "center",
|
|
530
|
+
justifyContent: "center",
|
|
531
|
+
width: 36,
|
|
532
|
+
height: 36,
|
|
533
|
+
flex: "0 0 auto",
|
|
534
|
+
background: "transparent",
|
|
535
|
+
border: "none",
|
|
536
|
+
borderRadius: 8,
|
|
537
|
+
color: "#fff",
|
|
538
|
+
cursor: "pointer",
|
|
539
|
+
opacity: 0.85,
|
|
540
|
+
padding: 0,
|
|
541
|
+
};
|
|
542
|
+
const formStyle = {
|
|
543
|
+
padding: 16,
|
|
544
|
+
display: "flex",
|
|
545
|
+
flexDirection: "column",
|
|
546
|
+
gap: 10,
|
|
547
|
+
};
|
|
548
|
+
const messagesStyle = {
|
|
549
|
+
flex: 1,
|
|
550
|
+
overflowY: "auto",
|
|
551
|
+
padding: 14,
|
|
552
|
+
display: "flex",
|
|
553
|
+
flexDirection: "column",
|
|
554
|
+
gap: 8,
|
|
555
|
+
background: "#f8fafc",
|
|
556
|
+
};
|
|
557
|
+
const composerStyle = {
|
|
558
|
+
display: "flex",
|
|
559
|
+
gap: 8,
|
|
560
|
+
padding: 12,
|
|
561
|
+
borderTop: "1px solid #e2e8f0",
|
|
562
|
+
background: "#fff",
|
|
563
|
+
};
|
|
564
|
+
const inputStyle = {
|
|
565
|
+
width: "100%",
|
|
566
|
+
padding: "10px 12px",
|
|
567
|
+
border: "1px solid #cbd5e1",
|
|
568
|
+
borderRadius: 10,
|
|
569
|
+
fontSize: 14,
|
|
570
|
+
fontFamily: FONT,
|
|
571
|
+
boxSizing: "border-box",
|
|
572
|
+
outline: "none",
|
|
573
|
+
};
|
|
574
|
+
function bubbleStyle(mine, accent) {
|
|
575
|
+
return {
|
|
576
|
+
padding: "9px 12px",
|
|
577
|
+
borderRadius: 14,
|
|
578
|
+
fontSize: 14,
|
|
579
|
+
lineHeight: 1.4,
|
|
580
|
+
whiteSpace: "pre-wrap",
|
|
581
|
+
wordBreak: "break-word",
|
|
582
|
+
background: mine ? accent : "#fff",
|
|
583
|
+
color: mine ? "#fff" : "#0f172a",
|
|
584
|
+
border: mine ? "none" : "1px solid #e2e8f0",
|
|
585
|
+
borderBottomRightRadius: mine ? 4 : 14,
|
|
586
|
+
borderBottomLeftRadius: mine ? 14 : 4,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
function primaryBtn(accent) {
|
|
590
|
+
return {
|
|
591
|
+
width: "100%",
|
|
592
|
+
padding: "11px 14px",
|
|
593
|
+
background: accent,
|
|
594
|
+
color: "#fff",
|
|
595
|
+
border: "none",
|
|
596
|
+
borderRadius: 10,
|
|
597
|
+
fontSize: 14,
|
|
598
|
+
fontWeight: 600,
|
|
599
|
+
cursor: "pointer",
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
function sendBtn(accent) {
|
|
603
|
+
return {
|
|
604
|
+
flex: "0 0 auto",
|
|
605
|
+
width: 42,
|
|
606
|
+
background: accent,
|
|
607
|
+
color: "#fff",
|
|
608
|
+
border: "none",
|
|
609
|
+
borderRadius: 10,
|
|
610
|
+
fontSize: 16,
|
|
611
|
+
cursor: "pointer",
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
const errorStyle = {
|
|
615
|
+
color: "#dc2626",
|
|
616
|
+
fontSize: 12.5,
|
|
617
|
+
margin: "-2px 0 2px",
|
|
618
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ChatMessage, ChatSession, ChatRole } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Persistence seam. The host app supplies a concrete implementation
|
|
4
|
+
* (e.g. Postgres/Prisma) so chatkit itself stays storage-agnostic.
|
|
5
|
+
*
|
|
6
|
+
* Sessions and the agent↔visitor relay are inherently async and survive
|
|
7
|
+
* across requests/cold-starts, so a real adapter must be durable — the
|
|
8
|
+
* in-memory adapter below is for local dev and the OSS demo only.
|
|
9
|
+
*/
|
|
10
|
+
export interface ChatStorage {
|
|
11
|
+
createSession(session: ChatSession): Promise<void>;
|
|
12
|
+
getSession(id: string): Promise<ChatSession | null>;
|
|
13
|
+
/** Map an inbound transport thread back to its session. */
|
|
14
|
+
getSessionByThread(threadId: string): Promise<ChatSession | null>;
|
|
15
|
+
/**
|
|
16
|
+
* Most recent thread id for a visitor email (case-insensitive), or null.
|
|
17
|
+
* Lets the engine reuse one topic per customer instead of spawning a new one
|
|
18
|
+
* each time they start a chat.
|
|
19
|
+
*/
|
|
20
|
+
getThreadByEmail(email: string): Promise<string | null>;
|
|
21
|
+
updateSession(id: string, patch: Partial<ChatSession>): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Non-closed sessions whose visitor hasn't been seen since `beforeTs`
|
|
24
|
+
* (using lastSeenAt, falling back to createdAt). Used to find disconnected
|
|
25
|
+
* visitors who may have unseen replies to email.
|
|
26
|
+
*/
|
|
27
|
+
getSessionsIdleSince(beforeTs: number): Promise<ChatSession[]>;
|
|
28
|
+
appendMessage(message: ChatMessage): Promise<void>;
|
|
29
|
+
getMessages(sessionId: string, opts?: {
|
|
30
|
+
after?: number;
|
|
31
|
+
roles?: ChatRole[];
|
|
32
|
+
}): Promise<ChatMessage[]>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Reference adapter — keeps everything in process memory. Fine for `next dev`
|
|
36
|
+
* on a single instance and for trying the OSS demo; do NOT use in production
|
|
37
|
+
* (state is lost on restart and not shared across instances).
|
|
38
|
+
*/
|
|
39
|
+
export declare class InMemoryStorage implements ChatStorage {
|
|
40
|
+
private sessions;
|
|
41
|
+
private threadIndex;
|
|
42
|
+
private messages;
|
|
43
|
+
createSession(session: ChatSession): Promise<void>;
|
|
44
|
+
getSession(id: string): Promise<ChatSession | null>;
|
|
45
|
+
getSessionByThread(threadId: string): Promise<ChatSession | null>;
|
|
46
|
+
getThreadByEmail(email: string): Promise<string | null>;
|
|
47
|
+
updateSession(id: string, patch: Partial<ChatSession>): Promise<void>;
|
|
48
|
+
getSessionsIdleSince(beforeTs: number): Promise<ChatSession[]>;
|
|
49
|
+
appendMessage(message: ChatMessage): Promise<void>;
|
|
50
|
+
getMessages(sessionId: string, opts?: {
|
|
51
|
+
after?: number;
|
|
52
|
+
roles?: ChatRole[];
|
|
53
|
+
}): Promise<ChatMessage[]>;
|
|
54
|
+
}
|