@arcote.tech/arc-chat 0.7.17 → 0.7.19
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 +143 -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.19",
|
|
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.19",
|
|
14
|
+
"@arcote.tech/arc-ai": "^0.7.19",
|
|
15
|
+
"@arcote.tech/arc-ai-voice": "^0.7.19",
|
|
16
|
+
"@arcote.tech/arc-auth": "^0.7.19",
|
|
17
|
+
"@arcote.tech/arc-ds": "^0.7.19",
|
|
18
|
+
"@arcote.tech/platform": "^0.7.19",
|
|
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,111 @@ 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]);
|
|
342
|
+
|
|
343
|
+
// ─── Backstop: merge interactive-tool answers regardless of isStreaming ──
|
|
344
|
+
// Answering an interactive tool (askQuestions) creates a `tool_result` row
|
|
345
|
+
// AND starts a new assistant turn — so `isStreaming` flips back to true and
|
|
346
|
+
// the global rebuild guard freezes the whole timeline. Without this, the
|
|
347
|
+
// answered question stays in input-view ("Odpowiedz na pytania" + the
|
|
348
|
+
// questions) instead of the answer-view, until that next turn ends — and on
|
|
349
|
+
// prod its SSE `done` is often lost (the turn finalizes after the DB
|
|
350
|
+
// projection that nulls `activeGeneratingMessageId` aborts the stream), so
|
|
351
|
+
// it never catches up. This flips any `calling` tool block to answer-view
|
|
352
|
+
// the moment its `tool_result` lands in the DB, bypassing the guard.
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
if (!historyData) return;
|
|
355
|
+
const resultMap = new Map<string, { content: string; isError?: boolean }>();
|
|
356
|
+
for (const msg of historyData) {
|
|
357
|
+
if (msg.role === "tool_result" && msg.toolCallId) {
|
|
358
|
+
resultMap.set(msg.toolCallId, {
|
|
359
|
+
content: msg.content,
|
|
360
|
+
isError: msg.isError,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (resultMap.size === 0) return;
|
|
365
|
+
setTimeline((prev) => {
|
|
366
|
+
let changed = false;
|
|
367
|
+
const next = prev.map((t) => {
|
|
368
|
+
if (t.type !== "tool" || !t.calling) return t;
|
|
369
|
+
const r = resultMap.get(t.toolCallId);
|
|
370
|
+
if (!r) return t;
|
|
371
|
+
changed = true;
|
|
372
|
+
return {
|
|
373
|
+
...t,
|
|
374
|
+
calling: false,
|
|
375
|
+
status: (r.isError ? "error" : "complete") as ToolStatus,
|
|
376
|
+
result: tryParseJson(r.content),
|
|
377
|
+
error: r.isError ? r.content : undefined,
|
|
378
|
+
};
|
|
379
|
+
});
|
|
380
|
+
return changed ? next : prev;
|
|
381
|
+
});
|
|
382
|
+
}, [historySig]);
|
|
251
383
|
|
|
252
384
|
// ─── SSE event processing ───────────────────────────────────
|
|
253
385
|
const processEventRef = useRef<((event: ChatStreamEvent) => void) | null>(null);
|
|
254
386
|
const processEvent = useCallback(
|
|
255
387
|
(event: ChatStreamEvent) => {
|
|
388
|
+
lastSseEventRef.current = event.type;
|
|
389
|
+
chatDebug("sse event", event.type);
|
|
256
390
|
switch (event.type) {
|
|
257
391
|
case "init": {
|
|
258
392
|
// Pierwszy event po `subscribe(messageId)`. Niesie snapshot
|
|
@@ -549,8 +683,11 @@ export function createChatComponent(
|
|
|
549
683
|
if (!activeGeneratingMessageId) return;
|
|
550
684
|
const messageId = activeGeneratingMessageId;
|
|
551
685
|
const ctrl = new AbortController();
|
|
686
|
+
sseCtrlRef.current = ctrl;
|
|
687
|
+
lastSseEventRef.current = null;
|
|
552
688
|
let cancelled = false;
|
|
553
689
|
setIsStreaming(true);
|
|
690
|
+
chatDebug("sse open", { messageId });
|
|
554
691
|
|
|
555
692
|
(async () => {
|
|
556
693
|
try {
|
|
@@ -613,6 +750,7 @@ export function createChatComponent(
|
|
|
613
750
|
return () => {
|
|
614
751
|
cancelled = true;
|
|
615
752
|
ctrl.abort();
|
|
753
|
+
if (sseCtrlRef.current === ctrl) sseCtrlRef.current = null;
|
|
616
754
|
};
|
|
617
755
|
}, [activeGeneratingMessageId]);
|
|
618
756
|
|