@arcote.tech/arc-chat 0.7.18 → 0.7.20

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-chat",
3
3
  "type": "module",
4
- "version": "0.7.18",
4
+ "version": "0.7.20",
5
5
  "private": false,
6
6
  "description": "Chat module with AI integration for Arc framework",
7
7
  "main": "./src/index.ts",
@@ -10,12 +10,12 @@
10
10
  "type-check": "tsc --noEmit"
11
11
  },
12
12
  "peerDependencies": {
13
- "@arcote.tech/arc": "^0.7.18",
14
- "@arcote.tech/arc-ai": "^0.7.18",
15
- "@arcote.tech/arc-ai-voice": "^0.7.18",
16
- "@arcote.tech/arc-auth": "^0.7.18",
17
- "@arcote.tech/arc-ds": "^0.7.18",
18
- "@arcote.tech/platform": "^0.7.18",
13
+ "@arcote.tech/arc": "^0.7.20",
14
+ "@arcote.tech/arc-ai": "^0.7.20",
15
+ "@arcote.tech/arc-ai-voice": "^0.7.20",
16
+ "@arcote.tech/arc-auth": "^0.7.20",
17
+ "@arcote.tech/arc-ds": "^0.7.20",
18
+ "@arcote.tech/platform": "^0.7.20",
19
19
  "lucide-react": ">=0.400.0",
20
20
  "react": ">=18.0.0",
21
21
  "typescript": "^5.0.0"
@@ -116,11 +116,21 @@ export function createChatComponent(
116
116
  * `historySig` already changed (during streaming) and won't change again.
117
117
  * See the watchdog effect below. */
118
118
  const [reconcileNonce, setReconcileNonce] = useState(0);
119
+ /** Bumped by the visibility / heartbeat handlers to force the SSE useEffect
120
+ * to re-fire (abort the dead reader and re-fetch with the same
121
+ * `messageId`). Backend's `FINALIZE_GRACE_MS` (5 s) + `init` snapshot
122
+ * recovers the in-progress state; 410 → existing retry/interrupted path. */
123
+ const [streamReopenNonce, setStreamReopenNonce] = useState(0);
119
124
  /** AbortController of the currently-open SSE stream, exposed via ref so the
120
125
  * watchdog can tear it down when the DB says the turn is already done. */
121
126
  const sseCtrlRef = useRef<AbortController | null>(null);
122
127
  /** Last SSE event type seen (for stuck telemetry — was `done` ever sent?). */
123
128
  const lastSseEventRef = useRef<string | null>(null);
129
+ /** Timestamp (performance.now()) of the last SSE byte received — set on
130
+ * every `reader.read()` resolve (data + keepalive ping). Drives the
131
+ * client-side heartbeat watchdog: if nothing arrived for >2× the server
132
+ * keepalive interval, treat the socket as dead and reconnect. */
133
+ const lastSseByteAtRef = useRef<number | null>(null);
124
134
  /** When the (isStreaming=true ∧ DB-idle) divergence first appeared — for
125
135
  * the debounce window and the `waitedMs` stuck attribute. */
126
136
  const stuckSinceRef = useRef<number | null>(null);
@@ -325,11 +335,19 @@ export function createChatComponent(
325
335
  const since = stuckSinceRef.current;
326
336
  const RECONCILE_DEBOUNCE_MS = 200;
327
337
  const timer = setTimeout(() => {
338
+ const lastByteAt = lastSseByteAtRef.current;
328
339
  reportReconcileStuck({
329
340
  scopeId,
330
341
  chatName,
331
342
  lastSseEvent: lastSseEventRef.current,
332
343
  waitedMs: Date.now() - since,
344
+ visibilityState:
345
+ typeof document !== "undefined" ? document.visibilityState : null,
346
+ hidden: typeof document !== "undefined" ? document.hidden : null,
347
+ msSinceLastSseByte:
348
+ lastByteAt !== null && typeof performance !== "undefined"
349
+ ? Math.round(performance.now() - lastByteAt)
350
+ : null,
333
351
  });
334
352
  sseCtrlRef.current?.abort();
335
353
  sseCtrlRef.current = null;
@@ -340,6 +358,47 @@ export function createChatComponent(
340
358
  return () => clearTimeout(timer);
341
359
  }, [isStreaming, activeGeneratingMessageId, scopeId]);
342
360
 
361
+ // ─── Backstop: merge interactive-tool answers regardless of isStreaming ──
362
+ // Answering an interactive tool (askQuestions) creates a `tool_result` row
363
+ // AND starts a new assistant turn — so `isStreaming` flips back to true and
364
+ // the global rebuild guard freezes the whole timeline. Without this, the
365
+ // answered question stays in input-view ("Odpowiedz na pytania" + the
366
+ // questions) instead of the answer-view, until that next turn ends — and on
367
+ // prod its SSE `done` is often lost (the turn finalizes after the DB
368
+ // projection that nulls `activeGeneratingMessageId` aborts the stream), so
369
+ // it never catches up. This flips any `calling` tool block to answer-view
370
+ // the moment its `tool_result` lands in the DB, bypassing the guard.
371
+ useEffect(() => {
372
+ if (!historyData) return;
373
+ const resultMap = new Map<string, { content: string; isError?: boolean }>();
374
+ for (const msg of historyData) {
375
+ if (msg.role === "tool_result" && msg.toolCallId) {
376
+ resultMap.set(msg.toolCallId, {
377
+ content: msg.content,
378
+ isError: msg.isError,
379
+ });
380
+ }
381
+ }
382
+ if (resultMap.size === 0) return;
383
+ setTimeline((prev) => {
384
+ let changed = false;
385
+ const next = prev.map((t) => {
386
+ if (t.type !== "tool" || !t.calling) return t;
387
+ const r = resultMap.get(t.toolCallId);
388
+ if (!r) return t;
389
+ changed = true;
390
+ return {
391
+ ...t,
392
+ calling: false,
393
+ status: (r.isError ? "error" : "complete") as ToolStatus,
394
+ result: tryParseJson(r.content),
395
+ error: r.isError ? r.content : undefined,
396
+ };
397
+ });
398
+ return changed ? next : prev;
399
+ });
400
+ }, [historySig]);
401
+
343
402
  // ─── SSE event processing ───────────────────────────────────
344
403
  const processEventRef = useRef<((event: ChatStreamEvent) => void) | null>(null);
345
404
  const processEvent = useCallback(
@@ -680,9 +739,17 @@ export function createChatComponent(
680
739
  const reader = res.body!.getReader();
681
740
  const decoder = new TextDecoder();
682
741
  let buf = "";
742
+ lastSseByteAtRef.current =
743
+ typeof performance !== "undefined" ? performance.now() : 0;
683
744
  while (!cancelled) {
684
745
  const { value, done } = await reader.read();
685
746
  if (done) break;
747
+ // Heartbeat tick — kept on every read (even keepalive ` : ping`
748
+ // bytes that don't produce events). Drives the watchdog effect
749
+ // below: if this stops ticking for >2× the server keepalive, we
750
+ // assume the socket is dead and reopen.
751
+ lastSseByteAtRef.current =
752
+ typeof performance !== "undefined" ? performance.now() : 0;
686
753
  buf += decoder.decode(value, { stream: true });
687
754
  const lines = buf.split("\n");
688
755
  buf = lines.pop() ?? "";
@@ -691,10 +758,14 @@ export function createChatComponent(
691
758
  try {
692
759
  const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
693
760
  processEventRef.current?.(event);
694
- // Yield between events in the same TCP chunk — bez tego
695
- // React batchuje setTimeline w jeden render, streaming
696
- // niewidoczny.
697
- await new Promise<void>((r) => setTimeout(r, 0));
761
+ // Yield między eventami żeby React nie batchował setTimeline
762
+ // w jeden render (streaming niewidoczny). Używamy
763
+ // queueMicrotask, NIE setTimeout(0): w karcie w tle Chrome
764
+ // throttluje timeouts do ≥1 s, więc setTimeout(0) zamienia
765
+ // pętlę w 1-event/s freeze i po powrocie buf jest zalany.
766
+ // Microtaski nie są throttlowane → pętla domyka turn nawet
767
+ // w tle, a w foreground React i tak renderuje co microtask.
768
+ await new Promise<void>((r) => queueMicrotask(r));
698
769
  } catch {}
699
770
  }
700
771
  }
@@ -710,8 +781,87 @@ export function createChatComponent(
710
781
  cancelled = true;
711
782
  ctrl.abort();
712
783
  if (sseCtrlRef.current === ctrl) sseCtrlRef.current = null;
784
+ lastSseByteAtRef.current = null;
785
+ };
786
+ // `streamReopenNonce`: tab-return / heartbeat-timeout forces re-fetch
787
+ // even though `activeGeneratingMessageId` didn't change. Backend's
788
+ // FINALIZE_GRACE_MS (5 s) + `init` snapshot recover state seamlessly.
789
+ }, [activeGeneratingMessageId, streamReopenNonce]);
790
+
791
+ // ─── Visibility recovery — tab return forces SSE reconnect ──────────
792
+ // Chrome/Safari freeze background tabs aggressively: setTimeout is clamped
793
+ // to ≥1 s, `reader.read()` can hang silently when the OS suspends the
794
+ // socket, and reactive-query WS updates may be missed. The net effect is
795
+ // a "zombie" turn after returning to the tab — caret stuck, `done` event
796
+ // dropped, missing tokens. We do not try to detect each failure mode;
797
+ // instead, every return-to-visible while we believe we're streaming
798
+ // tears down the (possibly dead) SSE and bumps `streamReopenNonce` so
799
+ // the SSE effect re-fires with the same messageId. The backend's
800
+ // FINALIZE_GRACE_MS window + `init` snapshot make this idempotent —
801
+ // worst case we get an immediate `done` and a clean teardown.
802
+ useEffect(() => {
803
+ if (typeof document === "undefined") return;
804
+ const onVisibility = () => {
805
+ if (document.visibilityState !== "visible") return;
806
+ if (!isStreaming) return;
807
+ chatDebug("visibility reconnect", {
808
+ activeGeneratingMessageId,
809
+ });
810
+ sseCtrlRef.current?.abort();
811
+ sseCtrlRef.current = null;
812
+ setStreamReopenNonce((n) => n + 1);
813
+ };
814
+ document.addEventListener("visibilitychange", onVisibility);
815
+ return () =>
816
+ document.removeEventListener("visibilitychange", onVisibility);
817
+ }, [isStreaming, activeGeneratingMessageId]);
818
+
819
+ // ─── BFCache restore (back/forward navigation) — force full reset ───
820
+ // `pageshow` with `persisted=true` fires when the doc is restored from
821
+ // BFCache. JS was paused, every socket is dead, and refs/state lie about
822
+ // current reality. Treat it like a fresh mount: abort, bump nonces,
823
+ // let the rebuild + SSE reopen run from scratch.
824
+ useEffect(() => {
825
+ if (typeof window === "undefined") return;
826
+ const onPageShow = (e: PageTransitionEvent) => {
827
+ if (!e.persisted) return;
828
+ chatDebug("bfcache restore");
829
+ sseCtrlRef.current?.abort();
830
+ sseCtrlRef.current = null;
831
+ setReconcileNonce((n) => n + 1);
832
+ if (isStreaming) setStreamReopenNonce((n) => n + 1);
713
833
  };
714
- }, [activeGeneratingMessageId]);
834
+ window.addEventListener("pageshow", onPageShow);
835
+ return () => window.removeEventListener("pageshow", onPageShow);
836
+ }, [isStreaming]);
837
+
838
+ // ─── Heartbeat watchdog — silent dead socket detector ───────────────
839
+ // Server sends a `: ping\n\n` keepalive every 5 s. If `reader.read()`
840
+ // doesn't resolve for >12 s, the socket is almost certainly dead
841
+ // (network glitch, server restart without a 410, or just the OS
842
+ // gracefully tearing down a long-idle connection). Reconnect — same
843
+ // path as the visibility handler. `setInterval` is clamped in
844
+ // background tabs, but the visibility handler already covers that
845
+ // case; this guards "tab active, socket silently dead".
846
+ useEffect(() => {
847
+ if (!isStreaming) return;
848
+ if (typeof performance === "undefined") return;
849
+ const HEARTBEAT_TIMEOUT_MS = 12_000; // 2× server KEEPALIVE_INTERVAL_MS + slack
850
+ const id = setInterval(() => {
851
+ const last = lastSseByteAtRef.current;
852
+ if (last === null) return;
853
+ const elapsed = performance.now() - last;
854
+ if (elapsed < HEARTBEAT_TIMEOUT_MS) return;
855
+ if (typeof document !== "undefined" && document.hidden) return;
856
+ chatDebug("heartbeat timeout — reconnect", {
857
+ msSinceLastByte: Math.round(elapsed),
858
+ });
859
+ sseCtrlRef.current?.abort();
860
+ sseCtrlRef.current = null;
861
+ setStreamReopenNonce((n) => n + 1);
862
+ }, 3_000);
863
+ return () => clearInterval(id);
864
+ }, [isStreaming]);
715
865
 
716
866
  // ─── Send message ───────────────────────────────────────────
717
867
  const handleSend = useCallback(