@usepanacea/react 0.1.0 → 0.1.3

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 CHANGED
@@ -1,4 +1,4 @@
1
- import { createContext, useMemo, useState, useRef, useCallback, useEffect, useContext } from 'react';
1
+ import { createContext, useMemo, useState, useRef, useEffect, useCallback, useContext } from 'react';
2
2
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
3
3
  import { cva } from 'class-variance-authority';
4
4
  import { clsx } from 'clsx';
@@ -80,6 +80,42 @@ function PanaceaProvider({
80
80
  const [liveEscalationId, setLiveEscalationId] = useState(null);
81
81
  const [liveMessages, setLiveMessages] = useState([]);
82
82
  const [isOpen, setIsOpen] = useState(false);
83
+ useEffect(() => {
84
+ if (!customerToken) return;
85
+ let cancelled = false;
86
+ async function tryResume() {
87
+ try {
88
+ const token = await tokenMgr.getToken();
89
+ const url = new URL(`${apiBase}/api/v1/sessions/resume`);
90
+ url.searchParams.set("customerToken", customerToken);
91
+ const res = await fetch(url.toString(), {
92
+ headers: { Authorization: `Bearer ${token}` }
93
+ });
94
+ if (!res.ok || cancelled) return;
95
+ const data = await res.json();
96
+ const resumedId = data?.session?.sessionId ?? null;
97
+ if (!resumedId || cancelled) return;
98
+ sessionId.current = resumedId;
99
+ const turnsRes = await fetch(`${apiBase}/api/v1/sessions/${resumedId}/turns`, {
100
+ headers: { Authorization: `Bearer ${token}` }
101
+ });
102
+ if (!turnsRes.ok || cancelled) return;
103
+ const turnsData = await turnsRes.json();
104
+ const rawTurns = turnsData?.data?.turns ?? turnsData?.turns ?? [];
105
+ if (cancelled) return;
106
+ const hydrated = rawTurns.flatMap((t) => [
107
+ { role: "user", content: t.query },
108
+ { role: "assistant", content: t.response }
109
+ ]);
110
+ if (hydrated.length > 0) setTurns(hydrated);
111
+ } catch {
112
+ }
113
+ }
114
+ void tryResume();
115
+ return () => {
116
+ cancelled = true;
117
+ };
118
+ }, [customerToken, apiBase]);
83
119
  const value = useMemo(
84
120
  () => ({
85
121
  config,
@@ -99,23 +135,12 @@ function PanaceaProvider({
99
135
  isOpen,
100
136
  setIsOpen
101
137
  }),
102
- [
103
- config,
104
- apiBase,
105
- tokenMgr,
106
- turns,
107
- loading,
108
- streaming,
109
- liveEscalationId,
110
- liveMessages,
111
- isOpen
112
- ]
138
+ [config, apiBase, tokenMgr, turns, loading, streaming, liveEscalationId, liveMessages, isOpen]
113
139
  );
114
140
  return /* @__PURE__ */ jsx(PanaceaContext.Provider, { value, children });
115
141
  }
116
142
  function useChat() {
117
143
  const {
118
- config,
119
144
  apiBase,
120
145
  getToken,
121
146
  turns,
@@ -134,17 +159,14 @@ function useChat() {
134
159
  setTurns((prev) => [...prev, { role: "user", content: message }]);
135
160
  try {
136
161
  const token = await getToken();
137
- await fetch(
138
- `${apiBase}/api/v1/inbox/${liveEscalationId}/customer-message`,
139
- {
140
- method: "POST",
141
- headers: {
142
- "Content-Type": "application/json",
143
- Authorization: `Bearer ${token}`
144
- },
145
- body: JSON.stringify({ content: message })
146
- }
147
- );
162
+ await fetch(`${apiBase}/api/v1/inbox/${liveEscalationId}/customer-message`, {
163
+ method: "POST",
164
+ headers: {
165
+ "Content-Type": "application/json",
166
+ Authorization: `Bearer ${token}`
167
+ },
168
+ body: JSON.stringify({ content: message })
169
+ });
148
170
  } catch {
149
171
  }
150
172
  return;
@@ -230,32 +252,21 @@ function useChat() {
230
252
  setStreaming("");
231
253
  }
232
254
  },
233
- [
234
- apiBase,
235
- getToken,
236
- liveEscalationId,
237
- sessionId,
238
- setLoading,
239
- setStreaming,
240
- setTurns
241
- ]
255
+ [apiBase, getToken, liveEscalationId, sessionId, setLoading, setStreaming, setTurns]
242
256
  );
243
257
  const escalate = useCallback(
244
258
  async (reason = "Customer requested human support") => {
245
259
  if (!sessionId.current) return;
246
260
  try {
247
261
  const token = await getToken();
248
- const res = await fetch(
249
- `${apiBase}/api/v1/sessions/${sessionId.current}/escalate`,
250
- {
251
- method: "POST",
252
- headers: {
253
- "Content-Type": "application/json",
254
- Authorization: `Bearer ${token}`
255
- },
256
- body: JSON.stringify({ reason })
257
- }
258
- );
262
+ const res = await fetch(`${apiBase}/api/v1/sessions/${sessionId.current}/escalate`, {
263
+ method: "POST",
264
+ headers: {
265
+ "Content-Type": "application/json",
266
+ Authorization: `Bearer ${token}`
267
+ },
268
+ body: JSON.stringify({ reason })
269
+ });
259
270
  if (res.ok) {
260
271
  const data = await res.json();
261
272
  const id = data?.data?.escalationId ?? data?.escalationId;
@@ -269,22 +280,17 @@ function useChat() {
269
280
  const react = useCallback(
270
281
  async (turnIndex, reaction) => {
271
282
  if (!sessionId.current) return;
272
- setTurns(
273
- (prev) => prev.map((t, i) => i === turnIndex ? { ...t, reaction } : t)
274
- );
283
+ setTurns((prev) => prev.map((t, i) => i === turnIndex ? { ...t, reaction } : t));
275
284
  try {
276
285
  const token = await getToken();
277
- await fetch(
278
- `${apiBase}/api/v1/sessions/${sessionId.current}/reaction`,
279
- {
280
- method: "POST",
281
- headers: {
282
- "Content-Type": "application/json",
283
- Authorization: `Bearer ${token}`
284
- },
285
- body: JSON.stringify({ reaction, turnIndex })
286
- }
287
- );
286
+ await fetch(`${apiBase}/api/v1/sessions/${sessionId.current}/reaction`, {
287
+ method: "POST",
288
+ headers: {
289
+ "Content-Type": "application/json",
290
+ Authorization: `Bearer ${token}`
291
+ },
292
+ body: JSON.stringify({ reaction, turnIndex })
293
+ });
288
294
  } catch {
289
295
  }
290
296
  },
@@ -295,6 +301,24 @@ function useChat() {
295
301
  setStreaming("");
296
302
  sessionId.current = null;
297
303
  }, [sessionId, setStreaming, setTurns]);
304
+ const setPageContext = useCallback(
305
+ async (ctx) => {
306
+ if (!sessionId.current) return;
307
+ try {
308
+ const token = await getToken();
309
+ await fetch(`${apiBase}/api/v1/sessions/${sessionId.current}`, {
310
+ method: "PATCH",
311
+ headers: {
312
+ "Content-Type": "application/json",
313
+ Authorization: `Bearer ${token}`
314
+ },
315
+ body: JSON.stringify({ pageContext: ctx })
316
+ });
317
+ } catch {
318
+ }
319
+ },
320
+ [apiBase, getToken, sessionId]
321
+ );
298
322
  return {
299
323
  turns,
300
324
  loading,
@@ -304,27 +328,21 @@ function useChat() {
304
328
  send,
305
329
  escalate,
306
330
  react,
331
+ setPageContext,
307
332
  reset
308
333
  };
309
334
  }
310
335
  function useLiveSession() {
311
- const {
312
- apiBase,
313
- getToken,
314
- liveEscalationId,
315
- liveMessages,
316
- setLiveMessages
317
- } = usePanaceaContext();
336
+ const { apiBase, getToken, liveEscalationId, liveMessages, setLiveMessages } = usePanaceaContext();
318
337
  const seenIds = useRef(/* @__PURE__ */ new Set());
319
338
  useEffect(() => {
320
339
  if (!liveEscalationId) return;
321
340
  const poll = async () => {
322
341
  try {
323
342
  const token = await getToken();
324
- const res = await fetch(
325
- `${apiBase}/api/v1/inbox/${liveEscalationId}/messages`,
326
- { headers: { Authorization: `Bearer ${token}` } }
327
- );
343
+ const res = await fetch(`${apiBase}/api/v1/inbox/${liveEscalationId}/messages`, {
344
+ headers: { Authorization: `Bearer ${token}` }
345
+ });
328
346
  if (!res.ok) return;
329
347
  const data = await res.json();
330
348
  const all = data?.data?.messages ?? data?.messages ?? [];
@@ -345,17 +363,14 @@ function useLiveSession() {
345
363
  if (!liveEscalationId) return;
346
364
  try {
347
365
  const token = await getToken();
348
- await fetch(
349
- `${apiBase}/api/v1/inbox/${liveEscalationId}/customer-message`,
350
- {
351
- method: "POST",
352
- headers: {
353
- "Content-Type": "application/json",
354
- Authorization: `Bearer ${token}`
355
- },
356
- body: JSON.stringify({ content })
357
- }
358
- );
366
+ await fetch(`${apiBase}/api/v1/inbox/${liveEscalationId}/customer-message`, {
367
+ method: "POST",
368
+ headers: {
369
+ "Content-Type": "application/json",
370
+ Authorization: `Bearer ${token}`
371
+ },
372
+ body: JSON.stringify({ content })
373
+ });
359
374
  } catch {
360
375
  }
361
376
  },
@@ -376,6 +391,55 @@ function useWidget() {
376
391
  const toggle = useCallback(() => setIsOpen((v) => !v), [setIsOpen]);
377
392
  return { isOpen, open, close, toggle };
378
393
  }
394
+ function useSessionHistory() {
395
+ const { apiBase, getToken, config, setTurns, sessionId: activeSessionIdRef } = usePanaceaContext();
396
+ const [sessions, setSessions] = useState([]);
397
+ const [loading, setLoading] = useState(false);
398
+ const fetchSessions = useCallback(async () => {
399
+ if (!config.customerToken) return;
400
+ setLoading(true);
401
+ try {
402
+ const token = await getToken();
403
+ const url = new URL(`${apiBase}/api/v1/sessions`);
404
+ url.searchParams.set("customerToken", config.customerToken);
405
+ const res = await fetch(url.toString(), {
406
+ headers: { Authorization: `Bearer ${token}` }
407
+ });
408
+ if (!res.ok) return;
409
+ const data = await res.json();
410
+ const list = Array.isArray(data) ? data : data?.data ?? data?.sessions ?? [];
411
+ setSessions(list);
412
+ } catch {
413
+ } finally {
414
+ setLoading(false);
415
+ }
416
+ }, [apiBase, getToken, config.customerToken]);
417
+ useEffect(() => {
418
+ void fetchSessions();
419
+ }, [fetchSessions]);
420
+ const loadSession = useCallback(
421
+ async (sessionId) => {
422
+ try {
423
+ const token = await getToken();
424
+ const res = await fetch(`${apiBase}/api/v1/sessions/${sessionId}/turns`, {
425
+ headers: { Authorization: `Bearer ${token}` }
426
+ });
427
+ if (!res.ok) return;
428
+ const data = await res.json();
429
+ const raw = data?.data?.turns ?? data?.turns ?? [];
430
+ const turns = raw.flatMap((t) => [
431
+ { role: "user", content: t.query },
432
+ { role: "assistant", content: t.response }
433
+ ]);
434
+ activeSessionIdRef.current = sessionId;
435
+ setTurns(turns);
436
+ } catch {
437
+ }
438
+ },
439
+ [apiBase, getToken, setTurns, activeSessionIdRef]
440
+ );
441
+ return { sessions, loading, loadSession, refresh: fetchSessions };
442
+ }
379
443
  function cn(...inputs) {
380
444
  return twMerge(clsx(inputs));
381
445
  }
@@ -415,6 +479,28 @@ function PanaceaFAB({ className }) {
415
479
  }
416
480
  );
417
481
  }
482
+ function renderContent(text) {
483
+ const parts = text.split(/(\[\[[^\]]+\]\])/g);
484
+ return parts.map((part, i) => {
485
+ const m = part.match(/^\[\[(?:([^|\]]+)\|)?([^\]]+)\]\]$/);
486
+ if (m) {
487
+ const label = (m[1] ?? m[2] ?? part).trim();
488
+ return /* @__PURE__ */ jsxs(
489
+ "span",
490
+ {
491
+ className: "inline-flex items-center gap-0.5 rounded bg-blue-50 px-1 py-0.5 text-xs font-medium text-blue-700",
492
+ children: [
493
+ /* @__PURE__ */ jsx("svg", { className: "inline size-3 shrink-0", viewBox: "0 0 16 16", fill: "currentColor", children: /* @__PURE__ */ jsx("path", { d: "M2 2h5v2H4v8h8v-3h2v5H2V2zm7 0h5v5h-2V4.414L6.707 9.707 5.293 8.293 10.586 3H9V2z" }) }),
494
+ label
495
+ ]
496
+ },
497
+ i
498
+ );
499
+ }
500
+ if (!part) return null;
501
+ return /* @__PURE__ */ jsx("span", { children: part }, i);
502
+ }).filter(Boolean);
503
+ }
418
504
  function Bubble({ turn }) {
419
505
  const isUser = turn.role === "user";
420
506
  const isAgent = turn.isLiveAgent;
@@ -427,7 +513,7 @@ function Bubble({ turn }) {
427
513
  "max-w-[82%] rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed",
428
514
  isUser ? "rounded-tr-sm bg-primary text-primary-foreground" : isAgent ? "rounded-tl-sm bg-blue-100 text-blue-900" : "rounded-tl-sm bg-muted text-foreground"
429
515
  ),
430
- children: turn.content
516
+ children: isUser ? turn.content : renderContent(turn.content)
431
517
  }
432
518
  )
433
519
  ] });
@@ -535,7 +621,81 @@ function PanaceaChat({
535
621
  )
536
622
  ] });
537
623
  }
624
+ function formatDate(iso) {
625
+ try {
626
+ return new Intl.DateTimeFormat(void 0, {
627
+ month: "short",
628
+ day: "numeric",
629
+ hour: "2-digit",
630
+ minute: "2-digit"
631
+ }).format(new Date(iso));
632
+ } catch {
633
+ return iso;
634
+ }
635
+ }
636
+ function PanaceaHistory({ onSessionLoaded, className }) {
637
+ const { config } = usePanaceaContext();
638
+ const { sessions, loading, loadSession } = useSessionHistory();
639
+ const [active, setActive] = useState(null);
640
+ const [loadingId, setLoadingId] = useState(null);
641
+ if (!config.customerToken) {
642
+ return /* @__PURE__ */ jsx("div", { className: cn("flex flex-col items-center justify-center gap-2 py-8 text-center text-sm text-muted-foreground", className), children: /* @__PURE__ */ jsx("span", { children: "Sign in to see your conversation history." }) });
643
+ }
644
+ if (loading) {
645
+ return /* @__PURE__ */ jsx("div", { className: cn("flex flex-col gap-2 p-3", className), children: [1, 2, 3].map((i) => /* @__PURE__ */ jsx(
646
+ "div",
647
+ {
648
+ className: "h-14 animate-pulse rounded-lg bg-muted"
649
+ },
650
+ i
651
+ )) });
652
+ }
653
+ if (sessions.length === 0) {
654
+ return /* @__PURE__ */ jsx("div", { className: cn("flex flex-col items-center justify-center gap-2 py-8 text-center text-sm text-muted-foreground", className), children: /* @__PURE__ */ jsx("span", { children: "No previous conversations found." }) });
655
+ }
656
+ async function handleLoad(session) {
657
+ setLoadingId(session.sessionId);
658
+ await loadSession(session.sessionId);
659
+ setActive(session.sessionId);
660
+ setLoadingId(null);
661
+ onSessionLoaded?.(session.sessionId);
662
+ }
663
+ return /* @__PURE__ */ jsx("div", { className: cn("flex flex-col gap-1 overflow-y-auto p-2", className), children: sessions.map((session) => {
664
+ const isActive = active === session.sessionId;
665
+ const isLoading = loadingId === session.sessionId;
666
+ return /* @__PURE__ */ jsxs(
667
+ "button",
668
+ {
669
+ onClick: () => void handleLoad(session),
670
+ disabled: isLoading,
671
+ className: cn(
672
+ "flex w-full flex-col gap-0.5 rounded-lg px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent disabled:cursor-wait",
673
+ isActive && "bg-accent"
674
+ ),
675
+ children: [
676
+ /* @__PURE__ */ jsx("span", { className: "truncate font-medium leading-snug text-foreground", children: session.preview ?? "Conversation" }),
677
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
678
+ /* @__PURE__ */ jsx("span", { children: formatDate(session.createdAt) }),
679
+ session.turnCount !== void 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
680
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, children: "\xB7" }),
681
+ /* @__PURE__ */ jsxs("span", { children: [
682
+ session.turnCount,
683
+ " ",
684
+ session.turnCount === 1 ? "message" : "messages"
685
+ ] })
686
+ ] }),
687
+ isLoading && /* @__PURE__ */ jsxs(Fragment, { children: [
688
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, children: "\xB7" }),
689
+ /* @__PURE__ */ jsx("span", { children: "Loading\u2026" })
690
+ ] })
691
+ ] })
692
+ ]
693
+ },
694
+ session.sessionId
695
+ );
696
+ }) });
697
+ }
538
698
 
539
- export { PanaceaChat, PanaceaFAB, PanaceaInput, PanaceaMessages, PanaceaProvider, useChat, useLiveSession, useWidget };
699
+ export { PanaceaChat, PanaceaFAB, PanaceaHistory, PanaceaInput, PanaceaMessages, PanaceaProvider, useChat, useLiveSession, useSessionHistory, useWidget };
540
700
  //# sourceMappingURL=index.js.map
541
701
  //# sourceMappingURL=index.js.map