@arcote.tech/arc-chat 0.7.17 → 0.7.18
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 +102 -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.18",
|
|
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.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",
|
|
19
19
|
"lucide-react": ">=0.400.0",
|
|
20
20
|
"react": ">=18.0.0",
|
|
21
21
|
"typescript": "^5.0.0"
|
|
@@ -9,6 +9,33 @@ import type {
|
|
|
9
9
|
import { Chat, ChatMessage, ChatInputProvider, ChatLabelsProvider, ChatToolLog, useChatLabels } from "@arcote.tech/arc-ds";
|
|
10
10
|
import { VoiceTextarea } from "@arcote.tech/arc-ai-voice";
|
|
11
11
|
|
|
12
|
+
// ─── Debug / instrumentation ─────────────────────────────────────────────
|
|
13
|
+
// Production-only chat hang: SSE `isStreaming` and DB `isGenerating` can
|
|
14
|
+
// desync (separate channels, prod latency). We log when the reconcile
|
|
15
|
+
// watchdog has to force-close a turn the DB already finished. Kept dependency-
|
|
16
|
+
// free (no OTel import — this fragment is src-only and bundles into any app):
|
|
17
|
+
// the host app can pipe these to its telemetry via `globalThis.__arcChatOnStuck`.
|
|
18
|
+
function reportReconcileStuck(attrs: Record<string, unknown>): void {
|
|
19
|
+
// eslint-disable-next-line no-console
|
|
20
|
+
console.warn("[arc-chat][reconcile_stuck]", attrs);
|
|
21
|
+
try {
|
|
22
|
+
(globalThis as any).__arcChatOnStuck?.(attrs);
|
|
23
|
+
} catch {
|
|
24
|
+
/* reporter must never break the chat */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Verbose per-message timing, gated behind `globalThis.__ARC_CHAT_DEBUG`.
|
|
29
|
+
* Used to see the SSE-vs-DB channel skew (who arrives first, delta-t). */
|
|
30
|
+
function chatDebug(...args: unknown[]): void {
|
|
31
|
+
if ((globalThis as any).__ARC_CHAT_DEBUG) {
|
|
32
|
+
const ts =
|
|
33
|
+
typeof performance !== "undefined" ? performance.now().toFixed(0) : "";
|
|
34
|
+
// eslint-disable-next-line no-console
|
|
35
|
+
console.debug(`[arc-chat +${ts}ms]`, ...args);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
12
39
|
interface ChatComponentConfig {
|
|
13
40
|
chatName: string;
|
|
14
41
|
tools: ArcToolAny[];
|
|
@@ -85,6 +112,18 @@ export function createChatComponent(
|
|
|
85
112
|
/** Set messageIds dla których SSE zwrócił 410 (server restart mid-stream).
|
|
86
113
|
* Renderowane jako TimelineItem "interrupted" z retry button. */
|
|
87
114
|
const [interruptedIds, setInterruptedIds] = useState<Set<string>>(() => new Set());
|
|
115
|
+
/** Bumped by the reconcile watchdog to FORCE a timeline rebuild even when
|
|
116
|
+
* `historySig` already changed (during streaming) and won't change again.
|
|
117
|
+
* See the watchdog effect below. */
|
|
118
|
+
const [reconcileNonce, setReconcileNonce] = useState(0);
|
|
119
|
+
/** AbortController of the currently-open SSE stream, exposed via ref so the
|
|
120
|
+
* watchdog can tear it down when the DB says the turn is already done. */
|
|
121
|
+
const sseCtrlRef = useRef<AbortController | null>(null);
|
|
122
|
+
/** Last SSE event type seen (for stuck telemetry — was `done` ever sent?). */
|
|
123
|
+
const lastSseEventRef = useRef<string | null>(null);
|
|
124
|
+
/** When the (isStreaming=true ∧ DB-idle) divergence first appeared — for
|
|
125
|
+
* the debounce window and the `waitedMs` stuck attribute. */
|
|
126
|
+
const stuckSinceRef = useRef<number | null>(null);
|
|
88
127
|
|
|
89
128
|
const queries = scope.useQuery();
|
|
90
129
|
const mutations = scope.useMutation();
|
|
@@ -243,16 +282,70 @@ export function createChatComponent(
|
|
|
243
282
|
}
|
|
244
283
|
|
|
245
284
|
setTimeline(items);
|
|
246
|
-
//
|
|
247
|
-
// było w deps: po `done` SSE flips isStreaming=false →
|
|
248
|
-
// może zobaczyć
|
|
249
|
-
// assistantTurnCompleted) → reset do streaming
|
|
250
|
-
|
|
285
|
+
// Deps: `historySig` + `interruptedIds` + `reconcileNonce`. Wciąż BEZ
|
|
286
|
+
// `isStreaming` — gdyby było w deps: po `done` SSE flips isStreaming=false →
|
|
287
|
+
// refire → może zobaczyć STARĄ `isGenerating:1` (DB jeszcze nie
|
|
288
|
+
// zaprojektowała assistantTurnCompleted) → reset do streaming → caret nie
|
|
289
|
+
// znika. `reconcileNonce` (bumpowany przez watchdog) wymusza rebuild w
|
|
290
|
+
// odwrotnym race (query-update wyprzedził SSE `done` → historySig zmienił
|
|
291
|
+
// się PODCZAS isStreaming=true → rebuild pominięty guardem i bez nonce
|
|
292
|
+
// nigdy nie nadrobiony → produkcyjny hang).
|
|
293
|
+
}, [historySig, interruptedIds, reconcileNonce]);
|
|
294
|
+
|
|
295
|
+
// Verbose DB-side timing (gated) — paired with the SSE-event logs above,
|
|
296
|
+
// this shows the channel skew: who arrives first, and how far apart.
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
chatDebug("db update", {
|
|
299
|
+
count: historyLen,
|
|
300
|
+
activeGenerating: activeGeneratingMessageId,
|
|
301
|
+
});
|
|
302
|
+
}, [historySig, historyLen, activeGeneratingMessageId]);
|
|
303
|
+
|
|
304
|
+
// ─── Reconcile watchdog — DB is the arbiter of "is a turn running" ──
|
|
305
|
+
// Production hang: SSE `done` and the DB `assistantTurnCompleted` projection
|
|
306
|
+
// travel on separate channels (SSE vs WS query sync). When the query-update
|
|
307
|
+
// (`isGenerating→false`) wins the race while `isStreaming` is still true — or
|
|
308
|
+
// when `done` is lost entirely (410 exhaustion / server restart) — the
|
|
309
|
+
// rebuild guard skips and never retries, so the chat sticks in a streaming
|
|
310
|
+
// state until a page refresh.
|
|
311
|
+
//
|
|
312
|
+
// DB truth: `activeGeneratingMessageId === null` ⇒ no turn is generating. If
|
|
313
|
+
// we still believe we're streaming after a short debounce, force-close the
|
|
314
|
+
// turn: abort the (dead) SSE, drop `isStreaming`, and bump `reconcileNonce`
|
|
315
|
+
// so the rebuild runs with the final DB blocks. The debounce avoids
|
|
316
|
+
// flicker in the brief between-turns window (old turn done, next not started
|
|
317
|
+
// yet). No false fires during a real stream — the DB then holds
|
|
318
|
+
// `isGenerating:1`, so `activeGeneratingMessageId` is non-null.
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
if (!(isStreaming && activeGeneratingMessageId === null)) {
|
|
321
|
+
stuckSinceRef.current = null;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (stuckSinceRef.current === null) stuckSinceRef.current = Date.now();
|
|
325
|
+
const since = stuckSinceRef.current;
|
|
326
|
+
const RECONCILE_DEBOUNCE_MS = 200;
|
|
327
|
+
const timer = setTimeout(() => {
|
|
328
|
+
reportReconcileStuck({
|
|
329
|
+
scopeId,
|
|
330
|
+
chatName,
|
|
331
|
+
lastSseEvent: lastSseEventRef.current,
|
|
332
|
+
waitedMs: Date.now() - since,
|
|
333
|
+
});
|
|
334
|
+
sseCtrlRef.current?.abort();
|
|
335
|
+
sseCtrlRef.current = null;
|
|
336
|
+
stuckSinceRef.current = null;
|
|
337
|
+
setIsStreaming(false);
|
|
338
|
+
setReconcileNonce((n) => n + 1);
|
|
339
|
+
}, RECONCILE_DEBOUNCE_MS);
|
|
340
|
+
return () => clearTimeout(timer);
|
|
341
|
+
}, [isStreaming, activeGeneratingMessageId, scopeId]);
|
|
251
342
|
|
|
252
343
|
// ─── SSE event processing ───────────────────────────────────
|
|
253
344
|
const processEventRef = useRef<((event: ChatStreamEvent) => void) | null>(null);
|
|
254
345
|
const processEvent = useCallback(
|
|
255
346
|
(event: ChatStreamEvent) => {
|
|
347
|
+
lastSseEventRef.current = event.type;
|
|
348
|
+
chatDebug("sse event", event.type);
|
|
256
349
|
switch (event.type) {
|
|
257
350
|
case "init": {
|
|
258
351
|
// Pierwszy event po `subscribe(messageId)`. Niesie snapshot
|
|
@@ -549,8 +642,11 @@ export function createChatComponent(
|
|
|
549
642
|
if (!activeGeneratingMessageId) return;
|
|
550
643
|
const messageId = activeGeneratingMessageId;
|
|
551
644
|
const ctrl = new AbortController();
|
|
645
|
+
sseCtrlRef.current = ctrl;
|
|
646
|
+
lastSseEventRef.current = null;
|
|
552
647
|
let cancelled = false;
|
|
553
648
|
setIsStreaming(true);
|
|
649
|
+
chatDebug("sse open", { messageId });
|
|
554
650
|
|
|
555
651
|
(async () => {
|
|
556
652
|
try {
|
|
@@ -613,6 +709,7 @@ export function createChatComponent(
|
|
|
613
709
|
return () => {
|
|
614
710
|
cancelled = true;
|
|
615
711
|
ctrl.abort();
|
|
712
|
+
if (sseCtrlRef.current === ctrl) sseCtrlRef.current = null;
|
|
616
713
|
};
|
|
617
714
|
}, [activeGeneratingMessageId]);
|
|
618
715
|
|