@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
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import type { AssistantContentBlock } from "@arcote.tech/arc-ai";
|
|
2
|
+
import { orderMessages } from "../ordering";
|
|
3
|
+
|
|
4
|
+
// ─── Czysta derywacja timeline'u ─────────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// Timeline NIE jest mutowalnym stanem — jest czystą funkcją dwóch źródeł:
|
|
7
|
+
// 1. DB (liveQuery `getByScope`) — jedyne źródło prawdy o STRUKTURZE:
|
|
8
|
+
// lista wiadomości, finalne blocks, isGenerating/interrupted/error,
|
|
9
|
+
// tool_results.
|
|
10
|
+
// 2. Overlays — ulotny, in-progress stan per generujący assistant row
|
|
11
|
+
// (bloki budowane z SSE przez shared reducer).
|
|
12
|
+
// Plus lokalny optimistic state (pending sends / pending tool results).
|
|
13
|
+
//
|
|
14
|
+
// Dzięki temu klasyczne race'y dwóch kanałów (SSE `done` vs flip
|
|
15
|
+
// `isGenerating` w DB, tool_result w trakcie nowego turnu, zgubione
|
|
16
|
+
// `done`) przestają istnieć: derywacja zawsze liczy się od NAJNOWSZEGO
|
|
17
|
+
// stanu obu źródeł, niezależnie od kolejności ich dostarczenia.
|
|
18
|
+
|
|
19
|
+
export type ToolStatus = "pending" | "executing" | "complete" | "error";
|
|
20
|
+
|
|
21
|
+
export type TimelineItem =
|
|
22
|
+
| {
|
|
23
|
+
type: "message";
|
|
24
|
+
id: string;
|
|
25
|
+
role: "user" | "assistant";
|
|
26
|
+
content: string;
|
|
27
|
+
isStreaming?: boolean;
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
type: "tool";
|
|
31
|
+
id: string; // = toolCallId, stabilne pomiędzy overlayem a final blocks
|
|
32
|
+
toolCallId: string;
|
|
33
|
+
toolName: string;
|
|
34
|
+
params: Record<string, unknown>;
|
|
35
|
+
result?: unknown;
|
|
36
|
+
status: ToolStatus;
|
|
37
|
+
/** Legacy compat — true gdy brak wyniku. Renderowane przez ChatToolLog. */
|
|
38
|
+
calling: boolean;
|
|
39
|
+
error?: string;
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
type: "interrupted";
|
|
43
|
+
id: string;
|
|
44
|
+
messageId: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type OverlayStatus = "connecting" | "live" | "done" | "gone";
|
|
48
|
+
|
|
49
|
+
export interface AssistantOverlay {
|
|
50
|
+
blocks: AssistantContentBlock[];
|
|
51
|
+
status: OverlayStatus;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PendingSend {
|
|
55
|
+
localId: string;
|
|
56
|
+
content: string;
|
|
57
|
+
sentAt: number;
|
|
58
|
+
/** `messageId` zwrócony przez mutację `sendMessage` (gdy już resolved). */
|
|
59
|
+
dbId?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DeriveTimelineInput {
|
|
63
|
+
history: any[] | undefined;
|
|
64
|
+
overlays: ReadonlyMap<string, AssistantOverlay>;
|
|
65
|
+
pendingSends: readonly PendingSend[];
|
|
66
|
+
pendingToolResults: ReadonlyMap<string, unknown>;
|
|
67
|
+
isServerTool: (toolName: string) => boolean;
|
|
68
|
+
/** Wstrzykiwany zegar — testowalność + staleness cutoff reguły busy. */
|
|
69
|
+
now: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface DerivedTimeline {
|
|
73
|
+
items: TimelineItem[];
|
|
74
|
+
/**
|
|
75
|
+
* Czy konwersacja jest "zajęta" (input disabled). Wyprowadzane z DB,
|
|
76
|
+
* nie z lokalnego flagu — pokrywa też okna między iteracjami tool-loopa,
|
|
77
|
+
* gdy żaden row nie jest `isGenerating`.
|
|
78
|
+
*/
|
|
79
|
+
busy: boolean;
|
|
80
|
+
/** Czy jakiś interactive tool czeka na odpowiedź użytkownika. */
|
|
81
|
+
hasWaitingInteractive: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Cutoff świeżości dla klauzul busy opartych o "ostatnia aktywność w DB".
|
|
86
|
+
* Chroni przed wiecznym disabled gdy listener padł między zapisami (np.
|
|
87
|
+
* wyjątek w buildInstructions po saveToolResult). Po przekroczeniu busy
|
|
88
|
+
* degraduje się do zachowania sprzed redesignu (input enabled mimo
|
|
89
|
+
* trwającej pętli) — bezpieczny gorszy przypadek.
|
|
90
|
+
*/
|
|
91
|
+
const BUSY_STALENESS_MS = 120_000;
|
|
92
|
+
|
|
93
|
+
function ts(value: string | Date): number {
|
|
94
|
+
return value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function tryParseJson(str: string): unknown {
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(str);
|
|
100
|
+
} catch {
|
|
101
|
+
return str;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseBlocks(raw: unknown): AssistantContentBlock[] {
|
|
106
|
+
// Adapter Postgresa auto-parsuje kolumny tekstowe wyglądające jak JSON
|
|
107
|
+
// (deserializeValue w postgres-adapter) — po hydracji store'a z bazy
|
|
108
|
+
// `blocks` przychodzi jako GOTOWA tablica, nie string. Oba kształty są
|
|
109
|
+
// poprawne na tej granicy.
|
|
110
|
+
if (Array.isArray(raw)) return raw as AssistantContentBlock[];
|
|
111
|
+
if (typeof raw !== "string" || raw.length === 0) return [];
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(raw);
|
|
114
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
115
|
+
} catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Czy optimistic pending send ma już swój odpowiednik w DB. Match po
|
|
122
|
+
* `dbId` (deterministyczny, gdy mutacja zdążyła resolve'ować) z fallbackiem
|
|
123
|
+
* po treści (liveQuery push potrafi wyprzedzić resolve mutacji).
|
|
124
|
+
*/
|
|
125
|
+
export function isPendingSendSettled(
|
|
126
|
+
history: any[] | undefined,
|
|
127
|
+
pending: PendingSend,
|
|
128
|
+
): boolean {
|
|
129
|
+
if (!history) return false;
|
|
130
|
+
for (const row of history) {
|
|
131
|
+
if (pending.dbId && row._id === pending.dbId) return true;
|
|
132
|
+
if (
|
|
133
|
+
row.role === "user" &&
|
|
134
|
+
row.content === pending.content &&
|
|
135
|
+
ts(row.createdAt) >= pending.sentAt - 60_000
|
|
136
|
+
) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function deriveTimeline(input: DeriveTimelineInput): DerivedTimeline {
|
|
144
|
+
const {
|
|
145
|
+
history,
|
|
146
|
+
overlays,
|
|
147
|
+
pendingSends,
|
|
148
|
+
pendingToolResults,
|
|
149
|
+
isServerTool,
|
|
150
|
+
now,
|
|
151
|
+
} = input;
|
|
152
|
+
|
|
153
|
+
const rows = orderMessages(history ?? []);
|
|
154
|
+
|
|
155
|
+
// Wyniki tooli: DB jest autorytatywne; optimistic pending tylko dopóki
|
|
156
|
+
// tool_result row nie dotarł przez liveQuery.
|
|
157
|
+
const resultMap = new Map<
|
|
158
|
+
string,
|
|
159
|
+
{ value: unknown; isError?: boolean; raw?: string }
|
|
160
|
+
>();
|
|
161
|
+
for (const msg of rows) {
|
|
162
|
+
if (msg.role === "tool_result" && msg.toolCallId) {
|
|
163
|
+
// `content` może przyjść jako string ALBO już sparsowany obiekt —
|
|
164
|
+
// patrz komentarz w parseBlocks (deserializacja adaptera Postgresa).
|
|
165
|
+
const content: unknown = msg.content;
|
|
166
|
+
resultMap.set(msg.toolCallId, {
|
|
167
|
+
value: typeof content === "string" ? tryParseJson(content) : content,
|
|
168
|
+
isError: msg.isError,
|
|
169
|
+
raw:
|
|
170
|
+
typeof content === "string"
|
|
171
|
+
? content
|
|
172
|
+
: JSON.stringify(content ?? ""),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const [toolCallId, value] of pendingToolResults) {
|
|
177
|
+
if (!resultMap.has(toolCallId)) {
|
|
178
|
+
resultMap.set(toolCallId, { value });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const items: TimelineItem[] = [];
|
|
183
|
+
|
|
184
|
+
const pushBlocks = (
|
|
185
|
+
messageId: string,
|
|
186
|
+
blocks: AssistantContentBlock[],
|
|
187
|
+
options: { streaming?: boolean } = {},
|
|
188
|
+
) => {
|
|
189
|
+
let textCount = 0;
|
|
190
|
+
blocks.forEach((block, idx) => {
|
|
191
|
+
if (block.type === "text") {
|
|
192
|
+
if (block.text) {
|
|
193
|
+
items.push({
|
|
194
|
+
type: "message",
|
|
195
|
+
id: `${messageId}_t${textCount}`,
|
|
196
|
+
role: "assistant",
|
|
197
|
+
content: block.text,
|
|
198
|
+
// Caret tylko na ostatnim bloku aktywnie streamowanego turnu.
|
|
199
|
+
isStreaming: options.streaming && idx === blocks.length - 1,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
textCount++;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const result = resultMap.get(block.id);
|
|
206
|
+
const status: ToolStatus = result
|
|
207
|
+
? result.isError
|
|
208
|
+
? "error"
|
|
209
|
+
: "complete"
|
|
210
|
+
: isServerTool(block.name)
|
|
211
|
+
? "executing"
|
|
212
|
+
: "pending";
|
|
213
|
+
items.push({
|
|
214
|
+
type: "tool",
|
|
215
|
+
id: block.id,
|
|
216
|
+
toolCallId: block.id,
|
|
217
|
+
toolName: block.name,
|
|
218
|
+
params: block.arguments,
|
|
219
|
+
result: result?.value,
|
|
220
|
+
status,
|
|
221
|
+
calling: !result,
|
|
222
|
+
error: result?.isError ? (result.raw ?? String(result.value)) : undefined,
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
for (const msg of rows) {
|
|
228
|
+
// System rows to developer-injected priming prompts — LLM je widzi
|
|
229
|
+
// (buildHistory), timeline nie.
|
|
230
|
+
if (msg.role === "system") continue;
|
|
231
|
+
if (msg.role === "tool_result") continue; // renderowane przy tool blocku
|
|
232
|
+
|
|
233
|
+
if (msg.role === "user") {
|
|
234
|
+
items.push({
|
|
235
|
+
type: "message",
|
|
236
|
+
id: msg._id,
|
|
237
|
+
role: "user",
|
|
238
|
+
// Wiadomość użytkownika zaczynająca się od "{" / "[" wraca
|
|
239
|
+
// z Postgresa sparsowana — renderujemy zawsze string.
|
|
240
|
+
content:
|
|
241
|
+
typeof msg.content === "string"
|
|
242
|
+
? msg.content
|
|
243
|
+
: JSON.stringify(msg.content ?? ""),
|
|
244
|
+
});
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (msg.role !== "assistant") continue;
|
|
249
|
+
|
|
250
|
+
if (msg.isGenerating) {
|
|
251
|
+
const overlay = overlays.get(msg._id);
|
|
252
|
+
|
|
253
|
+
// Stream nieosiągalny (410 po retry / błąd sieci) — natychmiastowy
|
|
254
|
+
// lokalny stan; lazy repair w route wkrótce utrwali `interrupted`
|
|
255
|
+
// w DB dla wszystkich klientów.
|
|
256
|
+
if (overlay?.status === "gone") {
|
|
257
|
+
items.push({
|
|
258
|
+
type: "interrupted",
|
|
259
|
+
id: `${msg._id}_interrupted`,
|
|
260
|
+
messageId: msg._id,
|
|
261
|
+
});
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (overlay && overlay.blocks.length > 0) {
|
|
266
|
+
const streaming =
|
|
267
|
+
overlay.status === "live" || overlay.status === "connecting";
|
|
268
|
+
// Interactive tool w trakcie streamowania pokazujemy dopiero z
|
|
269
|
+
// kompletem argumentów: overlay dodaje blok już przy
|
|
270
|
+
// `tool_call_pending` (arguments={}), a view komponenty interactive
|
|
271
|
+
// tooli (np. AskQuestionsView → registerInputOverride) montują się
|
|
272
|
+
// z params i oczekują pełnych danych. Filtr PRZED pushBlocks —
|
|
273
|
+
// dzięki temu caret ląduje na ostatnim WIDOCZNYM bloku (tekst
|
|
274
|
+
// trzyma caret, gdy args się streamują), a turn złożony z samego
|
|
275
|
+
// ukrytego toola spada do spinner-placeholdera niżej.
|
|
276
|
+
const visibleBlocks = streaming
|
|
277
|
+
? overlay.blocks.filter(
|
|
278
|
+
(b) =>
|
|
279
|
+
b.type !== "tool_call" ||
|
|
280
|
+
isServerTool(b.name) ||
|
|
281
|
+
Object.keys(b.arguments ?? {}).length > 0,
|
|
282
|
+
)
|
|
283
|
+
: overlay.blocks;
|
|
284
|
+
if (visibleBlocks.length > 0) {
|
|
285
|
+
pushBlocks(msg._id, visibleBlocks, { streaming });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Brak overlaya (SSE jeszcze się łączy), pusty stream albo same
|
|
291
|
+
// ukryte bloki — pusty streaming bubble (DS renderuje spinner).
|
|
292
|
+
items.push({
|
|
293
|
+
type: "message",
|
|
294
|
+
id: `${msg._id}_t0`,
|
|
295
|
+
role: "assistant",
|
|
296
|
+
content: "",
|
|
297
|
+
isStreaming: true,
|
|
298
|
+
});
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (msg.interrupted) {
|
|
303
|
+
items.push({
|
|
304
|
+
type: "interrupted",
|
|
305
|
+
id: `${msg._id}_interrupted`,
|
|
306
|
+
messageId: msg._id,
|
|
307
|
+
});
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Zamknięty turn — finalne blocks z DB. Klucze itemów identyczne jak
|
|
312
|
+
// przy overlayu (`${_id}_t${n}` / toolCallId) → przejście overlay→DB
|
|
313
|
+
// nie remountuje elementów.
|
|
314
|
+
pushBlocks(msg._id, parseBlocks(msg.blocks));
|
|
315
|
+
|
|
316
|
+
if (msg.error) {
|
|
317
|
+
items.push({
|
|
318
|
+
type: "message",
|
|
319
|
+
id: `${msg._id}_error`,
|
|
320
|
+
role: "assistant",
|
|
321
|
+
content: msg.error,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Optimistic user messages — jeszcze nie potwierdzone przez liveQuery.
|
|
327
|
+
for (const pending of pendingSends) {
|
|
328
|
+
if (isPendingSendSettled(history, pending)) continue;
|
|
329
|
+
items.push({
|
|
330
|
+
type: "message",
|
|
331
|
+
id: pending.localId,
|
|
332
|
+
role: "user",
|
|
333
|
+
content: pending.content,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const hasWaitingInteractive = items.some(
|
|
338
|
+
(it) => it.type === "tool" && it.calling && !isServerTool(it.toolName),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
items,
|
|
343
|
+
busy: deriveBusy(rows, overlays, pendingSends, history, isServerTool, now),
|
|
344
|
+
hasWaitingInteractive,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function deriveBusy(
|
|
349
|
+
rows: any[],
|
|
350
|
+
overlays: ReadonlyMap<string, AssistantOverlay>,
|
|
351
|
+
pendingSends: readonly PendingSend[],
|
|
352
|
+
history: any[] | undefined,
|
|
353
|
+
isServerTool: (toolName: string) => boolean,
|
|
354
|
+
now: number,
|
|
355
|
+
): boolean {
|
|
356
|
+
// 1. Trwa generacja (row otwarty, stream nie jest martwy).
|
|
357
|
+
for (const msg of rows) {
|
|
358
|
+
if (
|
|
359
|
+
msg.role === "assistant" &&
|
|
360
|
+
msg.isGenerating &&
|
|
361
|
+
!msg.interrupted &&
|
|
362
|
+
overlays.get(msg._id)?.status !== "gone"
|
|
363
|
+
) {
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 2. Wiadomość wysłana, row jeszcze nie dotarł przez liveQuery.
|
|
369
|
+
if (pendingSends.some((p) => !isPendingSendSettled(history, p))) return true;
|
|
370
|
+
|
|
371
|
+
if (rows.length === 0) return false;
|
|
372
|
+
const lastActivityTs = Math.max(...rows.map((r) => ts(r.createdAt)));
|
|
373
|
+
const fresh = now - lastActivityTs < BUSY_STALENESS_MS;
|
|
374
|
+
if (!fresh) return false;
|
|
375
|
+
|
|
376
|
+
// 3. Gap A: ostatni assistant turn zamknął się server-tool callami,
|
|
377
|
+
// których wyniki jeszcze nie dotarły (listener egzekwuje tool'e).
|
|
378
|
+
const lastAssistant = [...rows]
|
|
379
|
+
.reverse()
|
|
380
|
+
.find((r) => r.role === "assistant");
|
|
381
|
+
if (
|
|
382
|
+
lastAssistant &&
|
|
383
|
+
!lastAssistant.isGenerating &&
|
|
384
|
+
!lastAssistant.interrupted &&
|
|
385
|
+
!lastAssistant.error
|
|
386
|
+
) {
|
|
387
|
+
const resultIds = new Set(
|
|
388
|
+
rows
|
|
389
|
+
.filter((r) => r.role === "tool_result" && r.toolCallId)
|
|
390
|
+
.map((r) => r.toolCallId),
|
|
391
|
+
);
|
|
392
|
+
const blocks = parseBlocks(lastAssistant.blocks);
|
|
393
|
+
const awaitingServerTool = blocks.some(
|
|
394
|
+
(b) =>
|
|
395
|
+
b.type === "tool_call" &&
|
|
396
|
+
isServerTool(b.name) &&
|
|
397
|
+
!resultIds.has(b.id),
|
|
398
|
+
);
|
|
399
|
+
if (awaitingServerTool) return true;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 4. Gap B: ostatni row to świeży tool_result server-toola — listener
|
|
403
|
+
// jest pomiędzy saveToolResult a startAssistantTurn następnej
|
|
404
|
+
// iteracji (np. wolny buildInstructions). Po server-tool result
|
|
405
|
+
// ZAWSZE następuje kolejny turn.
|
|
406
|
+
const lastRow = rows[rows.length - 1];
|
|
407
|
+
if (
|
|
408
|
+
lastRow.role === "tool_result" &&
|
|
409
|
+
lastRow.toolName &&
|
|
410
|
+
isServerTool(lastRow.toolName)
|
|
411
|
+
) {
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { ChatStreamEvent } from "@arcote.tech/arc-ai";
|
|
3
|
+
import { applyStreamEvent } from "../streaming/blocks-reducer";
|
|
4
|
+
import type { AssistantOverlay } from "./derive-timeline";
|
|
5
|
+
|
|
6
|
+
// ─── Overlay hook — SSE jako warstwa nakładkowa, nie źródło prawdy ──
|
|
7
|
+
//
|
|
8
|
+
// Per generujący assistant row (`isGenerating=true` w DB) utrzymuje
|
|
9
|
+
// połączenie SSE i buduje `AssistantOverlay { blocks, status }` przez
|
|
10
|
+
// SHARED reducer (`applyStreamEvent` — ten sam kod co serwerowy
|
|
11
|
+
// `publish()`). Event `init` niesie snapshot — resetuje bazę bloków,
|
|
12
|
+
// więc reconnect w dowolnym momencie jest bezstratny i idempotentny.
|
|
13
|
+
//
|
|
14
|
+
// Hook NIGDY nie dotyka timeline'u — wyłącznie aktualizuje mapę
|
|
15
|
+
// overlayów. Autorytatywny koniec turnu to flip `isGenerating=false`
|
|
16
|
+
// w DB (liveQuery); `done`/`error` z SSE są czysto advisory.
|
|
17
|
+
//
|
|
18
|
+
// Failure mode'y kanału SSE (martwy socket, throttling karty w tle,
|
|
19
|
+
// BFCache, restart serwera) degradują się do "overlay przestał się
|
|
20
|
+
// aktualizować" — derywacja renderuje placeholder/ostatni znany stan,
|
|
21
|
+
// a DB ostatecznie domyka turn. Stąd recovery jest proste: zabij
|
|
22
|
+
// połączenie i otwórz nowe (init snapshot naprawia wszystko).
|
|
23
|
+
|
|
24
|
+
/** Telemetria — host app może podpiąć `globalThis.__arcChatOnStuck`. */
|
|
25
|
+
function reportStreamGone(attrs: Record<string, unknown>): void {
|
|
26
|
+
// eslint-disable-next-line no-console
|
|
27
|
+
console.warn("[arc-chat][stream_gone]", attrs);
|
|
28
|
+
try {
|
|
29
|
+
(globalThis as any).__arcChatOnStuck?.(attrs);
|
|
30
|
+
} catch {
|
|
31
|
+
/* reporter must never break the chat */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Verbose timing gated za `globalThis.__ARC_CHAT_DEBUG`. */
|
|
36
|
+
function chatDebug(...args: unknown[]): void {
|
|
37
|
+
if ((globalThis as any).__ARC_CHAT_DEBUG) {
|
|
38
|
+
const ts =
|
|
39
|
+
typeof performance !== "undefined" ? performance.now().toFixed(0) : "";
|
|
40
|
+
// eslint-disable-next-line no-console
|
|
41
|
+
console.debug(`[arc-chat +${ts}ms]`, ...args);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Connection {
|
|
46
|
+
ctrl: AbortController;
|
|
47
|
+
/** performance.now() ostatniego bajtu (dane + keepalive ping). */
|
|
48
|
+
lastByteAt: number | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const MAX_410_RETRIES = 4;
|
|
52
|
+
const RETRY_DELAY_MS = 300;
|
|
53
|
+
/** 2× serwerowy KEEPALIVE_INTERVAL_MS (5 s) + zapas. */
|
|
54
|
+
const HEARTBEAT_TIMEOUT_MS = 12_000;
|
|
55
|
+
|
|
56
|
+
export function useAssistantOverlays(
|
|
57
|
+
chatName: string,
|
|
58
|
+
generatingIds: readonly string[],
|
|
59
|
+
): ReadonlyMap<string, AssistantOverlay> {
|
|
60
|
+
const [overlays, setOverlays] = useState<Map<string, AssistantOverlay>>(
|
|
61
|
+
() => new Map(),
|
|
62
|
+
);
|
|
63
|
+
/** Bump → effect przeładowuje połączenia (visibility / heartbeat / BFCache). */
|
|
64
|
+
const [reopenNonce, setReopenNonce] = useState(0);
|
|
65
|
+
const connsRef = useRef<Map<string, Connection>>(new Map());
|
|
66
|
+
|
|
67
|
+
const idsKey = generatingIds.join("|");
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const ids = new Set(generatingIds);
|
|
71
|
+
|
|
72
|
+
// GC overlayów rzędów, które przestały generować (derywacja i tak by
|
|
73
|
+
// je zignorowała — flip isGenerating przyniósł finalne blocks w tym
|
|
74
|
+
// samym rzędzie liveQuery).
|
|
75
|
+
setOverlays((prev) => {
|
|
76
|
+
let changed = false;
|
|
77
|
+
const next = new Map(prev);
|
|
78
|
+
for (const key of next.keys()) {
|
|
79
|
+
if (!ids.has(key)) {
|
|
80
|
+
next.delete(key);
|
|
81
|
+
changed = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return changed ? next : prev;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
for (const messageId of generatingIds) {
|
|
88
|
+
const ctrl = new AbortController();
|
|
89
|
+
const conn: Connection = { ctrl, lastByteAt: null };
|
|
90
|
+
connsRef.current.set(messageId, conn);
|
|
91
|
+
/** Połączenie jest aktualne dopóki connsRef trzyma TĘ instancję —
|
|
92
|
+
* zamiennik sekwencerów: eventy ze starych readerów są ignorowane. */
|
|
93
|
+
const isCurrent = () => connsRef.current.get(messageId) === conn;
|
|
94
|
+
|
|
95
|
+
const setOverlay = (
|
|
96
|
+
update: (prev: AssistantOverlay | undefined) => AssistantOverlay,
|
|
97
|
+
) => {
|
|
98
|
+
if (!isCurrent()) return;
|
|
99
|
+
setOverlays((prev) => {
|
|
100
|
+
const next = new Map(prev);
|
|
101
|
+
next.set(messageId, update(prev.get(messageId)));
|
|
102
|
+
return next;
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Reconnect zachowuje ostatnie znane bloki (status connecting) —
|
|
107
|
+
// init snapshot zaraz je nadpisze; zero flasha do placeholdera.
|
|
108
|
+
setOverlay((prev) => ({
|
|
109
|
+
blocks: prev?.blocks ?? [],
|
|
110
|
+
status: "connecting",
|
|
111
|
+
}));
|
|
112
|
+
chatDebug("sse open", { messageId });
|
|
113
|
+
|
|
114
|
+
(async () => {
|
|
115
|
+
try {
|
|
116
|
+
// 410 = brak in-memory streamu. Serwer otwiera stream
|
|
117
|
+
// synchronicznie ze startem turnu (listener przed 1. awaitem),
|
|
118
|
+
// ale zostaje krótki residualny race + okno restartu serwera.
|
|
119
|
+
// Retry z backoffem; po wyczerpaniu → "gone" (route w tym
|
|
120
|
+
// czasie mógł już zrobić lazy repair `interrupted` w DB).
|
|
121
|
+
let res: Response | null = null;
|
|
122
|
+
for (let attempt = 0; ; attempt++) {
|
|
123
|
+
res = await fetch(`/route/chat/${chatName}/stream/${messageId}`, {
|
|
124
|
+
credentials: "include",
|
|
125
|
+
signal: ctrl.signal,
|
|
126
|
+
headers: { Accept: "text/event-stream" },
|
|
127
|
+
});
|
|
128
|
+
if (res.status !== 410) break;
|
|
129
|
+
if (attempt >= MAX_410_RETRIES) {
|
|
130
|
+
reportStreamGone({ chatName, messageId, reason: "410_exhausted" });
|
|
131
|
+
setOverlay((prev) => ({
|
|
132
|
+
blocks: prev?.blocks ?? [],
|
|
133
|
+
status: "gone",
|
|
134
|
+
}));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
await new Promise<void>((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
138
|
+
if (!isCurrent()) return;
|
|
139
|
+
}
|
|
140
|
+
if (!res.ok) throw new Error(`Stream failed: ${res.status}`);
|
|
141
|
+
|
|
142
|
+
const reader = res.body!.getReader();
|
|
143
|
+
const decoder = new TextDecoder();
|
|
144
|
+
let buf = "";
|
|
145
|
+
conn.lastByteAt =
|
|
146
|
+
typeof performance !== "undefined" ? performance.now() : 0;
|
|
147
|
+
|
|
148
|
+
while (isCurrent()) {
|
|
149
|
+
const { value, done } = await reader.read();
|
|
150
|
+
if (done) break;
|
|
151
|
+
conn.lastByteAt =
|
|
152
|
+
typeof performance !== "undefined" ? performance.now() : 0;
|
|
153
|
+
buf += decoder.decode(value, { stream: true });
|
|
154
|
+
const lines = buf.split("\n");
|
|
155
|
+
buf = lines.pop() ?? "";
|
|
156
|
+
for (const line of lines) {
|
|
157
|
+
if (!line.startsWith("data: ")) continue;
|
|
158
|
+
try {
|
|
159
|
+
const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
|
|
160
|
+
chatDebug("sse event", event.type);
|
|
161
|
+
if (event.type === "init") {
|
|
162
|
+
setOverlay(() => ({
|
|
163
|
+
blocks: event.currentBlocks ?? [],
|
|
164
|
+
status: "live",
|
|
165
|
+
}));
|
|
166
|
+
} else if (event.type === "done" || event.type === "error") {
|
|
167
|
+
// Advisory — zdejmuje caret od razu; autorytatywne
|
|
168
|
+
// domknięcie (finalne blocks / error) przyjdzie flipem
|
|
169
|
+
// isGenerating w DB.
|
|
170
|
+
setOverlay((prev) => ({
|
|
171
|
+
blocks: prev?.blocks ?? [],
|
|
172
|
+
status: "done",
|
|
173
|
+
}));
|
|
174
|
+
} else {
|
|
175
|
+
setOverlay((prev) => ({
|
|
176
|
+
blocks: applyStreamEvent(prev?.blocks ?? [], event),
|
|
177
|
+
status: prev?.status ?? "live",
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
// Yield między eventami, żeby React nie batchował updateów
|
|
181
|
+
// w jeden render (streaming niewidoczny). queueMicrotask,
|
|
182
|
+
// NIE setTimeout(0): Chrome throttluje timeouty w kartach
|
|
183
|
+
// w tle do ≥1 s — microtaski nie są throttlowane, pętla
|
|
184
|
+
// domyka turn nawet w tle.
|
|
185
|
+
await new Promise<void>((r) => queueMicrotask(r));
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if ((err as any)?.name === "AbortError") return;
|
|
191
|
+
if (!isCurrent()) return;
|
|
192
|
+
reportStreamGone({
|
|
193
|
+
chatName,
|
|
194
|
+
messageId,
|
|
195
|
+
reason: "network_error",
|
|
196
|
+
error: err instanceof Error ? err.message : String(err),
|
|
197
|
+
});
|
|
198
|
+
setOverlay((prev) => ({
|
|
199
|
+
blocks: prev?.blocks ?? [],
|
|
200
|
+
status: "gone",
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
})();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Cleanup zamyka WSZYSTKIE połączenia tego cyklu — następny run
|
|
207
|
+
// effectu (zmiana generatingIds / bump nonce / StrictMode remount)
|
|
208
|
+
// otwiera je od zera. Reconnect jest bezstratny: `init` snapshot
|
|
209
|
+
// resetuje bazę overlaya, a overlay state przeżywa cykl effectu.
|
|
210
|
+
return () => {
|
|
211
|
+
for (const conn of connsRef.current.values()) conn.ctrl.abort();
|
|
212
|
+
connsRef.current.clear();
|
|
213
|
+
};
|
|
214
|
+
}, [idsKey, reopenNonce, chatName]);
|
|
215
|
+
|
|
216
|
+
/** Wymuś przeładowanie połączeń (cleanup + ponowny run effectu). */
|
|
217
|
+
const reconnectAll = (reason: string) => {
|
|
218
|
+
if (connsRef.current.size === 0) return;
|
|
219
|
+
chatDebug("reconnect", { reason });
|
|
220
|
+
setReopenNonce((n) => n + 1);
|
|
221
|
+
};
|
|
222
|
+
const reconnectAllRef = useRef(reconnectAll);
|
|
223
|
+
reconnectAllRef.current = reconnectAll;
|
|
224
|
+
|
|
225
|
+
// ─── Visibility — powrót na kartę wymusza reconnect ────────────
|
|
226
|
+
// Chrome/Safari agresywnie zamrażają karty w tle: reader.read() potrafi
|
|
227
|
+
// wisieć na martwym sockecie bez błędu. Nie wykrywamy poszczególnych
|
|
228
|
+
// failure mode'ów — każdy powrót do widoczności przeładowuje połączenia.
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (typeof document === "undefined") return;
|
|
231
|
+
const onVisibility = () => {
|
|
232
|
+
if (document.visibilityState !== "visible") return;
|
|
233
|
+
reconnectAllRef.current("visibility");
|
|
234
|
+
};
|
|
235
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
236
|
+
return () => document.removeEventListener("visibilitychange", onVisibility);
|
|
237
|
+
}, []);
|
|
238
|
+
|
|
239
|
+
// ─── BFCache restore — wszystkie sockety są martwe ──────────────
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
if (typeof window === "undefined") return;
|
|
242
|
+
const onPageShow = (e: PageTransitionEvent) => {
|
|
243
|
+
if (!e.persisted) return;
|
|
244
|
+
reconnectAllRef.current("bfcache");
|
|
245
|
+
};
|
|
246
|
+
window.addEventListener("pageshow", onPageShow);
|
|
247
|
+
return () => window.removeEventListener("pageshow", onPageShow);
|
|
248
|
+
}, []);
|
|
249
|
+
|
|
250
|
+
// ─── Heartbeat — cichy martwy socket przy aktywnej karcie ───────
|
|
251
|
+
// Serwer wysyła `: ping` co 5 s; brak bajtów > HEARTBEAT_TIMEOUT_MS
|
|
252
|
+
// oznacza martwe połączenie → reconnect.
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (generatingIds.length === 0) return;
|
|
255
|
+
if (typeof performance === "undefined") return;
|
|
256
|
+
const id = setInterval(() => {
|
|
257
|
+
if (typeof document !== "undefined" && document.hidden) return;
|
|
258
|
+
for (const conn of connsRef.current.values()) {
|
|
259
|
+
if (conn.lastByteAt === null) continue;
|
|
260
|
+
if (performance.now() - conn.lastByteAt < HEARTBEAT_TIMEOUT_MS) continue;
|
|
261
|
+
reconnectAllRef.current("heartbeat");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}, 3_000);
|
|
265
|
+
return () => clearInterval(id);
|
|
266
|
+
}, [idsKey]);
|
|
267
|
+
|
|
268
|
+
return overlays;
|
|
269
|
+
}
|