@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-chat",
3
3
  "type": "module",
4
- "version": "0.7.17",
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.17",
14
- "@arcote.tech/arc-ai": "^0.7.17",
15
- "@arcote.tech/arc-ai-voice": "^0.7.17",
16
- "@arcote.tech/arc-auth": "^0.7.17",
17
- "@arcote.tech/arc-ds": "^0.7.17",
18
- "@arcote.tech/platform": "^0.7.17",
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
- // Dep TYLKO `historySig` + `interruptedIds`. Bez `isStreaming` gdyby
247
- // było w deps: po `done` SSE flips isStreaming=false → useEffect refires →
248
- // może zobaczyć STAREJ `isGenerating:1` row (DB jeszcze nie zaprojektowała
249
- // assistantTurnCompleted) → reset do streaming mode → caret nigdy nie znika.
250
- }, [historySig, interruptedIds]);
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