@sensiblestats/widget-react 0.37.3 → 0.38.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/dist/index.js CHANGED
@@ -20,6 +20,7 @@ var STRINGS = {
20
20
  launcherLabel: "Chat with SensibleStats",
21
21
  placeholder: "Ask about a team, player or match\u2026",
22
22
  closeChat: "Close chat",
23
+ newConversation: "New conversation",
23
24
  chatDialogLabel: "SensibleStats chat",
24
25
  stopResponse: "Stop response",
25
26
  sendMessage: "Send message",
@@ -92,6 +93,7 @@ var STRINGS = {
92
93
  launcherLabel: "SensibleStats ile sohbet et",
93
94
  placeholder: "Bir tak\u0131m, oyuncu veya ma\xE7 hakk\u0131nda sor\u2026",
94
95
  closeChat: "Sohbeti kapat",
96
+ newConversation: "Yeni sohbet",
95
97
  chatDialogLabel: "SensibleStats sohbet",
96
98
  stopResponse: "Yan\u0131t\u0131 durdur",
97
99
  sendMessage: "Mesaj g\xF6nder",
@@ -187,6 +189,7 @@ function normalizeUi(cfg) {
187
189
  starters: cfg.starters ?? [],
188
190
  injectStyles: cfg.injectStyles ?? true,
189
191
  closeChat: strings.closeChat,
192
+ newConversation: strings.newConversation,
190
193
  chatDialogLabel: strings.chatDialogLabel,
191
194
  stopResponse: strings.stopResponse,
192
195
  sendMessage: strings.sendMessage,
@@ -605,7 +608,7 @@ function marketOddsRowToSlipSelection(vm, row, conversationId) {
605
608
  // src/reducer.ts
606
609
  var POPUP_MAX = 160;
607
610
  function initialState(starters) {
608
- return { isOpen: false, messages: [], isStreaming: false, status: null, actionsLoading: false, starters, error: null, lastUserInput: null, lastDisplayText: null, popupText: null, conversationId: null };
611
+ return { isOpen: false, messages: [], isStreaming: false, status: null, actionsLoading: false, starters, configuredStarters: starters, error: null, lastUserInput: null, lastDisplayText: null, popupText: null, conversationId: null };
609
612
  }
610
613
  function activeTurn(state) {
611
614
  const last = state.messages[state.messages.length - 1];
@@ -685,6 +688,19 @@ function reducer(state, action) {
685
688
  }
686
689
  case "DISMISS_POPUP":
687
690
  return { ...state, popupText: null };
691
+ case "RESET":
692
+ return { ...initialState(state.configuredStarters), isOpen: state.isOpen };
693
+ case "RESTORE":
694
+ return {
695
+ ...state,
696
+ messages: action.messages,
697
+ conversationId: action.conversationId,
698
+ isStreaming: false,
699
+ status: null,
700
+ actionsLoading: false,
701
+ starters: action.messages.length ? [] : state.configuredStarters,
702
+ error: null
703
+ };
688
704
  case "EVENT":
689
705
  return applyEvent(state, action.event);
690
706
  case "STREAM_ERROR":
@@ -804,17 +820,82 @@ function useLiveDashboardPoll({ messages, dispatch, getLiveDashboard }) {
804
820
  }, [anchorKey, messages, forceUpdate]);
805
821
  }
806
822
 
823
+ // src/conversationStore.ts
824
+ var TTL_MS = 36e5;
825
+ var MAX_MESSAGES = 30;
826
+ function readConversation(storageKey, now) {
827
+ let raw;
828
+ try {
829
+ raw = window.localStorage.getItem(storageKey);
830
+ } catch {
831
+ return null;
832
+ }
833
+ if (!raw) return null;
834
+ let parsed;
835
+ try {
836
+ parsed = JSON.parse(raw);
837
+ } catch {
838
+ return "corrupt";
839
+ }
840
+ if (parsed?.v !== 1 || !Array.isArray(parsed.messages) || typeof parsed.updatedAt !== "number") return "corrupt";
841
+ if (now - parsed.updatedAt > TTL_MS) return "expired";
842
+ return { conversationId: parsed.conversationId ?? null, messages: parsed.messages };
843
+ }
844
+ function writeConversation(storageKey, conversationId, messages, now) {
845
+ let capped = messages.slice(-MAX_MESSAGES);
846
+ for (let attempt = 0; attempt < 2; attempt++) {
847
+ try {
848
+ window.localStorage.setItem(storageKey, JSON.stringify({ v: 1, updatedAt: now, conversationId, messages: capped }));
849
+ return;
850
+ } catch {
851
+ if (capped.length <= 1) return;
852
+ capped = capped.slice(Math.ceil(capped.length / 2));
853
+ }
854
+ }
855
+ }
856
+ function clearConversation(storageKey) {
857
+ try {
858
+ window.localStorage.removeItem(storageKey);
859
+ } catch {
860
+ }
861
+ }
862
+
807
863
  // src/useChatStream.ts
864
+ function hasAnswerContent(t) {
865
+ return !!t.text || t.cards.length > 0 || t.insights.length > 0 || t.marketOdds.length > 0 || t.oddsMovement.length > 0 || !!t.greetingDashboard || !!t.clubFanDashboard;
866
+ }
808
867
  var STREAM_FALLBACK_TIMEOUT_MS = 3e4;
809
- function useChatStream(conversation, starterTexts, greetingMessage) {
810
- const seed = useMemo(() => initialState(starterTexts.map(chipFromText)), []);
811
- const [state, dispatch] = useReducer2(reducer, seed);
812
- const idRef = useRef2(0);
868
+ function seedIdCounter(messages) {
869
+ let max = -1;
870
+ for (const m of messages) {
871
+ const n = Number(m.id.slice(1));
872
+ if (m.id[0] === "m" && Number.isFinite(n) && n > max) max = n;
873
+ }
874
+ return max + 1;
875
+ }
876
+ function useChatStream(conversation, starterTexts, greetingMessage, storageKey) {
877
+ const boot = useMemo(() => {
878
+ const payload = storageKey ? readConversation(storageKey, Date.now()) : null;
879
+ const restored = payload != null && payload !== "expired" && payload !== "corrupt";
880
+ const base = initialState(starterTexts.map(chipFromText));
881
+ if (restored) {
882
+ return { state: { ...base, messages: payload.messages, conversationId: payload.conversationId, starters: [] }, restored: true, stale: false };
883
+ }
884
+ return { state: base, restored: false, stale: payload === "expired" || payload === "corrupt" };
885
+ }, []);
886
+ const [state, dispatch] = useReducer2(reducer, boot.state);
887
+ const idRef = useRef2(seedIdCounter(boot.state.messages));
813
888
  const handleRef = useRef2(null);
814
889
  const fallbackTimerRef = useRef2(null);
815
890
  const stateRef = useRef2(state);
816
891
  stateRef.current = state;
817
- const greetedRef = useRef2(false);
892
+ const greetedRef = useRef2(boot.restored);
893
+ const hydratedRef = useRef2(false);
894
+ const restoreSeededRef = useRef2(false);
895
+ if (!restoreSeededRef.current) {
896
+ restoreSeededRef.current = true;
897
+ if (boot.restored && boot.state.conversationId) conversation.conversationId = boot.state.conversationId;
898
+ }
818
899
  const nextId = () => `m${idRef.current++}`;
819
900
  const clearFallbackTimer = useCallback(() => {
820
901
  if (fallbackTimerRef.current != null) {
@@ -870,10 +951,9 @@ function useChatStream(conversation, starterTexts, greetingMessage) {
870
951
  const last = stateRef.current.lastUserInput;
871
952
  if (last) run(last, stateRef.current.lastDisplayText ?? void 0);
872
953
  }, [run]);
873
- useEffect2(() => {
954
+ const fireGreeting = useCallback(() => {
874
955
  const message = greetingMessage?.trim();
875
- if (!message || greetedRef.current) return;
876
- greetedRef.current = true;
956
+ if (!message) return;
877
957
  try {
878
958
  handleRef.current?.cancel();
879
959
  clearFallbackTimer();
@@ -886,6 +966,55 @@ function useChatStream(conversation, starterTexts, greetingMessage) {
886
966
  dispatch({ kind: "STOP" });
887
967
  }
888
968
  }, [greetingMessage, conversation, consume, clearFallbackTimer, armFallbackTimer]);
969
+ useEffect2(() => {
970
+ if (greetedRef.current) return;
971
+ greetedRef.current = true;
972
+ fireGreeting();
973
+ }, [fireGreeting]);
974
+ useEffect2(() => {
975
+ if (boot.stale && storageKey) clearConversation(storageKey);
976
+ }, []);
977
+ useEffect2(() => {
978
+ if (!storageKey || state.isStreaming) return;
979
+ if (hydratedRef.current) {
980
+ hydratedRef.current = false;
981
+ return;
982
+ }
983
+ const msgs = state.messages;
984
+ const persistable = msgs.filter((m, i) => {
985
+ if (m.role === "user") {
986
+ const next = msgs[i + 1];
987
+ return !!next && next.role === "assistant" && next.done && !next.isGreeting && hasAnswerContent(next);
988
+ }
989
+ return m.done && !m.isGreeting && hasAnswerContent(m);
990
+ });
991
+ if (persistable.length === 0) return;
992
+ writeConversation(storageKey, state.conversationId, persistable, Date.now());
993
+ }, [storageKey, state.isStreaming, state.messages, state.conversationId]);
994
+ useEffect2(() => {
995
+ if (!storageKey) return;
996
+ const onStorage = (e) => {
997
+ if (e.key !== storageKey || e.storageArea !== window.localStorage) return;
998
+ if (stateRef.current.isStreaming) return;
999
+ if (e.newValue == null) {
1000
+ idRef.current = 0;
1001
+ conversation.conversationId = void 0;
1002
+ hydratedRef.current = true;
1003
+ dispatch({ kind: "RESET" });
1004
+ greetedRef.current = true;
1005
+ fireGreeting();
1006
+ return;
1007
+ }
1008
+ const parsed = readConversation(storageKey, Date.now());
1009
+ if (!parsed || parsed === "expired" || parsed === "corrupt") return;
1010
+ idRef.current = seedIdCounter(parsed.messages);
1011
+ conversation.conversationId = parsed.conversationId ?? void 0;
1012
+ hydratedRef.current = true;
1013
+ dispatch({ kind: "RESTORE", messages: parsed.messages, conversationId: parsed.conversationId });
1014
+ };
1015
+ window.addEventListener("storage", onStorage);
1016
+ return () => window.removeEventListener("storage", onStorage);
1017
+ }, [storageKey, conversation, fireGreeting]);
889
1018
  const open = useCallback(() => dispatch({ kind: "OPEN" }), []);
890
1019
  const close = useCallback(() => {
891
1020
  handleRef.current?.cancel();
@@ -904,9 +1033,20 @@ function useChatStream(conversation, starterTexts, greetingMessage) {
904
1033
  else open();
905
1034
  }, [close, open]);
906
1035
  const dismissPopup = useCallback(() => dispatch({ kind: "DISMISS_POPUP" }), []);
1036
+ const reset = useCallback(() => {
1037
+ handleRef.current?.cancel();
1038
+ handleRef.current = null;
1039
+ clearFallbackTimer();
1040
+ if (storageKey) clearConversation(storageKey);
1041
+ idRef.current = 0;
1042
+ conversation.conversationId = void 0;
1043
+ dispatch({ kind: "RESET" });
1044
+ greetedRef.current = true;
1045
+ fireGreeting();
1046
+ }, [storageKey, conversation, clearFallbackTimer, fireGreeting]);
907
1047
  const getLiveDashboard = useCallback(() => conversation.getLiveDashboard(), [conversation]);
908
1048
  useLiveDashboardPoll({ messages: state.messages, dispatch, getLiveDashboard });
909
- return { state, open, close, stop, toggle, send, retry, dismissPopup };
1049
+ return { state, open, close, stop, toggle, send, retry, dismissPopup, reset };
910
1050
  }
911
1051
 
912
1052
  // src/useLiveMonitor.ts
@@ -1251,6 +1391,9 @@ function CloseIcon() {
1251
1391
  function ChevronIcon() {
1252
1392
  return /* @__PURE__ */ jsx("svg", { viewBox: "0 0 24 24", width: "1em", height: "1em", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", d: "M9 5l7 7-7 7" }) });
1253
1393
  }
1394
+ function NewChatIcon() {
1395
+ return /* @__PURE__ */ jsx("svg", { viewBox: "0 0 24 24", width: "1em", height: "1em", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { fill: "none", d: "M12 5v14M5 12h14", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }) });
1396
+ }
1254
1397
  var DASHBOARD_EMOJI = {
1255
1398
  football: "\u26BD",
1256
1399
  "goal-net": "\u{1F945}",
@@ -1319,10 +1462,11 @@ import { useEffect as useEffect9, useState as useState17 } from "react";
1319
1462
 
1320
1463
  // src/components/ChatHeader.tsx
1321
1464
  import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
1322
- function ChatHeader({ onClose, closeLabel = "Close chat", logoUrl, headerTitle }) {
1465
+ function ChatHeader({ onClose, closeLabel = "Close chat", logoUrl, headerTitle, onNewConversation, canReset, newConversationLabel = "New conversation" }) {
1323
1466
  return /* @__PURE__ */ jsxs3("header", { className: "ss-w-header", children: [
1324
1467
  /* @__PURE__ */ jsx5(BrandMark, { logoUrl, className: "ss-w-header-mark" }),
1325
1468
  /* @__PURE__ */ jsx5("span", { className: "ss-w-header-title", children: headerTitle ?? "SensibleStats" }),
1469
+ canReset && onNewConversation ? /* @__PURE__ */ jsx5("button", { type: "button", className: "ss-w-newconv", "aria-label": newConversationLabel, onClick: onNewConversation, children: /* @__PURE__ */ jsx5(NewChatIcon, {}) }) : null,
1326
1470
  /* @__PURE__ */ jsx5("button", { type: "button", className: "ss-w-close", "aria-label": closeLabel, onClick: onClose, children: /* @__PURE__ */ jsx5(CloseIcon, {}) })
1327
1471
  ] });
1328
1472
  }
@@ -3194,7 +3338,7 @@ function useIsModal() {
3194
3338
  return modal;
3195
3339
  }
3196
3340
  function ChatPanel({ controller, ui, slip, onDashboardTap, monitor }) {
3197
- const { state, close, stop, send } = controller;
3341
+ const { state, close, stop, send, reset } = controller;
3198
3342
  const isModal = useIsModal();
3199
3343
  useEffect9(() => {
3200
3344
  const onKey = (e) => {
@@ -3211,7 +3355,18 @@ function ChatPanel({ controller, ui, slip, onDashboardTap, monitor }) {
3211
3355
  if (monitor && monitor.activeTab !== "chat" && state.isStreaming) monitor.markChatActivity();
3212
3356
  }, [monitor, state.isStreaming, state.messages.length]);
3213
3357
  return /* @__PURE__ */ jsxs43("div", { className: "ss-w-panel", role: "dialog", "aria-modal": isModal, "aria-label": ui.chatDialogLabel, children: [
3214
- /* @__PURE__ */ jsx49(ChatHeader, { onClose: close, closeLabel: ui.closeChat, logoUrl: ui.logoUrl, headerTitle: ui.headerTitle }),
3358
+ /* @__PURE__ */ jsx49(
3359
+ ChatHeader,
3360
+ {
3361
+ onClose: close,
3362
+ closeLabel: ui.closeChat,
3363
+ logoUrl: ui.logoUrl,
3364
+ headerTitle: ui.headerTitle,
3365
+ onNewConversation: reset,
3366
+ canReset: state.messages.length > 0,
3367
+ newConversationLabel: ui.newConversation
3368
+ }
3369
+ ),
3215
3370
  monitor && /* @__PURE__ */ jsx49(
3216
3371
  TabBar,
3217
3372
  {
@@ -3326,8 +3481,9 @@ var WIDGET_CSS = `.ss-w-root { all: revert; }
3326
3481
  }
3327
3482
  .ss-w-header { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--ss-w-line); }
3328
3483
  .ss-w-header-mark { width: 18px; height: 18px; display: block; object-fit: contain; }
3329
- .ss-w-header-title { font-weight: 660; }
3330
- .ss-w-close { margin-left: auto; background: none; border: none; color: var(--ss-w-faint); cursor: pointer; font-size: 16px; display: grid; place-items: center; }
3484
+ .ss-w-header-title { font-weight: 660; flex: 1; }
3485
+ .ss-w-newconv { background: none; border: none; color: var(--ss-w-faint); cursor: pointer; font-size: 16px; display: grid; place-items: center; }
3486
+ .ss-w-close { background: none; border: none; color: var(--ss-w-faint); cursor: pointer; font-size: 16px; display: grid; place-items: center; }
3331
3487
 
3332
3488
  /* messages */
3333
3489
  /* .ss-w-mon-list (Matches/Odds tab scrollers) shares .ss-w-list's box/scroll rules by design --
@@ -4074,7 +4230,8 @@ function StatsWidget(props) {
4074
4230
  const markIntroDone = useCallback5(() => {
4075
4231
  introDoneRef.current = true;
4076
4232
  }, []);
4077
- const controller = useChatStream(built?.conversation ?? emptyConversation(), ui.starters, built ? ui.greetingMessage : void 0);
4233
+ const convStorageKey = `ss-w-conv:${cfg?.operatorId ?? ""}:${cfg?.userContext?.operatorUserId ?? "anon"}`;
4234
+ const controller = useChatStream(built?.conversation ?? emptyConversation(), ui.starters, built ? ui.greetingMessage : void 0, convStorageKey);
4078
4235
  const monitor = useLiveMonitor({
4079
4236
  getLiveMonitor: (pins) => built ? built.conversation.getLiveMonitor(pins) : Promise.resolve(null),
4080
4237
  getMarketOdds: (matchId, dev) => built ? built.conversation.getMarketOdds(matchId, dev) : Promise.resolve(null),