@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 +7 -7
- package/src/react/chat-component.tsx +155 -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;
|
|
@@ -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
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
|
|
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
|
-
|
|
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(
|