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