@arcote.tech/arc-chat 0.7.19 → 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 +7 -7
- package/src/react/chat-component.tsx +114 -5
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.
|
|
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.
|
|
14
|
-
"@arcote.tech/arc-ai": "^0.7.
|
|
15
|
-
"@arcote.tech/arc-ai-voice": "^0.7.
|
|
16
|
-
"@arcote.tech/arc-auth": "^0.7.
|
|
17
|
-
"@arcote.tech/arc-ds": "^0.7.
|
|
18
|
-
"@arcote.tech/platform": "^0.7.
|
|
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;
|
|
@@ -721,9 +739,17 @@ export function createChatComponent(
|
|
|
721
739
|
const reader = res.body!.getReader();
|
|
722
740
|
const decoder = new TextDecoder();
|
|
723
741
|
let buf = "";
|
|
742
|
+
lastSseByteAtRef.current =
|
|
743
|
+
typeof performance !== "undefined" ? performance.now() : 0;
|
|
724
744
|
while (!cancelled) {
|
|
725
745
|
const { value, done } = await reader.read();
|
|
726
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;
|
|
727
753
|
buf += decoder.decode(value, { stream: true });
|
|
728
754
|
const lines = buf.split("\n");
|
|
729
755
|
buf = lines.pop() ?? "";
|
|
@@ -732,10 +758,14 @@ export function createChatComponent(
|
|
|
732
758
|
try {
|
|
733
759
|
const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
|
|
734
760
|
processEventRef.current?.(event);
|
|
735
|
-
// Yield
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
|
|
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));
|
|
739
769
|
} catch {}
|
|
740
770
|
}
|
|
741
771
|
}
|
|
@@ -751,8 +781,87 @@ export function createChatComponent(
|
|
|
751
781
|
cancelled = true;
|
|
752
782
|
ctrl.abort();
|
|
753
783
|
if (sseCtrlRef.current === ctrl) sseCtrlRef.current = null;
|
|
784
|
+
lastSseByteAtRef.current = null;
|
|
754
785
|
};
|
|
755
|
-
|
|
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);
|
|
833
|
+
};
|
|
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]);
|
|
756
865
|
|
|
757
866
|
// ─── Send message ───────────────────────────────────────────
|
|
758
867
|
const handleSend = useCallback(
|