@arcote.tech/arc-chat 0.7.20 → 0.7.22
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/README.md +175 -101
- package/package.json +7 -7
- package/src/aggregates/message.ts +83 -2
- package/src/chat-builder.ts +1 -0
- package/src/listeners/ai-generation-listener.ts +18 -3
- package/src/ordering.test.ts +118 -0
- package/src/ordering.ts +88 -0
- package/src/react/chat-component.tsx +189 -879
- package/src/react/derive-timeline.test.ts +654 -0
- package/src/react/derive-timeline.ts +416 -0
- package/src/react/use-assistant-overlays.ts +269 -0
- package/src/routes/chat-stream-route.ts +19 -5
- package/src/streaming/blocks-reducer.test.ts +126 -0
- package/src/streaming/blocks-reducer.ts +88 -0
- package/src/streaming/stream-registry.test.ts +64 -0
- package/src/streaming/stream-registry.ts +21 -49
- package/src/tools/ask-questions.tsx +7 -4
- package/src/react/use-chat.ts +0 -1
|
@@ -1,40 +1,51 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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 {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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,36 +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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
*
|
|
121
|
-
|
|
122
|
-
* recovers the in-progress state; 410 → existing retry/interrupted path. */
|
|
123
|
-
const [streamReopenNonce, setStreamReopenNonce] = useState(0);
|
|
124
|
-
/** AbortController of the currently-open SSE stream, exposed via ref so the
|
|
125
|
-
* watchdog can tear it down when the DB says the turn is already done. */
|
|
126
|
-
const sseCtrlRef = useRef<AbortController | null>(null);
|
|
127
|
-
/** Last SSE event type seen (for stuck telemetry — was `done` ever sent?). */
|
|
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);
|
|
134
|
-
/** When the (isStreaming=true ∧ DB-idle) divergence first appeared — for
|
|
135
|
-
* the debounce window and the `waitedMs` stuck attribute. */
|
|
136
|
-
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);
|
|
137
119
|
|
|
138
120
|
const queries = scope.useQuery();
|
|
139
121
|
const mutations = scope.useMutation();
|
|
@@ -141,740 +123,95 @@ export function createChatComponent(
|
|
|
141
123
|
const messageMutations = mutations[messageElementName];
|
|
142
124
|
|
|
143
125
|
const scopeId = identifyBy;
|
|
144
|
-
const historyResult =
|
|
145
|
-
|
|
146
|
-
|
|
126
|
+
const historyResult =
|
|
127
|
+
scopeId && messageQueries?.getByScope
|
|
128
|
+
? messageQueries.getByScope({ scopeId })
|
|
129
|
+
: [undefined, false];
|
|
147
130
|
const historyData = historyResult?.[0];
|
|
148
|
-
const historyLen = historyData?.length ?? 0;
|
|
149
131
|
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
// isGenerating to false), so the timeline-rebuild effect refires for
|
|
153
|
-
// both cases. `historyLen` alone misses updates that don't change count.
|
|
154
|
-
const historySig = useMemo(
|
|
132
|
+
// ─── Overlaye SSE per generujący row ──────────────────────────
|
|
133
|
+
const generatingIds = useMemo<string[]>(
|
|
155
134
|
() =>
|
|
156
|
-
historyData
|
|
157
|
-
|
|
135
|
+
(historyData ?? [])
|
|
136
|
+
.filter(
|
|
158
137
|
(m: any) =>
|
|
159
|
-
|
|
138
|
+
m.role === "assistant" && m.isGenerating && !m.interrupted,
|
|
160
139
|
)
|
|
161
|
-
.
|
|
140
|
+
.map((m: any) => m._id as string),
|
|
162
141
|
[historyData],
|
|
163
142
|
);
|
|
143
|
+
const overlays = useAssistantOverlays(chatName, generatingIds);
|
|
164
144
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
return null;
|
|
180
|
-
}, [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
|
+
);
|
|
181
158
|
|
|
182
|
-
// ─── Restore timeline from DB history ───────────────────────
|
|
183
|
-
// Podczas streamingu SSE jest źródłem prawdy dla aktualnie generowanej
|
|
184
|
-
// wiadomości — rebuild byłby kolizją. Po `done` klient ustawia
|
|
185
|
-
// `isStreaming=false` i ten useEffect refireuje — zaaplikuje final
|
|
186
|
-
// blocks z `assistantTurnCompleted` projection (jedyny moment gdy treść
|
|
187
|
-
// assistant'a ląduje w DB).
|
|
188
159
|
useEffect(() => {
|
|
189
|
-
if (
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const resultMap = new Map<string, { content: string; isError?: boolean }>();
|
|
194
|
-
for (const msg of historyData) {
|
|
195
|
-
if (msg.role === "tool_result" && msg.toolCallId) {
|
|
196
|
-
resultIds.add(msg.toolCallId);
|
|
197
|
-
resultMap.set(msg.toolCallId, { content: msg.content, isError: msg.isError });
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const items: TimelineItem[] = [];
|
|
202
|
-
|
|
203
|
-
for (const msg of historyData) {
|
|
204
|
-
// System messages are developer-injected priming prompts. They go
|
|
205
|
-
// to the LLM via buildHistory() but must not appear in the user's
|
|
206
|
-
// chat timeline.
|
|
207
|
-
if (msg.role === "system") continue;
|
|
208
|
-
|
|
209
|
-
if (msg.role === "user") {
|
|
210
|
-
items.push({
|
|
211
|
-
type: "message",
|
|
212
|
-
id: msg._id,
|
|
213
|
-
role: "user",
|
|
214
|
-
content: msg.content ?? "",
|
|
215
|
-
});
|
|
216
|
-
continue;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (msg.role === "assistant") {
|
|
220
|
-
const renderBlocks = (
|
|
221
|
-
blocks: AssistantContentBlock[],
|
|
222
|
-
options: { isStreaming?: boolean } = {},
|
|
223
|
-
) => {
|
|
224
|
-
let textCount = 0;
|
|
225
|
-
for (const block of blocks) {
|
|
226
|
-
if (block.type === "text") {
|
|
227
|
-
if (block.text) {
|
|
228
|
-
items.push({
|
|
229
|
-
type: "message",
|
|
230
|
-
id: `${msg._id}_t${textCount}`,
|
|
231
|
-
role: "assistant",
|
|
232
|
-
content: block.text,
|
|
233
|
-
isStreaming: options.isStreaming,
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
textCount++;
|
|
237
|
-
} else {
|
|
238
|
-
const result = resultMap.get(block.id);
|
|
239
|
-
const hasResult = resultIds.has(block.id);
|
|
240
|
-
// Brak result w DB = tool wciąż w toku (server: executing,
|
|
241
|
-
// interactive: pending). `calling: true` w obu utrzymuje loader/input override.
|
|
242
|
-
const status: ToolStatus = result?.isError
|
|
243
|
-
? "error"
|
|
244
|
-
: hasResult
|
|
245
|
-
? "complete"
|
|
246
|
-
: options.isStreaming
|
|
247
|
-
? "executing"
|
|
248
|
-
: "pending";
|
|
249
|
-
items.push({
|
|
250
|
-
type: "tool",
|
|
251
|
-
id: block.id,
|
|
252
|
-
toolCallId: block.id,
|
|
253
|
-
toolName: block.name,
|
|
254
|
-
params: block.arguments,
|
|
255
|
-
result: result ? tryParseJson(result.content) : undefined,
|
|
256
|
-
status,
|
|
257
|
-
calling: !hasResult,
|
|
258
|
-
error: result?.isError ? result.content : undefined,
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
// Open turn (in progress). The row exists w DB z `isGenerating: true`
|
|
265
|
-
// ale BEZ `blocks` — final treść ląduje w DB dopiero po
|
|
266
|
-
// `assistantTurnCompleted`. Live wartość jest in-memory w stream-registry
|
|
267
|
-
// i zostanie pobrana przez SSE `init` event.
|
|
268
|
-
if (msg.isGenerating) {
|
|
269
|
-
if (interruptedIds.has(msg._id)) {
|
|
270
|
-
items.push({
|
|
271
|
-
type: "interrupted",
|
|
272
|
-
id: `${msg._id}_interrupted`,
|
|
273
|
-
messageId: msg._id,
|
|
274
|
-
});
|
|
275
|
-
} else {
|
|
276
|
-
items.push({
|
|
277
|
-
type: "message",
|
|
278
|
-
id: `${msg._id}_t0`,
|
|
279
|
-
role: "assistant",
|
|
280
|
-
content: "",
|
|
281
|
-
isStreaming: true,
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Closed turn — render z final blocks.
|
|
288
|
-
const blocks =
|
|
289
|
-
(tryParseJson(msg.blocks ?? "") as AssistantContentBlock[]) ?? [];
|
|
290
|
-
renderBlocks(blocks);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
setTimeline(items);
|
|
295
|
-
// Deps: `historySig` + `interruptedIds` + `reconcileNonce`. Wciąż BEZ
|
|
296
|
-
// `isStreaming` — gdyby było w deps: po `done` SSE flips isStreaming=false →
|
|
297
|
-
// refire → może zobaczyć STARĄ `isGenerating:1` (DB jeszcze nie
|
|
298
|
-
// zaprojektowała assistantTurnCompleted) → reset do streaming → caret nie
|
|
299
|
-
// znika. `reconcileNonce` (bumpowany przez watchdog) wymusza rebuild w
|
|
300
|
-
// odwrotnym race (query-update wyprzedził SSE `done` → historySig zmienił
|
|
301
|
-
// się PODCZAS isStreaming=true → rebuild pominięty guardem i bez nonce
|
|
302
|
-
// nigdy nie nadrobiony → produkcyjny hang).
|
|
303
|
-
}, [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]);
|
|
304
164
|
|
|
305
|
-
//
|
|
306
|
-
// this shows the channel skew: who arrives first, and how far apart.
|
|
165
|
+
// ─── GC optimistic state potwierdzonego przez liveQuery ───────
|
|
307
166
|
useEffect(() => {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
167
|
+
setPendingSends((prev) => {
|
|
168
|
+
const next = prev.filter((p) => !isPendingSendSettled(historyData, p));
|
|
169
|
+
return next.length === prev.length ? prev : next;
|
|
311
170
|
});
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
// ─── Reconcile watchdog — DB is the arbiter of "is a turn running" ──
|
|
315
|
-
// Production hang: SSE `done` and the DB `assistantTurnCompleted` projection
|
|
316
|
-
// travel on separate channels (SSE vs WS query sync). When the query-update
|
|
317
|
-
// (`isGenerating→false`) wins the race while `isStreaming` is still true — or
|
|
318
|
-
// when `done` is lost entirely (410 exhaustion / server restart) — the
|
|
319
|
-
// rebuild guard skips and never retries, so the chat sticks in a streaming
|
|
320
|
-
// state until a page refresh.
|
|
321
|
-
//
|
|
322
|
-
// DB truth: `activeGeneratingMessageId === null` ⇒ no turn is generating. If
|
|
323
|
-
// we still believe we're streaming after a short debounce, force-close the
|
|
324
|
-
// turn: abort the (dead) SSE, drop `isStreaming`, and bump `reconcileNonce`
|
|
325
|
-
// so the rebuild runs with the final DB blocks. The debounce avoids
|
|
326
|
-
// flicker in the brief between-turns window (old turn done, next not started
|
|
327
|
-
// yet). No false fires during a real stream — the DB then holds
|
|
328
|
-
// `isGenerating:1`, so `activeGeneratingMessageId` is non-null.
|
|
329
|
-
useEffect(() => {
|
|
330
|
-
if (!(isStreaming && activeGeneratingMessageId === null)) {
|
|
331
|
-
stuckSinceRef.current = null;
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
if (stuckSinceRef.current === null) stuckSinceRef.current = Date.now();
|
|
335
|
-
const since = stuckSinceRef.current;
|
|
336
|
-
const RECONCILE_DEBOUNCE_MS = 200;
|
|
337
|
-
const timer = setTimeout(() => {
|
|
338
|
-
const lastByteAt = lastSseByteAtRef.current;
|
|
339
|
-
reportReconcileStuck({
|
|
340
|
-
scopeId,
|
|
341
|
-
chatName,
|
|
342
|
-
lastSseEvent: lastSseEventRef.current,
|
|
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,
|
|
351
|
-
});
|
|
352
|
-
sseCtrlRef.current?.abort();
|
|
353
|
-
sseCtrlRef.current = null;
|
|
354
|
-
stuckSinceRef.current = null;
|
|
355
|
-
setIsStreaming(false);
|
|
356
|
-
setReconcileNonce((n) => n + 1);
|
|
357
|
-
}, RECONCILE_DEBOUNCE_MS);
|
|
358
|
-
return () => clearTimeout(timer);
|
|
359
|
-
}, [isStreaming, activeGeneratingMessageId, scopeId]);
|
|
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) => {
|
|
171
|
+
setPendingToolResults((prev) => {
|
|
172
|
+
if (prev.size === 0) return prev;
|
|
384
173
|
let changed = false;
|
|
385
|
-
const next = prev
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
};
|
|
397
|
-
});
|
|
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
|
+
}
|
|
398
185
|
return changed ? next : prev;
|
|
399
186
|
});
|
|
400
|
-
}, [
|
|
401
|
-
|
|
402
|
-
// ─── SSE event processing ───────────────────────────────────
|
|
403
|
-
const processEventRef = useRef<((event: ChatStreamEvent) => void) | null>(null);
|
|
404
|
-
const processEvent = useCallback(
|
|
405
|
-
(event: ChatStreamEvent) => {
|
|
406
|
-
lastSseEventRef.current = event.type;
|
|
407
|
-
chatDebug("sse event", event.type);
|
|
408
|
-
switch (event.type) {
|
|
409
|
-
case "init": {
|
|
410
|
-
// Pierwszy event po `subscribe(messageId)`. Niesie snapshot
|
|
411
|
-
// in-memory `currentBlocks` — może być pusty (świeży stream) albo
|
|
412
|
-
// wypełniony (graceful reconnect mid-stream). Hydratuje bubble
|
|
413
|
-
// assistant'a.
|
|
414
|
-
if (!event.messageId) break;
|
|
415
|
-
const messageId = event.messageId;
|
|
416
|
-
const blocks = event.currentBlocks ?? [];
|
|
417
|
-
setTimeline((prev) => {
|
|
418
|
-
// Wytnij placeholdery i istniejące bubble assistantów dla tego
|
|
419
|
-
// messageId (z timeline rebuild) — zastąpimy świeżą wersją.
|
|
420
|
-
const cleaned = prev.filter(
|
|
421
|
-
(it) =>
|
|
422
|
-
!(
|
|
423
|
-
it.type === "message" &&
|
|
424
|
-
typeof it.id === "string" &&
|
|
425
|
-
it.id.startsWith(`${messageId}_t`)
|
|
426
|
-
) &&
|
|
427
|
-
!(it.type === "tool" && blocks.some((b) => b.type === "tool_call" && b.id === it.id)),
|
|
428
|
-
);
|
|
429
|
-
let textCount = 0;
|
|
430
|
-
const newItems: TimelineItem[] = [];
|
|
431
|
-
for (const block of blocks) {
|
|
432
|
-
if (block.type === "text") {
|
|
433
|
-
newItems.push({
|
|
434
|
-
type: "message",
|
|
435
|
-
id: `${messageId}_t${textCount}`,
|
|
436
|
-
role: "assistant",
|
|
437
|
-
content: block.text,
|
|
438
|
-
isStreaming: true,
|
|
439
|
-
});
|
|
440
|
-
textCount++;
|
|
441
|
-
} else {
|
|
442
|
-
newItems.push({
|
|
443
|
-
type: "tool",
|
|
444
|
-
id: block.id,
|
|
445
|
-
toolCallId: block.id,
|
|
446
|
-
toolName: block.name,
|
|
447
|
-
params: block.arguments,
|
|
448
|
-
status: "executing",
|
|
449
|
-
calling: true,
|
|
450
|
-
});
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
// Jeśli init przyniósł 0 text blocks → wciąż dodaj pusty
|
|
454
|
-
// streaming placeholder, żeby text_delta miał gdzie dolepić.
|
|
455
|
-
if (!newItems.some((it) => it.type === "message" && it.role === "assistant")) {
|
|
456
|
-
newItems.push({
|
|
457
|
-
type: "message",
|
|
458
|
-
id: `${messageId}_t0`,
|
|
459
|
-
role: "assistant",
|
|
460
|
-
content: "",
|
|
461
|
-
isStreaming: true,
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
return [...cleaned, ...newItems];
|
|
465
|
-
});
|
|
466
|
-
break;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
case "text_delta":
|
|
470
|
-
if (!event.textDelta) break;
|
|
471
|
-
setTimeline((prev) => {
|
|
472
|
-
const last = prev[prev.length - 1];
|
|
473
|
-
const willAppend = !!(
|
|
474
|
-
last &&
|
|
475
|
-
last.type === "message" &&
|
|
476
|
-
last.role === "assistant" &&
|
|
477
|
-
last.isStreaming
|
|
478
|
-
);
|
|
479
|
-
if (willAppend) {
|
|
480
|
-
return prev.map((item, i) =>
|
|
481
|
-
i === prev.length - 1 && item.type === "message"
|
|
482
|
-
? { ...item, content: item.content + event.textDelta }
|
|
483
|
-
: item,
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
const newId = event.messageId
|
|
487
|
-
? `${event.messageId}_streaming_${prev.length}`
|
|
488
|
-
: `assistant_${Date.now()}`;
|
|
489
|
-
return [
|
|
490
|
-
...prev,
|
|
491
|
-
{
|
|
492
|
-
type: "message",
|
|
493
|
-
id: newId,
|
|
494
|
-
role: "assistant",
|
|
495
|
-
content: event.textDelta!,
|
|
496
|
-
isStreaming: true,
|
|
497
|
-
},
|
|
498
|
-
];
|
|
499
|
-
});
|
|
500
|
-
break;
|
|
501
|
-
|
|
502
|
-
case "tool_call_pending":
|
|
503
|
-
// AI właśnie zaczyna tool call. Dla SERVER tools pokazujemy loader
|
|
504
|
-
// "Przygotowuje: {name}…" od razu. Dla INTERACTIVE tools czekamy
|
|
505
|
-
// na `interactive_tool_request` z pełnymi args.
|
|
506
|
-
if (event.toolCallId) {
|
|
507
|
-
const toolDef = event.toolCallName
|
|
508
|
-
? toolsMap.get(event.toolCallName)
|
|
509
|
-
: undefined;
|
|
510
|
-
if (toolDef && !toolDef.isServerTool) break;
|
|
511
|
-
setTimeline((prev) => {
|
|
512
|
-
if (prev.some((it) => it.type === "tool" && it.id === event.toolCallId)) {
|
|
513
|
-
return prev;
|
|
514
|
-
}
|
|
515
|
-
const next = prev.map((item) =>
|
|
516
|
-
item.type === "message" && item.isStreaming
|
|
517
|
-
? { ...item, isStreaming: false }
|
|
518
|
-
: item,
|
|
519
|
-
);
|
|
520
|
-
next.push({
|
|
521
|
-
type: "tool",
|
|
522
|
-
id: event.toolCallId!,
|
|
523
|
-
toolCallId: event.toolCallId!,
|
|
524
|
-
toolName: event.toolCallName ?? "",
|
|
525
|
-
params: {},
|
|
526
|
-
status: "pending",
|
|
527
|
-
calling: true,
|
|
528
|
-
});
|
|
529
|
-
return next;
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
break;
|
|
533
|
-
|
|
534
|
-
case "tool_call_arguments_delta":
|
|
535
|
-
// Args lecą znak po znaku — UI nie potrzebuje tej granularności.
|
|
536
|
-
break;
|
|
537
|
-
|
|
538
|
-
case "tool_call_arguments_complete":
|
|
539
|
-
// Pełne args dostępne. Trzy przypadki:
|
|
540
|
-
// 1. Server tool już jest w timeline (od tool_call_pending) → update.
|
|
541
|
-
// 2. Interactive tool jeszcze nie jest w timeline → ADD tutaj.
|
|
542
|
-
// 3. Tool już dodany przez interactive_tool_request → no-op.
|
|
543
|
-
if (event.toolCallId) {
|
|
544
|
-
setTimeline((prev) => {
|
|
545
|
-
const existing = prev.find(
|
|
546
|
-
(it): it is Extract<TimelineItem, { type: "tool" }> =>
|
|
547
|
-
it.type === "tool" && it.toolCallId === event.toolCallId,
|
|
548
|
-
);
|
|
549
|
-
if (existing) {
|
|
550
|
-
return prev.map((item) =>
|
|
551
|
-
item.type === "tool" && item.toolCallId === event.toolCallId
|
|
552
|
-
? {
|
|
553
|
-
...item,
|
|
554
|
-
params: event.arguments ?? item.params,
|
|
555
|
-
status: "executing",
|
|
556
|
-
calling: true,
|
|
557
|
-
toolName: event.toolCallName ?? item.toolName,
|
|
558
|
-
}
|
|
559
|
-
: item,
|
|
560
|
-
);
|
|
561
|
-
}
|
|
562
|
-
const next = prev.map((item) =>
|
|
563
|
-
item.type === "message" && item.isStreaming
|
|
564
|
-
? { ...item, isStreaming: false }
|
|
565
|
-
: item,
|
|
566
|
-
);
|
|
567
|
-
next.push({
|
|
568
|
-
type: "tool",
|
|
569
|
-
id: event.toolCallId!,
|
|
570
|
-
toolCallId: event.toolCallId!,
|
|
571
|
-
toolName: event.toolCallName ?? "",
|
|
572
|
-
params: event.arguments ?? {},
|
|
573
|
-
status: "pending",
|
|
574
|
-
calling: true,
|
|
575
|
-
});
|
|
576
|
-
return next;
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
break;
|
|
580
|
-
|
|
581
|
-
case "tool_call_executed":
|
|
582
|
-
if (event.toolResult) {
|
|
583
|
-
setTimeline((prev) =>
|
|
584
|
-
prev.map((item) =>
|
|
585
|
-
item.type === "tool" && item.toolCallId === event.toolResult!.toolCallId
|
|
586
|
-
? {
|
|
587
|
-
...item,
|
|
588
|
-
result: tryParseJson(event.toolResult!.content),
|
|
589
|
-
status: event.toolResult!.isError ? "error" : "complete",
|
|
590
|
-
calling: false,
|
|
591
|
-
error: event.toolResult!.isError ? event.toolResult!.content : undefined,
|
|
592
|
-
}
|
|
593
|
-
: item,
|
|
594
|
-
),
|
|
595
|
-
);
|
|
596
|
-
}
|
|
597
|
-
break;
|
|
598
|
-
|
|
599
|
-
case "interactive_tool_request":
|
|
600
|
-
if (event.toolCalls) {
|
|
601
|
-
setTimeline((prev) => {
|
|
602
|
-
const byId = new Map(
|
|
603
|
-
prev
|
|
604
|
-
.filter((it) => it.type === "tool")
|
|
605
|
-
.map((it) => [(it as any).id, it]),
|
|
606
|
-
);
|
|
607
|
-
const next = prev.map((item) =>
|
|
608
|
-
item.type === "message" && item.isStreaming
|
|
609
|
-
? { ...item, isStreaming: false }
|
|
610
|
-
: item,
|
|
611
|
-
);
|
|
612
|
-
for (const tc of event.toolCalls!) {
|
|
613
|
-
const existing = byId.get(tc.id);
|
|
614
|
-
if (existing) {
|
|
615
|
-
const idx = next.findIndex(
|
|
616
|
-
(it) => it.type === "tool" && (it as any).id === tc.id,
|
|
617
|
-
);
|
|
618
|
-
if (idx >= 0) {
|
|
619
|
-
next[idx] = {
|
|
620
|
-
...(next[idx] as any),
|
|
621
|
-
toolName: tc.name || (next[idx] as any).toolName,
|
|
622
|
-
params: tc.arguments ?? (next[idx] as any).params,
|
|
623
|
-
};
|
|
624
|
-
}
|
|
625
|
-
continue;
|
|
626
|
-
}
|
|
627
|
-
next.push({
|
|
628
|
-
type: "tool",
|
|
629
|
-
id: tc.id,
|
|
630
|
-
toolCallId: tc.id,
|
|
631
|
-
toolName: tc.name,
|
|
632
|
-
params: tc.arguments ?? {},
|
|
633
|
-
status: "pending",
|
|
634
|
-
calling: true,
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
|
-
return next;
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
break;
|
|
187
|
+
}, [historyData]);
|
|
641
188
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
);
|
|
653
|
-
setIsStreaming(false);
|
|
654
|
-
break;
|
|
655
|
-
|
|
656
|
-
case "error":
|
|
657
|
-
setTimeline((prev) => {
|
|
658
|
-
const last = prev[prev.length - 1];
|
|
659
|
-
if (
|
|
660
|
-
last &&
|
|
661
|
-
last.type === "message" &&
|
|
662
|
-
last.role === "assistant" &&
|
|
663
|
-
last.isStreaming
|
|
664
|
-
) {
|
|
665
|
-
return prev.map((item, i) =>
|
|
666
|
-
i === prev.length - 1 && item.type === "message"
|
|
667
|
-
? {
|
|
668
|
-
...item,
|
|
669
|
-
content: item.content || event.error || chatLabels.errorLabel,
|
|
670
|
-
isStreaming: false,
|
|
671
|
-
}
|
|
672
|
-
: item,
|
|
673
|
-
);
|
|
674
|
-
}
|
|
675
|
-
return [
|
|
676
|
-
...prev,
|
|
677
|
-
{
|
|
678
|
-
type: "message",
|
|
679
|
-
id: `error_${Date.now()}`,
|
|
680
|
-
role: "assistant",
|
|
681
|
-
content: event.error || chatLabels.errorLabel,
|
|
682
|
-
},
|
|
683
|
-
];
|
|
684
|
-
});
|
|
685
|
-
setIsStreaming(false);
|
|
686
|
-
break;
|
|
687
|
-
}
|
|
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
|
+
]);
|
|
688
199
|
},
|
|
689
200
|
[chatLabels],
|
|
690
201
|
);
|
|
691
|
-
processEventRef.current = processEvent;
|
|
692
|
-
|
|
693
|
-
// ─── Auto-subscribe SSE per active assistant messageId ──────
|
|
694
|
-
// Driven przez DB history: za każdym razem gdy w DB pojawia się
|
|
695
|
-
// assistant row z `isGenerating=true`, otwieramy do niego SSE.
|
|
696
|
-
// Wszystkie scenariusze (send, respond, retry, page reload mid-stream)
|
|
697
|
-
// przechodzą przez ten sam mechanizm — handleSend/respond/retry tylko
|
|
698
|
-
// wywołują mutację, ten effect załapie nowy generating row z DB query
|
|
699
|
-
// update.
|
|
700
|
-
useEffect(() => {
|
|
701
|
-
if (!activeGeneratingMessageId) return;
|
|
702
|
-
const messageId = activeGeneratingMessageId;
|
|
703
|
-
const ctrl = new AbortController();
|
|
704
|
-
sseCtrlRef.current = ctrl;
|
|
705
|
-
lastSseEventRef.current = null;
|
|
706
|
-
let cancelled = false;
|
|
707
|
-
setIsStreaming(true);
|
|
708
|
-
chatDebug("sse open", { messageId });
|
|
709
|
-
|
|
710
|
-
(async () => {
|
|
711
|
-
try {
|
|
712
|
-
// 410 = brak in-memory streamu dla messageId. Serwer tworzy stream
|
|
713
|
-
// synchronicznie ze startem turnu (listener przed 1. awaitem), więc
|
|
714
|
-
// race "GET przed startStream" jest zamknięty — ale zostaje krótki
|
|
715
|
-
// residualny race i okno restartu serwera. Ponów kilka razy z
|
|
716
|
-
// backoffem zanim uznasz turn za przerwany: startStream / grace
|
|
717
|
-
// window zwykle dogania w tym czasie.
|
|
718
|
-
let res: Response | null = null;
|
|
719
|
-
const MAX_410_RETRIES = 4;
|
|
720
|
-
const RETRY_DELAY_MS = 300;
|
|
721
|
-
for (let attempt = 0; ; attempt++) {
|
|
722
|
-
res = await fetch(`/route/chat/${chatName}/stream/${messageId}`, {
|
|
723
|
-
credentials: "include",
|
|
724
|
-
signal: ctrl.signal,
|
|
725
|
-
headers: { Accept: "text/event-stream" },
|
|
726
|
-
});
|
|
727
|
-
if (res.status !== 410) break;
|
|
728
|
-
if (attempt >= MAX_410_RETRIES) {
|
|
729
|
-
// Naprawdę nieosiągalny (restart mid-stream / poza grace window).
|
|
730
|
-
setInterruptedIds((prev) => new Set(prev).add(messageId));
|
|
731
|
-
setIsStreaming(false);
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
734
|
-
await new Promise<void>((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
735
|
-
if (cancelled) return;
|
|
736
|
-
}
|
|
737
|
-
if (!res.ok) throw new Error(`Stream failed: ${res.status}`);
|
|
738
|
-
|
|
739
|
-
const reader = res.body!.getReader();
|
|
740
|
-
const decoder = new TextDecoder();
|
|
741
|
-
let buf = "";
|
|
742
|
-
lastSseByteAtRef.current =
|
|
743
|
-
typeof performance !== "undefined" ? performance.now() : 0;
|
|
744
|
-
while (!cancelled) {
|
|
745
|
-
const { value, done } = await reader.read();
|
|
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;
|
|
753
|
-
buf += decoder.decode(value, { stream: true });
|
|
754
|
-
const lines = buf.split("\n");
|
|
755
|
-
buf = lines.pop() ?? "";
|
|
756
|
-
for (const line of lines) {
|
|
757
|
-
if (!line.startsWith("data: ")) continue;
|
|
758
|
-
try {
|
|
759
|
-
const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
|
|
760
|
-
processEventRef.current?.(event);
|
|
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));
|
|
769
|
-
} catch {}
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
} catch (err) {
|
|
773
|
-
if ((err as any)?.name === "AbortError") return;
|
|
774
|
-
// Network glitch / SSE hang — treat as interrupted.
|
|
775
|
-
setInterruptedIds((prev) => new Set(prev).add(messageId));
|
|
776
|
-
setIsStreaming(false);
|
|
777
|
-
}
|
|
778
|
-
})();
|
|
779
|
-
|
|
780
|
-
return () => {
|
|
781
|
-
cancelled = true;
|
|
782
|
-
ctrl.abort();
|
|
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);
|
|
833
|
-
};
|
|
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]);
|
|
865
202
|
|
|
866
|
-
// ─── Send message
|
|
203
|
+
// ─── Send message ─────────────────────────────────────────────
|
|
867
204
|
const handleSend = useCallback(
|
|
868
205
|
async (content: string, options: SendMessageOptions) => {
|
|
869
|
-
if (
|
|
870
|
-
|
|
871
|
-
const
|
|
872
|
-
|
|
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) => [
|
|
873
210
|
...prev,
|
|
874
|
-
{
|
|
211
|
+
{ localId, content, sentAt: Date.now() },
|
|
875
212
|
]);
|
|
876
213
|
try {
|
|
877
|
-
await messageMutations.sendMessage({
|
|
214
|
+
const res = await messageMutations.sendMessage({
|
|
878
215
|
scopeId,
|
|
879
216
|
content,
|
|
880
217
|
model: options.model,
|
|
@@ -882,66 +219,54 @@ export function createChatComponent(
|
|
|
882
219
|
? { attachmentsJson: JSON.stringify(options.attachments) }
|
|
883
220
|
: {}),
|
|
884
221
|
});
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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.
|
|
889
231
|
} catch (err) {
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
...prev,
|
|
893
|
-
{ type: "message", id: `error_${Date.now()}`, role: "assistant", content: `${chatLabels.errorLabel}: ${errorMsg}` },
|
|
894
|
-
]);
|
|
895
|
-
setIsStreaming(false);
|
|
232
|
+
setPendingSends((prev) => prev.filter((p) => p.localId !== localId));
|
|
233
|
+
pushLocalError(err);
|
|
896
234
|
}
|
|
897
235
|
},
|
|
898
|
-
[
|
|
236
|
+
[scopeId, messageMutations, pushLocalError, derived.busy, derived.hasWaitingInteractive],
|
|
899
237
|
);
|
|
900
238
|
|
|
901
|
-
// ─── Retry interrupted generation
|
|
239
|
+
// ─── Retry interrupted generation ─────────────────────────────
|
|
902
240
|
const handleRetry = useCallback(
|
|
903
241
|
async (messageId: string) => {
|
|
904
242
|
if (!scopeId) return;
|
|
905
243
|
try {
|
|
906
244
|
await messageMutations.retryGeneration({ messageId });
|
|
907
|
-
// Mutacja usuwa interrupted row + tworzy fresh
|
|
908
|
-
//
|
|
909
|
-
setInterruptedIds((prev) => {
|
|
910
|
-
const next = new Set(prev);
|
|
911
|
-
next.delete(messageId);
|
|
912
|
-
return next;
|
|
913
|
-
});
|
|
245
|
+
// Mutacja usuwa interrupted row + tworzy fresh generujący row —
|
|
246
|
+
// liveQuery i overlay hook przejmują dalej.
|
|
914
247
|
} catch (err) {
|
|
915
|
-
|
|
916
|
-
setTimeline((prev) => [
|
|
917
|
-
...prev,
|
|
918
|
-
{ type: "message", id: `error_${Date.now()}`, role: "assistant", content: `${chatLabels.errorLabel}: ${errorMsg}` },
|
|
919
|
-
]);
|
|
248
|
+
pushLocalError(err);
|
|
920
249
|
}
|
|
921
250
|
},
|
|
922
|
-
[scopeId, messageMutations,
|
|
251
|
+
[scopeId, messageMutations, pushLocalError],
|
|
923
252
|
);
|
|
924
253
|
|
|
925
|
-
// ───
|
|
926
|
-
const chatMessages: ChatMessageData[] = timeline
|
|
927
|
-
.filter((item): item is TimelineItem & { type: "message" } => item.type === "message")
|
|
928
|
-
.map((item) => ({
|
|
929
|
-
id: item.id,
|
|
930
|
-
role: item.role,
|
|
931
|
-
content: item.content,
|
|
932
|
-
isStreaming: item.isStreaming,
|
|
933
|
-
}));
|
|
934
|
-
|
|
935
|
-
// ─── Render tool view ───────────────────────────────────────
|
|
254
|
+
// ─── Render tool view ─────────────────────────────────────────
|
|
936
255
|
const renderToolItem = (item: TimelineItem & { type: "tool" }) => {
|
|
937
256
|
const tool = toolsMap.get(item.toolName);
|
|
938
257
|
const ViewComponent = tool?.viewComponent;
|
|
939
258
|
|
|
940
259
|
if (!ViewComponent) {
|
|
941
|
-
return createElement(
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
+
);
|
|
945
270
|
}
|
|
946
271
|
|
|
947
272
|
if (tool.isServerTool) {
|
|
@@ -953,15 +278,13 @@ export function createChatComponent(
|
|
|
953
278
|
});
|
|
954
279
|
}
|
|
955
280
|
|
|
956
|
-
// Interactive tool
|
|
281
|
+
// Interactive tool — odpowiedź idzie przez optimistic
|
|
282
|
+
// pendingToolResults (answer-view od razu), mutacja respondToTool
|
|
283
|
+
// tworzy tool_result row + nowy turn.
|
|
957
284
|
const respond = async (result: unknown) => {
|
|
958
285
|
if (!scopeId) return;
|
|
959
|
-
|
|
960
|
-
prev.
|
|
961
|
-
t.type === "tool" && t.toolCallId === item.toolCallId
|
|
962
|
-
? { ...t, calling: false, result }
|
|
963
|
-
: t,
|
|
964
|
-
),
|
|
286
|
+
setPendingToolResults((prev) =>
|
|
287
|
+
new Map(prev).set(item.toolCallId, result),
|
|
965
288
|
);
|
|
966
289
|
try {
|
|
967
290
|
await messageMutations.respondToTool({
|
|
@@ -970,18 +293,13 @@ export function createChatComponent(
|
|
|
970
293
|
toolName: item.toolName,
|
|
971
294
|
result: JSON.stringify(result),
|
|
972
295
|
});
|
|
973
|
-
// Auto-subscribe effect załapie nowy assistant row (utworzony
|
|
974
|
-
// atomowo przez mutację respondToTool razem z userResponded event).
|
|
975
296
|
} catch (err) {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
content: `${chatLabels.errorLabel}: ${err instanceof Error ? err.message : chatLabels.errorLabel}`,
|
|
983
|
-
},
|
|
984
|
-
]);
|
|
297
|
+
setPendingToolResults((prev) => {
|
|
298
|
+
const next = new Map(prev);
|
|
299
|
+
next.delete(item.toolCallId);
|
|
300
|
+
return next;
|
|
301
|
+
});
|
|
302
|
+
pushLocalError(err);
|
|
985
303
|
}
|
|
986
304
|
};
|
|
987
305
|
|
|
@@ -993,21 +311,19 @@ export function createChatComponent(
|
|
|
993
311
|
});
|
|
994
312
|
};
|
|
995
313
|
|
|
996
|
-
// ─── Render
|
|
997
|
-
// Streaming message renders last (after tools) for correct visual order
|
|
998
|
-
const sortedTimeline = [...timeline].sort((a, b) => {
|
|
999
|
-
const aStreaming = a.type === "message" && a.isStreaming ? 1 : 0;
|
|
1000
|
-
const bStreaming = b.type === "message" && b.isStreaming ? 1 : 0;
|
|
1001
|
-
return aStreaming - bStreaming;
|
|
1002
|
-
});
|
|
1003
|
-
|
|
314
|
+
// ─── Render timeline ──────────────────────────────────────────
|
|
1004
315
|
const timelineElements: ReactNode[] = [];
|
|
1005
|
-
for (const item of
|
|
316
|
+
for (const item of derived.items) {
|
|
1006
317
|
if (item.type === "message") {
|
|
1007
318
|
timelineElements.push(
|
|
1008
319
|
createElement(ChatMessage, {
|
|
1009
320
|
key: item.id,
|
|
1010
|
-
message: {
|
|
321
|
+
message: {
|
|
322
|
+
id: item.id,
|
|
323
|
+
role: item.role,
|
|
324
|
+
content: item.content,
|
|
325
|
+
isStreaming: item.isStreaming,
|
|
326
|
+
},
|
|
1011
327
|
}),
|
|
1012
328
|
);
|
|
1013
329
|
} else if (item.type === "tool") {
|
|
@@ -1020,7 +336,8 @@ export function createChatComponent(
|
|
|
1020
336
|
"div",
|
|
1021
337
|
{
|
|
1022
338
|
key: item.id,
|
|
1023
|
-
className:
|
|
339
|
+
className:
|
|
340
|
+
"flex items-center gap-2 text-sm text-muted-foreground",
|
|
1024
341
|
},
|
|
1025
342
|
createElement("span", null, chatLabels.interruptedLabel),
|
|
1026
343
|
createElement(
|
|
@@ -1036,13 +353,14 @@ export function createChatComponent(
|
|
|
1036
353
|
);
|
|
1037
354
|
}
|
|
1038
355
|
}
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
+
}
|
|
1046
364
|
|
|
1047
365
|
return createElement(
|
|
1048
366
|
ChatInputProvider,
|
|
@@ -1076,24 +394,16 @@ export function createChatComponent(
|
|
|
1076
394
|
placeholder,
|
|
1077
395
|
rows,
|
|
1078
396
|
})),
|
|
1079
|
-
disabled:
|
|
397
|
+
disabled: derived.busy || derived.hasWaitingInteractive,
|
|
1080
398
|
}),
|
|
1081
399
|
),
|
|
1082
400
|
);
|
|
1083
401
|
}
|
|
1084
402
|
|
|
1085
403
|
return function ChatComponent(props: { scope: any; identifyBy: string }) {
|
|
1086
|
-
return createElement(
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
);
|
|
404
|
+
return createElement(ChatLabelsProvider, {
|
|
405
|
+
labels,
|
|
406
|
+
children: createElement(ChatComponentInner, props),
|
|
407
|
+
});
|
|
1090
408
|
};
|
|
1091
409
|
}
|
|
1092
|
-
|
|
1093
|
-
function tryParseJson(str: string): unknown {
|
|
1094
|
-
try {
|
|
1095
|
-
return JSON.parse(str);
|
|
1096
|
-
} catch {
|
|
1097
|
-
return str;
|
|
1098
|
-
}
|
|
1099
|
-
}
|