@arcote.tech/arc-chat 0.7.19 → 0.7.21

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.
@@ -1,40 +1,51 @@
1
- import { useState, useCallback, useMemo, useRef, useEffect, type ComponentType, createElement, type ReactNode } from "react";
2
- import type { AssistantContentBlock, ChatStreamEvent, ArcToolAny } from "@arcote.tech/arc-ai";
1
+ import {
2
+ useState,
3
+ useCallback,
4
+ useMemo,
5
+ useEffect,
6
+ type ComponentType,
7
+ createElement,
8
+ type ReactNode,
9
+ } from "react";
10
+ import type { ArcToolAny } from "@arcote.tech/arc-ai";
3
11
  import type {
4
12
  ChatInputTextareaSlotProps,
5
13
  ChatLabels,
6
- ChatMessageData,
7
14
  SendMessageOptions,
8
15
  } from "@arcote.tech/arc-ds";
9
- import { Chat, ChatMessage, ChatInputProvider, ChatLabelsProvider, ChatToolLog, useChatLabels } from "@arcote.tech/arc-ds";
16
+ import {
17
+ Chat,
18
+ ChatMessage,
19
+ ChatInputProvider,
20
+ ChatLabelsProvider,
21
+ ChatToolLog,
22
+ useChatLabels,
23
+ } from "@arcote.tech/arc-ds";
10
24
  import { VoiceTextarea } from "@arcote.tech/arc-ai-voice";
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
- }
25
+ import {
26
+ deriveTimeline,
27
+ isPendingSendSettled,
28
+ type PendingSend,
29
+ type TimelineItem,
30
+ } from "./derive-timeline";
31
+ import { useAssistantOverlays } from "./use-assistant-overlays";
32
+
33
+ // ─── Architektura ────────────────────────────────────────────────
34
+ //
35
+ // Timeline jest CZYSTĄ FUNKCJĄ (deriveTimeline) dwóch źródeł:
36
+ // - DB przez liveQuery `getByScope` — źródło prawdy o strukturze
37
+ // (wiadomości, finalne blocks, isGenerating/interrupted/error,
38
+ // tool_results),
39
+ // - overlayów SSE (useAssistantOverlays) — ulotny in-progress stan
40
+ // generujących rzędów, budowany shared reducerem.
41
+ // Plus lokalny optimistic state (pendingSends / pendingToolResults /
42
+ // localErrors).
43
+ //
44
+ // NIE pisz do timeline'u imperatywnie. Każdy "merge" dwóch kanałów przez
45
+ // mutowalny stan + flagę trybu kończył się tu produkcyjnymi race'ami
46
+ // (watchdogi, backstopy, nonce — patrz git log). Derywacja liczy się
47
+ // zawsze od najnowszego stanu obu źródeł, więc kolejność dostarczenia
48
+ // (SSE `done` vs flip `isGenerating` w DB) jest bez znaczenia.
38
49
 
39
50
  interface ChatComponentConfig {
40
51
  chatName: string;
@@ -67,28 +78,6 @@ interface ChatComponentConfig {
67
78
  footer?: ReactNode;
68
79
  }
69
80
 
70
- type ToolStatus = "pending" | "executing" | "complete" | "error";
71
-
72
- type TimelineItem =
73
- | { type: "message"; id: string; role: "user" | "assistant"; content: string; isStreaming?: boolean }
74
- | {
75
- type: "tool";
76
- id: string; // = toolCallId, stabilne pomiędzy SSE i DB rebuild
77
- toolCallId: string;
78
- toolName: string;
79
- params: Record<string, unknown>;
80
- result?: unknown;
81
- status: ToolStatus;
82
- /** Legacy compat — true gdy status !== complete. Renderowane przez stary ChatToolLog. */
83
- calling: boolean;
84
- error?: string;
85
- }
86
- | {
87
- type: "interrupted";
88
- id: string;
89
- messageId: string;
90
- };
91
-
92
81
  export function createChatComponent(
93
82
  config: ChatComponentConfig,
94
83
  ): ComponentType<{ scope: any; identifyBy: string }> {
@@ -104,26 +93,29 @@ export function createChatComponent(
104
93
  footer,
105
94
  } = config;
106
95
  const toolsMap = new Map(tools.map((t) => [t.name, t]));
107
-
108
- function ChatComponentInner({ scope, identifyBy }: { scope: any; identifyBy: string }) {
96
+ const isServerTool = (toolName: string) =>
97
+ toolsMap.get(toolName)?.isServerTool === true;
98
+
99
+ function ChatComponentInner({
100
+ scope,
101
+ identifyBy,
102
+ }: {
103
+ scope: any;
104
+ identifyBy: string;
105
+ }) {
109
106
  const chatLabels = useChatLabels();
110
- const [timeline, setTimeline] = useState<TimelineItem[]>([]);
111
- const [isStreaming, setIsStreaming] = useState(false);
112
- /** Set messageIds dla których SSE zwrócił 410 (server restart mid-stream).
113
- * Renderowane jako TimelineItem "interrupted" z retry button. */
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);
107
+
108
+ // ─── Lokalny optimistic state ─────────────────────────────────
109
+ const [pendingSends, setPendingSends] = useState<PendingSend[]>([]);
110
+ const [pendingToolResults, setPendingToolResults] = useState<
111
+ Map<string, unknown>
112
+ >(() => new Map());
113
+ const [localErrors, setLocalErrors] = useState<
114
+ Array<{ id: string; content: string }>
115
+ >([]);
116
+ /** Wymusza re-derywację co interwał gdy busy klauzule busy mają
117
+ * staleness cutoff, który musi móc wygasnąć bez zmiany danych. */
118
+ const [busyTick, setBusyTick] = useState(0);
127
119
 
128
120
  const queries = scope.useQuery();
129
121
  const mutations = scope.useMutation();
@@ -131,641 +123,95 @@ export function createChatComponent(
131
123
  const messageMutations = mutations[messageElementName];
132
124
 
133
125
  const scopeId = identifyBy;
134
- const historyResult = scopeId && messageQueries?.getByScope
135
- ? messageQueries.getByScope({ scopeId })
136
- : [undefined, false];
126
+ const historyResult =
127
+ scopeId && messageQueries?.getByScope
128
+ ? messageQueries.getByScope({ scopeId })
129
+ : [undefined, false];
137
130
  const historyData = historyResult?.[0];
138
- const historyLen = historyData?.length ?? 0;
139
131
 
140
- // Stable signature of all messages `[id]:[isGenerating]:[hasBlocks]:[contentLen]`.
141
- // Changes on insert AND on partial update (e.g. ctx.modify flipping
142
- // isGenerating to false), so the timeline-rebuild effect refires for
143
- // both cases. `historyLen` alone misses updates that don't change count.
144
- const historySig = useMemo(
132
+ // ─── Overlaye SSE per generujący row ──────────────────────────
133
+ const generatingIds = useMemo<string[]>(
145
134
  () =>
146
- historyData
147
- ?.map(
135
+ (historyData ?? [])
136
+ .filter(
148
137
  (m: any) =>
149
- `${m._id}:${m.isGenerating ? 1 : 0}:${m.blocks ? "f" : "e"}:${m.content?.length ?? 0}`,
138
+ m.role === "assistant" && m.isGenerating && !m.interrupted,
150
139
  )
151
- .join("|") ?? "",
140
+ .map((m: any) => m._id as string),
152
141
  [historyData],
153
142
  );
143
+ const overlays = useAssistantOverlays(chatName, generatingIds);
154
144
 
155
- /** ID assistant rowa, którego currently otwarty stream subskrybujemy.
156
- * Wyciąga się z DB historii — jest max jeden taki w danej chwili
157
- * (listener sekwencyjny). Null gdy nie ma czego streamować. */
158
- const activeGeneratingMessageId = useMemo<string | null>(() => {
159
- if (!historyData) return null;
160
- for (const msg of historyData) {
161
- if (
162
- msg.role === "assistant" &&
163
- msg.isGenerating &&
164
- !interruptedIds.has(msg._id)
165
- ) {
166
- return msg._id;
167
- }
168
- }
169
- return null;
170
- }, [historyData, interruptedIds]);
145
+ // ─── Derywacja ────────────────────────────────────────────────
146
+ const derived = useMemo(
147
+ () =>
148
+ deriveTimeline({
149
+ history: historyData,
150
+ overlays,
151
+ pendingSends,
152
+ pendingToolResults,
153
+ isServerTool,
154
+ now: Date.now(),
155
+ }),
156
+ [historyData, overlays, pendingSends, pendingToolResults, busyTick],
157
+ );
171
158
 
172
- // ─── Restore timeline from DB history ───────────────────────
173
- // Podczas streamingu SSE jest źródłem prawdy dla aktualnie generowanej
174
- // wiadomości — rebuild byłby kolizją. Po `done` klient ustawia
175
- // `isStreaming=false` i ten useEffect refireuje — zaaplikuje final
176
- // blocks z `assistantTurnCompleted` projection (jedyny moment gdy treść
177
- // assistant'a ląduje w DB).
178
159
  useEffect(() => {
179
- if (isStreaming) return;
180
- if (!historyData || historyLen === 0) return;
181
-
182
- const resultIds = new Set<string>();
183
- const resultMap = new Map<string, { content: string; isError?: boolean }>();
184
- for (const msg of historyData) {
185
- if (msg.role === "tool_result" && msg.toolCallId) {
186
- resultIds.add(msg.toolCallId);
187
- resultMap.set(msg.toolCallId, { content: msg.content, isError: msg.isError });
188
- }
189
- }
190
-
191
- const items: TimelineItem[] = [];
192
-
193
- for (const msg of historyData) {
194
- // System messages are developer-injected priming prompts. They go
195
- // to the LLM via buildHistory() but must not appear in the user's
196
- // chat timeline.
197
- if (msg.role === "system") continue;
198
-
199
- if (msg.role === "user") {
200
- items.push({
201
- type: "message",
202
- id: msg._id,
203
- role: "user",
204
- content: msg.content ?? "",
205
- });
206
- continue;
207
- }
208
-
209
- if (msg.role === "assistant") {
210
- const renderBlocks = (
211
- blocks: AssistantContentBlock[],
212
- options: { isStreaming?: boolean } = {},
213
- ) => {
214
- let textCount = 0;
215
- for (const block of blocks) {
216
- if (block.type === "text") {
217
- if (block.text) {
218
- items.push({
219
- type: "message",
220
- id: `${msg._id}_t${textCount}`,
221
- role: "assistant",
222
- content: block.text,
223
- isStreaming: options.isStreaming,
224
- });
225
- }
226
- textCount++;
227
- } else {
228
- const result = resultMap.get(block.id);
229
- const hasResult = resultIds.has(block.id);
230
- // Brak result w DB = tool wciąż w toku (server: executing,
231
- // interactive: pending). `calling: true` w obu utrzymuje loader/input override.
232
- const status: ToolStatus = result?.isError
233
- ? "error"
234
- : hasResult
235
- ? "complete"
236
- : options.isStreaming
237
- ? "executing"
238
- : "pending";
239
- items.push({
240
- type: "tool",
241
- id: block.id,
242
- toolCallId: block.id,
243
- toolName: block.name,
244
- params: block.arguments,
245
- result: result ? tryParseJson(result.content) : undefined,
246
- status,
247
- calling: !hasResult,
248
- error: result?.isError ? result.content : undefined,
249
- });
250
- }
251
- }
252
- };
253
-
254
- // Open turn (in progress). The row exists w DB z `isGenerating: true`
255
- // ale BEZ `blocks` — final treść ląduje w DB dopiero po
256
- // `assistantTurnCompleted`. Live wartość jest in-memory w stream-registry
257
- // i zostanie pobrana przez SSE `init` event.
258
- if (msg.isGenerating) {
259
- if (interruptedIds.has(msg._id)) {
260
- items.push({
261
- type: "interrupted",
262
- id: `${msg._id}_interrupted`,
263
- messageId: msg._id,
264
- });
265
- } else {
266
- items.push({
267
- type: "message",
268
- id: `${msg._id}_t0`,
269
- role: "assistant",
270
- content: "",
271
- isStreaming: true,
272
- });
273
- }
274
- continue;
275
- }
276
-
277
- // Closed turn — render z final blocks.
278
- const blocks =
279
- (tryParseJson(msg.blocks ?? "") as AssistantContentBlock[]) ?? [];
280
- renderBlocks(blocks);
281
- }
282
- }
283
-
284
- setTimeline(items);
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]);
160
+ if (!derived.busy) return;
161
+ const id = setInterval(() => setBusyTick((t) => t + 1), 10_000);
162
+ return () => clearInterval(id);
163
+ }, [derived.busy]);
294
164
 
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.
165
+ // ─── GC optimistic state potwierdzonego przez liveQuery ───────
297
166
  useEffect(() => {
298
- chatDebug("db update", {
299
- count: historyLen,
300
- activeGenerating: activeGeneratingMessageId,
167
+ setPendingSends((prev) => {
168
+ const next = prev.filter((p) => !isPendingSendSettled(historyData, p));
169
+ return next.length === prev.length ? prev : next;
301
170
  });
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) => {
171
+ setPendingToolResults((prev) => {
172
+ if (prev.size === 0) return prev;
366
173
  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
- });
174
+ const next = new Map(prev);
175
+ for (const msg of historyData ?? []) {
176
+ if (
177
+ msg.role === "tool_result" &&
178
+ msg.toolCallId &&
179
+ next.has(msg.toolCallId)
180
+ ) {
181
+ next.delete(msg.toolCallId);
182
+ changed = true;
183
+ }
184
+ }
380
185
  return changed ? next : prev;
381
186
  });
382
- }, [historySig]);
383
-
384
- // ─── SSE event processing ───────────────────────────────────
385
- const processEventRef = useRef<((event: ChatStreamEvent) => void) | null>(null);
386
- const processEvent = useCallback(
387
- (event: ChatStreamEvent) => {
388
- lastSseEventRef.current = event.type;
389
- chatDebug("sse event", event.type);
390
- switch (event.type) {
391
- case "init": {
392
- // Pierwszy event po `subscribe(messageId)`. Niesie snapshot
393
- // in-memory `currentBlocks` — może być pusty (świeży stream) albo
394
- // wypełniony (graceful reconnect mid-stream). Hydratuje bubble
395
- // assistant'a.
396
- if (!event.messageId) break;
397
- const messageId = event.messageId;
398
- const blocks = event.currentBlocks ?? [];
399
- setTimeline((prev) => {
400
- // Wytnij placeholdery i istniejące bubble assistantów dla tego
401
- // messageId (z timeline rebuild) — zastąpimy świeżą wersją.
402
- const cleaned = prev.filter(
403
- (it) =>
404
- !(
405
- it.type === "message" &&
406
- typeof it.id === "string" &&
407
- it.id.startsWith(`${messageId}_t`)
408
- ) &&
409
- !(it.type === "tool" && blocks.some((b) => b.type === "tool_call" && b.id === it.id)),
410
- );
411
- let textCount = 0;
412
- const newItems: TimelineItem[] = [];
413
- for (const block of blocks) {
414
- if (block.type === "text") {
415
- newItems.push({
416
- type: "message",
417
- id: `${messageId}_t${textCount}`,
418
- role: "assistant",
419
- content: block.text,
420
- isStreaming: true,
421
- });
422
- textCount++;
423
- } else {
424
- newItems.push({
425
- type: "tool",
426
- id: block.id,
427
- toolCallId: block.id,
428
- toolName: block.name,
429
- params: block.arguments,
430
- status: "executing",
431
- calling: true,
432
- });
433
- }
434
- }
435
- // Jeśli init przyniósł 0 text blocks → wciąż dodaj pusty
436
- // streaming placeholder, żeby text_delta miał gdzie dolepić.
437
- if (!newItems.some((it) => it.type === "message" && it.role === "assistant")) {
438
- newItems.push({
439
- type: "message",
440
- id: `${messageId}_t0`,
441
- role: "assistant",
442
- content: "",
443
- isStreaming: true,
444
- });
445
- }
446
- return [...cleaned, ...newItems];
447
- });
448
- break;
449
- }
450
-
451
- case "text_delta":
452
- if (!event.textDelta) break;
453
- setTimeline((prev) => {
454
- const last = prev[prev.length - 1];
455
- const willAppend = !!(
456
- last &&
457
- last.type === "message" &&
458
- last.role === "assistant" &&
459
- last.isStreaming
460
- );
461
- if (willAppend) {
462
- return prev.map((item, i) =>
463
- i === prev.length - 1 && item.type === "message"
464
- ? { ...item, content: item.content + event.textDelta }
465
- : item,
466
- );
467
- }
468
- const newId = event.messageId
469
- ? `${event.messageId}_streaming_${prev.length}`
470
- : `assistant_${Date.now()}`;
471
- return [
472
- ...prev,
473
- {
474
- type: "message",
475
- id: newId,
476
- role: "assistant",
477
- content: event.textDelta!,
478
- isStreaming: true,
479
- },
480
- ];
481
- });
482
- break;
187
+ }, [historyData]);
483
188
 
484
- case "tool_call_pending":
485
- // AI właśnie zaczyna tool call. Dla SERVER tools pokazujemy loader
486
- // "Przygotowuje: {name}…" od razu. Dla INTERACTIVE tools czekamy
487
- // na `interactive_tool_request` z pełnymi args.
488
- if (event.toolCallId) {
489
- const toolDef = event.toolCallName
490
- ? toolsMap.get(event.toolCallName)
491
- : undefined;
492
- if (toolDef && !toolDef.isServerTool) break;
493
- setTimeline((prev) => {
494
- if (prev.some((it) => it.type === "tool" && it.id === event.toolCallId)) {
495
- return prev;
496
- }
497
- const next = prev.map((item) =>
498
- item.type === "message" && item.isStreaming
499
- ? { ...item, isStreaming: false }
500
- : item,
501
- );
502
- next.push({
503
- type: "tool",
504
- id: event.toolCallId!,
505
- toolCallId: event.toolCallId!,
506
- toolName: event.toolCallName ?? "",
507
- params: {},
508
- status: "pending",
509
- calling: true,
510
- });
511
- return next;
512
- });
513
- }
514
- break;
515
-
516
- case "tool_call_arguments_delta":
517
- // Args lecą znak po znaku — UI nie potrzebuje tej granularności.
518
- break;
519
-
520
- case "tool_call_arguments_complete":
521
- // Pełne args dostępne. Trzy przypadki:
522
- // 1. Server tool już jest w timeline (od tool_call_pending) → update.
523
- // 2. Interactive tool jeszcze nie jest w timeline → ADD tutaj.
524
- // 3. Tool już dodany przez interactive_tool_request → no-op.
525
- if (event.toolCallId) {
526
- setTimeline((prev) => {
527
- const existing = prev.find(
528
- (it): it is Extract<TimelineItem, { type: "tool" }> =>
529
- it.type === "tool" && it.toolCallId === event.toolCallId,
530
- );
531
- if (existing) {
532
- return prev.map((item) =>
533
- item.type === "tool" && item.toolCallId === event.toolCallId
534
- ? {
535
- ...item,
536
- params: event.arguments ?? item.params,
537
- status: "executing",
538
- calling: true,
539
- toolName: event.toolCallName ?? item.toolName,
540
- }
541
- : item,
542
- );
543
- }
544
- const next = prev.map((item) =>
545
- item.type === "message" && item.isStreaming
546
- ? { ...item, isStreaming: false }
547
- : item,
548
- );
549
- next.push({
550
- type: "tool",
551
- id: event.toolCallId!,
552
- toolCallId: event.toolCallId!,
553
- toolName: event.toolCallName ?? "",
554
- params: event.arguments ?? {},
555
- status: "pending",
556
- calling: true,
557
- });
558
- return next;
559
- });
560
- }
561
- break;
562
-
563
- case "tool_call_executed":
564
- if (event.toolResult) {
565
- setTimeline((prev) =>
566
- prev.map((item) =>
567
- item.type === "tool" && item.toolCallId === event.toolResult!.toolCallId
568
- ? {
569
- ...item,
570
- result: tryParseJson(event.toolResult!.content),
571
- status: event.toolResult!.isError ? "error" : "complete",
572
- calling: false,
573
- error: event.toolResult!.isError ? event.toolResult!.content : undefined,
574
- }
575
- : item,
576
- ),
577
- );
578
- }
579
- break;
580
-
581
- case "interactive_tool_request":
582
- if (event.toolCalls) {
583
- setTimeline((prev) => {
584
- const byId = new Map(
585
- prev
586
- .filter((it) => it.type === "tool")
587
- .map((it) => [(it as any).id, it]),
588
- );
589
- const next = prev.map((item) =>
590
- item.type === "message" && item.isStreaming
591
- ? { ...item, isStreaming: false }
592
- : item,
593
- );
594
- for (const tc of event.toolCalls!) {
595
- const existing = byId.get(tc.id);
596
- if (existing) {
597
- const idx = next.findIndex(
598
- (it) => it.type === "tool" && (it as any).id === tc.id,
599
- );
600
- if (idx >= 0) {
601
- next[idx] = {
602
- ...(next[idx] as any),
603
- toolName: tc.name || (next[idx] as any).toolName,
604
- params: tc.arguments ?? (next[idx] as any).params,
605
- };
606
- }
607
- continue;
608
- }
609
- next.push({
610
- type: "tool",
611
- id: tc.id,
612
- toolCallId: tc.id,
613
- toolName: tc.name,
614
- params: tc.arguments ?? {},
615
- status: "pending",
616
- calling: true,
617
- });
618
- }
619
- return next;
620
- });
621
- }
622
- break;
623
-
624
- case "done":
625
- // Stream zakończył turn. setIsStreaming(false) odpali timeline
626
- // rebuild z DB — final blocks z `assistantTurnCompleted` projection
627
- // zastąpią streaming bubble.
628
- setTimeline((prev) =>
629
- prev.map((item) =>
630
- item.type === "message" && item.isStreaming
631
- ? { ...item, isStreaming: false }
632
- : item,
633
- ),
634
- );
635
- setIsStreaming(false);
636
- break;
637
-
638
- case "error":
639
- setTimeline((prev) => {
640
- const last = prev[prev.length - 1];
641
- if (
642
- last &&
643
- last.type === "message" &&
644
- last.role === "assistant" &&
645
- last.isStreaming
646
- ) {
647
- return prev.map((item, i) =>
648
- i === prev.length - 1 && item.type === "message"
649
- ? {
650
- ...item,
651
- content: item.content || event.error || chatLabels.errorLabel,
652
- isStreaming: false,
653
- }
654
- : item,
655
- );
656
- }
657
- return [
658
- ...prev,
659
- {
660
- type: "message",
661
- id: `error_${Date.now()}`,
662
- role: "assistant",
663
- content: event.error || chatLabels.errorLabel,
664
- },
665
- ];
666
- });
667
- setIsStreaming(false);
668
- break;
669
- }
189
+ const pushLocalError = useCallback(
190
+ (err: unknown) => {
191
+ const msg = err instanceof Error ? err.message : chatLabels.errorLabel;
192
+ setLocalErrors((prev) => [
193
+ ...prev,
194
+ {
195
+ id: `error_${Date.now()}_${prev.length}`,
196
+ content: `${chatLabels.errorLabel}: ${msg}`,
197
+ },
198
+ ]);
670
199
  },
671
200
  [chatLabels],
672
201
  );
673
- processEventRef.current = processEvent;
674
-
675
- // ─── Auto-subscribe SSE per active assistant messageId ──────
676
- // Driven przez DB history: za każdym razem gdy w DB pojawia się
677
- // assistant row z `isGenerating=true`, otwieramy do niego SSE.
678
- // Wszystkie scenariusze (send, respond, retry, page reload mid-stream)
679
- // przechodzą przez ten sam mechanizm — handleSend/respond/retry tylko
680
- // wywołują mutację, ten effect załapie nowy generating row z DB query
681
- // update.
682
- useEffect(() => {
683
- if (!activeGeneratingMessageId) return;
684
- const messageId = activeGeneratingMessageId;
685
- const ctrl = new AbortController();
686
- sseCtrlRef.current = ctrl;
687
- lastSseEventRef.current = null;
688
- let cancelled = false;
689
- setIsStreaming(true);
690
- chatDebug("sse open", { messageId });
691
202
 
692
- (async () => {
693
- try {
694
- // 410 = brak in-memory streamu dla messageId. Serwer tworzy stream
695
- // synchronicznie ze startem turnu (listener przed 1. awaitem), więc
696
- // race "GET przed startStream" jest zamknięty — ale zostaje krótki
697
- // residualny race i okno restartu serwera. Ponów kilka razy z
698
- // backoffem zanim uznasz turn za przerwany: startStream / grace
699
- // window zwykle dogania w tym czasie.
700
- let res: Response | null = null;
701
- const MAX_410_RETRIES = 4;
702
- const RETRY_DELAY_MS = 300;
703
- for (let attempt = 0; ; attempt++) {
704
- res = await fetch(`/route/chat/${chatName}/stream/${messageId}`, {
705
- credentials: "include",
706
- signal: ctrl.signal,
707
- headers: { Accept: "text/event-stream" },
708
- });
709
- if (res.status !== 410) break;
710
- if (attempt >= MAX_410_RETRIES) {
711
- // Naprawdę nieosiągalny (restart mid-stream / poza grace window).
712
- setInterruptedIds((prev) => new Set(prev).add(messageId));
713
- setIsStreaming(false);
714
- return;
715
- }
716
- await new Promise<void>((r) => setTimeout(r, RETRY_DELAY_MS));
717
- if (cancelled) return;
718
- }
719
- if (!res.ok) throw new Error(`Stream failed: ${res.status}`);
720
-
721
- const reader = res.body!.getReader();
722
- const decoder = new TextDecoder();
723
- let buf = "";
724
- while (!cancelled) {
725
- const { value, done } = await reader.read();
726
- if (done) break;
727
- buf += decoder.decode(value, { stream: true });
728
- const lines = buf.split("\n");
729
- buf = lines.pop() ?? "";
730
- for (const line of lines) {
731
- if (!line.startsWith("data: ")) continue;
732
- try {
733
- const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
734
- processEventRef.current?.(event);
735
- // Yield between events in the same TCP chunk — bez tego
736
- // React batchuje setTimeline w jeden render, streaming
737
- // niewidoczny.
738
- await new Promise<void>((r) => setTimeout(r, 0));
739
- } catch {}
740
- }
741
- }
742
- } catch (err) {
743
- if ((err as any)?.name === "AbortError") return;
744
- // Network glitch / SSE hang — treat as interrupted.
745
- setInterruptedIds((prev) => new Set(prev).add(messageId));
746
- setIsStreaming(false);
747
- }
748
- })();
749
-
750
- return () => {
751
- cancelled = true;
752
- ctrl.abort();
753
- if (sseCtrlRef.current === ctrl) sseCtrlRef.current = null;
754
- };
755
- }, [activeGeneratingMessageId]);
756
-
757
- // ─── Send message ───────────────────────────────────────────
203
+ // ─── Send message ─────────────────────────────────────────────
758
204
  const handleSend = useCallback(
759
205
  async (content: string, options: SendMessageOptions) => {
760
- if (isStreaming || !scopeId) return;
761
- setIsStreaming(true);
762
- const userMsgId = `user_${Date.now()}`;
763
- setTimeline((prev) => [
206
+ if (!scopeId) return;
207
+ if (derived.busy || derived.hasWaitingInteractive) return;
208
+ const localId = `pending_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
209
+ setPendingSends((prev) => [
764
210
  ...prev,
765
- { type: "message", id: userMsgId, role: "user", content },
211
+ { localId, content, sentAt: Date.now() },
766
212
  ]);
767
213
  try {
768
- await messageMutations.sendMessage({
214
+ const res = await messageMutations.sendMessage({
769
215
  scopeId,
770
216
  content,
771
217
  model: options.model,
@@ -773,66 +219,54 @@ export function createChatComponent(
773
219
  ? { attachmentsJson: JSON.stringify(options.attachments) }
774
220
  : {}),
775
221
  });
776
- // Reszta dzieje się przez auto-subscribe effect powyżej: mutacja
777
- // emit'uje `assistantTurnStarted` → DB query pushuje fresh assistant
778
- // row do klienta → `activeGeneratingMessageId` ustawia się → effect
779
- // otwiera SSE.
222
+ const dbId = (res as any)?.messageId;
223
+ if (dbId) {
224
+ setPendingSends((prev) =>
225
+ prev.map((p) => (p.localId === localId ? { ...p, dbId } : p)),
226
+ );
227
+ }
228
+ // Reszta dzieje się sama: mutacja emituje `assistantTurnStarted` →
229
+ // liveQuery pushuje generujący row → useAssistantOverlays otwiera
230
+ // SSE → deriveTimeline renderuje overlay.
780
231
  } catch (err) {
781
- const errorMsg = err instanceof Error ? err.message : chatLabels.errorLabel;
782
- setTimeline((prev) => [
783
- ...prev,
784
- { type: "message", id: `error_${Date.now()}`, role: "assistant", content: `${chatLabels.errorLabel}: ${errorMsg}` },
785
- ]);
786
- setIsStreaming(false);
232
+ setPendingSends((prev) => prev.filter((p) => p.localId !== localId));
233
+ pushLocalError(err);
787
234
  }
788
235
  },
789
- [isStreaming, scopeId, messageMutations, chatLabels],
236
+ [scopeId, messageMutations, pushLocalError, derived.busy, derived.hasWaitingInteractive],
790
237
  );
791
238
 
792
- // ─── Retry interrupted generation ───────────────────────────
239
+ // ─── Retry interrupted generation ─────────────────────────────
793
240
  const handleRetry = useCallback(
794
241
  async (messageId: string) => {
795
242
  if (!scopeId) return;
796
243
  try {
797
244
  await messageMutations.retryGeneration({ messageId });
798
- // Mutacja usuwa interrupted row + tworzy fresh assistant row
799
- // auto-subscribe effect załapie nowy generating row.
800
- setInterruptedIds((prev) => {
801
- const next = new Set(prev);
802
- next.delete(messageId);
803
- return next;
804
- });
245
+ // Mutacja usuwa interrupted row + tworzy fresh generujący row
246
+ // liveQuery i overlay hook przejmują dalej.
805
247
  } catch (err) {
806
- const errorMsg = err instanceof Error ? err.message : chatLabels.errorLabel;
807
- setTimeline((prev) => [
808
- ...prev,
809
- { type: "message", id: `error_${Date.now()}`, role: "assistant", content: `${chatLabels.errorLabel}: ${errorMsg}` },
810
- ]);
248
+ pushLocalError(err);
811
249
  }
812
250
  },
813
- [scopeId, messageMutations, chatLabels],
251
+ [scopeId, messageMutations, pushLocalError],
814
252
  );
815
253
 
816
- // ─── Build messages for Chat DS (only user/assistant) ───────
817
- const chatMessages: ChatMessageData[] = timeline
818
- .filter((item): item is TimelineItem & { type: "message" } => item.type === "message")
819
- .map((item) => ({
820
- id: item.id,
821
- role: item.role,
822
- content: item.content,
823
- isStreaming: item.isStreaming,
824
- }));
825
-
826
- // ─── Render tool view ───────────────────────────────────────
254
+ // ─── Render tool view ─────────────────────────────────────────
827
255
  const renderToolItem = (item: TimelineItem & { type: "tool" }) => {
828
256
  const tool = toolsMap.get(item.toolName);
829
257
  const ViewComponent = tool?.viewComponent;
830
258
 
831
259
  if (!ViewComponent) {
832
- return createElement(ChatToolLog, {
833
- calling: item.calling,
834
- label: item.toolName,
835
- }, item.calling ? chatLabels.toolCallingLabel : item.error ?? chatLabels.toolDoneLabel);
260
+ return createElement(
261
+ ChatToolLog,
262
+ {
263
+ calling: item.calling,
264
+ label: item.toolName,
265
+ },
266
+ item.calling
267
+ ? chatLabels.toolCallingLabel
268
+ : (item.error ?? chatLabels.toolDoneLabel),
269
+ );
836
270
  }
837
271
 
838
272
  if (tool.isServerTool) {
@@ -844,15 +278,13 @@ export function createChatComponent(
844
278
  });
845
279
  }
846
280
 
847
- // Interactive tool
281
+ // Interactive tool — odpowiedź idzie przez optimistic
282
+ // pendingToolResults (answer-view od razu), mutacja respondToTool
283
+ // tworzy tool_result row + nowy turn.
848
284
  const respond = async (result: unknown) => {
849
285
  if (!scopeId) return;
850
- setTimeline((prev) =>
851
- prev.map((t) =>
852
- t.type === "tool" && t.toolCallId === item.toolCallId
853
- ? { ...t, calling: false, result }
854
- : t,
855
- ),
286
+ setPendingToolResults((prev) =>
287
+ new Map(prev).set(item.toolCallId, result),
856
288
  );
857
289
  try {
858
290
  await messageMutations.respondToTool({
@@ -861,18 +293,13 @@ export function createChatComponent(
861
293
  toolName: item.toolName,
862
294
  result: JSON.stringify(result),
863
295
  });
864
- // Auto-subscribe effect załapie nowy assistant row (utworzony
865
- // atomowo przez mutację respondToTool razem z userResponded event).
866
296
  } catch (err) {
867
- setTimeline((prev) => [
868
- ...prev,
869
- {
870
- type: "message",
871
- id: `error_${Date.now()}`,
872
- role: "assistant",
873
- content: `${chatLabels.errorLabel}: ${err instanceof Error ? err.message : chatLabels.errorLabel}`,
874
- },
875
- ]);
297
+ setPendingToolResults((prev) => {
298
+ const next = new Map(prev);
299
+ next.delete(item.toolCallId);
300
+ return next;
301
+ });
302
+ pushLocalError(err);
876
303
  }
877
304
  };
878
305
 
@@ -884,21 +311,19 @@ export function createChatComponent(
884
311
  });
885
312
  };
886
313
 
887
- // ─── Render full timeline ───────────────────────────────────
888
- // Streaming message renders last (after tools) for correct visual order
889
- const sortedTimeline = [...timeline].sort((a, b) => {
890
- const aStreaming = a.type === "message" && a.isStreaming ? 1 : 0;
891
- const bStreaming = b.type === "message" && b.isStreaming ? 1 : 0;
892
- return aStreaming - bStreaming;
893
- });
894
-
314
+ // ─── Render timeline ──────────────────────────────────────────
895
315
  const timelineElements: ReactNode[] = [];
896
- for (const item of sortedTimeline) {
316
+ for (const item of derived.items) {
897
317
  if (item.type === "message") {
898
318
  timelineElements.push(
899
319
  createElement(ChatMessage, {
900
320
  key: item.id,
901
- message: { id: item.id, role: item.role, content: item.content, isStreaming: item.isStreaming },
321
+ message: {
322
+ id: item.id,
323
+ role: item.role,
324
+ content: item.content,
325
+ isStreaming: item.isStreaming,
326
+ },
902
327
  }),
903
328
  );
904
329
  } else if (item.type === "tool") {
@@ -911,7 +336,8 @@ export function createChatComponent(
911
336
  "div",
912
337
  {
913
338
  key: item.id,
914
- className: "flex items-center gap-2 text-sm text-muted-foreground",
339
+ className:
340
+ "flex items-center gap-2 text-sm text-muted-foreground",
915
341
  },
916
342
  createElement("span", null, chatLabels.interruptedLabel),
917
343
  createElement(
@@ -927,13 +353,14 @@ export function createChatComponent(
927
353
  );
928
354
  }
929
355
  }
930
-
931
- // Check if any interactive tool is waiting for response
932
- const hasWaitingInteractive = timeline.some(
933
- (item) => item.type === "tool" && item.calling && !toolsMap.get(item.toolName)?.isServerTool,
934
- );
935
-
936
- void chatMessages;
356
+ for (const err of localErrors) {
357
+ timelineElements.push(
358
+ createElement(ChatMessage, {
359
+ key: err.id,
360
+ message: { id: err.id, role: "assistant", content: err.content },
361
+ }),
362
+ );
363
+ }
937
364
 
938
365
  return createElement(
939
366
  ChatInputProvider,
@@ -967,24 +394,16 @@ export function createChatComponent(
967
394
  placeholder,
968
395
  rows,
969
396
  })),
970
- disabled: isStreaming || hasWaitingInteractive,
397
+ disabled: derived.busy || derived.hasWaitingInteractive,
971
398
  }),
972
399
  ),
973
400
  );
974
401
  }
975
402
 
976
403
  return function ChatComponent(props: { scope: any; identifyBy: string }) {
977
- return createElement(
978
- ChatLabelsProvider,
979
- { labels, children: createElement(ChatComponentInner, props) },
980
- );
404
+ return createElement(ChatLabelsProvider, {
405
+ labels,
406
+ children: createElement(ChatComponentInner, props),
407
+ });
981
408
  };
982
409
  }
983
-
984
- function tryParseJson(str: string): unknown {
985
- try {
986
- return JSON.parse(str);
987
- } catch {
988
- return str;
989
- }
990
- }